diff --git a/.env.example b/.env.example index e192aa07..837c710b 100644 --- a/.env.example +++ b/.env.example @@ -585,3 +585,7 @@ ENABLE_JAPANESE_USER_DICTIONARY=true # Experimental Japanese contextual readings ENABLE_JAPANESE_CONTEXT_READING=false # END GENERATED CONFIG: tts_japanese_reading + +# BEGIN GENERATED CONFIG: desktop_extensions +# Extension downloads +# END GENERATED CONFIG: desktop_extensions diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index b5b3f947..6294e86f 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -81,6 +81,26 @@ jobs: - name: Verify environment run: uv run --locked --no-sync python tools/verify_python_environment.py --profile ci + - name: Check the minimal extension authoring environment + shell: bash + run: | + uv export --locked --only-group extension-authoring --no-emit-project --no-header --output-file tools/requirements-extension-authoring.txt + git diff --exit-code -- tools/requirements-extension-authoring.txt + python -m venv build/extension-authoring-env + build/extension-authoring-env/Scripts/python.exe -m pip install --require-hashes -r tools/requirements-extension-authoring.txt + build/extension-authoring-env/Scripts/python.exe -I tools/extension_package.py profile --format text + for package in examples/extensions/*/src/*/amadeus-extension.json; do + build/extension-authoring-env/Scripts/python.exe -I tools/extension_package.py check-package "$(dirname "$package")" --format text + done + # Build only the repository reference packages using the locked Host build tools. + # The minimal checker environment itself remains free of build/runtime dependencies. + for project in examples/extensions/*/pyproject.toml; do + uv build --wheel --offline --no-build-isolation --python .venv/Scripts/python.exe --out-dir build/extension-examples "$(dirname "$project")" + done + for wheel in build/extension-examples/*.whl; do + build/extension-authoring-env/Scripts/python.exe -I tools/extension_package.py check-wheel "$wheel" --format text + done + - name: Check source and architecture views # bash (not pwsh): Actions runs bash with -e, so a ruff failure must # fail the step instead of being masked by the next command's exit 0. @@ -103,10 +123,35 @@ jobs: npm ci npm run build + - name: Run offline extension author journeys + run: >- + uv run --locked --no-sync python -X utf8 tools/probes/extension_author_journey.py + + - name: Run plugin store UI journey with real reference wheels + shell: pwsh + run: | + $authorRun = Get-ChildItem -LiteralPath build/extension-author-journey -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (!$authorRun) { throw 'Author journey wheel directory is missing' } + $wheelDirectory = Join-Path $authorRun.FullName 'wheels' + uv run --locked --no-sync python -X utf8 tools/probes/extension_catalog_journey.py --execute --store-ui --wheels $wheelDirectory + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Prepare shipping-shell extension fixture + shell: pwsh + run: | + $authorRun = Get-ChildItem -LiteralPath build/extension-author-journey -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (!$authorRun) { throw 'Author journey wheel directory is missing' } + $prepared = uv run --locked --no-sync python -X utf8 tools/probes/extension_catalog_journey.py --execute --store-ui --prepare-only --failure-fixture --wheels (Join-Path $authorRun.FullName 'wheels') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $fixture = ($prepared | ConvertFrom-Json).fixture + if (!$fixture) { throw 'Release fixture was not prepared' } + Add-Content -LiteralPath $env:GITHUB_ENV -Value "EXTENSION_RELEASE_FIXTURE=$fixture" + - name: Run Electron model-less smoke run: >- uv run --locked --no-sync python -X utf8 tools\smoke_electron_model_less.py --report-dir build\electron-smoke + --extension-fixture "${{ env.EXTENSION_RELEASE_FIXTURE }}" --extension-failures - uses: actions/setup-dotnet@v5 with: @@ -123,6 +168,32 @@ jobs: uv run --locked --no-sync python -X utf8 tools\smoke_electron_model_less.py --packaged-executable electron\build\win-unpacked\amadeus-desktop.exe --report-dir build\electron-packaged-smoke + --extension-fixture "${{ env.EXTENSION_RELEASE_FIXTURE }}" --extension-failures + + - uses: actions/checkout@v7 + with: + repository: Code-Amadeus/Amadeus-Extensions + ref: e2fc0e93fa7cfd9bf98e378cac6e158f14181b9a + path: build/catalog-demo-protocol + fetch-depth: 0 + persist-credentials: false + - uses: actions/checkout@v7 + with: + ref: 1840c8e4f646992ddaebea3139c8895f29743a8f + path: build/catalog-demo-host + fetch-depth: 0 + persist-credentials: false + - name: Qualify packaged catalog publication and recovery roundtrip + shell: pwsh + run: | + python -m venv build/catalog-demo-checker + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + build/catalog-demo-checker/Scripts/python.exe -m pip install --require-hashes -r build/catalog-demo-host/tools/requirements-extension-authoring.txt + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $authorRun = Get-ChildItem -LiteralPath build/extension-author-journey -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (!$authorRun) { throw 'Author journey wheels missing' } + uv run --locked --no-sync python -X utf8 tools/probes/extension_store_demo.py --execute --protocol-repo build/catalog-demo-protocol --checker-host build/catalog-demo-host --checker-python build/catalog-demo-checker/Scripts/python.exe --wheels (Join-Path $authorRun.FullName 'wheels') --packaged-executable electron/build/win-unpacked/amadeus-desktop.exe --report-dir build/catalog-publication-smoke + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Upload Electron smoke diagnostics if: failure() @@ -130,8 +201,20 @@ jobs: with: name: electron-model-less-smoke-${{ github.run_id }} path: | + build/extension-author-journey/**/report.json + build/extension-author-journey/**/*-report.json + build/extension-author-journey/**/*.log + build/extension-catalog-journey/**/report.json + build/extension-catalog-journey/**/*-runtime.json + build/extension-catalog-journey/**/*.png + build/extension-catalog-journey/**/*.log build/electron-smoke/** build/electron-packaged-smoke/** + build/catalog-publication-smoke/**/report.json + build/catalog-publication-smoke/**/*-report.json + build/catalog-publication-smoke/**/publication.json + build/catalog-publication-smoke/**/result.json + build/catalog-publication-smoke/**/*.png if-no-files-found: ignore # Preserve the branch-protection check name. A failed, cancelled or skipped diff --git a/.github/workflows/source-release.yml b/.github/workflows/source-release.yml index 977627bd..10621291 100644 --- a/.github/workflows/source-release.yml +++ b/.github/workflows/source-release.yml @@ -12,6 +12,8 @@ on: - electron/package.json - electron/package-lock.json - scripts/** + - wallpaper/** + - electron/scripts/build-windows-wallpaper.cjs - tools/build_source_release.py - tools/stage_japanese_dictionary.py - tools/probes/verify_bundled_dictionary.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68ab8864..d0946d4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,3 +139,10 @@ Change the canonical SDK/schema/validator in Amadeus-Extensions first, then update the recorded source commit, wheel and checksums together. Do not edit pinned copies in place; provenance checks reject drift. These scopes do not change the AGPL-3.0 license of Host implementation code. + +## Extension authors + +Use [the author guide](docs/extension_authoring.md) for the small static-checker +environment; it does not require the full Host dependencies. Plugins use the +standalone SDK or their native MCP/Skill/theme contracts, not private Host +imports. Catalog review and runtime support are separate decisions. diff --git a/LICENSES/provenance.json b/LICENSES/provenance.json index aa4e7acd..5e5d38f5 100644 --- a/LICENSES/provenance.json +++ b/LICENSES/provenance.json @@ -11,6 +11,7 @@ "assets/audio/sfx", "vendor/aec-audio-processing", "vendor/extension-sdk", + "vendor/extension-catalog", "vendor/qwen-asr" ], "components": [ @@ -85,11 +86,18 @@ "id": "japanese-pronunciation", "name": "Optional Japanese pronunciation dictionary", "category": "external-runtime-data", - "paths": ["assets/text/ja/english/**"], + "paths": [ + "assets/text/ja/english/**" + ], "source_url": "https://github.com/RVC-Boss/GPT-SoVITS", "source_revision": "1b47ca07ef43a284e0b8bd7f29d3e1e3ff76082a; userdict.csv blob 2be580b461185ce18c9000d9ec6c8032623e17fd; Amadeus corrections recorded in tools/data/japanese_dictionary_source.json", "license_expression": "AGPL-3.0 AND MIT AND BSD-2-Clause", - "license_evidence": ["LICENSES/JAPANESE-DICTIONARY.md", "LICENSES/GPT-SoVITS-MIT.txt", "LICENSES/CMUdict-BSD-2-Clause.txt", "LICENSE"], + "license_evidence": [ + "LICENSES/JAPANESE-DICTIONARY.md", + "LICENSES/GPT-SoVITS-MIT.txt", + "LICENSES/CMUdict-BSD-2-Clause.txt", + "LICENSE" + ], "modified": true, "release_action": "exclude", "gate_status": "ready", @@ -484,6 +492,24 @@ "gate_status": "ready", "notes": "Unchanged model assets are optional external runtime data. Exact hashes and the upstream MIT notice (Copyright (c) 2026 Aivis Project) are retained; exact training-data provenance is not established. No model publication is authorized by this record." }, + { + "id": "amadeus-extension-catalog", + "name": "Amadeus curated catalog policy and reader fixtures", + "category": "vendored-source", + "paths": [ + "vendor/extension-catalog/**" + ], + "source_url": "https://github.com/Code-Amadeus/Amadeus-Extensions", + "source_revision": "58d3892d55c6a7ec6f8626153f503291bb10ab9a (local experimental branch; publication pending)", + "license_expression": "Apache-2.0", + "license_evidence": [ + "vendor/extension-catalog/LICENSE" + ], + "modified": false, + "release_action": "include", + "gate_status": "ready", + "notes": "Exact byte copies pinned in server/extensions/catalog-source.json. Separate from the unchanged manifest/SDK pin. Merge protocol catalog before publishing a Host release that requires this revision." + }, { "id": "qwen-asr", "name": "Qwen-ASR 0.0.6 (demo dependency separated)", diff --git a/README.md b/README.md index 873dc977..cea2e930 100644 --- a/README.md +++ b/README.md @@ -1026,6 +1026,8 @@ automatic rights to character, model, reference-audio, or external asset packs. - [Aqua-TTS](https://github.com/Lucas1479/Aqua-TTS): an MIT-licensed low-latency GPT-SoVITS v3 inference runtime. Amadeus does not require Aqua to start today. - [Amadeus SpriteForge](https://github.com/Code-Amadeus/Amadeus-SpriteForge): public **0.1.0 Source Alpha** for local sprite inspection, behavior-graph editing, and KTX2 character-pack preview/export. Licensed under AGPL-3.0-only; generation services are separate from the Amadeus runtime. - [Experimental extension Host profile](docs/extension_host_profile_v0.1.md): the implemented local-wheel TTS/MCP/Skill/theme subset, package application lifecycle, Agent binding and current limits. This is not a stable SDK or a full extension protocol conformance claim. +- [Extension authoring](docs/extension_authoring.md): minimal static checks, local wheel examples and the implemented Host support boundary. +- [Experimental plugin catalog](docs/extension_catalog.md): browse, install and manually update exact reviewed artifacts; public publication and independent review remain pending. See the [release checklist](docs/verification/extensions/store-v1.md). - [AUIP](https://github.com/Code-Amadeus/AUIP): the experimental application-session / typed-action protocol implemented in Amadeus. The separate repository documents its status and public implementation entry points; a separately versioned SDK and conformance suite are not yet published. - [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS): the embedded speech-synthesis inference foundation. - [OpenClaw](https://github.com/openclaw/openclaw): an optional external Work gateway. diff --git a/README_ZH.md b/README_ZH.md index ab6d0e79..e0a6ae99 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -907,6 +907,8 @@ Amadeus 第一方源码和修改依据 - [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS):内嵌语音合成推理基础。 - [OpenClaw](https://github.com/openclaw/openclaw):可选外部 Work gateway。 - [实验性扩展 Host profile](docs/extension_host_profile_v0.1.md):已实现的本地 wheel TTS/MCP/Skill/主题子集、包应用生命周期、Agent 绑定与当前限制;不是稳定 SDK,也不声明完整扩展协议一致性。 +- [插件作者入口](docs/extension_authoring.md):最小静态检查环境、现有 wheel 示例、Host 支持范围与运行验收边界。 +- [实验性插件目录](docs/extension_catalog.md):浏览、安装和手动更新具体产物;正式目录发布与独立审核仍待完成,资格边界见[发布检查表](docs/verification/extensions/store-v1.md)。
Star History diff --git a/ROADMAP.md b/ROADMAP.md index 7ac08f40..8ad7c4a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,25 +36,27 @@ change to Amadeus's product identity. ## Active directions -### A complete horizontal extension entry +### Community extension authoring and a curated catalog -The Host already has a read-only Capability Catalog that projects Providers, -MCP servers, Skills, and AUIP apps without replacing their native contracts. -Settings can manage MCP connections and display shared Provider capabilities. +The experimental Host already installs local wheels for TTS, MCP, Agent Skills +and data-only themes through one manager in Settings. Configuration, encrypted +credentials, package revisions and native-owner activation are implemented. -The next useful product step is a coherent extension entry that can: +The experimental branch implements the author checker, four reference journeys, +curated catalog rules, trusted downloads, durable safety decisions, browsing and +manual updates. The same installer serves local and catalog wheels. Source and +packaged-shell qualification is recorded in the [store release checklist](docs/verification/extensions/store-v1.md). +A public catalog still requires independent artifact review, repository governance +and a separate release decision. External-author evidence remains pending; reference +packages do not complete that qualification. -- show installation identity, compatibility, health, enablement, and the exact - Provider or product surface that consumes each contribution; -- add, configure, disable, and remove supported out-of-tree contributions - without requiring users to find several settings files; -- keep secrets in Host-owned bindings and retain each Provider, MCP, Skill, or - AUIP runtime as the authority for its native payload; -- prove at least two real out-of-tree adapters before allowing trusted Python - entry-point discovery. +Catalog admission policy is separate from runtime contracts. New types retain +native owners; an older client must disable unsupported packages without losing +compatible catalog entries or understood safety updates. Installing a package +never grants Main Chat direct MCP/Skill execution authority. -This direction does not grant plugins action authority and does not attach MCP -tools or Skill prompts directly to Main Chat. +See [authoring](docs/extension_authoring.md) and the +[implemented Host profile](docs/extension_host_profile_v0.1.md). ### Source Alpha setup and diagnosability @@ -145,8 +147,7 @@ runtime path. ## Later, after Alpha evidence -- remote extension catalogs, package signatures, automatic updates, rollback, - and marketplace-style discovery; +- package signatures, automatic updates and rollback; - broader public SDK surfaces beyond contracts already exercised by external adapters; - platform-specific wallpaper implementations outside Windows; diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b2694082..c1e14e89 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -207,3 +207,5 @@ source commits, adaptations and wheel/source SHA-256 values are recorded in pending upstream main acceptance. Authored `examples/extensions/` code is MIT-0 under its LICENSE, as recorded in `LICENSES/provenance.json`. These scoped grants do not relicense the Host or external voices, models, artwork or dependencies. + +The curated catalog policy under `vendor/extension-catalog/` is an unmodified Apache-2.0 copy from Amadeus-Extensions, pinned independently in `server/extensions/catalog-source.json`. Its source revision is currently on the local experimental branch; publish the protocol revision before releasing this Host integration. diff --git a/config/catalog/desktop/extensions.json b/config/catalog/desktop/extensions.json new file mode 100644 index 00000000..6d5fdd7e --- /dev/null +++ b/config/catalog/desktop/extensions.json @@ -0,0 +1,33 @@ +{ + "id": "desktop_extensions", + "title": { + "en-US": "Extension downloads", + "zh-CN": "插件下载" + }, + "description": { + "en-US": "Only wheel downloads use this mirror. The index always comes from the official repository.", + "zh-CN": "镜像只用于下载 wheel,目录索引始终来自官方仓库。" + }, + "desktop": true, + "apply": "frontend", + "config": { + "AMADEUS_EXTENSION_ARTIFACT_MIRROR": { + "type": "url", + "title": { + "en-US": "Artifact mirror base URL", + "zh-CN": "产物镜像根地址" + }, + "description": { + "en-US": "Optional HTTPS directory serving /. Empty uses the original artifact URL.", + "zh-CN": "可选 HTTPS 目录,按 / 提供文件。留空使用原始下载地址。" + }, + "default": "", + "scope": "desktop", + "schemes": [ + "https" + ], + "allow_empty": true, + "max_length": 2048 + } + } +} diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md new file mode 100644 index 00000000..b0ad5942 --- /dev/null +++ b/docs/extension_authoring.md @@ -0,0 +1,218 @@ +# Extension authoring + +Amadeus accepts experimental local-wheel TTS, MCP, Agent Skill and data-theme +packages. Start with a fixed Host source checkout and record its commit. The +checker below reports the actual Python/platform, Host API and SDK it uses; +it does not emulate another machine or execute plugin code. + +## Choose an integration + +| Contribution | Author interface | Consumer and application boundary | +| --- | --- | --- | +| TTS | Standalone `amadeus-extension-sdk` 0.1.0 | Separate Runner; select in Voice; backend restart | +| MCP | Declarative stdio/HTTP connection and native MCP | Bind compatible Work Provider; backend restart | +| Skill | Packaged `SKILL.md` and relative resources | Bind Agent; backend restart; changed recorded Skills require a new session | +| Theme | `amadeus.ui.theme/v0` data palette and PNG/JPEG | Electron theme owner; pure themes apply immediately, mixed packages on restart | + +Run `profile` to see the current open/closed matrix. ASR, surfaces, installed +AUIP, context sources, observers and character/visual contributions remain +closed. A new manifest type does not create an execution owner. Main Chat does +not receive installed MCP tools or Skill bodies. Installation does not grant +an action permission; OS declarations are disclosures, not an OS sandbox. + +## Minimal static checker environment + +From this Host checkout on Windows with CPython 3.12.10: + +```powershell +python -m venv build/extension-authoring-env +build/extension-authoring-env/Scripts/python.exe -m pip install --require-hashes -r tools/requirements-extension-authoring.txt +build/extension-authoring-env/Scripts/python.exe -I tools/extension_package.py profile --format text +build/extension-authoring-env/Scripts/python.exe -I tools/extension_package.py check-package examples/extensions/handoff-skill/src/amadeus_example_handoff --format text +``` + +Only jsonschema, packaging, PyYAML and their locked transitive dependencies are +installed. No GPU, models, audio stack or full Host installation is needed. +On POSIX use the environment's `bin/python`; a Windows-only manifest will be +reported incompatible there. Windows qualification runs on Windows with the +recorded interpreter and Host revision. + +The `extension-authoring` dependency group is resolved in the Host's existing +`uv.lock`. Regenerate this export after changing that lock with uv 0.12.8; CI +checks for drift. Do not hand-edit a second set of pins: + +```powershell +uv export --locked --only-group extension-authoring --no-emit-project --no-header --output-file tools/requirements-extension-authoring.txt +``` + +Build tools are a separate requirement: the checker environment is not a package +builder or the environment for runtime/GUI testing. Do not `uv sync` a voice +installation to the core profile merely to run these static checks. + +## Build and check your wheel + +Choose one of the [six source examples](../examples/extensions/README.md). +Change the package ID, distribution/import-package name, version and entry-point +metadata together. Keep the manifest directly inside its top-level import +package. Package all declared Skills, references and theme images. Use `X.Y.Z` +versions for the first preview; the Host currently compares manifest and wheel +metadata version strings exactly. + +In a separate build environment with the example's `build-system` requirements: + +```powershell +python -m pip wheel --no-build-isolation --no-deps --no-index examples/extensions/handoff-skill -w build/extension-examples +build/extension-authoring-env/Scripts/python.exe -I tools/extension_package.py check-wheel build/extension-examples/amadeus_example_handoff_skill-0.1.0-py3-none-any.whl --format text +``` + +`check-wheel` snapshots and inspects the wheel, then validates its extracted +package resources using the same `validate_installed` owner as the desktop. +It checks Skill frontmatter without importing package modules, starting a +Runner or installing dependencies. Temporary extracted resources are removed. +`check-package` checks only the manifest and package resources. It cannot check +wheel metadata: entry points, manifest/wheel version consistency or declared SDK +dependencies. A source PASS can therefore accompany a rejected wheel. Always run +`check-wheel` on the built artifact before installation. Pass the import-package +directory containing `amadeus-extension.json`, not the outer project directory +containing `pyproject.toml`. + +For automation omit `--format text`; JSON is the default and exit code 1 means +failure. Existing `inspect-wheel`, `validate-manifest`, `validate-config` and +`validate-installed` helper results retain their JSON shape. `inspect-wheel` +checks wheel structure/admission; it alone does not validate all packaged +resources. Configuration JSON belongs in a file or stdin, never command-line +secret values. The checker reports missing required configuration without +printing secret values. + +## Use the SDK without Host imports + +Only TTS packages need the standalone SDK API. Its exact wheel and source are +recorded in [protocol-source.json](../server/extensions/protocol-source.json). +Install that wheel from `vendor/extension-sdk` into the author's build/test +virtual environment with `pip install --no-index --no-deps `; the desktop +supplies the same pinned SDK to its private installation environment. + +Use the [pinned SDK reference](https://github.com/Code-Amadeus/Amadeus-Extensions/blob/49449a0f3572e74ac7269129c02c5b9a9e685bef/sdk/python/README.md). +Plugins must not import `server`, `core`, `agent_host`, `config` or private TTS +helpers. MCP/Skill/theme packages retain their native contracts instead of +using the TTS Runner as a general execution API. + +## Install and validate the actual journey + +Use a separately configured development desktop. Go to Settings → Extensions +(or General → Manage plugins), install the wheel, inspect its disclosure, +configure and enable it, then bind an Agent or select Voice/Appearance. +TTS/MCP/Skill changes need a backend restart. Pure themes apply immediately; +mixed packages keep a single loaded version until restart. Changing a Skill +already recorded by a native session requires a new session. + +Validate one real call, cancellation/close, an explicit new-version update, +disable, uninstall and reinstall. Check both retained data and explicitly +requested data deletion. A preparation failure preserves the installed version; +a later backend startup failure does not guarantee rollback. Never replay a +failed paid synthesis to hide a failure. + +The normal desktop backend currently uses port 17777. A different user-data +directory alone does not give it a separate backend port. Do not start a second +backend over an existing instance; use the offline lifecycle probe or stop your +own test instance first. The native desktop must also have its own settings, +cache and data directories. Never copy normal user credentials into a fixture. + +## What a pass means + +| Result | Scope | +| --- | --- | +| Protocol-valid | Matches the pinned schema and semantic rules | +| Source check passes | This checkout/interpreter accepts the manifest and package resources; wheel metadata remains unchecked | +| Wheel check passes | The built bytes pass static wheel admission and resource checks; runtime and native checks remain separate | +| Runtime/GUI qualified | Explicitly recorded real calls, native theme validation and lifecycle journeys | +| Catalog reviewed | A particular wheel digest passed the published catalog review policy | + +Static checks do not prove image decoding, installed font availability, painted +contrast, Agent discovery, paid services or catalog review. The catalog is not +yet available. First-catalog restrictions are distribution policy; a locally +supported dependency does not become invalid solely because the catalog will +not initially accept it. New types require an owner and contract, and older +clients must refuse unsupported packages while retaining compatible entries. + +The application version, Host API (`0.1`), contribution contract (`/v0`) and SDK +version (`0.1.0`) are separate versions. No stable SDK 1.0 promise is implied. +Record the Host commit, OS/Python, SDK pin, package ID/version/SHA-256, commands, +and which of static/process/GUI/remote-service checks actually ran. Use +synthetic inputs and omit credentials and personal paths from shared reports. + +## Maintainer validation + +Existing entry points include `tests/test_extension_authoring.py`, the scoped +`test_extension_*.py` suites and Electron extension tests. The real +`tools/probes/extension_theme_lifecycle.py` uses independent environments and +SDK Runners but supplies confirmation decisions through a driver. Component +screenshots with synthetic Host responses are not native-dialog acceptance. +Pi/MCP qualification must use the target mainline including #234, not an older +working checkout. See [Host profile](extension_host_profile_v0.1.md) for contracts, +[historical evidence](extension_validation_history_2026-10-13.md) for past runs, +and [ROADMAP](../ROADMAP.md) for the next stages. + +Host code remains AGPL-3.0, the standalone SDK/protocol is Apache-2.0 and authored +examples are MIT-0. Code licenses do not grant rights to external art, voices, +models or other media; document those separately. + + +## Explicit runtime contract checks + +Use the full locked Host environment and built Electron/Pi dependencies for this +step. The minimal static checker environment does not contain runtime dependencies. +Only execute your trusted package, in an isolated checkout/profile. Local plugin +code is not OS-sandboxed. The tool does not pip-install, load normal desktop settings, +or infer credentials from a user's regular installation. + +```powershell +python -I tools/check_extension_contract.py --execute --kind mcp --wheel --record --scenario examples/extensions/contract-scenarios/notes.json --output +python tools/probes/extension_author_journey.py +``` + +The first command requires ONE record from the isolated installer's startup snapshot, +not an entire registry; its ID/version/digest/manifest must match the wheel. The +maintainer journey above produces these records under `build/extension-author-journey/`. +For themes, which deliberately have no backend startup record, that driver exports +the actual installed resource location and manifest from the same installer/store. +Do not pass a record from your daily profile. The tool uses the installed interpreter +and immutable package path, but creates new temporary data, cache and native Agent +session directories for each check. Its owned child process tree is bounded by a +wall-clock timeout and terminated on exit. No logs or tool responses are included +in the shareable report; arbitrary exception messages may contain secrets. + +Kinds and scenario inputs: + +- `tts`: contribution ID, text, declared language and cancellation text. Checks + initialize/ready, a PCM stream, rejection of an undeclared language, stream + cancellation and shutdown through the existing Runner/proxy. Remote TTS requires + `--allow-remote` and may be billable; a second request tests cancellation, and + neither failed request is replayed. This switch only acknowledges declared remote + use; it is not a network sandbox for arbitrary package code. +- `mcp`: contribution ID, an existing Provider binding, and a list of synthetic + tool calls/arguments with optional expected text. Checks native MCP discovery, + calls and close. This direct transport check does not count as Agent routing; + the installer journey separately exercises Notes through real Pi with approvals. +- `skill`: currently the existing `pi` binding and package-relative read paths. + Reads the selected SKILL.md and resources through actual Pi tools, then resumes + the unchanged native session. A controlled loopback model drives the requests; + real LLM choice quality and other Provider qualifications remain separate. +- `theme`: empty scenario. Uses the real Electron image/font/palette owner, paints + each theme through the production renderer theme application, switches and restores + the built-in theme in a disposable hidden window. No Runner is started. + +Reports distinguish static, process contract, native Agent, GUI and remote-service +layers. An unrun layer stays `not_run`. Failures return exit code 1 with a controlled +stage, exception class, worker exit code when available and Runner readiness state +for TTS. Timeout is a failure, not a retry. The tool does not certify installation +solely from a supplied record: use the actual installer-backed journey for that. + +Existing adversarial tests cover wrong registration/handshake, illegal PCM, +late audio after cancellation, timeouts, credential projection, unbound Providers, +missing/shadowed Skills, changed/removed Skill revisions, unrelated additions, +invalid theme colors/images/contrast and native recovery. The journey additionally +runs `test_skill_pi_runtime.py` against its actual installed versions and Notes +through the native Pi path. Paid Fish/MiMo service checks remain explicitly separate. + +Catalog distribution has a separate [client, mirror and security recovery contract](extension_catalog.md). Its curated restrictions do not change local-wheel or native runtime contracts. The S2 experiment includes a store browser and manual updates; a published reviewed package list and S3 release qualification remain pending. diff --git a/docs/extension_catalog.md b/docs/extension_catalog.md new file mode 100644 index 00000000..fcf4af90 --- /dev/null +++ b/docs/extension_catalog.md @@ -0,0 +1,257 @@ +# Experimental catalog client and security recovery + +S1 supplies the desktop transport, installer integration and durable security boundary. +S2 adds discovery, search, version details, download progress and manual updates in the +existing plugin manager. This remains an experimental client, not a published store. +The official catalog and its independent reviews must be published before this client +can provide a public store. The current S0 reference index contains no reviewed packages. + +## Authority and compatibility + +The only index is `https://raw.githubusercontent.com/Code-Amadeus/Amadeus-Extensions/main/catalog/v1/index.json`. +Its repository permissions and HTTPS delivery are the trust root; this version has no +independent index signature. Renderer IPC selects an action and, where needed, a package +ID and version. Main resolves the URL, digest, review and compatibility. Cache data can be +shown offline but cannot authorize installation: a store install first refreshes the +index. Local installation remains available offline while local security state is intact. + +`server/extensions/catalog-source.json` pins an unmodified copy of the S0 policy, schema +and compatibility fixtures in `vendor/extension-catalog/`. This is independent of the +unchanged manifest and SDK pins. Python owns catalog structure/policy and environment +compatibility. Unknown contribution kinds/contracts and mixed unsupported packages are +incompatible as whole entries; other entries and valid security decisions remain usable. +An unknown catalog format or malformed safety structure rejects the complete new index. + +A catalog artifact is identified by package ID, version and wheel SHA-256. Installed +`record.digest` already has that meaning. Review and withdrawal status are projections +of that identity, never a persisted source or trust label. Cached review information is +historical; lack of a known ban is not certification that a package is safe. + +The curated policy (X.Y.Z, py3-none-any, SDK-only dependencies, restricted MCP commands) +is separate from general local-wheel admission and native execution contracts. After +copying a downloaded wheel into the existing installation transaction, the installer +rechecks its identity and catalog policy. Security/admission are checked before +preparation and again after confirmation and before persistence. Cancellation during +preparation prevents commit; an already running preparation command must finish or be +confirmed stopped before its files can be removed. No automatic rollback is promised. + +Both entry points select installation mode from effective wheel dependencies evaluated +by the installing Python interpreter. Resource wheels are extracted. Python wheels +with no effective dependencies or only the supported SDK install offline with +`--no-index --no-deps --only-binary=:all:`. Third-party dependency resolution stays +available only for local installation. No package code is imported by static checks. + +## Downloads and mirrors + +Desktop requests use Electron net in a separate, nonpersistent session with credentials +omitted. Chromium retains its normal system proxy and certificate checks. Every redirect +must be credential-free HTTPS; at most five redirects, a 120-second total request deadline, +4 MiB index limit and 256 MiB wheel limit apply. Streamed decoded bytes are bounded too; +compressed Content-Length is not mistaken for decoded size. The official digest is +checked by the installer, not by net's ignored `integrity` option. + +In the plugin manager, **Artifact mirror base URL** is the existing desktop setting +`AMADEUS_EXTENSION_ARTIFACT_MIRROR`. Empty uses the original wheel URL. A configured +HTTPS directory serves `//`, for example: + +```text +https://mirror.example/extensions/<64-lowercase-hex-digest>/example-1.0.0-py3-none-any.whl +``` + +The base must not contain credentials, a query or fragment. Mirrors provide bytes only; +there is no index mirror setting. Incorrect mirror content fails static inspection or +identity verification before pip. No silent original/mirror fallback occurs. + +Downloads use private staging directories and clean up cancellation/failure. On the +next request after a desktop restart, the client reclaims interrupted directories with +intact download ownership and only expected regular files. Unknown ownership, links and +unexpected contents are retained. The existing desktop single-instance lock owns this +staging root; active downloads in the current process are excluded from collection. + +## Durable security and recovery + +`settings.json` and its existing `.bak` hold one optional `extensionSecurity` snapshot: +maximum accepted sequence, complete decisions, check time and a content checksum. The +checksum detects accidental corruption; it is not a signature or protection against +local administrative edits. Sequence and decisions always move together through the +existing fsync/atomic replacement writer. The display cache holds neither execution +permission nor rollback protection. + +- Read both settings copies and select the highest complete security snapshot. A newer + complete backup wins even if the primary parses. The normal settings owner still + prefers readable primary values and encrypted credentials. +- Same-sequence conflicting decisions enter a durable unavailable state. A lower index + cannot replace accepted facts; omission cannot remove a known decision. A changed + decision needs a later publication sequence. Explicit higher-sequence `allowed` facts + lift a ban; uninstall/reinstall or cache deletion do not. +- A damaged field can recover from an intact copy. If no complete security snapshot + survives, preserve damaged bytes as `.corrupt-*` evidence and durably persist + unavailability. Ordinary settings saves and restarts cannot turn this into an empty + ban list. A remembered sequence floor is retained when a conflict can establish one. +- While unavailable, reject new installs and activation, suppress frontend extensions, + preserve builtin functionality and removal/recovery operations. Existing backend + execution is reported as still running and needing stop/restart; it is never falsely + reported as stopped. Desired and actually loaded identities are checked separately. +- **Recover official extension security state** explicitly fetches the sole official + index. It cannot bypass a known sequence floor. With both settings files destroyed, + this recovers security facts, not lost package records, configuration or credentials. + The client does not promise recovery after all local evidence is deliberately deleted. + +The writer persists backup before primary. An error between the two writes does not +publish a successful refresh, but the complete newer backup is durable and must govern +the next read. It cannot be overwritten by an older primary during an ordinary save. +Fault sentinels also survive an interrupted write; incomplete recovery stays blocked. + +Maintainer withdrawal and copyright withdrawal stop store supply only. A security ban +blocks the exact identity through local and store entry points and future activation. +It does not delete package data, credentials or saved enable intent, and cannot recognize +unknown modified bytes as the same artifact. + +## Browsing and manual updates + +Use **Installed** to configure, enable, bind, disable or remove packages. **Browse** shows +the official catalog with name/description/capability search and a type filter. Open +**View plugin details** to select a version and inspect its artifact identity, declared +platform/runtime/SDK, review scope, wheel size and activation behavior. Configuration +and catalog views share the same manager; switching views keeps an unsaved draft. + +Version recommendations compare all three X.Y.Z integers without floating-point rounding. +Only greater, compatible, non-withdrawn, non-blocked candidates are recommended. A newer +incompatible version does not hide an older compatible update. Local versions outside +X.Y.Z have no guessed update order; select a specific catalog version and review its +native installation confirmation instead. Matching an installed ID/version but different +bytes does not permit replacing the artifact silently. + +**Update plugin…** uses the same native confirmation and installer as **Install plugin…**. +Progress comes from main: checking the index, downloading, verifying, awaiting confirmation, +and preparing. Download completion is not installation success. **Cancel installation** +waits for safe cleanup and the operation receipt; a concurrent preparation/persistence +failure is not relabeled as successful cancellation. Existing saved/applied package state +continues to be shown after a failed update. Nothing automatically updates or rolls back. + +A new install remains disabled. Configure and enable it in Installed, then choose its +Agents, voice or Appearance. Resource themes can apply immediately; backend contributions +keep their established restart and session boundaries. A security-blocked package can be +disabled and removed, but the editor cannot activate it. Safety recovery is still explicit. + +Catalog descriptions and access details are plain text. No third-party images or Markdown +are loaded. Homepage/source buttons send only package ID/version and the requested link +kind to main, which resolves and validates a credential-free HTTPS URL before opening it +externally. The optional `display.access` is derived by the catalog generator from the +wheel: OS disclosures and TTS/MCP service/command templates, excluding configuration +defaults, environment values and bearer tokens. Older indexes without this field show a +clear missing-details notice and retain the native verified-wheel disclosure at install. + +Unknown kinds/contracts and mixed future packages remain visible with reasons and disabled +installation. Filtering for a future capability shows the matching version, not an older +version that lacks it. Closed/future voice roles are not silently presented as TTS. These +presentation changes do not open a new native execution owner or change the format major. + +## Network coverage and verification + +The installed manager offers **Check updates and security status**, the last successful +check time and a separate damage recovery action. There is no automatic startup network +check. Users who never check cannot receive new remote bans. Catalog failure and local +security damage are different states. Opening Browse performs an explicit catalog refresh; +opening Installed alone reads local state and does not initiate a remote check. When +security storage is unavailable, Browse keeps the display cache; only the explicitly +labeled recovery button can request recovery. Navigation is not recovery authorization. + +Run the unit suites through `npm test` in `electron/` and the catalog Python tests with +`python -m pytest tests/test_extension_catalog.py`. The explicit Windows probe reuses +wheels produced by the D2 author journey: + +```powershell +python tools/probes/extension_author_journey.py +python tools/probes/extension_catalog_journey.py --execute --wheels build/extension-author-journey//wheels +python tools/probes/extension_catalog_journey.py --execute --store-ui --wheels build/extension-author-journey//wheels +``` + +The probe uses real net requests, installers and reference packages. Public HTTPS is +checked with normal system trust. Loopback fault tests pin only a disposable certificate +inside that test process; they do not change OS roots or add production TLS exceptions. +The index/independent-review records in those tests are fixtures, not published approvals. +Native confirmations are driven automatically. See the [S1 construction record](verification/extensions/community-preview-batch3-2026-10-21.md) +for exact results, hashes, limits and failed intermediate probes. + +Transport references: [Electron ClientRequest](https://www.electronjs.org/docs/latest/api/client-request), +[Electron net](https://www.electronjs.org/docs/latest/api/net), and +[certificate verification caching](https://www.electronjs.org/docs/latest/api/session#sessetcertificateverifyprocproc). + +The [S2 construction record](verification/extensions/community-preview-batch4-2026-10-21.md) records the real renderer/IPC/installer journey, screenshots, code revisions and remaining publication gates. The `--store-ui` probe uses loopback HTTPS fixture authority and automated confirmations; it does not launch the complete Chat backend or certify public reviews. + + +## Source and packaged desktop qualification + +The packaged frontend ships the static Python checker, its native Skill metadata +reader, protocol/profile files and pinned catalog policy under `resources/extension-host`. +The SDK remains under `resources/extension-sdk`. This subtree is separate from the +externally supplied Python backend: it must not create a `resources/server` package +that shadows that backend. The selected Python interpreter still needs the documented +checker dependencies; bundling these sources does not bundle a Python runtime. + +The shipping-shell smoke starts the normal authenticated backend and uses the actual +built renderer, preload and IPC handlers. An explicit `--extension-fixture` installs +real reference wheels in a fresh synthetic legacy settings profile. Its private main +inspector rewrites only fixture catalog/artifact requests to a loopback HTTPS server +and automatically confirms extension access dialogs. The certificate exception stays +inside that process's download session. Pi registers against a synthetic loopback-only +model configuration for binding tests; the probe sends no model request. There is no production test URL/IPC switch, +OS trust change or copied developer profile. These results do not establish independent +review or manual native-dialog acceptance. See the [release checklist](verification/extensions/store-v1.md). + +```powershell +python tools/probes/extension_catalog_journey.py --execute --store-ui --prepare-only --failure-fixture --wheels +python tools/smoke_electron_model_less.py --extension-fixture --extension-failures --report-dir build/source-store-smoke +python tools/smoke_electron_model_less.py --extension-fixture --packaged-executable electron/build/win-unpacked/amadeus-desktop.exe --extension-failures --report-dir build/packaged-store-smoke +python tools/probes/extension_reader_matrix.py --fixture /fixture.json --ref --ref --output build/reader-matrix +``` + +The fixed reader matrix exports unmodified Git blobs into isolated directories, +checks compatible neighbors and understood safety decisions beside future kinds, +roles, contracts and delivery modes, and rejects unknown global formats or malformed +safety data. Its report includes exact reader commits and exported file hashes. + + +`--extension-failures` adds cancellation, bad digest, copyright withdrawal, +retained/deleted data, real backend restarts, deliberate plugin startup rejection, +and three fresh application processes covering NUL-damaged settings. Both damaged +copies keep safety unavailable until explicit official recovery; ordinary refresh +or settings writes cannot clear it. Damage is injected while the owned test app is +stopped. This is not physical power-loss or clean-OS certification. The deliberately +failing wheel is a disposable test artifact, never a publication candidate. + +To exercise the actual native Cancel, Install and activation buttons, run a separate +fresh profile with `--native-confirmations` instead of `--extension-failures`. Keep +`--extension-fixture` and optionally `--packaged-executable`. Each dialog waits up to +five minutes for an external UI operator; the fixture does not return a fabricated +confirmation response. Record whether the operator was a human or an agent. +Agent-operated dialogs do not close the independent human acceptance gate. + +A source ZIP may run the same smoke without Git. The launcher verifies every listed +release input against `SOURCE_MANIFEST.json` before launch and records the archived +commit. This is an input-integrity check, not a signature or an assertion about extra +files. Dependency locks and actual frontend/executable hashes are recorded separately. + + +## Complete local publication roundtrip + +The [functional prototype demonstration](extension_store_demo.md) connects the unchanged +Git input/review, generation and publication CI tools to loopback HTTPS and the shipping +desktop. It covers real Notes/theme installs and manual updates, copyright withdrawal, +exact-artifact blocking, explicit unblocking and separate distribution restoration. +Every accepted desktop snapshot is matched to the generated index bytes; offline +restarts and single/double NUL damage exercise the same durable settings owners. +This locally simulated review/publishing workflow is separate from public-release gates. + + +The official v1 path is permanently bound to amadeus.catalog/v1. Future index +formats use distinct versioned paths; supported older clients keep receiving +safety decisions on their existing path. Empty catalogs retain the Browse view +with an explicit preview notice. + +Downloads abort after 30 seconds without progress. Their separate total budget +allows 32 KiB/s over the admitted maximum size plus connection time (at least two +minutes), so an active slow wheel transfer can continue past the old two-minute +limit. Asynchronous stream backpressure bounds queued writes and completion waits +for disk flush/close. Limits, HTTPS redirect validation and digest checks still apply. diff --git a/docs/extension_community_preview_and_store_plan_2026-10-21.md b/docs/extension_community_preview_and_store_plan_2026-10-21.md new file mode 100644 index 00000000..65fde74a --- /dev/null +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -0,0 +1,590 @@ +# 社区插件开发预览与首版插件商店实施计划 + +日期:2026-10-21 + +状态:已批准并开始实验施工;第一批 D0/D1 见[施工记录](verification/extensions/community-preview-batch1-2026-10-21.md),D2/S0 的实现与本地验收见[第二批施工记录](verification/extensions/community-preview-batch2-2026-10-21.md)。S1 下载、安装与耐久安全准入的实现和本地验收见[第三批施工记录](verification/extensions/community-preview-batch3-2026-10-21.md)。S2 浏览、详情、安装进度和手动更新的实现及本地验收见[第四批施工记录](verification/extensions/community-preview-batch4-2026-10-21.md)。S3 的本地打包、真实 shell 和固定 reader 准备见[第五批施工记录](verification/extensions/community-preview-batch5-2026-10-21.md),公开门槛见[发布检查表](verification/extensions/store-v1.md)。D0 跨仓公开入口、D3 外部作者和 S3 发布资格仍待完成;未合并、未发布。 + +目标是让外部作者独立完成插件,让用户从精选目录发现、安装和更新插件。先完成 **D0 文档收口 → D1 作者校验入口**,再并行推进 **D2 合约与模板 → D3 外部作者验收** 和 **S0 目录规则 → S1 下载与安装 → S2 商店界面**,在 S3 汇合验收。商店开发使用参考包和测试 fixture,不等待作者招募或公开发布。复用已有安装事务、配置存储、运行时和原生能力接口。 + +本计划只覆盖已经开放的 TTS、MCP、Skill 和数据主题。首版商店采用维护者审核的公开索引,安装包由作者发布,更新由用户触发。它不承诺稳定 SDK 1.0、任意第三方代码沙箱、自动更新或完整更新回滚。 + +首版实现还须满足第 16 节的扩展约束:目录收录政策与运行契约分开,新能力由明确的原生 owner 消费,旧客户端能隔离不支持的条目而继续使用兼容目录。这些约束通过现有阶段和合成未来条目验收,不提前开放新的插件类型。 + +## 1 基线与文档关系 + +本计划的 Host 参考主线为 [`fd17e707`](https://github.com/Code-Amadeus/Amadeus/commit/fd17e707d8793480003f929bc8c78a8d134e41b5),对应 0.17.1 Alpha。核查工作区 HEAD 为 `de2b5f4a`;已核对的 `server/extensions/`、Electron 插件核心文件和 `examples/extensions/` 与参考主线相同。这个结论不包括完整调用链:`agent_host/adapters/pi.py`、`agent_host/pi_runtime/mcp.ts` 和 `agent_host/provider_runtime.py` 与主线存在差异,工作区缺少 #234 的相关实现。 + +D2 的 Pi/MCP 资格必须在包含 #234、并固定 commit 的目标主线上验证。工作区小范围回归不能代替目标主线、原生适配器或发布验收。 + +协议仓参考主线为 [`bf1a848`](https://github.com/Code-Amadeus/Amadeus-Extensions/commit/bf1a848448a19ed20eca029e8cc3991563e42e95)。Host 协议 pin 为 `b30df73669c50d95d08fb2cd57c289ecddc959f2`,SDK 源码 pin 为 `49449a0f3572e74ac7269129c02c5b9a9e685bef`,随 Host 交付的 SDK 为 0.1.0。来源、文件和摘要以 [`protocol-source.json`](../server/extensions/protocol-source.json) 为准。 + +| 材料 | 在本计划中的作用 | +| --- | --- | +| [Host profile](extension_host_profile_v0.1.md) 与 [支持矩阵](../server/extensions/profile.json) | 当前实际支持范围和生效边界 | +| [开发者套件留档](extension_licensing_and_developer_kit_proposal_2026-10-12.md) | K2 校验、K4 合约检查、K5 作者验收的来源 | +| [扩展候选留档](extension_pilot_expansion_plan_2026-10-12.md) | 商店索引、可信摘要和手动更新方案的来源 | +| [插件收口与记忆衔接](extension_module_implementation_plan_2026-10-11.md) | 已合入基线及独立记忆方向;记忆不成为本计划前置 | +| [CONTRIBUTING](../CONTRIBUTING.md) 与 [ROADMAP](../ROADMAP.md) | 后续设计 Issue、兼容性说明和实现评审规则 | + +本计划细化社区开发预览到首版商店的路径,不把旧 P3 至 P5 或记忆 M0 至 M2 纳入施工顺序。旧留档保留历史含义;后续公共文档更新在 D0 阶段单独完成。 + +涉及公开 API、持久化格式、安装信任或兼容性承诺的实现,按 CONTRIBUTING 先形成维护者认可的设计 Issue。当前留档不创建 Issue、PR、仓库、Release,不邀请作者或发布社区消息。 + +## 2 已有能力与需要补齐的部分 + +| 已有能力 | 当前代码落点 | 后续应复用的边界 | +| --- | --- | --- | +| 清单、配置、wheel 和安装目录检查 | [`server/extensions/package.py`](../server/extensions/package.py),[`manifest.py`](../server/extensions/manifest.py),[`tools/extension_package.py`](../tools/extension_package.py) | 静态检查不导入插件代码;Host profile 决定是否接纳 | +| 协议来源与 SDK 校验 | [`protocol-source.json`](../server/extensions/protocol-source.json),[`sdk.py`](../server/extensions/sdk.py),[`check_extension_protocol.py`](../tools/check_extension_protocol.py),[`extensionSdk.ts`](../electron/src/main/extensionSdk.ts) | 固定来源和摘要;不隐式追踪协议 main | +| 安装、更新、清理 | [`ExtensionInstaller`](../electron/src/main/extensions.ts),[`extensionPaths.ts`](../electron/src/main/extensionPaths.ts) | 准备成功后提交;旧引用释放前保留文件 | +| 配置、凭据、期望与实际修订 | [`desktopSettings.ts`](../electron/src/main/desktopSettings.ts),[`shared/extensions.ts`](../electron/src/shared/extensions.ts) | 继续使用现有记录、加密和回执,不新建插件数据库 | +| 启动准入与 TTS Runner | [`runtime.py`](../server/extensions/runtime.py),[`runner.py`](../server/extensions/runner.py),[`tts.py`](../server/extensions/tts.py) | 后端重启应用;有限恢复;失败请求不重放 | +| MCP 和 Skill | [`extension_mcp.py`](../agent_host/extension_mcp.py),[`skills.py`](../agent_host/skills.py),`agent_host/adapters/` | Provider 绑定、原生权限和会话修订仍由现有 owner 负责 | +| 数据主题 | [`extensionThemes.ts`](../electron/src/main/extensionThemes.ts),[`extensionTheme.ts`](../electron/src/shared/extensionTheme.ts) | 纯主题即时应用;混合包整包随后端重启 | +| 管理界面与 IPC | [`ExtensionSettings.tsx`](../electron/src/renderer/components/ExtensionSettings.tsx),[`SettingsPage.tsx`](../electron/src/renderer/components/SettingsPage.tsx),[`main/index.ts`](../electron/src/main/index.ts),[`preload/index.mts`](../electron/src/preload/index.mts) | 一个管理入口和一条安装事务 | +| 作者参考与测试 | [`examples/extensions`](../examples/extensions/README.md),`tests/test_extension_*.py`,`electron/tests/extension*.mjs` | 将现有验证整理成作者可运行的入口 | + +缺口主要有四组: + +1. 作者文档分散,协议仓首页仍有已过时的“未合并实验”描述;当前支持范围、草案和历史验收混在一起。 +2. 已有校验与合约回归,但没有完整的作者入口、模板旅程和外部作者独立验收。 +3. 安装器接收本地 wheel 并计算摘要,尚无可信目录预期摘要校验、发布者归属和审核投影,也没有持久化的目录安全事实。 +4. 尚无目录客户端、下载、更新发现和商店界面。SDK 以外的 Python 依赖仍由 pip 解析下载,主 wheel 摘要不能覆盖全部依赖。 + +## 3 范围与依赖顺序 + +### 首版范围 + +- 社区开发预览保留四类已开放能力;Python 插件仍使用现有私有环境和 Runner 或原生 MCP 机制。 +- 首版商店只收录经过公开审核清单检查、在支持环境完成验收的具体 wheel。每个版本分别审核,不根据作者名或源码仓直接授予资格。 +- 首批 wheel 仅接受 `py3-none-any`,允许 Python 源码和声明的资源,不含原生模块或可执行二进制;文件名、WHEEL 元数据和实际内容都要检查。版本仅接受无前导零的 `X.Y.Z`,不接纳预发布、开发版或 `+build` 后缀。这是目录首版限制,不缩小现有本地安装的协议范围。 +- 首批目录包不声明第三方 Python 运行依赖,或者仅声明与 Host 自带版本兼容的 SDK。标准库实现的 TTS、MCP,以及数据主题、Skill 均可参与。 +- 当前带第三方依赖的本地安装能力保留。例如 Fish Audio 可继续通过现有方式使用,但其 websockets、msgpack 依赖未解决商店交付规则前,不进入首批目录。 +- Skill 或 MCP 即使使用资源包,也可能执行脚本或命令;“安装时不执行代码”不表示“运行时没有执行风险”。首版目录 MCP stdio 启动限于 Host 提供的 `${python}` 和包内已审核脚本;Windows 参考方式为 `${python}` 调用包内模块或 `.py`。启动参数和包内实现必须符合第 8 节审核规则,不能用 npx、uvx、pip 或脚本在运行时下载执行未经审核的代码。 +- 初始桌面资格目标为已验证的 Windows 路径;跨平台静态与进程测试继续保留,不据此宣称完整 Linux/macOS GUI 支持。 +- 离线本地 wheel 安装继续可用;关闭商店或目录服务不可用时,已有插件管理仍可用。 + +自动更新、热重载、任意目录开发模式、SDK PyPI 发布、评分评论、账号系统、付费、遥测、通用依赖锁定、自动回滚、网页扩展、ASR、可安装 AUIP、记忆与形象包均不在首版范围。 + +### 阶段安排 + +| 阶段 | 交付目标 | 必须依赖 | 退出后允许推进 | +| --- | --- | --- | --- | +| D0 | 当前公共契约与作者路径清晰 | 本计划范围评审 | D1 | +| D1 | 作者与 Host 复用同一验证结论 | D0 | D2 与 S0 可并行 | +| D2 | 四类模板和可运行合约检查 | D1 | D3 | +| D3 | 外部作者独立完成生命周期 | D2 | 完成社区作者资格,供 S3 汇合 | +| S0 | 精选目录格式、收录与信任规则 | D1;安装信任设计评审 | S1 | +| S1 | 可信下载复用安装器,持久安全事实与撤回生效 | S0 | S2 | +| S2 | 商店浏览、安装和手动更新旅程 | S1 | S3 | +| S3 | 首批目录、源码版与打包版发布验收 | S2、D2;社区资格还需 D3 | 经发布决定交付对应资格的预览 | + +S0 至 S2 使用维护者参考包和 fixture 独立验收。S3 可以收录维护者参考包,但必须经过相同清单和非作者本人评审;不能将其冒称外部作者成果。D3 可以用作者同意验收但不公开发布的包完成,是否公开源码或 wheel 不作为退出条件。D3 尚未完成时,只能交付明确标注“外部作者验证未完成”的技术预览,S3 的社区资格仍待完成。每阶段以可检查结果结案,不按工期百分比宣称完成。 + +## 4 D0 收口公共契约与作者入口 + +**要补什么** + +1. 将当前支持范围、作者快速开始、历史验收分开。Host profile 顶部保留简洁当前契约,历史探针和审计移到独立历史资料并保留原引用的可追溯入口。 +2. 说明应用版本、Host API `0.1`、各贡献 contract `/v0`、SDK `0.1.0` 的不同含义。Host 版本不能直接当作 SDK 兼容范围。 +3. 发布一张来自现有 profile 的支持表,列出接入 owner、选择或绑定方式、生效时机、测试平台和限制。 +4. 修正协议仓“尚未合并”的状态与 Host ROADMAP 中已经完成的安装管理描述;把本计划范围内的精选远程目录、商店式发现移到当前方向,将包签名、自动更新、回滚继续留在 Later。写清 Settings 中仍可访问管理器;本次留档不直接改 ROADMAP。 +5. 明确预览期兼容性:契约变化通过版本、来源 pin、变更说明和迁移方案表达,不悄悄改变已有 contract 的含义。 +6. 在作者入口区分“符合包协议”“当前 Host 能运行”“首版目录收录”三个结论,链接第 16 节的责任划分。新增类型的流程由真实调用方、原生 owner 和合约验收驱动,不承诺修改一个枚举即可开放。 + +**改哪里** + +- Host:`docs/extension_host_profile_v0.1.md`、`examples/extensions/README.md`、`README.md`、`README_ZH.md`、`ROADMAP.md`、`CONTRIBUTING.md`。 +- 拟新增 `docs/extension_authoring.md` 作为唯一作者入口,链接现有规范与示例,不复制全部协议。 +- 协议仓:`README.md`、`sdk/python/README.md`、`spec/extension-protocol-v0.1.md`;跨仓引用优先指向已发布或固定的版本。 +- 如调整规范或 SDK 来源文件,先在协议仓完成,再更新 Host pin;不能为改文案直接编辑受 pin 管理的 Host 副本。 + +**验收标准** + +- 作者从主仓或协议仓首页均能到达同一条当前作者路径,无开发分支链接依赖。 +- 表格中的开放类型与 `profile.json` 相同;明确区分 Schema 合法、Host 接受、运行健康、真实服务资格。 +- 新作者能判断:自己的能力属于哪类、是否需要 SDK、在哪配置和绑定、何时生效。 +- 明确作者静态检查、示例构建与实际 Host 安装所需的不同环境;静态检查不要求完整 Host 依赖、GPU 或角色资源。 +- 本阶段只改变文档,不宣称已完成 D1 至 D3。 + +## 5 D1 整理统一作者校验入口 + +**要补什么** + +先把已有 `tools/extension_package.py` 整理成对外有文档的目标 Host 校验入口,复用 `validate_manifest`、`inspect_wheel`、`validate_config`、`validate_installed`。作者取得固定 Host 源码快照后,可在最小检查环境中使用工具,不必安装完整 Host。首版不要求作者工具成为独立 PyPI 产品。 + +静态检查的直接依赖为 jsonschema、packaging、PyYAML;在 `pyproject.toml` 声明 `extension-authoring` 依赖组,从同一份 `uv.lock` 用 `uv export` 生成带哈希的 `tools/requirements-extension-authoring.txt`,CI 比对导出结果,固定全部传递依赖而不维护第二份锁,使用 `pip --require-hashes` 安装。示例构建另列所需构建工具,实际运行和 GUI 使用 Host 支持的环境,不能把静态检查环境描述为完整执行环境。 + +当前 `inspect_wheel` 按运行解释器的平台和 Python 版本准入。首版目录 CI 固定使用 Windows runner 与 CPython 3.12.10,固定包含 D1 的 Host 校验器 commit、协议 pin 和检查依赖锁,不跟随浮动 main。首次可用 pin 由 D1 完成后记录;后续升级必须重新验证。暂不增加目标环境模拟参数;其他系统上的作者会得到本机准入结果,Windows 资格由固定 CI/实机给出。 + +保留内部 helper 的 JSON/stdin 行为,避免破坏 Electron 调用。需要面向人的结果时增加显式输出选项,默认机器协议保持兼容。输出指出目标 Host/profile、检查范围、失败位置与原因;不输出密钥值。必要的新静态能力放回现有 owner,不在 CLI 重新实现一套规则。 + +分清三层结果: + +- 协议结构与语义:使用协议仓的 schema/validator。 +- 指定 Host 的包、SDK、平台与配置准入:使用 Host 的 `package.py` 和 `profile.py`。 +- 字体可用性、原生图像解码和主题绘制对比度:需要目标 Electron 环境验证,静态 CLI 不代替这层验收。 + +**改哪里** + +- `tools/extension_package.py`:作者输出、帮助、检查范围说明;已有命令优先复用。 +- `server/extensions/package.py`、`profile.py`、`sdk.py`:只补目标 Host 所需的公共检查结果,避免把 Electron 运行环境规则迁入静态验证器。 +- `tests/test_extensions.py`、`test_extension_resources.py`、`test_extension_sdk_build.py`:复用有效和无效包用例。 +- 拟新增 `tests/test_extension_authoring.py`:验证作者入口与 Host helper 对同一输入结论一致。 +- `pyproject.toml` 的 `extension-authoring` 依赖组与 `uv.lock`:唯一解析来源;`tools/requirements-extension-authoring.txt` 为带哈希的派生导出,CI 检查漂移。 +- `docs/extension_authoring.md`:文档化现有命令及新增选项,明确静态检查不执行包。 +- 协议仓只有在发现实际重复规则时才抽取共享静态规则;遵守规范来源与许可归属,不能把 Host AGPL 代码直接改标为 Apache-2.0。 + +**依赖与验收** + +- 依赖 D0 确定的支持范围;不依赖商店或开发目录模式。 +- 对同一 wheel,`check-wheel` 与安装 helper 的静态准入结论一致;`check-package` 只承诺清单与资源检查,不具备 wheel 元数据,必须将入口点、清单/wheel 版本一致性和 SDK 依赖列为未检查,并提示安装前运行 `check-wheel`。 +- 用 SDK 要求不兼容、版本不一致和入口点不匹配的 wheel 验证上述边界:源码可以通过已声明范围,但 wheel 检查必须与安装器一致拒绝;错误的源码目录应提示缺少 `amadeus-extension.json`,不能冒称校验器不可用。 +- 该一致性指相同目标 Host 下的通用包检查;目录首版政策另行判断。一个被当前 Host 正常支持、但因第三方依赖等原因不满足首版目录政策的包,仍应通过原有本地准入,目录拒绝原因须明确来自收录政策。 +- 干净最小环境能完成所有静态检查,不导入模型、音频或 Provider 运行时;锁文件可复现安装。`platforms: ["win32"]` 的有效包在固定 Windows CI 通过,在其他平台报告实际不兼容,不能被通用 Linux 收录任务误拒。 +- 覆盖未知贡献、非空 Host 权限、SDK 不兼容、清单与 wheel 身份不符、错误配置类型、缺失资源、危险归档路径及 `.pth`。 +- 恶意 `__init__.py` 哨兵在静态检查时不执行;网络不被调用;凭据不进入输出。 +- 某项原生检查未运行时,报告该项待目标环境验证,不能给出“整个插件通过认证”。 +- 未新增权限、插件扫描、环境自动安装或隐式 pip 调用。 + +## 6 D2 提供模板与可运行合约检查 + +**要补什么** + +用现有六个示例整理四条作者旅程:TTS、MCP、Skill、主题。保留示例作为可构建包,文档说明必须修改的包 ID、Python distribution、版本、entry point、贡献 ID、许可和配置。第一批用合成音频和本地服务验证,付费服务资格单独记录。 + +在 Host 的作者工具旁提供一个显式运行的合约检查脚本,拟名 `tools/check_extension_contract.py`。静态验证与执行检查是不同命令;执行检查会启动所选择的可信测试包,应在独立临时环境中运行,不借用用户正在使用的配置或 Agent 会话。 + +| 类型 | 最小可执行检查 | 必须验证的失败结果 | +| --- | --- | --- | +| TTS | initialize/ready、清单注册一致、一次流式合成、取消、关闭 | 错误握手、未声明语言、非法音频、超时、取消后迟到音频;失败请求不重放 | +| MCP | 本地 stdio 或受控 HTTP 服务发现、一次合成数据调用、关闭 | 凭据不能进入 argv/URL、错误 Provider 绑定、启动失败;不回退到 Host 全量环境 | +| Skill | 清单与资源有效、指定 Agent 发现、一次资源读取、会话恢复 | 资源缺失或遮蔽、修改/移除已记录修订后拒绝旧会话;新增无关 Skill 不误伤旧会话 | +| 主题 | 静态包检查,目标 Electron 解码、绘制和切换 | 无效颜色/图片、对比度不足、更新失败、内置主题恢复;纯主题不启动 Runner | + +报告分开列明静态、合约进程、Agent 原生、GUI、远程真实服务各层结果。合约工具不模拟一个“万能 Host”,也不以受控模型证明真实模型选择质量。 + +**改哪里** + +- `examples/extensions/` 六个现有示例及 README;规范侧示例继续作为清单参考,避免两边都维护相同完整实现。 +- 拟新增 `tools/check_extension_contract.py` 与对应工具回归;复用生产 Runner/MCP/Skill 接口及现有 fixture,避免将测试私有实现变成公共 SDK。 +- `tests/test_extensions.py`、`test_extension_examples.py`、`test_extension_fish_audio.py`、`test_extension_mcp.py`、`test_extension_skills.py`。 +- `tests/test_skill_codex_runtime.py`、`test_skill_pi_runtime.py` 与 `tests/test_pi_mcp.py`:原生适配器资格。 +- Pi/MCP 相关命令在包含 #234 的干净目标主线运行,连同该主线的精确调用授权回归一起记录;不能复用当前工作区结果作为这条资格。 +- `tools/probes/extension_theme_lifecycle.py`、`electron/tests/extensionsInstall.smoke.mjs`、`extensionThemeNative.smoke.mjs`、`extensionSettings.smoke.mjs`:安装与主题实机层。 +- `.github/workflows/` 现有 Python/Electron 工作流:加入最小离线作者旅程,不让每个静态检查依赖完整 GUI。 + +**验收标准** + +- 每类至少一个参考包在独立环境完成构建、静态校验和对应最小合约检查。 +- TTS 示例使用交付给用户的固定 SDK wheel,不依赖另一棵未发布源码。 +- 至少覆盖一条 MCP 与一条 Skill 的真实 Provider 路径;其他 Provider 按各自已有测试和限制列出,不统称全部认证。 +- 安装、更新、卸载测试复用现有 installer/store;新工具不能仅通过绕过安装器的内存 fixture 宣称端到端成功。 +- 错误用例使工具以非零状态退出;报告可定位哪个阶段失败。 + +**诊断日志的处理** + +当前 `RunnerClient._stderr` 只排空 stderr。D2 先提供握手阶段、状态、退出码和超时等受控诊断,不承诺保留任意插件日志。若 D3 作者因诊断不足无法完成旅程,再单独补默认关闭、短期、有大小限制的日志功能;必须明确敏感数据边界、删除方式和测试。通用目录挂载与自动重载继续延后。 + +## 7 D3 完成外部作者验收 + +**目标** + +至少两位未参与底座实现的作者,各自在独立源码仓完成一个插件。其中至少一个使用进程能力,另一个使用资源能力;两者合计覆盖至少两类贡献。优先按第 3 节目录范围选型,便于以后收录,但不要求作者公开发布包。四类参考模板均须完成 D2,不要求首轮四类都有外部作者。 + +**旅程** + +1. 作者只从公开作者入口开始,创建自己的身份和包,不修改 Amadeus 核心。 +2. 构建 wheel,运行校验与合约检查,安装到独立用户配置。 +3. 配置、启用、选择或绑定,完成一次有意义的调用。 +4. 发布本地第二版本,验证更新披露、生效时机和既有配置保留。 +5. 停用、卸载、重装,分别验证默认保留数据和明确删除数据。 +6. 记录文档缺口、兼容性问题、诊断困难和实际所需依赖。 + +**改哪里与依赖** + +- 依赖 D2。作者源码保留在作者自己的仓库;贡献不默认转移进 Host。 +- `docs/extension_authoring.md`、`examples/extensions/README.md` 按实际反馈修正。 +- 拟新增 `docs/verification/extensions/community-preview.md`,记录作者同意公开的包、Host/SDK 版本、平台、命令、成功和失败结果。 +- 必要修复回到对应 owner,不能为了某个包增加永久 ID 特例或扩大 Host 权限。 +- 邀请、公开消息与作者包发布属于后续明确授权的协作动作,本计划不自动执行。 + +**退出条件** + +- 两个外部包完成上述全流程;只有复制模板或能启动不算完成。 +- 作者可以只授权本地验收和脱敏结论记录;公开源码、wheel 或加入目录需另获作者同意。维护者参考包不能充当这两个外部成果。 +- 没有依赖未记录的本地补丁、维护者手动改配置、个人绝对路径或未发布 SDK。 +- 关键阻塞已经修复并回归;剩余限制进入作者说明。 +- “社区开发预览”仅承诺已验证子集,稳定 SDK 和新贡献类型继续另行评审。 + +## 8 S0 确定精选目录与收录规则 + +**依赖与发布位置** + +依赖 D1,不依赖 D3。先使用已有参考包和测试 fixture 验收。目录源放在 Amadeus-Extensions 的 `catalog/`,首版选择受保护 main 上的 `catalog/v1/index.json`,通过官方 raw HTTPS 地址分发,不另外部署 Pages 或 Release 索引资产。 + +目录输入先经审核合入 main。发布 PR 选定一个已合入的输入 commit,固定生成器和 Host 校验器版本,生成索引并提交审核。生成时间取该输入 commit 的时间,不读取构建机器当前时间;相同输入和工具版本生成相同字节。索引记录输入 commit,避免把包含生成文件自身的 commit 当作输入造成循环。 + +发布序号是版本库中的显式递增整数,每次发布相对当前 main 已发布索引加一,重复构建不增加。发布 PR 合并前重新验证序号和当前状态;并行 PR 发生冲突必须更新后重审。回退目录内容也使用更大的序号,并保留既有封禁或显式解除记录,不能恢复旧序号文件。不用时间戳或 Actions 运行次数代替发布序号,不允许发布机器人绕过分支保护直接推 main。 + +**条目输入与生成结果** + +| 维护方式 | 信息 | 规则 | +| --- | --- | --- | +| 手工提交并审核 | wheel 精确 URL、SHA-256 | 每一份待审核产物有确定字节;初始源可使用 GitHub Release 的具体资产 | +| 手工提交并审核 | 发布者对包 ID 的归属证明、维护权转移记录 | author 文本和 PR 账号不能单独自证所有权;支持组织与代理提交 | +| 手工提交并审核 | 审核人、清单版本、日期、证据及绑定的产物摘要 | 每个版本重新审核,不能继承前一版本的资格 | +| 手工提交并审核 | 撤回原因、安全封禁或显式解除决定 | 区分维护者撤回、版权/素材授权下架和安全封禁 | +| 从 wheel 生成 | 包 ID、版本、作者、许可、主页、说明、源码仓 | 读取 manifest 和 metadata;不要求提交者再填一份 | +| 从 wheel 与固定检查环境生成 | Host API、平台、Python、SDK、依赖、贡献与启动声明、文件大小 | 复用 D1 检查,不创建可漂移的第二份清单 | +| 从发布输入生成 | 格式版本、发布序号、输入 commit 及其时间、校验器 pin | 使发布可追溯、可重建 | + +包清单不新增商店字段。生成器校验人工事实与产物身份的绑定,但不再要求作者手写能从产物推导出的元数据。客户端以安装记录已有的 `id + version + digest` 查询目录并投影审核状态,不新增持久化分发来源或信誉等级。 + +目录格式使用可独立解析的展示与兼容性摘要;其中贡献的 kind、role、contract 等来自实际产物,但读取目录不调用客户端的完整插件 manifest 校验器。新增贡献值不应迫使目录格式升级。哪些可选信息字段可忽略、哪些字段决定安装资格和安全语义,由 `catalog/schema.json` 与说明明确规定;旧端不能理解的必需语义必须阻止相应安装,不能当作可忽略展示字段。格式与条目兼容规则见第 16 节。 + +**公开审核清单** + +拟交付 `catalog/REVIEW_CHECKLIST.md`。审核对象必须是目录 SHA-256 对应的 wheel 解包内容;源码仓、构建说明和源码对照是辅助证据,不能替代产物审核。目录中的“已审核”表示该具体产物按某版清单完成检查,不是安全保证,也不扩大权限。 + +| 检查项 | 自动检查与人工检查 | +| --- | --- | +| 产物身份 | 校验 SHA-256、包 ID、版本、metadata、entry point;保存文件清单。版本仅为无前导零的 `X.Y.Z`,清单与 metadata 字符串一致;同一 ID/版本不可替换字节 | +| wheel 格式与内容 | 文件名和 `.dist-info/WHEEL` 均须为 `py3-none-any`,并声明 purelib;拒绝 `.pyd`、`.so`、`.dll`、`.exe`、`.dylib` 等原生载荷和已知可执行二进制。检查异常内容/签名与源码可审查性,不能只信标签或扩展名;主题图片等合法资源按原契约检查 | +| 静态 Host 准入 | 使用固定 Windows/CPython/Host pin,检查支持类型、配置、SDK、路径、资源及 `.pth`;保留正反 fixture | +| 依赖 | 目标环境有效依赖为空或仅为兼容 Host SDK;不含第三方运行依赖、直接 URL、源码构建或隐式安装步骤 | +| 启动命令 | MCP stdio 只允许 `${python}` 或 `${package}/` 内已审核脚本。`${python}` 的模块必须属于包,脚本必须位于包内;拒绝 `-c`、`-m pip`、任意配置值充当可执行目标,以及 npx/uvx 等运行时获取代码的入口。包脚本须证明在声明平台可执行,Windows 优先使用 `${python}` 加包内模块/`.py` | +| 运行内容 | 审核入口、脚本、Skill 指令和网络使用;拒绝下载后执行代码、隐藏安装器或绕过声明的命令。允许声明的远程服务调用,但网络响应不能被当作代码执行;人工审核不是 OS 沙箱 | +| 权限与数据 | 核对实际文件/网络行为与披露、凭据处理、数据保留和删除;不授予 Main Chat 或新 Host 权限 | +| 素材与许可 | 代码许可与包内图片、音频及其他素材授权分别可核对;记录来源、许可或作者授权。当前主题只引用已安装字体,审核不代表开放字体分发;以后如包含字体等素材仍需单独证明授权 | +| 生命周期证据 | 完成该类型所需的安装、调用、更新、取消/关闭和卸载检查,注明环境与未验证范围 | +| 版本差异 | `check_catalog.py` 对比上一已审核版本,输出新增/删除/修改文件、文本差异、二进制摘要与清单/依赖/命令差异;每个版本仍完整过清单,差异只用于辅助审查 | + +维护者自己的参考包也需遵循同一清单,并由非作者本人评审。初始包没有前一版本时输出完整清单;未知格式或无法审查的内容不给“已审核”状态。 + +**信任与网络规则** + +- 索引只认一个内置官方 HTTPS 来源。首版不做签名,因此信任根落在官方 GitHub 仓库及发布权限;S3 必须落实对应治理条件。索引镜像要等签名和密钥管理完成后再开放。 +- 允许用户在 Host 设置中配置产物镜像。镜像只替换下载位置,不能提供新的身份、摘要或审核事实;产物仍与官方索引逐字节校验。 +- 下载与重定向不硬编码 GitHub CDN 域名;逐跳限定 HTTPS、无 URL 用户名/密码、重定向次数、超时和体积。不携带应用会话 cookie/凭据,不允许 renderer 提交任意可信下载地址。 +- 静态检查可以处理未信任 PR,但生成器、工作流和 Host 校验器来自固定可信版本。合约执行只在维护者批准的隔离、无凭据环境中进行,不使用有发布或仓库写权限的任务执行投稿代码。 +- 维护者撤回和版权下架停止目录新安装与更新推荐;安全封禁还阻止已知产物后续激活。版权下架不自动变成安全封禁,也不静默删除用户已有内容。 + +**改哪里** + +- 协议仓拟新增 `catalog/packages/`、`catalog/schema.json`、`catalog/README.md`、`catalog/REVIEW_CHECKLIST.md`、`catalog/v1/index.json`、`tools/check_catalog.py`、`tools/build_catalog.py` 及对应测试。 +- 固定 Host 校验器 commit 和 D1 的最小依赖锁;复用协议 validator、规范 fixture 和指定 Host 入口,目录收录规则保持独立于一般本地安装准入。 +- 首版纯 Python、版本格式、依赖和命令限制集中在目录收录政策与公开清单中,不写成所有插件的永久运行条件。客户端需要复核的目录交付限制使用同一政策定义/一致性 fixture,不能在 renderer、Runner 和 Provider 各维护一份允许列表。 +- `.github/workflows/validate.yml` 增加 Windows 收录检查、wheel 内容差异和索引重建比对;发布 PR 使用受保护 main 的审核流程。 +- Host 侧此阶段只确定消费契约,不增加用户可见商店入口。 + +**验收标准** + +- 合法主题、Skill、标准库 MCP 和 SDK TTS 参考包可以生成目录;`platforms: ["win32"]` 的包不被 Linux 环境误拒。 +- 错摘要、内部身份不一致、错误 wheel 标签、原生载荷、非 `X.Y.Z` 版本、归属不明、未审核的新版本、额外依赖和下载执行命令不能收录。 +- 元数据均从实际产物生成,修改 manifest 后不需要维护另一份重复描述。审查明确覆盖实际 wheel 与上一版本的差异。 +- 同一输入和工具 pin 重建得到相同索引;并发发布、重复序号、旧序号和同版本换字节被拒绝,回退内容仍保持发布序号递增。 +- 发布流程不能执行未审核投稿代码;维护者参考包不能绕过审核。版权下架、普通撤回、安全封禁及解除均有 fixture 供 S1 使用。 +- 用“当前兼容包 + 未知未来类型 + 已知类型的新 contract + 混合贡献包”的目录 fixture 验证第 16 节行为。生成器仍只发布其固定 Host 已验证的生产类型;未来 fixture 不修改生产 profile,也不作为真实新类型已获支持的证据。 + +## 9 S1 将可信目录接入现有安装器 + +**责任分配** + +Electron main 获取、校验目录,持久提交安全事实后更新展示缓存,下载 wheel,并把可信的预期身份和摘要交给现有安装器。Renderer 只提交包 ID、具体版本和用户动作,不提交可信 URL、等级或期望摘要。Python 后端继续消费 Host 已校验的启动快照,不增加第二个联网目录客户端。 + +沿用 `StoredExtension.id/version/digest` 匹配目录,动态得出“目录收录”“已审核”“已撤回”等展示结果。不新增安装来源或等级字段,也不设置本地包转商店包的接管流程。同一份字节经过两个入口安装应有相同身份与准入结果;本次操作的实际下载地址可用于确认披露,不作为长期信任依据。 + +**拟新增与修改位置** + +| 位置 | 修改职责 | +| --- | --- | +| 拟新增 `electron/src/main/extensionCatalog.ts` | 获取目录、格式校验、逐条兼容性判断、审核投影与展示缓存;通过现有 store 提交安全事实 | +| 拟新增 `electron/src/main/extensionDownloads.ts` | 使用 Electron net 的有界下载、镜像、取消和失败清理;不拥有安装或配置状态 | +| `electron/src/main/extensions.ts` | `install` 接收可选可信交付元数据;复用复制、inspect、确认、准备、提交与清理;在启动及前端准入检查已知封禁 | +| `electron/src/main/desktopSettings.ts` | 复用 fsync、原子替换及 `.bak`,保存已知封禁与已接受最大发布序号;补安全事实的损坏/恢复边界,兼容旧设置 | +| `electron/src/shared/extensions.ts` | 商店摘要、动态审核/安全状态与操作结果类型;不增加持久化分发来源或传递凭据 | +| `electron/src/main/index.ts`、`preload/index.mts`、`renderer/env.d.ts` | 受限目录查询、安装、取消和安全刷新 IPC;主进程校验外链后打开浏览器 | +| 现有配置目录、设置存储及界面 | 产物镜像设置复用现有设置体系,不引入第二份配置文件;不提供索引镜像设置 | +| `server/extensions/package.py`、`sdk.py`、`runtime.py` | 静态依赖分类复用 packaging 与当前 marker 语义;启动拒绝仍由已有 owner 处理 | +| 拟新增 `electron/tests/extensionCatalog.test.mjs`、`extensionDownloads.test.mjs` | 目录、下载和可信输入边界 | +| `electron/tests/extensions.test.mjs`、`desktopSettingsDurability.test.mjs` 及作者工具测试 | 两入口一致性、依赖分支、断电恢复、更新与撤回集成 | + +新增文件名是建议落点,开工时可以按实际职责收敛;已有 installer/store 仍是唯一安装与状态 owner。 + +**必须保持的行为** + +1. 下载使用 Electron `net`/Chromium 网络栈,继承受支持的系统代理与证书验证,不关闭证书检查。使用不携带应用会话凭据的请求;每次重定向均检查 HTTPS 和无 URL 凭据,限制跳转数、超时和体积。不要依赖 `net.fetch` 的 `integrity` 选项,它不会执行该校验;摘要由后续安装器显式验证。系统代理、证书拒绝和镜像分别验收。 +2. 下载在 Host 管理的暂存目录完成,有取消和失败清理;异常不提交记录,也不进入 pip。wheel 上限复用当前检查边界,不创建无限缓存。镜像只提供字节;地址与目录摘要的绑定由 main 处理。 +3. 安装器先复制为受控字节,再用现有检查得到摘要和清单;与目录的预期包 ID、版本、摘要逐一比较。只在下载层算一次 hash 不足以建立安装事务的身份。 +4. 目录准入、依赖限制和已知封禁在安装与提交前重新核对。用户等待确认期间发生目录变化或包状态变化,不能提交已经失效的授权对象。 +5. 按 wheel 内容选择准备方式,而非按入口选择。`runtime.kind=none` 继续直接解包;Python wheel 的目标环境有效 `Requires-Dist` 为空或仅为兼容 SDK 时,两个入口均在准备固定 SDK 后用 `--no-index --no-deps --only-binary=:all:` 安装。仅本地入口允许带第三方依赖的包进入现有依赖解析分支。依赖分类在静态检查 owner 完成,兼容环境 marker,不在 Electron 另写一套解析规则。 +6. 旧安装记录不做来源迁移;以现有三元组精确匹配目录。目录无匹配时显示“未收录”,目录不可用或缓存过期则如实说明审核状态未知/截至上次检查,不能宣称当前安全。持久封禁始终独立检查,缺目录不能解封。 +7. 更新继续调用相同安装事务;新包准备失败保留旧版。配置保留、混合包重启和 Skill 旧会话失效规则不变。不额外按本地/商店身份建立更新所有权。 +8. 不承诺启动失败自动回滚。旧后端退出后新版本不能启动时,保留明确错误与管理入口,允许停用、卸载或安装允许的修复版本;降级还需作者说明数据兼容性。 +9. 目录可展示不等于安装可执行。main 在安装入口重新检查本机对包内全部贡献及其 contract、交付方式和依赖的支持,随后对实际 wheel 做既有完整准入;renderer 绕过禁用按钮也不能安装。不认识的贡献不被删除、重写或自动映射到 TTS Runner,混合包不能只安装其中“认识的部分”。 + +**安全事实持久化与恢复** + +- 已知封禁和已接受的最大发布序号属于 Host 耐久事实,放进现有设置存储;目录缓存只负责展示,不承担防回退或启动授权。二者在同一次持久提交中更新,提交成功后才公开新目录结果;失败保留旧事实,不能只更新序号或先宣称刷新成功。 +- 复用现有主文件、`.bak`、fsync 与原子替换。恢复安全事实时识别主副本中最高完整序号对应的安全快照,不能仅因主文件可解析而忽略副本中更高的已持久事实;同序号但内容冲突视为不可用,不逐字段拼凑。普通设置与凭据保持现有恢复职责。 +- 旧版有效设置中不存在安全字段,按尚未取得目录状态迁移;字段存在但损坏、主副本均被 NUL 填满/截断、或已接受的安全事实无法恢复,不能等同于空封禁。此时暂停新的扩展安装和激活,关闭前端扩展准入,已运行后端按既有停止边界提示处理;保留内置功能、卸载和恢复入口。 +- 当前 `read()` 在无法解析且无可用副本时会隔离文件并返回空设置。S1 必须让安全事实的不可恢复状态持续可见,不能被下一次普通设置保存或重启清空;保留损坏证据,普通设置恢复不能静默宣布插件已解封。修复使用可恢复副本或显式重新取得官方安全状态,记录这是恢复操作,不承诺抵抗用户主动删除全部本地状态。 +- 低于已接受序号的索引拒绝;相同序号只允许一致的安全事实和展示缓存重建;更高序号才能改变安全决定。封禁不因目录缺项、缓存删除、普通卸载/重装或网络失败消失,解除必须是更高序号中的明确决定。 +- 条目兼容性不能改变安全事实的解析:未知贡献类型的条目可标为不兼容,但同一索引中有效的封禁/解除仍须按既有事务处理。目录格式主版本或安全语义无法理解、身份冲突或安全结构损坏时拒绝整份新快照,保留旧安全事实和最高序号,不把局部兼容处理当作忽略安全错误的理由。 + +**联网覆盖与撤回处理** + +- 打开“浏览”、显式刷新,或在“已安装”视图点击“检查更新与安全状态”时联网;首版不自动在启动时检查。显示最后成功检查时间和失败原因,从不检查的用户无法获知新的远程封禁,这项取舍在第 14 节明确保留。 +- 网络失败使用已存安全事实;展示缓存缺失/损坏不改变封禁或序号。安全存储有效时,本地安装和已有插件管理可离线使用;无目录时浏览不可用。安全存储本身不可恢复时按上一小节处理,不能以离线模式绕过。 +- 普通撤回或版权下架阻止目录新安装与更新推荐,不自动停用或删除已安装版本;它们不是本地执行封禁。安全封禁按包 ID、版本和摘要识别,同时覆盖本地与商店入口。 +- 安全封禁阻止下一次后端激活,并立即关闭对应前端贡献;已运行后端明确显示需要重启/停止,遵循已有进程退出与引用清理边界,不假报已经停止。 +- 处理时同时检查期望版本和实际加载版本。例如已保存安全 v2、仍运行封禁 v1,仍须显示旧后端待停止;已运行安全 v1、待启用封禁 v2,则拒绝 v2 激活。 +- 封禁不静默删除包数据、凭据或用户启用意图;UI 显示政策阻止原因。继续复用启动拒绝与前端准入 owner,不引入通用热协调协议。 + +**验收标准** + +- 正确包完成下载、校验、确认与安装;同一文件走本地/商店入口时准备方式、身份、审核投影及生命周期结果一致。无依赖/仅 SDK 的本地包也不访问 pip 索引。 +- 摘要不符、身份不符、超限、断网、取消、无权限、错误重定向、不支持的依赖和 SDK 版本均不留下已安装成功状态;合法第三方依赖仅从本地入口走现有解析路径。 +- 系统代理、正常证书验证、无效证书拒绝、镜像正确字节/错误字节、HTTPS 重定向/降级及请求凭据隔离有明确测试,不依赖某个 CDN 主机名。 +- 模拟下载后替换字节、确认期间封禁、并发更新/卸载,拒绝过期对象且不删除仍被引用的环境。 +- Renderer 伪造 URL、等级、来源或另一版本不能绕过 main 的解析和授权。 +- 旧设置兼容且不添加来源字段;配置/凭据/绑定不丢失。缓存损坏或删除不改变封禁;安全记录主损坏、副损坏、双损坏、NUL、截断、写入两文件之间失败、更高序号只存在副本、重启及普通设置保存均不能静默解封。 +- 首次取得目录、旧版有效记录迁移、同序号冲突、旧索引重放、显式恢复、离线、普通撤回、版权下架、安全封禁、解除及 desired/applied 版本不同的情况都有正反测试。 +- 普通卸载/重装、从本地选择相同已知封禁产物不能绕过;不承诺识别修改字节后产生的未知产物,首版封禁范围明确为三元组。 +- 本地安装和安装后保持禁用的默认行为不因商店接入改变。 +- 第 16 节的旧端兼容矩阵全部通过:未知类型/contract 只阻止对应包,兼容条目仍可浏览和安装;混合包整包拒绝且没有部分提交;未知类型出现时合法安全更新照常持久化。未知目录格式主版本或损坏安全结构不提交新序号、不覆盖旧事实。 + +## 10 S2 完成商店用户旅程 + +**界面范围** + +在现有插件管理器中增加“已安装”和“浏览”视图。首版提供名称/能力搜索、类型筛选、详情、版本兼容原因、安装、下载进度与取消、手动刷新和手动更新。“已安装”视图提供“检查更新与安全状态”,显示最后成功检查时间;用户无需进入浏览页才能取得安全状态。不新增独立插件管理页,不要求恢复侧边栏 Plugins 才能使用。 + +详情显示维护者、许可、产物地址、审核范围、所需平台/运行环境、外部服务或命令、下载体积及生效方式。审核信息按具体产物匹配,并带上目录检查时间。下载体积只包含实际可核对的 wheel/交付内容,不能冒称包含外部服务或未安装运行时。 + +目录说明、作者名和其他第三方文本首版只按纯文本显示,不渲染 HTML/Markdown,也不加载远程图片。主页和源码链接由主进程确认是无 URL 凭据的 HTTPS 后,在用户点击时交给外部浏览器;不能直接将目录字符串传给 shell 或导航当前窗口。素材授权下架与安全封禁使用不同原因文案。 + +安装前沿用现有原生披露;安装成功后引导用户到配置、启用、Agent 绑定、Voice 或 Appearance。显示“已下载”“已保存”“待重启”“实际生效”“被阻止”的真实区别。审核标记不替代安装与执行确认。 + +**改哪里** + +- `electron/src/renderer/components/ExtensionSettings.tsx`:复用列表、编辑与操作反馈。 +- 拟新增 `ExtensionCatalog.tsx`:浏览与详情;组件只消费受限 main 返回的数据。 +- `extensionSettingsCatalog.ts`、`SettingsPage.tsx`:兼容性呈现、导航与草稿保护。 +- `electron/src/shared/extensions.ts`、`renderer/env.d.ts`、`preload/index.mts`:界面所需公开数据和方法。 +- `electron/src/renderer/locales/zh-CN.json`:完整中文文案;沿用现有英文来源。 +- `electron/tests/extensionSettings.test.mjs` 和实际组件/导航 smoke:加入浏览、取消、失败与更新旅程。 + +**验收标准** + +- 用户可在一个管理器中发现、安装、配置、启用、使用、更新、停用和卸载一个包。 +- 无兼容版本说明具体原因;未知类型、SDK 不兼容和平台限制不能只显示为通用“安装失败”。 +- 同一目录存在未来类型时,显示可安全读取的名称、类型与不兼容原因,关闭该版本的安装/启用入口;兼容条目的搜索、筛选、详情与安装不受影响。新版本不兼容时保留已有正常版本,不推荐该更新;若仍有允许安装的较新兼容版本,可推荐该版本。 +- 有更新仅代表存在可安装的较新 `X.Y.Z` 版本,按三个非负整数比较,不按字符串排序。非目录格式的已有本地版本继续可管理,但不猜测与目录版本的升级关系;用户可查看具体候选并走显式安装确认。首版不提供预发布规则或开关。 +- 商店刷新失败不清空已安装列表,不丢未保存配置,不把旧成功提示留在新失败操作之后。 +- 双击安装、切换视图、取消下载、更新失败、后端离线都不会误报完成。 +- 新装、更新、审核状态变化、版权下架、封禁及安全状态不可用提示覆盖中英文、窄窗口和键盘操作。 +- 含 HTML、脚本、Markdown 图片和危险链接的目录文本只能呈现为普通文字;远程图片不发请求,非 HTTPS/带凭据外链在 main 被拒绝。 +- 不进入浏览页的用户可以在“已安装”检查安全状态;离线和安全存储损坏显示不同原因,不能把“未检查”显示成“安全”。 +- 纯主题即时生效、混合包待重启、卸载数据选择、主题原生恢复仍通过现有旅程。 + +## 11 S3 首批目录和发布验收 + +**交付物** + +- 一份受版本管理的精选目录,至少含一个可实际使用的进程包和一个资源包;维护者参考包可按相同清单由其他维护者审核后收录,四类参考包资格各自可查。 +- D3 的外部作者证据与公开目录条目分别记录。作者不愿公开发布时,可以使用其同意的脱敏验收证据,并以已审核参考包组成首批目录;不强制转让源码或发布权。 +- 社区作者文档、收录指南、兼容性表、已知限制和维护者撤回操作说明。 +- 源码发行与打包桌面均包含所需 SDK、目录消费代码和兼容存储;默认不因启动而访问商店。 +- 有问题时可停止目录安装或撤回具体版本,已有本地管理功能仍能使用。 + +**改哪里** + +- `docs/extension_authoring.md`、`docs/extension_host_profile_v0.1.md`。 +- 拟新增 `docs/extension_catalog.md` 和 `docs/verification/extensions/store-v1.md`。 +- `README.md`、`README_ZH.md`、`ROADMAP.md` 及该版 release note。 +- `release/source_release_policy.json`、`tools/build_source_release.py`、`electron/package.json`:仅补必要发行资源及包内检查。 +- 现有 Python/Electron、源码发行工作流;目录仓的索引发布与撤回演练。 + +**发布退出条件** + +1. 干净 Windows 环境分别从源码版和打包版完成一次全过程;不复用开发者已安装 wheel 或隐藏缓存。使用包含 #234 的固定目标主线,并重新取得 Pi/MCP 资格。 +2. 实际下载已审核产物并完成至少一次资源插件和一次进程插件使用;付费服务另行授权,其结果不替代离线基线。 +3. 验证旧版应用设置升级、商店离线、系统代理与产物镜像、下载失败、取消、错摘要、版权下架、封禁版本、安全记录断电/双副本损坏、更新后启动失败、数据保留与显式删除。 +4. 每条证据记录 Host commit、构建产物摘要、目录 revision、插件版本与摘要、SDK pin、平台和使用方式;自动化驱动确认与人工原生确认分开。 +5. 维护者可通过目录 PR 撤回测试版本;客户端刷新后准确反映,离线时保留已知封禁。回滚目录发布不能无声抹去已知封禁。 +6. 当前目标主线的必要 CI 与作用域回归通过,GUI 截图和失败证据可审查。跳过项明确列出,不能当作通过。 +7. 发布前确认目录维护责任、审核范围、问题接收与撤回流程。公开发布是独立动作,不能由测试通过自动触发。 +8. 官方索引所在仓库的 main 已启用保护,合并要求非作者评审和必要检查,禁止普通直接推送与强制推送;维护者启用两步验证,工作流 token/发布步骤使用最小权限,执行投稿代码的任务没有写权限或发布凭据。记录实际配置核对结果,不能只在文档中建议。 +9. D3 完成后才能结案为经过外部作者验证的社区预览。D3 尚未完成而 S2/D2 与技术发布检查均完成时,可另作技术预览发布决定,明确未完成资格;不能把维护者包或测试 fixture 替代 D3。 +10. 完成第 16 节的政策分离、原生 owner 与旧端兼容验收。首版以固定 reader 和合成未来目录验证;后续扩展以实际上一受支持客户端复验,不能只用修改后的新 reader 测试。新目录类型不得中断旧端兼容包管理或让其漏掉可理解的安全更新。 + +## 12 建议的 PR 切分 + +| PR | 主要内容 | 前置与完成标志 | +| --- | --- | --- | +| D0 文档收口 | Host 与协议仓各自文档更新,当前支持表和作者入口 | 独立可读、互相引用已合入版本 | +| D1 作者校验 | 现有 CLI 对外化、结果边界与一致性回归 | D0;不改变内部 helper 默认协议 | +| D2 合约与模板 | 四类示例旅程、显式执行检查、CI | D1;离线正反用例完成 | +| D3 作者反馈 | 作者问题的最小修复与验收记录 | D2;两个外部包完整旅程 | +| S0 目录规范 | 索引发布、公开审核清单、产物差异与固定 Windows 检查 | D1;与 D2/D3 并行,以参考包和 fixture 验收 | +| S1a 下载和安装 | 目录客户端、net/镜像、可信身份、按依赖选择准备方式 | S0;复用现有安装事务,无来源迁移 | +| S1b 撤回与启动准入 | 持久安全事实、断电恢复、实际/期望版本处理 | S1a;损坏、失败与离线语义完成 | +| S2 商店界面 | 浏览、详情、安装进度和手动更新 | 完整 S1;组件与真实 GUI 旅程 | +| S3 发行收口 | 真实目录、打包、升级与仓库治理验收 | S2 与 D2;社区资格需 D3,作者公开发布非前置 | + +S1a、S1b 可以分别评审,但 S1b 未完成前不得向普通用户开放商店安装。协议变更先合入规范仓,再更新 Host pin 和必要副本;不要形成跨多个未合并功能的长期 PR 栈。若仅文档更新,不应无意义重建未变化的 SDK。 + +## 13 验证入口与证据规则 + +以下是当前已经存在的验证入口,供各阶段复用。未来阶段新增的文件与选项必须另列,不能把计划名称当成已经可执行的命令。 + +从受支持的完整测试环境、固定目标主线仓库根目录运行以下回归;这不是 D1 的最小静态作者环境: + +```powershell +python tools/check_extension_protocol.py +node --experimental-strip-types electron/scripts/check-extension-sdk.mjs +node electron/scripts/generate-extension-profile.mjs --check +python -m pytest -q tests/test_extensions.py tests/test_extension_resources.py tests/test_extension_mcp.py tests/test_extension_skills.py tests/test_extension_examples.py tests/test_extension_fish_audio.py tests/test_extension_protocol_provenance.py tests/test_extension_sdk_build.py +``` + +在 `electron/` 运行: + +```powershell +node --experimental-strip-types --test tests/extensions.test.mjs tests/extensionSettings.test.mjs tests/extensionTheme.test.mjs +npm run build +``` + +按修改范围增加原生 Agent、MCP、GUI、打包和发行检查;完整合并检查遵循 CONTRIBUTING 与当时 CI,不以以上小集合替代。涉及 Windows junction、符号链接或进程 Job 的测试,记录执行环境限制,不能通过放宽生产路径检查来消除测试权限问题。 + +Pi/MCP 资格在包含 #234 的目标主线上至少覆盖 `tests/test_pi_mcp.py`、`tests/test_pi_scoped_approvals.py` 及对应 Provider 权限回归,连同原生 Agent 的真实发现/调用证据记录。新增目录检查固定 Windows、CPython 3.12.10、Host commit 与哈希锁;不把 CI 自己的平台误作其他目标的资格。 + +2026-10-21 前一轮核查在工作区完成了上述 Python 作用域测试的 **112 通过、2 跳过**,Electron 三组测试 **89 通过**,协议副本、SDK 摘要和 profile 生成检查通过。两项跳过分别为 POSIX 执行权限和 Windows 符号链接权限。这组命令未包含 Pi/MCP 主线资格,且工作区缺少 #234;结果只作为已有插件局部基线参考,不是新阶段交付、完整主线或新 GUI 验收。本次计划修订没有重跑这些测试。 + +网络与依赖实现的参考依据:[Electron net 文档](https://www.electronjs.org/docs/latest/api/net) 和 [pip 哈希安装文档](https://pip.pypa.io/en/stable/topics/secure-installs/)。实施时仍须核对项目固定版本,尤其不能以 `net.fetch` 的 `integrity` 选项替代 SHA-256 检查。 + +## 14 设计决定与实施前核对 + +| 决定 | 本计划采用的方案 | 实施核对与限制 | +| --- | --- | --- | +| 开发依赖 | D1 后分作者线和商店线;S0 至 S2 不等 D3 | D3 未完成只能交付相应技术预览,不能声称外部作者验证完成 | +| 首批目录来源 | 允许维护者参考包;同清单、非作者本人审核 | 不强制外部作者公开发布,不以参考包替代 D3 | +| 索引承载与序号 | 官方仓受保护 main 的 raw `catalog/v1/index.json`,发布 PR 递增序号 | 固定输入 commit/工具,提交时间用于重建;回退内容不回退序号 | +| 无签名的信任根 | 官方 GitHub 仓库与发布权限 | S3 核实保护、评审、两步验证和最小权限;不承诺仓库失陷后的独立真实性 | +| 产物镜像 | Host 设置可配置 HTTPS 镜像,仅下载字节 | 索引仍只认官方;逐跳检查、无请求凭据、安装器验证官方摘要 | +| 首批包规则 | `py3-none-any`、可审查源码/资源、`X.Y.Z`、无第三方依赖或仅 SDK | 当前仅为目录收录限制;不顺带收紧一般本地包协议 | +| 政策与契约 | 收录政策、Host 准入、原生运行契约各由对应 owner 维护 | 调整目录政策不能自动开放 Host 类型,新增类型也不必重写安装事务 | +| 新类型与旧端 | 目录格式兼容时逐包判断类型、contract 和交付能力 | 不支持包整包禁止安装,兼容条目继续可用;格式/安全结构不兼容拒绝新快照 | +| 审核与素材 | 针对 wheel 的公开清单,每版本重新审核 | 覆盖脚本/Skill 内容、运行时获取代码、素材授权;徽标不授予权限 | +| 安装方式 | 由包依赖决定,两个入口共享 | 资源包直接解包;无依赖/仅 SDK 离线;第三方依赖仅本地解析 | +| 展示状态 | 按已有 ID/版本/摘要查询目录 | 不持久化来源或等级;未知/过时状态不等于安全 | +| 撤回与封禁 | 版权/普通撤回停止目录供应;安全封禁阻止具体产物激活 | 封禁覆盖本地与商店;不静默删除用户数据,不声称阻止未知改包 | +| 安全事实 | 现有设置存储保存封禁和最高序号,缓存只展示 | 主副本损坏不能退回空封禁;安全状态未知时保留恢复入口并关闭扩展准入 | +| 安全检查频率 | 浏览/显式刷新,已安装页提供检查按钮,首版无自动启动检查 | 从不检查的用户收不到新的远程封禁;显示最后成功时间,不承诺实时保护 | +| 作者与目录环境 | 哈希锁定的最小静态依赖;固定 Windows/Python/Host CI | 不要求完整 Host 才能静态检查,不使用浮动 main 或 Linux 结果误判 Windows | +| 故障恢复 | 准备失败保旧版,启动失败可诊断、可管理 | 不宣传自动回滚,不自动降级数据 | +| 主线与资格 | 包含 #234 的固定主线重新跑 Pi/MCP | 不将当前工作区局部结果扩为整个调用链资格 | + +这些决定可在后续设计评审中按真实作者反馈调整。变更必须同时更新阶段范围、代码落点、依赖和验收,不能仅在实现中悄悄增加例外。 + +## 15 第三方依赖的后续路径 + +此项不进入首版商店,但为 Fish Audio 等包保留明确入口。后续目录按目标 Python/平台锁定完整依赖闭包,逐个记录依赖 wheel 的精确地址、版本和 SHA-256;直接依赖与传递依赖都必须覆盖,不能只固定插件主 wheel。 + +Host 从原始地址或允许的产物镜像下载这些字节并校验,形成受控本地 wheel 集合和带哈希的 requirements 文件。安装使用 `--no-index --find-links --require-hashes --only-binary=:all:`,禁止缺项时回到网络索引或源码构建;Host SDK 仍由既有固定交付约束管理。 + +该阶段需要单独设计目录格式、目标环境 marker、SDK 一致性、依赖许可证与审核范围、下载体积、撤回影响和安装事务的失败清理。依赖集合每次改变都重新审核和验收。它不要求新增包来源字段,也不改变现有安装记录和原生运行 owner。 + +## 16 新能力接入与旧客户端兼容约束 + +本节约束首版代码的职责与目录兼容方式,不启动 ASR、网页、AUIP、记忆或其他新类型的实现。现在使用合成未来条目验证边界;真实新增能力以后单独立项。 + +### 目录收录政策与运行契约分离 + +| 层次 | 回答的问题 | 所有者与落点 | +| --- | --- | --- | +| 包协议与具体能力契约 | 包如何声明身份、配置和贡献;该能力的输入、输出及生命周期是什么 | 协议仓 schema、contract 与已交付 SDK;Host 通过 pin 消费 | +| Host 支持与安装准入 | 当前版本是否具有对应 owner,是否接受该包的全部贡献、SDK、平台与运行要求 | `server/extensions/profile.json`、`profile.py`、`package.py` 和原生 owner;Electron 使用既有 profile 投影 | +| 目录收录政策 | 当前精选目录愿意分发哪些已支持产物,要求完成哪些审核 | `catalog/REVIEW_CHECKLIST.md`、`check_catalog.py`、固定检查环境及审核记录 | +| 客户端目录展示与交付 | 能否读取条目、是否支持其运行要求和下载/依赖交付方式 | `extensionCatalog.ts`、现有安装器和管理界面;最终校验实际 wheel | + +`py3-none-any`、`X.Y.Z`、无第三方 Python 依赖及受限 MCP 启动方式属于首版收录政策。它们不能进入所有插件通用的 manifest 合法性、Runner 调用或 Provider 执行判断中,也不能因为目录放宽就自动改变 Host profile。未来需要原生 ASR 或更复杂依赖时,分别评审该能力契约、Host 消费实现和目录交付政策;没有变化的安装事务与能力不必改写。 + +通用权限、身份、路径、完整性和取消等已有契约仍适用于所有入口;政策分离不等于从本地入口绕过这些边界或已知封禁。客户端复核首版目录资格的代码集中在目录 owner,复用同一政策定义或一致性 fixture,不能在多个组件复制限制。政策演进通过版本化清单、规范说明和测试表达,不给插件新增“请求放宽政策”的 manifest 字段,也不新增安装记录中的政策授权标志。 + +**验收标准** + +- 选择一个现有 Host 支持、但带第三方依赖的有效包:通用静态检查和本地路径保持原有结果,首版目录因依赖政策拒绝;错误明确区分“目录不收录”与“Host 不支持”。用受控 fixture 验证,不要求付费服务调用。 +- 仅改变目录审核政策 fixture,不得改变生产 profile、通用 manifest/SDK 接受范围、TTS 调用、MCP/Skill 权限或本地资源包解包行为。 +- “目录已审核”但本机缺少对应 owner/contract/交付能力的包仍不能安装;审核状态不能替代 Host 准入。两入口的通用权限与已知封禁结果保持一致。 +- S0/S1 的规则定位检查确认收录限制没有散落在 renderer、Runner、Provider 中;合法与非法案例由目录工具和客户端复核共享,避免两套不同准入结论。 + +### 新类型由原生 owner 消费 + +每种新能力按以下顺序接入: + +1. 由真实使用场景确定输入、输出、失败、取消、资源释放和权限范围。 +2. 指定已有或确有必要新增的原生 owner,明确它如何消费贡献,以及何时停止接受调用。 +3. 在协议仓形成最小契约和示例;只有实际执行路径需要时才扩充 SDK 或 wire 方法。 +4. 在 Host 接入对应消费者,再更新支持矩阵和生成投影,复用已有身份、配置、凭据、修订、安装和清理事务。 +5. 完成该能力正反验收及混合包生命周期回归,最后增加目录类型专属审核项和兼容性证据。 + +包声明不能选择任意 Python 导入目标作为 Host owner;类型映射仍由 Host 决定。支持矩阵增加一行只表达已经具备的支持,不创建执行能力。ASR 使用识别 owner,主题使用前端资源 owner,MCP/Skill 保持原生协议;不得为了新增类型把 TTS Runner 改成通用插件执行器。可以复用经过验证的进程与 RPC 机制,但不提前增加无人调用的方法、通用回调、热切换或新的授权体系。 + +**验收标准** + +- 首版通过未知类型 fixture 证明没有自动注册、导入、通用 Runner 分派或部分包激活;原四类行为不变。 +- 未来真实类型开放前必须同时具备消费者、合约测试、示例和 profile 更新;缺少消费者时保持关闭,不能仅靠清单或目录声明启用。 +- 每个新 owner 验证正常调用、无权限/不兼容、取消、故障、禁用、更新和卸载;旧引用释放前不能清理文件,失败请求不因换代重放。 +- 新旧贡献组合时继续遵守完整包的修订和生效边界,不在 owner 内新增一套安装记录或绕过混合包重启规则。 +- 新类型无启用实例时,原四类能力的注册、路由、执行权和用户旅程通过原有回归。共享基础设施变化必须有新调用方和必要性证据。 + +### 旧客户端读取新类型目录 + +S0 的目录 schema 必须把稳定的身份/展示/兼容性摘要与具体贡献的执行细节分开。kind、role、contract 等值可以对旧端未知,浏览时只将它们作为有界文本和不兼容原因,不创建动态代码加载。完整 manifest 仍在安装准入时按本机支持版本校验。 + +在客户端支持的目录格式内,规范允许的可忽略展示字段可以增加;新增运行必需语义必须能通过既有兼容信息让旧端明确拒绝,无法做到时升级目录格式主版本。不能把未知必需字段伪装成可选字段,也不为兼容旧端删掉新包实际声明的贡献。需要更新的是现有目录 schema、说明和 reader,不另建第二个协商协议。 + +处理顺序为:验证来源与目录格式 → 完整验证身份和安全结构 → 按现有事务接受安全事实 → 逐条判断本机能力 → 提供安全展示和可安装集合。不支持某条目不妨碍接受可理解的安全事实。格式主版本未知或安全结构不完整时,本次刷新失败,旧有效缓存和安全事实保留,最高序号不前进。 + +包内任一贡献、contract、必要依赖或交付要求不受支持,就将该版本整包标为不兼容。不得摘掉未知贡献后安装剩余部分。已安装且正常工作的旧版本保持原有状态;更新候选只从仍允许安装且兼容的版本中选择,不因最新版本较新就强推,安装仍由用户触发。 + +**兼容性验收矩阵** + +| 输入或操作 | 必须观察到的结果 | +| --- | --- | +| 同一格式目录含兼容主题 A、未知未来类型 B、兼容 Skill C | A/C 正常显示、筛选和安装;B 显示安全摘要及不支持原因,不能安装;整个目录不报废 | +| 已知类型使用未知 role 或新 contract | 对应版本不兼容,main 安装入口也拒绝;不回退到旧 contract 或默认 owner | +| 一个包同时含兼容 TTS 和未知贡献 | 整包禁止安装/更新,无部分配置提交、Runner 启动或贡献注册 | +| 某包 v1 已安装,v2 兼容,v3 引入未知类型 | 保留 v1 正常运行;v3 不作为更新推荐,仍允许查看并推荐合格的 v2;用户不能绕过 main 安装 v3 | +| 同格式增加规范允许的可忽略展示字段 | 旧端保留原有操作语义,忽略该字段;不把它解释成权限或新能力 | +| 兼容信息缺失、必需语义未知,或未来依赖交付格式不可理解 | 对应版本不能安装,显示原因;不能因为“已审核”而放行或偷偷走本地依赖下载 | +| 未知类型 B 与已安装兼容包 A 的安全封禁同时发布 | B 不兼容,但 A 的封禁和发布序号正常持久提交并生效;跳过 B 不能遗漏安全更新 | +| 未知目录格式主版本、身份冲突或损坏安全结构 | 整份新快照拒绝,序号不推进、不覆写旧安全事实;显示需升级客户端或刷新失败,不声称已检查最新安全状态 | +| 下载的 wheel 与目录摘要/贡献要求不符 | 既有完整检查拒绝,不因目录列表已经展示过而减少验证 | +| 应用降级后,已经安装的包包含旧 Host 不支持的贡献 | 保留可验证的身份、数据和管理/移除路径,拒绝执行;不伪造已加载、不自动卸载或部分激活 | + +首版使用固定 reader 加合成未来目录,并断言被拒条目没有 pip、Runner、主题准入或持久安装提交;这不要求实现未来能力。下一次实际扩大类型或目录交付格式时,用上一受支持客户端和新目录重复此矩阵,连同新端正常使用测试保存精确版本证据。 + +**阶段与落点** + +| 阶段 | 本节对应工作 | 主要验收 | +| --- | --- | --- | +| D0/D1 | 作者文档明确三层结论,通用检查保持独立 | 通用 Host 接受与目录拒绝可同时成立,原因可定位 | +| S0 | 目录 schema 的字段兼容规则、审核政策集中、未来 fixture | 新 kind/contract 不破坏稳定目录格式,安全结构仍严格 | +| S1 | `extensionCatalog.ts` 逐条判断,main/installer 最终准入 | 整包拒绝、无部分副作用、安全更新不被条目过滤丢掉 | +| S2 | 不兼容卡片、版本选择和升级提示 | 兼容条目继续可用,已安装旧版本不受不兼容新版本影响 | +| S3 | 固定 reader/旧客户端与新目录交叉验收 | 保存矩阵结果,不将仅新端通过当作旧端兼容 | +| 后续新类型立项 | 最小契约、原生 owner、示例、profile 和专属审核项 | 正反运行与混合包生命周期通过后才开放生产类型 | + + +## 2026-10-23:投稿流程补充与减负 + +作者路径固定为“一次预检、一个 PR”。首次附可核查的归属和权利依据,更新复用已有依据并补变更/迁移说明;不要求作者重填产物可推导元数据、虚构审核人、组织审核班子或另开发布 PR。已有 README、发布说明和测试日志均可复用。返修在原 PR,实质修改只重做受影响的检查。维护者承担最终目录记录、评审交接、可批量的发布 PR 和上线验收,标签可选,不增加账号后台或状态数据库。 + +协议仓交付 catalog/SUBMISSION.md、短投稿模板、复用固定 Host/curated policy 的 preflight_submission.py、verify_publication.py。预检明确区分格式/Host、目录政策和待人工/运行审核,不能把检查通过写成已审核。权属有歧义才追加挑战,转移不得以沉默为同意。 + +验收:真实 wheel 预检→错误投稿被拒→原投稿修订→模拟批准→正式生成/CI→候选与实际分发地址校验。新增上架、解封、恢复分发或换地址的产物重新下载;已撤回/封禁或未变化的旧包可跳过,防止旧资产失效阻断紧急安全发布。客户端每次商店安装刷新官方索引,展示缓存无需额外授权 TTL。原有 UI 配置、绑定、外观和重启引导继续复用;破坏性配置/数据变化由版本说明和人工步骤处理,不建通用迁移框架。 + +用户本轮选择本地 Git 演练和草稿 PR 准备,保留运行中的桌面,因此维护端报告明确 desktop=not_run。真实 GitHub 权限/Actions/批准和独立作者资格仍是公开发布前的实际验收,不能以流程固定推定完成。详见第九批施工记录。 diff --git a/docs/extension_host_profile_v0.1.md b/docs/extension_host_profile_v0.1.md index c85c162a..370f5d04 100644 --- a/docs/extension_host_profile_v0.1.md +++ b/docs/extension_host_profile_v0.1.md @@ -1,10 +1,13 @@ # Experimental extension Host profile 0.1 Status: implemented experimental subset. -Updated 2026-10-13. This document describes what this checkout actually accepts; +Authoring entry updated 2026-10-21; runtime contracts retain their recorded pins. This document describes what this checkout actually accepts; it does not upgrade the broader Extension Protocol v0.1 draft or its provisional TTS/MCP/Skill/theme contracts to a stable SDK. The schema is wider than this profile. +Start with [Extension authoring](extension_authoring.md) for the minimal checker environment, +examples and the distinction between protocol, Host support and catalog review. + ## Supported contribution owners | Contribution | Native owner | Public authoring interface | @@ -37,7 +40,7 @@ never downloads or builds an SDK from a third party. The manifest and referenced package files are checked before importing code. The wheel's `amadeus.extensions` entry point names the package containing `amadeus-extension.json`; a TTS manifest additionally names its Runner object. -All six [examples](../examples/extensions/) are validated against these rules. +The reference [examples](../examples/extensions/) are validated against these rules. New `runtime.kind=none` wheels contain resources only: their inspected package directory is extracted without creating a venv, invoking pip or importing code. They cannot declare `Requires-Dist`. Existing no-code installations retain their @@ -155,6 +158,23 @@ last verified builtin base, with a reason shown in Appearance. Reinstalling never silently enables it. Uninstall defaults to keeping package data, with explicit deletion available; cleanup failure remains owned and retryable. +## Backend startup receipt semantics + +The paired Electron/Python Host reports a received revision and a separate +`startup: accepted|rejected` result. A unique valid identity/revision rejected +by Python validation settles that launch without creating a loaded receipt. +A missing required configuration also rejects startup. Dynamic Runner health +and per-contribution availability do not rewrite a successfully accepted +snapshot. These receipts describe Host snapshot admission, not voice readiness. + +HTTP/JSON/status-section failures, an unreadable whole startup snapshot, +duplicate identities, missing/invalid results and mismatched revisions are +unconfirmed. They never count as rejection or success, never confer loaded +state, and retain pending work with a diagnostic. Host preflight failures remain +known rejections. Revision comparisons still prevent late replies from clearing +newer edits. No package filesystem or Runner authority is granted by a failure +receipt, and SDK bytes/wire contracts are unchanged. + ## TTS wire and SDK contract The public [SDK README](https://github.com/Code-Amadeus/Amadeus-Extensions/blob/49449a0f3572e74ac7269129c02c5b9a9e685bef/sdk/python/README.md) is the authoring entry for the pinned version. @@ -227,6 +247,17 @@ Native discovery checks exact paths. A missing or shadowed package is reported as unavailable for that Agent while ordinary Agent tasks can continue; the Host does not silently claim that a same-name native Skill is the selected package. +### DSH qualification limits + +The DSH 0.2.0-rc.2 / MCP client 2.0.0 combination can write SDK diagnostics to +ACP stdout; a successful MCP tool call does not establish a clean protocol wire. +Windows workspace-write setup also requires both `WRITE_DAC` and `WRITE_OWNER`; +missing `WRITE_OWNER` can prevent sandbox initialization even in a writable +directory. These are qualification limits, not permission to filter protocol +output or relax the sandbox. See [current DSH constraints and diagnostics](acp_provider.md#current-dsh-constraints-and-diagnostics-2026-10-12) +for the affected versions, probes and ACL requirements. A changed dependency +combination needs its own qualification. + ## Work artifacts and AUIP Plugins are installed capabilities; files and AUIP applications are Work outputs. @@ -304,379 +335,52 @@ The offline hash check alone does not prove that a pin has reached canonical mai ## Evidence and explicit limits -Windows local source builds are the tested runtime target. MiMo English live acceptance -is pending; Fish has one live Japanese synthesis, not a listening -or performance certification. Codex/Pi native lifecycle tests use controlled -model endpoints; DSH additionally has real-model and desktop evidence. +The implemented profile supports local wheel installation of TTS, MCP, Skill +and data themes. Static author checks do not certify native image decoding, +installed fonts, contrast, live services or Agent behavior. MiMo English service +acceptance remains pending; the historical Fish Japanese synthesis is not a +voice-quality certification. OS declarations and private environments are not an +OS sandbox. Backend changes still apply after restart; mixed-package startup +failure after the old environment is released does not guarantee rollback. -The native Windows Electron lifecycle was also exercised in an isolated profile, -with a loopback model endpoint and a local synthetic tone wheel. The production -file picker, install/update/enable disclosures, settings store, Python backend, -independent Runner and playback path were used; no installer or confirmation -handler was mocked. This verifies lifecycle and playback, not paid-model or -natural-speech quality. - -| Native GUI journey | Observed outcome | -| --- | --- | -| Install 0.1.0, confirm, enable, select voice, restart | Installed disabled first; activation required confirmation and restart; Chat invoked the Runner with `ja`, neutral `speed=1.0` and 4,800 samples; physical playback started and finished | -| Update to 0.2.0 while active | Saved version became 0.2.0 while 0.1.0 and its installation stayed active until restart; afterwards the old process and directory were gone and only 0.2.0 synthesized | -| Resume Chat after restart | Previous messages reappeared and another reply completed through the new Runner | -| Remove with default data retention, restart | Package, process and installation were removed; data remained; saved voice was visibly unavailable and a new Chat reply remained text-only | -| Reinstall, then remove with explicit data deletion | Reinstalled disabled with the retained data; after confirmed deletion and restart, both installation and saved data were absent | - -![Native installation disclosure](images/extensions/native-install-confirmation.jpg) - -![Chat invoking the installed local test voice](images/extensions/native-chat-use.jpg) - -![Unavailable saved voice after removal](images/extensions/native-uninstalled-voice.jpg) - -DSH 0.2.0-rc.2 with MCP client 2.0.0 has a reproduced SDK diagnostic-to-stdout -defect when listing unsupported resources. Keep this combination experimental -until an upstream fix is consumed and the clean-wire probe passes. No Host line -filter or global console monkeypatch is installed. See the -[ACP diagnostics and upstream tracking](acp_provider.md#current-dsh-constraints-and-diagnostics-2026-10-12). - -DSH Windows workspace-write confinement requires both DACL and integrity-label -access. A directory can be writable and owned by the user while lacking -`WRITE_OWNER`, causing initialization to fail. The Host does not repair ACLs, -relocate work or downgrade to unrestricted execution. Use the read-only -diagnostic documented in [ACP setup](acp_provider.md). - -An extracted source fixture verifies the shipped SDK using Node with an empty -PATH and an unavailable Python override, then installs/imports the wheel offline -in a fresh environment. Damaged bytes fail verification. Runtime Runner and -example tests consume that same wheel, not a second source tree. Build tools -remain development dependencies; frontend builds do not invoke Python. -Public SDK publication, other-platform runtime qualification and natural-voice -Chat/Wallpaper/VN acceptance remain release work. Memory remains out of scope. +See [historical evidence and its recorded limitations](extension_validation_history_2026-10-13.md). +New validation must name its own Host commit and distinguish controlled process, +GUI and external-service results. Historical green checks do not qualify later +Pi/MCP authorization changes or a new release. ## Licensing and SDK delivery (2026-10-13) -Host code remains AGPL-3.0. The canonical protocol/schema/validator and standalone -SDK in Amadeus-Extensions are Apache-2.0. Authored examples carry MIT-0; external -assets, voices, models and dependencies retain their own rights. The current -SDK source pin is retained in protocol main history by the merge of protocol #1. -Theme schema/validator provenance tracks protocol #2 pending its acceptance; -the reviewed SDK wheel and digest are unchanged. There is no PyPI release, -development-directory loader or new diagnostic-log feature in this change. - -The installed SDK has PEP 639 license metadata and an embedded LICENSE. -`tools/check_extension_protocol.py --source ` verifies the -pinned text copies, wheel digest, metadata/license and packaged SDK source -against the recorded upstream commit. Source releases contain the same wheel. - -The canonical build uses CPython 3.12.10 and hash-locked development dependencies. -It normalizes text line endings and ZIP timestamps/permissions, stores entries -without compression and recalculates RECORD hashes and sizes. Protocol CI -compares wheels built independently on Windows and Linux. The Host carries that -artifact unchanged; runtime still requires Python >=3.11. Matching SDK build -bytes do not imply cross-platform qualification of the Host or third-party -extensions. - - -### Explicit backend startup receipts (2026-10-13 audit follow-up) - -The paired Electron/Python Host reports a received revision and a separate -`startup: accepted|rejected` result. A unique valid identity/revision rejected -by Python validation settles that launch without creating a loaded receipt. -A missing required configuration also rejects startup. Dynamic Runner health -and per-contribution availability do not rewrite a successfully accepted -snapshot. These receipts describe Host snapshot admission, not voice readiness. +See [the recorded evidence](extension_validation_history_2026-10-13.md#licensing-and-sdk-delivery-2026-10-13). -HTTP/JSON/status-section failures, an unreadable whole startup snapshot, -duplicate identities, missing/invalid results and mismatched revisions are -unconfirmed. They never count as rejection or success, never confer loaded -state, and retain pending work with a diagnostic. Host preflight failures remain -known rejections. Revision comparisons still prevent late replies from clearing -newer edits. No package filesystem or Runner authority is granted by a failure -receipt, and SDK bytes/wire contracts are unchanged. ## Local theme pilot acceptance (2026-10-12) -The local branch integrates extension lifecycle 55551e1 and Work files/AUIP -adf1632 without changing either parent PR. P1 support/profile and persistence -rules are committed separately from P2 theme/UI work. The companion schema -source is pinned to 9c3f3a5 on a local protocol branch; publish that source -before opening a downstream PR that references it. - -Automated evidence includes 470 desktop unit tests; 130 Python extension, -SDK, files/AUIP regressions (2 skips); the official model-less Electron smoke; -and the production-component visual smoke in English/classic at 1100 px and -Chinese/wallpaper-slice at 620 px. These visual fixtures use synthetic Host -snapshots, actual React components and the actual theme validator/renderer. -They verify base identity, one installed style, Blob image visibility, absence -of covering builtin gradients and no horizontal overflow. They do not exercise -native installation confirmation. - -The following component-fixture screenshots were refreshed after the 2026-10-13 -audit repairs, including the synthetic loaded revision. They are not new native -dialog acceptance evidence. - -![Plugins with the light extension theme](images/extensions/theme-day-plugins-en-US-1100.png) - -![Appearance with the dark extension theme in a narrow Chinese window](images/extensions/theme-night-settings-zh-CN-620.png) - -A separate local diagnostic used the real Electron installer/store, trusted Python -wheel helper, native image/font APIs and an independent SDK Runner. Confirmation -decisions were supplied by the driver; it did not operate native dialogs. -The pure-theme lifecycle retained backend PID 11912, including cancel, invalid -update rejection, disable/re-enable, retained-data reinstall and explicit deletion. -The mixed journey used backend PIDs 12788 then 2272 and Runner PIDs 4296 then -34160. Completed tone requests were v1, v1 (after v2 was saved), then v2 (after -restart), each 4,800 samples at 24 kHz. Old installation references survived until -shutdown; final removal deleted the requested data. This is process/contract -evidence, not Chat playback or native GUI acceptance. - -The process journey is reproducible on Windows after the normal desktop build: - -```powershell -python tools/probes/extension_theme_lifecycle.py -``` - -The probe builds all synthetic wheels offline, creates a fresh profile, and -records the report under `build/extension-theme-lifecycle//report.json`. -It verifies real image decoding and installed fonts, pure-theme preparation -without pip/venv, actual revision acknowledgements, two mixed-package versions, -SDK audio and removal. Its isolated Python process hosts `ExtensionRuntime`; -it is not the HTTP/WebSocket server or the Chat playback path. Confirmation -choices are supplied by the driver and reported explicitly. - -The P2 native Windows GUI journey also passed in an isolated profile. The real -file picker and native install/update/enable dialogs were operated, followed by -production settings, the backend, independent Runner and Chat playback. A local -synthetic model and tone package keep this repeatable without paid services. - -| Native theme journey | Observed outcome | -| --- | --- | -| Pure theme install, enable and select day/night | Installed disabled first; both palettes applied immediately without backend restart | -| Cancel v2 update, then accept it | Cancellation preserved v1, its revision and installation; acceptance applied v2 and reclaimed the old resources | -| Invalid v3 theme | Contrast validation rejected the update and preserved working v2 | -| Disable/re-enable, remove, reinstall and delete data | Immediate fallback preserved the saved theme identity; re-enable restored it; retained data survived a disabled reinstall; explicit deletion removed it | -| Mixed theme/TTS v1, then save v2 | Saved revision 3/version 0.2.0 while loaded revision 2/version 0.1.0 remained active; Chat still invoked the same v1 Runner | -| Disable, then re-enable before restart | Frontend access closed immediately and stayed closed; the UI disclosed that the old backend continued until restart | -| Restart mixed package and resume Chat | Revision 5/version 0.2.0 applied together; old process and installation disappeared; history resumed and the new Runner played a response | -| Remove mixed package with data deletion, restart | Record, installation, data and Runner disappeared; theme/voice selections retained their unavailable identities; another Chat turn completed without speech | - -The pure-theme journey kept the same backend process throughout. The mixed -journey completed three Japanese-tagged requests in order: v1, v1 after v2 was -saved, and v2 after restart. Each produced 4,800 samples at 24 kHz; playback logs -recorded physical start and completion. The final text-only turn added no -playback. These are observed process and playback results, not a subjective -listening assessment of natural speech. - -Acceptance found two stale-feedback cases: a rejected update left an earlier -success notice, and a successful restart left the old pending-start notice. -Operation feedback now clears at the next attempt and confirms only that saving -succeeded; package cards remain the source of current application state. Both -cases have component regressions. The final build and visual smoke passed after -this presentation fix; the full native journey above preceded that final fix. - -![Native mixed-package update disclosure](images/extensions/theme-native-mixed-update.jpg) - -![Saved v2 while the original theme and backend remain active](images/extensions/theme-native-mixed-pending.jpg) - -![Chat history and playback after the whole mixed package switches to v2](images/extensions/theme-native-mixed-chat.jpg) - -![Saved theme identity remains visible after removal](images/extensions/theme-native-removed-identity.jpg) - -No remote paid synthesis, natural voice audition or cross-platform GUI -certification is claimed. Narrow-window evidence is from the production-component -Electron smoke, not the native-dialog journey. DSH/ACL constraints described -above are unchanged; this theme work does not qualify a new Agent path. +See [the recorded evidence](extension_validation_history_2026-10-13.md#local-theme-pilot-acceptance-2026-10-12). ## Audit repair revalidation (2026-10-13) -The parent file-delivery branch now preserves amendment ownership through -recovery and keeps previews stable during unrelated renderer updates. The -extension branch builds with the documented core-only Python environment and -recovers overdue cancellation under the existing bounded budget. General MCP -environment migration and desktop process shutdown are separate reviewed -dependencies; this pilot consumes their contracts. - -After synchronizing those changes, the P1-only head passed 464 Electron tests -and the normal build. The combined P1/P2 head passed 478 Electron tests and -211 scoped Python regressions (2 skips), plus the core-environment build. The -actual model-less Electron smoke passed all 13 checks, including Plugins, -file/app separation and clean shutdown. The reusable theme lifecycle probe also -passed with the real installer/store and SDK Runner; its confirmation choices -are supplied by the driver, not native dialog input. - -The earlier native-dialog and physical-playback acceptance remains historical -evidence for its recorded head. No second full manual GUI run, natural-voice -audition or new Agent/cross-platform qualification is claimed for this repair. -The new cancellation and shutdown failure regressions address gaps that the -earlier normal lifecycle journey did not cover. +See [the recorded evidence](extension_validation_history_2026-10-13.md#audit-repair-revalidation-2026-10-13). ## Mainline reconstruction and focused revalidation (2026-10-13) -P1 is rebuilt from its own reviewed diff on main `c569a0f`, which contains -the accepted file/AUIP, MCP migration, backend shutdown and extension lifecycle -PRs. Its stable patch-id is unchanged. P2 is rebuilt on that P1, preserving -the accepted SDK source `49449a0` and wheel digest. Theme schema/validator -provenance now points at protocol #2 `223aac5`; that theme contract remains -provisional pending separate review and merge. Developer mode, logs and memory -are outside this reconstruction. - -The prior Electron Windows failure was reproduced in the real offline settings -smoke: its standalone test entry did not register `desktop-theme.get`, so the -theme provider could not obtain the locked theme and the GUI assertion timed -out. The smoke now calls the production `ExtensionInstaller.themeState` owner -through that IPC handler. Its eight existing outcome checks pass without a -backend or Python process. No production fallback or timeout increase was added. - -Fresh local evidence on the rebuilt implementation: - -- P1: 99 Python tests passed, one symlink privilege skip; 467 Electron tests - passed and the production build passed with an unavailable Python override. -- P2: 264 scoped Python tests passed, three skips (two Windows symlink privileges - and the optional DSH runtime); all 481 Electron tests passed. The scoped suite - includes ordinary-file/AUIP ownership, preview and launch boundaries. -- The actual Electron/backend model-less smoke passed all 13 checks, including - Plugins navigation, saved startup mode, separate file/app views and clean - process shutdown. -- The production-component smoke passed its seven journeys, including theme - rendering at 1100px English and 620px Chinese and the 560px configuration form. - Host responses in this visual fixture are synthetic; image/font validation - and component rendering use the real implementation. -- The real installer/store, native image/font validation and independent SDK - Runner lifecycle probe passed. Pure themes retained their backend process - with no venv or pip; mixed synthesis used v1, v1 after saving v2, then v2 after - restart. Cancellation/rejected updates retained the working version, frontend - admission closed on disable/remove, and owned processes/resources/data were - cleaned according to the selected retention policy. - -The lifecycle probe reports `nativeDialogs: false`: confirmation decisions were -supplied by the driver. The 2026-10-12 manual native-dialog/Chat playback journey -above remains historical evidence; it was not repeated or relabeled as a new -manual acceptance run. Final-head CI and focused external review remain the -merge gates for P1/P2 and protocol #2. +See [the recorded evidence](extension_validation_history_2026-10-13.md#mainline-reconstruction-and-focused-revalidation-2026-10-13). ## First audit repairs (historical, 2026-10-13) -**Evidence correction:** the historical 14-check model-less smoke claim below is -withdrawn. Its asynchronous predicate could return false without failing the -wait; see the second audit investigation and replacement acceptance below. - -P1 now retires logical backend receipts at every new launch, independently of -whether prior process-tree cleanup was confirmed. File deletion still requires -the original confirmation/reference boundary. POSIX resource extraction keeps -executable bits only (never setuid/setgid); its regression actually runs the -bundled command on Linux/macOS. A Host-generated `startup_error` settles that -launch revision without pretending it loaded; later edits remain pending. -Frontend-only validation/removal errors do not create backend restart work. - -That first repair pinned protocol #2 `d06f319`: its published 25-token allowlist and literal/image -signature checks are shared by the Host. Runtime font availability, full native -image decoding and palette contrast remain Host responsibilities. The stricter -readability rules and theme-independent recovery described above replace the -previous fixed-white accent labels and translucent surface allowance. Plugins -now opens Voice in the navigable Settings view and preserves its remembered -section. Theme notifications no longer expose raw Electron events; startup -chrome uses the last verified theme base. Screenshot fixtures carry real-shaped -loaded revisions and the current component screenshots were refreshed. - -Other checks from that first repair: 492 Electron tests, production build, eight -offline startup GUI checks and eight component visual journeys passed. The -separate 14-check model-less report is withdrawn as recovery evidence; see -the investigation below. The real -installer/store/native image/font/SDK Runner lifecycle probe passed for both -resource-only and mixed packages; it reports `nativeDialogs: false`. It is not -a new manual confirmation or natural-voice playback run. Protocol tests passed -91 cases with one local Windows symlink-privilege skip. SDK bytes remain unchanged. - -Known lifecycle limit: once the old backend has stopped and references are -released, a mixed-package update may collect v1 before v2 finishes starting. -Preparation failures preserve the old version, but a later startup failure does -not provide automatic rollback. That behavior is disclosed, not expanded in -this repair. Linux/macOS executable-bit execution is a required CI check rather -than a claim of local Windows execution. These PRs remain Draft pending final -head CI and focused external review; no merge, developer mode, logs or memory -work is included. - -The final scoped Host Python run passed 264 tests with four skips (POSIX execute -bits on Windows, two symlink privilege checks, and optional DSH availability). -Canonical-source comparison passed. Clean release gates passed for P1 (3,991 -selected / 101 excluded) and P2 (4,021 / 95), each with zero errors or warnings. -Protocol #2 final-head CI also passed Windows/Linux contracts and wheel byte -comparison; downstream Host final-head checks remain separate merge gates. - +See [the recorded evidence](extension_validation_history_2026-10-13.md#first-audit-repairs-historical-2026-10-13). ## Second audit: rendered surfaces and receipt evidence (2026-10-13) -This audit pinned protocol #2 to `d5c8a7d` (superseded by the merge follow-up below). Theme v0 supports PNG/JPEG only, including -matching signatures and actual native decoding. WebP declarations fail before -installation, and renaming WebP bytes does not grant acceptance. The canonical -night example and shared valid/invalid CSS literal cases are pinned as fixtures; -Host tests validate that example and real Chromium accepts the supported syntax. -Comma and space/slash forms cannot be mixed. Host-specific validator help and -provenance match again; the SDK wheel is unchanged. - -Hard-coded wallpaper-slice card/tab decorations now apply only to the builtin -base. Extension capability cards and nested visual controls paint the same -opaque token surfaces used by the Host proof. A native Electron regression -checks decoded PNG/JPEG files and actual Chromium computed styles/contrast for -an accepted near-threshold black palette and an image-backed translucent canvas. -It also verifies builtin decoration returns after leaving the extension theme. - -Mixed-package selection resolves content from the applied manifest while the -current desired record retains enable/removal authority. A saved v2 that drops -or renames a selected v1 theme therefore leaves v1 visible until restart. -New themes wait for v2 admission; disable/remove still closes frontend access. +See [the recorded evidence](extension_validation_history_2026-10-13.md#second-audit-rendered-surfaces-and-receipt-evidence-2026-10-13). -### Why the earlier 14/14 result was invalid +## Merge follow-up (2026-10-13) + +See [the recorded evidence](extension_validation_history_2026-10-13.md#merge-follow-up-2026-10-13). -A clean rebuild of `d7988dc` produced the same renderer asset names as the old -build log. Running its real application in a new profile returned -`AMADEUS_UI_THEME is locked by the parent process environment` for the theme -setup. The probe stopped before invoking the native error dialog. An independent -Chromium reproduction with Playwright 1.60.0 showed that -`wait_for_function("async () => false")` immediately returned a handle to false, -while a synchronous false predicate timed out. This known Promise-truthiness -behavior is recorded in [Playwright #29132](https://github.com/microsoft/playwright/issues/29132). -The checked release tag is `87bb9ddbd78f329df18c2b24847bc9409240cd07`; -this was reproduced without modifying Playwright. No duplicate upstream issue -was opened. - -The smoke made two errors: it ignored a failed setting-save reply and used an -asynchronous wait predicate as proof of persistence. The resulting true check -flag did not establish recovery, so the prior local and CI green results are -not accepted as recovery evidence. This was a test-instrument defect, not a -reason to relax the product's environment lock. - -The corrected smoke removes the inherited theme variable, checks each IPC save -reply, verifies classic -> wallpaper-slice -> shortcut -> classic, and awaits -saved values in Python with explicit comparisons and a deadline. The existing -startup-mode persistence check uses the same corrected polling. DOM theme changes -use locator attribute assertions, avoiding wait-for-function eval under the -shipping CSP. The GUI launcher uses an explicit environment allowlist, a temporary -profile and a checkout without `.env`. A temporary loopback main inspector -checks the owned PID and sole main-window identity, then uses Electron -`sendInputEvent` to exercise the actual `before-input-event` hook. The probe -does not call recovery directly; its connection closes immediately and the -inspector exits with the owned test process. Production startup and CSP remain -unchanged. Regressions prove a missing/wrong saved value times out. Fresh reports bind source identity -and the actual dist/packaged-ASAR digest to the result. The accompanying PR lists -the final clean-build GUI runs; historical reports above remain distinguishable. - -Installation dialog decisions in the separate lifecycle probe remain driver -supplied. Mixed-package startup failure still does not guarantee rollback after -old references are released. No developer mode, logs feature, memory or later -phase was added by that repair; all three PRs were kept Draft for external review. +### Explicit backend startup receipts (2026-10-13 audit follow-up) +The [current receipt semantics](#backend-startup-receipt-semantics) remain normative +for this experimental Host. See also [the recorded evidence](extension_validation_history_2026-10-13.md#explicit-backend-startup-receipts-2026-10-13-audit-follow-up). -## Merge follow-up (2026-10-13) +### Why the earlier 14/14 result was invalid -Host P1 (#191) was merged as `2799ba8`. Protocol #2 was merged using merge -commit `bf1a848`, retaining the pinned source `b30df73` in protocol main history. -P2 was moved onto Host main without changing its file tree; its final head is -subject to fresh CI before the authorized merge. Protocol source is now pinned -to `b30df73`. Both the main specification's P4 -check and the theme contract consistently restrict v0 images to PNG/JPEG; the -WebP exclusion no longer interrupts the background-field description. Protocol -runtime/validator/SDK bytes are unchanged by this documentation correction. - -If a selected theme is absent from the loaded package, the Host recommends a -restart only when a different saved revision contains that theme and the -package requires restart. A theme removed by a frontend-only update or absent -after backend restart instead directs the user to choose another theme in -Appearance, preserving the saved identity and last verified base. Regressions -cover both actionable pending additions and removed frontend/mixed themes. +See [the recorded evidence](extension_validation_history_2026-10-13.md#why-the-earlier-1414-result-was-invalid). diff --git a/docs/extension_licensing_and_developer_kit_proposal_2026-10-12.md b/docs/extension_licensing_and_developer_kit_proposal_2026-10-12.md new file mode 100644 index 00000000..ea74217e --- /dev/null +++ b/docs/extension_licensing_and_developer_kit_proposal_2026-10-12.md @@ -0,0 +1,81 @@ +# 扩展许可证与开发者套件:已交付基线及后续候选 + +> 更新:2026-10-14。D1–D4 与最小 SDK 交付已经合入;K2–K5 仍为候选。 +> 本轮只做文档收口,不发布 SDK 到 PyPI,不启动开发模式、日志、市场或记忆实现。 + +## 1. 已接受的基线 + +Host main `b5a889e` 已包含 #188/#191/#192 及其独立前置;协议 #1/#2 已合并。 +Host pin `b30df73` 保留在协议 main `bf1a848` 的历史中;SDK 独立源码 pin `49449a0` 不变。 +当前支持与验收见 [Host profile](https://github.com/Code-Amadeus/Amadeus/blob/b5a889eccd30301957219d6f8a562f00d62a3ad7/docs/extension_host_profile_v0.1.md)。 + +| 决定 | 已落实状态 | +|---|---| +| D1 | 协议、schema、参考校验器、独立 SDK 采用 Apache-2.0 | +| D2 | 自有模板和示例采用 MIT-0;第三方内容保留原许可 | +| D3 | 规范 SDK 源码在 Amadeus-Extensions 的 `sdk/python` | +| D4 | Host 携带固定版本/来源/摘要 wheel,不在默认前端构建或安装时临时构建 SDK | + +Host 自身仍是 AGPL-3.0。代码许可不授予角色、声音、模型或媒体权利;不能仅凭进程边界对插件许可适用性作法律保证。 +来源与许可证据沿用 `LICENSES/provenance.json` 和 `server/extensions/protocol-source.json`,不新增另一份注册表。 + +## 2. 本轮已经交付什么 + +- SDK `0.1.0` wheel 摘要为 `274bd66b81932c8ea50f39f58e7bf91530a8015196acfcf9bcfd77fa2d8b8084`;Node 校验固定清单,安装器不再写死文件名。 +- 默认 `npm run build` 不需要 Python/setuptools 来构建 SDK。桌面安装从随 Host 交付的 wheel 准备环境,不从外部下载或构建 SDK。 +- 安装前检查插件声明的 SDK 范围、精确版本与直接来源要求;不兼容时在 pip 之前给出原因。传递依赖冲突仍由 pip resolver 处理。 +- SDK 带 PEP 639 许可元数据和 LICENSE。规范构建统一文本换行、ZIP 属性及 RECORD,锁定开发依赖;Windows/Linux CI 独立构建并比较实际字节。 +- 安装/更新披露包含作者声明的许可证、完整 wheel 摘要、本地来源文件、贡献、命令及 OS 声明。 +- 主题 token 白名单、清单校验器、示例和共享颜色用例已有固定来源与漂移检查;真实 Host 的字体、图像解码和对比度仍是运行环境责任。 + +这是 L1 与收窄 K1 的交付。公开 PyPI 发布不属于本轮,也不是现有 Host 安装、记忆设计或后续校验工具的前置条件。 +已有协议/Host 回归和真实进程验收;不能再写“没有一致性测试”。尚未交付的是面向作者的统一工具体验。 + +## 3. 后续作者工具候选 + +| 候选 | 尚需完成的工作 | 验收 | +|---|---|---| +| K2 共享包规则与 validate | 在现有 manifest 固定副本之上,厘清 wheel/配置/资源检查归属;提供统一作者校验入口 | 同一组有效/无效包在 CLI 与 Host 安装路径得到一致结论和可定位原因,不把字体/原生解码伪装成静态保证 | +| K4 离线合约检查 | 面向 TTS/MCP/Skill 的可运行检查,复用已实现契约 | 有效示例真实发现/调用;错误握手、取消、清单不一致和无效资源按各自边界失败 | +| K5 模板与首批作者 | 以已开放的 TTS/MCP/Skill/主题为模板,文档可在干净环境复现 | 外部作者实际完成安装、使用、更新与卸载;反馈进入独立问题记录,不以复制文件为成功 | +| K3 开发模式与诊断日志 | 按真实作者反馈单独设计原地目录引用、重新加载和受控诊断输出 | 保持同一安装/配置 owner、同一生效边界;日志有明确开关、脱敏和保留策略 | + +推荐先厘清 K2,并以最小 K4/K5 验证作者是否能独立完成一个插件,再决定 K3 的投入。 +这是候选优先级,不是开工授权;不因阶段编号要求串行完成全部项目。 +更广类型、商店、签名强制、自动更新、通用热重载和回滚继续另行评审。 + +## 4. 不可改变的边界 + +- 安装不等于启用或授权;Main Chat 不直接运行 MCP/Skill/插件。 +- 作者工具校验包,不授予 Host 能力,也不根据 README/脚本自动扩大权限。 +- 开发目录将来即使开放,也复用相同身份、记录、绑定和原生生效边界;不能偷偷引入后端热切换。 +- 非密钥配置与密钥保持现有来源规则;第三方日志不得包含 Host 的全量环境、明文绑定或用户历史。 +- SDK 版本来自单一固定清单,Host 不隐式追踪协议仓 main;每次更新仍需 pin、许可和验收。 +- 规范与实现只承诺已验证的子集。进程隔离不是 OS 沙箱,跨平台构建不等于完整 GUI 资格。 + +## 5. 尚未决定或实施的事项 + +| 项目 | 状态 | +|---|---| +| D5 开发模式是否进入打包版 | 未决定、未实施;须有具体作者旅程 | +| D6 诊断日志默认开关/保留 | 未决定、未实施;后续可评估全部默认关闭,不能把建议写成批准 | +| D7 AUIP 是否采用相同许可原则 | 另行决定,不由扩展协议决定 | +| D8 名称使用说明 | 待决定,不等于已建立商标或认证制度 | +| PyPI 公开发布、签名/目录、社区邀请 | 本轮未执行;发布/消息需明确授权 | + +## 6. 与记忆及现有 PR 的关系 + +旧 #188/#191/#192 和协议 #1/#2 全部已合入,不再需要修改这一组 PR 或等待其临时 base。 +下一批功能从最新 main 单独切分;新增贡献必须有明确需求和退出证据。 + +Host 内置记忆走 M0 → M2 → M1,见[记忆衔接计划](extension_module_implementation_plan_2026-10-11.md)。 +K2–K5、P3–P5、PyPI 和商店都不是 M0/M2 的前置条件。外部记忆插件要等 Host 的作用域、纠正和遗忘语义成立后再做 M1。 + +## 7. 验收与历史记录 + +最终 #191/#192 各 16 项 CI、协议 #2 三项 CI 均通过;合并后的主线 CI 已核对通过。 +Windows 源码版与打包版各 14 项真实 GUI 检查通过;本轮文档编辑没有重跑这些实现检查。 +旧异步等待导致的 14/14 恢复假阳性已撤回,修正原因与新证据见 Host profile / #192。 + +原始法律/框架调研、过往构建失败和旧提交记录保存在本地 `build/extension-doc-closeout-20261014/originals/`,不作为今天的产品缺陷清单或施工授权。 +本次仅修改文档;没有创建或合并新的 PR,也没有启用任何后续功能。 diff --git a/docs/extension_module_implementation_plan_2026-10-11.md b/docs/extension_module_implementation_plan_2026-10-11.md new file mode 100644 index 00000000..74f6d272 --- /dev/null +++ b/docs/extension_module_implementation_plan_2026-10-11.md @@ -0,0 +1,122 @@ +# 插件基线收口与记忆模块衔接 + +> 历史计划:插件施工顺序已由 [2026-10-21 社区预览与商店计划](extension_community_preview_and_store_plan_2026-10-21.md) 取代。 +> 以下日期、基线与记忆 M0 → M2 → M1 顺序仅记录当时决定;记忆仍为独立候选,不是当前插件施工前置。 + +> 更新:2026-10-14。本文保留原文件名供既有链接使用,替代 2026-10-11 的 MiMo 单点施工方案。 +> 状态:插件基础已合入主线;记忆为下一步设计候选,尚未开始实现。 +> 本轮只更新文档,不发布新 Issue/PR,不开放新贡献类型,不开始记忆、开发模式或日志功能。 + +## 1. 当前基线 + +- Host main:`b5a889eccd30301957219d6f8a562f00d62a3ad7`。#189、#198、#197、#188、#191、#192 已合并。 +- 协议 #1/#2 已合并;协议 main `bf1a848` 保留 Host pin `b30df73669c50d95d08fb2cd57c289ecddc959f2`。 +- SDK 源 `49449a0f3572e74ac7269129c02c5b9a9e685bef`;Host 自带 wheel,摘要 `274bd66b81932c8ea50f39f58e7bf91530a8015196acfcf9bcfd77fa2d8b8084`。 +- 已核对合并后的 Windows/Linux/macOS、Electron 和安装验证 CI 全部通过。文档收口不冒称重新跑过实现验收。 +- 已实现行为以 [Host profile](https://github.com/Code-Amadeus/Amadeus/blob/b5a889eccd30301957219d6f8a562f00d62a3ad7/docs/extension_host_profile_v0.1.md) 和 `server/extensions/profile.json` 为准;协议能校验一种类型不等于 Host 支持执行。 + +| 已合入工作 | 合并提交 | 责任 | +|---|---|---| +| #189 | `d9bfb1a` | 普通文件与 AUIP 应用的列表、归属和预览边界 | +| #198 | `0dbf7ef` | 本实例后端停止、确认与重试 | +| #197 | `52194c0` | 手动 MCP 连接测试的环境隔离与迁移反馈 | +| #188 | `c569a0f` | 安装与记录、TTS Runner、MCP/Skill、SDK 交付 | +| #191 | `2799ba8` | 支持矩阵、保存/加载修订、资源包准备、明确启动回执 | +| #192 | `b5a889e` | 主题与统一 Plugins 入口 | + +## 2. 已完成的插件基础及保留边界 + +- 已开放 TTS、MCP、Skill、数据主题;安装一次后选择贡献或绑定兼容 Agent。只有一套安装/更新/卸载管理器。 +- Electron main 拥有安装事务、配置、加密凭据与期望状态;Python 拥有启动准入;TTS 用独立 Runner;MCP/Skill 仍由各原生 owner 消费。 +- TTS/MCP/Skill 配置与启停在后端重启时生效。纯主题即时生效;混合包使用已加载快照,整包版本在重启时切换。没有扩展 TTS 热交接或对 pipeline 的通用热切换改造。 +- 停用/卸载混合包立即关闭前端准入;旧后端可能仍待停止。保存成功、明确接受/拒绝、未确认、健康状态和物理清理必须区分。 +- 明确拒绝可结案但不记为已加载;无效、缺失、重复或无法读取的回执仍待确认。旧回执不能清除较新的修改。 +- `permissions.host` 必须为空,不保存 Grant 记录。Runner 环境用白名单;原生 Agent 保留自身契约。这不是恶意第三方程序的 OS 沙箱。 +- 主题 v0 仅支持 PNG/JPEG,校验实际解码和绘制对比度,提供原生恢复入口。 +- MiMo 不支持日语,示例只声明英语;英语实机验收仍待办。Fish 有一次日语真实合成证据,但不是全场景听感认证。`speed` 是建议值,不要求复现 GPT-SoVITS 节奏。 +- 内置 MiMo 未移除;迁移与移除另行安排,不是启动记忆设计的前置条件。 + +剩余限制:混合包旧引用释放后没有自动启动回滚;跨平台 GUI、自然语音、DSH stdout 和 Windows ACL 的资格范围没有因 CI 通过而扩大。完整来源和验收边界见 Host profile。 + +## 3. 后续工作排序 + +本次插件基建已达到可以规划 Host 内置记忆的门槛。当时建议顺序为 **文档收口 → 记忆设计 Issue/验收范围 → M0 → M2 → M1**。 +M0/M2/M1 沿用既有阶段名,不重新编号;顺序调整为先确立 Host 的记忆语义,再开放外部来源。 + +作者工具和更多贡献类型是独立候选: + +- 共享包检查、离线合约检查、模板和真实作者反馈,见[开发者套件方案](extension_licensing_and_developer_kit_proposal_2026-10-12.md)。 +- P3 扩展网页、P4 ASR、P5 可安装 AUIP,见[扩展候选方案](extension_pilot_expansion_plan_2026-10-12.md)。 +- 开发模式、诊断日志、商店、自动更新、热重载、形象包和自动记忆均未启动。 + +以上均不是 M0/M2 的依赖。此次仅调整文档中的设计顺序;按 CONTRIBUTING/ROADMAP,记忆实现仍需维护者认可的设计 Issue 和明确验收案例。 + +## 4. M0:Host 内置、有来源的跨会话召回 + +### 4.1 用户价值与最小范围 + +第一批证明:在同一个 Project 的新会话中,能找到此前 Session、Work 或 Artifact 中相关的事实,并说明来源。 +使用现有 Host 读取接口,不新建第二本 Work 事实库,不把最近窗口里已有的对话再读一遍当作交付成果。 + +Draft 首批不开放个人跨会话召回;它已有近期历史。个人共享信息与角色共同经历的边界先由 M2 定义。 +角色 RAG 是角色参考资料,不应自动解释成用户记忆或角色亲历。 + +### 4.2 注入与失败边界 + +- 当前 `server/cooperative_provider_loop.py` 的 `role_reference` 在 `_decide` 中追加到 system,调用处没有独立的检索 deadline;这是当前代码事实,不是新记忆入口的既定设计。 +- M0 的结果进入现有 turn frame 中独立、带来源的参考字段,放在历史之后;首轮和后续轮均不写入 system,不伪造历史消息,不改角色身份或执行权。 +- 这样可以将召回证据与稳定的人设/指令分开,允许下一轮纠正、排除和撤回,并避免在 system 前缀中引入每轮检索变化。缓存收益须按实际请求测量,不能仅凭字段位置承诺命中率。 +- 用现有 turn/admission 和 Host 绑定身份检查 Project、角色、Session、取消状态;检索完成后再核对,晚到或归属变化的结果丢弃。 +- 每源与总结果均有长度预算和截止时间;超时/异常返回空参考并留下可诊断原因,普通回复继续。 +- 限制仍在执行的查询数量;线程等待超时并不意味着底层查询已终止,不能每轮无限堆积后台查询。 +- 与已有 frame 的 `context`、`work_tasks`、`retained_contexts` 按稳定来源去重;同一事项以 Host 事实为准,模型摘要只是派生参考。 +- 无来源或旧记录缺少逐条时间时标明未知,不用 Session 文件时间冒充事实发生时间。先证明有界搜索有用,再决定索引或向量存储。 + +### 4.3 最小验收 + +| 情景 | 应见结果 | +|---|---| +| 同 Project 的新会话追问之前交付/决定 | 返回相关事实及可追溯来源,不创建重复 Work | +| 其他 Project/角色或未经授权的 Draft 历史 | 不返回、不扩大范围 | +| 无命中、检索异常、超时 | 主对话继续,不捏造记忆 | +| 查询期间切换归属或取消;迟到结果返回 | 不进入当前 turn | +| 账本事实已更新、来源不存在或已被排除 | 不用陈旧文本覆盖当前事实;去重、失效或排除有效 | +| 召回文本含“忽略规则”等指令 | 只作为带来源资料,不授予执行权或修改角色规则 | +| 启用/禁用 M0;未命中 | 原有角色、语言锁、Chat/Work 流程保持可验证行为 | + +## 5. M2:Host 显式记忆 + +提供用户主动的“记住”、查看、纠正、遗忘。先决定用户级共享偏好、角色关系/共同经历、Project 事实各自的作用域、优先级和保存规则,再开放 Draft 跨会话召回。 + +Host 拥有记忆身份、持久化范围和用户操作入口。查看时显示来源与适用范围;纠正后的事实替换旧的有效记忆;遗忘后,缓存、在途结果和派生索引都不能将其恢复。重启或重建索引必须保持这些效果。 +“忘记一条记忆/排除来源”不自动删除原始 Session/Work 账本;删除原始记录是另一项明确操作。保留源记录时也必须保证被排除内容不会再次被召回。 + +验收覆盖范围隔离、纠正/遗忘与在途查询竞争、重启、索引重建、过期/冲突事实、来源失效和用户可见管理。自动抽取、人格演化、作息与主动对话不在 M2 内。 + +## 6. M1:外部记忆来源 + +在 M0/M2 已证明作用域和管理语义后,再修订 `context_source` 契约并做真实外部适配器。 +当前 Host 仍拒绝 `context_source`/`observer`;Notes MCP 自己保存笔记,不代表拥有 Amadeus 历史或记忆授权。 + +开放前必须共同明确: + +1. Host 授予的作用域与稳定不透明引用,不让包自行指定任意原始 id 扩大范围。 +2. 有界、只读、可撤销的来源接口;不把 Host 数据库或 Session 路径作为插件 API。该契约限制不是对恶意程序 OS 访问的沙箱保证。 +3. 查询、列表、纠正、遗忘、导出使用同一套作用域规则;源更新/删除能使缓存和派生内容失效。 +4. 遗忘成功后清理旧在途结果;重启/重建不能复活内容;卸载保留/删除数据的选择如实呈现。 +5. 来源仅关联到实际被接受的 turn;不能把每次消息 append 或 observer 事件当作永久记忆同意。 + +M1 不预造第二套身份或事实 schema,不以安装成功替代运行和撤销验收。 + +## 7. 下一次实现前的决定 + +- 为 M0 准备设计 Issue:用户旅程、owner、Project 首批范围、非目标、失败边界和上表验收。 +- 定出 M2 的作用域问题清单,保持 M0 不依赖尚未决定的个人/角色共享规则。 +- 核对社区 [#97](https://github.com/Code-Amadeus/Amadeus/issues/97) 与 [#146](https://github.com/Code-Amadeus/Amadeus/issues/146) 的方案,准备与上述边界一致的回复草稿;本轮不发消息、不承诺接收整套实现。 +- 设计认可后再开始 M0。此前“把召回放进 system”“先外部 M1 再定 M2”“必须迁移内置 MiMo 才能进入记忆”的旧安排均不再适用。 + +## 8. 历史与证据 + +2026-10-05 调研与 2026-10-11 原审计属于未随本仓发布的内部历史材料,不作为公开验收依赖。 +可公开复核的历史记录见 [插件验收历史](extension_validation_history_2026-10-13.md);当前施工以文首的新计划为准。 +公开的实现与验收以已合并 PR 和版本化 Host profile 为准;不把旧测试数字、旧分支状态或合成服务结果当作新证据。 diff --git a/docs/extension_pilot_expansion_plan_2026-10-12.md b/docs/extension_pilot_expansion_plan_2026-10-12.md new file mode 100644 index 00000000..65aa1053 --- /dev/null +++ b/docs/extension_pilot_expansion_plan_2026-10-12.md @@ -0,0 +1,416 @@ +# 插件基线收口与后续贡献候选 + +> 历史计划:插件施工顺序已由 [2026-10-21 社区预览与商店计划](extension_community_preview_and_store_plan_2026-10-21.md) 取代。 +> 以下日期、基线与记忆 M0 → M2 → M1 顺序仅记录当时决定;记忆仍为独立候选,不是当前插件施工前置。 + +> 更新:2026-10-14。P0/P1/P2 已完成并合入;P3 以后保留为设计候选,未启动。 +> 本轮仅做文档收口。后续优先讨论 Host 记忆 M0 → M2 → M1;不自动按旧阶段表继续扩展类型。 + +## 0. 文档职责与基线 + +已实现支持范围、边界和验收见 [Host profile](https://github.com/Code-Amadeus/Amadeus/blob/b5a889eccd30301957219d6f8a562f00d62a3ad7/docs/extension_host_profile_v0.1.md);本文件只说明候选贡献及依赖。 +原始内部审计和本地备份未随本仓发布,不作为读者前置材料;可复核的历史边界见 [插件验收历史](extension_validation_history_2026-10-13.md)。 + +- Host main `b5a889e`:#189/#198/#197/#188/#191/#192 全部已合并。 +- 协议 #1/#2 全部已合并;main `bf1a848` 保留 Host pin `b30df73`。SDK 源 `49449a0`、wheel 摘要 `274bd66b81932c8ea50f39f58e7bf91530a8015196acfcf9bcfd77fa2d8b8084` 不变。 +- 许可证/SDK Apache-2.0、模板及自有示例 MIT-0、Host AGPL-3.0 已确定。Host 自带固定 wheel;默认前端构建不调用 Python 构建 SDK。 +- 最终 #191/#192 各 16 项 CI、协议 #2 三项 CI 通过;合并后的主线 CI 也已核对通过。 + +## 1. 现有统一模型 + +只有一种包清单、一套 Electron 安装/记录事务和一个 Plugins 管理入口;Settings 复用它。 +支持矩阵 `server/extensions/profile.json` 与生成的 Electron 副本控制可接入类型;各原生 owner 保留运行和权限责任。 + +| 类型 | 当前状态 | 接入与生效 | +|---|---|---| +| TTS | 已实现 | 选择语音;保存后重启 | +| MCP | 已实现 | 绑定兼容 Work Provider;保存后重启 | +| Skill | 已实现 | 安装一次、选择 Agent;保存后重启 | +| 主题 | 已实现 | 纯前端即时;混合包整包随后端重启 | +| surface / ASR / 可安装 AUIP | 关闭 | P3/P4/P5 候选,尚无当前 Host 准入 | +| context_source / observer / 角色或形象包 | 关闭 | 分别等待记忆或形象契约,不由此表自动开放 | + +安装不授予 Main Chat 执行权。现阶段 `permissions.host` 必须为空,无 Grant 记录;OS 声明不等于 OS 沙箱。 +“保存”“明确接受/拒绝”“没有有效回执”“实际健康”“文件已清理”是不同事实。 +混合包停用/卸载先关闭前端,旧后端可仍待停止;只有真实引用和确认退出允许清理。 + +## 2. 已完成依赖 + +| 工作 | 合并提交 | 后续可复用内容 | +|---|---|---| +| #189 | `d9bfb1a` | 普通文件/AUIP 分类、来源和读取边界 | +| #197 / #198 | `52194c0` / `0dbf7ef` | 连接测试环境、后端停止边界 | +| #188 | `c569a0f` | 生命周期、TTS/MCP/Skill、最小 SDK | +| #191 | `2799ba8` | 支持矩阵、修订与启动回执、无代码包准备 | +| #192 | `b5a889e` | 主题与统一管理入口 | + +不存在仍需合入的临时 base 或 P1/P2 PR 栈。新的工作从最新 main 开始;任何旧方案中的“不合并”“等待父 PR”只属于当时授权,不是当前状态。 + +## 3. P1 收口 + +支持矩阵、TTS/主题选择验证、保存/实际修订和 `runtime.kind=none` 资源准备已实现。 +资源包不调用 pip/venv,不导入包代码;POSIX 可执行位保留,特权位不继承。 +后端明确拒绝携带有效身份/修订并结案;无有效回执继续待确认,不能伪造成功。 +本阶段未改 ASR/wake 选择器、未开放 Host 权限、未扩充未来 Runner/SDK 入口。 + +## 4. P2 收口 + +主题保留 classic/wallpaper-slice 的 base 身份,扩展 token 单独叠加。v0 背景仅 PNG/JPEG,校验真实解码、包内路径、8 MiB/4096 像素上限和实际绘制对比度。 +状态色保留给 Host;可用原生快捷键和 Windows 托盘恢复内置主题。 +混合包更新删除/改名主题时,重启前保持已加载版本;已删除的选择回退并引导重新选择,只有新版本确实含该主题时才建议重启。 + +统一 Plugins 提供安装、披露、状态、配置/凭据、Agent 绑定、更新、停用、卸载和数据保留。主题选择留在 Appearance,语音选择留在 Voice,通过链接进入同一管理器。 +没有第二套生命周期。真实资源/混合包探针与 GUI 验收已完成;驱动提供确认的探针不冒称人工原生确认。 +混合包旧引用释放后没有自动启动回滚;DSH/ACL、跨平台 GUI 和自然语音的限制仍见 Host profile。 + +## 后续候选的阅读方式 + +以下 §5–§10 保留未实施设计细节,不是当前 API 或施工授权。旧行号只是原提案定位;开工前必须对照新的 main 复核。 +P3 网页层候选 MIME(例如浏览器可解码 WebP)不改变已实现主题 v0 仅 PNG/JPEG 的限制。 +每一阶段都须先有用户旅程、owner、权限边界、回滚/清理语义和允许/拒绝两侧验收,再决定是否实施。 + +## 5. P3:扩展窗口(未实施候选) + +### 5.1 扩展窗口(唯一新增的 owner) + +**结构:** 与 Work Preview 相同,由 Host 外壳页面加一个沙箱内容视图组成。内容视图的配置参照 `openAuipInWorkPreview`(`electron/src/main/index.ts:2051`): + +- `sandbox: true`、`contextIsolation: true`、`nodeIntegration: false`、`webSecurity: true`; +- 分区为 `persist:amadeus-ext-`。同一个包的所有窗口共享这个分区,不同包之间隔离。 + +**规则按 Session 统一管理:** + +- Electron 的 `webRequest` 对每个事件只使用最后注册的那个监听器,而 `restrictAuipContentNetwork`(`index.ts:1882`)每次调用都会注册一个新监听器。因此不能每开一个窗口就注册一次。 +- 改为在分区首次创建时,按 Session 安装一次监听器,查询该包统一的策略对象。 +- 窗口级的状态(例如文件查看器当前打开的是哪个文件)放在通信桥里,不放进 webRequest。 +- `setPermissionRequestHandler` 和 `setPermissionCheckHandler` 都设置为一律拒绝,设备和屏幕采集的处理器也同样拒绝。 + +**稳定的 origin:** + +- `amadeus-ext` 协议的主机名取包 id 的稳定哈希,不取安装 id。这样更新后 origin 不变,保留的网页数据仍能访问。 +- 重新安装同一 id 的包会沿用同一个 origin。卸载时选择删除数据,则清空该分区的存储。 + +**资源协议:** + +- scheme 的 standard/secure/supportFetchAPI 属性在 app ready 前一次注册;资源 handler 只挂在对应扩展 Session,主窗口 Session 不提供扩展文件。 +- 同包稳定 origin 下,用贡献 id 对应的路径前缀映射已声明 root,复用 `assertExtensionPath` 的无链接检查;只从实际加载版本提供资源。同包共享 origin/storage 是明确的信任范围,不宣称同包各网页相互隔离;不同包不能读取对方资源。 +- MIME 白名单:html、js、mjs、css、json、png、jpg、webp、svg、woff2、wasm。 +- 每个响应附带 CSP:`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' <声明主机>; object-src 'none'; frame-ancestors 'none'`。 + +**守卫与网络:** + +- 导航守卫沿用 `guardAuipEntryNavigation`(`index.ts:1888`)的做法,只允许包内导航;拒绝打开新窗口、webview 和下载。 +- 网络默认只允许访问本包的协议地址;`permissions.os.network` 中列出的 `https://` 主机放行。网页层的网络限制由 Electron 真正执行,界面上写"已限制";Runner 的网络披露仍写"未强制"。 + +**外壳与窗口规则:** + +- 外壳显示扩展名、版本、"扩展内容,非 Amadeus"标记和关闭按钮,扩展无法修改外壳。 +- 主窗口获得焦点时,扩展窗口取消置顶;主窗口失焦后仅为用户原本选择置顶的小组件恢复。来源栏不能进入扩展的可绘制区域。 + +### 5.2 通信桥 + +- 内容视图只加载一个最小 preload:`electron/src/preload/extension.cts`。它只暴露 `amadeusExtension.request(method, params)` 和 `amadeusExtension.on(event, handler)`。 +- 不暴露 `window.amadeus`,页面也拿不到后端连接凭据。 +- main 校验已登记的 sender、主 frame/origin 与仍有效的加载修订,确定包、贡献和呈现方式;不信任参数自称身份,不接收其他 frame 冒用。关闭、导航失效、停用、更新切换后撤销旧视图的桥身份。 +- 报文格式和错误码沿用 Runner 协议(`-32601`、`-32602`、`-32001 capability_denied`)。 + +| 方法 | 可用于 | 结果 | +|---|---|---| +| `host.context.get` | 全部 | locale、主题 token 值、`colorScheme`、包 id 和版本、呈现方式 | +| `host.config.get` | 全部 | 本视图实际加载快照的非密钥配置,不能把新期望配置注入旧版本页面 | +| `host.config.update` | settings | 经同一校验器写期望记录,沿用 §1 的整包生效边界。不能读写密钥。若视图加载修订已不同于期望修订,则拒绝旧页保存并引导回 Host 管理页,避免旧 schema 覆盖新配置。 | +| `viewer.document.get` | file_viewer | 当前文件:`{title, suffix, sizeBytes, sha256, content}` | +| 通知 `host.context.changed` | 全部 | 主题或语言变化 | + +本阶段不提供:后端调用、Main Chat、Work、让角色发言、读取其他包或其他文件、与本包 Runner 通信。 + +### 5.3 三批呈现方式 + +```json +"contributes": { "surface": [ + { "id": "pomodoro", "contract": "amadeus.ui.surface/v0", "presentation": "page", + "root": "ui", "entry": "index.html", "title": { "zh-CN": "番茄钟" } }, + { "id": "csv-table", "contract": "amadeus.ui.surface/v0", "presentation": "file_viewer", + "root": "viewer", "entry": "index.html", "file_types": [".csv", ".tsv"] } +]} +``` + +- **P3a:扩展窗口、通信桥、页面(page)。** 在 P2 已有的“插件”管理页增加打开入口;5.6 的共同安全与页面验收在本批完成,其他呈现专属验收随 P3b/P3c。 +- **P3b:设置页(settings)与文件查看器(file_viewer),依赖 #189。** + - settings:管理页里每个包上有"打开扩展设置",只能读写本包的配置。 + - file_viewer:`file_types` 只能是 `TEXT_SUFFIXES`(#189 的 `server/work_artifact_source.py:12`)的子集。`WorkFilesPanel.tsx:67` 的预览区提供"用 <查看器> 打开"。Host 沿用已登记版本的校验路径读出内容,再交给查看器;查看器拿不到文件路径,也接触不到 Work 账本。同一文件类型有多个查看器时,由用户选择并记住默认值。 +- **P3c:小组件(widget)。** + - 紧凑型扩展窗口,Host 限定尺寸(例如不超过 360×240),位置由用户拖放。 + - 允许置顶,但受 5.1 的焦点规则约束。 + - 每个包最多一个;失去焦点时节流。 +- **资源上限:** 同时存在的扩展内容视图数量设上限(例如 4 个),超过时提示用户关闭。每个视图的内存占用显示在管理页上。 + +### 5.4 "插件"管理页(统一入口) + +- P2 在主导航新增"插件"入口。它是所有已安装类型的管理页,提供:运行状态、待生效变化、启用/停用、配置与凭据、Agent 绑定、更新、卸载与数据保留;P3/P5 只逐步增加对应“打开”动作。 +- 页面和 AUIP 应用只是管理页里可以打开的内容。 +- 设置 → Extensions 导向同一管理视图或复用同一个组件与数据源,不创建第二份草稿/生命周期状态。Voice、外观、Agent 页只负责选择或使用能力,并链接定位到所属插件。 +- 插件卡片区分“已保存”“当前加载”“健康状态”“待停止/删除失败”,支持按类型/状态筛选;更新/移除仍从卡片进入。没有 page 贡献的 TTS/MCP/Skill/主题包也必须可见、可管理。 +- 入口与桥分开:Host 的凭据编辑只在可信管理组件,扩展自己的 settings 页面不能代替它或取得密钥。 + +### 5.5 生命周期与版本释放 + +整包版本切换适用于主题、surface、TTS/ASR、MCP/Skill 和以后 AUIP 的组合。**先证明旧 owner 已释放,再清理旧资源;保存期望状态不是释放证明。** + +| 操作 | 只含前端贡献的包 | 旧/新版本任一包含后端贡献的包 | +|---|---|---| +| 首次安装 | 默认停用;确认启用后才能选择/打开 | 默认停用;启用保存为待重启,重启应用前不开放网页/主题/后端贡献 | +| 配置/更新准备失败或用户取消 | 期望记录、活动版本、存储保持原样 | 同左,运行中的旧后端不受影响 | +| 更新/配置提交成功 | 关闭旧视图并撤销桥,切换同包资源/配置,释放旧引用后清理;不自动重开窗口 | 保存期望 v2/r2;旧窗口、后端、新打开的允许入口仍使用活动 v1/r1,不混读新配置或网络披露;管理页明示待重启 | +| 停用/请求卸载 | 阻止新打开、关闭窗口、主题回退;释放引用后完成清理 | 立即执行相同前端收口;后端 v1 到重启才停止,显示“前端已停用,后端待重启” | +| 再次启用 | 确认后应用当前有效记录 | 确认后保存为待重启;不重开仍待停止的旧版本网页 | +| 重启与换版 | 不要求重启后端 | 先关闭旧视图、撤销旧桥并确认旧后端及其受管资源停止;再启动/注册新快照,由对应 owner 确认实际应用后开放该版本的前端入口 | +| 回收旧安装目录 | 所有视图/协议读取/主题资源引用释放后 | 上述前端引用已释放,且 Runner、MCP 与使用包资源的受管 Agent 会话已终止或明确释放引用后 | +| 卸载保留数据 | 保留包数据和网页分区,清理安装文件及配置/凭据记录 | 等后端停用与引用释放后完成卸载,保留相同数据;不把等待当成已卸载 | +| 卸载删除数据 | 关闭所有视图及相关执行上下文后清空分区、缓存与包数据,无需无关后端重启 | 确认该包的前后端 owner 都已退出后,清空分区、缓存与包数据 | + +- **停止/清理失败:** 不删除仍可能被使用的目录或数据,不提前销毁所有权标记,保留 pendingRemoval/错误供重试;不能通过改路径、降权限隔离或强制覆盖解决。 +- **提交后的激活失败:** 与“准备失败”分开报告。保留已提交期望记录和错误,不能报告新版本已运行;新后端未接受快照时混合包前端入口保持关闭。已停止的旧后端不自动复活,也不自动降级版本;修正配置或显式重新安装后重试。 +- **包种类改变:** 后端包更新成纯前端包也要先按重启释放旧后端;纯前端包更新成混合包也不能提前开放新网页。类型变化不能绕过同一次安装事务。 +- **保留数据重装:** origin 与包数据身份按包 id 保持;包默认停用,移除的配置、凭据和 Agent 绑定不自动恢复。包自行负责其保留数据格式兼容,Host 不执行隐式数据迁移脚本。 +- **进程重启恢复:** Electron/后端重启后从期望记录和实际启动结果重建状态;没有运行进程时不得沿用上次的“运行中”标记。正常启动、启动失败及未完成删除均要覆盖。 + +### 5.6 验收 + +- **安全(每条都要有自动化测试):** + - 页面中看不到 `window.amadeus` 和后端凭据。 + - 不同包的存储相互隔离。 + - 同一个包打开两个窗口时,网络策略不会相互覆盖。 + - 对未声明主机的 fetch 和 WebSocket 被拒绝。 + - 导航到包外、打开新窗口、下载被拒绝;权限请求与权限查询都被拒绝。 + - `file://` 不可读。 + - 错包/子 frame/已关闭或旧修订的视图不能使用桥;旧 settings 页面不能覆盖新期望配置。 +- **数据和版本(P3a):** 同包不同窗口共享存储、不同包隔离;v1→v2 后 origin 不变;纯网页包与网页+TTS 混合包分别执行 5.5 矩阵。覆盖更新前后新开窗口、Electron 退出重开、后端启动失败、卸载清理失败重试、保留/删除数据后重装,不能只测资源复制。 +- **呈现:** + - 外壳始终显示来源。 + - 主窗口获得焦点时,扩展窗口不在其上方。 +- **设置与文件查看器(P3b,基于 #189):** + - 配置只写本包已声明的非密钥字段;普通页面不能调用 settings 专属方法,密钥字段不能读写。 + - 只收到被打开文件的当前登记版本。 + - 文件被改动后拒绝打开,并提示刷新。 + - 明确打开文件/查看器即仅同意交付这一已登记版本;有网络声明时在 Host 选择处展示。对内容和默认查看器选择做更新/卸载回归,不向扩展传递路径、账本或任意文件读取方法。 + - 回归普通文件/AUIP 混合交付:报告仍归文件,查看器打开不构成 AUIP Attach,也不接受 WorkItem。 +- **小组件(P3c):** 用户选择置顶→Host 得焦→取消置顶→Host 失焦→恢复用户偏好;未选置顶的不提升。测试尺寸上限、位置恢复、每包数量限制、禁用/卸载和节流。 +- **性能:** 达到内容视图上限后明确拒绝额外打开;冷启动基线之后打开/关闭 20 次,受管视图与活动任务归零,区分 Chromium 可复用闲置进程和孤儿执行;记录内存是否持续增长及测量口径。 +- **示例包(全部本地运行,不联网):** 番茄钟页面、CSV 表格查看器、桌面时钟小组件。 +- **实机证据:** 每批使用构建后的 Electron 操作完整路径,附截图/短记录和确切命令;安全边界自动化、界面 smoke 与人工/真实进程验收分开报告。有关 API 约束参见 [Electron WebRequest](https://www.electronjs.org/docs/latest/api/web-request)、[Session 权限处理](https://www.electronjs.org/docs/latest/api/session)、[IPC sender 校验](https://www.electronjs.org/docs/latest/tutorial/security#17-validate-the-sender-of-all-ipc-messages)。 + +## 6. P4:ASR(先只做最终识别,再接投机识别) + +### 6.1 P4a:只做最终识别 + +- **Host 侧:** + - 在 `ExtensionRuntime.start` 中用 `register_asr_backend`(`asr/registry.py:51`)注册 `ExtensionASRBackend(BaseASRBackend)`。 + - `supports_speculative_transcription` 固定为 False。 + - 沿用现有接口 `transcribe(audio, sample_rate, context)`(`asr/backend.py:43`)。 +- **Runner 侧:** + - P4a 才扩展 Host 注册/健康检查和 SDK 方法分派,支持 `voice_backend/asr`;仍按声明精确核对注册 ID,不为 future 类型创建通用动态执行入口。 + - 一次识别是一个带截止时间的请求。 + - 沿用 voice-v0 的 `segment_id`(本阶段用于单次请求关联)、`mode=final`、16000 Hz 单声道 PCM16/base64、最长 120 秒、hotwords 与 deadline。Host 在现有 float32 接口和 wire 间转换;不向旧接口虚构 speculative 身份。 + - 超时后发送 `$/cancel`。只有收到终态/取消确认,或确认执行进程退出,才释放并发名额;Host 丢弃迟到结果或移除等待者不是释放证明。容量满时快速返回可见不可用,不重试付费识别。 + - 这套请求生命周期与 TTS 共用,在 Runner 客户端中统一实现。 +- **选择:** + - 到 P4a 才为 `ASR_BACKEND` 接入 #191 已有的贡献选择校验,接受完整 id;沿用内置 ASR 选择原有的 `apply: host`,新增扩展包的生效边界另按本节验证。 + - `WAKE_ASR_BACKEND`(`config/catalog/voice/wake_asr.json`)不接受扩展。 +- **隐私:** 远程 ASR 在选择处标明"麦克风音频会离开本机"。只有被选中的后端会收到音频。 +- **失败:** 沿用现有的 ASR 失败路径(提示并允许打字),不自动换用其他远程后端。 +- **SDK:** 提供 `@extension.asr("id")`,同步函数返回 `str | None`。 +- **验收:** + - 最终识别、取消、超时、Runner 崩溃、重启恢复各有测试。 + - 同包 TTS+ASR 的注册、共享容量与故障范围;连续慢取消不耗尽崩溃预算,迟到文本不成为新的用户输入。 + - 错误采样率、超时长、非法音频、空结果;选择变更发生在录音/识别期间时不串后端、不重复交付同一段结果。 + - 无扩展时内置后端选择、wake、回退行为不变;新增 ASR 安装需重启,已注册后端的选择沿用原有即时语义。 + - 打断(barge-in)和回声防护不受影响。 + - 确定性合成音频覆盖协议;真实麦克风用同一组约 5 秒语音对比内置与扩展的端到端延迟,并验证打断、恢复、空音频与键盘可用。分别记录本地/远程服务、硬件与测试范围;没有 key 时把远程验收列待办,不能以 mock 冒充完成。 + +### 6.2 P4b:投机识别(同一阶段,分开验收) + +- 当前 ASR 接口没有 segment 身份和识别模式;投机识别作废时,只是把结果丢弃。接入投机识别需要: + - 在 Host 的 ASR 接口中引入 segment 身份和模式(final/speculative); + - segment 身份由 capture/manager 创建并沿同一段的调用传播;不由插件按音频长度或文本猜测。内置调用保持原有端点/复用语义。 + - 同一 segment 的最终识别到来时,取消尚未完成的投机请求;并发名额仍按 P4a 的终态规则释放,不能在发送 cancel 时提前释放。明确 final 的有界准入与过载反馈,不用无限排队掩盖慢取消。 +- 清单通过 `speculative: true` 声明支持。没有声明的后端继续只走最终识别。 +- **验收重点:** 内置后端的语音端点判断行为不变。扩展后端的投机结果只作为提示,只有最终结果会成为用户的这一轮输入。 +- **退出证据:** 覆盖暂停后继续说话、跨 segment 迟到、投机已完成/仍在运行/失败、final 与取消竞态、打断后重新录音;每段最多一次最终交付,无旧投机文本串入。实机比较 final-only 与 speculative 的延迟及识别结果,同时通过内置语音基线回归。 + +## 7. P5:可安装的 AUIP 应用(先完成"应用来源"边界) + +技术前提为 P3a 已验收,且施工 main 包含 #189(当前已满足后者)。P3b/P3c/P4 无直接调用依赖;是否以及何时实施 P5 须单独确定,不将它们或 P5 作为记忆的前置条件。 + +- **应用来源边界:** + - 现在的发现、启动前重新校验、Attach 都依赖 Work 产物。因此不能只往候选列表里加一个 `package:...`。 + - 新增"应用来源"接口:Work 产物来源和已安装包来源,各自在发现、启动前重新校验、Attach 这三处证明应用的身份、版本和可启动内容。 + - 之后复用现有的票据、类型化动作校验、回执和叙述机制。 +- **包来源:** + - 身份为 `package:/@`,没有 work_item_id。 + - 该字符串只用于引用;授权与会话实际绑定包 id、贡献 id、版本、已验证安装摘要和活动修订,不能仅凭可重复的版本号判定同一内容。 + - 可启动内容是已安装包目录中的 entry 和 AUIP manifest。安装时由可信检查器用 `server/auip_contract.py` 的 `parse_manifest` 校验,并记录摘要;启动前重新校验时比对这些摘要。 + - `AuipAppSession`(`server/auip_runtime.py:110`)的 `artifact_ref` 记录包引用。不写 Work 账本。 +- **数量与选择行为:** + - 现有发现是"全有或全无":任一目录不完整时,结果整体作废(`server/auip_launch.py` 中 `project_candidates` 返回空并标记为不完整)。 + - 已安装应用独立成有界列表,本阶段最多启用 8 个应用贡献(按贡献计数,不按 wheel 个数),在启用和更新时统一检查,超限不部分提交。 + - Work 来源保留 #189 基线的界限与完整性判断。组合快照的预算明确为原 Work 有界集合加最多 8 个包候选,不能再对并集静默截成原来的 8 个;有任一相关来源不完整时,不把另一个来源的单个结果擅自当成全局唯一。 + - 同名或省略来源时由现有语义入口澄清,不按类型优先级猜测;用户明确选中的有效来源可独立校验。安装包数量不会改写 Work 集合的 complete 标志。 +- **前端:** 应用在扩展窗口中承载,沿用同一套沙箱与网络规则,再加上 Host 指定的精确 `/auip/ws` 票据连接例外;不放行整个 loopback 或其他后端端点,不暴露普通后端凭据。 +- **产品语义:** + - 已安装应用是能力,不是 Work 产出。它出现在"插件"管理页和语音启动候选中,不出现在 Project/Draft 的产物架上。 + - 只在用户请求时启动,角色不会主动打开。 + - 没有启用任何已安装应用时,AUIP 候选与提示逐字节不变。启用后,候选集合会变化,建议按路由实验的惯例先做对照。 +- **验收:** + - 用语音或文字说"打开 X"可以启动应用。 + - 角色的动作经过校验后执行,并有回执;关闭应用后会话结束。 + - 分别验证已启用 0、1、上限个已安装应用,以及尝试启用第上限加一个(在启用时被拒),并与 Work 应用同时存在的情况。 + - 同名 Work/包应用必须澄清;来源不完整、并集合计超过旧上限、更新新增贡献越过包上限时不误选、不截断、不半提交。 + - 包 entry/manifest/相关已登记内容被篡改,或发现后但 Attach 前发生更新/卸载时,重新校验拒绝过期身份与票据。 + - 正在运行时更新、停用、卸载遵守 5.5;关闭或后端重启后旧票据/会话不能执行动作,不遗留内容视图。 + - 卸载并重启后,该应用不再是启动候选;Work 生成的应用行为不变。 + - 实机同时交付普通报告与 Work AUIP,再打开一个包应用:三者位置、身份、会话、回执可分别验证;包应用不写 Work 产物账本。无包应用启用的对照 fixture 保持原候选和 role 请求字节一致(仅排除已明确的非确定性时间/ID)。 + +## 8. 未来类型的协议同步要求 + +协议 #1/#2 已合并;当前 Host pin 是 `b30df73`,SDK 源仍为 `49449a0`。 +后续每种贡献都必须由真实调用方驱动,配套契约、Host 支持矩阵、示例和正反验收;未知类型维持关闭。 +P3/P4/P5 的候选设计不表示当前契约已经开放。ASR final/speculative 分别验收,不提前扩充 SDK 分派。 +`context_source`/`observer` 等记忆契约待 M0/M2 确定语义后再在 M1 评审;不随网页或 ASR 阶段搭车。 + +## 9. 插件商店与热重载(后续阶段) + +候选目标:用户在"插件"管理页(5.4)中浏览、下载、安装、更新插件,部分插件支持热重载。以下是立项后才评审的候选设计,不是当前管理器已提供的能力。 + +### 9.1 商店:只做索引,不托管安装包 + +- **索引仓库。** 一个公开的 git 仓库或目录,每个包一个 JSON 条目,包含: + - 包 id、展示信息、维护者; + - 每个版本的产物地址(GitHub Release 或 PyPI)、sha256、清单摘要、最低 Host API、支持的平台; + - 等级:community 或 verified。 +- **收录流程。** 作者提交 PR,CI 执行检查: + - 与 Host 相同的清单和包检查; + - 下载产物并核对 sha256; + - 拒绝 `.pth`、源码包和超出限制的包; + - `io.github..*` 命名空间必须与 PR 作者的 GitHub 账号一致。 + + verified 等级另需维护者人工审查。等级只影响提示的强度,不影响任何权限。 +- **撤回。** 索引包含已撤回的版本和封禁列表。已安装的版本若被封禁,下次启动时不激活并显示原因;数据保留,不静默删除。 +- **信任前提。** sha256 只有来自可信索引时,才能用来校验镜像提供的包;如果镜像同时提供可被替换的索引和 wheel,攻击者可以同时修改两者。因此: + - 索引只从官方来源经 HTTPS 获取,或者使用签名索引,由 Host 内置的公钥校验签名; + - 镜像只用于下载产物,产物按可信索引中的 sha256 校验; + - 如果镜像也要提供索引,那份索引必须带签名。 +- **客户端。** + - 只在用户打开管理页的商店视图时拉取索引,没有后台上报,也不统计。 + - 下载完成后,走与本地 wheel 完全相同的安装事务和确认页,确认页显示等级与来源。 + - 离线时仍可安装本地 wheel。 +- **S1 引入的管理字段:** + - 记录的来源(本地文件或索引)与等级(local、community、verified); + - 安装入口接受索引给出的预期 sha256,不符即拒绝。 +- **更新。** 有新版本时提示"有更新",由用户点击更新,不做自动更新。确认页沿用 Plugins 现有边界,突出显示新增的披露与贡献。 +- **依赖。** 仍然只安装 wheel。条目展示依赖的下载体积。需要 CUDA 版 torch 这类重型 GPU 依赖的包,不进入商店的第一阶段。 + +### 9.2 热重载:按 owner 分级,不回到"整条管线热切换" + +| 类型 | 目标生效方式 | 机制 | 阶段 | +|---|---|---|---| +| theme(只含前端类型的包) | 即时 | 前端 owner 重新渲染 | 已实现;文件监视热重载未实现 | +| surface(只含前端类型的包) | 即时 | 前端 owner 重建视图 | P3 候选,未实现 | +| auip_app | 即时 | 协调后刷新包应用来源;已打开的会话不受影响 | H1 | +| TTS、ASR | Runner 换代 | 稳定代理内部先启动新一代 Runner,就绪后新请求转到新一代,旧一代在有界时间内结束;TTS 管线和 ASR manager 都不感知 | H2 | +| mcp_server、skill | 新会话生效 | 协调后更新进程内的 MCP/Skill 注册表;已有会话按 Skill 修订规则继续或被拒绝 | H3 | +| 内置设置的选择(`TTS_BACKEND` 等) | 不变 | 沿用各自原有的生效语义 | — | + +- **通道(H1 引入):** + - Electron main 通过一个已鉴权的后端方法 `extensions.reconcile { records }` 推送期望状态。格式与启动快照相同,经过同一套校验。 + - Python 逐包比较期望与实际状态,操作幂等,并回报每个包实际生效的 revision。 + - 启动流程同时改为一次协调。 +- **包版本:** 当前混合包仍按 §1 的规则整包随重启切换。H1/H2 若允许旧一代已准入请求收尾,则需要单独定义“当前接受新请求的版本”和“仍持有资源的旧一代”,不能沿用“只有一个 revision 就可删除旧目录”的推断;网页切换、引用释放与数据兼容在热重载阶段重新验收。 +- **安全规则(与协议 §4.4 一致):** + - 新一代启动失败时,只要旧一代仍被允许,就由旧一代继续服务。 + - 停用、卸载、清除必需密钥会立即关闭准入,不依赖旧一代。 + - 已经失败的请求不重放。 + +### 9.3 开发者模式 + +- 贡献者可以把源码目录"链接"为本地包:等级为 local,永远不会自动启用。 +- 网页类型的文件变化时自动刷新视图;Runner 类型的文件变化时触发换代(H2 之后)。 +- 从商店安装的包不受文件监视。 + +## 10. 形象包(后续阶段,第一批不做) + +目标:社区用 SpriteForge 制作的角色素材(或 Live2D 模型)作为扩展安装,在外观页全局选择。 + +它与人格、记忆无关:形象配置由整个应用共享,不使用 `character_id`(`docs/live2d_character_visuals.md`)。以后人格配置只需引用形象配置的 `profile_id`,就能做到每个角色各用自己的形象。 + +### 10.1 现状(2026-10-12 核对 Amadeus main 与 SpriteForge main `ce25816`) + +- **SpriteForge 导出的包里已经有:** + - 全部动画片段与贴图; + - 行为图:节点、自动跳转的权重,以及只能由意图触发的手动边(`prob: 0`); + - 嘴型配置。凡绑定了嘴型的循环片段都会导出嘴型配置与覆盖层,所以包里已经标明哪些节点是说话循环(SpriteForge `src/spriteforge/production/exports.py:176-183`)。 + + 因此任何包装进来,待机遍历和说话循环上的口型同步都能工作。 +- **包里没有语义绑定。** Amadeus 遇到开始说话、认真说话、思考、要点强调、各情绪的进入/说话/停留、说完释放这些事件时,进入哪个节点是按 Kurisu 包的节点名写死的: + - `render/spriteforge_animator.py:42-107` 和 `render/spriteforge_intent.py:19-35`; + - 网页渲染器读取 Python 下发的配置,默认值也是这些名字(`render/web/renderer.js:1033-1035`); + - 另有两条按 Kurisu 美术调过的嘴型遮罩参数,写在 `spriteforge_animator.py:123`。 + + SpriteForge 那边也是这样划分的:`behavior.py` 开头写明它不实现语义别名、说话停留和说完后的释放;`docs/character_pack_authoring.md` 写明这些仍是 Amadeus 的运行时配置。 +- **加载位置固定。** 只从 Kurisu 的目录加载(`config/asset_paths.py:19`);形象配置只支持 Live2D(`render/visual_profile.py:60`)。 +- **小结:** + - 节点名照抄 Kurisu 的包,现在已经能说话、能做情绪; + - 自己命名节点的包,只有待机和口型; + - 两种包都只有放进固定目录才会被加载。 + +### 10.2 方案(按顺序) + +1. **包内语义绑定。** 这一步要改 SpriteForge 契约,工程量主要在这里。 + - character-pack 增加一份语义绑定文件,由新版本 runtime manifest 的字段引用。v1 manifest 是 `additionalProperties: false`,不能直接加字段;也不做按文件名猜测的探测。 + - 绑定声明以下内容:开始说话的入口(含权重和闭眼变体);说话循环的类别(普通、认真、思考、要点);各情绪的进入、说话、停留节点;说完后的释放。 + - 语义词汇与 Live2D 的 `emotion_map`、`render.character_intent` 使用同一套。 + - SpriteForge Studio 给节点增加语义标签,导出时写入并校验。 +2. **迁移 Kurisu。** + - Kurisu 包导出一份与现有常量完全等价的绑定。 + - `spriteforge_animator.py:123` 的两条嘴型参数移入 Kurisu 包的嘴型配置。 + - 验收:Kurisu 的事件、配置值、随机选择次数与改动前逐字节一致(角色身份方案对表现抽取的要求)。 +3. **Amadeus 读取绑定。** + - animator 和 intent 模块改为从包读取绑定,删除硬编码常量。 + - 没有绑定的 v1 包按"只有待机与口型"运行,外观页显示该包的能力说明。 +4. **Sprite 成为可选的形象配置。** + - `VisualProfileStore` 增加 `kind: "sprite"`,指向包目录,外观页可以选择。 + - Companion 和 VN 继续使用 Kurisu 素材,直到各自有实机验证。 +5. **扩展类型 `visual_pack`(数据层)。** + - 内容为 SpriteForge 包,或 Live2D 模型加一份默认映射。 + - 安装复用 #191 的无代码包准备(§3),用现有的 `tools/validate_character_pack.py` 和 Live2D 模型检查作为可信校验。 + - 候选接入方式为 Host 全局形象配置中的显式选择。 + +### 10.3 注意事项 + +- **版权:** 同人素材会很常见。商店条目要求作者声明素材权利,遇到投诉通过撤回列表(9.1)处理。Live2D Core 由用户自备,包内不得包含。 +- **体积:** 形象包常有数十到数百 MB。可信检查器现在的 256 MiB 上限需要为形象包单独评估;商店产物放在 GitHub Releases。 +- **性能:** 贴图内存计入现有的纹理预算与采样设置;切换形象时要释放旧贴图。 +- **声线不在此列:** 它涉及声优和角色声音的权利,需要单独定好政策后再考虑。 + +### 10.4 当前只保留关闭声明 + +- 类型表已包含关闭的 `visual_pack` 声明,原因是需要形象包导出的语义绑定契约。 +- 没有因此实现形象包读取或授予安装权限;真正开放须另行设计与验收。 + +## 11. 下一步优先级与开工门槛 + +2026-10-14 当时推荐顺序是:文档收口 → 记忆设计 Issue/验收范围 → M0 → M2 → M1,见[记忆衔接计划](extension_module_implementation_plan_2026-10-11.md)。本轮没有启动其中的实现。 + +| 独立候选 | 技术依赖 | 不应误解为 | +|---|---|---| +| 开发者校验/模板/合约工具 | 已实现贡献及协议来源 | 必须先发布 PyPI 或建商店 | +| P3a 网页窗口 | 已合入 P1/P2;新的窗口/权限设计 | 已获得执行权限 | +| P3b 设置/文件查看器、P3c 小组件 | P3a;文件查看器复用已合入 #189 | 记忆工作的前置条件 | +| P4a 最终 ASR、P4b 投机 ASR | P1/P2;P4b 依赖 P4a 的音频/取消语义 | 可以提前开放 wake 或全部 SDK 方法 | +| P5 可安装 AUIP | P3a、#189 与应用来源边界 | 普通文件可直接执行或 #189 已提供包应用 | +| M0/M2 内置记忆 | 现有会话/项目/账本、作用域与失败边界设计 | 必须等 P3–P5 做完 | +| M1 外部记忆 | M0/M2 已验证的语义 | 安装包即有历史访问权 | + +选中一个候选后再单独切 PR,避免重新形成跨多个未合并基线的长期堆叠。其他候选保持关闭。 +每次验收记录精确 base/head、平台、命令、通过/失败/跳过,以及自动化、真实进程、GUI、外部服务之间的区别。CI 绿灯不替代产品审批;历史结果不替代新提交验收。 +本次不发布新 Issue/PR、不修改协议或支持矩阵、不发送社区消息、不启动记忆与后续贡献实现。 diff --git a/docs/extension_store_demo.md b/docs/extension_store_demo.md new file mode 100644 index 00000000..c90b5170 --- /dev/null +++ b/docs/extension_store_demo.md @@ -0,0 +1,146 @@ +# Local end-to-end plugin store demonstration + +This is the functional-prototype acceptance path. It does not publish an index, +push a branch, approve real artifacts or change repository protection. The +maintainer side is the existing Git/CLI workflow; no separate administration +website is part of v1. + +## Prerequisites and entry point + +Use the Host checkout containing the store implementation without a personal .env, with its Python +core/dev and independent Electron/Pi Node dependencies installed. Build the +frontend with npm --prefix electron run build. Do not use directory junctions +to another checkout's dependencies. + +The catalog toolchain requires Windows and CPython 3.12.10. Supply a real clean +Git checkout of Host commit 1840c8e4f646992ddaebea3139c8895f29743a8f and its +minimal checker environment installed from tools/requirements-extension-authoring.txt +with pip --require-hashes. The protocol tools verify the commit, checker inputs, +Python/platform and dependency-file hash themselves. Use protocol main commit +e2fc0e93fa7cfd9bf98e378cac6e158f14181b9a (or a separately reviewed compatible +revision); its real bootstrap remains empty. + +Build reference wheels with python -X utf8 tools/probes/extension_author_journey.py, +or supply the exact Notes MCP and desktop-theme 0.1.0/0.2.0 wheels from the review +package. The demonstration snapshots these four files before serving them. + +Run from the Host checkout: + +~~~powershell +python -X utf8 tools/probes/extension_store_demo.py --execute --protocol-repo --checker-host --checker-python \Scripts\python.exe --wheels --report-dir build/pub +~~~ + +To exercise the actual packaged Windows desktop, add: + +~~~powershell +--packaged-executable electron/build/win-unpacked/amadeus-desktop.exe +~~~ + +The package still uses the explicitly supplied Python backend/runtime. This does +not claim a self-contained installer or clean-machine qualification. + +For this experiment's existing local environments, the complete command is: + +~~~powershell +& '..\qualification-20261021\fix-runtime\Scripts\python.exe' -X utf8 tools/probes/extension_store_demo.py --execute --protocol-repo '..\amadeus-extensions' --checker-host '..\qualification-20261021\pinned-host' --checker-python '..\qualification-20261021\checker\Scripts\python.exe' --wheels 'build\extension-author-journey\d151d209c26f\wheels' --report-dir 'build\pub' +~~~ + +Use a short checkout and report path on Windows: the generated plugin working +directory must remain below the Windows process-creation path limit. The final +integrated local run used `build/pub`; a deeply nested report path previously +produced `WinError 267` at a 260-character working directory. + +Each invocation creates a new run directory, new desktop settings/install/data +directories, a disposable HTTPS certificate and local Git clones without remotes. +An occupied backend port is an error; the demonstration never stops an unrelated +application. Native access dialogs are automatically confirmed only in this owned +test process. The visible desktop closes when the automated journey ends. + +## One connected workflow + +Each phase first commits proposed inputs in the disposable repository. The +unchanged protected-base check_catalog_ci.py checks those inputs and real wheels. +A local demo-approved ref then models the reviewed-input merge. A separate +publication commit contains the output of the original build_catalog.py. +check_catalog.py checks the transition, a second build with --check verifies +identical bytes, and protected-base publication CI independently rebuilds them. +Only a successful result becomes the HTTPS server's active index. + +The Windows desktop consumes those exact file bytes through its normal renderer, +preload, main-process catalog, installer and durable settings owners. Every +successful refresh compares the desktop cache SHA-256 with the generated index +SHA-256. The test transport redirects only the fixed official index request to +loopback; wheel URLs actually name the local HTTPS artifact service. Mirror +updates use that service's separate mirror path. No renderer URL/digest authority +or production catalog override is introduced. + +| Sequence | Maintainer operation | Client verification | +| --- | --- | --- | +| 1 | Add Notes MCP and theme 0.1.0 | Browse/install/enable; theme applies; installed Notes passes native MCP save/read/list | +| 2 | Add both 0.2.0 wheels | Installed-view manual updates through mirror HTTPS; enablement/binding retained; fresh application process and Notes execution | +| 3 | Copyright withdrawal of Notes 0.2.0 | Distribution refused, no security decision added; existing installed Notes still works | +| 4 | Security block both exact 0.2.0 artifacts | Installation/activation refused, theme suppressed; offline backup recovery retains bans; local wheel path also blocked | +| 5 | Explicit higher-sequence unban | Both safety decisions become allowed; theme can install again; independent Notes copyright withdrawal still applies | +| 6 | Restore Notes distribution | Notes installs/runs again; replay of authentic sequence 4 refused; unban/high-water sequence survive offline NUL recovery | + +After sequence 4, corrupting both settings copies must preserve unavailable safety. +An ordinary settings save or refresh cannot clear it. Explicit official recovery +restores the exact generated security decisions, without inventing lost installation +records. All damage is injected only after stopping the owned application; this is +not physical power-loss testing. + +The maintenance tools also reject a hand-edited publication that differs from its +approved inputs and a snapshot that drops known safety decisions. Rejected output +never becomes the server's active index. + +## Evidence and limits + +The top-level smoke report must say status=passed, publication.ok=true, and report +normal Electron/backend shutdown with no renderer errors. The run includes: + +- extension-publication-report.json: client assertions, observed snapshot hashes, + exact plugin identities, fresh-process count and Notes runtime results. +- publisher/report.json: all six input/publication commits, generator identity, + exact-rebuild results, four wheel hashes and HTTPS request evidence. +- publisher//index.json and publication.json: original generated bytes and + phase receipts; command-*.json records the real tools and exit codes. +- Original wheel-content inventories/diffs, native MCP contract JSON and screenshots. + +The review records explicitly identify demo-author/demo-reviewer and loopback +demo-only evidence. They exercise data contracts, not real human review or GitHub +identity enforcement. An HTTPS loopback certificate is trusted only by the owned +maintenance processes and Electron download session; system trust is unchanged. +Notes calls use the real native MCP client and installed package, not a model turn. + +Keep private keys, raw process logs, settings, provider files, environment directories +and temporary repositories out of a shared evidence archive. Export only the +documented result files, snapshots, synthetic input facts, wheels and screenshots; +sanitize local paths. Original index bytes retain their hashes and must not be +rewritten during sanitization. + +Public release still requires independent exact-wheel/asset review, public immutable +URLs and pins, remote CI, repository governance and an explicit release decision. +Those are separate from completion of this local functional-prototype journey. + + +## Lightweight submission and maintainer-only runs + +Authors need one preflight command and one submission PR. The protocol repository's +catalog/SUBMISSION.md and short catalog PR template explain the four evidence items; +existing README/release notes/logs are accepted. Updates reuse ownership and rights +evidence unless those facts changed. Maintainers prepare catalog records and the +separate publication PR; authors need no second form or reviewer roster. + +The demo now performs preflight, submits an incorrect digest, records protected-base +CI rejection and revises the same submission before simulated approval. Every stage +also verifies exact candidate and served index bytes, freshly downloading newly +available/relocated artifacts. This includes unblocking and restored distribution; +unavailable retired artifacts do not prevent urgent safety publication. + +When the regular desktop is in use, append --publisher-only to the same command. +This runs Git/preflight/revision/generation/HTTPS delivery without Electron or port +17777. result.json explicitly says desktop=not_run. This does not replace the +separate actual desktop qualification. No live application is stopped. + +The [2026-10-23 record](verification/extensions/community-preview-batch9-2026-10-23.md) +separates this maintainer-only run from the earlier complete desktop evidence. diff --git a/docs/extension_validation_history_2026-10-13.md b/docs/extension_validation_history_2026-10-13.md new file mode 100644 index 00000000..82c08c90 --- /dev/null +++ b/docs/extension_validation_history_2026-10-13.md @@ -0,0 +1,383 @@ +# Extension validation history + +These are historical observations at the recorded commits, including withdrawn evidence. +For current authoring use [the author guide](extension_authoring.md) and [Host profile](extension_host_profile_v0.1.md). + +## Evidence and explicit limits + +Windows local source builds are the tested runtime target. MiMo English live acceptance +is pending; Fish has one live Japanese synthesis, not a listening +or performance certification. Codex/Pi native lifecycle tests use controlled +model endpoints; DSH additionally has real-model and desktop evidence. + +The native Windows Electron lifecycle was also exercised in an isolated profile, +with a loopback model endpoint and a local synthetic tone wheel. The production +file picker, install/update/enable disclosures, settings store, Python backend, +independent Runner and playback path were used; no installer or confirmation +handler was mocked. This verifies lifecycle and playback, not paid-model or +natural-speech quality. + +| Native GUI journey | Observed outcome | +| --- | --- | +| Install 0.1.0, confirm, enable, select voice, restart | Installed disabled first; activation required confirmation and restart; Chat invoked the Runner with `ja`, neutral `speed=1.0` and 4,800 samples; physical playback started and finished | +| Update to 0.2.0 while active | Saved version became 0.2.0 while 0.1.0 and its installation stayed active until restart; afterwards the old process and directory were gone and only 0.2.0 synthesized | +| Resume Chat after restart | Previous messages reappeared and another reply completed through the new Runner | +| Remove with default data retention, restart | Package, process and installation were removed; data remained; saved voice was visibly unavailable and a new Chat reply remained text-only | +| Reinstall, then remove with explicit data deletion | Reinstalled disabled with the retained data; after confirmed deletion and restart, both installation and saved data were absent | + +![Native installation disclosure](images/extensions/native-install-confirmation.jpg) + +![Chat invoking the installed local test voice](images/extensions/native-chat-use.jpg) + +![Unavailable saved voice after removal](images/extensions/native-uninstalled-voice.jpg) + +DSH 0.2.0-rc.2 with MCP client 2.0.0 has a reproduced SDK diagnostic-to-stdout +defect when listing unsupported resources. Keep this combination experimental +until an upstream fix is consumed and the clean-wire probe passes. No Host line +filter or global console monkeypatch is installed. See the +[ACP diagnostics and upstream tracking](acp_provider.md#current-dsh-constraints-and-diagnostics-2026-10-12). + +DSH Windows workspace-write confinement requires both DACL and integrity-label +access. A directory can be writable and owned by the user while lacking +`WRITE_OWNER`, causing initialization to fail. The Host does not repair ACLs, +relocate work or downgrade to unrestricted execution. Use the read-only +diagnostic documented in [ACP setup](acp_provider.md). + +An extracted source fixture verifies the shipped SDK using Node with an empty +PATH and an unavailable Python override, then installs/imports the wheel offline +in a fresh environment. Damaged bytes fail verification. Runtime Runner and +example tests consume that same wheel, not a second source tree. Build tools +remain development dependencies; frontend builds do not invoke Python. +Public SDK publication, other-platform runtime qualification and natural-voice +Chat/Wallpaper/VN acceptance remain release work. Memory remains out of scope. + +## Licensing and SDK delivery (2026-10-13) + +Host code remains AGPL-3.0. The canonical protocol/schema/validator and standalone +SDK in Amadeus-Extensions are Apache-2.0. Authored examples carry MIT-0; external +assets, voices, models and dependencies retain their own rights. The current +SDK source pin is retained in protocol main history by the merge of protocol #1. +Theme schema/validator provenance tracks protocol #2 pending its acceptance; +the reviewed SDK wheel and digest are unchanged. There is no PyPI release, +development-directory loader or new diagnostic-log feature in this change. + +The installed SDK has PEP 639 license metadata and an embedded LICENSE. +`tools/check_extension_protocol.py --source ` verifies the +pinned text copies, wheel digest, metadata/license and packaged SDK source +against the recorded upstream commit. Source releases contain the same wheel. + +The canonical build uses CPython 3.12.10 and hash-locked development dependencies. +It normalizes text line endings and ZIP timestamps/permissions, stores entries +without compression and recalculates RECORD hashes and sizes. Protocol CI +compares wheels built independently on Windows and Linux. The Host carries that +artifact unchanged; runtime still requires Python >=3.11. Matching SDK build +bytes do not imply cross-platform qualification of the Host or third-party +extensions. + + +### Explicit backend startup receipts (2026-10-13 audit follow-up) + +The paired Electron/Python Host reports a received revision and a separate +`startup: accepted|rejected` result. A unique valid identity/revision rejected +by Python validation settles that launch without creating a loaded receipt. +A missing required configuration also rejects startup. Dynamic Runner health +and per-contribution availability do not rewrite a successfully accepted +snapshot. These receipts describe Host snapshot admission, not voice readiness. + +HTTP/JSON/status-section failures, an unreadable whole startup snapshot, +duplicate identities, missing/invalid results and mismatched revisions are +unconfirmed. They never count as rejection or success, never confer loaded +state, and retain pending work with a diagnostic. Host preflight failures remain +known rejections. Revision comparisons still prevent late replies from clearing +newer edits. No package filesystem or Runner authority is granted by a failure +receipt, and SDK bytes/wire contracts are unchanged. +## Local theme pilot acceptance (2026-10-12) + +The local branch integrates extension lifecycle 55551e1 and Work files/AUIP +adf1632 without changing either parent PR. P1 support/profile and persistence +rules are committed separately from P2 theme/UI work. The companion schema +source is pinned to 9c3f3a5 on a local protocol branch; publish that source +before opening a downstream PR that references it. + +Automated evidence includes 470 desktop unit tests; 130 Python extension, +SDK, files/AUIP regressions (2 skips); the official model-less Electron smoke; +and the production-component visual smoke in English/classic at 1100 px and +Chinese/wallpaper-slice at 620 px. These visual fixtures use synthetic Host +snapshots, actual React components and the actual theme validator/renderer. +They verify base identity, one installed style, Blob image visibility, absence +of covering builtin gradients and no horizontal overflow. They do not exercise +native installation confirmation. + +The following component-fixture screenshots were refreshed after the 2026-10-13 +audit repairs, including the synthetic loaded revision. They are not new native +dialog acceptance evidence. + +![Plugins with the light extension theme](images/extensions/theme-day-plugins-en-US-1100.png) + +![Appearance with the dark extension theme in a narrow Chinese window](images/extensions/theme-night-settings-zh-CN-620.png) + +A separate local diagnostic used the real Electron installer/store, trusted Python +wheel helper, native image/font APIs and an independent SDK Runner. Confirmation +decisions were supplied by the driver; it did not operate native dialogs. +The pure-theme lifecycle retained backend PID 11912, including cancel, invalid +update rejection, disable/re-enable, retained-data reinstall and explicit deletion. +The mixed journey used backend PIDs 12788 then 2272 and Runner PIDs 4296 then +34160. Completed tone requests were v1, v1 (after v2 was saved), then v2 (after +restart), each 4,800 samples at 24 kHz. Old installation references survived until +shutdown; final removal deleted the requested data. This is process/contract +evidence, not Chat playback or native GUI acceptance. + +The process journey is reproducible on Windows after the normal desktop build: + +```powershell +python tools/probes/extension_theme_lifecycle.py +``` + +The probe builds all synthetic wheels offline, creates a fresh profile, and +records the report under `build/extension-theme-lifecycle//report.json`. +It verifies real image decoding and installed fonts, pure-theme preparation +without pip/venv, actual revision acknowledgements, two mixed-package versions, +SDK audio and removal. Its isolated Python process hosts `ExtensionRuntime`; +it is not the HTTP/WebSocket server or the Chat playback path. Confirmation +choices are supplied by the driver and reported explicitly. + +The P2 native Windows GUI journey also passed in an isolated profile. The real +file picker and native install/update/enable dialogs were operated, followed by +production settings, the backend, independent Runner and Chat playback. A local +synthetic model and tone package keep this repeatable without paid services. + +| Native theme journey | Observed outcome | +| --- | --- | +| Pure theme install, enable and select day/night | Installed disabled first; both palettes applied immediately without backend restart | +| Cancel v2 update, then accept it | Cancellation preserved v1, its revision and installation; acceptance applied v2 and reclaimed the old resources | +| Invalid v3 theme | Contrast validation rejected the update and preserved working v2 | +| Disable/re-enable, remove, reinstall and delete data | Immediate fallback preserved the saved theme identity; re-enable restored it; retained data survived a disabled reinstall; explicit deletion removed it | +| Mixed theme/TTS v1, then save v2 | Saved revision 3/version 0.2.0 while loaded revision 2/version 0.1.0 remained active; Chat still invoked the same v1 Runner | +| Disable, then re-enable before restart | Frontend access closed immediately and stayed closed; the UI disclosed that the old backend continued until restart | +| Restart mixed package and resume Chat | Revision 5/version 0.2.0 applied together; old process and installation disappeared; history resumed and the new Runner played a response | +| Remove mixed package with data deletion, restart | Record, installation, data and Runner disappeared; theme/voice selections retained their unavailable identities; another Chat turn completed without speech | + +The pure-theme journey kept the same backend process throughout. The mixed +journey completed three Japanese-tagged requests in order: v1, v1 after v2 was +saved, and v2 after restart. Each produced 4,800 samples at 24 kHz; playback logs +recorded physical start and completion. The final text-only turn added no +playback. These are observed process and playback results, not a subjective +listening assessment of natural speech. + +Acceptance found two stale-feedback cases: a rejected update left an earlier +success notice, and a successful restart left the old pending-start notice. +Operation feedback now clears at the next attempt and confirms only that saving +succeeded; package cards remain the source of current application state. Both +cases have component regressions. The final build and visual smoke passed after +this presentation fix; the full native journey above preceded that final fix. + +![Native mixed-package update disclosure](images/extensions/theme-native-mixed-update.jpg) + +![Saved v2 while the original theme and backend remain active](images/extensions/theme-native-mixed-pending.jpg) + +![Chat history and playback after the whole mixed package switches to v2](images/extensions/theme-native-mixed-chat.jpg) + +![Saved theme identity remains visible after removal](images/extensions/theme-native-removed-identity.jpg) + +No remote paid synthesis, natural voice audition or cross-platform GUI +certification is claimed. Narrow-window evidence is from the production-component +Electron smoke, not the native-dialog journey. DSH/ACL constraints described +above are unchanged; this theme work does not qualify a new Agent path. + +## Audit repair revalidation (2026-10-13) + +The parent file-delivery branch now preserves amendment ownership through +recovery and keeps previews stable during unrelated renderer updates. The +extension branch builds with the documented core-only Python environment and +recovers overdue cancellation under the existing bounded budget. General MCP +environment migration and desktop process shutdown are separate reviewed +dependencies; this pilot consumes their contracts. + +After synchronizing those changes, the P1-only head passed 464 Electron tests +and the normal build. The combined P1/P2 head passed 478 Electron tests and +211 scoped Python regressions (2 skips), plus the core-environment build. The +actual model-less Electron smoke passed all 13 checks, including Plugins, +file/app separation and clean shutdown. The reusable theme lifecycle probe also +passed with the real installer/store and SDK Runner; its confirmation choices +are supplied by the driver, not native dialog input. + +The earlier native-dialog and physical-playback acceptance remains historical +evidence for its recorded head. No second full manual GUI run, natural-voice +audition or new Agent/cross-platform qualification is claimed for this repair. +The new cancellation and shutdown failure regressions address gaps that the +earlier normal lifecycle journey did not cover. + +## Mainline reconstruction and focused revalidation (2026-10-13) + +P1 is rebuilt from its own reviewed diff on main `c569a0f`, which contains +the accepted file/AUIP, MCP migration, backend shutdown and extension lifecycle +PRs. Its stable patch-id is unchanged. P2 is rebuilt on that P1, preserving +the accepted SDK source `49449a0` and wheel digest. Theme schema/validator +provenance now points at protocol #2 `223aac5`; that theme contract remains +provisional pending separate review and merge. Developer mode, logs and memory +are outside this reconstruction. + +The prior Electron Windows failure was reproduced in the real offline settings +smoke: its standalone test entry did not register `desktop-theme.get`, so the +theme provider could not obtain the locked theme and the GUI assertion timed +out. The smoke now calls the production `ExtensionInstaller.themeState` owner +through that IPC handler. Its eight existing outcome checks pass without a +backend or Python process. No production fallback or timeout increase was added. + +Fresh local evidence on the rebuilt implementation: + +- P1: 99 Python tests passed, one symlink privilege skip; 467 Electron tests + passed and the production build passed with an unavailable Python override. +- P2: 264 scoped Python tests passed, three skips (two Windows symlink privileges + and the optional DSH runtime); all 481 Electron tests passed. The scoped suite + includes ordinary-file/AUIP ownership, preview and launch boundaries. +- The actual Electron/backend model-less smoke passed all 13 checks, including + Plugins navigation, saved startup mode, separate file/app views and clean + process shutdown. +- The production-component smoke passed its seven journeys, including theme + rendering at 1100px English and 620px Chinese and the 560px configuration form. + Host responses in this visual fixture are synthetic; image/font validation + and component rendering use the real implementation. +- The real installer/store, native image/font validation and independent SDK + Runner lifecycle probe passed. Pure themes retained their backend process + with no venv or pip; mixed synthesis used v1, v1 after saving v2, then v2 after + restart. Cancellation/rejected updates retained the working version, frontend + admission closed on disable/remove, and owned processes/resources/data were + cleaned according to the selected retention policy. + +The lifecycle probe reports `nativeDialogs: false`: confirmation decisions were +supplied by the driver. The 2026-10-12 manual native-dialog/Chat playback journey +above remains historical evidence; it was not repeated or relabeled as a new +manual acceptance run. Final-head CI and focused external review remain the +merge gates for P1/P2 and protocol #2. + +## First audit repairs (historical, 2026-10-13) + +**Evidence correction:** the historical 14-check model-less smoke claim below is +withdrawn. Its asynchronous predicate could return false without failing the +wait; see the second audit investigation and replacement acceptance below. + +P1 now retires logical backend receipts at every new launch, independently of +whether prior process-tree cleanup was confirmed. File deletion still requires +the original confirmation/reference boundary. POSIX resource extraction keeps +executable bits only (never setuid/setgid); its regression actually runs the +bundled command on Linux/macOS. A Host-generated `startup_error` settles that +launch revision without pretending it loaded; later edits remain pending. +Frontend-only validation/removal errors do not create backend restart work. + +That first repair pinned protocol #2 `d06f319`: its published 25-token allowlist and literal/image +signature checks are shared by the Host. Runtime font availability, full native +image decoding and palette contrast remain Host responsibilities. The stricter +readability rules and theme-independent recovery described above replace the +previous fixed-white accent labels and translucent surface allowance. Plugins +now opens Voice in the navigable Settings view and preserves its remembered +section. Theme notifications no longer expose raw Electron events; startup +chrome uses the last verified theme base. Screenshot fixtures carry real-shaped +loaded revisions and the current component screenshots were refreshed. + +Other checks from that first repair: 492 Electron tests, production build, eight +offline startup GUI checks and eight component visual journeys passed. The +separate 14-check model-less report is withdrawn as recovery evidence; see +the investigation below. The real +installer/store/native image/font/SDK Runner lifecycle probe passed for both +resource-only and mixed packages; it reports `nativeDialogs: false`. It is not +a new manual confirmation or natural-voice playback run. Protocol tests passed +91 cases with one local Windows symlink-privilege skip. SDK bytes remain unchanged. + +Known lifecycle limit: once the old backend has stopped and references are +released, a mixed-package update may collect v1 before v2 finishes starting. +Preparation failures preserve the old version, but a later startup failure does +not provide automatic rollback. That behavior is disclosed, not expanded in +this repair. Linux/macOS executable-bit execution is a required CI check rather +than a claim of local Windows execution. These PRs remain Draft pending final +head CI and focused external review; no merge, developer mode, logs or memory +work is included. + +The final scoped Host Python run passed 264 tests with four skips (POSIX execute +bits on Windows, two symlink privilege checks, and optional DSH availability). +Canonical-source comparison passed. Clean release gates passed for P1 (3,991 +selected / 101 excluded) and P2 (4,021 / 95), each with zero errors or warnings. +Protocol #2 final-head CI also passed Windows/Linux contracts and wheel byte +comparison; downstream Host final-head checks remain separate merge gates. + + +## Second audit: rendered surfaces and receipt evidence (2026-10-13) + +This audit pinned protocol #2 to `d5c8a7d` (superseded by the merge follow-up below). Theme v0 supports PNG/JPEG only, including +matching signatures and actual native decoding. WebP declarations fail before +installation, and renaming WebP bytes does not grant acceptance. The canonical +night example and shared valid/invalid CSS literal cases are pinned as fixtures; +Host tests validate that example and real Chromium accepts the supported syntax. +Comma and space/slash forms cannot be mixed. Host-specific validator help and +provenance match again; the SDK wheel is unchanged. + +Hard-coded wallpaper-slice card/tab decorations now apply only to the builtin +base. Extension capability cards and nested visual controls paint the same +opaque token surfaces used by the Host proof. A native Electron regression +checks decoded PNG/JPEG files and actual Chromium computed styles/contrast for +an accepted near-threshold black palette and an image-backed translucent canvas. +It also verifies builtin decoration returns after leaving the extension theme. + +Mixed-package selection resolves content from the applied manifest while the +current desired record retains enable/removal authority. A saved v2 that drops +or renames a selected v1 theme therefore leaves v1 visible until restart. +New themes wait for v2 admission; disable/remove still closes frontend access. + +### Why the earlier 14/14 result was invalid + +A clean rebuild of `d7988dc` produced the same renderer asset names as the old +build log. Running its real application in a new profile returned +`AMADEUS_UI_THEME is locked by the parent process environment` for the theme +setup. The probe stopped before invoking the native error dialog. An independent +Chromium reproduction with Playwright 1.60.0 showed that +`wait_for_function("async () => false")` immediately returned a handle to false, +while a synchronous false predicate timed out. This known Promise-truthiness +behavior is recorded in [Playwright #29132](https://github.com/microsoft/playwright/issues/29132). +The checked release tag is `87bb9ddbd78f329df18c2b24847bc9409240cd07`; +this was reproduced without modifying Playwright. No duplicate upstream issue +was opened. + +The smoke made two errors: it ignored a failed setting-save reply and used an +asynchronous wait predicate as proof of persistence. The resulting true check +flag did not establish recovery, so the prior local and CI green results are +not accepted as recovery evidence. This was a test-instrument defect, not a +reason to relax the product's environment lock. + +The corrected smoke removes the inherited theme variable, checks each IPC save +reply, verifies classic -> wallpaper-slice -> shortcut -> classic, and awaits +saved values in Python with explicit comparisons and a deadline. The existing +startup-mode persistence check uses the same corrected polling. DOM theme changes +use locator attribute assertions, avoiding wait-for-function eval under the +shipping CSP. The GUI launcher uses an explicit environment allowlist, a temporary +profile and a checkout without `.env`. A temporary loopback main inspector +checks the owned PID and sole main-window identity, then uses Electron +`sendInputEvent` to exercise the actual `before-input-event` hook. The probe +does not call recovery directly; its connection closes immediately and the +inspector exits with the owned test process. Production startup and CSP remain +unchanged. Regressions prove a missing/wrong saved value times out. Fresh reports bind source identity +and the actual dist/packaged-ASAR digest to the result. The accompanying PR lists +the final clean-build GUI runs; historical reports above remain distinguishable. + +Installation dialog decisions in the separate lifecycle probe remain driver +supplied. Mixed-package startup failure still does not guarantee rollback after +old references are released. No developer mode, logs feature, memory or later +phase was added by that repair; all three PRs were kept Draft for external review. + + +## Merge follow-up (2026-10-13) + +Host P1 (#191) was merged as `2799ba8`. Protocol #2 was merged using merge +commit `bf1a848`, retaining the pinned source `b30df73` in protocol main history. +P2 was moved onto Host main without changing its file tree; its final head is +subject to fresh CI before the authorized merge. Protocol source is now pinned +to `b30df73`. Both the main specification's P4 +check and the theme contract consistently restrict v0 images to PNG/JPEG; the +WebP exclusion no longer interrupts the background-field description. Protocol +runtime/validator/SDK bytes are unchanged by this documentation correction. + +If a selected theme is absent from the loaded package, the Host recommends a +restart only when a different saved revision contains that theme and the +package requires restart. A theme removed by a frontend-only update or absent +after backend restart instead directs the user to choose another theme in +Appearance, preserving the saved identity and last verified base. Regressions +cover both actionable pending additions and removed frontend/mixed themes. diff --git a/docs/verification/extensions/community-preview-batch1-2026-10-21.md b/docs/verification/extensions/community-preview-batch1-2026-10-21.md new file mode 100644 index 00000000..c51c3efc --- /dev/null +++ b/docs/verification/extensions/community-preview-batch1-2026-10-21.md @@ -0,0 +1,141 @@ +# 社区插件开发预览第一批施工记录 + +日期:2026-10-21。状态:本地实验,未推送、未发布。 + +Host 实验分支为 `codex/extension-community-preview`,基线为主线 +`fd17e707d8793480003f929bc8c78a8d134e41b5`,包含 #234。独立协议仓同名分支从 +`bf1a848448a19ed20eca029e8cc3991563e42e95` 开始。日常工作区保持原分支和已有修改。 + +## 本批交付 + +- D0(部分完成):新增统一作者入口;主仓中英文 README、贡献说明和 ROADMAP 链接到当前路径。 + Host profile 保留当前契约,历史验收移到独立文件并保留旧标题锚点。协议仓首页修正了 + “未合并实验”的过期表述;没有改变规范、SDK 或其来源 pin。 + 协议仓首页仍链接旧 Host profile,尚不能到达新作者入口;必须先合入 Host,再把协议仓 + README 指向合入后精确提交的 `docs/extension_authoring.md`,验证公开链接后才能结案 D0。 +- D1:已有静态 CLI 新增 `profile`、`check-package`、`check-wheel` 和显式文本输出。 + 默认 Electron JSON helper 结果保持兼容。wheel 检查在临时快照中复用生产 + `validate_installed` 检查资源和 Skill 元数据,不导入插件、不执行 pip。 +- 最小作者依赖清单锁定 jsonschema、packaging、PyYAML 及其传递依赖;独立环境只安装 + 这组依赖即可检查包。增加 Windows CI 最小环境步骤和源码发行包含规则。 +- 六个已有示例均重新构建并通过最小环境检查。没有增加新的生产插件类型、Host 权限 + 或商店收录限制。 + +## 验证结果 + +执行环境为 Windows、CPython 3.12.10。Node 依赖复用现有锁定安装目录,构建输出属于 +实验工作区;这些结果不是一次全新安装环境的完整发布资格。 + +| 检查 | 结果与范围 | +| --- | --- | +| 最小作者环境 | 由新 venv 和哈希依赖清单安装;profile、源码 Skill 和六个 wheel 静态检查通过 | +| Python 作用域回归 | 162 通过,2 跳过;含作者工具、八组插件回归、Pi MCP、精确调用授权与 Provider 自动授权回归 | +| 跳过项 | Windows 上的 POSIX 执行位契约,以及不可用的符号链接权限 | +| Electron 作用域回归 | extensions、extensionSettings、extensionTheme 共 89 项通过 | +| 生产桌面构建 | 配置生成、SDK 摘要、Pi TypeScript、Electron TypeScript 与 Vite 均通过;保留现有大 chunk 提示 | +| 静态代码检查 | 修改的 Python 工具与回归通过 ruff | +| 真实主题生命周期探针 | 资源主题和混合主题/TTS 的安装、更新、取消、启停、数据保留/删除通过;使用真实安装器和 SDK Runner,合成 PCM | +| 实际 Electron 无模型 GUI | 15 项通过,包含后端鉴权、设置中的插件入口、原生主题恢复及完整退出 | +| 实际安装的 Notes MCP | 原生 MCP 传输发现 3 个工具,save_note/read_note/list_notes 的合成笔记往返通过,连接关闭 | +| 源码发行规则 | 新作者工具、依赖锁与文档进入选择范围;发行/SDK 回归其余 27 项通过,required-file 检查在新增文件纳入 Git 后复验通过 | + +本批 Python 命令在该实验主线上运行: + +```powershell +python -m pytest -q --tb=short -rs tests/test_extension_authoring.py tests/test_extensions.py tests/test_extension_resources.py tests/test_extension_mcp.py tests/test_extension_skills.py tests/test_extension_examples.py tests/test_extension_fish_audio.py tests/test_extension_protocol_provenance.py tests/test_extension_sdk_build.py tests/test_pi_mcp.py tests/test_pi_scoped_approvals.py tests/test_provider_automatic_permissions.py +``` + +GUI 与生命周期复用现有入口: + +```powershell +python tools/probes/extension_theme_lifecycle.py --output-dir build/extension-preview-verification/theme-lifecycle +python tools/smoke_electron_model_less.py --report-dir build/extension-preview-verification/model-less --startup-timeout 120 +``` + +本地原始报告保存在 `build/extension-preview-verification/`,未作为公共日志上传。 +主题探针中的 `nativeDialogs` 为 false,安装确认由驱动代答。独立窗口的包准备也使用 +生产安装器加驱动确认,并非手工原生文件选择/确认全过程。Notes MCP 的验证是直接原生 +传输调用,不是由真实 LLM 选择工具的验收。 + +## 独立实验窗口 + +实验配置位于 `build/extension-preview-verification/interactive-lab/`,安装并启用了: + +- `com.example.desktop-theme`,选择 Quiet day; +- `com.example.notes-mcp`; +- `com.example.handoff-skill`; +- `com.example.csv-audit-skill`。 + +MCP 和 Skill 绑定 Pi。窗口没有复制个人凭据,没有配置远程模型,Pi 在该无凭据配置中 +显示不可用属于预期,不代表已经取得真实模型使用 Skill 的资格。Fish Audio 与 MiMo +本批完成静态构建检查,没有调用付费服务。TTS 合成由独立生命周期探针中的本地测试包验证。 + +启动前确认 17777 空闲,并拒绝覆盖已有后端。实验窗口仍使用生产固定端口 17777; +它的设置、会话、账本、缓存与数据独立,但不能与另一实例的后端同时占用该端口。 +首次验收后曾保持窗口运行;本轮外审修订已停止实验进程,17777 的监听已释放。 +普通关闭只收起了窗口,没有结束实例;核对 PID、命令行和后端父子归属后终止了该实验 +进程树。这次停止不计作正常退出验收,后续启动日常实例前仍须确认端口空闲。 + +Computer Use 已观察到实际 Chat 窗口;原生控件几何捕获不稳定,因此没有把后续点击尝试 +计作通过,也没有用其他应用的画面作为证据。完整 GUI 结果来自上面的现有生产应用 smoke。 +实验准备脚本曾因顶层等待和错误的启动枚举值失败,修复的是一次性驱动;正式记录仅采用 +修复后成功的准备和调用结果,没有修改生产启动逻辑来适应探针。 + +## 尚未交付 + +- D2 统一作者合约检查 CLI 尚未实现;本批只复用现有进程与 GUI 探针。 +- D3 外部作者验证未开始,不把维护者示例当作社区作者证据。 +- S0 目录规范、公开审核清单和发布流水线尚未实现。 +- S1/S2 的远程下载、镜像、持久安全事实与商店界面尚未实现,当前窗口仍是本地插件管理器。 +- 没有发布公开索引、SDK、软件版本或社区消息;没有合并到主线。 + +下一批从已可用的 D1 入口出发,按批准计划并行推进 D2 和 S0,继续保留首版收录政策与 +Host 运行契约的边界。阶段退出证据以新的精确提交和验收记录更新。 + + +## 外审修订(2026-10-21) + +外审核对基于 `4818e2ee`,其独立测试为 67 通过、1 跳过;本段不会把外审未重跑的 +生命周期和 Notes 调用记作新证据。首批 GUI 是 `fd17e707` 加当时未提交改动的结果, +15 项均通过;并非对 `4818e2ee` 的干净快照另跑一次。 + +- 修正 D1 检查范围:源码目录只检查清单和资源,wheel 元数据明确列为未检查并指向 + `check-wheel`。以 MiMo 资源构造不兼容 SDK、版本不符和错误入口点的 wheel,断言 + wheel CLI 和安装器一致拒绝;未在 CLI 添加第二套元数据规则。 +- 错误源码目录返回缺少 `amadeus-extension.json` 的输入错误,不带 `unavailable`。 + 原 Electron helper 默认 JSON 契约保持兼容。 +- 最小依赖改为 `pyproject.toml` 的 `extension-authoring` 组,从 `uv.lock` 导出哈希清单; + 删除独立 `.in`。`uv lock --offline` 只新增组元数据,没有改任何包版本。CI 比对导出, + 并用 Host 的锁定构建工具构建六个参考 wheel,在最小环境逐个执行 `check-wheel`。 +- Host profile 的当前部分恢复 accepted/rejected/unconfirmed 语义,恢复 DSH stdout 和 + Windows WRITE_OWNER 限制的摘要与 ACP 文档链接。历史验收保留日期和旧锚点。 +- 10-11、10-12 内部中文计划作为带明确历史标注的参考保留;插件施工顺序已被 10-21 + 计划取代。移除未发布内部材料的死链和本机 build 备份路径依赖,不将记忆候选当作 + 社区作者或目录的前置。 +- 已删除两个指向日常工作区的 node_modules junction。删除前核对路径和目标,使用 + 非递归目录联接删除;删除后主仓锁文件摘要与顶层条目数均相同(Electron 324、Pi 90)。 + 实验工作区运行 `npm ci`,两个 node_modules 现在都是独立普通目录。没有归档 worktree。 + 此后归档前仍须检查外部联接,不能把本次依赖替换当作所有未来文件的保证。 + +本轮修订形成单独提交,作为 D2/S0 后续工作的校验器候选基线;D0 的跨仓公开入口仍待 +Host 合入后完成。当前没有新增插件类型、商店入口、下载能力或权限,也没有改 SDK pin。 + + +### 外审修订验证 + +| 检查 | 本轮结果 | +| --- | --- | +| 作者 CLI、安装与资源、协议来源回归 | 58 通过、1 跳过;Windows 上 POSIX 执行位用例跳过 | +| 发行工具与 SDK 构建回归 | 28 项通过;首次因删除的 `.in` 仍在 Git 索引中产生 1 项失败,暂存删除后该项复验通过 | +| 依赖导出 | `uv export --locked --only-group extension-authoring --no-emit-project --no-header` 与已保存文件逐字节一致 | +| 新建最小 Python 环境 | 8 个锁定依赖按哈希安装;6 个参考 wheel 用离线 `uv build` 重建并通过 `check-wheel` | +| 独立 Node 依赖 | `npm ci` 成功;Electron/Pi node_modules 均为普通目录;未改 npm 锁文件 | +| Electron | 独立依赖下 `npm run build` 成功,extensions/extensionSettings/extensionTheme 共 89 项通过 | +| 文档与静态检查 | 5 份改动的作者/profile/计划文档的相对文件链接可解析;ruff 和 Git whitespace 检查通过 | + +本轮未重跑 GUI、主题生命周期或 Notes MCP 远程/原生调用;这些仍只引用首批明确范围的 +证据。新建 Node 环境的 npm 审计报告仍有 8 项 moderate,未在此次外审修订中升级依赖; +构建仍显示已有的大 chunk 提示。D2/S0 的功能交付与验收仍按下一批单独记录。 + + +后续进展:D2/S0 的本地实现与验收见[第二批施工记录](community-preview-batch2-2026-10-21.md);以上本批结果仍按其原有提交和验证范围保留。 diff --git a/docs/verification/extensions/community-preview-batch10-2026-10-23.md b/docs/verification/extensions/community-preview-batch10-2026-10-23.md new file mode 100644 index 00000000..7db6d0e8 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch10-2026-10-23.md @@ -0,0 +1,44 @@ +# 插件商店主线集成与草稿 PR 验证(2026-10-23) + +用户授权在当前实验分支解决最新主线冲突,采用主线已有依赖修复,验证后向两个仓库推草稿 PR;不合并主线、不公开发布、不影响正在运行的 Amadeus。本记录补充第九批,历史 ZIP、失败记录和桌面结果不改写。 + +## 主线集成 + +Host 从实验提交 9e3938c1 合入 main 5382b32901024787ada8d70ac10fda23b9b4f2fa,集成提交 af56e72c70cbe6aac874e3604b8ba5b6b65baf1f。这里是 main 合入实验分支,不是实验合入 main。协议仓以 bf1a848448a19ed20eca029e8cc3991563e42e95 为基线,工具提交 8fb39ba 已包含该基线,无需冲突合并。 + +依赖冲突归属集成层:完整采用 main 的 Qwen vendor、上游 pyproject 快照、来源说明和依赖测试;不保留实验分支旧的 initializer 别名。pyproject.toml 与 uv.lock 相对 main 只新增 extension-authoring 依赖组;已有包版本、Pillow/certifi 修复、包级构建后端约束均复用 main。provenance 保留 main 全部组件,只增加目录政策来源。model-less smoke 测试保留两边各自新增的契约。 + +固定目录 Host checker 1840c8e4f646992ddaebea3139c8895f29743a8f、Host 内目录政策 pin 58d3892d55c6a7ec6f8626153f503291bb10ab9a、SDK/运行协议 pin 均未改。先推 Host 实验分支,使协议 Actions 能获取历史 checker;再推协议分支和建立草稿 PR。空 bootstrap 不加入审核记录、公开包或安全决定。 + +## 本轮验证 + +- Host 插件、作者工具、目录、运行契约、打包、发布工具与主线依赖来源回归:184 通过、7 个已有条件跳过;原有 locale 弃用警告。包含实际隔离构建 Qwen sdist/wheel 和源码一致性校验。日志 build/integration-python.log。 +- Pi MCP 与 scoped approvals:30 通过,未放宽原超时或断言。日志 build/integration-pi-tests.log。 +- Electron 全量单测:576 通过、0 跳过;npm --prefix electron run build 通过(配置生成检查、SDK 摘要、Pi typecheck、TypeScript、Vite)。日志 build/integration-electron-tests.log 和 integration-electron-build.log。Vite 仅有现有大 chunk 提示。 +- 协议仓全量测试,明确指定历史 checker 及其独立最小环境:132 通过、1 个 Windows symlink 权限条件跳过。日志 build/integration-protocol-tests.log。 +- uv lock --check 通过;按 CI 参数 uv export --locked --only-group extension-authoring --no-emit-project --no-header 导出后,与已提交的最小带哈希清单逐字节相同。 +- 全仓 Ruff 通过;未增加忽略或排除项;依赖实现选定路径与 main 无差异。 + +测试工具问题单独处理:首次 Electron 全量测试有 4 项因固定使用 15173 与用户运行中的 Vite 冲突。仅将需要真实监听器的自定义端口夹具改为临时可用端口;默认 5173、端口解析、端口占用必须拒绝、子进程清理与首屏可见性断言保持。没有关闭占用者、增加重试/skip、放宽超时或修改产品端口行为。 + +调整后第一次全量跑有一个 uiThemeSettings.test.mjs worker 退出码 1、无内部失败堆栈;原样单跑该文件 2 项通过,随后原样全量 576 项通过。根因尚未确认,不把这次退出归因于产品或端口。首次端口失败与 worker 退出分别保留于 integration-electron-tests-initial.log、integration-electron-tests-worker-exit.log。 + +## 六阶段维护端复验 + +按 docs/extension_store_demo.md 执行 extension_store_demo.py --execute --publisher-only,复用真实参考 wheel 与固定 checker;协议生成器为 8fb39bac8450562296975492d007dd8b852c407b。结果 build/integration-publisher/8e174b9f84b6/result.json:ok=true、desktop=not_run。 + +首次上架、更新、版权撤回、安全封禁、解除封禁、恢复分发六个阶段均完成 input CI、publication CI、原字节重建及候选/已分发索引验证,共 12 次分发验证通过。错误摘要投稿先被拒绝,同一份投稿修订后才获得模拟批准;生成结果篡改和安全事实丢失继续被正式工具拒绝。这是本地 Git + loopback HTTPS 演练,simulated_review_only=true,不是 GitHub 人类审批或公开目录发布。 + +## 与 UI/UX 和图标 PR 的衔接 + +核对时 Host #255 为 draft,head 44403f355971797ccea9fd6abf6bb91fba53fc2c;协议 #4 为 draft,head c3d1d897a3a97dc23f9880d2de042cc6cc9372ef。本轮不合入或修改它们。 + +Host 有 13 个共享修改文件,涉及插件设置、main/preload IPC、类型、样式/中文文本、Host profile 与 smoke。后续同时合入时必须保留商店浏览/详情/进度/手动更新/安全操作和包内图标读取两类行为,重新跑对应 UI、IPC、图片边界和目录测试;不能用整文件 ours/theirs 覆盖。 + +协议仓文件层面没有重叠,但有语义依赖:当前及固定历史 checker 的 manifest schema 都没有 icon,且 additionalProperties=false。因此图标协议合入不意味着目录已能收录带 icon 的包。届时需明确升级固定 Host checker/其哈希依赖、验证 Windows 真实图标 wheel 预检和生成/消费兼容,再协调 Host 协议与目录来源 pin。首版目录不加载远程图片的边界保持。本轮未新增图标字段,也不把这项跨 PR 资格记成通过。 + +## 验证边界与交接 + +本机日常 Amadeus 的 17777 和 Vite 15173 监听保持原进程。本轮不运行完整后台/打包版桌面验收;第八批 15+25 桌面闭环结果是历史证据。第九批新增的完整桌面断言仍需将来端口空闲时执行。本轮仅运行单测自有的临时 Electron 窗口和维护端工具。 + +正式发布仍需真实非作者审核、素材授权、公开地址、main 保护/2FA/最小发布权限、远端 Actions 与独立作者资格。草稿 PR 是代码审查入口,不构成发布或合并批准。旧外审包保持原摘要;本轮以集成提交、此记录和本地原始日志补充。 diff --git a/docs/verification/extensions/community-preview-batch11-2026-10-23.md b/docs/verification/extensions/community-preview-batch11-2026-10-23.md new file mode 100644 index 00000000..8f2795db --- /dev/null +++ b/docs/verification/extensions/community-preview-batch11-2026-10-23.md @@ -0,0 +1,36 @@ +# 插件商店外审修复(2026-10-23) + +本轮核对主仓本地 pr_audit_254-256_extensions_4-5_2026-10-23.md,负责 Host #256 与 Extensions #5。用户决定:空商店保留浏览并标注预览;图标用新 schema(由原 UI 会话更新 #255/#4);词典 #254 保留 rc2,发布时再定。两个商店 PR 保持 Draft,不合并、不发布目录,不操作正在运行的 Amadeus。 + +## 审计结论与处理 + +- C1 属实,构建层修复。源码身份校验正确指出 wallpaper packages.lock.json 被 publish 隐式 restore 改写。仅加 RestoreLockedMode 首次得到 NU1004,进一步定位为锁文件缺少发布使用的 win-x64 目标。csproj 声明 RuntimeIdentifiers=win-x64、RestoreLockedMode=true;受控 force-evaluate 只增加空的 net8.0-windows7.0/win-x64 目标,未改任何依赖版本/哈希。此后实际 BuildOnly/Rebuild 成功、锁字节不变;保留打包后的完整源码身份校验。Source Release 的触发路径补入 wallpaper 与 build hook。 +- D1 属实,执行权威边界修复。原 proposal 测试可污染同 job 解释器;python -I 不禁用 site .pth。受信检查移到独立 catalog-admission.yml,从 pull_request_target 的受保护基线加载 workflow/代码,在全新环境安装基线及固定 Host 的带哈希依赖;不运行 proposal requirements、测试或工具,不下载 proposal job 的 cache/artifact,checkout 不保留凭据。普通 pull_request 的 catalog-contracts 只做开发回归,不再冒充受信 admission。 +- 首次 bootstrap 的边界:新受信 workflow 尚未存在于 main,不能为自身引入出具可信证明。本 PR 仍只包含空目录,需要人工审核工作流。bootstrap 合入后、首个包/发布 PR 之前,必须把 catalog-admission 设为必需检查,并用故意无效投稿验证受保护路径拒绝。当前未改仓库规则,也未把本地隔离演练称为该远端门禁验收。 +- C3/D2 属实。唯一官方地址改为 catalog/v1/index.json,工具、客户端、测试 fixture 和文档同步,移除未发布的无版本入口。v1 路径永久绑定严格 v1 格式;新格式另开路径。支持期内旧路径仍需发布可理解的安全事实并递增序号,不能以 v2 替换 v1 导致旧客户端丢失封禁信息。客户端本地缓存文件名不变。 +- C4 属实。保留 Electron net、隔离 session、HTTPS/重定向和大小边界,逐块同步写盘改为有背压的异步 stream pipeline,完成后等待 flush/close。30 秒停滞超时,每次新数据重置;另设按准入大小、32 KiB/s 加连接时间计算的总预算(最少两分钟)。仍可取消,失败只清理自有 staging 文件,不能覆盖/删除已有目标。 +- Electron 44 的官方源码 IncomingMessage extends Readable,生成 d.ts 却只声明 EventEmitter。本地首次 TypeScript 构建因此拒绝 pipeline 参数;在该单一 API 边界使用明确的 ReadableStream 类型断言,并通过实际 Electron HTTPS 流程验证。没有替换网络栈或放宽类型检查全局配置。 +- C5 采纳:恢复 main 的 package.json build 格式与 Unicode 描述,只保留新增 checker extraResources 和已有版本选择。 +- C8/D3:按用户明确批准创建注释标签 extension-catalog-checker-d1,剥离后为 1840c8e4f646992ddaebea3139c8895f29743a8f。工具仍钉 SHA,不信任可移动标签;未创建 Release。之后将检查器重新认证至 main 并验证真实 v1 图标 wheel,仍是后续资格;当前固定 checker 不接受图标包。 +- C2 是跨未合并分支的真实集成门槛。本轮先复用已有 capability-page-tabs,去除商店独立 tab CSS。#255 的全部 token/容器规则及卡片启用开关尚未进入 #256;剩余 CSS 两处 token、容器布局和 activation 探针必须在整合 #255 后一起更新并跑组合认证,当前不加探测多套 UI 的兼容分支,也不声称组合已通过。 +- C6 是 PR 组织建议,不是代码缺陷。本轮保留已验证的 Pi/端口夹具及历史施工记录,避免重写已被目录 pin 引用的历史。最新摘要为本文件;旧记录不当作当前结果。 + +A1/A2 已由 #255 的 0e761b9d 修复,不再重复改它。B1 规范冲突成立,但外审“只提升 Host API 即可让旧宿主显示 incompatible”的建议不成立:已发布旧 Host 先检查 schema,再检查 engines。用户选定新 schema,已授权交给原 UI 会话落实。A3 及新图标 pin 的主线来源资格仍需在协议合入后验证。E2 保持 rc2;不代替词典发布决定。 + +C7 的旧状态已过期:检查时 #256 Python Windows 的全部 job 与 cpu-model-less 已成功,无需重跑。source-archive 的旧失败真实存在;本轮新 head 会重新触发构建资格,不能将旧绿灯当作新 head 结果。 + +## 本地验证 + +- Host 相关 Python 回归:67 通过。build/audit-host-tests.log。 +- Electron 全量:579 通过、0 跳过;完整 build 通过。新增慢速持续传输跨越旧 120 秒上限、停滞清理、既有文件保留和空目录预览验证。 +- 全量首次 578/579,失败是新增翻译键未排序;修正字典顺序后全量通过,没有跳过/忽略或放宽断言。 +- 实际 Electron 商店 UI + HTTPS + 真实参考 wheel:27 项通过,build/extension-catalog-journey/21b5eaab6443/report.json;覆盖取消、更新失败保持旧版本、实际 TTS Runner、封禁/撤回展示、窄屏和无远程图片。独立临时窗口/配置,不启动日常后端。 +- 实际 Electron 传输/安装/新进程恢复:30 项通过,build/extension-catalog-journey/750626bea988/report.json。含系统代理/证书、HTTPS 重定向、镜像错摘要、真实 Notes/Tone/Handoff、封禁缓存删除、主设置 NUL 恢复、双 NUL 拒绝、新 Electron 进程仍受限及显式恢复;临时 profile、fixture authority,不是完整 shipping-shell 或物理断电。 +- 协议完整套件:132 通过、1 个原有 Windows symlink 权限跳过。build/audit-protocol-tests.log。 +- 新版本化路径的正式本地 Git/HTTPS 演练:六阶段 input/publication CI、可重复生成及 12 份候选/实际分发验收通过,build/audit-publisher/c8ecec3fcfe4/result.json。仍为模拟审批、desktop=not_run。 +- 隔离污染探针 build/audit-admission/result.json:复现 -I 执行污染环境中的 .pth;fresh admission 环境不执行 proposal 的恶意 requirements、sitecustomize 或测试文件;合法空输入通过,无效安全记录拒绝。可信工具为 bd4b233471fca608ab339011fb9371ec675c47b6。该探针不是 GitHub pull_request_target 的线上验收。 +- Host 全仓 Ruff、协议修改的 Python 文件 Ruff 通过。额外全协议 Ruff 暴露 19 项原有 SDK/manifest 等问题(未改部分);未修改固定协议来源或加入忽略项来消除它们。 + +完整 shipping-shell 重启/NUL 恢复的第八批证据保持为历史;本轮不关闭用户应用来重跑占用 17777 的脚本。跨 #255 的组合桌面、首次受信 workflow 主线运行、分支必需检查配置、独立作者和真实包审核均未被本轮结果替代。 + +参考:Electron v44.0.0 lib/common/api/net-client-request.ts 与官方 IncomingMessage 文档;GitHub pull_request_target 事件文档。网络类型断言只针对已核实的实际 Readable 实现。 diff --git a/docs/verification/extensions/community-preview-batch12-2026-10-23.md b/docs/verification/extensions/community-preview-batch12-2026-10-23.md new file mode 100644 index 00000000..7279531a --- /dev/null +++ b/docs/verification/extensions/community-preview-batch12-2026-10-23.md @@ -0,0 +1,53 @@ +# 复审整合与主线合并记录(2026-10-23) + +用户授权:修复阻塞后合并五个 PR,不发布插件包或创建 Release。词典继续 rc2;N1 四色方案按用户最新决定保留,主题适配作为后续项。主工作区的本地文件不切换、不覆盖。 + +## 已完成的合并与门禁 + +- Extensions #4:merge commit 6f3fee2d386125d6c31a4c8157f53b254e35dd79;b444dd8 图标协议 pin 在 main 上可达。以该 main 的独立检出运行 #255 的 check_extension_protocol.py --source,通过。 +- Host #255:全部检查已成功(滞留 macOS 状态自行完成,无需重跑),按 squash 合入 49c67f5c0cbbca9744bfcfad7ebfad370da94f27。 +- Extensions #5:先合入协议 main 并跑完整组合测试(164 通过、2 个条件跳过),新 head 的四项 CI 成功,再 merge 至 e2fc0e93fa7cfd9bf98e378cac6e158f14181b9a;历史政策 pin 58d3892d 仍可达。 +- main 的目录门禁使用增量规则集 25654501,唯一新增要求为 GitHub Actions(integration 15368)的 catalog-admission,strict=true、无 bypass。没有覆盖或移除评审规则。一次整体 PUT 保护请求被自动审批拒绝,理由是空评审配置可能弱化保护;未执行该请求,改为只追加检查的规则集。 +- 真实远端资格 PR Extensions #6:42b12d5 的无效安全记录被受信 job 拒绝、PR 为 BLOCKED;4b99af9 恢复与 main 相同数据后全部检查通过、PR 为 CLEAN。测试 PR 关闭且未合并,没有无效记录进入 main。 +- Host #254:同步 #255 后所有必需检查成功,按保留 rc2 的决定 squash 至 48f23acffcbbb036f620fbbf14d1a976b0c22539。没有发布词典 ZIP;代码版验收使用它明确提供的 code-only 构建配置。 + +## #256 的组合修复 + +实验分支以 merge 保留历史,先整合 UI main(016b0e0),再整合词典 main(25276b0)。八处 UI/IPC/类型/翻译/fixture 冲突及两处打包/源码政策冲突按语义合并。 + +保留 UI 的 Installed plugins 标题操作、卡片开关、图标、卡内配置、详情与移除;保留商店页签、进度、手动更新、精确产物审核/撤回/封禁状态。封禁/安全存储不可用时不能开启包,但仍可关闭;草稿跨页签保留。镜像设置保留在 Plugins 页面。 + +商店样式使用现有角色 token 和 settings-main 容器查询。N1 的四色状态样式未改变。预览、SDK 和摘要标签通过当前 i18n 检查。 + +N2:extension-host 资源过滤改为 server/extensions/*.schema.json。资源选择回归在独立暂存目录运行实际 helper,v0 与 v1 图标 wheel 均通过;删除暂存 v1 schema 后按预期失败,不能借源码目录补足。shipping-shell packaged 验收也直接运行 resources/extension-host 中的 helper,重打真实参考 wheel 的 v1 图标变体并更新 RECORD;未导入插件代码。 + +探针按明确包 ID 定位卡片开关,详情内执行移除;不再借 Configure/Save 改 enabled。已适配 extension_release_shell、extension_store_roundtrip、extensionStore.smoke,保留原有错误、权限、重启及恢复断言。旧包待删除时仍可能留在列表,因此不能用“第一个/唯一开关”推测当前包。 + +Windows CI 增加正式生成器、固定 Host checker 和真实打包客户端的六阶段发布/恢复闭环。工具来源固定为已合入的协议 e2fc0e9,checker 保持 1840c8e;不收录 v1 图标包。上传范围排除这项演练的私钥和 profile。 + +## 本地证据与正在完成的检查 + +- Electron 全量 603 项通过,build 通过;真实商店 UI 流程通过(build/extension-catalog-journey/c927bea66582/report.json)。 +- 插件相关 Python 158 通过、7 个原有条件跳过;v0/v1 打包与作者/图标测试 31 通过、1 条件跳过;词典并入后的发布/打包回归 42 通过、2 条件跳过。没有放宽超时/断言或增加忽略。 +- N2 实际 code-only exe 已打包;从其 resources/extension-host 校验真实 v0 与 v1 wheel 均成功,build/merge-actual-bundle-schemas.json。 +- 独立 extensionSettings UI 验收通过。首次用 PowerShell 直接启动 GUI exe 导致父进程提前返回、Vite 向关闭的日志管道写入 EPIPE,用户看到了测试窗口弹窗。确认 PID、可执行路径和命令后仅清理该测试进程;改用现有 ProcessTree 和持有文件日志句柄的 Python 启动器,等待真实结束后通过。没有在产品中忽略 EPIPE。 +- 随后用户明确授权关闭当前 Amadeus;通过正常窗口关闭请求退出 UI 及所属后台,释放 17777,开始本机完整验收。未删除或改写用户 profile,不自动重启日常应用。 +- 完整验收探索轮保留失败:聊天侧栏 Artifacts 悬停定位两次超时;探针改用既有焦点维持展开契约,额外断言页签 aria-pressed=true,并保留内容可见性和原超时;整合探针的多卡片选择歧义及一次参数名错误已定位修正。最终完整源码/打包/发布闭环及新 head 的 CI 结果以各自回执为准,未完成前不将 #256 记为已合并。 + +后续独立资格:把目录 checker 重新认证到含 v1 的 Host main、将 manifest 版本纳入目录兼容信息并覆盖旧客户端、测试真实图标 wheel。当前历史 checker 仍拒绝它,空目录未发布任何包。历史计划和 Pi 测试保留,不重写已被 pin 引用的提交。 + +## 新 head 的审计与本机路径诊断 + +CI run 39817357388 的 checks-and-smoke 在依赖审计阶段报告 opentelemetry-api 1.44.0 / CVE-2026-114603(修复版 1.45.1)。使用 uv lock --upgrade-package opentelemetry-api==1.45.1,仅该包的版本和产物哈希变化;独立验收环境审计通过,原有忽略不变,uv pip check 通过。作者最小依赖导出字节不变。相关运行状态/打包/子进程测试 18 项通过。 + +完整本机探针的长产物目录曾导致真实 Runner 启动失败。保留报告 unexpected_runtime 后,直接用同一合成安装记录调用 _launch,得到 WinError 267;data_path 长度 260。复制到短的隔离目录后回执为 accepted/active。此处归类为本机验收路径问题,最终验收使用 build/src 和 build/pkg;没有修改产品启动目录、状态要求或重试时限。超长自定义 profile 路径不在本次支持声明内。 + +## 完整本机闭环最终结果 + +- 源码:build/src/electron_model_less_20261022T170153Z_1e5bdb/report.json,passed;15 桌面 + 14 插件 + 18 故障检查,3 次应用重启。启动时身份为 1e564d38 加随后提交到 cd3f36fe 的工作区修改,原始 fingerprint 保留,不能称为干净提交验收。 +- 实际 code-only 打包版:build/pkg/electron_model_less_20261022T170614Z_44a275/report.json,passed;15 桌面 + 15 插件(含包内 v0/v1 helper)+ 18 故障检查,3 次应用重启。源码探针为干净 cd3f36fe;exe 是同一整合生产代码的既有构建,锁文件修复通过显式 Python runtime 提供。 +- 正式工具本地 HTTPS 发布闭环:build/pub/057de5788e6a/electron_model_less_20261022T170924Z_62b5d3/report.json,passed;15 桌面 + 27 闭环检查,4 次应用重启、5 次真实安装 Notes 原生 MCP 合约、10 次目录摘要对照。六次发布的 candidate/live 分发校验均通过。起跑为干净 cd3f36fe,运行中只补写文档,代码未变。 + +最终覆盖:浏览/详情/安装/镜像手动更新、撤回与安全封禁独立语义、明确解除封禁、单/双设置 NUL 损坏、无缓存离线重启、显式安全恢复、旧序号拒绝、原生 MCP 安装产物读写以及生成器输出原字节分发。NUL 注入只发生在测试应用停止后,不是物理断电实验;确认框为受控测试自动确认,没有宣称独立人工审核。 + +新 head 的 build、source-archive 等远端检查已通过;Windows 必需汇总继续等待完整分片和新增打包发布闭环。本次文档提交的最终 CI/合并回执保存在 PR #256 与不可变外审补充包 `external-review-2026-10-23-merge-final.zip`,不把等待中的状态记成完成。原始 r1/r2/r3 及此前补充包不覆盖。 diff --git a/docs/verification/extensions/community-preview-batch2-2026-10-21.md b/docs/verification/extensions/community-preview-batch2-2026-10-21.md new file mode 100644 index 00000000..2b74c5b6 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch2-2026-10-21.md @@ -0,0 +1,102 @@ +# 社区插件开发预览第二批:D2 / S0 + +日期:2026-10-21。状态:实验分支实现与本地验收完成,未推送、未合并、未发布。 + +继续使用两个仓库的 `codex/extension-community-preview` 分支。Host 从已修订的 +`1840c8e4f646992ddaebea3139c8895f29743a8f` 开始,底层仍是包含 #234 的 `fd17e707`。 +协议仓本批最终提交为 `c7f5487a0fb9962b718187eec20428697ce4c244`;目录生成器为 +`85f4931`,精确来源记录在其 `catalog/index.json`。Host 本批提交可从本文件的 Git +历史定位。本批不改生产插件 owner、权限、SDK/协议来源 pin 或安装事务。 + +## D2 交付 + +- 新增 `tools/check_extension_contract.py`:显式 `--execute`,匹配实际 wheel 与独立 + 安装记录的 ID/version/digest/manifest;不隐式安装依赖。每次运行建立临时 data/cache + 和 Agent 会话,使用已有 ProcessTree 限定子进程树与总超时。声明的远程服务另需显式开关。 +- TTS 复用 RunnerClient 和 TTS proxy;MCP 复用原生投影与传输;Skill 复用真实 Pi + 发现、读取和会话恢复;主题复用 Electron 原生资源 owner、生产 themeApplication + 和实际 renderer CSS。没有新增通用 Runner 框架或生产模型调用。 +- 报告区分 static/process_contract/native_agent/gui/remote_service,未运行保持 + not_run;失败非零退出并给出受控阶段、错误类、可用的 worker 退出码及 TTS 就绪状态。 + 任意插件异常、工具返回和密钥值不进入报告。 +- 保留六个现有示例,新增 MIT-0 `tone-tts`,使用随 Host 交付的固定 SDK wheel 输出 + 合成 PCM。这是无付费依赖的 TTS 作者参考,不是 MiMo/Fish 的真实服务或听感资格。 +- `tools/probes/extension_author_journey.py` 使用实际 Electron installer/store,构建 + Tone、Notes MCP、Handoff Skill、桌面主题的 0.1.0/0.2.0,完成安装、启用、更新、 + 对应运行检查、停用、卸载、保留数据重装及显式数据删除。 +- 作者文档补齐复制模板时的身份/元数据修改项、不同检查环境、场景参数和验证边界。 + Windows CI 在桌面构建后加入这条离线旅程,发行清单包含运行工具及其依赖源码。 + +## D2 验证 + +| 检查 | 结果与证据 | +| --- | --- | +| 最终四类真实安装旅程 | `build/extension-author-journey/4005cfb5b41a/report.json` 为 ok;12 个阶段全部完成,8 次按版本/类型划分的运行检查通过 | +| 真实 Provider 与失败路径 | 上述目录中的 `native-provider-regressions.log`:9 通过。Notes 经实际 Pi 完成 save/read,出现两次精确 MCP 调用审批;Skill 经实际 Pi 读取资源、恢复会话、更新/移除后拒绝旧会话,并覆盖无关新增与遮蔽 | +| 作者工具失败 | 未显式执行、错误 Provider 绑定、缺失资源、总超时均非零;输出不包含合成密钥。主题继承背景且保留有效对比度覆盖的用例通过 | +| Host 作用域回归 | 150 项通过、2 跳过;包括作者静态工具、Runner、MCP/Skill/资源/示例、Pi MCP/精确授权、协议来源、SDK 和发行工具 | +| 跳过项 | Windows 账户无法创建符号链接;POSIX 执行位契约 | +| 实际原生主题生命周期 | `build/extension-preview-batch2/theme-lifecycle/4f040befd2b3/report.json` 为 passed;资源主题与混合 TTS/主题的版本边界、取消更新、坏更新保留、停用/重装、数据删除、字体和进程退出通过 | +| Electron 回归与构建 | 89 项通过;独立依赖下 build 完成配置生成、SDK 摘要、Pi TypeScript、Electron TypeScript 和 Vite;保留既有大 chunk 提示 | +| 源码与发行检查 | 修改的 Python 经 ruff;Git whitespace 与相对文档链接检查通过;未改变 SDK wheel、协议副本及生产运行 owner | + +Host 作用域命令: + +```powershell +python -m pytest -q --tb=short -rs tests/test_extension_authoring.py tests/test_extensions.py tests/test_extension_mcp.py tests/test_extension_skills.py tests/test_extension_resources.py tests/test_extension_examples.py tests/test_pi_mcp.py tests/test_pi_scoped_approvals.py tests/test_extension_protocol_provenance.py tests/test_release_tooling.py tests/test_extension_sdk_build.py +python tools/probes/extension_author_journey.py +python tools/probes/extension_theme_lifecycle.py --output-dir build/extension-preview-batch2/theme-lifecycle +``` + +150 项的首轮结果是 149 通过、1 失败、2 跳过:发行扫描命中了新测试里名为 secret 的 +合成长字串赋值。改为清楚的 fixture_value 后,该发行检查复验通过;未放宽扫描规则。 +早期旅程探针也分别暴露了驱动的 MCP 返回结构/模型工具名选择、UTF-8 读取和新进程 +校验缓存刷新遗漏,均在工具/测试驱动所属层修正。最终完整旅程重新运行后才计为通过。 + +确认由测试驱动代答,`nativeDialogs=false`。Pi 使用本地受控模型驱动真实原生工具, +不证明真实模型选择质量。主题检查包含实际 Electron/Chromium 隐藏窗口,但不冒称完整 +手工文件选择、弹框或 Chat 窗口交互。第一批完整 GUI 的 15 项结果没有在本轮重新计数。 +本轮没有调用付费 Fish/MiMo 服务,没有复制日常配置、凭据、Agent 会话或用户数据。 + +## S0 交付与验证 + +协议仓新增目录 schema、集中政策、公开审核清单、手工事实目录、独立安全事实文件、 +共享未来兼容性 fixture、生成/校验/差异工具、固定 Windows CI 和空的序号 0 索引。 +实际信息和逐项结果另见该仓 `catalog/README.md`、`REVIEW_CHECKLIST.md`、`VERIFICATION.md`。 + +- 唯一官方索引位置、GitHub 仓库信任根、审核治理与后续镜像边界已写明。首版纯 Python、 + X.Y.Z、SDK-only 依赖和 MCP 启动限制集中在目录工具;未加入通用 Host 运行校验。 +- 审核对象是摘要对应的实际 wheel。源仓只提供辅助证据。工具输出实际版本内容差异, + 每个版本仍需独立人工审核;维护者自己的参考包不例外。 +- 同一 ID/version 不能换字节或消失;撤回保留条目,版权原因与安全封禁分离。序号严格 + 递增,明确解除必须使用新序号。未知贡献只使相应整包不兼容;坏格式/安全结构拒绝新快照。 +- 输入 commit 和生成器 commit 都可追溯;时间取输入提交时间。测试中实际生成两次字节 + 一致,错误缓存摘要被拒绝。已审核旧事实只能从受保护 main 的旧索引复用,不能从展示 + 缓存取得权威,避免旧产物下载失败阻塞安全/版权撤回发布。 +- 固定校验器仍为 Host `1840c8e`、Windows、Python 3.12.10 及 D1 最小依赖哈希。 + 当前开发分支后续会前进;复跑目录检查时必须另外提供该精确 checkout,不把浮动 main + 或本批新 HEAD 冒充这个 pin。 +- 独立、按哈希安装的协议仓环境全套测试:121 通过、1 跳过(符号链接权限)。最后补充 + GitHub 审核人大小写身份处理后,目录专项 28 通过。固定 Host 环境验证了五个现有 + 参考包可生成目录,Fish 因第三方依赖只被目录拒绝,本地 Host 静态检查仍通过。 +- 受保护基线 CI 检查入口在本地通过;最终空索引重建与 --check 一致。实际 GitHub Actions + 未运行,Host pin 需先公开可获取。没有绕过这一前置,也没有把本地结果冒称远端 CI 通过。 + +真实包的归属和独立审核尚未发生,所以 `catalog/packages/` 为空,索引没有真实收录。 +测试中的 example.test 审核事实只是 fixture,不进入目录。未来客户端兼容矩阵在本批 +确立并验证参考语义;Electron 真正的目录读取、耐久安全事实和安装入口复核属于 S1。 + +2026-10-21 后续:S1 已在同一实验分支完成实现与本地验收,见[第三批施工记录](community-preview-batch3-2026-10-21.md)。下面的“下一阶段”保留本批结案时点。 + +## 阶段状态与下一步 + +- D2:四类作者入口、独立环境参考旅程和 Windows 本地运行验收已交付。其他 Provider、 + 平台、手工 GUI、真实模型和付费服务保持各自原有限制,不统称全部认证。 +- S0:目录格式、收录政策、审核清单、发布工具与本地 fixture 验收已交付;远端 CI 和 + 公开发布仍待相应分支/版本可获取及后续发布决定。 +- D0 仍需先合入 Host,再把协议仓首页改到合入后的作者入口;本轮遵守不合并主线。 +- D3 外部作者验证未开始。维护者模板不能代替两名外部作者的资格。 +- 下一阶段 S1:Electron net 下载与镜像、可信身份传入现有安装事务、按 wheel 依赖选择 + 准备方式,以及设置存储中的封禁/最高序号、备份恢复与启用边界。S2 才提供商店 UI。 +- S3 的真实收录、独立审核和仓库治理没有提前宣称完成。签名、自动更新、自动回滚及新 + 插件类型均未实现。本批没有推送、建 PR、合并或归档;Node 依赖继续独立安装,无主仓联接。 diff --git a/docs/verification/extensions/community-preview-batch3-2026-10-21.md b/docs/verification/extensions/community-preview-batch3-2026-10-21.md new file mode 100644 index 00000000..dca978a2 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch3-2026-10-21.md @@ -0,0 +1,131 @@ +# 社区插件开发预览第三批:S1 下载、安装与耐久安全准入 + +日期:2026-10-21。状态:实验分支实现和本地验收完成;未推送、未合并、未发布。 + +Host 继续使用 `codex/extension-community-preview`,从 D2 的 +`36ae48287779d05608b1bb505ece39391da8eb4a` 施工。主实现提交为 +`f00623a811e001f37a76e4135c03dabe8fd84a17`,实际/期望版本反馈修正为 +`a7aab2be21bbdf03744131ef8868e5bf09064306`;双副本损坏丢失安装清单后仍显示已运行 +后端停止提示的收口为 `a6fd0c95d6706cdc16f6d7afd178a7d0b2f9fe48`。本记录随后独立提交。 +协议实验仓保持 `c7f5487a0fb9962b718187eec20428697ce4c244`,本轮没有改动。 + +没有更改 SDK wheel、SDK/manifest 协议来源 pin、Host 权限或开放贡献类型。 +新增目录 pin 独立记录在 `server/extensions/catalog-source.json`,副本直接取自协议 +提交的 Git blob,避免 Windows 工作区换行影响文件摘要。原有协议来源检查及目录 +副本与已提交 Git blob 的 SHA-256 核对通过。 + +## 交付范围 + +| 责任边界 | 本轮实现 | +| --- | --- | +| 目录解释 | `server/extensions/catalog.py` 复用固定 S0 schema/政策;从现有 schema 和 profile 得出当前 contract,再按实际 Python、平台和 SDK 判断逐包兼容性。未知类型/contract、混合未来包不影响其他条目和有效安全事实 | +| 目录 main owner | `electron/src/main/extensionCatalog.ts` 固定唯一官方索引,缓存只展示;商店安装先刷新,renderer 只能提交 action、ID、版本,不能提交 URL、摘要、等级或来源;安装时及提交前重核当前条目和安全状态 | +| 网络与镜像 | `extensionDownloads.ts` 使用 Electron net、独立内存 Session、omit credentials、正常证书与系统代理;逐跳 HTTPS、最多 5 跳、120 秒请求期限、4 MiB 索引和 256 MiB wheel 上限;按解码后的流量计数 | +| 暂存清理 | 成功、失败、取消后清理;下一次启动后的请求回收有完整归属标记、只有预期普通文件的中断下载,跳过当前活动目录、链接、未知或损坏归属。复用桌面的单实例边界 | +| 安装事务 | `extensions.ts` 继续私有复制、inspect、原生确认、准备、校验、提交;在复制后的 wheel 上验证三元组。SDK-only/无依赖 Python 包的本地和商店入口都离线安装;第三方依赖只从本地走已有 resolver;资源包直接解包 | +| 安全事实 | `desktopSettings.ts` 与 `extensionSecurity.ts` 在原 settings/.bak 中保存最高序号、完整决定、检查时间和内容校验和;不添加分发来源或另一份配置文件。校验和用于意外损坏检测,不是签名 | +| 启动与显示 | 封禁覆盖本地/商店安装、启用和下一次后端启动;主题即时恢复内置底色并显示安全原因。已运行的被封禁版本如实显示待停止,安全旧版与被封禁的期望新版分开处理 | +| 最小用户入口 | 已安装管理器增加检查时间、手动检查及独立恢复按钮;刷新不清掉编辑草稿。HTTPS 产物镜像字段复用配置目录及设置 UI;完整浏览、详情、搜索、更新推荐和进度界面留在 S2 | + +镜像采用 `//<原 wheel 文件名>` 布局,只换产物字节位置;没有 +索引镜像和静默切换来源。说明见 [客户端与恢复文档](../../extension_catalog.md)。 + +## 断电与恢复的实际语义 + +- 主副本都参与完整安全快照选择;最高序号的完整备份不能被较旧、但可解析的主文件 + 覆盖。同序号冲突进入 unavailable,不逐字段拼接。 +- 旧版有效设置没有安全字段时为 unseen。字段损坏且没有完整副本、双 NUL、截断等 + 情况保留 `.corrupt-*` 字节证据,并将 unavailable 写回原有耐久存储。普通保存、 + markApplied、进程重启都不能把它变成空封禁;已知的冲突序号下限仍被保留。 +- 备份先写、主文件后写之间的故障不报告刷新成功;已完整持久化的新备份仍是之后 + 恢复必须尊重的事实。不可用哨兵自身的中断写入也不能悄悄恢复为空。 +- 已知封禁不因目录缓存丢失、离线、卸载/重装或条目缺失消失。解除需更高序号明确 + allowed 决定;安全存储不可恢复时需要显式官方恢复,不可用普通刷新冒充恢复。 +- 双副本整体损毁时,官方目录只能恢复安全事实,不能恢复其中已经丢失的安装记录、 + 配置或凭据;受损证据保留。没有承诺抵抗主动删除全部本地状态,也没有物理断电实验。 +- 普通撤回和版权下架只停止目录供应,不自动停用本地已装版本。封禁保留启用意图、 + 凭据和包数据;不会把仍在运行的后端宣称为已停止。 + +## 验证结果 + +| 验证 | 结果与证据 | +| --- | --- | +| 完整 Electron 测试 | 最终 `npm --prefix electron test`:565 通过,`build/s1-electron-final.log` | +| Python 插件、作者、协议、SDK、发行及 Pi 权限作用域 | 按文件运行合计 187 通过、7 跳过,`build/s1-python-suites/*.log`。目录独立来源与发行复验另有 33 通过:`build/s1-source-final.log` | +| 跳过项 | POSIX 执行位 1 项、Windows symlink 权限 1 项;独立 pytest 未传安装旅程记录的作者契约 5 项。实际安装后的 Tone/Notes/Skill 运行检查见下,不将未运行的项目算作通过 | +| 实际下载、安装与损坏恢复 | 主实现提交 f00623a8 上的 `build/extension-catalog-journey/5f1362210254/report.json` 为 ok,30 个检查全为 true;之前完整复验 `89550fedcf27` 也通过 | +| 真实运行 | 同目录 `tone-runtime.json`:进程契约通过;`notes-runtime.json`:原生 MCP 传输发现、读写及关闭通过,native_agent 为 not_run;`handoff-runtime.json`:实际 Pi 读取/恢复通过,使用受控 loopback 模型 | +| 主题与用户入口 GUI | `output/diagnostics/extension-settings-ui/report.json` 为 passed,10 项检查。生产 React/CSS 在真实隐藏 Electron 窗口运行,包含中英文/窄窗口、刷新失败与草稿保留、显式恢复和镜像字段;截图包括 `security-recovery-and-mirror.png` | +| 封禁反馈收口 | a7aab2b 的插件单元组 71 通过,`build/s1-security-feedback.log`;覆盖安全旧版/被封禁新版与反向情况、活动主题立即回退和原因为安全封禁。双损坏导致清单丢失后,仍从内存中的实际加载事实显示停止提示;最终完整 Electron 集合 565 通过 | +| 构建及静态检查 | `npm run build` 完成配置/SDK/Pi 检查、TypeScript 和 Vite;最终反馈改动再经 tsc。保留既有大 chunk 提示。ruff 全仓、协议副本、Git whitespace 通过 | +| 文件系统隔离 | 两处 node_modules 都是独立目录,无 junction;没有改主仓、复制日常凭据、推送、合并、归档或启动日常后端 | + +主要边界用例包括:摘要/ID/版本不符、下载后换字节、确认期间封禁、取消、超限、 +失败清理、并发卸载串行化、缓存仅展示、过时目录重放、同序号冲突、缺失安全决定、 +普通/版权撤回、SDK-only 与第三方依赖分支、未来类型/contract/mixed 目录,以及 +主坏/副坏/主缺失/双坏/NUL/截断/安全字段校验和损坏、双文件写入中断、备份较新、 +普通保存和显式恢复。未改变安装后默认禁用或失败更新保留旧版本的行为。 + +### 真实网络与测试信任的区分 + +真实 Electron 网络栈验证了公网 HTTPS 的系统证书检查、与默认 Session 一致的系统 +代理解析、无效证书拒绝、HTTPS 重定向、HTTP 降级拒绝、CONNECT 代理隧道、认证与 +Cookie 隔离、取消和体积限制。代理隧道只在测试 Session 中配置,没有修改系统代理。 + +故障服务器为本机 HTTPS:只在探针进程中钉住那一次生成的证书,系统证书库没有变化, +产品代码没有 TLS 例外。无效证书用独立主机名检查,避免 Chromium 缓存先前证书判定 +干扰夹具。测试 wrapper 把固定官方索引请求映射到本机 fixture;产品没有索引覆盖参数。 +发布者和独立审核记录都是明确的 fixture,不是四个包已正式审核、收录或发布的证明。 +原生安装确认由驱动代答,`nativeDialogs=false`;没有调用付费 Fish/MiMo 服务。 + +四个复用产物均来自 D2 的 `build/extension-author-journey/4005cfb5b41a/wheels`,版本 0.1.0: + +| 包 ID | wheel SHA-256 | +| --- | --- | +| com.example.tone-tts | cdbea2a6b70654952d9aeda97fec8085134ce6ecccfac2395f0b8467a7b2f0c1 | +| com.example.notes-mcp | a249c1233ab259ead7d401619e5f898ce95dfff993fd0447b0a47d20c2869b5f | +| com.example.handoff-skill | 20571002ac5be0f90720d845af0c32c3168cda508ad83acf61ac0a71861c7fab | +| com.example.desktop-theme | d70397a19c8698f0352cfe57df385ab648a9d7e8f0b8f7facf514ee429cf852e | + +环境:Windows、CPython 3.12.10、Host API 0.1、SDK 0.1.0;SDK 来源仍为 49449a0f, +manifest 来源仍为 b30df736。完整来源和每次运行层级由上述 JSON 报告记录。 +探针结束后安装目录按既有清理边界处理;双损坏证据和测试数据保留在 build 内。 +报告中的临时安装快照不再作为之后执行插件的可用记录。 + +### 中间失败与修正 + +- 早期公网请求发现 Electron 响应头是 string 或 string[],且内容可能由 Chromium + 解压。错误的 Content-Length 判断在下载 owner 修复并加入字符串/压缩响应回归。 +- 本机证书夹具最初受证书判定缓存影响;调整测试主机名和精确临时证书校验,没有 + 放宽产品证书验证。此前阻塞的探针进程树已按确切测试路径清理,驱动也增加超时树清理。 +- 真实主题的目录 platforms 为空,表示未声明平台限制。reader 起初误判不兼容,已按 + manifest 默认语义修复,并加无平台限制的正例。 +- 配置默认空 URL 暴露了设置 owner 未尊重 allow_empty 的情况;修复通用语义并保持 + 非空 URL 的 scheme 校验。中文目录排序按原规则整理,没有放宽测试。 +- 第一次 Python 合并运行结果是 186 通过、7 跳过、1 失败:发行检查要求新增源文件 + 已纳入 Git。纳入后按与 CI 相同的分文件方式全部通过,发行及目录额外复验 33 通过。 +- 中断下载归属回收测试曾发现 marker 长度常量写错,改为从唯一 marker 内容推导, + 不再保留第二份长度事实。最终整个 Electron 集合通过。 + +## 复验入口和后续 + +```powershell +npm --prefix electron test +npm --prefix electron run build +python -m pytest -q tests/test_extension_catalog.py tests/test_release_tooling.py +python tools/probes/extension_catalog_journey.py --execute --wheels build/extension-author-journey/4005cfb5b41a/wheels +node -e "require('node:child_process').execFileSync(require('./electron/node_modules/electron'), ['electron/tests/extensionSettings.smoke.mjs'], {stdio:'inherit',windowsHide:true,timeout:120000})" +``` + +Python 较大作用域按文件运行:extension_catalog、extensions、extension_resources、 +extension_mcp、extension_skills、extension_examples、extension_fish_audio、 +extension_protocol_provenance、extension_sdk_build、extension_authoring、extension_contract、 +release_tooling、pi_mcp、pi_scoped_approvals(文件名均为 tests/test_*.py)。 +本轮没有运行远端 GitHub Actions、跨平台资格或安装版完整发行检查。 + +S1 可以进入评审,下一阶段为 S2 的完整商店用户旅程。S0 仍只有本地实验索引;实际 +官方索引发布、真实独立审核、仓库治理和安装版验收留在 S3;D3 外部作者资格仍未完成。 +D0 协议首页链接仍需先合 Host 后更新。本轮遵守不合并主线,签名、自动更新、自动回滚 +和新的插件能力均没有提前实现。 + +后续留档:S2 已在同一实验分支完成浏览、详情、进度和手动更新,见[第四批施工记录](community-preview-batch4-2026-10-21.md)。本文件保留 S1 结案时点及其验证边界。 diff --git a/docs/verification/extensions/community-preview-batch4-2026-10-21.md b/docs/verification/extensions/community-preview-batch4-2026-10-21.md new file mode 100644 index 00000000..061382b3 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch4-2026-10-21.md @@ -0,0 +1,115 @@ +# 社区插件开发预览第四批:S2 商店用户旅程 + +日期:2026-10-21。状态:实验分支实现与本地验收完成,未推送、未合并、未发布。 + +继续使用 Host `codex/extension-community-preview`,起点为 S1 留档提交 `39ed76f5`。 +主实现:`24d410e47b920d69445a3ced5163df676180321f`;闭合语音角色展示修正: +`41e4836d45095c48a0bb96584a629e7e73e0c8c7`;明确恢复动作修正为 +`a78399dba0bb37cab0befa265f43ce5b1a6d8e04`。本记录随后单独提交。 +协议实验分支生成器补充:`58d3892d55c6a7ec6f8626153f503291bb10ab9a`,其验证留档为 `a5ca015f30f074474ec12633c9c8cc8fc45e9a02`。 + +Host API、开放贡献类型、运行 owner、权限、SDK wheel 和 manifest/SDK 来源 pin 均未改变。 +仅独立目录副本 pin 更新到上述协议生成器提交;当前目录仍为 v1,没有增加分发来源 +持久字段、自动更新、签名、回滚或插件沙箱承诺。 + +## 已交付 + +| 范围 | 实现与责任 | +| --- | --- | +| 同一管理器内导航 | `ExtensionSettings.tsx` 增加“已安装 / 浏览”;Tab/方向键/Home/End 可操作。切换视图保留未提交配置,下载状态在两个视图中可见,不新建独立插件设置页 | +| 发现与详情 | 新增 `ExtensionCatalog.tsx`:名称/说明/能力搜索、类型筛选、版本选择、维护者/许可、平台/Host API/Python/SDK、wheel 体积、产物地址/摘要、审核范围与检查时间、生效方式及外部访问说明 | +| 更新选择 | `extensionCatalogModel.ts` 按三个整数精确比较 X.Y.Z,跳过不兼容、撤回、封禁及待移除状态;更新最高版本不兼容时保留可安装的较新版本。非 X.Y.Z 本地版本不猜测升级顺序,允许明确选择版本走原生确认 | +| 真实阶段反馈 | main 维护短期检查/下载/校验/确认/准备进度,installer 在实际边界报告阶段;renderer 只轮询紧凑 status,不重复读取整个索引。下载完成不等于保存成功;取消等待操作回执,错误不冒充成功取消 | +| main 权限边界 | 受信 renderer 仍只能提交 action/ID/版本。`dispatch()` 被生产 IPC 和真实 UI 探针共同使用;URL/摘要/审核不是 renderer 的可信输入。主页及源码链接由 main 解析并验证 HTTPS、无凭据后打开 | +| 安装后旅程 | 新装保持禁用,成功提示引导到配置、启用、Agent/Voice/Appearance。已安装卡片继续区分已保存、待重启、实际加载和受阻。失败更新保留既有状态;安全阻止时仍可停用和卸载 | +| 扩展兼容 | 未来类型/contract 及混合未来包可浏览具体原因但不可安装;筛选未来能力时显示实际匹配版本。ASR 与未来语音角色不会被误标成 TTS,也没有因此开放执行能力 | +| 文本和样式 | 第三方说明、作者与访问数据只作文本,不渲染 HTML/Markdown、不请求远程图片;中文文案、窄窗口换行、详情焦点和原有主题 token 接入 | +| 来源与验证 | 协议 `artifact_display()` 从 wheel 生成可选 `display.access`;只含 OS 声明及 TTS/MCP 服务/命令模板,不复制配置默认值、环境值或 bearer token。旧目录没有该字段时明示缺失,并沿用安装确认中的 wheel 披露 | + +协议 display 是原有 v1 的开放展示字段,本轮未改变格式主版本、安全决定结构或审核 +规则。老条目可以继续复用历史生成事实而没有 access;客户端不会据缺字段宣称无外部 +访问。协议仓的公开索引仍没有真实收录,测试审核记录不进入目录。 + +## 验证与证据 + +| 检查 | 结果 | +| --- | --- | +| 最终完整 Electron 集合 | 576 通过;`build/s2-electron-final.log`。覆盖现有设置/安装/主题/生命周期,新增目录模型、取消语义、紧凑 status、草稿保留与未来语音角色 | +| Host Python 作用域 | 105 通过、1 跳过;`build/s2-python-final.log`。目录/作者工具/安装静态 owner/资源/协议来源/SDK/发行检查。跳过项为 Windows 上的 POSIX 执行位契约 | +| 协议目录单元检查 | 18 通过、11 跳过;其中新增公开披露提取与敏感默认值/环境值排除。11 项要求显式 1840c8e 固定 Host 检查环境,本轮没有重跑该固定历史 CI;不把跳过当通过 | +| 真实商店 UI 旅程 | 在主实现提交 24d410e 上,`build/extension-catalog-journey/add316eb2079/report.json` 为 ok,27 项全部通过。此前完整轮次 `79a32b43af93` 亦通过 | +| 真实运行 | 同目录 `tone-runtime.json` 为 ok:UI 安装并启用的 Tone TTS 经实际 Runner 完成运行契约。真实主题由安装器原生校验,且通过生产 Appearance / ThemeProvider / themeApplication 实际绘制和更新 | +| 既有 GUI 回归 | `output/diagnostics/extension-settings-ui/report.json` 为 passed,10 项检查;真实隐藏 Electron 中保留旧配置、MCP/Skill 绑定展示、主题、导航、刷新/恢复与窄窗口行为。该套 Host 响应为合成,不混作真实网络安装证据 | +| 构建与静态检查 | 完整 npm build、Pi/SDK/profile/config 检查及 TypeScript 通过;ruff 全仓通过;协议副本来源、发行检查与 whitespace 检查通过。保留既有 Vite 大 chunk 提示 | + +真实 UI 使用生产 renderer、preload 桥接、main `dispatch`、Electron net、installer 和 +settings owner。它使用独立 profile、loopback HTTPS 与临时证书精确 pin;测试代码 +才将固定官方索引请求映射到 fixture,产品没有索引覆盖开关。没有修改系统信任库或 +系统代理,没有复制日常配置/凭据,也没有调用付费 Fish/MiMo 服务。 + +原生安装确认由驱动代答(`nativeDialogs=false`),完整 Chat/backend 未启动 +(`fullBackend=false`)。TTS 运行由明确执行的契约工具验证;主题使用实际已安装资源。 +因此这些证据证明商店界面到安装/资源应用/Runner 的连接,不等同于手工确认、完整 +聊天体验、全部 Provider 资格或正式发布审查。 + +### 27 项真实 UI 检查覆盖 + +- 只打开“已安装”不联网;进入“浏览”取得目录。真实 wheel 详情和第三方 HTML 字符串 + 以纯文本显示;危险主页被 main 拒绝,安全源码链接只将验证后的 HTTPS 交给外链 owner。 +- 下载显示实际字节进度,切换到已安装仍可取消。取消后没有新记录;双击只确认并提交 + 一次;新装处于禁用状态。 +- 主题安装、启用、进入 Appearance 选用后实际绘制;0.1.0 → 0.2.0 手动更新;合成 + 0.10.0 混合未来版本没有覆盖兼容更新推荐,详情给出原因且强行 IPC 安装也被拒绝。 +- 错字节更新失败保留已保存/已应用的旧主题;成功更新保留启用意图并应用资源包;离线 + 刷新保留配置草稿并清掉旧成功提示。 +- 停用恢复内置外观;卸载默认保留数据;重装后明确选择删除数据才删除。 +- Tone TTS 经界面安装/启用,实际 Runner 输出契约通过,然后停用/卸载走原有停止清理边界。 +- 中文封禁、禁用安装按钮与版权下架原因分别可见;560px 页面不横向溢出;没有远程图片 + 请求、renderer 未处理错误或遗留的活动下载目录。 + +五张截图位于该轮报告目录:`store-details-en.png`、`store-download-en.png`、 +`store-future-version-en.png`、`store-updated-en.png`、`store-withdrawal-zh-narrow.png`。 +人工读取了英文详情与中文窄窗口截图;真实滚动位置可能只显示详情的一部分。 + +使用 D2 原有 `build/extension-author-journey/4005cfb5b41a/wheels`,四类包的 +0.1.0 / 0.2.0 共八份 wheel 均由同一静态 owner 检查并生成 fixture。完整 ID、版本、 +SHA-256 在 `report.json.artifacts` 中;安装和使用旅程选择主题、Tone TTS。0.1.0 的 +摘要与第三批记录一致,0.2.0 在该报告中独立记录。 + +## 测试中发现和处理的问题 + +- 原有浅层 React harness 没有调用 effect cleanup,加入进度轮询后会留下定时器。 + 在测试 owner 修复卸载/重绘清理;原“离线页面完全没有 input”的断言改为检查未打开 + 配置表单,允许离线浏览搜索框。没有删除草稿/错误/运行状态的行为断言。 +- GUI fixture 漏了后端离线时会调用的 startup-failure API,又只改变 localStorage + 而没有同步真实设置中的语言偏好;补齐显式“无启动故障”响应与独立测试设置,使用 + 原子 DOM 查找/点击,未改变产品逻辑迎合夹具。驱动脚本启动/引号错误也在夹具层修复, + 增加预先语法检查和有界 UI 查询;精确清理了失败测试进程树。 +- 一轮 fixture 在刷新时移除了正查看的版本,界面正确关闭安装并显示“版本已不在目录”。 + 正向旅程随后遵守版本保留规则,显式选择 Tone 版本;没有增加自动替换用户所选版本的 fallback。 +- 类型筛选起初可能命中未来版本却展示同包较旧版本;修正为从实际匹配版本中选卡片, + 全部版本仍在详情选择器可见。已补同包未来版本正反测试。 +- 取消信号不能覆盖 installer 自身的准备/持久化错误。仅将下载阶段的中止转为取消; + installer 的明确取消例外继续沿用,其他错误保留真实失败,避免误报“没有保存”。 +- 最后收口语音角色文案,ASR/未来 role 保持身份且仍不兼容,没有映射到 TTS。该改动的 + 模型/文本/翻译十项检查和完整 Electron 集合通过。 +- 进入 Browse 只能触发普通刷新,损坏安全状态必须通过明确的恢复按钮请求 recover; + 不将导航到商店当作恢复授权。对应组件组 16 项与 GUI 恢复回归通过。 + +## 复验与下一步 + +```powershell +npm --prefix electron test +npm --prefix electron run build +python -m pytest -q --tb=short -rs tests/test_extension_catalog.py tests/test_extension_authoring.py tests/test_extensions.py tests/test_extension_resources.py tests/test_extension_protocol_provenance.py tests/test_extension_sdk_build.py tests/test_release_tooling.py +python tools/probes/extension_catalog_journey.py --execute --store-ui --wheels build/extension-author-journey/4005cfb5b41a/wheels +node -e "require('node:child_process').execFileSync(require('./electron/node_modules/electron'), ['electron/tests/extensionSettings.smoke.mjs'], {stdio:'inherit',windowsHide:true,timeout:120000})" +``` + +Windows CI 在 D2 生成真实参考 wheel 后增加同一 `--store-ui` 旅程,并收集报告、运行 +结果、截图和日志;不会上传临时私钥。此次没有执行远端 Actions、跨平台或完整安装版 +发行资格,不把本地成功写成远端 CI 通过。 + +S2 可进入评审。S3 仍需正式目录发布、每个实际产物的独立审核、仓库治理、打包/升级 +和干净环境验收;D3 两名外部作者资格未完成。D0 协议首页链接仍按先合 Host 后改入口 +的顺序。本轮不推送、不合并主线、不归档 worktree;两处 Node 依赖继续是独立目录。 diff --git a/docs/verification/extensions/community-preview-batch5-2026-10-21.md b/docs/verification/extensions/community-preview-batch5-2026-10-21.md new file mode 100644 index 00000000..a12eb58e --- /dev/null +++ b/docs/verification/extensions/community-preview-batch5-2026-10-21.md @@ -0,0 +1,71 @@ +# 社区插件施工记录:第五批 S3 本地发行准备 + +日期:2026-10-21。实验分支 `codex/extension-community-preview`。 +本批从 Host `e579f099` 与协议仓 `a5ca015` 继续;未合并、未推送、未发布。 + +发布资格以 [store-v1 检查表](store-v1.md) 为准;本记录不把自动化技术检查当作独立审核或完整 S3 结案。 + +## 发行缺口与修复 + +实际打包配置只复制了 SDK wheel,`ExtensionPackageHelper` 却在 packaged `resources/tools/extension_package.py` 查找校验器;该路径不存在。归类为发行资源集成缺口。现在将已有静态 owner、Skill 元数据 reader、schema/profile、目录固定副本与许可复制至 `resources/extension-host`,主进程的插件 helper/installer 使用这个独立根。SDK 仍用 `resources/extension-sdk`,运行时、能力类型和协议/SDK pin 不变。 + +不能把这些 `server` 文件直接放到 resources 根:正常后端仍由显式配置的 Python runtime 提供,会被同名 package 遮蔽。隔离单测从真正的打包过滤清单复制资源,在 `python -I` 下检查 profile、目录和 Skill,验证无需源码 checkout。生产代码没有加入测试 URL 或确认绕过入口。 + +## 实际桌面与 reader 检查 + +- 源码 shell:`build/s3-source-shell/electron_model_less_20261021T090109Z_dc3551/report.json`,15 项既有桌面检查 + 14 项插件检查通过。 +- 实际 Windows 可执行文件:`build/s3-packaged-shell/electron_model_less_20261021T090301Z_f2628f/report.json`,同样 15 + 14 通过。两者使用正常认证后端、生产 renderer/preload/IPC,测试通过私有 inspector 提供合成目录 HTTPS 字节和自动原生确认。新建 settings、profile、安装目录,未复制个人配置或已安装环境。 +- 主题 0.1.0 下载、启用、Appearance 选择,镜像更新 0.2.0 保持启用;离线刷新保留已安装;Tone 0.1.0 商店安装后以实际私有 Python 运行 Runner 合约,随后禁用/卸载。Notes MCP 0.1.0 通过实际界面保存 Pi 绑定,安装后的包经原生 MCP client 完成 save/read/list;这不包含模型驱动的原生 Agent turn。Tone 是合成 PCM,不冒称有用的语音服务;完整后台重启应用和最终发行异常矩阵仍未据此结案。 +- `build/s3-reader-matrix/report.json`:从 Git blob 导出未修改 `f00623a811e001f37a76e4135c03dabe8fd84a17`(S1)和 `e579f099ca9a3583591323c386fec0a20bd850ff`(S2)reader。对同一新目录保留 8 个兼容版本与安全事实,未来类型/角色/contract/运行/交付逐包禁用;未知大版本和损坏安全结构整份拒绝。报告保存输入和 reader 文件摘要。 +- 首次 shell 探针因只启用主题、未在 Appearance 选择而失败;这是验收脚本漏步骤,补齐真实选择后通过,没有修改产品语义。 + +## 首批候选与公开门槛 + +协议实验仓 `catalog/review-candidates/reference-wheels.json` 保存四类八份 wheel 的字节摘要、完整内容清单和候选版本差异。静态检查使用从固定 `1840c8e4f646992ddaebea3139c8895f29743a8f` 导出的原始 checker、最小依赖环境与 CPython 3.12.10;八份均通过 Host check-wheel 和固定 curated-v1 policy。不是完整 Git checkout 上的远端 CI/verify_host。审核状态全部 `not_reviewed`,不填写虚构 reviewer、归属或发布 URL,bootstrap/安全事实/包条目均未改动。 + +2026-10-21 只读核查实际 GitHub 配置: + +```text +gh api repos/Code-Amadeus/Amadeus-Extensions/branches/main/protection + HTTP 404: Branch not protected +gh api repos/Code-Amadeus/Amadeus-Extensions/rules/branches/main + [] +gh api repos/Code-Amadeus/Amadeus-Extensions/actions/permissions/workflow + default_workflow_permissions=read; can_approve_pull_request_reviews=false +gh api orgs/Code-Amadeus + two_factor_requirement_enabled=false +``` + +组织未强制 2FA 不等于个人未开启;个人状态未验证。未改仓库设置。公开发布仍需独立产物审核、真实 wheel 地址、公开可取的 pin、保护/评审/2FA/发布权限和远端 CI。D0 协议首页需在 Host 合入后更新;D3 外部作者仍未完成。清洁 Windows 机器、人工原生确认、最终发布构建上的完整异常矩阵仍是开放门槛。 + +## 自动化与复现 + +`python-windows.yml` 现在用 D2 构建的真实 wheel 生成独立 fixture,在源码与打包 smoke 两处加入 `--extension-fixture`。fixture 仅含一次性证书、测试索引和受检产物位置;不上传私钥。默认 smoke 不运行插件。固定 reader 使用 `tools/probes/extension_reader_matrix.py`;命令见 [客户端指南](../../extension_catalog.md#source-and-packaged-desktop-qualification)。 + +本地打包钩子会给 wallpaper 的 packages.lock.json 加一个空 win-x64 目标;本轮已还原这个无关生成差异,不把它作为插件修改提交。 + + +## 最终代码、产物与回归 + +实现提交:`c1aa9f0`(打包资源与发行探针),`3ea67e4`(Notes 旅程),`ebcfd00`、`ee45e33`(真实界面选择和隔离 Pi 注册)。最终代码 commit 为 `ee45e337428de4ccd21d9f0252497a5c49a32270`;协议候选材料 commit 为 `e09f431`,目录 source pin 仍为 `58d3892`,manifest/SDK pin 均未改变。 + +两次最终 shell 运行时是 `ebcfd00` 加当时未提交的隔离 Pi fixture 改动,该改动随后原样提交为 `ee45e33`。不能把运行报告的 dirty 标记说成干净提交验收。报告同时记录可执行文件/asar/SDK 的实际摘要、24 个 checker 资源摘要、全部 8 个 fixture 产物与实际使用的 4 个产物。另核对打包资源与当前源码逐字节相同,asar 内 48 个编译文件与本轮 dist 相同。后续仅追加记录,不把记录提交冒称已重新构建的发布包。 + +| 产物 | SHA-256 | +| --- | --- | +| `build/s3-source-ee45e33.zip` | `e73a442d58e5f4e955bba744a7bb64f9184ce44f744b90875504d1ff609fe98f` | +| `electron/build/win-unpacked/amadeus-desktop.exe` | `e5528a9c00a5f1d8f592fd95b8f21c051ad9276d16ab7f42ca8fb2002081fafe` | +| `resources/app.asar` | `cce87e3fbdd2d021737624d58f5e863ca5d6ddf59a2ec6b14ea944bbfb282109` | +| SDK 0.1.0 wheel | `274bd66b81932c8ea50f39f58e7bf91530a8015196acfcf9bcfd77fa2d8b8084` | + +源码归档来自干净 `ee45e33`:4216 文件入选、138 排除、0 错误、0 警告;解压至新目录后用最小依赖解释器 `-I` 校验八份 wheel 全通过,报告 `build/s3-source-final-static.json`。未从这个 ZIP 重新安装完整后端/Node 依赖,也未声称是干净 Windows 机器验收。source-release 的 `release_ready` 只表示该工具覆盖的源文件/许可/归档门槛,不是 S3 公开发布批准。 + +- Electron:`npm --prefix electron test`,**576 通过**,`build/s3-electron.log`。 +- Python:插件、Pi/MCP/scoped approvals、来源、SDK、发行和 model-less scope 首轮 **193 通过、7 跳过、1 失败**。唯一失败是新 required 文档尚未进入 Git 的 `required-file-not-tracked`;纳入提交后重跑发行与 smoke 文件 **21 通过**,随后 smoke 配置修订再次 **6 通过**。没有修改或放宽断言;合计覆盖的通过用例为 194,跳过 7。日志 `build/s3-python.log`、`build/s3-release-tests.log`。 +- 七个跳过:五个要求显式 installer-backed author journey 的合约用例(本轮 shell 仅补 Tone/Notes,四类完整 D2 证据仍引用第二批),一个 POSIX executable 权限、一个 Windows symlink 权限。未冒称这些 pytest 用例通过。 +- 协议 catalog 测试 **18 通过、11 跳过**;11 项仍要求固定历史 Host 的完整 Git checkout。八份候选的固定 blob 静态校验补充证据不替代这些测试。 +- 构建通过(仅原有 Vite 大 chunk 提示),Ruff、fixture Node syntax、Git whitespace 检查通过。没有远端 Actions 运行。 + +Notes 扩充探针的失败也保留在 `build/s3-source-shell`:先绕 UI 写绑定造成配置草稿仍为空;改成界面选择后发现无凭据隔离环境中 Pi 被 `pi_model_credentials_unavailable` 拒绝注册。最终按原生 Pi 的自定义模型格式写入仅指向 `127.0.0.1:1` 的合成条目,使用非真实 token,让本地 `auth check --no-refresh` 注册通过。没有模型请求、外部服务调用或付费验证;没有更改生产启动/权限规则。相关等待显式限时。 + +本轮完成 S3 的本地工程准备和可审查材料。S3 公共发布仍受 [store-v1](store-v1.md) 所列门槛约束;不创建或合并 PR,不发布目录,不调整仓库治理设置,不归档 worktree。 diff --git a/docs/verification/extensions/community-preview-batch6-2026-10-21.md b/docs/verification/extensions/community-preview-batch6-2026-10-21.md new file mode 100644 index 00000000..326d1d75 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch6-2026-10-21.md @@ -0,0 +1,79 @@ +# 社区插件施工记录:第六批预合并审查与本机隔离验收 + +日期:2026-10-21。实验分支 `codex/extension-community-preview`。本轮从 Host `febff4b6`、协议仓 `1531535` 继续;未合并、未推送、未发布,也未改仓库保护规则。 + +本轮完成送外审前的本地审查、独立依赖安装、源码 ZIP 和 Windows 打包版商店验收;整组 Host 回归与 Python 依赖审计仍有阻断项,不建议合并或发布。用户没有干净 Windows VM,明确接受先完成本机隔离验收,并要求使用 Docker 补充验证。这不把本机隔离或 Linux 容器等同于干净 Windows、独立人工验收或公开 S3 发布资格。 + +## 审查范围与修订 + +审查安装身份与确认前后重检、renderer/主进程权威边界、官方索引与镜像下载、离线安装依赖、持久化封禁、设置原子写入与备份、显式损坏恢复、启动回执、旧 reader、新类型隔离、打包资源闭包和协议目录 CI。范围内未确认新的生产权限绕过或封禁持久化缺陷;这不是独立安全审计或无漏洞保证。 + +- 测试工具缺陷:源码 ZIP 没有 Git,原 smoke 无法建立代码身份。现在从 `SOURCE_MANIFEST.json` 验证全部列明输入的相对路径、大小和摘要,保留归档提交与 manifest 摘要;缺失、篡改、越界路径拒绝。不是签名认证,也不担保归档之外新增文件。依赖与实际 dist/exe/asar 仍另记哈希。对应 7 项 smoke 测试通过。 +- 依赖缺陷:Vite 8.2.2 落在已披露 launch-editor 漏洞范围,最小补丁升级至 8.2.4。SDK、协议 pin 和目录源 pin 没有改动。最终 `npm audit --omit=dev` 为零;全依赖仍有 8 项中危计数,来自同一 `sprintf-js` advisory 的构建工具传播链,不是 8 个独立根漏洞。未采用审计建议的 electron-builder 大版本回退。 +- 验收工具:添加 shipping failure matrix 和不自动应答的原生确认模式。CI 在源码和 Windows 包两处启用失败矩阵。探针处理 idle progress 的 `null`;没有为探针修改产品规则。 + +参考:[Vite/launch-editor 高危项](https://github.com/advisories/GHSA-587h-7wmj-h5pm)、[相关项一](https://github.com/advisories/GHSA-698v-4r3x-3vcv)、[相关项二](https://github.com/advisories/GHSA-r825-w8mm-5pxw)、[剩余 sprintf-js 项](https://github.com/advisories/GHSA-hp3w-g68c-fv3c)。后者当前无已修复版本;其实际使用在构建工具链,不能据生产依赖审计为零声称整条工具链无风险。 + +## 隔离边界与代码身份 + +新建独立资格目录,源码通过发布工具生成 ZIP 后解压,目录没有 `.git` 或 `.env`。新建 Python venv,用 `uv sync --locked --extra dev --no-cache --link-mode copy` 安装;新 npm 缓存与独立 `npm ci` 安装 Electron/Pi 依赖。没有目录联接、没有复制个人配置、凭据或已安装插件。主仓日常环境没有修改。 + +Windows 11 Home 10.0.26200 x64、CPython 3.12.10、Node 22.21.1、npm 10.9.4。Python、Node、.NET 基础可执行程序和操作系统仍来自本机,所以不是干净 OS。测试 profile 为新建的合成旧版设置,仅含英语 locale 和空安装清单;Pi 只配置关闭的 loopback 模型地址与合成 token,未调用外部模型/付费服务。 + +源码依次更新到干净提交生成的归档;只覆盖归档输入文件,独立依赖保留在资格目录。打包钩子添加的 wallpaper 空 `win-x64` lock 目标由自己的归档原字节还原。源码 smoke 使用 `f5ea6d45`;打包与原生窗口 smoke 使用 `457b1fe7` 的完整 manifest。两者之间只新增原生确认探针及入口,不改变打包应用代码。报告均记录 `identity_kind=source-release-manifest`、`dirty=false`、实际 frontend 摘要;后续留档提交不是重新构建的应用。 + +## 验收结果 + +证据根为本地资格目录 `qualification-20261021/`;对外压缩包内提供脱敏 JSON、测试结果和哈希索引。 + +| 检查 | 结果 | 证据相对路径 | +| --- | --- | --- | +| 新 Python 环境闭包 | 110 个安装包兼容 | `python-verify.log` | +| Electron | 576 通过 | `electron-tests.log` | +| Host 选定回归 | 194 通过、7 跳过、1 失败(Pi scoped approval) | `host-final-tests.log` | +| 独立串行 Pi/MCP + scoped approvals 复核 | 30 通过;不消除整组偶发失败 | `pi-final-serial.log` | +| 固定历史 Host 完整 Git checkout + 最小校验器 | catalog 29 通过,无跳过 | `catalog-tests.log` | +| 四类作者旅程 | 四类各 0.1.0/0.2.0、12 阶段全部通过 | `source/.../build/extension-author-journey/27c906e22fd4/report.json` | +| 源码 ZIP 实际 shell | 15 桌面 + 14 插件 + 18 故障检查通过 | `source-smoke/electron_model_less_20261021T094813Z_fd58d7/` | +| 实际 Windows exe | 同样 15 + 14 + 18 通过 | `packaged-smoke/electron_model_less_20261021T095401Z_2aafcc/` | +| 实际 Windows 原生确认 | 15 桌面 + 4 原生确认结果通过 | `native-confirmation/electron_model_less_20261021T095844Z_28c7a1/` | +| Linux Docker 断网协议回归 | 113 通过、11 跳过 | `docker-tests.log` | +| Linux SDK 构建 | 与 Windows/已有 pin 字节摘要相同 | `docker-sdk.log` | +| Ruff / Node fixture syntax / Git whitespace | 通过 | 本轮命令记录 | + +所有三次实际 shell 报告 `console_errors=[]`、`page_errors=[]`,正常退出前验证 Electron/后端关闭。源与打包版都实际运行下载、镜像、安装、手动更新、Theme 外观、Tone Runner 和 Notes MCP 原生 client 的 save/read/list。D2 使用受控模型做原生 Pi Skill 旅程;不把 Notes 的直接 MCP client 测试说成模型驱动 Agent turn。 + +原生窗口使用 `--native-confirmations`,fixture 保留真正的 `dialog.showMessageBox`:Codex 通过 Windows computer-use 观察并点击一次 Cancel、一次安装 Confirm、一次 Enable Confirm,检查未安装、默认禁用、启用和主题生效四种结果。**操作员是代理,不是用户或独立人工审核者**。窗口截图在任务工具记录中,外审包提供独立操作回执与自动结果,不伪造截图文件。 + +## 故障矩阵与边界 + +源与打包版各 18 项:进程重启处理 pending removal;取消流式下载不安装;摘要错误的更新保留旧版与已应用主题;版权撤回阻止分发但不写安全封禁;默认卸载保留数据、明确要求才删除;真实后台重启激活插件;新版本启动失败可见、保留新版且不自动回滚;封禁立即撤下主题;删除目录展示缓存后损坏主 settings、离线重启从 `.bak` 恢复封禁;同一产物走本地入口仍被拒;低发布序号不能抹掉封禁;两份 settings 均损坏后新进程保持 unavailable;普通设置保存与普通刷新均不能清除故障;显式官方恢复恢复安全状态但不捏造丢失安装清单;恢复后仍拒绝被封禁产物;失败事务没有残留下载 staging。 + +每轮共三次新应用进程,另有真正后台 restart。NUL 在测试应用停止后注入,**没有做物理断电,也没有声称覆盖所有文件系统崩溃时序**。目录与 TLS 是一次性 fixture;没有公共审核目录。故意抛出异常的 Tone 0.3.0 只用于启动拒绝测试,不进入候选目录。 + +## 保留的失败与审计限制 + +1. 首次工作树回归 `194 passed, 7 skipped, 1 failed`:`test_only_host_bound_servers_load_despite_ambient_configuration` 没有发现 fixture MCP tool。原位置单项复核通过,独立源码目录完整 Pi/MCP 22 项通过;后续完整回归见上表。没有修改测试、增加重试或放宽超时。第二次工作树整组回归又在 `test_revocation_requires_fresh_approval_on_next_identical_call` 得到 `status=error`(仍为 194/7/1);最后在无桌面探针并行的情况下单独执行两组 Pi 测试,30 项通过(171 秒),但没有一次完整工作树运行全绿,不能据此证明并行负载就是原因。根因未确定,交外审保留两次失败,不能写成已修复。 +2. 在无 Git 的源码目录照搬包含 Git 发布工具的开发测试,得到 `194 passed, 7 skipped, 1 failed`。唯一失败是 `test_current_source_policy_has_no_selected_provenance_blockers` 调用 `git ls-files`;这是运行位置不满足该开发测试前置条件。Git 发行工具在工作树检查,真正源码 ZIP 的运行/篡改检查由 manifest 路径覆盖;不给源码伪造 `.git` 或放宽断言。 +3. 七个跳过为五个要求显式 installer-backed journey 的合约用例、一个 POSIX executable 权限和一个 Windows symlink 权限。五项由另行完整 D2 旅程覆盖,但不把这次 pytest 的 skip 改写成 pass。 +4. Docker 的 11 个 skip 要求固定 Host checkout;在 Linux 没有冒充 Windows 目标。相应 Windows catalog 29 项全部执行。Docker 使用 `python:3.12.10-slim@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db`,构建时以哈希清单安装依赖,测试/SDK 构建 `--network none --cap-drop ALL --security-opt no-new-privileges`。未挂载 Docker socket、个人目录或凭据。 +5. Docker Desktop 本机启动因旧 AF_UNIX socket 不可访问而失败;停止后仅将正常 `run` 目录改名隔离,重建空目录后 Engine 29.8.2/Linux 正常启动。未恢复出厂、未删除镜像/容器/卷,隔离目录保留。此环境修复不是插件产品修订。 +6. Python 审计没有通过:依照现有 CI 的 `--ignore-vuln PYSEC-2026-1845` 执行后,仍发现 certifi 2026.7.22 的 1 条、Pillow 12.3.0 的 8 条已知漏洞;既有 pytest 9 升级前豁免继续存在(审计提示 ignored 2)。本地 Amadeus 自身不是 PyPI 包,审计跳过。精简 advisory IDs 和修复版本在外审包 `python-audit-summary.json`。已对照 `fd17e707` 确认这两个版本就在原 Host 基线中,不是商店引入的升级。它们仍是发布阻断项;本轮不扩大成 Pillow 主版本迁移或重新安排整个图像链路验收,也没有加忽略项掩盖失败。参见 [certifi 官方公告](https://github.com/certifi/python-certifi/security/advisories/GHSA-6q48-6c7w-9gw2) 与 [Pillow 13.0.0 发布说明](https://github.com/python-pillow/Pillow/releases/tag/13.0.0)。 + +## 固定产物 + +| 产物 | SHA-256 | +| --- | --- | +| `premerge-qualified.zip`(f5ea6d45,源码实际验收) | `c0da8d58994b294691597938ee2f18dfddae1b88bc7ba506edbcd223a9c9dd19` | +| `premerge-native.zip`(457b1fe7,含原生探针) | `2f499091e051ddb05fd40286a52f653b815a6b1ec4aaffbac5af1eaa0df9542f` | +| `amadeus-desktop.exe` | `e5528a9c00a5f1d8f592fd95b8f21c051ad9276d16ab7f42ca8fb2002081fafe` | +| `app.asar` | `cce87e3fbdd2d021737624d58f5e863ca5d6ddf59a2ec6b14ea944bbfb282109` | +| SDK 0.1.0 wheel(包括本轮 Docker 构建) | `274bd66b81932c8ea50f39f58e7bf91530a8015196acfcf9bcfd77fa2d8b8084` | + +代码提交:`b3a18270`、`f5ea6d45`、`457b1fe7`。协议仓保持 `153153598a9f94e3b44a477e99c3017f978d13e5`;目录源 pin 为 `58d3892d55c6a7ec6f8626153f503291bb10ab9a`,固定 Host checker 为 `1840c8e4f646992ddaebea3139c8895f29743a8f`。完整八份新构建参考 wheel 身份、被使用的产物、checker/SDK/exe/asar 哈希在 shell JSON。参考 wheel 全部仍未经独立审核;新构建字节与旧候选不同也不覆盖旧版审核记录。 + +## 交外审与后续门槛 + +外审入口见 [审查说明](external-review-handoff-2026-10-21.md)。本地压缩包包含经过发布政策检查的源码归档、Host/协议增量 Git bundle、脱敏结果、参考 wheel 和 SHA256 清单,不含用户 profile、原始 backend 日志、TLS 私钥、凭据或运行环境。仅在本地生成,由用户决定交付对象。 + +[store-v1 检查表](store-v1.md) 继续保留:独立 exact-wheel 审核与素材权利、公开不可变下载地址、公开可取 pin、远端 Actions、main 保护/非作者评审/2FA/最小发布权限、最终已审核目录重跑、维护者发布决定。D0 协议首页必须等 Host 合入后指向不可变作者入口;D3 两位外部作者的资格仍未完成。没有因为本轮本地测试通过就开启普通用户安装或宣告 S3 完成。 diff --git a/docs/verification/extensions/community-preview-batch7-2026-10-21.md b/docs/verification/extensions/community-preview-batch7-2026-10-21.md new file mode 100644 index 00000000..0ee71eb1 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch7-2026-10-21.md @@ -0,0 +1,88 @@ +# 社区插件施工记录:第七批 Pi 稳定性与依赖审计修复 + +日期:2026-10-21 至 2026-10-22(Pacific/Auckland)。分支 `codex/extension-community-preview`。接续第六批外审准备;未推送或合并主线,未发布商店/索引。旧外审包及其失败证据保留,新材料使用 r2 名称。 + +本轮用户要求修复 Pi 集成失败与 certifi/Pillow 漏洞,不放宽测试、不新增忽略项。另一个词典会话获用户授权协调环境升级:依赖修复保持独立提交,由该会话独立审查/承接单独依赖 PR,本会话没有修改其工作树。 + +## 独立提交和范围 + +- 依赖修复:`0c8150c973c5dbf45c7d993ded8407a9c388c385`。 +- 依赖 lint 收尾:`59c379a6e9615b1cc17ba90cbfc9a8f55508ea39`,三处 package initializer 的七个显式重导出,编译字节码/名字/常量与上游相同。 +- Pi 测试夹具修复:`51c8363e7c2bc2d9198b93ade047bd10d5af7d79`。 +- 后续仅追加施工/交接记录。SDK、协议来源 pin、目录 source pin、插件类型、生产权限和安装事务未变。 + +## Pi:修复测试准备条件,保留生产边界 + +第六批失败涉及合成 MCP 服务的首轮发现和权限撤销。该服务原先通过 `mcp.server.MCPServer` 启动,导入整套 Python MCP SDK 后才创建 ready 标记。3 次本机测量为 1.219–1.687 秒;这还不含 native Pi 的进程启动和工具发现,负载下可能错过 Host 明确限定的 3 秒首轮窗口。受控启动延迟实验复现了首轮没有工具、权限测试没有实际调用的现象。 + +曾尝试去掉 Host 的 3 秒覆盖、恢复 Pi 的 10 秒默认值;原有挂起服务测试立即失败,因为它要求模型请求在 7 秒测试上限内到达。**这项生产改动已完全撤回**,`agent_host/pi_runtime/mcp.ts` 和其契约文档与第六批相同。没有改测试超时、移除用例、跳过失败或自动重试。既有慢服务可以在后续模型轮次才提供工具的行为保留,没有承诺任意 SDK 服务都必须首轮就绪。 + +最终归类为测试仪器的准备成本与被测时限互相干扰。`tests/fixtures/pi_mcp_server.py` 改为只依赖标准库的合成 MCP JSONL peer,保持原来的工具名/参数/注解、凭据回显、文件效果、资源读取与 EOF 退出行为。Pi CLI、原生 MCP client/stdio transport、工具发现和真实权限 pipeline 仍完整执行;不是替换 Pi 或直接伪造权限结果。ready 标记的同机测量为 0.140–0.156 秒。Python SDK/真实包覆盖仍在单独作者旅程,包括安装后的 Notes 经真实 Pi 授权和 native transport 调用。 + +现有非 ASCII 长工具名测试还捕获了新夹具使用 Windows 默认 stdin 编码的错误;改为按 MCP 约定明确解码 UTF-8,未改该测试。两个原失败用例只补了失败诊断,原断言全部保留。5 轮独立稳定性验证每轮运行“绑定发现、撤销、挂起服务”三项,**15/15 通过**;每轮任一失败即停止,不是失败重试。 + +旧权限失败只有 `status=error`,没有完整底层错误,不能声称已经证明那个单次结果的每个细节。可复现的准备条件竞争已消除;同一整组回归和稳定性结果见下表。 + +## 依赖:真实可解的 Pillow 13 环境 + +`Pillow 12.3.0 → 13.0.0`;`certifi 2026.7.22 → 2026.10.19`。其它保留包版本没有变化。最小作者工具依赖导出逐字节不变。 + +直接升级被 Gradio `<13` 上限阻断,Qwen-ASR 0.0.6 又把 Gradio 作为必需依赖;只移除 Host 的 Gradio 下限会导致解析器退到旧 Gradio。这两种方案均未保留。 + +最终采用有完整来源记录的最小打包修改:从已校验 SHA-256 的官方 Qwen-ASR 0.0.6 sdist 保留全部推理/模型/CLI 实现文件,逐字节不改;将 pyproject 中 `gradio` 从必需依赖移到 `demo` extra。`uv.lock` 使用这个目录 source,保留上游版本以支持漏洞匹配。全仓 lint 随后检出上游三个 `__init__.py` 的七处隐式公共重导出;仅增加 `as 原名`,不改公开名字或 `__all__`。测试反向还原这些机械别名后必须匹配上游哈希,同时比较编译字节码、名字和常量,三者完全相同。全仓 Ruff 通过,没有增加 exclude/noqa。不是 resolver metadata override、跳过依赖安装或审计豁免。许可证、上游文件摘要和移除条件见 [Qwen-ASR provenance](../../../vendor/qwen-asr.PROVENANCE.md)。 + +Host local-model profiles 不再安装 Gradio 演示 UI;`tools.my_utils` 的 Gradio/pandas 导入移进原有 UI helper,`load_audio` 解码路径不变。Qwen 的 Gradio demo 与旧 `subfix_webui.py` 需要与 Host 分离的兼容演示环境,不建议向 Host 填回旧 Pillow/Gradio。ASR/TTS 的 in-process/sidecar 选择、模型代码、权重与 GPU 资格没有改变。 + +完整 CPU 模型环境实际安装 223 个包并通过依赖检查及 GSV/Qwen 导入,证明不是仅让 resolver 表面通过。额外 61 项 CPU 推理/音频路径测试无跳过。未下载模型权重、未运行新的 GPU 实机资格测试。 + +参考:[Pillow 13 官方发布说明](https://pillow.readthedocs.io/en/stable/releasenotes/13.0.0.html)、[certifi 修复版本](https://github.com/certifi/python-certifi/releases/tag/2026.10.19)。 + +## 结果与证据 + +仓库证据根:`build/blocker-fixes-20261021/`。独立环境/桌面证据根:同级 `qualification-20261021/`。对外副本已脱敏。 + +| 验证 | 结果 | 证据 | +| --- | --- | --- | +| 同一 Host 插件/Pi/发行组 + 2 项依赖边界新测试 | 197 通过、7 原有条件跳过 | `host-final.log` | +| Pi 原失败两项 + 原挂起服务约束,5 个独立新进程批次 | 15 通过,无失败重试 | `pi-stability-1.log` 至 `pi-stability-5.log` | +| 新核心环境依赖一致性 | 110 包兼容 | `environment-verify.log` | +| 10 组 locked profile/platform 安装预演 | 全部通过 | `profile-matrix.json` 与各 profile 日志 | +| 新 local-cpu + dev 完整环境 | 223 包兼容、`--profile t4-cpu` 通过 | `model-environment-verify.log` | +| Pillow 图像/相关路径 | 204 通过、2 项缺 Torch 跳过 | `image-inference-regression.log` | +| 在真实 CPU 模型依赖环境补验 | 61 通过,无跳过 | `model-inference-regression.log` | +| 最终显式导出补丁 | 2 项来源/构建测试、全仓 Ruff、刷新安装后真实 GSV/Qwen 导入通过 | `headless-final-tests.log`、`ruff-final.log`、`model-exports-final.log` | +| 最小作者依赖 export + lock freshness | 无差异,287 包锁解析通过 | `author-export.log`、`lock-check.log` | +| pip-audit(现有 CI 规则) | 无未豁免漏洞;certifi/Pillow 各 `vulns=[]` | `python-audit.json`、`python-audit.log` | +| 四类参考插件 0.1/0.2 作者旅程 | 12 阶段通过,内含 9 项 native provider/contract 回归 | `build/extension-author-journey/d151d209c26f/` | +| 更新后的源码商店/故障矩阵 | 15 桌面 + 14 插件 + 18 故障全部通过 | `source-fixed-smoke/electron_model_less_20261021T105222Z_62c9b8/` | +| 更新后的实际 Windows 包/故障矩阵 | 15 桌面 + 14 插件 + 18 故障全部通过 | `packaged-fixed-smoke/electron_model_less_20261021T105719Z_9c4e4a/` | +| 源码归档 | 4241 文件入选、140 排除、0 错误/警告 | `source-archive.log` | + +七项 Host pytest skip 为五个显式 installer-backed 合约用例、POSIX executable 权限和 Windows symlink 权限;前五项另在完整作者旅程执行,不把 skip 文本改成 pass。图像首轮两个缺 Torch 的 skip 已通过真实 CPU 环境补跑。既有 `locale.getdefaultlocale`、上游音频 API 弃用等 warning 保留,未新增过滤器。 + +审计仍使用既有 `--ignore-vuln PYSEC-2026-1845`,工具显示原 pytest 的 2 条 ignored;本地 Amadeus 项目本身不在 PyPI 漏洞数据库审计范围;本轮没有新增或扩展豁免,不声称整个模型依赖集合无漏洞。GPU/模型栈的其它历史审计边界继续按安装文档处理。本轮用户指定的 certifi/Pillow 9 条记录全部消除。 + +MPS 预演第一次默认模拟 macOS 13,发现已锁 onnxruntime 只提供 macOS 14 wheel;用与现有 CI 一致的 `MACOSX_DEPLOYMENT_TARGET=14.0` 复核通过。首轮日志仍保留。其它预演包括 Windows core/voice/vad、CPU/CUDA/ROCm 与 Linux CPU/CUDA。预演不等于对应硬件验收。 + +全新安装最初使用 `--no-cache`,uv 在 freshness 检查时开始下载未选用的 ROCm URL 元数据/产物,因此停止该安装。最终先 `uv lock --check` 验证一致,再从同一锁使用 `uv sync --frozen` 安装独立环境;不修改锁或忽略约束,`uv pip check` 与 verifier 均通过。模型环境从锁安装,未复制主仓 venv 或使用 junction。 + +Qwen 上游原始文件保留既有行尾空白,完整 `git diff --check` 会报告这些原样文件;本地修改文件的 whitespace 检查和全仓 Ruff 通过。没有格式化上游文件使来源哈希失效,也没有加入 Git whitespace 忽略配置。第三方 provenance 检查零错误,8 条仓库原有 unmatched-component warning 保留。 + +## 固定产物与后续 + +源码归档 `source-fixed.zip`(代码 `51c8363e`)SHA-256:`451552d8b645bc6fa76b0204ac2152393b33f230dfa3489a02a759119c8807e1`。Windows exe 和 asar 的字节未因 Python 依赖修复改变,仍为 `e5528a9c00a5f1d8f592fd95b8f21c051ad9276d16ab7f42ca8fb2002081fafe`、`cce87e3fbdd2d021737624d58f5e863ca5d6ddf59a2ec6b14ea944bbfb282109`;重新打包的 Python checker 资源哈希与当前 source 在桌面报告单列。 + +桌面两轮使用干净 `51c8363e` 源归档和 T1 环境;最后的 `59c379a6` 仅修改不被这两轮 T1 加载的 Qwen 包显式导出与来源测试,已单独验证字节码等价、实际刷新安装后的 GSV/Qwen 导入和全仓 lint,没有冒称桌面报告的 commit 已自动变成最终提交。r2 同时保留实际桌面受测归档及最终源归档,哈希在清单中。 + +外审 r2 包更新为当前源归档、两个仓库 bundle、三份可单独提取的修复 patch(两份依赖、一份 Pi)、当前结果及原失败历史;原批次 native 代理操作回执和 Docker 证据作为历史保留,没有冒充本轮重新运行。全部参考 wheel 仍未独立审核。主分支保护、公开 pin/目录/产物、远端 CI、D0 公开链接、D3 作者资格等 [S3 门槛](store-v1.md) 没有因此关闭。 + + +## 2026-10-22:独立依赖 PR 交接 + +协作会话已创建 [草稿 PR #252](https://github.com/Code-Amadeus/Amadeus/pull/252),目标 `main`,分支 `codex/headless-inference-security`,核查时 head 为 `80be1ebfc1dc03cdd466f7650baedbe6774de43a`,状态 OPEN / draft。它承接本实验分支的 `0c8150c9` 与 `59c379a6`,不包含词典或商店功能,不重复创建依赖 PR。 + +本会话只读核查 GitHub PR 元数据及对应本地 Git 对象:Qwen vendor 树、来源说明、`tools/my_utils.py`、headless 依赖测试与实验分支补丁完全相同;Pillow 13.0.0、certifi 2026.10.19、Qwen-ASR 0.0.6 目录 source 和移除 Host Gradio 依赖的声明一致。协议 provenance 的实验目录上下文未带入该 main-based PR。 + +核查快照中 dependency-review 已通过,其余列出的 CI 作业仍在运行;不宣称该 PR 全绿,也不将依赖 PR 的 CI 当作商店实验分支的 CI。协作会话报告的 198 项独立回归等详见 PR;本会话没有把这些结果重复记作亲自重跑。主线未合并,资源未发布。 + +r2 外审 ZIP 保持原字节与 SHA-256;新增 `external-review-2026-10-22-pr252-addendum.zip` 保存本次交接快照和更新记录,应与 r2 一同提供给外审。这是文档补充,没有新的代码或测试结论。 diff --git a/docs/verification/extensions/community-preview-batch8-2026-10-22.md b/docs/verification/extensions/community-preview-batch8-2026-10-22.md new file mode 100644 index 00000000..ce8c7f08 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch8-2026-10-22.md @@ -0,0 +1,83 @@ +# 社区插件施工记录:第八批完整本地商店闭环 + +日期:2026-10-22(Pacific/Auckland)。实验分支 codex/extension-community-preview。 +本轮按“完整功能原型”验收,不要求实际公开发布。未推送、未合并主线、未改变仓库保护或公开目录。 + +## 完成范围与实现归属 + +原缺口属于验收集成:维护端生成/校验工具与客户端真实安装旅程分别通过,但客户端之前消费的是手工构造的 fixture index。新增本地发布演练把它们接成同一条链,没有修改生产下载、安装、安全状态、权限或运行契约。 + +- tools/probes/extension_store_demo.py:独立 Git 克隆、真实参考 wheel 快照、仅 loopback 的 HTTPS 服务、正式目录 CLI 调用及命令/发布回执;--execute 明确开启执行。 +- tools/probes/extension_store_roundtrip.py:实际 renderer/preload/IPC、手动更新、安装后原生 MCP 调用、进程重启与损坏恢复。 +- electron/tests/fixtures/extensionPublication.cjs:仅在受控演示进程的私有 inspector 注入,将固定官方索引请求转向 loopback;不修改响应字节。wheel URL 本身就是本地 HTTPS,镜像按 /sha256/filename 提供相同字节。生产包没有新增测试 URL/信任开关。 +- 复用原 TLS 证书生成与 model-less 桌面启动器;新模式与旧 fixture、失败矩阵、人工确认模式互斥。新增三项启动前参数边界测试。 +- 新入口与文档加入既有源码归档必需清单/白名单。固定 checker、协议/SDK pin 和依赖锁不变。 + +维护端每阶段先提交合成收录/审核事实,使用前一发布版本的 check_catalog_ci.py 验证输入,再通过本地 demo-approved ref 模拟输入合入。正式 build_catalog.py 读取已提交输入,用 --check 重复核对同字节输出;check_catalog.py 验证转移,独立 publication commit 再通过 protected-base CI 重建检查。只有检查成功的原始 index.json 才被 HTTPS 服务提供。没有在客户端进程中拼接或修改目录 entries/security。 + +模拟身份明确叫 demo-author/demo-reviewer,证据 URL 为 loopback demo-only。它们验证流程和数据契约,不构成真实人工审核、GitHub 身份认证或分支保护证明。 + +## 六次发布与真实客户端结果 + +| 序号 | 操作 | 验收结果 | +| --- | --- | --- | +| 1 | Notes MCP、Theme 0.1.0 首次收录 | 实际浏览、下载、安装、启用;主题应用;安装后的 Notes 完成 save/read/list | +| 2 | 两包 0.2.0 | 已安装视图手动更新按钮,真实镜像 HTTPS,摘要一致;启用/Pi 绑定保留;新应用进程继续使用 | +| 3 | Notes 0.2.0 版权撤回 | 新安装被拒;不产生安全封禁;已安装 Notes 仍能读写 | +| 4 | 两份 0.2.0 exact artifact 安全封禁 | 安装/激活被拒,主题撤下;离线本地 wheel 入口也拒绝 | +| 5 | 显式高序号解封 | 两份安全事实保留并改为 allowed;主题重新可装;Notes 仍因版权撤回拒绝分发 | +| 6 | 单独恢复 Notes 分发 | Notes 重新安装/使用;重放真实序号 4 被拒;序号 6 与解封事实离线恢复后仍保留 | + +封禁后删除目录展示缓存,停止应用,将 settings.json 填充 NUL,再离线启动,新进程从 .bak 恢复已知封禁。随后停止应用、同时损坏两份设置,新进程保持 safety unavailable;普通保存/刷新不能清除,必须显式 recover 才从同一正式生成目录恢复安全事实,并且不捏造丢失的安装清单。 + +解封和恢复分发后再次删除缓存、损坏主设置、离线重启,验证 allowed 决定和最高发布序号也能恢复,主题继续应用,Notes 仍能读写。总计四次完整应用重启,五个独立应用进程。故障是停机后注入 NUL,不是物理断电或所有文件系统崩溃时序证明。 + +另外,正式发布 CI 拒绝手改生成目录,正式转移校验拒绝删除已有安全事实。两份拒绝结果未替换服务端当前目录。 + +## 固定版本与结果 + +工具代码提交:c44799eaa12c983245c1551cfd6f901007997b05。 +协议工具:153153598a9f94e3b44a477e99c3017f978d13e5;固定 Windows/CPython 3.12.10 checker:1840c8e4f646992ddaebea3139c8895f29743a8f。 +目录副本 pin 仍为 58d3892d55c6a7ec6f8626153f503291bb10ab9a。 + +| 检查 | 结果 | 证据 | +| --- | --- | --- | +| 源码版实际桌面 | 15 桌面 + 25 闭环检查通过;4 重启、5 Notes 合约、6 发布、10 次目录摘要匹配 | build/pub-src/ee8238356951/electron_model_less_20261021T115449Z_c14da0/ | +| 实际 Windows 打包版,干净 c44799e | 同样全部通过;console_errors/page_errors 均为空,正常关闭桌面和后端 | build/pub-exe/e01ed5ec853e/electron_model_less_20261021T120247Z_24c931/ | +| 桌面隔离/打包/目录回归 | 30 通过,无跳过 | build/publication-tests.log | +| 源码发行回归 | 15 通过,无跳过 | build/publication-release-tests.log | +| 原 fixture 准备入口 | 复用 TLS 后仍通过 | build/publication-legacy-prepare.log | +| 新增/受影响文件 Ruff、Node syntax、Git whitespace | 通过 | build/publication-lint.log 及本轮命令记录 | + +源码轮约 6 分钟,打包轮约 4 分 34 秒;这是本机观测值,不是性能承诺。 + +源码探索轮在 7a6389cd 加未提交实现上运行,不能称为干净 c44799e 验收。提交前另将三处安装清单断言补强为明确包含两个插件,避免空列表使 all() 空真,并完成文档白名单/行尾收尾;实际 Windows 包轮在干净 c44799e 上完整执行这些最终断言。后续文档提交不是重新运行的代码版本。 + +本轮没有修改 production Electron 源码或 Python Host 运行代码。实际 exe SHA-256 为 e5528a9c00a5f1d8f592fd95b8f21c051ad9276d16ab7f42ca8fb2002081fafe,asar 为 cce87e3fbdd2d021737624d58f5e863ca5d6ddf59a2ec6b14ea944bbfb282109,与前批相同;本轮实际重新启动并验收该打包程序,使用当前显式指定的 Python 后端环境。 + +每轮六份生成目录通过维护端与客户端贯通,十次成功读取逐字节摘要相符。每轮五次 Notes 合约检查是安装后真实 native MCP client 的 save/read/list,不是模型驱动 Agent turn。没有复制个人配置或调用付费模型。 + +## 初次失败与修正 + +保留了四次未完成的桌面探索报告: +1. 深层临时克隆超过 Git for Windows 默认路径限制:仅对演练克隆设置 core.longpaths=true,不改全局 Git 配置。 +2. 首次更新定位仍使用首次安装按钮名称:修正演练对已有版本的操作入口。 +3. 直接 IPC 刷新没有更新 renderer 的局部目录状态:补齐用户实际“检查更新与安全状态”操作,再点击已安装列表更新按钮。 +4. 镜像断言误写成 /mirror/filename,实际契约为 /mirror/sha256/filename:按现有契约校正服务端精确路径和断言;真实更新已经成功,未修改产品镜像规则。 + +首次归档回归 14 通过、1 失败,因为新文档仅加 required_files、未加 include_files;已加入白名单,原断言不变,15 项随后全部通过。原失败日志保留。新文件的 EOF 空行也已清理。没有新增测试重试、skip、超时放宽、审计忽略或自动回滚。 + +## 复现与交付 + +完整命令及各阶段解释见 [演示入口](../../extension_store_demo.md)。既可使用当前源码构建,也可加 --packaged-executable 指向实际 Windows exe;每次建立全新演示目录并在结束后关闭应用与 HTTPS 服务。可从外审包提供的四类参考 wheel 中直接选用本轮相同的 Notes/Theme 四份字节,也可重新跑作者旅程。 + +r3 外审包以当前源码和两个实验仓 Git bundle 为入口;另包含两轮六份原始目录、模拟输入 Git 历史 bundle、生成/校验命令与退出码、wheel 内容清单和差异、HTTP 请求摘要、MCP 合约 JSON、截图、脱敏结果及本轮失败历史。r2 的修复与更早证据作为历史保留,r1/r2 原 ZIP 不改写。排除 TLS 私钥、用户/演示设置、provider 文件、原始后台日志、venv/node_modules、临时工作树和应用二进制。 + +按本轮定义,完整本地功能原型已经贯通。独立真实产物审核、素材权利、公开 URL/pin、远端 Actions、真实仓库治理、干净 OS/独立人工资格和公开发布决定仍是另外的发布门槛;D0 公开链接与 D3 外部作者资格未因此关闭。公开 bootstrap 仍为空,未发布任何插件。 + + +## 独立依赖 CI 补充(2026-10-22) + +收到协作会话更新后,本会话通过 GitHub 只读复核 [草稿 PR #252](https://github.com/Code-Amadeus/Amadeus/pull/252):head 仍为 80be1ebfc1dc03cdd466f7650baedbe6774de43a,目标 main,OPEN / draft,18 项检查全部 COMPLETED / SUCCESS。没有创建重复 PR、改动该 PR 或合并主线。这个结果属于独立依赖变更,不替代商店实验分支远端 CI,也不扩大为 GPU 实机验收。 + +已交付 r3 ZIP 原字节保持不变,SHA-256 仍为 4d07af6de478a446b5d9fcf6e8eaf5f4fa1f357c030ec1cf8251826b1b4e20ba。另附 external-review-2026-10-22-r3-ci-addendum.zip,包含实际核查快照、当前记录和文档补丁;这是证据补充,没有新的商店实现或本地测试结论。 diff --git a/docs/verification/extensions/community-preview-batch9-2026-10-23.md b/docs/verification/extensions/community-preview-batch9-2026-10-23.md new file mode 100644 index 00000000..8b32e7c7 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch9-2026-10-23.md @@ -0,0 +1,50 @@ +# 插件投稿流程补充验收(2026-10-23) + +本轮范围由用户收敛为本地 Git 演练和草稿 PR 准备,不新建 GitHub 测试仓、不公开发布、不合并主线。随后用户选择保留运行中的 Amadeus,因此只执行新增维护端工具验收,不重跑桌面。 + +## 对外审的判断 + +外审引用的是 F: 主工作区和公开仓库 main;它关于“主线没有在线商店/公开目录”的观察成立,但不能据此判断实验分支只有本地安装。实验分支的 S0–S2、完整本地 Git→HTTPS→桌面链路已在第八批验证。真实缺口是作者投稿与维护者发布交接,不需要重写下载器或增加作者账号系统。 + +采纳:短投稿指南/模板、首次与更新/转移的授权依据、非作者最新实质修改审核、同一个 PR 的返修、发布负责人交接、候选与实际分发验收、同摘要换地址及安全反馈说明。实现不新增目录或 manifest 字段,不新增数据库或持久状态。 + +按用户要求减负: +- 作者只运行一个预检命令,发一个 PR;四项简短信息,复用 README、发布说明、测试日志。 +- 初次确认归属/权利,更新复用已有证据;只有控制或权利变化才补证据。有歧义时才用挑战证明。 +- 作者不填写虚构审核人,也不组织多个审核角色。维护者指定一个对接人,负责评审交接和发布;标签只是可选展示。 +- 返修留在原 PR;仅实质变化使相关检查/审批失效。文字修正不要求重新做整套运行资格。 +- 维护者准备第二个、可批量的索引发布 PR。复杂度由工具和维护者承担。 + +调整两条审计建议:不为展示缓存增加独立 TTL,因为现有安装器每次商店安装都会强制刷新官方索引;不要求每次安全发布重新下载全部旧包,否则原地址失效会阻断封禁。新上架、恢复收录、解封和换 URL 的产物做新下载校验,撤回/封禁或未变化产物的跳过范围写进回执。 + +## 实现与边界 + +协议仓新增 catalog/SUBMISSION.md、短 PR 模板、preflight_submission.py 和 verify_publication.py。 +预检复用固定 Host check-wheel 与同一 check_policy:报告格式/Host 接受、首版政策、待人工/运行审核;通用 Host 拒绝时不猜测“包格式通过”。Notes 通过目录政策,Fish Audio 可被 Host 接受但目录政策拒绝。工具不导入插件代码,也不会生成“已审核”结论。 + +分发验收读取指定、已检查的候选/官方索引原始字节,匹配后用新临时缓存验证新增可分发/换地址产物的摘要和大小。旧但合法的索引也不能冒充本次发布。重复使用现有 HTTPS、重定向、体积和摘要规则。 + +Host 演练在首次提交中故意写错 wheel 摘要,正式 CI 拒绝;修订同一份输入并重检后才推进本地模拟 approved ref。六个发布阶段均验证 candidate-index 与实际 index;它们仍为明确模拟的审核/批准,不能代表 GitHub 身份或独立人类审核。 + +运行入口增加 --publisher-only,不启动 Electron、不占用 17777。默认完整桌面演练仍保留;公开 bootstrap 和运行契约/pin 未变。 + +## 验证 + +- 协议目录回归与新工具:38 通过、无跳过(build/submission-catalog-tests.log)。 +- Host 桌面隔离、打包和目录单测:30 通过、无跳过(build/submission-host-tests.log)。 +- 独立维护端:build/submission-publisher/7b4570b34dc0/result.json,ok=true、desktop=not_run。 +- 六个阶段各完成候选与已分发索引验收,共 12 份成功回执;第一次与更新各新下载 2 个产物,解封和恢复收录各 1 个,撤回/封禁不依赖旧产物可取得。 +- 4 份作者预检均保持 not_reviewed、人工/运行审核 pending;首次错误摘要对应一个被拒 Git commit,修订后新 commit 才获模拟批准。手改生成结果和丢弃安全事实继续被正式工具拒绝。 +- 本轮 Host 实现提交:341df15f、6dcec45c;协议工具提交:29a1ec0、8fb39ba。SDK、Host 固定 checker 和客户端目录政策 pin 未改。 + +目录测试首次在未提交工具上运行时,原有可信生成器测试按规则拒绝(37 通过、1 失败);固定工具提交后 38 全部通过,没有放宽检查。新文件的 lint 风格问题已修正,未增加排除项。 + +桌面首轮因 17777 已被现有 Amadeus 占用,在启动前退出。按用户明确选择,不关闭现有进程、不改生产端口,改跑维护端。前批桌面 40 项检查、原生 MCP 和损坏恢复属于历史证据,本轮没有把它们记成重跑。新增完整桌面路径的两条断言尚未执行。 + +## PR 准备和仍需实际验证的部分 + +建议按仓库准备两份草稿:协议仓收录规范/工具/模板,Host 仓现有商店实现与演练。当前没有现存商店 PR,本轮保留本地 PR 正文与提交/差异信息,不推远端。 + +只读模拟发现 Host 实验分支与当前 main 5382b329 存在旧依赖来源和验收文件冲突:main 已合入 #252 的后续版本,不能把旧实验依赖重新推成新的升级。实际创建 Host PR 前必须在隔离集成分支采用 main 的已合入依赖修复,保留商店变化,重新跑集成检查。此处未修改 main,也未把冲突模拟当作已完成集成。 + +真实 GitHub 分支保护、非作者审批、Actions 凭据/公共 pin 可达性、公开地址可用性,以及独立作者仅凭文档完成投稿仍在发布前验证。用户已明确本轮不做远端测试仓;流程固定不等于这些环境条件已验证。无需首版加入评分、签名、自动升级、通用配置迁移或作者后台。 diff --git a/docs/verification/extensions/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md new file mode 100644 index 00000000..25d839cf --- /dev/null +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -0,0 +1,84 @@ +# 插件基础设施外审交接:2026-10-21 + +r3 保存完整本地维护端到客户端闭环;r2 的依赖/Pi 修复及历史失败继续作为历史保留。最新集成范围见第十二批,下面各批次的 PR 状态均为当时的历史快照。独立真实产物审核和公开 S3 门槛仍未关闭,**不是合并或发布批准**。 + +## 2026-10-23 复审整合与合并验收 + +[第十二批记录](community-preview-batch12-2026-10-23.md)是当前入口。用户已授权修复阻塞后合并;Extensions #4/#5 与 Host #255/#254 已合并,词典保持 rc2、N1 四色方案保留。目录 main 的 strict 必需检查已启用,并通过真实远端负向/正向资格 PR 验证。#256 已完成 UI、打包 schema 与词典资源整合;最终合并仍以新 head 的必需 CI 和本批完整源码/打包/目录闭环回执为准。不发布插件 wheel 或 Release,历史固定 checker 暂不收录 v1 图标包。 + +## 2026-10-23 外审修复 + +[第十一批记录](community-preview-batch11-2026-10-23.md)逐条核对 A–E 审计:修复锁定还原、目录 CI 受信环境、v1 发布地址、异步限时下载和空商店预览;校验器已用专用 Git 标签保留。#255/#4 的新 schema 由原 UI 会话独立更新;词典继续 rc2。查看实际 PR 新 head 的 CI,勿沿用第十批或旧包状态。bootstrap 工作流主线启用和跨 UI 分支组合认证仍是明确门槛。 + +## 2026-10-23 主线集成与远端草稿 + +[第十批记录](community-preview-batch10-2026-10-23.md)接续第九批未完成的主线同步:实验分支已合入 main 5382b329,完整复用 main 的 #252 依赖修复,保留目录来源记录与双方测试。当前 PR 审查应以最新 main 与实验 head 的差异为准;下方 r2/r3 提交范围和 ZIP 仅表示当时证据。 + +本轮 Host 184/7 skip、Pi 30、Electron 576 和 build、协议 132/1 skip、锁与最小清单一致性均通过。已记录 UI/UX #255 与图标协议 #4 的共享文件和固定 checker 尚不接受 icon 的兼容边界。按用户授权推两个草稿 PR,不合并或发布;没有重跑日常应用占用端口的完整桌面验收。 + +## 2026-10-23 投稿流程补充 + +[第九批记录](community-preview-batch9-2026-10-23.md)补充一个预检命令、一个作者 PR、同投稿返修、维护者发布交接与候选/实际分发验证。协议 38 项、Host 30 项通过,维护端六阶段通过。本轮按用户选择不重跑桌面;第八批完整桌面结果保留为历史证据。原 r3 和 CI 补充包不改写,新证据另附。草稿 PR 材料保留本地,Host 推送前还需与已合入新依赖的 main 做隔离集成。 + +## r3 完整桌面入口 + +先读 [第八批施工记录](community-preview-batch8-2026-10-22.md) 与 [可重复演示入口](../../extension_store_demo.md)。新代码为 c44799eaa12c983245c1551cfd6f901007997b05,新增验收/演示工具,没有改变生产下载、安装、安全或权限实现。 + +正式目录工具在隔离 Git 历史中完成六次输入与发布检查,生成结果通过本地 HTTPS 原字节进入实际客户端。源码与打包版各 15 项桌面 + 25 项闭环检查通过;每轮 4 次应用重启、5 次安装后的 Notes 原生 MCP 合约检查、10 次客户端目录摘要比对。打包版从干净 c44799e 完整执行最终断言;源码探索轮的 dirty 身份与其后的断言补强在施工记录中单列,没有改写原报告。 + +优先核对 r3 的 evidence/publication/source、packaged:每阶段 index.json 与 publication.json、正式 CLI 命令/退出码、HTTP 请求摘要、原生合约与截图,以及自包含 demo-history.bundle 中的输入/发布提交。模拟身份、模拟 approved ref 和临时证书不能被误认成实际人工审核、GitHub 权限或公开信任根。r3 也保留本轮四个桌面探索失败和一次归档白名单失败。没有放宽原有测试、添加重试/skip/忽略项。 + + +## 范围和入口 + +前批修复背景见 [第七批修复记录](community-preview-batch7-2026-10-21.md)、[第六批历史记录](community-preview-batch6-2026-10-21.md) 与 [发布门槛](store-v1.md),再看 [获认可的计划](../../extension_community_preview_and_store_plan_2026-10-21.md)。第一至第六批记录是历史证据,不应替代最新的结果/限制。 + +- Host 审查范围:`fd17e707d8793480003f929bc8c78a8d134e41b5..codex/extension-community-preview`。 +- Host 生产应用代码仍为第七批范围;新增闭环工具端点为 c44799eaa12c983245c1551cfd6f901007997b05。两轮实际受测身份见第八批记录和原始报告。bundle 的精确记录末端由 review-manifest.json 指明。 +- 协议仓范围:`bf1a848448a19ed20eca029e8cc3991563e42e95..153153598a9f94e3b44a477e99c3017f978d13e5`。 +- 固定目录 checker:Host `1840c8e4f646992ddaebea3139c8895f29743a8f`;Host 内目录副本 pin `58d3892d55c6a7ec6f8626153f503291bb10ab9a`。SDK/protocol pins 未变。 + +外审 ZIP 里的两个增量 Git bundle 需要上述公共基线提交。它们包含当前未推送提交以及固定历史 checker,供在已有仓库副本中 `git bundle verify`、`git fetch refs/heads/codex/extension-community-preview:refs/heads/review-extension-store` 后审查。不要覆盖自己的工作分支。无 Git 阅读可用源码 ZIP;固定 checker CI 的 `verify_host` 必须用真实、无修改的历史 checkout,不能用解压目录冒充。 + +## 独立依赖 PR(2026-10-22 历史快照) + +更新:#252 现已合入 main,第十批直接复用其最终实现;以下为旧包证据说明,initializer 等价性检查已由 main 的原始字节检查取代。 + +[草稿 PR #252](https://github.com/Code-Amadeus/Amadeus/pull/252) 已从 `main` 独立建立,head `80be1ebfc1dc03cdd466f7650baedbe6774de43a`,承接依赖补丁 `0c8150c9` + `59c379a6`。已核对 vendor/导入边界/来源测试相同及锁中的修复版本;它不包含商店或词典功能。较早快照中 CI 尚在运行;2026-10-22 再次只读核查同一 head,18 个检查均已 COMPLETED / SUCCESS。这是依赖 PR 的远端结果,不能据此关闭商店实验分支的远端 CI 门槛。 + +r2 包不改写,随附 `external-review-2026-10-22-pr252-addendum.zip` 提供本次 PR 快照与更新文档。独立 PR 的测试区分父审自行执行与协作提供的 CPU 模型证据,均没有被描述成 GPU 实机验收。 + +r3 ZIP 保持原字节与 SHA-256;新增 external-review-2026-10-22-r3-ci-addendum.zip 保存上述 GitHub 快照和更新记录,与 r3 一起交外审。本次只更新文档,不创建重复依赖 PR,也未合并或发布。 + +## 建议优先核查 + +1. **修复是否充分**:独立依赖提交 `0c8150c9`、显式导出收尾 `59c379a6` 与 Pi 夹具提交 `51c8363e`。复核标准库 MCP peer 是否保持原工具/权限/UTF-8/资源/关闭语义,以及旧时限和断言确实未放宽;检查真实 Notes/Pi 旅程仍执行。复核 Qwen-ASR 原始 sdist 哈希、推理/模型/CLI 实现字节一致、三个 initializer 可逆且字节码等价的重导出、demo 依赖 metadata patch、Pillow/certifi 锁与真实 223 包 local-cpu 导入资格。无 resolver override、Gradio 降级或新增审计忽略项。r2 的 `evidence/history/batch6/` 保留原失败,当前结果在 `evidence/remediation/`。 +2. **安装权威**:`electron/src/main/extensions.ts`、`extensionCatalog.ts`、`extensionDownloads.ts`。renderer 只给 ID/版本,主进程从唯一官方目录选 URL/摘要;镜像只换产物地址;产物私有快照、确认前后/提交前复查;本地和商店入口按 wheel 本身依赖选择安装方式。 +3. **安全状态**:`extensionSecurity.ts`、`desktopSettings.ts` 和相应测试。封禁与最大序号不依赖展示缓存;备份优先原子写入、NUL/双损坏后 fail closed、普通保存/刷新不解封、显式恢复不捏造安装记录;运行中进程包警告与真实启动回执。 +4. **公开目录与未来能力**:协议 `catalog/REVIEW_CHECKLIST.md`、`policy.json`、`tools/catalog.py` / `check_catalog_ci.py`。审核针对 exact-wheel;v1 收录限制与运行契约分离;未知未来能力逐包禁用、不可丢弃已理解的安全信息;发布序号只增不减;Windows + 固定 Python/checker/最小锁;受保护 base 工具验证提案。 +5. **发行/验收工具**:Host `electron/package.json` 的 checker 资源闭包、`tools/smoke_electron_model_less.py`、`tools/probes/extension_release_shell.py` / `extension_release_failures.py` / `extension_native_confirmation.py`、test-only fixture 和 CI。生产包没有测试 URL 或确认绕过开关;manifest 身份检查不应被误称签名或完整机器证明。 + +## 前批可复核证据(r2) + +- 当前修复:Host 197 pass / 7 原有 skip;Pi 三个重点用例五轮独立批次 15/15;Pillow/相关路径 204 pass,另在真实 CPU 模型环境补验 61 pass;core 与 local-cpu 包一致性及 10 组 profile/platform 预演通过。 +- 四类真实参考 wheel 作者旅程本轮 12 阶段通过。未改 Electron/协议实现,前批 Electron 576 项、Windows 固定 catalog 29 项、Docker 113 项(11 skip)作为明确历史证据保留。 +- 源码和打包 shell 各 15 桌面 + 14 插件 + 18 故障检查通过;新建 profile、独立 Python/Node 依赖、实际后台/应用重启;双 settings NUL 故障由显式操作恢复。 +- 第六批历史 native dialog:代理观察后点 Cancel / Install Confirm / Enable Confirm,4 项结果通过。不是自动 response stub,也不是独立人工验收。操作回执单列。 +- 全部测试首次失败、未保留的 10 秒等待方案与夹具 UTF-8 修正过程均保留。`host-isolated-tests.log` 唯一失败来自在无 Git 源码 ZIP 中运行必须 `git ls-files` 的开发测试,不能被归类为产品启动失败;其余 194 通过。原工作树的两次 Pi 失败属于第六批历史;第七批同组回归已通过,且没有更改生产 3 秒等待。 +- 第六批历史 Linux 容器断网构建 SDK 摘要与 Windows/现有 SDK pin 一致。Dockerfile 使用已记录镜像 digest 和协议哈希依赖清单;无特权、无个人目录/凭据、无 Docker socket 挂载。 + +## 复现 + +在独立目录按 README 安装 Python/Node 依赖,执行 `npm --prefix electron test`、插件相关 Python 测试,以及 `python -X utf8 tools/probes/extension_author_journey.py`。这会生成四类两版本 wheel。随后按 [客户端资格指南](../../extension_catalog.md#source-and-packaged-desktop-qualification) 生成带 `--failure-fixture` 的目录,分别跑源码/实际打包 exe 的 `--extension-failures`;原生确认使用另外一轮 `--native-confirmations`,记录真正操作员。 + +`SOURCE_MANIFEST.json` 的源码完整性检查会拒绝被改写的输入。若打包钩子改写 wallpaper lock,请记录差异并只从对应源码 ZIP 恢复该输入。不要在源码 ZIP 里运行依赖 Git 索引的源码发布工具测试;在 bundle 的真实 Git checkout 上运行。 + +Docker 只补协议/SDK 隔离:把 `protocol-source.zip` 解压后将包内 `docker/Dockerfile` 放到该源码根,`docker build -t amadeus-extension-review .`;`docker run --rm --network none --cap-drop ALL --security-opt no-new-privileges amadeus-extension-review`。SDK 构建另挂载一个专用空输出目录,运行 `python tools/build_sdk.py --output-dir /evidence`。Windows catalog 资格按协议 workflow 使用固定历史 Host checkout,Linux 不替代它。 + +## 包内容与禁止误读 + +当前包名带 -r3,旧 r1/r2 ZIP 原样保留。r3 的 evidence/history/r2 保留前批结果,evidence/history/batch8 保留本轮探索失败。三份 patch(两份依赖、一份 Pi)可独立提取;依赖补丁交另一个会话独立审查并保持与词典 PR 分离。 + +`review-manifest.json` 和 `SHA256SUMS.txt` 标识精确提交、源归档及每份交付文件。应用二进制不放入轻量审查 ZIP,实际 exe/asar 摘要保留在结果和施工记录中。参考 wheel 是本轮测试字节,均未被独立审核;故意启动失败的 0.3.0 不混入参考目录。 + +目录公开发布仍要求 independent wheel review、素材授权、可达不可变 URL/pins、远程 Actions、main 保护、评审/2FA/发布权限及维护者决定。D0 的跨仓首页链接、D3 外部作者资格也未关闭。这里没有新建签名、自动更新、回滚承诺或新 Host 权限。 diff --git a/docs/verification/extensions/store-v1.md b/docs/verification/extensions/store-v1.md new file mode 100644 index 00000000..1c4f6f0e --- /dev/null +++ b/docs/verification/extensions/store-v1.md @@ -0,0 +1,89 @@ +# Plugin store technical preview: release checklist + +Status: **complete local functional-prototype roundtrip**, with public S3 gates still open. +**Not released and not approved for publication**. +The experimental branch implements D1/D2 and S0–S2. D0's cross-repository author link, +D3 external-author qualification and the remaining S3 gates below are still open. +No public index, release, repository permission or main branch was changed. + +## Locally available evidence + +- [Complete publisher-to-desktop roundtrip](community-preview-batch8-2026-10-22.md): + six real CLI-generated/rechecked publications consumed byte-for-byte over loopback + HTTPS by source and packaged Windows desktops; real Notes/theme install/update, + copyright withdrawal, durable block/unblock, separate restoration, old-snapshot + rejection and offline single/double NUL recovery. Each run has 15 desktop plus + 25 roundtrip checks, four fresh-process restarts and five native Notes contracts. + [Repeatable demonstration](../../extension_store_demo.md). Review/approval refs + are explicitly simulated; this closes local functional integration, not public + human approval, repository governance or release authorization. + +- [S1 transport and durable safety](community-preview-batch3-2026-10-21.md): + system proxy/certificates, mirrors, download failures, cancellation, wrong digest, + revocation, settings NUL corruption/backup recovery and fail-closed double damage. +- [S2 discovery and manual update](community-preview-batch4-2026-10-21.md): + 27 real-wheel UI checks, literal third-party text, unsupported future versions, + copyright withdrawal and lifecycle/data retention. These use fixture authority. +- [S3 construction record](community-preview-batch5-2026-10-21.md): packaged checker + closure, actual shipping-shell journeys, fixed old readers and source archive checks. +- [Pi/dependency remediation](community-preview-batch7-2026-10-21.md): original + Pi assertions/deadlines retained, headless Qwen packaging, patched image/TLS + dependencies, full local-model import checks and updated review package. +- [Premerge review and isolated qualification](community-preview-batch6-2026-10-21.md): + fresh Python/Node dependencies, source ZIP, Windows package, Docker protocol/SDK, + shipping failure recovery and separately labelled native UI operator evidence. +- Four reference families (Tone TTS, Notes MCP, Handoff Skill, desktop theme), each + at 0.1.0 and 0.2.0, have static checker/policy evidence and full wheel-content + inventories in the protocol experiment's `catalog/review-candidates/`. + All eight are **not reviewed**. The 0.1.0 to 0.2.0 diff compares candidates, + not previously approved versions. No publisher or reviewer identity is invented. + +The shipping-shell journey uses normal backend authentication and production +renderer/preload/IPC. It exercises resource install, Appearance selection, mirror +update, offline management, process install, real Runner calls and Notes save/read/list through the native MCP client. +The Pi binding is selected in the UI; no model-driven Agent turn is claimed. The desktop +package remains a frontend with an explicitly supplied Python backend/runtime; +this does not claim a self-contained installer or a clean-machine certification. +A synthetic pre-catalog settings file demonstrates additive settings compatibility, +not restoration of arbitrary old user credentials or profiles. + +Dependency maintenance is now tracked separately in [draft PR #252](https://github.com/Code-Amadeus/Amadeus/pull/252), checked at `80be1ebf` on 2026-10-22. A fresh read-only check on 2026-10-22 confirmed all 18 checks COMPLETED / SUCCESS at the same head. It remains OPEN / draft, contains no store features, and does not close the experimental store branch CI or publication gates. + +## Required before a public technical preview + +| Gate | Current state | Concrete completion evidence | +| --- | --- | --- | +| Host regression and dependency audit | Batch 6 blockers locally remediated; remote CI pending | Batch 7: 197 pass / 7 existing conditional skips; original Pi assertions and 3s/7s startup bounds retained; five independent focused batches all pass. certifi/Pillow findings cleared under the unchanged CI audit policy. Core and local-cpu dependency checks pass; the existing pytest exceptions and separate model/GPU qualification limits remain explicit. | +| Independent artifact approval | Pending | Non-author maintainer reviews each exact wheel against curated-v1, confirms ownership and asset rights, records digest and evidence in an approved input PR. At least one useful process package and one resource package. Tone is only a synthetic PCM reference; Notes MCP is a candidate useful process package. | +| Public immutable artifacts | Pending | Approved versioned wheel URLs with matching SHA-256 and retained review inventory; never replace bytes at an existing ID/version. | +| Public checker/generator pins | Pending | Reviewed commits reachable from the intended public repositories; fixed Windows/CPython 3.12.10 catalog CI passes using the recorded Host checker and minimal lock. | +| Main protection | Failed configuration gate on 2026-10-21 | Branch protection or active ruleset requires non-author approval and necessary checks; direct and force pushes are restricted. GitHub returned “Branch not protected” and no applicable branch rules. | +| Maintainer two-factor authentication | Unverified | Organization requires 2FA or maintainers provide appropriate administrative verification. The API reports organization enforcement false; that does not prove individual maintainers lack 2FA. | +| Workflow privilege | Partially verified | Repository default token permission is read, Actions cannot approve PRs. Recheck final workflows and environment/release access before publication; submitted code gets no publishing credentials. | +| Clean source and packaged Windows journeys | Local isolated qualification available; clean-machine/independent human acceptance pending | Fresh supported Windows environment, documented supplied Python dependencies, no old profile/install/cache; useful process + resource package through full lifecycle, manual native confirmations separately recorded. Local automated shell evidence alone is insufficient. | +| Full release failure matrix | Local final-build fixture matrix recorded in batch 6; reviewed public catalog still pending | Re-run cancellation, digest mismatch, withdrawal, ban, double corruption, startup failure, data retention/delete on final fixed release build and reviewed catalog. Record screenshots, traces, skips and hashes. | +| Remote CI and release resources | Local checks only | Required remote Actions green on final target commit; archive/desktop hashes, SDK/catalog pins, exact catalog revision and plugin identities recorded. | +| First catalog publication/withdrawal | Local full simulated roundtrip passed; reviewed public publication pending | Reviewed input PR, separate publication PR with sequence +1, exact reproducible generation; withdrawal and security decision PR exercise, offline known bans retained. Content rollback must still increase sequence. | +| D0 public author path | Pending Host integration | After reviewed Host integration, protocol README points to the immutable integrated author guide. Do not point a public completion claim at a local-only commit. | +| Release decision | Pending | Maintainer explicitly authorizes publication after evaluating the above evidence. Passing tests never publishes automatically. | + +Maintainer reference packages may form the first catalog after independent review. +D3 can remain separate, but the public description must then say **external-author +validation is not complete**. Two external authors' consented lifecycle evidence is +required to close community qualification; it cannot be substituted by these fixtures. + +## Withdrawal operation + +Use the protocol repository's `catalog/README.md` publication procedure and +`catalog/REVIEW_CHECKLIST.md`. Ordinary retirement and copyright claims update +`withdrawal` with the appropriate reason. A security block adds/updates the durable +exact ID/version/SHA-256 decision with the next sequence; retain all prior known +identities. Unblocking is an explicit higher-sequence decision. Regenerate from +approved input with a reviewed generator, compare against protected main, and merge +only after review and CI. No workflow should push a result directly to main. + +Clients learn new decisions only when users browse or explicitly check security. +Offline clients retain already known bans; never checking means no new remote +protection. A copyright withdrawal stops distribution without becoming a security +ban. Existing local management remains available; no automatic rollback or data +restoration is promised. diff --git a/electron/package-lock.json b/electron/package-lock.json index cc84fdad..b7017d6a 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -22,7 +22,7 @@ "electron-builder": "^26.15.3", "tailwindcss": "^4.0.0", "typescript": "^5.6.0", - "vite": "^8.2.2" + "vite": "^8.2.4" } }, "node_modules/@electron-internal/extract-zip": { @@ -5998,9 +5998,9 @@ } }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.4.tgz", + "integrity": "sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/electron/package.json b/electron/package.json index c9641db2..26ebac29 100644 --- a/electron/package.json +++ b/electron/package.json @@ -22,6 +22,31 @@ "files": ["dist/main/**/*", "dist/shared/**/*", "dist/preload/**/*", "dist/renderer/**/*", "package.json"], "extraResources": [ { "from": "../vendor/extension-sdk", "to": "extension-sdk", "filter": ["*.whl"] }, + { + "from": "..", + "to": "extension-host", + "filter": [ + "LICENSE", + "tools/requirements-extension-authoring.txt", + "pyproject.toml", + "tools/extension_package.py", + "server/__init__.py", + "server/extensions/__init__.py", + "server/extensions/package.py", + "server/extensions/manifest.py", + "server/extensions/profile.py", + "server/extensions/profile.json", + "server/extensions/sdk.py", + "server/extensions/catalog.py", + "server/extensions/catalog-source.json", + "server/extensions/protocol-source.json", + "server/extensions/*.schema.json", + "server/extensions/theme-tokens.v0.json", + "agent_host/__init__.py", + "agent_host/skills.py", + "vendor/extension-catalog/**/*" + ] + }, { "from": "../build/bundled-assets/assets/bundled/japanese-pronunciation", "to": "assets/bundled/japanese-pronunciation", "filter": ["**/*"] } ], "beforePack": "scripts/build-runtime-resources.cjs", @@ -48,6 +73,6 @@ "electron-builder": "^26.15.3", "tailwindcss": "^4.0.0", "typescript": "^5.6.0", - "vite": "^8.2.2" + "vite": "^8.2.4" } } diff --git a/electron/scripts/check-extension-theme.mjs b/electron/scripts/check-extension-theme.mjs new file mode 100644 index 00000000..e9fd4939 --- /dev/null +++ b/electron/scripts/check-extension-theme.mjs @@ -0,0 +1,45 @@ +// Author check: native resource owner and actual Chromium painting in a disposable profile. +import { app, BrowserWindow, session } from 'electron' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +const root = fileURLToPath(new URL('../../', import.meta.url)) +const base = process.env.AMADEUS_CONTRACT_THEME_ROOT +app.setPath('userData', path.join(base, 'profile')) +app.whenReady().then(async () => { + let window + try { + const record = JSON.parse(fs.readFileSync(process.env.AMADEUS_CONTRACT_THEME_RECORD, 'utf8')) + const { ExtensionThemeResources } = await import('../dist/main/extensionThemes.js') + const source = fs.readFileSync(path.join(root, 'electron/src/renderer/themeApplication.ts'), 'utf8') + const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText + const { createThemeApplication } = await import('data:text/javascript;base64,' + Buffer.from(compiled).toString('base64')) + session.defaultSession.setPermissionCheckHandler((_webContents, permission) => permission === 'local-fonts') + session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => callback(permission === 'local-fonts')) + window = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false } }) + await window.loadURL('data:text/html,

Theme contract

') + const ui = expression => window.webContents.executeJavaScript(expression, true) + const assets = path.join(root, 'electron/dist/renderer/assets') + const css = fs.readdirSync(assets).filter(name => name.endsWith('.css')).map(name => fs.readFileSync(path.join(assets, name), 'utf8')).join('\n') + await ui(`{ const style=document.createElement('style'); style.textContent=${JSON.stringify(css)}; document.head.appendChild(style); true }`) + const resources = new ExtensionThemeResources(() => ui("(async()=>[...new Set((await queryLocalFonts()).map(f=>f.family))])()")) + await resources.validate(record.manifest, record.package_path) + await ui(`window.application=(${createThemeApplication.toString()})(document,URL); true`) + let count = 0 + for (const definition of record.manifest.contributes.theme) { + const themes = await resources.validate(record.manifest, record.package_path, definition.id) + const theme = { ...themes[definition.id], id: record.manifest.id + '/' + definition.id, version: record.manifest.version, revision: 1 } + await ui(`application.apply(${JSON.stringify({ selected: theme.id, base: theme.base, reason: '', theme })}); true`) + const painted = await ui("({id:document.documentElement.dataset.themeExtension, background:getComputedStyle(document.documentElement).getPropertyValue('--bg').trim()})") + if (painted.id !== theme.id || !painted.background) throw new Error('Theme did not paint') + count++ + } + await ui("application.apply({selected:'classic',base:'classic',reason:''}); true") + if (await ui("Boolean(document.documentElement.dataset.themeExtension)")) throw new Error('Builtin restoration failed') + fs.writeFileSync(path.join(base, 'theme-result.json'), JSON.stringify({ themes: count, nativeDecode: true, painted: true, builtinRestored: true, nativeDialogs: false, scope: 'Native hidden-window rendering; no interactive dialogs' })) + window.destroy(); app.exit(0) + } catch { + window?.destroy(); app.exit(1) + } +}) diff --git a/electron/src/main/desktopSettings.ts b/electron/src/main/desktopSettings.ts index 0958894e..3a645d37 100644 --- a/electron/src/main/desktopSettings.ts +++ b/electron/src/main/desktopSettings.ts @@ -7,6 +7,8 @@ import { catalogApplication, catalogInputKeys, catalogLaunchDefaults, catalogOpt import type { ExtensionConfigValue, ExtensionManifest, InstalledExtensionSummary } from '../shared/extensions.js' import { matchesExtensionSelection, extensionRequiresRestart } from '../shared/extensionProfile.js' +import { acceptSecurity, parseSecurity, recoverSecurity, securityBlock, type ArtifactIdentity, type ExtensionSecurity } from './extensionSecurity.js' + export const EXTENSIONS_ENV = 'AMADEUS_EXTENSIONS' export type StoredExtension = { revision?: number @@ -27,6 +29,7 @@ export type StoredExtension = { export type ExtensionRecordValidator = (manifest: ExtensionManifest, values: Record) => boolean | 'unavailable' type StoredDesktopSettings = { + extensionSecurity?: ExtensionSecurity themeBase?: 'classic' | 'wallpaper-slice' version: 2 values: Record @@ -353,6 +356,7 @@ function storedSettingsFrom(text: string, validate: ExtensionRecordValidator, er const extensions = cleanStoredExtensions(parsed.extensions, validate) errors.push(...extensions.errors) return { + extensionSecurity: parseSecurity(parsed.extensionSecurity), version: 2, values: cleanRecord(parsed.values, VALUE_KEYS), encryptedSecrets: cleanRecord(parsed.encryptedSecrets, SECRET_KEYS), @@ -409,30 +413,47 @@ export class DesktopSettingsStore { if (result === false) this.incompatibleExtensionIds.add(manifest.id) return result } - let failure: unknown - try { - return storedSettingsFrom(fs.readFileSync(this.filePath, 'utf8'), validate, this.extensionErrors) - } catch (error) { - failure = error - } - const code = (failure as NodeJS.ErrnoException).code - // A missing file is a first launch or a deliberate reset, not damage. - if (code === 'ENOENT') return emptyStore() - try { - const saved = storedSettingsFrom(fs.readFileSync(this.backupPath, 'utf8'), validate, this.extensionErrors) - console.error(`[desktop-settings] ${this.filePath} is unreadable; using the copy from its last save`, failure) - return saved - } catch { - // No usable copy; decide below without guessing. + const copies = [this.filePath, this.backupPath].map(file => { + try { + const text = fs.readFileSync(file, 'utf8') + const raw = JSON.parse(text) + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new SyntaxError('Invalid settings object') + const security = parseSecurity(raw.extensionSecurity) + const malformed = security?.status === 'unavailable' && raw.extensionSecurity?.status !== 'unavailable' + return { file, text, raw, security: malformed ? undefined : security, corrupt: malformed, missing: false } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code && code !== 'ENOENT') throw error // never overwrite unreadable files + return { file, text: undefined, raw: undefined, security: undefined, corrupt: !code, missing: code === 'ENOENT' } + } + }) + const security = recoverSecurity(copies.map(c => c.security), copies.some(c => c.corrupt)) + // Keep legacy deliberate-reset behavior only when there are no durable security facts. + const source = copies[0].missing && !security ? undefined : copies.find(c => c.raw) + const stored = source ? storedSettingsFrom(source.text!, validate, this.extensionErrors) : emptyStore() + stored.extensionSecurity = security + if (security?.status === 'unavailable' && !copies.every(c => c.raw && isDeepStrictEqual(c.raw.extensionSecurity, security))) { + // Copy evidence before replacing anything. A failed write leaves either damaged + // original bytes or a durable fault sentinel, never an apparent first launch. + for (const copy of copies.filter(c => c.corrupt)) { + const evidence = `${copy.file}.corrupt-${Date.now()}` + fs.copyFileSync(copy.file, evidence, fs.constants.COPYFILE_EXCL) + } + this.write(stored) } - // An I/O error says nothing about the content, so it is not replaced. - if (code) throw failure - // Unparseable content without a usable copy: keep the bytes for inspection - // and start from defaults instead of silently overwriting them. - const setAside = `${this.filePath}.corrupt-${Date.now()}` - fs.renameSync(this.filePath, setAside) - console.error(`[desktop-settings] unreadable settings were moved to ${setAside}`, failure) - return emptyStore() + return stored + } + + extensionSecurityState(): ExtensionSecurity | undefined { return this.read().extensionSecurity } + extensionSecurityBlock(identity?: ArtifactIdentity): string { return securityBlock(this.read().extensionSecurity, identity) } + assertExtensionAllowed(identity?: ArtifactIdentity): void { + const reason = this.extensionSecurityBlock(identity) + if (reason) throw new Error(reason) + } + commitExtensionSecurity(next: ExtensionSecurity, recovery = false): void { + const stored = this.read() + stored.extensionSecurity = acceptSecurity(stored.extensionSecurity, next, recovery) + this.write(stored) } private write(value: StoredDesktopSettings): void { @@ -532,18 +553,24 @@ export class DesktopSettingsStore { .sort((left, right) => left.name.localeCompare(right.name)) .map(mcpConnectionSnapshot), mcpConnectionsLocked: environment[MCP_CONNECTIONS_ENV] !== undefined, - extensions: Object.values(stored.extensions).map(record => this.extensionSummary(record, pendingKeys.includes(EXTENSIONS_ENV), this.validatedExtensionIds.has(record.id))), - extensionErrors: [...this.extensionErrors, ...this.extensionWarnings, ...(environment[EXTENSIONS_ENV] !== undefined + extensionSecurity: stored.extensionSecurity ? { status: stored.extensionSecurity.status, ...(stored.extensionSecurity.status === 'ready' ? { sequence: stored.extensionSecurity.sequence, checkedAt: stored.extensionSecurity.checkedAt } : {}) } : { status: 'unseen' }, + extensions: Object.values(stored.extensions).map(record => this.extensionSummary(record, pendingKeys.includes(EXTENSIONS_ENV), this.validatedExtensionIds.has(record.id), stored.extensionSecurity)), + extensionErrors: [...this.extensionErrors, ...this.extensionWarnings, + ...([...this.appliedExtensions.values()].some(record => record.enabled && extensionRequiresRestart(record.manifest) && securityBlock(stored.extensionSecurity, record)) + ? ['A running extension is blocked by security policy or unreadable security state. Stop or restart the backend.'] : []), + ...(environment[EXTENSIONS_ENV] !== undefined ? ['Parent AMADEUS_EXTENSIONS was ignored; extension startup is owned by desktop installation records.'] : [])], } } - private extensionSummary(record: StoredExtension, restartRequired: boolean, validated: boolean): InstalledExtensionSummary { + private extensionSummary(record: StoredExtension, restartRequired: boolean, validated: boolean, security?: ExtensionSecurity): InstalledExtensionSummary { const applied = this.appliedExtensions.get(record.id) const application = this.extensionChangeNeedsRestart(record) ? 'backend_restart' : 'frontend' restartRequired = restartRequired && application === 'backend_restart' && (record.pendingRemoval || applied?.revision !== record.revision) + const appliedPolicyError = applied?.enabled ? securityBlock(security, applied) : '' + const policyError = securityBlock(security, record) || appliedPolicyError const revisions = { revision: record.revision ?? 1, appliedRevision: applied?.revision, appliedVersion: applied?.version, application, - frontendSuppressed: this.closedFrontends.has(record.id), applicationError: this.applicationErrors.get(record.id), + frontendSuppressed: Boolean(policyError) || this.closedFrontends.has(record.id), applicationError: policyError ? policyError + (appliedPolicyError && applied && extensionRequiresRestart(applied.manifest) ? ' Stop or restart the running backend.' : '') : this.applicationErrors.get(record.id), backendStillRunning: Boolean(applied?.enabled && extensionRequiresRestart(applied.manifest)) } as const if (!validated) return { id: record.id, version: record.version, digest: record.digest, manifest: null, ...revisions, @@ -572,7 +599,12 @@ export class DesktopSettingsStore { if (record.enabled && desired?.enabled && desired.revision === record.revision) this.closedFrontends.delete(record.id) this.applicationErrors.delete(record.id) } - extensionFrontendClosed(id: string): boolean { return this.closedFrontends.has(id) } + extensionFrontendClosed(id: string): boolean { + const stored = this.read() + const desired = stored.extensions[id] + const applied = this.appliedExtensions.get(id) + return this.closedFrontends.has(id) || Boolean(securityBlock(stored.extensionSecurity, desired) || (applied && securityBlock(stored.extensionSecurity, applied))) + } reportExtensionApplicationError(id: string, error: string): void { this.applicationErrors.set(id, error) } verifiedThemeBase(): 'classic' | 'wallpaper-slice' { return this.read().themeBase ?? 'wallpaper-slice' } rememberThemeBase(base: 'classic' | 'wallpaper-slice'): void { @@ -817,7 +849,7 @@ export class DesktopSettingsStore { const choices = VALUE_CHOICES[key] if (choices && !choices.has(value) && !extensionSelection) throw new Error(`Invalid value for ${key}: ${value}`) const schemes = desktopCatalogFields[key]?.schemes - if (schemes) { + if (schemes && !(value === '' && desktopCatalogFields[key]?.allow_empty)) { let protocol = '' try { protocol = new URL(value).protocol.slice(0, -1) } catch { /* rejected below */ } if (!schemes.includes(protocol)) throw new Error(`${key} must use ${schemes.join(' or ')} URL`) diff --git a/electron/src/main/extensionCatalog.ts b/electron/src/main/extensionCatalog.ts new file mode 100644 index 00000000..3286f28d --- /dev/null +++ b/electron/src/main/extensionCatalog.ts @@ -0,0 +1,132 @@ +import fs from 'node:fs' +import path from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import type { CatalogEntry, CatalogIndex, CatalogRequest, CatalogView, CatalogReply } from '../shared/extensionCatalog.js' +import { DesktopSettingsStore } from './desktopSettings.js' +import { ExtensionInstaller, ExtensionPackageHelper, ExtensionChangeCancelled } from './extensions.js' +import { artifactUrl, downloadExtension, httpsUrl, MAX_INDEX_BYTES, MAX_WHEEL_BYTES, stagingDirectory, type Download } from './extensionDownloads.js' +import { securityBlock, securitySnapshot } from './extensionSecurity.js' + +export const OFFICIAL_EXTENSION_INDEX = 'https://raw.githubusercontent.com/Code-Amadeus/Amadeus-Extensions/main/catalog/v1/index.json' +export function catalogRequest(raw: unknown): CatalogRequest { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid catalog request') + const r = raw as CatalogRequest + const selected = r.action === 'install' || r.action === 'homepage' || r.action === 'repository' + if (!['get', 'status', 'refresh', 'recover', 'cancel', 'install', 'homepage', 'repository'].includes(r.action) + || Object.keys(r).some(k => !(selected ? ['action', 'id', 'version'] : ['action']).includes(k))) throw new Error('Invalid catalog request fields') + if (selected && (typeof r.id !== 'string' || !r.id || r.id.length > 128 || typeof r.version !== 'string' || !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(r.version))) throw new Error('Invalid catalog selection') + return r +} +export class ExtensionCatalog { + private trusted?: CatalogIndex + private cached?: CatalogIndex + private error = '' + private refreshTask?: Promise + private refreshController?: AbortController + private active?: AbortController + private progress?: CatalogView['downloading'] + constructor(private readonly root: string, private readonly store: DesktopSettingsStore, private readonly helper: ExtensionPackageHelper, + private readonly installer: ExtensionInstaller, private readonly mirror: () => string, private readonly changed: () => void = () => {}, + private readonly download: Download = downloadExtension) {} + async dispatch(raw: unknown, environment: NodeJS.ProcessEnv, openExternal: (url: string) => Promise): Promise { + try { + const request = catalogRequest(raw) + if (request.action === 'status') return { ok: true, progress: this.status() } + if (request.action === 'install') return { ok: true, settings: await this.install(request.id, request.version), catalog: await this.view() } + if (request.action === 'homepage' || request.action === 'repository') { await openExternal(this.homepage(request.id, request.version, request.action)); return { ok: true } } + if (request.action === 'cancel') this.cancel() + const catalog = request.action === 'refresh' || request.action === 'recover' ? await this.refresh(request.action === 'recover') : await this.view() + return { ok: true, catalog, settings: this.store.snapshot(environment) } + } catch (error) { + return { ok: false, ...(error instanceof ExtensionChangeCancelled ? { cancelled: true } : {}), error: error instanceof Error ? error.message : 'Catalog operation failed', catalog: await this.view(), settings: this.store.snapshot(environment) } + } + } + private get cacheFile(): string { return path.join(this.root, 'index.json') } + async view(): Promise { + if (!this.trusted && !this.cached && fs.existsSync(this.cacheFile)) { + try { this.cached = (await this.helper.call('validate-catalog', [this.cacheFile])).catalog } catch { this.error ||= 'Catalog display cache is unavailable' } + } + const security = this.store.extensionSecurityState() + const index = this.trusted || this.cached + return { entries: (index?.entries || []).map(entry => ({ ...entry, blockedReason: securityBlock(security, { id: entry.id, version: entry.version, digest: entry.sha256 }) || undefined })), sequence: index?.sequence, cached: !this.trusted || Boolean(this.error), + checkedAt: security?.status === 'ready' && security.sequence === index?.sequence ? security.checkedAt : undefined, + installed: this.store.storedExtensionRecords().map(record => { + const match = index?.entries.find(e => e.id === record.id && e.version === record.version && e.sha256 === record.digest) + return { id: record.id, version: record.version, digest: record.digest, catalogState: !index ? 'unavailable' : match ? 'matched' : 'unlisted', + review: match?.review, withdrawal: match?.withdrawal, blocked: Boolean(securityBlock(security, record)) } + }), + error: this.error || this.store.extensionSecurityBlock() || undefined, downloading: this.progress } + } + refresh(recovery = false): Promise { + if (this.refreshTask) return this.refreshTask + this.refreshTask = this.fetchIndex(recovery).finally(() => { this.refreshTask = undefined }) + return this.refreshTask + } + private async fetchIndex(recovery: boolean): Promise { + const stage = stagingDirectory(path.join(this.root, 'staging')) + const controller = new AbortController() + this.refreshController = controller + try { + const file = path.join(stage.directory, 'index.json') + await this.download(OFFICIAL_EXTENSION_INDEX, file, MAX_INDEX_BYTES, controller.signal) + const index = (await this.helper.call('validate-catalog', [file])).catalog + if (!index) throw new Error('Catalog validator returned no catalog') + this.store.commitExtensionSecurity(securitySnapshot(index.sequence, index.security.map(d => ({ id: d.id, version: d.version, digest: d.sha256, blocked: d.status === 'blocked', sequence: d.sequence })), new Date().toISOString()), recovery) + // Publish only after the durable security transaction. Cache never authorizes installation. + this.trusted = index + this.error = '' + try { fs.copyFileSync(file, this.cacheFile + '.tmp'); fs.renameSync(this.cacheFile + '.tmp', this.cacheFile) } + catch { this.error = 'Security updated; catalog display cache could not be saved' } + return await this.view() + } catch (error) { + this.error = error instanceof Error ? error.message : 'Catalog refresh failed' + throw error + } finally { this.refreshController = undefined; stage.dispose(); this.changed() } + } + private entry(id: string, version: string): CatalogEntry { + const entry = this.trusted?.entries.find(e => e.id === id && e.version === version) + if (!entry) throw new Error('Select a version from a freshly checked official catalog') + return entry + } + private admitted(entry: CatalogEntry): void { + this.store.assertExtensionAllowed({ id: entry.id, version: entry.version, digest: entry.sha256 }) + if (entry.withdrawal) throw new Error('This catalog version has been withdrawn: ' + entry.withdrawal.reason) + if (entry.incompatible.length) throw new Error('Incompatible catalog package: ' + entry.incompatible.join('; ')) + } + status(): CatalogView['downloading'] { return this.progress ? { ...this.progress } : undefined } + cancel(): void { if (this.progress) this.progress.cancelRequested = true; this.active?.abort(); this.refreshController?.abort() } + async install(id: string, version: string): Promise> { + if (this.active) throw new Error('Another catalog installation is in progress') + const controller = new AbortController() + this.active = controller + this.progress = { id, version, received: 0, total: 0, stage: 'checking' } + let stage: ReturnType | undefined + const cancelledDownload = (error: unknown): never => { if (controller.signal.aborted) throw new ExtensionChangeCancelled(); throw error } + try { + await this.refresh().catch(cancelledDownload) + const selected = structuredClone(this.entry(id, version)) + const assertCurrent = () => { + if (controller.signal.aborted) throw new ExtensionChangeCancelled() + const current = this.entry(id, version) + if (!isDeepStrictEqual(selected, current)) throw new Error('Catalog selection changed; review the current version again') + this.admitted(current) + } + assertCurrent() + stage = stagingDirectory(path.join(this.root, 'staging')) + const filename = decodeURIComponent(new URL(selected.url).pathname.split('/').pop()!) + if (!filename.endsWith('.whl') || /[\\/:]/.test(filename)) throw new Error('Invalid catalog wheel filename') + const wheel = path.join(stage.directory, filename) + const url = artifactUrl(selected.url, selected.sha256, this.mirror()) + this.progress = { id, version, received: 0, total: selected.size, stage: 'downloading' } + await this.download(url, wheel, Math.min(MAX_WHEEL_BYTES, selected.size), controller.signal, received => { this.progress = { id, version, received, total: selected.size, stage: 'downloading', cancelRequested: controller.signal.aborted } }).catch(cancelledDownload) + if (fs.statSync(wheel).size !== selected.size) throw new Error('Downloaded wheel size differs from catalog') + assertCurrent() + return await this.installer.install(wheel, { identity: { id, version, digest: selected.sha256 }, assertCurrent, onStage: stage => { this.progress = { id, version, received: selected.size, total: selected.size, stage, cancelRequested: controller.signal.aborted } } }) + } finally { stage?.dispose(); this.progress = undefined; this.active = undefined } + } + homepage(id: string, version: string, field: 'homepage' | 'repository' = 'homepage'): string { + const value = (this.trusted || this.cached)?.entries.find(entry => entry.id === id && entry.version === version)?.display[field] + if (typeof value !== 'string') throw new Error('This package has no HTTPS homepage') + return httpsUrl(value) + } +} diff --git a/electron/src/main/extensionDownloads.ts b/electron/src/main/extensionDownloads.ts new file mode 100644 index 00000000..2b1e25a4 --- /dev/null +++ b/electron/src/main/extensionDownloads.ts @@ -0,0 +1,127 @@ +import { net, session } from 'electron' +import fs from 'node:fs' +import path from 'node:path' +import { Transform, pipeline } from 'node:stream' +import { assertExtensionPath } from './extensionPaths.js' + +export const MAX_WHEEL_BYTES = 256 * 1024 * 1024 +export const MAX_INDEX_BYTES = 4 * 1024 * 1024 +export function httpsUrl(raw: string): string { + if (typeof raw !== 'string' || raw.length > 2048 || /[\x00-\x20\x7f]/.test(raw)) throw new Error('Expected a bounded HTTPS URL without credentials') + const url = new URL(raw) + if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.hash) throw new Error('Expected HTTPS without credentials or fragment') + return url.href +} +export function artifactUrl(url: string, digest: string, mirror: string): string { + httpsUrl(url) + if (!mirror) return url + const base = new URL(httpsUrl(mirror)) + if (base.search) throw new Error('Artifact mirror must be an HTTPS base directory') + const name = decodeURIComponent(new URL(url).pathname.split('/').pop()!) + if (!/^[a-f0-9]{64}$/.test(digest) || !name.endsWith('.whl') || /[\\/:]/.test(name)) throw new Error('Invalid artifact identity') + return httpsUrl(base.href.replace(/\/$/, '') + '/' + digest + '/' + encodeURIComponent(name)) +} +export type Download = (url: string, destination: string, limit: number, signal: AbortSignal, progress?: (bytes: number) => void) => Promise +/** A separate in-memory Chromium session inherits system transport, not application cookies/auth. */ +export const downloadExtension: Download = (url, destination, limit, signal, progress) => new Promise((resolve, reject) => { + httpsUrl(url) + if (signal.aborted) { reject(new Error('Extension download cancelled')); return } + // Claim the file synchronously; never remove a pre-existing destination on failure. + const handle = fs.openSync(destination, 'wx', 0o600) + const output = fs.createWriteStream(destination, { fd: handle, autoClose: true, flush: true }) + let request: Electron.ClientRequest | undefined + let finished = false + let received = 0 + let redirects = 0 + let stalled: ReturnType + // At least 32 KiB/s is allowed for the full admitted size, plus connection time. + const total = setTimeout(() => finish(new Error('Extension download exceeded its total time limit')), Math.max(120_000, Math.ceil(limit / 32768) * 1000 + 30_000)) + const activity = () => { clearTimeout(stalled); stalled = setTimeout(() => finish(new Error('Extension download stalled')), 30_000) } + const cancel = () => finish(new Error('Extension download cancelled')) + const finish = (error?: Error) => { + if (finished) return + finished = true + clearTimeout(stalled); clearTimeout(total) + signal.removeEventListener('abort', cancel) + const settle = () => { + if (error) { + fs.promises.unlink(destination).catch(() => {}).finally(() => reject(error)) + } else resolve() + } + if (error) request?.abort() + if (output.closed) settle() + else { output.once('close', settle); output.destroy() } + } + output.on('error', () => finish(new Error('Extension download could not be saved'))) + signal.addEventListener('abort', cancel, { once: true }) + activity() + try { + request = net.request({ url, session: session.fromPartition('amadeus-extension-downloads', { cache: false }), + credentials: 'omit', redirect: 'manual', useSessionCookies: false, cache: 'no-store', bypassCustomProtocolHandlers: true }) + request.on('redirect', (_status, _method, location) => { + try { httpsUrl(location); if (++redirects > 5) throw new Error('Too many redirects'); request!.followRedirect() } + catch { finish(new Error('Extension download rejected an unsafe or excessive redirect')) } + }) + request.on('error', () => finish(new Error('Extension download failed; check network, proxy and certificate settings'))) + request.on('abort', () => finish(new Error('Extension download cancelled'))) + request.on('response', response => { + if (finished) return + if (response.statusCode !== 200) { finish(new Error('Extension download did not return a complete artifact')); return } + const header = (name: string): string => { const value = response.headers[name]; return Array.isArray(value) ? value.join(',') : value || '' } + // Chromium emits decoded bytes; compressed Content-Length measures wire bytes. + const encoding = header('content-encoding').toLowerCase() + const length = encoding && encoding !== 'identity' ? 0 : Number(header('content-length') || 0) + if (!Number.isFinite(length) || length < 0 || length > limit) { finish(new Error('Extension download exceeds size limit')); return } + activity() + response.on('error', () => finish(new Error('Extension download was interrupted'))) + response.on('aborted', cancel) + const bounded = new Transform({ + transform(chunk, _encoding, callback) { + received += chunk.length + if (received > limit) { callback(new Error('Extension download exceeds size limit')); return } + activity() + try { progress?.(received); callback(null, chunk) } + catch { callback(new Error('Extension download progress failed')) } + }, + flush(callback) { + callback(received === 0 || (length && received !== length) ? new Error('Incomplete extension download') : undefined) + }, + }) + // pipeline applies disk backpressure; success waits for asynchronous flush/close. + // Electron 44 implements Readable; its generated IncomingMessage type only lists EventEmitter. + pipeline(response as unknown as NodeJS.ReadableStream, bounded, output, error => finish(error ? new Error('Extension download was incomplete or could not be saved') : undefined)) + }) + request.end() + } catch { finish(new Error('Extension download could not start')) } +}) + +const activeStages = new Set() +const STAGE_MARKER = '.amadeus-extension-download' +const STAGE_OWNER = 'amadeus.download/v1' +/** The desktop single-instance lock owns this root; active requests share this module. */ +export function stagingDirectory(root: string): { directory: string; dispose: () => void } { + assertExtensionPath(path.dirname(root), root) + fs.mkdirSync(root, { recursive: true }) + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const stale = path.join(root, entry.name) + if (!/^download-[A-Za-z0-9]{6}$/.test(entry.name) || activeStages.has(stale) || !entry.isDirectory() || entry.isSymbolicLink()) continue + assertExtensionPath(root, stale) + const files = fs.readdirSync(stale) + // Downloads never create subdirectories. Retain unknown or damaged ownership. + if (!files.includes(STAGE_MARKER) || files.some(name => { + const stat = fs.lstatSync(path.join(stale, name)) + return !stat.isFile() || stat.isSymbolicLink() || !(name === STAGE_MARKER || name === 'index.json' || name.endsWith('.whl')) + })) continue + const marker = path.join(stale, STAGE_MARKER) + if (fs.statSync(marker).size !== Buffer.byteLength(STAGE_OWNER) || fs.readFileSync(marker, 'utf8') !== STAGE_OWNER) continue + fs.rmSync(stale, { recursive: true }) + } + const directory = fs.mkdtempSync(path.join(root, 'download-')) + fs.writeFileSync(path.join(directory, STAGE_MARKER), STAGE_OWNER, { flag: 'wx', mode: 0o600 }) + activeStages.add(directory) + return { directory, dispose: () => { + assertExtensionPath(root, directory) + fs.rmSync(directory, { recursive: true, force: true }) + activeStages.delete(directory) + } } +} diff --git a/electron/src/main/extensionSecurity.ts b/electron/src/main/extensionSecurity.ts new file mode 100644 index 00000000..8b5ddef6 --- /dev/null +++ b/electron/src/main/extensionSecurity.ts @@ -0,0 +1,80 @@ +import { createHash } from 'node:crypto' + +export type ArtifactIdentity = { id: string; version: string; digest: string } +export type SecurityDecision = ArtifactIdentity & { blocked: boolean; sequence: number } +export type ExtensionSecurity = { status: 'ready'; sequence: number; decisions: SecurityDecision[]; checkedAt: string; checksum: string } + | { status: 'unavailable'; floor: number | null } +export const securityKey = (item: ArtifactIdentity): string => JSON.stringify([item.id, item.version, item.digest]) +const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 +const ordered = (decisions: SecurityDecision[]) => [...decisions].sort((a, b) => securityKey(a) < securityKey(b) ? -1 : securityKey(a) > securityKey(b) ? 1 : 0) +function checksum(sequence: number, decisions: SecurityDecision[], checkedAt: string): string { + return createHash('sha256').update(JSON.stringify([sequence, decisions.map(d => [d.id, d.version, d.digest, d.blocked, d.sequence]), checkedAt])).digest('hex') +} +export function securitySnapshot(sequence: number, decisions: SecurityDecision[], checkedAt: string): ExtensionSecurity { + decisions = ordered(decisions) + return { status: 'ready', sequence, decisions, checkedAt, checksum: checksum(sequence, decisions, checkedAt) } +} +export function parseSecurity(value: unknown): ExtensionSecurity | undefined { + if (value === undefined) return undefined // valid pre-catalog settings + const bad: ExtensionSecurity = { status: 'unavailable', floor: null } + if (!value || typeof value !== 'object') return bad + const v = value as ExtensionSecurity + if (v.status === 'unavailable') return { status: 'unavailable', floor: integer(v.floor) ? v.floor : null } + if (v.status !== 'ready' || !integer(v.sequence) || !Array.isArray(v.decisions) || v.decisions.length > 16384 + || typeof v.checkedAt !== 'string' || !Number.isFinite(Date.parse(v.checkedAt))) return bad + const keys = new Set() + for (const d of v.decisions) { + if (!d || typeof d.id !== 'string' || !d.id || d.id.length > 128 || typeof d.version !== 'string' + || !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(d.version) + || typeof d.digest !== 'string' || !/^[a-f0-9]{64}$/.test(d.digest) || typeof d.blocked !== 'boolean' + || !integer(d.sequence) || d.sequence < 1 || d.sequence > v.sequence || keys.has(securityKey(d))) return bad + keys.add(securityKey(d)) + } + if (v.checksum !== checksum(v.sequence, v.decisions, v.checkedAt)) return bad + return structuredClone(v) +} +function sameDecisions(a: Extract, b: Extract): boolean { + return JSON.stringify(ordered(a.decisions).map(d => [d.id, d.version, d.digest, d.blocked, d.sequence])) + === JSON.stringify(ordered(b.decisions).map(d => [d.id, d.version, d.digest, d.blocked, d.sequence])) +} +/** Read both complete copies. A persisted fault cannot be cleared by an ordinary save. */ +export function recoverSecurity(copies: Array, corrupt: boolean): ExtensionSecurity | undefined { + const ready = copies.filter((s): s is Extract => s?.status === 'ready').sort((a, b) => b.sequence - a.sequence) + const faults = copies.filter((s): s is Extract => s?.status === 'unavailable') + const best = ready[0] + if (best && ready.some(s => s.sequence === best.sequence && !sameDecisions(s, best))) return { status: 'unavailable', floor: best.sequence } + // A malformed field is treated like damaged bytes: an intact copy can recover it. + // Persisted sentinels are distinguished by the read owner and supplied here as faults. + if (faults.length) { + const floor = faults.some(s => s.floor === null) ? null : Math.max(...faults.map(s => s.floor!)) + if (!best || floor === null || best.sequence <= floor) return { status: 'unavailable', floor } + } + if (best) return best + return corrupt ? { status: 'unavailable', floor: null } : undefined +} +export function acceptSecurity(previous: ExtensionSecurity | undefined, next: ExtensionSecurity, recovery: boolean): ExtensionSecurity { + if (next.status !== 'ready' || parseSecurity(next)?.status !== 'ready') throw new Error('Invalid extension security snapshot') + if (previous?.status === 'unavailable') { + if (!recovery) throw new Error('Extension security requires explicit official recovery') + if (previous.floor !== null && next.sequence < previous.floor) throw new Error('Catalog sequence rollback rejected') + } else if (previous) { + if (next.sequence < previous.sequence) throw new Error('Catalog sequence rollback rejected') + if (next.sequence === previous.sequence && !sameDecisions(previous, next)) throw new Error('Conflicting catalog security at the same sequence') + const incoming = new Map(next.decisions.map(d => [securityKey(d), d])) + const known = new Set(previous.decisions.map(securityKey)) + for (const old of previous.decisions) { + const current = incoming.get(securityKey(old)) + if (!current || current.sequence < old.sequence || (current.sequence === old.sequence && current.blocked !== old.blocked) + || (current.blocked !== old.blocked && current.sequence <= previous.sequence)) throw new Error('Known security decisions cannot disappear or regress') + } + for (const current of next.decisions) { + if (!known.has(securityKey(current)) && current.sequence <= previous.sequence) throw new Error('New security decision predates accepted catalog') + } + } + return next +} +export function securityBlock(state: ExtensionSecurity | undefined, identity?: ArtifactIdentity): string { + if (state?.status === 'unavailable') return 'Extension security state is damaged. Recover from the official catalog before installing or activating extensions.' + if (identity && state?.status === 'ready' && state.decisions.some(d => securityKey(d) === securityKey(identity) && d.blocked)) return 'This exact extension artifact is blocked for security reasons.' + return '' +} diff --git a/electron/src/main/extensions.ts b/electron/src/main/extensions.ts index d29b43c3..90258ec3 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -1,4 +1,6 @@ import fs from 'node:fs' +import { securityBlock, type ArtifactIdentity } from './extensionSecurity.js' +import type { CatalogIndex } from '../shared/extensionCatalog.js' import { assertExtensionPath } from './extensionPaths.js' export { assertExtensionPath } from './extensionPaths.js' import path from 'node:path' @@ -15,9 +17,9 @@ import { startupValue } from '../shared/startupSettings.js' import type { DesktopThemeState } from '../shared/extensionTheme.js' import { extensionNeedsAgent, extensionRequiresRestart } from '../shared/extensionProfile.js' -type HelperReply = { ok: boolean; error?: string; unavailable?: boolean; manifest?: ExtensionManifest; digest?: string; +type HelperReply = { catalog?: CatalogIndex; dependency_mode?: 'offline' | 'resolve'; ok: boolean; error?: string; unavailable?: boolean; manifest?: ExtensionManifest; digest?: string; package_root?: string; manifest_member?: string; distribution?: string; config?: Record; missing?: string[] } -type RunOptions = { cwd: string; env: NodeJS.ProcessEnv; input?: string; allowFailureJson?: boolean; timeoutMs?: number } +type RunOptions = { cwd: string; env: NodeJS.ProcessEnv; input?: string; allowFailureJson?: boolean; timeoutMs?: number; maxOutputBytes?: number } export type ExtensionCommandRunner = (command: string, args: string[], options: RunOptions) => Promise export class ExtensionPreparationShutdownError extends Error { @@ -55,7 +57,7 @@ export const runExtensionCommand: ExtensionCommandRunner = (command, args, optio const timeout = setTimeout(() => { timedOut = true; terminate() }, options.timeoutMs ?? 300_000) child.stdout?.on('data', chunk => { size += chunk.length - if (size > 1_048_576) { overflow = true; terminate(); return } + if (size > (options.maxOutputBytes ?? 1_048_576)) { overflow = true; terminate(); return } stdout += String(chunk) }) // Pip may include authenticated index/proxy URLs. Never forward its output. @@ -111,10 +113,10 @@ export class ExtensionPackageHelper { } } - async call(command: 'inspect-wheel' | 'prepare-data-wheel' | 'validate-config' | 'validate-installed', args: string[] = [], input?: unknown): Promise { + async call(command: 'validate-catalog' | 'inspect-catalog-wheel' | 'inspect-wheel' | 'prepare-data-wheel' | 'validate-config' | 'validate-installed', args: string[] = [], input?: unknown): Promise { const output = await this.run(this.python(), ['-I', this.script, command, ...args], { cwd: this.projectRoot, env: extensionInstallerEnvironment(this.environment), - allowFailureJson: true, + allowFailureJson: true, maxOutputBytes: command === 'validate-catalog' ? 8 * 1024 * 1024 : undefined, ...(input === undefined ? {} : { input: JSON.stringify(input) }), }) let reply: HelperReply @@ -238,8 +240,10 @@ export class ExtensionInstaller { return result } - install(wheel: string): Promise> { + install(wheel: string, delivery?: { identity: ArtifactIdentity; assertCurrent: () => void; onStage?: (stage: 'verifying' | 'confirming' | 'preparing') => void }): Promise> { return this.serialize(async () => { + this.store.assertExtensionAllowed() + delivery?.assertCurrent() if (!path.isAbsolute(wheel) || path.extname(wheel).toLowerCase() !== '.whl' || !fs.lstatSync(wheel).isFile()) { throw new Error('Select a local Python wheel (.whl)') } @@ -256,11 +260,21 @@ export class ExtensionInstaller { // inspected by the trusted helper even if the selected source changes. const copied = path.join(directory, path.basename(wheel)) fs.copyFileSync(wheel, copied, fs.constants.COPYFILE_EXCL) - const inspection = await this.helper.call('inspect-wheel', [copied]) + delivery?.onStage?.('verifying') + const inspection = await this.helper.call(delivery ? 'inspect-catalog-wheel' : 'inspect-wheel', [copied]) const manifest = inspection.manifest! if (!manifest || !inspection.digest || !inspection.package_root || !safeRelative(inspection.package_root)) { throw new Error('The wheel inspector returned incomplete installation metadata') } + const identity = { id: manifest.id, version: manifest.version, digest: inspection.digest } + const assertAllowed = () => { + this.store.assertExtensionAllowed(identity) + if (delivery && (identity.id !== delivery.identity.id || identity.version !== delivery.identity.version || identity.digest !== delivery.identity.digest)) throw new Error('Downloaded artifact identity or digest differs from the official catalog') + delivery?.assertCurrent() + } + assertAllowed() + if (!['offline', 'resolve'].includes(inspection.dependency_mode!)) throw new Error('Dependency classification is unavailable') + if (delivery && inspection.dependency_mode !== 'offline') throw new Error('Third-party dependencies require local installation') await this.themes.validate(manifest) let sdkWheel = '' if (manifest.runtime.kind === 'python') { @@ -272,10 +286,13 @@ export class ExtensionInstaller { if (this.store.extensionIsInstalled(manifest.id) && (!existing || existing.pendingRemoval || existing.version === manifest.version)) { throw new Error('This extension is already installed; select a different version to update, or finish its pending removal') } + delivery?.onStage?.('confirming') if (!await this.confirm({ operation: existing ? 'update' : 'install', manifest, previousVersion: existing?.version, previousManifest: existing?.manifest, digest: inspection.digest, sourceFile: path.basename(wheel) })) { throw new ExtensionChangeCancelled() } + assertAllowed() + delivery?.onStage?.('preparing') const env = extensionInstallerEnvironment(this.environment) const venv = path.join(directory, 'venv') let purelib: string @@ -302,10 +319,10 @@ export class ExtensionInstaller { || Object.keys(pipConfiguration).some(key => /\.(root|prefix|target|user)$/.test(key))) { throw new Error('Configured pip installation path overrides are unsupported; use index, proxy, and certificate transport settings') } - await this.run(privatePython, ['-I', '-m', 'pip', 'install', '--target', purelib, '--no-user', '--no-deps', '--only-binary=:all:', sdkWheel], { cwd: directory, env }) + await this.run(privatePython, ['-I', '-m', 'pip', 'install', '--target', purelib, '--no-user', '--no-index', '--no-deps', '--only-binary=:all:', sdkWheel], { cwd: directory, env }) const constraints = path.join(directory, 'constraints.txt') fs.writeFileSync(constraints, `amadeus-extension-sdk @ ${pathToFileURL(sdkWheel).href}\n`, { mode: 0o600 }) - await this.run(privatePython, ['-I', '-m', 'pip', 'install', '--target', purelib, '--no-user', '--only-binary=:all:', '--constraint', constraints, copied], { cwd: directory, env }) + await this.run(privatePython, ['-I', '-m', 'pip', 'install', '--target', purelib, '--no-user', '--only-binary=:all:', '--constraint', constraints, ...(inspection.dependency_mode === 'offline' ? ['--no-index', '--no-deps'] : []), copied], { cwd: directory, env }) } const packagePath = path.join(purelib, inspection.package_root) assertExtensionPath(directory, packagePath) @@ -331,6 +348,7 @@ export class ExtensionInstaller { deleteDataOnRemoval: false, values: checked.config || {}, encryptedSecrets: retainedSecrets, providers: existing?.providers || [] } if (existing && (extensionRequiresRestart(existing.manifest) || extensionRequiresRestart(manifest))) this.backendReferences.add(existing.installId) + assertAllowed() this.store.saveExtension(this.environment, record) committed = true await this.applyFrontendChanges() @@ -362,6 +380,7 @@ export class ExtensionInstaller { const providers = raw.providers === undefined ? record.providers : raw.providers if (!Array.isArray(providers) || providers.length > 32 || providers.some(id => typeof id !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(id))) throw new Error('Invalid extension Work Provider binding') const enabled = raw.enabled ?? record.enabled + if (enabled) this.store.assertExtensionAllowed(record) if (enabled && !record.enabled && !await this.confirm({ operation: 'enable', manifest: record.manifest, digest: record.digest })) { throw new ExtensionChangeCancelled() } @@ -384,6 +403,7 @@ export class ExtensionInstaller { // command arguments, environment variables, or plaintext temporary files. const validated = await this.helper.call('validate-config', [], { manifest: record.manifest, config: { ...values, ...secrets }, allow_incomplete: !enabled }) if (enabled) await this.themes.validate(record.manifest, this.packagePath(record)) + if (enabled) this.store.assertExtensionAllowed(record) this.store.saveExtension(this.environment, { ...record, values: validated.config || {}, enabled, providers: [...new Set(providers)] }, changedSecrets) await this.applyFrontendChanges() @@ -543,7 +563,7 @@ export class ExtensionInstaller { } catch { this.store.reportExtensionApplicationError(record.id, 'Extension cleanup is incomplete; ownership records were retained for retry.') } continue } - if (!valid.has(record.id)) continue + if (!valid.has(record.id) || this.store.extensionSecurityBlock(record)) continue const applied = this.store.appliedExtensionRecords().find(item => item.id === record.id) if (applied?.revision === record.revision) continue try { @@ -552,6 +572,7 @@ export class ExtensionInstaller { await this.helper.call('validate-installed', [packagePath], { manifest: record.manifest }) await this.themes.validate(record.manifest, packagePath) this.packageData(record.id) + this.store.assertExtensionAllowed(record) } this.store.acceptExtensionApplication(record) if (applied && applied.installId !== record.installId) released.add(applied.installId) @@ -577,11 +598,15 @@ export class ExtensionInstaller { if (selected === 'classic' || selected === 'wallpaper-slice') { this.store.rememberThemeBase(selected); return { selected, base: selected, reason: '' } } const fallback = (reason: string): DesktopThemeState => ({ selected, base: this.store.verifiedThemeBase(), reason }) const [packageId] = selected.split('/') + const policyState = this.store.extensionSecurityState() + if (securityBlock(policyState)) return fallback(securityBlock(policyState)) const desired = this.store.extensionRecords().find(record => record.id === packageId) if (!desired) return fallback('The saved theme is unavailable. Its last verified base is shown.') if (!desired.enabled || desired.pendingRemoval) return fallback('The saved theme is disabled or being removed. Its base is shown.') - if (this.store.extensionFrontendClosed(desired.id)) return fallback('Frontend access is closed until the updated package is applied after restart.') const applied = this.store.appliedExtensionRecords().find(record => record.id === desired.id && record.enabled) + const policyError = securityBlock(policyState, desired) || (applied ? securityBlock(policyState, applied) : '') + if (policyError) return fallback(policyError) + if (this.store.extensionFrontendClosed(desired.id)) return fallback('Frontend access is closed until the updated package is applied after restart.') if (!applied) return fallback('This theme is waiting for its package to be applied.') const item = (applied.manifest.contributes.theme || []).find(theme => selected === `${applied.id}/${theme.id}`) if (!item) { @@ -613,6 +638,7 @@ export class ExtensionInstaller { let config: Record = { ...record.values } let startupError = '' try { + this.store.assertExtensionAllowed(record) assertExtensionPath(this.root, packagePath) if (privatePython) assertExtensionPath(this.root, privatePython) if (!fs.existsSync(packagePath) || (privatePython && !fs.existsSync(privatePython))) throw new Error('missing installation') @@ -620,10 +646,11 @@ export class ExtensionInstaller { const checked = await this.helper.call('validate-config', [], { manifest: record.manifest, config: { ...record.values, ...secrets }, allow_incomplete: false }) config = checked.config || {} this.packageData(record.id) + this.store.assertExtensionAllowed(record) } catch { // Saved enable intent remains visible. Python retains the contribution // in its unavailable catalog and never executes a startup-error entry. - startupError = 'Extension installation, credentials, configuration, or package data could not be validated' + startupError = this.store.extensionSecurityBlock(record) || 'Extension installation, credentials, configuration, or package data could not be validated' secrets = {} this.store.reportExtensionWarning(startupError) } diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 1c5e8734..56ea316e 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -41,6 +41,7 @@ import { ExtensionInstaller, ExtensionPackageHelper, ExtensionChangeCancelled } import { EXTENSIONS_ENV } from './desktopSettings.js' import type { ExtensionRemovalOptions, ExtensionUpdate } from '../shared/extensions.js' import { extensionDisclosure } from '../shared/extensions.js' +import { ExtensionCatalog } from './extensionCatalog.js' import { ExtensionThemeResources } from './extensionThemes.js' import { developmentServer, isDevelopmentRendererUrl } from './developmentServer.js' @@ -55,14 +56,16 @@ const CACHE_DIR = process.env.AMADEUS_ELECTRON_CACHE_DIR ? path.resolve(process.env.AMADEUS_ELECTRON_CACHE_DIR) : path.join(PROJECT_ROOT, '.electron-cache') const APP_ICON_PATH = path.join(PROJECT_ROOT, 'assets', 'icons', 'app', 'app_icon.ico') -const extensionPackageHelper = new ExtensionPackageHelper(PROJECT_ROOT, getPythonCommand, process.env) +// Keep the static checker separate from the externally supplied backend runtime. +const EXTENSION_HOST_ROOT = app.isPackaged ? path.join(process.resourcesPath, 'extension-host') : PROJECT_ROOT +const extensionPackageHelper = new ExtensionPackageHelper(EXTENSION_HOST_ROOT, getPythonCommand, process.env) const desktopSettings = new DesktopSettingsStore( path.join(USER_DATA_DIR, 'settings.json'), path.join(PROJECT_ROOT, '.env'), process.platform, extensionPackageHelper.validateRecord, ) -const extensionInstaller = new ExtensionInstaller(path.join(USER_DATA_DIR, 'extensions'), PROJECT_ROOT, +const extensionInstaller = new ExtensionInstaller(path.join(USER_DATA_DIR, 'extensions'), EXTENSION_HOST_ROOT, desktopSettings, extensionPackageHelper, getPythonCommand, process.env, undefined, async review => { const options: Electron.MessageBoxOptions = { type: 'question', title: 'Review extension access', message: `${review.operation === 'update' ? `Update ${review.previousVersion} → ${review.manifest.version}` : review.operation === 'enable' ? 'Enable extension' : 'Install extension'}: ${review.manifest.id}`, @@ -75,6 +78,9 @@ const extensionInstaller = new ExtensionInstaller(path.join(USER_DATA_DIR, 'exte return mainWindow.webContents.executeJavaScript("(async () => { if (typeof queryLocalFonts !== 'function') throw new Error('System font enumeration is unavailable'); return [...new Set((await queryLocalFonts()).map(font => font.family))] })()", true) }), () => notifyDesktopThemeChanged()) +const extensionCatalog = new ExtensionCatalog(path.join(USER_DATA_DIR, 'extension-catalog'), desktopSettings, extensionPackageHelper, extensionInstaller, + () => String(startupValue('AMADEUS_EXTENSION_ARTIFACT_MIRROR', desktopSettings.snapshot(process.env), '')), () => notifyDesktopThemeChanged()) + function notifyDesktopThemeChanged(): void { for (const window of BrowserWindow.getAllWindows()) if (!window.isDestroyed() && isTrustedBackendRenderer(window.webContents)) window.webContents.send('desktop-theme.changed') } @@ -2220,6 +2226,10 @@ ipcMain.handle('mcp-connections.remove', (event, connectionId: string) => { } } }) +ipcMain.handle('extensions.catalog', async (event, raw: unknown) => { + if (!isTrustedBackendRenderer(event.sender)) return { ok: false, error: 'Untrusted extension requester.' } + return extensionCatalog.dispatch(raw, process.env, url => shell.openExternal(url)) +}) ipcMain.handle('extensions.icon', (event, id: string) => { if (!isTrustedBackendRenderer(event.sender) || typeof id !== 'string') return null return extensionInstaller.icon(id) diff --git a/electron/src/preload/index.mts b/electron/src/preload/index.mts index 1a120ae8..964d137a 100644 --- a/electron/src/preload/index.mts +++ b/electron/src/preload/index.mts @@ -1,3 +1,4 @@ +import type { CatalogRequest, CatalogReply } from '../shared/extensionCatalog.js' /** * Preload script — exposes minimal backend IPC to the renderer via contextBridge. */ @@ -51,6 +52,7 @@ contextBridge.exposeInMainWorld('amadeus', { clearEnvironment?: boolean }): Promise<{ ok: boolean; error?: string; settings?: Record }> => ipcRenderer.invoke('mcp-connections.upsert', update), removeMcpConnection: (connectionId: string): Promise<{ ok: boolean; error?: string; settings?: Record }> => ipcRenderer.invoke('mcp-connections.remove', connectionId), + extensionCatalog: (request: CatalogRequest): Promise => ipcRenderer.invoke('extensions.catalog', request), getExtensionIcon: (id: string): Promise => ipcRenderer.invoke('extensions.icon', id), installExtension: (): Promise => ipcRenderer.invoke('extensions.install'), updateExtension: (update: ExtensionUpdate): Promise => ipcRenderer.invoke('extensions.update', update), diff --git a/electron/src/renderer/components/ExtensionCatalog.tsx b/electron/src/renderer/components/ExtensionCatalog.tsx new file mode 100644 index 00000000..805ec806 --- /dev/null +++ b/electron/src/renderer/components/ExtensionCatalog.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from 'react' +import type { CatalogEntry, CatalogView } from '../../shared/extensionCatalog.js' +import type { ExtensionSummary } from '../../shared/extensions.js' +import { useI18n } from '../i18n' +import { CardShell, SettingsButton, StatusPill } from './SettingsPrimitives' +import { catalogApplication, catalogBytes, catalogContributions, catalogGroups, catalogText, catalogUnavailable, contributionLabel, compareCatalogVersions } from './extensionCatalogModel' + +export default function ExtensionCatalog({ catalog, installed, busy, selection, onSelect, onInstall, onLink }: { + catalog: CatalogView; installed: ExtensionSummary[]; busy: boolean + selection: { id: string; version: string } | null + onSelect: (selection: { id: string; version: string } | null) => void + onInstall: (entry: CatalogEntry) => void + onLink: (entry: CatalogEntry, field: 'homepage' | 'repository') => void +}) { + const { locale, t } = useI18n() + const [query, setQuery] = useState(''), [kind, setKind] = useState('') + const groups = catalogGroups(catalog.entries, query, kind, locale, t) + const kinds = [...new Set(catalog.entries.flatMap(entry => catalogContributions(entry).map(c => c.kind)))] + const selected = selection && catalog.entries.find(e => e.id === selection.id && e.version === selection.version) + const detail = useRef(null) + useEffect(() => { + if (selected) { detail.current?.scrollIntoView({ block: 'nearest' }); detail.current?.focus({ preventScroll: true }) } + }, [selected?.id]) + const prior = selected ? installed.find(item => item.id === selected.id) : undefined + const unavailable = selected ? catalogUnavailable(selected, prior) : [] + const value = (input: unknown) => typeof input === 'string' ? input : Array.isArray(input) ? input.filter(i => typeof i === 'string').join(', ') : '' + return
+
+ + +
+ {catalog.cached && catalog.entries.length > 0 ?

{t('Showing the last catalog snapshot. Installation checks the official index again.')}

: null} + {!catalog.entries.length ?

{t(catalog.error ? 'The catalog is unavailable. Installed packages remain manageable.' : 'Plugin store preview: no packages have been listed yet.')}

: !groups.length ?

{t('No plugins match this search.')}

: null} + {selected ?
+ +

{catalogText(selected.display.display_name, locale, selected.id)}

{selected.id}
onSelect(null)}>{t('Close details')}
+

{catalogText(selected.display.description, locale)}

+ +
+
{t('Author')}
{catalogText(selected.display.author && typeof selected.display.author === 'object' ? (selected.display.author as Record).name : selected.display.author, locale, t('Not provided'))}
+
{t('Maintainers')}
{value(selected.publisher.maintainers) || value(selected.publisher.id)}
+
{t('License')}
{value(selected.display.license) || t('Not provided')}
+
{t('Capabilities')}
{catalogContributions(selected).map((c, index) =>
{t(contributionLabel(c.kind, c.role))} · {c.contract}
)}
+
{t('Host API')}
{value(selected.compatibility.host_api)}
+
{t('Platform')}
{value(selected.compatibility.platforms) || t('No platform restriction declared')}
+
{t('Plugin runtime')}
{selected.compatibility.runtime === 'none' ? t('No plugin process') : selected.compatibility.runtime === 'python' ? t('Private Python environment') : value(selected.compatibility.runtime)}
+
{t('Python compatibility')}
{value(selected.compatibility.python) || t('No version restriction declared')} · {t('SDK')} {value(selected.compatibility.sdk)}
+
{t('Wheel download size')}
{catalogBytes(selected.size)} · {t('Excludes external services and runtimes.')}
+
{t('When changes apply')}
{t(catalogApplication(selected))}
+
{t('Artifact URL')}
{selected.url}
+
{t('SHA-256')}
{selected.sha256}
+
{t('Artifact review')}
{value(selected.review.reviewer)} · {value(selected.review.date)} · {value(selected.review.policy)}
+
+

{t('Review covers this wheel digest against the public checklist. It does not grant permissions or guarantee runtime safety.')}

+

{t('The checklist covers wheel contents, dependencies, commands, runtime code downloads and asset licensing. Each version requires a separate review.')}

+ {catalog.checkedAt ?

{t('Catalog checked at')}: {new Date(catalog.checkedAt).toLocaleString(locale)}

:

{t('Catalog check time is unavailable.')}

} +
{t('External services, commands and declared OS access')}{selected.display.access ?
{JSON.stringify(selected.display.access, null, 2)}
:

{t('This catalog has no access details. Review the verified wheel disclosure before confirming installation.')}

}
+ {prior ?

{t('Installed version')}: {prior.version} · {prior.digest === selected.sha256 ? t('Exact artifact match') : t('Different artifact')}

: null} + {unavailable.map(reason =>
{t(reason)}
)} +
+ {typeof selected.display.homepage === 'string' ? onLink(selected, 'homepage')}>{t('Open homepage')} : null} + {typeof selected.display.repository === 'string' ? onLink(selected, 'repository')}>{t('Open source repository')} : null} + 0} onClick={() => onInstall(selected)}>{t(prior ? 'Install selected version…' : 'Install plugin…')} +
+
+
: selection ?

{t('This version is no longer in the catalog. Refresh or choose another version.')}

: null} +
{groups.map(group => { + const entry = group.versions.find(e => !e.incompatible.length && !e.blockedReason && !e.withdrawal) || group.versions[0] + return +

{catalogText(entry.display.display_name, locale, group.id)}

{entry.version}
+
{catalogText(entry.display.description, locale)}
+
{catalogContributions(entry).map(c => t(contributionLabel(c.kind, c.role))).join(' · ')} · {catalogBytes(entry.size)}
+ {catalogUnavailable(entry).map(reason =>
{t(reason)}
)} +
onSelect({ id: entry.id, version: entry.version })}>{t('View plugin details')}
+
+ })}
+
+} diff --git a/electron/src/renderer/components/ExtensionSettings.tsx b/electron/src/renderer/components/ExtensionSettings.tsx index e7f4a38c..2bf6d828 100644 --- a/electron/src/renderer/components/ExtensionSettings.tsx +++ b/electron/src/renderer/components/ExtensionSettings.tsx @@ -1,3 +1,6 @@ +import type { CatalogEntry, CatalogReply, CatalogView, CatalogProgress } from '../../shared/extensionCatalog.js' +import ExtensionCatalog from './ExtensionCatalog' +import { catalogBytes, recommendedUpdate } from './extensionCatalogModel' import { useEffect, useRef, useState } from 'react' import type { ExtensionSummary, ExtensionMutationResult } from '../../shared/extensions.js' import { extensionDisclosure } from '../../shared/extensions.js' @@ -39,20 +42,60 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn const [busy, setBusy] = useState(false) const [error, setError] = useState('') const [notice, setNotice] = useState('') + const [security, setSecurity] = useState<{ status: string; checkedAt?: string }>({ status: 'unseen' }) + const [tab, setTab] = useState<'installed' | 'browse'>('installed') + const [catalog, setCatalog] = useState({ entries: [], cached: true }) + const [catalogSelection, setCatalogSelection] = useState<{ id: string; version: string } | null>(null) + const [progress, setProgress] = useState() + const catalogBusy = useRef(false) + const metadataRequest = useRef(0) + const receiveCatalog = (result: CatalogReply) => { + if (result.catalog) setCatalog(result.catalog) + if (result.settings) { + onSettingsChanged(result.settings) + if (result.settings.extensionSecurity) setSecurity(result.settings.extensionSecurity as { status: string; checkedAt?: string }) + } + } + useEffect(() => { + let closed = false + const request = ++metadataRequest.current + void window.amadeus?.extensionCatalog?.({ action: 'get' }).then(result => { + if (!closed && request === metadataRequest.current) { receiveCatalog(result); setProgress(result.catalog?.downloading) } + }).catch(() => { if (!closed) setError('Catalog state could not be loaded.') }) + return () => { closed = true } + }, []) + useEffect(() => { + const api = window.amadeus + if (!api || !busy && !progress) return + let closed = false + let timer: ReturnType + const poll = async () => { + try { + const result = await api.extensionCatalog({ action: 'status' }) + if (!closed) { + setProgress(result.progress) + if (!result.progress && !catalogBusy.current && progress) { const snapshot = await api.extensionCatalog({ action: 'get' }); if (!closed) receiveCatalog(snapshot) } + } + } catch { /* The operation receipt owns errors; polling cannot declare success. */ } + if (!closed) timer = setTimeout(poll, 350) + } + void poll() + return () => { closed = true; clearTimeout(timer) } + }, [busy, Boolean(progress)]) const [removeCandidate, setRemoveCandidate] = useState('') const [disableCandidate, setDisableCandidate] = useState('') const [deleteData, setDeleteData] = useState(false) const dirty = draft !== null && JSON.stringify(draft) !== initial.current useEffect(() => { - onEditorStateChange?.({ dirty, busy }) + onEditorStateChange?.({ dirty, busy: busy || Boolean(progress) }) return () => onEditorStateChange?.({ dirty: false, busy: false }) - }, [dirty, busy, onEditorStateChange]) + }, [dirty, busy, Boolean(progress), onEditorStateChange]) useEffect(() => { if (!selected) return editor.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) editor.current?.querySelector('input')?.focus({ preventScroll: true }) }, [selected]) - const canLeave = () => canLeaveSettingsEditors([{ dirty, busy }], () => window.confirm(t(DISCARD_CONNECTION_CHANGES))) + const canLeave = () => canLeaveSettingsEditors([{ dirty, busy: busy || Boolean(progress) }], () => window.confirm(t(DISCARD_CONNECTION_CHANGES))) const close = () => { setSelected(null); setDraft(null); setRemoveCandidate(''); setDisableCandidate(''); setDeleteData(false) } const edit = (extension: ExtensionSummary) => { if (!extension.manifest || !canLeave()) return @@ -87,6 +130,53 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn } catch (reason) { setError(reason instanceof Error ? reason.message : t('Could not install extension')) } finally { setBusy(false) } } + const checkSecurity = async (recovery = false) => { + if (!window.amadeus || busy || catalogBusy.current) return + catalogBusy.current = true + ++metadataRequest.current + setBusy(true); setError(''); setNotice('') + try { + const result = await window.amadeus.extensionCatalog({ action: recovery ? 'recover' : 'refresh' }) + receiveCatalog(result) + if (!result.ok) throw new Error(result.error || 'Catalog refresh failed') + setNotice('Official extension security state checked. Existing backend processes may still need to stop.') + } catch (reason) { setError(reason instanceof Error ? reason.message : 'Catalog refresh failed') } + finally { catalogBusy.current = false; setBusy(false) } + } + const installCatalog = async (entry: CatalogEntry) => { + if (!window.amadeus || catalogBusy.current || !canLeave()) return + catalogBusy.current = true + ++metadataRequest.current + setBusy(true); setError(''); setNotice('') + setProgress({ id: entry.id, version: entry.version, received: 0, total: 0, stage: 'checking' }) + try { + const result = await window.amadeus.extensionCatalog({ action: 'install', id: entry.id, version: entry.version }) + receiveCatalog(result) + if (result.cancelled) { setNotice('Catalog installation cancelled. No new package was saved.'); return } + if (!result.ok) throw new Error(result.error || 'Could not install extension') + close(); setTab('installed') + setNotice('Package saved. Configure and enable it in Installed, then choose its Agents, voice or appearance. Saved updates may require a backend restart.') + if (result.settings?.restartRequired) onRestartRequired() + } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not install extension') } + finally { catalogBusy.current = false; setProgress(undefined); setBusy(false) } + } + const cancelCatalog = async () => { + if (!window.amadeus) return + setProgress(current => current ? { ...current, cancelRequested: true } : current) + try { const result = await window.amadeus.extensionCatalog({ action: 'cancel' }); if (!result.ok) throw new Error(result.error) } + catch { setError('Cancellation could not be requested. The operation is still running.') } + } + const openCatalogLink = async (entry: CatalogEntry, action: 'homepage' | 'repository') => { + if (!window.amadeus) return + setError(''); setNotice('') + try { const result = await window.amadeus.extensionCatalog({ action, id: entry.id, version: entry.version }); if (!result.ok) throw new Error(result.error) } + catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not open package link') } + } + const showCatalog = (id: string, version: string) => { setCatalogSelection({ id, version }); setTab('browse') } + const switchTab = (next: 'installed' | 'browse') => { + setTab(next) + if (next === 'browse' && tab !== next && security.status !== 'unavailable') void checkSecurity() + } const save = async () => { if (!window.amadeus || !selected || !draft) return setBusy(true) @@ -121,9 +211,11 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn } const compatibleProviders = compatibleExtensionProviders(providers, selected) const selectedMetadataAvailable = selected && extensions.some(extension => extension.id === selected.id && extension.manifest) + const selectedPolicyBlocked = security.status === 'unavailable' || Boolean(catalog.installed?.find(item => item.id === selected?.id && item.version === selected?.version && item.digest === selected?.digest)?.blocked) const missingProviders = draft?.providers.filter(id => !compatibleProviders.some(provider => provider.provider_id === id)) || [] const editorForm = (current: ExtensionSummary) => !selected?.manifest || !draft ? null :
{ event.preventDefault(); void save() }} aria-label={t('Configure extension')}> + {selectedPolicyBlocked && current.enabled ?
{t('Security policy prevents activation. Disable this package to save configuration changes.')}
: null}
{Object.entries(selected.manifest.config || {}).map(([key, field]) => { const id = `extension-config-${key}` @@ -165,25 +257,44 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn
{t(selected.application === 'frontend' ? 'Save to apply immediately without restarting the backend.' : 'Save, then restart the backend to apply. The current runtime keeps its existing extension state.')}
{ if (canLeave()) close() }}>{t('Cancel')} -
+
- return void install()}>{t(busy && !selected ? 'Working…' : 'Install plugin…')}}> -
+ return
+
+ {(['installed', 'browse'] as const).map(value => )} +
+
+ void checkSecurity(security.status === 'unavailable')}>{t(security.status === 'unavailable' ? 'Recover official extension security state' : 'Check updates and security status')} +
{security.status === 'unavailable' ? t('Extension security state is damaged. Installation and activation are blocked until recovery.') : security.checkedAt ? `${t('Last successful security check')}: ${new Date(security.checkedAt).toLocaleString(locale)}` : t('Official extension security state has not been checked.')}
+
+ {error || catalog.error ?
{t(error || catalog.error!)}
: null} + {notice ?
{t(notice)}
: null} + {progress ?
+ {progress.id} · {progress.version} +
{t(progress.cancelRequested ? 'Cancellation requested; waiting for safe cleanup.' : { checking: 'Checking the official catalog…', downloading: 'Downloading wheel…', verifying: 'Downloaded. Verifying the artifact…', confirming: 'Review the native installation confirmation.', preparing: 'Preparing the package. It has not been saved yet.' }[progress.stage])}
+ {progress.stage === 'downloading' ? <>{catalogBytes(progress.received)} / {catalogBytes(progress.total)} : null} + void cancelCatalog()}>{t('Cancel installation')} +
: null} + + +
} diff --git a/electron/src/renderer/components/SettingsPage.tsx b/electron/src/renderer/components/SettingsPage.tsx index ddc71769..6285267d 100644 --- a/electron/src/renderer/components/SettingsPage.tsx +++ b/electron/src/renderer/components/SettingsPage.tsx @@ -1318,6 +1318,7 @@ export default function SettingsPage({ send, subscribe, connected, reconnectBack onOpenAppearance={() => presentation === 'plugins' ? onOpenSettings?.('general') : setSection('general')} onSettingsChanged={settings => setDesktop(settings as unknown as DesktopSettingsSnapshot)} onRestartRequired={() => setRestartPending(true)} onOpenVoice={() => presentation === 'plugins' ? onOpenSettings?.('voice') : setSection('voice')} onEditorStateChange={extensionEditorChanged} /> + {catalogConfiguration('desktop_extensions', desktop).fields.map(field => )} : null} {section === 'diagnostics' ? diagnostics : null} {section === 'capabilities' ? ( diff --git a/electron/src/renderer/components/extensionCatalogModel.ts b/electron/src/renderer/components/extensionCatalogModel.ts new file mode 100644 index 00000000..ff32f520 --- /dev/null +++ b/electron/src/renderer/components/extensionCatalogModel.ts @@ -0,0 +1,56 @@ +import type { CatalogEntry } from '../../shared/extensionCatalog.js' +import type { ExtensionSummary } from '../../shared/extensions.js' +import { contributionProfile } from '../../shared/extensionProfile.js' + +export function catalogText(value: unknown, locale: string, fallback = ''): string { + if (typeof value === 'string') return value + if (!value || typeof value !== 'object' || Array.isArray(value)) return fallback + const localized = value as Record + return [localized[locale], localized[locale.split('-')[0]], localized.en, ...Object.values(localized)].find(item => typeof item === 'string') as string || fallback +} +export function compareCatalogVersions(a: string, b: string): number | null { + const pattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/ + if (!pattern.test(a) || !pattern.test(b)) return null + const left = a.split('.').map(BigInt), right = b.split('.').map(BigInt) + for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] > right[i] ? 1 : -1 + return 0 +} +export function catalogContributions(entry: CatalogEntry): Array<{ kind: string; role: string; contract: string }> { + const items = Array.isArray(entry.compatibility.contributions) ? entry.compatibility.contributions : [] + return [...new Map(items.map(item => [JSON.stringify([item.kind, item.role, item.contract]), item])).values()] +} +export const contributionLabel = (kind: string, role = '') => kind === 'voice_backend' + ? role === 'tts' ? 'Speech synthesis' : role === 'asr' ? 'Speech recognition' : role ? `voice_backend/${role}` : 'Voice backends' + : ({ mcp_server: 'MCP', skill: 'Skill', theme: 'Theme' }[kind] || kind) +export function catalogApplication(entry: CatalogEntry): string { + const profiles = catalogContributions(entry).map(c => contributionProfile(c.kind, c.role || undefined)) + return !profiles.length || profiles.some(p => !p || p.status !== 'open') ? 'Compatibility unavailable' + : profiles.every(p => p?.apply === 'frontend') ? 'Applies when enabled; no backend restart.' : 'Applies after enabling and restarting the backend.' +} +export function catalogUnavailable(entry: CatalogEntry, installed?: ExtensionSummary): string[] { + return [entry.blockedReason, entry.withdrawal ? entry.withdrawal.reason === 'copyright' ? 'Withdrawn for copyright reasons' : 'Withdrawn by the maintainer' : '', + ...entry.incompatible, installed?.pendingRemoval ? 'Removal pending restart' : '', + installed?.version === entry.version ? installed.digest === entry.sha256 ? 'This artifact is already installed' : 'A different artifact of this version is already installed' : ''].filter((value): value is string => Boolean(value)) +} +export function recommendedUpdate(entries: CatalogEntry[], installed: ExtensionSummary): CatalogEntry | undefined { + if (installed.pendingRemoval || compareCatalogVersions(installed.version, installed.version) === null) return undefined + return entries.filter(entry => entry.id === installed.id && compareCatalogVersions(entry.version, installed.version) === 1 && !catalogUnavailable(entry, installed).length) + .sort((a, b) => compareCatalogVersions(b.version, a.version) || 0)[0] +} +export function catalogGroups(entries: CatalogEntry[], query: string, kind: string, locale: string, translate: (text: string) => string = text => text): Array<{ id: string; versions: CatalogEntry[] }> { + const groups = new Map() + for (const entry of entries) { + const versions = groups.get(entry.id) + if (versions) versions.push(entry) + else groups.set(entry.id, [entry]) + } + const term = query.trim().toLocaleLowerCase(locale) + const matches = (entry: CatalogEntry) => { + const contributions = catalogContributions(entry) + return (!kind || contributions.some(c => c.kind === kind)) && (!term || [entry.id, catalogText(entry.display.display_name, locale), catalogText(entry.display.description, locale), ...contributions.flatMap(c => [c.kind, c.role, c.contract, translate(contributionLabel(c.kind, c.role))])].join(' ').toLocaleLowerCase(locale).includes(term)) + } + return [...groups].map(([id, versions]) => ({ id, versions: versions.filter(matches).sort((a, b) => compareCatalogVersions(b.version, a.version) || 0) })) + .filter(group => group.versions.length > 0) + +} +export const catalogBytes = (size: number): string => size < 1024 ? `${size} B` : size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KiB` : `${(size / 1024 / 1024).toFixed(1)} MiB` diff --git a/electron/src/renderer/env.d.ts b/electron/src/renderer/env.d.ts index 3b77b617..07f2fc83 100644 --- a/electron/src/renderer/env.d.ts +++ b/electron/src/renderer/env.d.ts @@ -1,4 +1,5 @@ /// +import type { CatalogRequest, CatalogReply } from '../shared/extensionCatalog' import type { BackendStartupFailure } from '../shared/characterStartup' import type { ExtensionMutationResult, ExtensionRemovalOptions, ExtensionUpdate } from '../shared/extensions' import type { DesktopThemeState } from '../shared/extensionTheme' @@ -66,6 +67,7 @@ export declare global { clearEnvironment?: boolean }) => Promise<{ ok: boolean; error?: string; settings?: Record }> removeMcpConnection: (connectionId: string) => Promise<{ ok: boolean; error?: string; settings?: Record }> + extensionCatalog: (request: CatalogRequest) => Promise getExtensionIcon: (id: string) => Promise installExtension: () => Promise updateExtension: (update: ExtensionUpdate) => Promise diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index dd0d97bb..3e1cee17 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -1,6 +1,8 @@ { "A centered PNG copy is stored in the app's local assets. The original file is never modified.": "居中处理后的 PNG 副本会保存在应用本地资源中,原始文件不会被修改。", "A companion for your next story": "一起走进下一段故事", + "A different artifact of this version is already installed": "已安装该版本的另一份产物", + "A running extension is blocked by security policy or unreadable security state. Stop or restart the backend.": "正在运行的插件受到安全封禁或本地安全状态损坏影响。请停止或重启后端。", "ACP agents — Experimental": "ACP 代理——实验性", "ACP v1 preset using a referenced Anthropic credential.": "使用所引用 Anthropic 凭据的 ACP v1 预设。", "ACP v1 preset using a referenced DeepSeek credential.": "使用所引用 DeepSeek 凭据的 ACP v1 预设。", @@ -63,6 +65,7 @@ "Agent installation (shared by all games)": "Agent 程序(所有游戏共用)", "All roles follow the application’s appearance and voice settings.": "所有角色均使用应用的外观与语音设置。", "All roles use this voice setup. Model weights, voice profiles, reference audio and speech services are configured here. Changing personality does not change the voice.": "所有角色共用这套语音配置。模型权重、声线、参考音频和语音服务统一在此设置。更换人格不会更换声音。", + "All types": "所有类型", "Allow identical calls for this Work": "本次 Work 允许相同调用", "Allow once": "仅允许一次", "Already configured · shared by all games": "已配置 · 所有游戏共用", @@ -79,10 +82,12 @@ "Applied": "已生效", "Applied configuration reloaded.": "已重载应用中的配置。", "Applied visual status": "已应用形象的状态", + "Applies after enabling and restarting the backend.": "启用并重启后端后生效。", "Applies after restart": "重启后生效", "Applies after saving and restarting the backend.": "保存并重启后端后生效。", "Applies immediately after saving.": "保存后立即生效。", "Applies to character rendering and wallpapers, not model inference or voice processing. Restart the backend after saving, then reopen existing character and wallpaper windows.": "用于角色渲染和壁纸画面,不影响模型推理或语音处理。保存后请重启后端,再重新打开现有角色和壁纸窗口。", + "Applies when enabled; no backend restart.": "启用后生效,无需重启后端。", "Apply observation settings": "应用观察设置", "Applying…": "正在应用…", "Approval required": "需要批准", @@ -92,7 +97,9 @@ "Archiving": "正在归档", "Arguments": "参数", "Arguments — one per line": "参数——每行一个", + "Artifact URL": "产物地址", "Artifact collections": "产物集合", + "Artifact review": "产物审核", "Artifact types": "产物类型", "Artifacts": "产物", "Artwork selection": "选择形象", @@ -107,6 +114,7 @@ "Attach game view": "附加游戏画面", "Attach hook agent": "附加 Hook 代理", "Attached game view": "已附加的游戏画面", + "Author": "作者", "Auto Return to Idle": "自动返回待机", "Automatic": "自动", "Automatic (recommended)": "自动(推荐)", @@ -140,6 +148,7 @@ "Base companion": "基础陪玩", "Base theme": "基础样式", "Bearer token environment variable": "Bearer Token 环境变量", + "Blocked by security policy": "安全策略已阻止", "Bridge": "桥接", "Browse": "浏览", "Browser provider": "浏览器服务提供方", @@ -152,6 +161,9 @@ "CUDA Graph ×1": "CUDA Graph ×1", "Cancel": "取消", "Cancel connection": "取消连接", + "Cancel installation": "取消安装", + "Cancellation could not be requested. The operation is still running.": "无法请求取消,操作仍在进行。", + "Cancellation requested; waiting for safe cleanup.": "已请求取消,正在等待安全清理。", "Cancelled": "已取消", "Capabilities": "能力", "Capabilities overview": "能力总览", @@ -162,6 +174,14 @@ "Capture when a planned story beat arrives": "计划剧情节点到达时采集", "Captured text": "取文预览", "Capturing game view…": "正在截取游戏画面…", + "Catalog check time is unavailable.": "目录检查时间不可用。", + "Catalog checked at": "目录检查时间", + "Catalog installation cancelled. No new package was saved.": "目录安装已取消,未保存新插件。", + "Catalog packages": "目录插件", + "Catalog refresh failed": "目录刷新失败", + "Catalog review status is unknown": "目录审核状态未知", + "Catalog state could not be loaded.": "无法读取目录状态。", + "Catalog version": "目录版本", "Change AMADEUS_CHARACTER_ID in the parent process environment, then reopen Amadeus. Desktop recovery is locked by that source.": "请修改父进程环境中的 AMADEUS_CHARACTER_ID,然后重新打开 Amadeus。此来源锁定了桌面恢复操作。", "Change AMADEUS_CHARACTER_ID to {characterId} in your launch environment and restart the backend to open, rename, or delete this chat.": "在启动环境中将 AMADEUS_CHARACTER_ID 改为 {characterId},然后重启后端,即可打开、重命名或删除此会话。", "Changes are saved together. Restart the backend to apply.": "修改将一起保存,重启后端后生效。", @@ -193,6 +213,8 @@ "Chat window": "聊天窗口", "Chats": "对话", "Check a few lines": "核对几句对白", + "Check updates and security status": "检查更新与安全状态", + "Checking the official catalog…": "正在检查官方目录…", "Checking video support; static playback remains active.": "正在检测视频支持,当前继续静态播放。", "Choice": "选项", "Choices become available after this agent opens its first task. Leave overrides empty to use the agent's defaults.": "此代理打开第一个任务后会显示可选项。将覆盖值留空以使用代理的默认设置。", @@ -230,10 +252,13 @@ "Close": "关闭", "Close MCP editor": "关闭 MCP 编辑器", "Close and reopen Render or Wallpaper to apply the Cubism Core SDK change.": "请关闭并重新打开 Render 或 Wallpaper,以应用 Cubism Core SDK 变更。", + "Close details": "关闭详情", "Close game on stop": "停止时关闭游戏", "Close games launched by VN Player on stop": "停止时关闭由 VN Player 启动的游戏", "Close preview": "关闭预览", "Codex agent": "Codex Agent", + "Codex agent · current selection": "Codex Agent · 当前选择", + "Codex agent · recommended default": "Codex Agent · 推荐默认", "Codex · Recommended": "Codex · 推荐", "Collapse navigation": "收起导航", "Command": "命令", @@ -249,6 +274,7 @@ "Compare extracted text with the game before playing.": "开始前,确认提取的文本与游戏一致。", "Compatibility unavailable": "兼容性状态不可用", "Compatible Work Providers": "兼容的 Work Provider", + "Compatible update available": "有兼容更新", "Completed": "已完成", "Completed Japanese assistant messages are translated for display only.": "完成的日语助手消息只翻译用于显示。", "Compress old history; keep recent frames complete": "压缩旧历史,保持近期帧完整", @@ -298,6 +324,7 @@ "Core": "核心", "Could not install extension": "无法安装扩展", "Could not load roles.": "无法加载角色。", + "Could not open package link": "无法打开插件链接", "Could not read startup status.": "无法读取启动状态。", "Could not remove extension": "无法卸载扩展", "Could not save console language": "无法保存控制台语言", @@ -333,6 +360,9 @@ "DeepSeek · Built-in profile": "DeepSeek · 内置配置", "DeepSeek · Default": "DeepSeek · 默认", "DeepSeek · Not configured": "DeepSeek · 未配置", + "DeepSeek · connection default": "DeepSeek · 连接默认模型", + "DeepSeek · current selection": "DeepSeek · 当前选择", + "DeepSeek · recommended default": "DeepSeek · 推荐默认", "Default Work Provider": "默认工作服务提供方", "Default model saved for the next backend restart; the current runtime was not changed.": "默认模型已保存,将在下次重启后端时应用;当前运行时未发生变化。", "Default model saved for the next backend start; the current conversation still uses the previous model.": "默认模型已保存,将在下次启动后端时生效;当前对话仍使用之前的模型。", @@ -349,6 +379,7 @@ "Diagnostics": "诊断", "Diagnostics and manual line test": "诊断与手动台词测试", "Dialogue will appear here when the connection is working.": "连接成功后,游戏文本会显示在这里。", + "Different artifact": "不同产物", "Direct image input is supported first by OpenAI and Gemini": "优先由 OpenAI 和 Gemini 提供直接图像输入", "Disable": "停用", "Disable extension": "停用扩展", @@ -361,6 +392,8 @@ "Display name": "显示名称", "Done": "完成", "Download and extract Agent, then select agent.exe. Keep its data folder beside it. This is needed only once.": "下载并解压 Agent 后选择 agent.exe,保留同目录的 data 文件夹。所有游戏共用,只需配置一次。", + "Downloaded. Verifying the artifact…": "下载完成,正在校验产物…", + "Downloading wheel…": "正在下载 wheel…", "Draft": "草稿", "Draft artifacts": "草稿产物", "Drafts": "草稿", @@ -402,6 +435,8 @@ "Environment references — child variable=Host variable": "环境变量引用——子进程变量=宿主变量", "Environment values": "环境变量值", "Error": "错误", + "Exact artifact match": "产物完全匹配", + "Excludes external services and runtimes.": "不含外部服务和运行时。", "Executable": "可执行文件", "Execution": "执行", "Execution boundary": "执行边界", @@ -420,11 +455,15 @@ "Extension management is available in the Electron app.": "请在 Electron 应用中管理扩展。", "Extension metadata is unavailable.": "扩展元数据不可用。", "Extension metadata is unavailable. Your unsaved changes are retained until validation recovers.": "扩展元数据不可用。验证恢复之前会保留未保存的更改。", + "Extension security state is damaged. Installation and activation are blocked until recovery.": "插件安全状态已损坏。恢复前暂停安装和激活插件。", + "Extension security state is damaged. Recover from the official catalog before installing or activating extensions.": "插件安全状态已损坏。请先从官方目录恢复,再安装或激活插件。", + "Extension security state is damaged. Recover from the official catalog before installing or activating extensions. Stop or restart the running backend.": "插件安全状态已损坏。请先从官方目录恢复,再安装或激活插件;请停止或重启正在运行的后端。", "Extension startup record is invalid or incompatible": "扩展启动记录无效或不兼容", "Extension unavailable": "扩展不可用", "Extension validation is temporarily unavailable; saved records were retained and activation was skipped.": "扩展验证暂时不可用,已保留保存的记录并跳过启用。", "Extension voice compatibility": "扩展语音兼容性", "Extensions": "扩展", + "External services, commands and declared OS access": "外部服务、命令与声明的系统访问", "Fade out after delay — otherwise stays until next turn": "延迟后淡出;否则保持到下一轮", "Failed": "失败", "Falls back to Main conversation": "回退到主对话", @@ -482,6 +521,7 @@ "History": "历史", "Hook": "Hook", "Horizontal position": "水平位置", + "Host API": "Host API", "Host AUIP runtime": "宿主 AUIP 运行时", "Host connection tests do not inherit Host credentials. If this server needs environment variables, add them in Settings → Providers → MCP → Encrypted environment values, then save and restart the backend.": "Host 的连接测试不会继承 Host 凭据。如果此服务需要环境变量,请在“设置 → Providers → MCP → 加密环境变量”中补充,然后保存并重启后端。", "Host connection tests use a platform environment allowlist. Add required tokens or toolchain variables here; Host .env credentials are not inherited. Codex and ACP launch MCP independently.": "Host 的连接测试使用平台环境变量白名单。请在此添加所需的令牌或工具链变量;不会继承 Host .env 中的凭据。Codex 和 ACP 独立启动 MCP。", @@ -518,11 +558,13 @@ "Install once. Manage contributions and their application state here.": "安装一次,在这里管理所有贡献及其生效状态。", "Install plugins and manage their settings.": "安装插件并管理设置。", "Install plugin…": "安装插件…", + "Install selected version…": "安装所选版本…", "Install the optional emotion reference pack, then restart.": "请安装可选情绪参考包,然后重启。", "Installed": "已安装", "Installed Skill discovery": "已安装 Skill 的发现方式", "Installed once by the Host, then projected only to Providers that explicitly support the capability shape.": "由宿主安装一次,然后仅投影给明确支持该能力形态的服务提供方。", "Installed plugins": "已安装插件", + "Installed version": "已安装版本", "Installed visual resources": "已安装的视觉资源", "Installed voice resources": "已安装的声音资源", "Interact with Amadeus": "与 Amadeus 交互", @@ -550,6 +592,7 @@ "Kurisu reference library": "红莉栖资料库", "Last VN line": "最近处理的 VN 台词", "Last question screenshot": "上次提问截图", + "Last successful security check": "上次成功检查安全状态", "Latest game text": "最新游戏文本", "Launch game if it is not running": "游戏未运行时自动启动", "Launch game process": "启动游戏进程", @@ -557,6 +600,7 @@ "Leave blank to keep stored values; use KEY= to remove one. Values are stored encrypted by the Host.": "留空保留已存值;用 KEY= 删除某项。变量值由宿主加密保存。", "Leave empty to inherit the main conversation model.": "留空以继承主对话模型。", "Leave empty to use the selected provider's configured model.": "留空以使用所选服务提供方已配置的模型。", + "License": "许可", "Listening": "正在聆听", "Listening & recognition": "聆听与识别", "Live VN activity, player intervention, and runtime control.": "视觉小说实时活动、玩家介入与运行时控制。", @@ -595,6 +639,7 @@ "Main Chat may delegate work to a Provider. Skills and MCP connections are shared only among compatible Work Providers; their prompts and tool schemas are never attached directly to Main Chat.": "主聊天可以把工作委派给服务提供方。技能和 MCP 连接仅在兼容的工作服务提供方之间共享,其提示词和工具架构不会直接附加到主聊天。", "Main chat model": "主聊天模型", "Main conversation": "主对话", + "Maintainers": "维护者", "Manage appearance": "管理外观", "Manage community voices": "管理社区语音", "Manage installed packages, their contributions and application state.": "管理已安装的扩展包、贡献及其生效状态。", @@ -640,6 +685,7 @@ "Mystery VN": "推理视觉小说", "Mystery companion": "推理陪玩", "Name": "名称", + "Name, description or capability": "名称、说明或能力", "Name: {count}/{limit} characters. Use a single line.": "名字:{count}/{limit} 个字符。请使用单行文本。", "Narrates verified application outcomes.": "叙述已经验证的应用执行结果。", "Native pixel density": "原生像素密度", @@ -672,6 +718,9 @@ "No interactive apps ready to open": "还没有可以打开的交互应用", "No log output yet...": "暂无日志输出…", "No matching chats": "没有匹配的对话", + "No platform restriction declared": "未声明平台限制", + "No plugin process": "无插件进程", + "No plugins match this search.": "没有符合搜索条件的插件。", "No presets loaded": "尚未加载预设", "No registered expressions": "没有注册表情", "No selectable window is available. Open the target window, then refresh the list.": "当前没有可选窗口。请打开目标窗口,然后刷新列表。", @@ -679,6 +728,7 @@ "No shared Provider capability is active in this backend process.": "当前后端进程没有活动的共享服务能力。", "No surface report": "尚无展示面回执", "No user-managed connection settings.": "没有可由用户管理的连接设置。", + "No version restriction declared": "未声明版本限制", "No video package installed; using the existing static path.": "未安装视频包,沿用原有静态路径。", "No visible windows found.": "没有找到可见窗口。", "None": "无", @@ -687,6 +737,8 @@ "Not configured": "未配置", "Not included": "不适用", "Not installed": "未安装", + "Not listed in the checked catalog": "未被已检查的目录收录", + "Not provided": "未提供", "Not set": "未设置", "Not used": "未使用", "Not used for this game type.": "此游戏类型不使用这项能力。", @@ -699,6 +751,8 @@ "Occasional": "偶尔", "Off": "关闭", "Off by default. Sends the selected game window to the VN model when you ask by voice or text; {seconds}s cooldown. May increase response latency. Works without continuous observation.": "默认关闭。通过语音或文本提问时,将所选游戏窗口发送给 VN 模型;冷却时间 {seconds} 秒。可能增加响应延迟,无需开启持续观察即可使用。", + "Official extension security state checked. Existing backend processes may still need to stop.": "已检查官方插件安全状态。已运行的后端可能仍需要停止。", + "Official extension security state has not been checked.": "尚未检查官方插件安全状态。", "Official help links are available in the desktop app.": "请在桌面应用中打开官方帮助链接。", "Offline": "离线", "On": "开", @@ -716,11 +770,13 @@ "Open character knowledge": "打开角色资料", "Open expression presets": "打开表情预设", "Open general settings": "打开通用设置", + "Open homepage": "打开主页", "Open model connections": "打开模型连接", "Open models settings": "打开模型设置", "Open official Agent downloads": "打开 Agent 官方下载页", "Open preview": "打开预览", "Open providers settings": "打开服务提供方设置", + "Open source repository": "打开源码仓库", "Open transparent overlay": "打开透明浮层", "Open voice settings": "打开语音设置", "OpenAI or DeepSeek": "OpenAI 或 DeepSeek", @@ -752,6 +808,7 @@ "Package ID": "插件 ID", "Package is disabled": "扩展包已禁用", "Package resources are removed now. Saved data is kept unless you choose to delete it.": "现在移除包资源。默认保留保存的数据,除非你选择删除。", + "Package saved. Configure and enable it in Installed, then choose its Agents, voice or appearance. Saved updates may require a backend restart.": "插件已保存。请在“已安装”中配置并启用,再选择 Agent、语音或外观。保存的更新可能需要重启后端。", "Parallel ×2": "并行 ×2", "Pause comments": "暂停评论", "Pause comments while continuing to follow the story.": "暂停发言,继续跟读和记录剧情。", @@ -767,12 +824,17 @@ "Pinned Pi runtime and selected model credentials are available; remote access is checked on the first request.": "Pi 固定版本运行时和所选模型凭据均可用;首次实际请求时再检查远程访问。", "Plan drawer": "计划抽屉", "Plans and routes cooperative Work.": "规划并路由协作工作。", + "Platform": "平台", "Play preferences": "游玩偏好", "Player interaction": "玩家互动", "Player interaction must be available to use this input.": "玩家互动能力可用时,才能使用此输入方式。", "Player intervention": "玩家介入", "Player speech never enters main chat.": "玩家语音不会进入主聊天。", "Plugin details": "插件详情", + "Plugin manager views": "插件管理器视图", + "Plugin runtime": "插件运行时", + "Plugin store preview: no packages have been listed yet.": "插件商店预览:暂未收录插件。", + "Plugin type": "插件类型", "Plugins": "插件", "Portrait art is optional. Without the Companion Lite pack, the window shows a simple avatar and captions.": "头像资源为可选项。未安装 Companion Lite 时,小窗显示简洁头像和字幕。", "Portrait overlay": "头像浮层", @@ -780,6 +842,7 @@ "Prefill from current Kurisu Japanese persona": "填入当前红莉栖日语人格文本", "Preparing preview…": "正在准备预览…", "Preparing the final interactive application": "正在准备最终交互应用", + "Preparing the package. It has not been saved yet.": "正在准备插件,尚未保存。", "Preparing the provider run…": "正在准备执行…", "Presentation": "呈现", "Presentation roles": "呈现职责", @@ -793,6 +856,7 @@ "Previously selected window · unavailable": "此前选择的窗口 · 当前不可用", "Primary and scene-specific conversational responsibilities": "主对话与场景专用的对话职责", "Primary model assignment shared by Chat and Wallpaper-originated turns": "聊天窗口与壁纸对话轮次共享的主要模型分配", + "Private Python environment": "独立 Python 环境", "Proactive commentary": "主动评论", "Process environment": "进程环境", "Profile": "配置", @@ -812,6 +876,7 @@ "Provider-only boundary": "仅限服务提供方的边界", "Providers": "服务提供方", "Pure-local Backend Type": "纯本地后端类型", + "Python compatibility": "Python 兼容范围", "RECENT DRAFT ARTIFACTS": "最近的草稿产物", "Raw events": "原始事件", "React to the current story moment.": "回应当前剧情。", @@ -829,6 +894,7 @@ "Reconnect": "重新连接", "Reconnect to the desktop service to manage VN Player.": "重新连接桌面服务后即可管理 VN Player。", "Reconnect to the game to resume text capture.": "重新连接游戏以恢复取文。", + "Recover official extension security state": "恢复官方插件安全状态", "Reference API keys saved in Settings or supplied in the backend environment. Enter variable names here, not secret values. Omit the reference when using an agent's existing login.": "引用保存在设置中或由后端环境提供的 API 密钥。这里填写环境变量名,不填写密钥值。使用代理已有的登录状态时,无需填写引用。", "Reference knowledge and history boundaries are managed in Characters.": "参考资料与历史范围在角色页管理。", "Reflect on earlier story details.": "回顾先前的剧情细节。", @@ -890,9 +956,14 @@ "Retiring": "正在停用", "Reuse persistent native sessions": "复用持久化原生会话", "Reuse visual feature cache": "复用视觉特征缓存", + "Review covers this wheel digest against the public checklist. It does not grant permissions or guarantee runtime safety.": "审核针对该摘要对应的 wheel,依据公开清单进行;不授予权限,也不保证运行安全。", "Review knowledge & history": "查看资料与历史", "Review reference knowledge and how conversations are kept.": "管理参考资料,了解会话历史的保存范围。", "Review the error below, then retry or edit this game.": "查看下方原因后重试,或编辑游戏配置。", + "Review the native installation confirmation.": "请查看原生安装确认窗口。", + "Review update details": "查看更新详情", + "Reviewed artifact in the cached catalog": "缓存目录中的已审核产物", + "Reviewed artifact in the checked catalog": "已检查目录中的已审核产物", "Revoke": "撤销", "Role assignments": "职责分配", "Role identity": "角色身份", @@ -913,7 +984,9 @@ "Runtime notice": "运行时提示", "Runtime packages": "运行时资源包", "Runtime presentation": "运行时呈现", + "SDK": "SDK", "SESSION DRAFT": "会话草稿", + "SHA-256": "SHA-256", "Sandboxed web preview": "沙盒网页预览", "Save": "保存", "Save agent": "保存代理", @@ -948,9 +1021,11 @@ "Screenshot with my questions": "随提问附加截图", "Script lines do not wait for visual inference. Late observations keep their capture-time script reference. Planned beats capture immediately and can wait up to {wait}ms for inference.": "脚本行不会等待视觉推理。延迟返回的观察保留采集时的脚本引用;计划剧情节点会立即采集,并最多等待 {wait} 毫秒。", "Search chats": "搜索对话", + "Search plugins": "搜索插件", "Search results": "搜索结果", "Secrets are encrypted by the operating system and are never returned to the renderer.": "密钥由操作系统加密,且不会返回给渲染进程。", "Secure credential storage is unavailable. Credentials cannot be saved on this device.": "安全凭据存储不可用,无法在此设备上保存凭据。", + "Security policy prevents activation. Disable this package to save configuration changes.": "安全策略禁止激活。请停用该插件后保存配置修改。", "Select OpenAI, Gemini, or hybrid3 to use visual input": "请选择 OpenAI、Gemini 或 hybrid3 以使用视觉输入", "Select a Live2D profile": "选择 Live2D 形象配置", "Select a model and open the preview to test its expressions and mouth movement.": "选择模型并打开预览,即可测试表情和嘴型。", @@ -994,6 +1069,7 @@ "Show": "显示", "Show history": "显示历史记录", "Show portrait": "显示头像", + "Showing the last catalog snapshot. Installation checks the official index again.": "当前显示上次取得的目录。安装前会再次检查官方索引。", "Shown beside Amadeus responses": "显示在 Amadeus 回复旁", "Shown beside your messages": "显示在你的消息旁", "Smoothing (ms)": "平滑时间(毫秒)", @@ -1005,6 +1081,7 @@ "Speech language": "语音语言", "Speech mouth movement": "语音嘴型", "Speech output": "语音输出", + "Speech recognition": "语音识别", "Sprite (default)": "Sprite(默认)", "Sprite or Live2D, chat avatars and installed visual resources. Reuse the same artwork across personalities.": "管理 Sprite、Live2D、聊天头像及已安装的视觉资源。不同人格可以共用同一套形象。", "Standard": "标准", @@ -1086,6 +1163,8 @@ "The VN companion role uses the configured {provider} connection.": "VN 陪玩职责使用已配置的 {provider} 连接。", "The Wallpaper preset can start Wake with the surface, but Wake is disabled in the current startup profile.": "壁纸预设可以随界面启动唤醒,但当前启动配置关闭了唤醒。", "The built-in pipeline is present, but realtime echo cancellation is disabled in the current startup profile.": "内置管线已就位,但当前启动配置关闭了实时回声消除。", + "The catalog is unavailable. Installed packages remain manageable.": "目录不可用,仍可管理已安装的插件。", + "The checklist covers wheel contents, dependencies, commands, runtime code downloads and asset licensing. Each version requires a separate review.": "清单覆盖 wheel 内容、依赖、命令、运行时下载代码及素材授权;每个版本都需单独审核。", "The commentary model is turned off.": "即时评论模型已关闭。", "The companion model is unavailable.": "陪玩模型当前不可用。", "The configured Work Provider passed its startup registration boundary.": "已配置的工作服务提供方通过了启动注册。", @@ -1126,12 +1205,16 @@ "These controls apply immediately and do not select the synthesis or recognition provider.": "这些控件立即生效,但不负责选择语音合成或识别服务。", "These settings apply to the application. Creating or changing a role does not require new artwork or a new voice.": "这些设置作用于整个应用。创建或更换角色无需重新配置形象或声音。", "This Kurisu setting is inactive for the current replies. It applies only when Kurisu is active and replies are Japanese.": "这项红莉栖设置当前不适用。仅在启用红莉栖且使用日语回复时生效。", + "This artifact is already installed": "已安装此产物", "This capability is not part of the current built-in scene lifecycle.": "此能力不在当前内置场景的生命周期中。", + "This catalog has no access details. Review the verified wheel disclosure before confirming installation.": "此目录未提供访问详情。确认安装前,请查看已校验 wheel 的原生披露。", "This character uses advanced fields; edit its TOML file on disk.": "此角色使用高级字段,请在磁盘上编辑其 TOML 文件。", "This chat belongs to {character}.": "此会话属于 {character}。", "This chat is bound to {name}": "此对话属于 {name}", "This companion type does not support this ability.": "当前陪玩类型不支持此功能。", "This draft has changed. Refresh preview to test the latest edits.": "草稿已修改。请刷新预览,再测试最新设置。", + "This exact extension artifact is blocked for security reasons.": "此确切插件产物已因安全原因被封禁。", + "This exact extension artifact is blocked for security reasons. Stop or restart the running backend.": "此确切插件产物已因安全原因被封禁。请停止或重启正在运行的后端。", "This expression is missing from the model.": "模型中缺少此表情。", "This game starts in text capture mode. Companion behavior is not configured yet.": "这款游戏将以取文模式启动,陪玩行为尚未配置。", "This is a default Draft chat": "这是默认的草稿对话", @@ -1145,6 +1228,7 @@ "This text may contain Kurisu’s name. Review and edit the visible text before saving your new role.": "此文本可能包含红莉栖的名字。保存新角色前,请检查并编辑显示的文本。", "This theme is not in the currently loaded package. Restart the backend to apply the update.": "当前加载的包中没有此主题,重启后端以应用更新。", "This theme is waiting for its package to be applied.": "此主题正在等待包更改生效。", + "This version is no longer in the catalog. Refresh or choose another version.": "该版本已不在目录中,请刷新或选择其他版本。", "Translated subtitles": "翻译字幕", "Translation": "译文", "Translation and user-facing output transforms": "翻译与面向用户的输出转换", @@ -1169,6 +1253,7 @@ "Untitled chat": "未命名对话", "Untitled project": "未命名项目", "Unverified": "未验证", + "Update plugin…": "更新插件…", "Updates every 3s": "每 3 秒更新", "Updates sentence splitting, cache keys, and embedded reference selection": "更新分句、缓存键和内置参考音频选择", "Updating": "正在更新", @@ -1209,6 +1294,7 @@ "Video package does not match; using the existing static path.": "视频包不匹配,沿用原有静态路径。", "Video with static heads": "视频与静态入口帧", "View file": "查看文件", + "View plugin details": "查看插件详情", "View status reason": "查看状态原因", "View {title}": "查看 {title}", "Vision Context": "视觉上下文", @@ -1253,8 +1339,13 @@ "Wallpaper visual context": "壁纸视觉上下文", "Wallpaper-originated conversation turns reuse the shared Chat delegation path.": "来自壁纸的对话轮次沿用共享的对话委派通路。", "Wallpaper-originated conversation turns reuse the shared Host-governed AUIP interaction path.": "来自壁纸的对话轮次沿用由宿主管理的共享 AUIP 交互通路。", + "Wheel download progress": "Wheel 下载进度", + "Wheel download size": "Wheel 下载体积", "When I ask": "提问时读取", + "When changes apply": "生效方式", "Will be cleared after saving": "保存后清除", + "Withdrawn by the maintainer": "维护者已撤回", + "Withdrawn for copyright reasons": "因版权原因下架", "Work": "工作执行", "Work & application roles": "工作与应用职责", "Work Agents": "工作智能体", @@ -1295,6 +1386,7 @@ "bilingual": "双语", "capabilities": "项能力", "capability": "项能力", + "catalog dependencies are unsupported": "不支持此目录依赖", "chat": "对话", "chats": "对话", "connection default": "连接默认模型", @@ -1304,6 +1396,9 @@ "full screen": "全屏", "full_screen": "全屏", "idle": "空闲", + "incompatible Host API": "Host API 不兼容", + "incompatible Python version": "Python 版本不兼容", + "incompatible catalog SDK": "目录 SDK 不兼容", "inherited": "项继承", "keeps game focus clean": "避免干扰游戏焦点", "last": "上次", @@ -1338,6 +1433,10 @@ "stopping": "停止中", "to open, rename, or delete this chat.": "之后才能打开、重命名或删除此会话。", "translated": "译文", + "unsupported contribution contract": "不支持该插件类型或运行契约", + "unsupported delivery or runtime": "不支持该交付方式或运行时", + "unsupported environment or delivery requirement": "不支持此运行环境或交付要求", + "unsupported platform": "不支持当前平台", "version unknown": "版本未知", "voice lane active": "语音通道活动中", "watching": "持续观察", diff --git a/electron/src/renderer/styles/index.css b/electron/src/renderer/styles/index.css index 4da09765..a39daa97 100644 --- a/electron/src/renderer/styles/index.css +++ b/electron/src/renderer/styles/index.css @@ -2604,3 +2604,25 @@ input[type="range"]::-webkit-slider-thumb:hover { background: var(--focus-fill); /* Extension surfaces are the same opaque token surfaces the Host validates. */ html[data-theme-extension] .capability-scene-item { background: var(--surface); } +html[data-theme-extension] .settings-inline-action[data-tone='danger']:is(:hover, :focus-visible):not(:disabled) { + color: var(--danger); + background: var(--danger-bg) !important; +} + +/* Catalog shares the existing plugin manager and theme tokens. Third-party copy is text only. */ +.extension-settings [hidden] { display: none !important; } +.extension-catalog-toolbar, .extension-download-progress { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.extension-download-progress { padding: 12px; border: 1px solid var(--card-border); border-radius: var(--radius-container); } +.extension-download-progress strong, .extension-download-progress > div { flex-basis: 100%; overflow-wrap: anywhere; } +.extension-download-progress progress { flex: 1; min-width: 100px; accent-color: var(--text); } +.extension-catalog-filters { display: flex; gap: 12px; flex-wrap: wrap; } +.extension-catalog-filters label { flex: 1; min-width: min(180px, 100%); } +.extension-catalog-results { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(270px, 100%), 1fr)); gap: 12px; } +.extension-catalog-copy { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; font: inherit; } +.extension-catalog-facts { display: grid; grid-template-columns: minmax(90px, 1fr) minmax(0, 3fr); gap: 7px 16px; font-size: var(--font-small); } +.extension-catalog-facts dt { color: var(--muted); } +.extension-catalog-facts dd { margin: 0; overflow-wrap: anywhere; } +.extension-catalog-update { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +@container settings-main (max-width: 560px) { .extension-catalog-facts { grid-template-columns: 1fr; } .extension-catalog-facts dd { margin-bottom: 8px; } } + +.extension-catalog .settings-card-title, .extension-catalog .settings-status-pill { min-width: 0; max-width: 100%; overflow-wrap: anywhere; white-space: normal; } diff --git a/electron/src/shared/configCatalog.generated.ts b/electron/src/shared/configCatalog.generated.ts index fff0f31d..feadbcf3 100644 --- a/electron/src/shared/configCatalog.generated.ts +++ b/electron/src/shared/configCatalog.generated.ts @@ -41,6 +41,39 @@ export const catalogGroups: CatalogGroup[] = [ } } }, + { + "id": "desktop_extensions", + "title": { + "en-US": "Extension downloads", + "zh-CN": "插件下载" + }, + "description": { + "en-US": "Only wheel downloads use this mirror. The index always comes from the official repository.", + "zh-CN": "镜像只用于下载 wheel,目录索引始终来自官方仓库。" + }, + "desktop": true, + "apply": "frontend", + "config": { + "AMADEUS_EXTENSION_ARTIFACT_MIRROR": { + "type": "url", + "title": { + "en-US": "Artifact mirror base URL", + "zh-CN": "产物镜像根地址" + }, + "description": { + "en-US": "Optional HTTPS directory serving /. Empty uses the original artifact URL.", + "zh-CN": "可选 HTTPS 目录,按 / 提供文件。留空使用原始下载地址。" + }, + "default": "", + "scope": "desktop", + "schemes": [ + "https" + ], + "allow_empty": true, + "max_length": 2048 + } + } + }, { "id": "desktop_interface", "title": { diff --git a/electron/src/shared/extensionCatalog.ts b/electron/src/shared/extensionCatalog.ts new file mode 100644 index 00000000..de94d46e --- /dev/null +++ b/electron/src/shared/extensionCatalog.ts @@ -0,0 +1,17 @@ +export type CatalogEntry = { + id: string; version: string; sha256: string; url: string; size: number + display: Record; compatibility: Record + publisher: Record; review: Record + withdrawal: null | { reason: 'maintainer' | 'copyright'; evidence: string } + incompatible: string[]; blockedReason?: string +} +export type CatalogIndex = { + format: 'amadeus.catalog/v1'; sequence: number; entries: CatalogEntry[] + security: Array<{ id: string; version: string; sha256: string; status: 'blocked' | 'allowed'; sequence: number; reason: string; evidence: string }> +} +export type CatalogRequest = { action: 'get' | 'status' | 'refresh' | 'recover' | 'cancel' } + | { action: 'install'; id: string; version: string } + | { action: 'homepage' | 'repository'; id: string; version: string } +export type CatalogView = { installed?: Array<{ id: string; version: string; digest: string; catalogState: 'matched' | 'unlisted' | 'unavailable'; review?: Record; withdrawal?: CatalogEntry['withdrawal']; blocked: boolean }>; entries: CatalogEntry[]; sequence?: number; checkedAt?: string; cached: boolean; error?: string; downloading?: CatalogProgress } +export type CatalogProgress = { id: string; version: string; received: number; total: number; stage: 'checking' | 'downloading' | 'verifying' | 'confirming' | 'preparing'; cancelRequested?: boolean } +export type CatalogReply = { ok: boolean; error?: string; cancelled?: boolean; catalog?: CatalogView; settings?: Record; progress?: CatalogProgress } diff --git a/electron/tests/desktopSettingsDurability.test.mjs b/electron/tests/desktopSettingsDurability.test.mjs index 05aa2b70..98f6a50f 100644 --- a/electron/tests/desktopSettingsDurability.test.mjs +++ b/electron/tests/desktopSettingsDurability.test.mjs @@ -109,3 +109,85 @@ test('legacy manual MCP bindings survive explicit encrypted environment migratio assert.equal(registry.connections[0].command, 'python') assert.ok(!JSON.stringify(restarted.snapshot({})).includes('synthetic-explicit-token')) }) + +const { securitySnapshot } = require('./extensionSecurity.js') +const identity = { id: 'org.example.demo', version: '0.1.0', digest: 'a'.repeat(64) } +const facts = (sequence, blocked = true) => securitySnapshot(sequence, [{ ...identity, blocked, sequence }], '2026-10-21T00:00:00Z') +function restart(file) { return new exports.DesktopSettingsStore(file, path.join(path.dirname(file), '.env')) } + +for (const damaged of ['primary', 'backup', 'missing-primary']) test(`security survives ${damaged} loss and ordinary save`, t => { + const { file, store } = savedStore(t) + store.commitExtensionSecurity(facts(5)) + if (damaged === 'missing-primary') fs.unlinkSync(file) + else zeroFill(damaged === 'primary' ? file : file + '.bak') + const next = restart(file) + assert.match(next.extensionSecurityBlock(identity), /blocked/) + next.update({}, { values: { WORK_EXECUTION_PROVIDER: 'pi' } }) + assert.equal(restart(file).extensionSecurityState().sequence, 5) + assert.match(restart(file).extensionSecurityBlock(identity), /blocked/) +}) +for (const damage of ['nul', 'truncated', 'field']) test(`both copies ${damage}: durable fail-closed until explicit official recovery`, t => { + const { root, file, store } = savedStore(t) + store.commitExtensionSecurity(facts(5)) + for (const target of [file, file + '.bak']) { + if (damage === 'nul') zeroFill(target) + else if (damage === 'truncated') fs.writeFileSync(target, '{"version":2,"extensionSecurity":') + else { const raw = JSON.parse(fs.readFileSync(target)); raw.extensionSecurity.decisions = []; fs.writeFileSync(target, JSON.stringify(raw)) } + } + let next = restart(file) + assert.equal(next.extensionSecurityState().status, 'unavailable') + assert.match(next.extensionSecurityBlock(), /damaged/) + assert.equal(fs.readdirSync(root).filter(n => n.includes('.corrupt-')).length, 2) + next.update({}, { values: { WORK_EXECUTION_PROVIDER: 'pi' } }) + next.markApplied({}, {}) + next = restart(file) + assert.equal(next.extensionSecurityState().status, 'unavailable') + assert.throws(() => next.commitExtensionSecurity(facts(6)), /explicit/) + next.commitExtensionSecurity(facts(6), true) + assert.match(restart(file).extensionSecurityBlock(identity), /blocked/) + next.commitExtensionSecurity(facts(7, false)) + assert.equal(restart(file).extensionSecurityBlock(identity), '') +}) +test('higher complete backup survives failure between backup and primary commit', t => { + const { file, store } = savedStore(t) + store.commitExtensionSecurity(facts(3, false)) + const rename = fs.renameSync + fs.renameSync = (source, target) => { if (target === file) throw new Error('injected power loss'); return rename(source, target) } + try { assert.throws(() => store.commitExtensionSecurity(facts(4)), /power loss/) } finally { fs.renameSync = rename } + assert.equal(JSON.parse(fs.readFileSync(file)).extensionSecurity.sequence, 3) + assert.equal(JSON.parse(fs.readFileSync(file + '.bak')).extensionSecurity.sequence, 4) + assert.match(restart(file).extensionSecurityBlock(identity), /blocked/) + restart(file).update({}, { values: { WORK_EXECUTION_PROVIDER: 'pi' } }) + assert.equal(JSON.parse(fs.readFileSync(file)).extensionSecurity.sequence, 4) +}) +test('same-sequence conflict stays unavailable across interrupted fault persistence and restart', t => { + const { file, store } = savedStore(t) + store.commitExtensionSecurity(facts(4)) + const backup = JSON.parse(fs.readFileSync(file + '.bak')) + backup.extensionSecurity = facts(4, false) + fs.writeFileSync(file + '.bak', JSON.stringify(backup)) + const rename = fs.renameSync + fs.renameSync = (source, target) => { if (target === file) throw new Error('injected power loss'); return rename(source, target) } + try { assert.throws(() => restart(file).extensionSecurityState(), /power loss/) } finally { fs.renameSync = rename } + const next = restart(file) + assert.equal(next.extensionSecurityState().status, 'unavailable') + next.update({}, { values: { WORK_EXECUTION_PROVIDER: 'pi' } }) + assert.equal(restart(file).extensionSecurityState().status, 'unavailable') + assert.throws(() => next.commitExtensionSecurity(facts(3), true), /rollback/) + next.commitExtensionSecurity(facts(4), true) + assert.match(restart(file).extensionSecurityBlock(identity), /blocked/) +}) +test('legacy migration, replay, omission, same-sequence change and explicit higher unban', t => { + const { file, store } = savedStore(t) + assert.equal(store.extensionSecurityState(), undefined) + store.commitExtensionSecurity(securitySnapshot(0, [], '2026-10-21T00:00:00Z')) + store.commitExtensionSecurity(facts(3)) + assert.throws(() => store.commitExtensionSecurity(facts(2)), /rollback/) + assert.throws(() => store.commitExtensionSecurity(facts(3, false)), /Conflicting/) + assert.throws(() => store.commitExtensionSecurity(securitySnapshot(4, [], '2026-10-21T00:00:00Z')), /disappear/) + store.commitExtensionSecurity(securitySnapshot(4, [{ ...identity, blocked: true, sequence: 3 }], '2026-10-21T00:00:00Z')) + assert.match(restart(file).extensionSecurityBlock(identity), /blocked/) + store.commitExtensionSecurity(facts(6, false)) + assert.equal(restart(file).extensionSecurityBlock(identity), '') + assert.equal(store.backendEnvironment({}).DEEPSEEK_API_KEY, 'sk-test') +}) diff --git a/electron/tests/developmentServer.test.mjs b/electron/tests/developmentServer.test.mjs index ec080b79..46113cb3 100644 --- a/electron/tests/developmentServer.test.mjs +++ b/electron/tests/developmentServer.test.mjs @@ -26,6 +26,15 @@ function fixture(t, dotenv) { return root } +async function availablePort() { + const reservation = net.createServer() + reservation.listen(0, 'localhost') + await once(reservation, 'listening') + const port = reservation.address().port + await new Promise(resolve => reservation.close(resolve)) + return port +} + test('missing port defaults to 5173; only the root .env supplies file configuration', t => { const root = fixture(t) fs.writeFileSync(path.join(root, 'electron', '.env'), 'AMADEUS_DEV_PORT=15173') @@ -137,9 +146,10 @@ test('actual Electron auxiliary windows and trust checks share the port; product } }) -for (const [label, dotenv, port] of [['default', undefined, 5173], ['custom root .env', 'AMADEUS_DEV_PORT=15173', 15173]]) { +for (const label of ['default', 'custom root .env']) { test(`real Vite serves the ${label} port before the child starts, then closes on child exit`, { timeout: 20000 }, async t => { - const root = fixture(t, dotenv) + const port = label === 'default' ? 5173 : await availablePort() + const root = fixture(t, label === 'default' ? undefined : `AMADEUS_DEV_PORT=${port}`) let childPort const session = await startDevelopment({ projectRoot: root, environment: { ...process.env, AMADEUS_DEV_PORT: undefined }, withElectron: true, @@ -197,7 +207,7 @@ test('EACCES from the listening socket closes startup and retains Windows diagno }) test('Electron spawn failure releases the Vite listener', { timeout: 20000 }, async t => { - const root = fixture(t, 'AMADEUS_DEV_PORT=15173') + const root = fixture(t, `AMADEUS_DEV_PORT=${await availablePort()}`) await assert.rejects(startDevelopment({ projectRoot: root, environment: {}, withElectron: true, startElectron() { return spawn(path.join(root, 'missing-electron'), [], { windowsHide: true }) }, }), /ENOENT/) @@ -207,7 +217,7 @@ test('Electron spawn failure releases the Vite listener', { timeout: 20000 }, as }) test('explicit shutdown requests child-owned cleanup before releasing the listening port', { timeout: 20000 }, async t => { - const root = fixture(t, 'AMADEUS_DEV_PORT=15173') + const root = fixture(t, `AMADEUS_DEV_PORT=${await availablePort()}`) const cleaned = path.join(root, 'child-cleaned') const session = await startDevelopment({ projectRoot: root, environment: {}, withElectron: true, startElectron() { return spawn(process.execPath, ['-e', `process.on('message', message => { @@ -225,7 +235,7 @@ test('explicit shutdown requests child-owned cleanup before releasing the listen test('real Windows Electron is visible on its first show through the development launcher', { skip: process.platform !== 'win32', timeout: 30000, }, async t => { - const root = fixture(t, 'AMADEUS_DEV_PORT=15173') + const root = fixture(t, `AMADEUS_DEV_PORT=${await availablePort()}`) fs.writeFileSync(path.join(root, 'electron', 'package.json'), JSON.stringify({ type: 'module', main: 'main.cjs' })) fs.writeFileSync(path.join(root, 'electron', 'main.cjs'), ` const { app, BrowserWindow } = require('electron') diff --git a/electron/tests/extensionAuthoring.smoke.mjs b/electron/tests/extensionAuthoring.smoke.mjs new file mode 100644 index 00000000..2856012d --- /dev/null +++ b/electron/tests/extensionAuthoring.smoke.mjs @@ -0,0 +1,77 @@ +/** Real installer/store lifecycle for four author references. Synthetic confirmation/encryption. */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { app } from 'electron' +import { createHash } from 'node:crypto' +import { DesktopSettingsStore } from '../dist/main/desktopSettings.js' +import { ExtensionInstaller, ExtensionPackageHelper } from '../dist/main/extensions.js' +const root = fileURLToPath(new URL('../../', import.meta.url)) +const base = path.resolve(process.env.AMADEUS_AUTHOR_JOURNEY_ROOT) +const relative = path.relative(path.join(root, 'build'), base) +assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative)) +const python = process.env.AMADEUS_TEST_PYTHON +assert.ok(python && fs.existsSync(python)) +const environment = { ...process.env, PIP_NO_INDEX: '1', PIP_FIND_LINKS: path.join(root, 'vendor/extension-sdk'), PIP_CONFIG_FILE: process.platform === 'win32' ? 'NUL' : '/dev/null' } +const helper = new ExtensionPackageHelper(root, () => python, environment) +const store = new DesktopSettingsStore(path.join(base, 'settings.json'), path.join(base, '.env'), process.platform, helper.validateRecord) +const installer = new ExtensionInstaller(path.join(base, 'extensions'), root, store, helper, () => python, environment, undefined, async () => true) +const definitions = [ + ['tone', 'com.example.tone-tts', 'amadeus_example_tone_tts'], + ['notes', 'com.example.notes-mcp', 'amadeus_example_notes_mcp'], + ['handoff', 'com.example.handoff-skill', 'amadeus_example_handoff_skill'], + ['theme', 'com.example.desktop-theme', 'amadeus_example_theme'], +] +const wheel = (name, version) => path.join(base, 'wheels', `${name}-${version}-py3-none-any.whl`) +const phase = process.argv[2] +app.setPath('userData', path.join(base, 'profile')) +app.whenReady().then(async () => { +try { +await installer.refreshValidation() +if (phase === '--prepare' || phase === '--update') { + const version = phase === '--prepare' ? '0.1.0' : '0.2.0' + for (const [name, id, distribution] of definitions) { + await installer.install(wheel(distribution, version)) + if (phase === '--prepare') await installer.update({ id, enabled: true, ...(['notes', 'handoff'].includes(name) ? { providers: ['pi'] } : {}) }) + } + const snapshots = JSON.parse(await installer.launchSnapshot()) + for (const [name, id] of definitions) { + const installed = store.extensionRecords().find(record => record.id === id) + assert.equal(installed.version, version); assert.equal(installed.enabled, true) + let record = snapshots.find(record => record.id === id) + if (name === 'theme') { + // Pure frontend packages intentionally have no backend startup snapshot. + assert.equal(record, undefined); assert.equal(installed.sitePackages, '') + record = { id, version, digest: installed.digest, manifest: installed.manifest, enabled: true, + package_path: path.join(base, 'extensions', installed.installId, 'package', installed.packageRoot), + data_path: path.join(base, 'extension-data', createHash('sha256').update(id).digest('hex')), config: {}, secrets: {}, providers: [] } + } + assert.ok(!record.startup_error) + fs.writeFileSync(path.join(base, `${name}-${version}.json`), JSON.stringify(record)) + if (name === 'handoff') fs.writeFileSync(path.join(base, `skill-${version}.json`), JSON.stringify(record)) + } + console.log(JSON.stringify({ ok: true, phase, packages: definitions.length, nativeDialogs: false })) +} else if (phase === '--finish') { + await installer.cleanupStoppedBackend() + for (const [name, id, distribution] of definitions) { + const record = JSON.parse(fs.readFileSync(path.join(base, `${name}-0.2.0.json`), 'utf8')) + // The real store owns retained data; the author CLI uses its own disposable data instead. + const dataPath = record.data_path + fs.mkdirSync(dataPath, { recursive: true }); fs.writeFileSync(path.join(dataPath, 'retained.txt'), 'synthetic retention') + await installer.update({ id, enabled: false }) + await installer.remove(id); await installer.cleanupStoppedBackend() + assert.ok(fs.existsSync(path.join(dataPath, 'retained.txt'))) + await installer.install(wheel(distribution, '0.2.0')) + const reinstalled = store.extensionRecords().find(record => record.id === id) + assert.equal(reinstalled.enabled, false); assert.deepEqual(reinstalled.providers, []) + await installer.remove(id, { deleteData: true }); await installer.cleanupStoppedBackend() + assert.ok(!fs.existsSync(dataPath)) + } + assert.deepEqual(store.extensionRecords(), []) + console.log(JSON.stringify({ ok: true, phase, retainedAndExplicitlyDeleted: true, packages: definitions.length })) +} else throw new Error('Unknown author journey phase') + +app.exit(0) +} catch (error) { console.error(error); app.exit(1) } +}) diff --git a/electron/tests/extensionCatalog.smoke.mjs b/electron/tests/extensionCatalog.smoke.mjs new file mode 100644 index 00000000..83757056 --- /dev/null +++ b/electron/tests/extensionCatalog.smoke.mjs @@ -0,0 +1,183 @@ +/** Real Electron net + real wheels + real installer/settings; explicit fixture authority only. */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import https from 'node:https' +import http from 'node:http' +import tcp from 'node:net' +import { X509Certificate, createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { app, session } from 'electron' +import { DesktopSettingsStore } from '../dist/main/desktopSettings.js' +import { ExtensionInstaller, ExtensionPackageHelper } from '../dist/main/extensions.js' +import { ExtensionCatalog, OFFICIAL_EXTENSION_INDEX } from '../dist/main/extensionCatalog.js' +import { downloadExtension, MAX_INDEX_BYTES } from '../dist/main/extensionDownloads.js' +const root = fileURLToPath(new URL('../../', import.meta.url)) +const base = path.resolve(process.env.AMADEUS_CATALOG_JOURNEY_ROOT) +const relative = path.relative(path.join(root, 'build/extension-catalog-journey'), base) +assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative)) +const python = process.env.AMADEUS_TEST_PYTHON +const fixture = JSON.parse(fs.readFileSync(path.join(base, 'fixture.json'))) +let index = structuredClone(fixture.index), badMirror = false, banOnConfirm = false +const report = process.argv.includes('--recover') ? JSON.parse(fs.readFileSync(path.join(base, 'report.json'))) : { ok: false, checks: {}, artifacts: fixture.index.entries.map(e => ({ id:e.id,version:e.version,sha256:e.sha256 })), fixtureAuthority: true, nativeDialogs: false, osTrustModified: false } +const checked = (name, value = true) => { assert.ok(value, name); report.checks[name] = true } +const requests = [] +let origin, catalog +const environment = { ...process.env, PIP_CONFIG_FILE: process.platform === 'win32' ? 'NUL' : '/dev/null' } +const helper = new ExtensionPackageHelper(root, () => python, environment) +const settingsFile = path.join(base, 'settings.json') +const store = new DesktopSettingsStore(settingsFile, path.join(base, '.env'), process.platform, helper.validateRecord) +const installer = new ExtensionInstaller(path.join(base, 'extensions'), root, store, helper, () => python, environment, undefined, async review => { + if (banOnConfirm) { + index.sequence++ + index.security = [{ id:review.manifest.id,version:review.manifest.version,sha256:review.digest,status:'blocked',sequence:index.sequence,reason:'security',evidence:'https://fixture.test/ban' }] + await catalog.refresh() + } + return true +}) +const server = https.createServer({ key:fs.readFileSync(path.join(base,'key.pem')),cert:fs.readFileSync(path.join(base,'cert.pem')) }, (request,response) => { + requests.push({ url:request.url,cookie:request.headers.cookie,authorization:request.headers.authorization }) + const url = new URL(request.url, origin) + if (url.pathname === '/index.json') { response.end(JSON.stringify(index)); return } + if (url.pathname === '/redirect') { response.writeHead(302,{Location:origin+'/bytes'});response.end();return } + if (url.pathname === '/downgrade') { response.writeHead(302,{Location:'http://localhost/unsafe'});response.end();return } + if (url.pathname === '/auth') { response.writeHead(401,{'WWW-Authenticate':'Basic realm="fixture"'});response.end();return } + if (url.pathname === '/oversize') { response.end(Buffer.alloc(100));return } + if (url.pathname === '/slow') { response.write(Buffer.alloc(10));setTimeout(()=>response.end(Buffer.alloc(10)),1500);return } + if (url.pathname === '/bytes') { response.end('fixture');return } + const wheel = fixture.wheels[decodeURIComponent(url.pathname.split('/').at(-1))] + if (wheel) { const bytes=fs.readFileSync(wheel);response.end(badMirror && url.pathname.startsWith('/mirror/') ? Buffer.alloc(bytes.length,1) : bytes);return } + response.writeHead(404);response.end() +}) +let tunnels = 0 +const proxy = http.createServer() +proxy.on('connect',(request,socket,head)=> { + const target = new URL('https://'+request.url) + if (target.hostname !== 'localhost') { socket.destroy();return } + tunnels++ + const upstream=tcp.connect(Number(target.port),'127.0.0.1',()=>{socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);socket.pipe(upstream);upstream.pipe(socket)}) + upstream.on('error',()=>socket.destroy());socket.on('error',()=>upstream.destroy()) +}) +app.setPath('userData',path.join(base,'profile')) +process.on('uncaughtException', error => { console.error(error); app.exit(1) }) +app.whenReady().then(async()=> { + try { + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));origin='https://localhost:'+server.address().port + const isolated=session.fromPartition('amadeus-extension-downloads',{cache:false}) + const download = async(url,target,limit,signal,progress)=> { + const actual = url === OFFICIAL_EXTENSION_INDEX ? origin+'/index.json' : url.startsWith('https://artifact.test/') ? origin+'/wheels/'+new URL(url).pathname.split('/').at(-1) : url + await downloadExtension(actual,target,limit,signal,progress) + } + catalog = new ExtensionCatalog(path.join(base,'catalog'),store,helper,installer,()=>String(store.snapshot({}).values.AMADEUS_EXTENSION_ARTIFACT_MIRROR||''),()=>{},download) + await installer.refreshValidation() + const fingerprint=new X509Certificate(fs.readFileSync(path.join(base,'cert.pem'))).fingerprint256 + if (!process.argv.includes('--recover')) { + const publicUrl='https://raw.githubusercontent.com/Code-Amadeus/Amadeus-Extensions/bf1a848448a19ed20eca029e8cc3991563e42e95/LICENSE' + checked('systemProxyInherited',(await isolated.resolveProxy(publicUrl)) === (await session.defaultSession.resolveProxy(publicUrl))) + await downloadExtension(publicUrl,path.join(base,'public-license.txt'),MAX_INDEX_BYTES,new AbortController().signal) + checked('publicHttpsSystemCertificateValidation') + await assert.rejects(downloadExtension(origin.replace('localhost','127.0.0.1')+'/bytes',path.join(base,'invalid-cert.bin'),100,new AbortController().signal)) + checked('invalidCertificateRejected',!fs.existsSync(path.join(base,'invalid-cert.bin'))) + } + // Test-only trust of exactly one disposable certificate. No OS roots or production options change. + isolated.setCertificateVerifyProc((request,callback)=> { + const matches = request.hostname === 'localhost' && new X509Certificate(request.certificate.data).fingerprint256 === fingerprint + callback(matches ? 0 : -3) + }) + await isolated.closeAllConnections() + if (process.argv.includes('--recover')) { + checked('faultSurvivesNewElectronProcess',store.extensionSecurityState().status==='unavailable') + store.update({}, { values:{AMADEUS_EXTENSION_ARTIFACT_MIRROR:origin+'/mirror'} }) + checked('faultSurvivesOrdinarySave',store.extensionSecurityState().status==='unavailable') + index=JSON.parse(fs.readFileSync(path.join(base,'recovery-index.json'))) + await catalog.refresh(true) + checked('explicitOfficialRecovery',store.extensionSecurityState().status==='ready') + for(const record of store.storedExtensionRecords()) { + await installer.update({id:record.id,enabled:false});await installer.remove(record.id) + } + await installer.cleanupStoppedBackend() + report.ok=true + } else { + await isolated.cookies.set({url:origin,name:'fixture-session',value:'private-fixture',secure:true}) + await session.defaultSession.cookies.set({url:origin,name:'app-session',value:'private-fixture',secure:true}) + await downloadExtension(origin+'/redirect',path.join(base,'redirect.bin'),100,new AbortController().signal) + checked('httpsRedirect') + await assert.rejects(downloadExtension(origin+'/downgrade',path.join(base,'downgrade.bin'),100,new AbortController().signal)) + checked('downgradeRejected') + await assert.rejects(downloadExtension(origin+'/auth',path.join(base,'auth.bin'),100,new AbortController().signal)) + checked('originAuthenticationNotUsed') + await assert.rejects(downloadExtension(origin+'/oversize',path.join(base,'oversize.bin'),20,new AbortController().signal)) + checked('oversizeCleaned',!fs.existsSync(path.join(base,'oversize.bin'))) + const abort=new AbortController();setTimeout(()=>abort.abort(),100) + await assert.rejects(downloadExtension(origin+'/slow',path.join(base,'cancelled.bin'),100,abort.signal)) + checked('cancelledCleaned',!fs.existsSync(path.join(base,'cancelled.bin'))) + await new Promise(resolve=>proxy.listen(0,'127.0.0.1',resolve)) + await isolated.setProxy({proxyRules:'http=127.0.0.1:'+proxy.address().port+';https=127.0.0.1:'+proxy.address().port,proxyBypassRules:'<-loopback>'}) + await isolated.closeAllConnections() + await downloadExtension(origin+'/bytes',path.join(base,'proxy.bin'),100,new AbortController().signal) + checked('chromiumHttpsConnectProxy',tunnels>0) + await isolated.setProxy({mode:'system'});await isolated.closeAllConnections() + const notes=fixture.index.entries.find(e=>e.id.endsWith('notes-mcp')) + const theme=fixture.index.entries.find(e=>e.id.endsWith('desktop-theme')) + const tone=fixture.index.entries.find(e=>e.id.endsWith('tone-tts')) + const skill=fixture.index.entries.find(e=>e.id.endsWith('handoff-skill')) + await catalog.install(notes.id,notes.version) + checked('originalDownloadAndRealMcpInstall') + store.update({}, {values:{AMADEUS_EXTENSION_ARTIFACT_MIRROR:origin+'/mirror'}}) + badMirror=true + await assert.rejects(catalog.install(theme.id,theme.version)) + checked('mirrorWrongBytesRejected',!store.extensionIsInstalled(theme.id)) + badMirror=false + await catalog.install(theme.id,theme.version) + checked('mirrorRealThemeInstall') + banOnConfirm=true + await assert.rejects(catalog.install(tone.id,tone.version),/blocked/) + banOnConfirm=false + checked('confirmationBanBeforePip',!store.extensionIsInstalled(tone.id)) + await assert.rejects(installer.install(fixture.wheels[new URL(tone.url).pathname.split('/').at(-1)]),/blocked/) + checked('sameBannedLocalWheelRejected') + index.sequence++;index.security[0]={...index.security[0],status:'allowed',sequence:index.sequence,reason:'security-resolved'} + await catalog.refresh();await catalog.install(tone.id,tone.version) + checked('explicitHigherSequenceUnban') + await catalog.install(skill.id,skill.version) + for(const entry of fixture.index.entries) await installer.update({id:entry.id,enabled:true,...([notes.id,skill.id].includes(entry.id)?{providers:['pi']}:{})}) + const snapshots=JSON.parse(await installer.launchSnapshot()) + for(const snapshot of snapshots) { assert.ok(!snapshot.startup_error);fs.writeFileSync(path.join(base,snapshot.id+'.json'),JSON.stringify(snapshot)) } + for (const [name, entry, kind] of [['tone',tone,'tts'],['notes',notes,'mcp'],['handoff',skill,'skill']]) { + const recordFile=path.join(base,entry.id+'.json') + const output=path.join(base,name+'-runtime.json') + const args=['-I',path.join(root,'tools/check_extension_contract.py'),'--execute','--kind',kind,'--wheel',fixture.wheels[new URL(entry.url).pathname.split('/').at(-1)],'--record',recordFile,'--scenario',path.join(root,'examples/extensions/contract-scenarios',name+'.json'),'--output',output] + await new Promise((resolve,reject)=> { + const child=spawn(python,args,{cwd:root,env:environment,windowsHide:true,stdio:['ignore','pipe','pipe']}) + let outputText='' + child.stdout.on('data',chunk=>{outputText+=chunk});child.stderr.on('data',()=>{}) + child.on('error',reject);child.on('close',code=>code===0?resolve():reject(new Error(name+' contract failed: '+outputText))) + }) + checked(name+'RealRuntimeContract') + } + checked('fourRealReferencesEnabled',store.extensionRecords().length===4) + checked('snapshotIdentitiesMatch',store.extensionRecords().every(r=>fixture.index.entries.some(e=>e.id===r.id&&e.version===r.version&&e.sha256===r.digest))) + const view=await catalog.view();checked('reviewDerivedFromInstalledTriple',view.installed.every(r=>r.catalogState==='matched')) + checked('requestCredentialsIsolated',requests.every(r=>!r.cookie&&!r.authorization)) + index.sequence++;index.security.push({id:notes.id,version:notes.version,sha256:notes.sha256,status:'blocked',sequence:index.sequence,reason:'security',evidence:'https://fixture.test/notes-ban'}) + await catalog.refresh() + fs.unlinkSync(path.join(base,'catalog/index.json')) + checked('cacheDeletionRetainsBan',Boolean(store.extensionSecurityBlock({id:notes.id,version:notes.version,digest:notes.sha256}))) + fs.writeFileSync(settingsFile,Buffer.alloc(fs.statSync(settingsFile).size)) + checked('nulPrimaryRecovered',store.extensionSecurityState().status==='ready') + store.update({}, {values:{AMADEUS_EXTENSION_ARTIFACT_MIRROR:origin+'/mirror'}}) + for(const file of [settingsFile,settingsFile+'.bak'])fs.writeFileSync(file,Buffer.alloc(fs.statSync(file).size)) + checked('doubleNulFailsClosed',store.extensionSecurityState().status==='unavailable') + await assert.rejects(installer.install(fixture.wheels[new URL(notes.url).pathname.split('/').at(-1)]),/damaged/) + checked('damagedStoreBlocksLocalInstallation') + fs.writeFileSync(path.join(base,'recovery-index.json'),JSON.stringify(index)) + checked('stagingClean',fs.readdirSync(path.join(base,'catalog/staging')).length===0) + } + fs.writeFileSync(path.join(base,'report.json'),JSON.stringify(report,null,2)+'\n') + console.log(JSON.stringify({ok:true,phase:process.argv.includes('--recover')?'recover':'prepare',checks:Object.keys(report.checks).length})) + await isolated.closeAllConnections();server.close();proxy.close();app.exit(0) + } catch(error) { + report.error=String(error);fs.writeFileSync(path.join(base,'report.json'),JSON.stringify(report,null,2)+'\n');console.error(error);app.exit(1) + } +}) diff --git a/electron/tests/extensionCatalog.test.mjs b/electron/tests/extensionCatalog.test.mjs new file mode 100644 index 00000000..acb27126 --- /dev/null +++ b/electron/tests/extensionCatalog.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { loadTypeScript } from './helpers/loadTypeScript.mjs' +const mocks = { electron: { safeStorage: { isEncryptionAvailable: () => true } } } +const { ExtensionCatalog, catalogRequest, OFFICIAL_EXTENSION_INDEX } = loadTypeScript(new URL('../src/main/extensionCatalog.ts', import.meta.url), mocks) +const { DesktopSettingsStore } = loadTypeScript(new URL('../src/main/desktopSettings.ts', import.meta.url), mocks) +const entry = () => ({ id: 'org.example.demo', version: '0.1.0', sha256: 'a'.repeat(64), url: 'https://source.example/demo-0.1.0-py3-none-any.whl', size: 5, display: { homepage: 'https://example.org/' }, compatibility: {}, publisher: {}, review: {}, withdrawal: null, incompatible: [] }) +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-catalog-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const store = new DesktopSettingsStore(path.join(root, 'settings.json'), path.join(root, '.env')) + const state = { index: { format: 'amadeus.catalog/v1', sequence: 1, entries: [entry()], security: [] }, mirror: '', networkError: false, requests: [], installed: [], confirm: async () => {} } + const helper = { call: async (_cmd, [file]) => { const catalog = JSON.parse(fs.readFileSync(file)); if (catalog.invalid) throw new Error('Invalid catalog'); return { ok: true, catalog } } } + const installer = { install: async (file, context) => { context.assertCurrent(); await state.confirm(); context.assertCurrent(); state.installed.push(context.identity); return { installed: true } } } + const download = async (url, target, limit, signal) => { + state.requests.push(url) + if (state.networkError) throw new Error('offline') + if (signal.aborted) throw new Error('cancelled') + fs.writeFileSync(target, url === OFFICIAL_EXTENSION_INDEX ? JSON.stringify(state.index) : 'wheel') + } + const catalog = new ExtensionCatalog(path.join(root, 'catalog'), store, helper, installer, () => state.mirror, () => {}, download) + return { root, store, catalog, state, reopen: () => new ExtensionCatalog(path.join(root, 'catalog'), store, helper, installer, () => state.mirror, () => {}, download) } +} +test('renderer may select identity/action only, never supply URL/digest/review/source', () => { + for (const field of ['url', 'sha256', 'digest', 'review', 'source', 'mirror']) assert.throws(() => catalogRequest({ action: 'install', id: 'p', version: '1.0.0', [field]: 'forged' })) + for (const version of ['1.0.0-beta.1', '1.0.0+build', '01.0.0', {}, null]) assert.throws(() => catalogRequest({ action: 'install', id: 'p', version })) + assert.deepEqual(catalogRequest({ action: 'install', id: 'p', version: '1.0.0' }), { action: 'install', id: 'p', version: '1.0.0' }) +}) +test('official lookup and mirror bytes keep identity, empty entry is not installable', async t => { + const f = fixture(t); f.state.mirror = 'https://mirror.example/files/' + await f.catalog.install('org.example.demo', '0.1.0') + assert.equal(f.state.requests[0], OFFICIAL_EXTENSION_INDEX) + assert.equal(f.state.requests[1], 'https://mirror.example/files/' + 'a'.repeat(64) + '/demo-0.1.0-py3-none-any.whl') + assert.equal(f.state.installed.length, 1) + await assert.rejects(f.catalog.install('org.example.demo', '9.0.0'), /Select a version/) +}) +test('cache deletion and offline refresh cannot clear persisted bans; future entries do not hide security', async t => { + const f = fixture(t) + f.state.index.entries[0].incompatible = ['future contract'] + f.state.index.security = [{ ...entry(), status: 'blocked', sequence: 1 }] + await f.catalog.refresh() + fs.unlinkSync(path.join(f.root, 'catalog/index.json')) + f.state.networkError = true + await assert.rejects(f.catalog.refresh(), /offline/) + assert.match(f.store.extensionSecurityBlock({ id: entry().id, version: entry().version, digest: entry().sha256 }), /blocked/) + assert.equal((await f.catalog.view()).entries.length, 1) +}) +test('withdrawal, incompatibility, and changes during confirmation reject without commit', async t => { + for (const reason of ['maintainer', 'copyright', 'future', 'confirmation']) { + const f = fixture(t) + if (reason === 'future') f.state.index.entries[0].incompatible = ['unsupported contract'] + else if (reason === 'confirmation') f.state.confirm = async () => { f.state.index.sequence++; f.state.index.entries[0].withdrawal = { reason: 'maintainer', evidence: 'https://example.org/' }; await f.catalog.refresh() } + else f.state.index.entries[0].withdrawal = { reason, evidence: 'https://example.org/' } + await assert.rejects(f.catalog.install(entry().id, entry().version)) + assert.equal(f.state.installed.length, 0) + assert.deepEqual(fs.readdirSync(path.join(f.root, 'catalog/staging')), []) + assert.equal(f.store.extensionSecurityBlock({ id: entry().id, version: entry().version, digest: entry().sha256 }), '') + } +}) +test('format/safety rejection and old sequence replay retain complete prior snapshot', async t => { + const f = fixture(t) + await f.catalog.refresh() + f.state.index.sequence = 0 + await assert.rejects(f.catalog.refresh(), /rollback/) + f.state.index.invalid = true + await assert.rejects(f.catalog.refresh(), /Invalid catalog/) + assert.equal(f.store.extensionSecurityState().sequence, 1) + assert.equal((await f.catalog.view()).sequence, 1) +}) +test('homepage goes through main HTTPS validation', async t => { + const f = fixture(t) + f.state.index.entries[0].display.homepage = 'javascript:alert(1)' + await f.catalog.refresh() + assert.throws(() => f.catalog.homepage(entry().id, entry().version), /HTTPS/) +}) + +test('a cached catalog displays offline but cannot authorize artifact installation', async t => { + const f = fixture(t) + await f.catalog.refresh() + f.state.networkError = true + const reopened = f.reopen() + assert.equal((await reopened.view()).cached, true) + await assert.rejects(reopened.install(entry().id, entry().version), /offline/) + assert.equal(f.state.installed.length, 0) +}) +test('cancel while confirmation is open prevents commit and removes staging', async t => { + const f = fixture(t) + f.state.confirm = async () => f.catalog.cancel() + await assert.rejects(f.catalog.install(entry().id, entry().version), /cancelled/) + assert.equal(f.state.installed.length, 0) + assert.deepEqual(fs.readdirSync(path.join(f.root, 'catalog/staging')), []) +}) + +test('production dispatch rejects forged links, exposes compact progress and reports native cancellation truthfully', async t => { + const f = fixture(t); const opened = [] + assert.equal((await f.catalog.dispatch({ action: 'install', id: entry().id, version: entry().version, url: 'https://forged.test/' }, {}, async url => opened.push(url))).ok, false) + await f.catalog.refresh() + f.state.index.entries[0].display.repository = 'https://user:pass@example.org/' + f.state.index.sequence++ + await f.catalog.refresh() + assert.equal((await f.catalog.dispatch({ action: 'repository', id: entry().id, version: entry().version }, {}, async url => opened.push(url))).ok, false) + assert.deepEqual(opened, []) + f.state.confirm = async () => { assert.equal(f.catalog.status().stage, 'downloading'); f.catalog.cancel() } + const result = await f.catalog.dispatch({ action: 'install', id: entry().id, version: entry().version }, {}, async () => {}) + assert.equal(result.cancelled, true) + const status = await f.catalog.dispatch({ action: 'status' }, {}, async () => {}) + assert.deepEqual(Object.keys(status).sort(), ['ok', 'progress']) + assert.equal(status.progress, undefined) +}) + +test('a preparation or persistence error is not misreported as cancellation', async t => { + const f = fixture(t) + f.state.confirm = async () => { f.catalog.cancel(); throw new Error('simulated persistence failure') } + const result = await f.catalog.dispatch({action:'install',id:entry().id,version:entry().version},{},async()=>{}) + assert.equal(result.ok, false) + assert.equal(result.cancelled, undefined) + assert.match(result.error, /persistence failure/) +}) diff --git a/electron/tests/extensionCatalogUI.test.mjs b/electron/tests/extensionCatalogUI.test.mjs new file mode 100644 index 00000000..ee351c24 --- /dev/null +++ b/electron/tests/extensionCatalogUI.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { loadTypeScript } from './helpers/loadTypeScript.mjs' +const model = loadTypeScript(new URL('../src/renderer/components/extensionCatalogModel.ts', import.meta.url)) +const entry = (version, more = {}) => ({ id: 'org.example.demo', version, sha256: version.repeat(16), size: 100, url: 'https://example.test/pkg.whl', display: { display_name: 'Example', description: 'Tools' }, compatibility: { contributions: [{ kind: 'theme', role: '', contract: 'amadeus.ui.theme/v0' }] }, publisher: {}, review: {}, withdrawal: null, incompatible: [], ...more }) +const installed = { id: 'org.example.demo', version: '1.2.0', digest: 'installed', pendingRemoval: false } +test('updates use exact three-integer ordering and only whole compatible, supplied, allowed packages', () => { + const entries = [entry('1.9.0'), entry('1.10.0'), entry('2.0.0', { incompatible: ['future'] }), entry('3.0.0', { blockedReason: 'blocked' }), entry('4.0.0', { withdrawal: { reason: 'copyright' } })] + assert.equal(model.recommendedUpdate(entries, installed).version, '1.10.0') + assert.equal(model.compareCatalogVersions('100000000000000000001.0.0', '100000000000000000000.0.0'), 1) + for (const version of ['1.0', '1.0.0-beta.1', '1.0.0+build', '01.0.0']) assert.equal(model.recommendedUpdate(entries, { ...installed, version }), undefined) + assert.equal(model.recommendedUpdate(entries, { ...installed, pendingRemoval: true }), undefined) + assert.equal(model.recommendedUpdate([entry('1.1.0')], installed), undefined) +}) +test('future types stay visible and localized capability search and filtering work', () => { + const entries = [entry('1.0.0'), entry('1.1.0'), entry('1.0.0', { id: 'future', incompatible: ['unsupported contribution contract'], compatibility: { contributions: [{ kind: 'future', role: '', contract: 'future/v1' }] } })] + assert.equal(model.catalogGroups(entries, '', '', 'en-US').length, 2) + assert.equal(model.catalogGroups(entries, '主题', 'theme', 'zh-CN', s => s === 'Theme' ? '主题' : s)[0].versions[0].version, '1.1.0') + assert.equal(model.catalogGroups(entries, '', 'future', 'en-US')[0].id, 'future') +}) +test('same-version different bytes, withdrawal and safety damage cannot look installable', () => { + assert.match(model.catalogUnavailable(entry('1.2.0'), installed)[0], /different artifact/) + assert.match(model.catalogUnavailable(entry('1.2.0', { sha256: 'installed' }), installed)[0], /already installed/) + assert.match(model.catalogUnavailable(entry('2.0.0', { withdrawal: { reason: 'copyright' } }))[0], /copyright/) + assert.deepEqual(model.catalogUnavailable(entry('2.0.0', { blockedReason: 'damaged' })), ['damaged']) +}) +test('catalog text is escaped, has no remote media, and links are action buttons', () => { + const malicious = ' **text**' + const Component = loadTypeScript(new URL('../src/renderer/components/ExtensionCatalog.tsx', import.meta.url), { '../i18n': { useI18n: () => ({ locale: 'en-US', t: s => s }) }, './FluentIcon': { default: () => null } }).default + const item = entry('1.0.0', { display: { display_name: malicious, description: malicious, author: { name: malicious }, homepage: 'javascript:alert(1)', repository: 'https://remote.test' } }) + const html = renderToStaticMarkup(React.createElement(Component, { catalog: { entries: [item], cached: false }, installed: [], busy: false, selection: item, onSelect() {}, onInstall() {}, onLink() {} })) + assert.ok(html.includes('<img')) + assert.ok(!/ { + const current = entry('1.0.0') + const future = entry('2.0.0', { incompatible: ['unsupported contribution contract'], compatibility: { contributions: [{kind:'future',role:'',contract:'future/v1'}] } }) + const groups = model.catalogGroups([current, future], '', 'future', 'en-US') + assert.equal(groups[0].versions[0].version, '2.0.0') + assert.deepEqual(groups[0].versions[0].incompatible, ['unsupported contribution contract']) +}) + +test('closed and future voice roles are not presented as TTS', () => { + assert.equal(model.contributionLabel('voice_backend','tts'), 'Speech synthesis') + assert.equal(model.contributionLabel('voice_backend','asr'), 'Speech recognition') + assert.equal(model.contributionLabel('voice_backend','future'), 'voice_backend/future') + const asr = entry('2.0.0', {compatibility:{contributions:[{kind:'voice_backend',role:'asr',contract:'amadeus.voice.asr/v0'}]},incompatible:['unsupported contribution contract']}) + assert.equal(model.catalogApplication(asr), 'Compatibility unavailable') + assert.equal(model.recommendedUpdate([asr], installed), undefined) +}) + + +test('empty catalog keeps browsing available and explains preview status', () => { + const Component = loadTypeScript(new URL('../src/renderer/components/ExtensionCatalog.tsx', import.meta.url), { '../i18n': { useI18n: () => ({ locale: 'en-US', t: s => s }) }, './FluentIcon': { default: () => null } }).default + const html = renderToStaticMarkup(React.createElement(Component, { catalog: { entries: [], cached: false }, installed: [], busy: false, selection: null, onSelect() {}, onInstall() {}, onLink() {} })) + assert.match(html, /Plugin store preview: no packages have been listed yet/) + assert.match(html, /Catalog packages/) +}) diff --git a/electron/tests/extensionDownloads.test.mjs b/electron/tests/extensionDownloads.test.mjs new file mode 100644 index 00000000..384dd4b1 --- /dev/null +++ b/electron/tests/extensionDownloads.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { loadTypeScript } from './helpers/loadTypeScript.mjs' +let scenario, options, requests +const transport = { fromPartition: (name, config) => { assert.equal(name, 'amadeus-extension-downloads'); assert.equal(config.cache, false); return { isolated: true } } } +const net = { request: opts => { + options = opts; requests++ + const request = new EventEmitter() + request.abort = () => request.emit('abort') + request.followRedirect = () => { request.followed = true } + request.end = () => setImmediate(() => scenario(request)) + return request +} } +const { downloadExtension, artifactUrl, httpsUrl } = loadTypeScript(new URL('../src/main/extensionDownloads.ts', import.meta.url), { electron: { net, session: transport } }) +function response(request, chunks, length, status = 200) { + const r = new PassThrough(); r.statusCode = status; r.headers = length === undefined ? {} : { 'content-length': [String(length)] } + request.emit('response', r) + for (const chunk of chunks) r.write(Buffer.from(chunk)) + r.end() +} +test('network boundary rejects invalid URLs and mirrors before requesting bytes', () => { + for (const url of ['http://a/', 'https://u:p@a/', 'https://a/#x', 'file:///tmp', 'https://a/\n']) assert.throws(() => httpsUrl(url)) + assert.throws(() => artifactUrl('https://a/p.whl', 'a'.repeat(64), 'https://mirror/?token=x')) +}) +test('bounded transport, credentials isolation, redirects, cancellation and failure cleanup', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-download-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const cases = [ + ['valid', r => response(r, ['wheel'], 5), true], + ['decoded response', request => { const r = new PassThrough(); r.statusCode = 200; r.headers = { 'content-length': '99', 'content-encoding': 'gzip' }; request.emit('response', r); r.write(Buffer.from('wheel')); r.end() }, true], + ['string header', request => { const r = new PassThrough(); r.statusCode = 200; r.headers = { 'content-length': '5' }; request.emit('response', r); r.write(Buffer.from('wheel')); r.end() }, true], + ['stream limit', r => response(r, ['wheel-long']), false], + ['header limit', r => response(r, ['wheel'], 9), false], + ['truncated', r => response(r, ['wh'], 5), false], + ['status', r => response(r, ['wheel'], 5, 404), false], + ['network', r => r.emit('error', new Error('credential-containing error must not escape')), false], + ['redirect valid', r => { r.emit('redirect', 302, 'GET', 'https://changed-cdn.example/p.whl'); assert.ok(r.followed); response(r, ['wheel'], 5) }, true], + ['redirect downgrade', r => r.emit('redirect', 302, 'GET', 'http://a/'), false], + ['redirect credentials', r => r.emit('redirect', 302, 'GET', 'https://u:p@a/'), false], + ['redirect loop', r => { for (let i = 0; i < 6; i++) r.emit('redirect', 302, 'GET', 'https://a/') }, false], + ] + for (const [name, run, success] of cases) { + scenario = run; requests = 0 + const target = path.join(root, name + '.whl') + const operation = downloadExtension('https://origin.example/p.whl', target, 5, new AbortController().signal) + if (success) { await operation; assert.equal(fs.readFileSync(target, 'utf8'), 'wheel'); fs.unlinkSync(target) } + else { await assert.rejects(operation); assert.ok(!fs.existsSync(target)) } + assert.equal(options.credentials, 'omit'); assert.equal(options.useSessionCookies, false); assert.equal(options.redirect, 'manual'); assert.equal(options.bypassCustomProtocolHandlers, true) + assert.equal(requests, 1) + } + const abort = new AbortController(); scenario = () => abort.abort() + const target = path.join(root, 'cancelled.whl') + await assert.rejects(downloadExtension('https://a/p.whl', target, 5, abort.signal), /cancelled/) + assert.ok(!fs.existsSync(target)) + abort.abort() + await assert.rejects(downloadExtension('https://a/p.whl', target, 5, abort.signal), /cancelled/) +}) + +test('next process reclaims owned interrupted downloads while retaining unknown directories and live requests', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-download-stages-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const first = loadTypeScript(new URL('../src/main/extensionDownloads.ts', import.meta.url), { electron: { net, session: transport } }) + const old = first.stagingDirectory(root) + fs.writeFileSync(path.join(old.directory, 'partial.whl'), 'interrupted bytes') + const live = first.stagingDirectory(root) + assert.ok(fs.existsSync(old.directory), 'active operation must survive another download') + const unknown = path.join(root, 'download-ABCDEF'); fs.mkdirSync(unknown); fs.writeFileSync(path.join(unknown, 'private.txt'), 'retained') + const restarted = loadTypeScript(new URL('../src/main/extensionDownloads.ts', import.meta.url), { electron: { net, session: transport } }) + const current = restarted.stagingDirectory(root) + assert.ok(!fs.existsSync(old.directory)); assert.ok(!fs.existsSync(live.directory)) + assert.ok(fs.existsSync(path.join(unknown, 'private.txt'))) + current.dispose() +}) + + +test('active slow downloads outlive the old total deadline; stalled transfers are removed', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-slow-download-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + t.mock.timers.enable({ apis: ['setTimeout'] }) + let stream + scenario = request => { + stream = new PassThrough(); stream.statusCode = 200; stream.headers = {} + request.emit('response', stream) + } + const target = path.join(root, 'slow.whl') + const operation = downloadExtension('https://a/p.whl', target, 256 * 1024 * 1024, new AbortController().signal) + await new Promise(resolve => setImmediate(resolve)) + for (let n = 0; n < 7; n++) { + t.mock.timers.tick(20_000) + stream.write(Buffer.from('chunk')) + await new Promise(resolve => setImmediate(resolve)) + } + stream.end() + await operation + assert.equal(fs.readFileSync(target, 'utf8'), 'chunk'.repeat(7)) + const stalled = path.join(root, 'stalled.whl') + const pending = downloadExtension('https://a/p.whl', stalled, 256 * 1024 * 1024, new AbortController().signal) + const rejected = assert.rejects(pending, /stalled/) + await new Promise(resolve => setImmediate(resolve)) + t.mock.timers.tick(30_001) + await rejected + assert.ok(!fs.existsSync(stalled)) +}) + +test('exclusive destination ownership preserves an existing file', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-existing-download-')) + try { + const target = path.join(root, 'existing.whl') + fs.writeFileSync(target, 'retain') + await assert.rejects(downloadExtension('https://a/p.whl', target, 5, new AbortController().signal), /EEXIST/) + assert.equal(fs.readFileSync(target, 'utf8'), 'retain') + } finally { fs.rmSync(root, { recursive: true, force: true }) } +}) diff --git a/electron/tests/extensionSettings.smoke.mjs b/electron/tests/extensionSettings.smoke.mjs index 9d32ec0a..4231f6db 100644 --- a/electron/tests/extensionSettings.smoke.mjs +++ b/electron/tests/extensionSettings.smoke.mjs @@ -64,6 +64,16 @@ try { assert.equal(await ui("[...document.querySelectorAll('.settings-form input, .settings-form select')].every(node => node.labels?.length > 0)"), true) await input('#extension-config-voice', 'unsubmitted voice') await input('#extension-config-api_key', 'synthetic replacement') + assert.equal(await ui("window.extensionFixture.calls.filter(c => c.method==='extensions.catalog' && c.params.action!=='get').length"), 0) + await ui('window.extensionFixture.setFailCatalog(true)') + await click('Check updates and security status') + await until("document.body.textContent.includes('Synthetic catalog offline')", 'Catalog failure was not visible') + assert.equal(await ui("document.querySelector('#extension-config-voice').value"), 'unsubmitted voice') + await ui('window.extensionFixture.setFailCatalog(false)') + await click('Check updates and security status') + await until("document.body.textContent.includes('Last successful security check')", 'Successful check timestamp missing') + assert.equal(await ui("document.querySelector('#extension-config-voice').value"), 'unsubmitted voice') + report.checks.push('Security check is explicit, reports offline/success, preserves the open configuration draft') await ui('window.extensionFixture.setFailSave(true)') await click('Save extension') await until("document.body.textContent.includes('Synthetic save failure')", 'Save failure was not visible') @@ -165,6 +175,15 @@ try { await until("document.body.textContent.includes('Installed plugins')", 'Could not return to Plugins') assert.equal(await ui("localStorage.getItem('amadeus.settings.section')"), 'general') report.checks.push('Plugin voice link opens Voice settings; General Manage plugins returns to the manager and preserves General') + await window.loadURL(url + '?presentation=plugins&security=damaged') + await until("document.body.textContent.includes('Installation and activation are blocked until recovery')", 'Damaged security state is not visible') + await click('Recover official extension security state') + await until("document.body.textContent.includes('Last successful security check')", 'Recovery success not shown') + assert.equal(await ui("window.extensionFixture.calls.filter(c=>c.method==='extensions.catalog'&&c.params.action!=='status').at(-1).params.action"), 'recover') + assert.ok(await ui("document.body.textContent.includes('Artifact mirror base URL')")) + await capture('security-recovery-and-mirror.png'); report.screenshots.push('security-recovery-and-mirror.png') + assert.deepEqual(await ui('window.extensionFixture.errors'), []) + report.checks.push('Damaged security has an explicit official recovery action and the manager exposes the mirror setting') report.passed = true console.log(JSON.stringify(report, null, 2)) } catch (error) { diff --git a/electron/tests/extensionSettings.test.mjs b/electron/tests/extensionSettings.test.mjs index 35ec5ef4..8f1baf55 100644 --- a/electron/tests/extensionSettings.test.mjs +++ b/electron/tests/extensionSettings.test.mjs @@ -105,7 +105,7 @@ test('saved disable and removal remain distinct from observed active runtime; di assert.ok(offline.includes('Backend status unavailable')) assert.ok(!offline.includes('Synthetic runtime')) assert.ok(offline.includes('Japanese output has not been verified')) - assert.ok(!offline.includes(' true } - const slots = []; let cursor = 0; let tree; let effects = []; let currentProps = props(initialProps) + const slots = []; let cursor = 0; let tree; let effects = []; let cleanups = []; let currentProps = props(initialProps) const hooks = { ...React, useState(initial) { const index = cursor++; if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial return [slots[index], update => { slots[index] = typeof update === 'function' ? update(slots[index]) : update }] }, @@ -132,7 +132,7 @@ function harness(api, initialProps = {}) { useEffect(callback) { effects.push(callback) }, } const Component = loadTypeScript(new URL('../src/renderer/components/ExtensionSettings.tsx', import.meta.url), { ...mocks, react: hooks }).default - const render = (update = {}) => { currentProps = { ...currentProps, ...update }; cursor = 0; effects = []; tree = Component(currentProps); effects.forEach(callback => callback()); return tree } + const render = (update = {}) => { cleanups.forEach(cleanup => cleanup()); cleanups = []; currentProps = { ...currentProps, ...update }; cursor = 0; effects = []; tree = Component(currentProps); cleanups = effects.map(callback => callback()).filter(value => typeof value === 'function'); return tree } function* walk(node) { if (!node || typeof node !== 'object') return if (Array.isArray(node)) { for (const item of node) yield* walk(item); return } yield node; yield* walk(node.props?.children); yield* walk(node.props?.action) @@ -140,7 +140,7 @@ function harness(api, initialProps = {}) { const text = node => Array.isArray(node) ? node.map(text).join('') : node && typeof node === 'object' ? text(node.props?.children) : String(node ?? '') render() return { render, find: predicate => [...walk(tree)].find(predicate), button: label => [...walk(tree)].find(node => node.props?.onClick && text(node) === label), - restore() { globalThis.window = previous } } + restore() { cleanups.forEach(cleanup => cleanup()); globalThis.window = previous } } } const flush = () => new Promise(resolve => setImmediate(resolve)) @@ -288,3 +288,34 @@ test('pending installation reports a busy navigation guard; failed installation assert.ok(page.button('Configure extension')) assert.equal(page.find(node => node.props?.role === 'alert').props.children, 'Synthetic invalid wheel') }) + +test('catalog view switching retains a draft and blocked packages can be disabled but not activated', async t => { + const updates=[] + const page = harness({extensionCatalog: async () => ({ok:true,catalog:{entries:[],cached:false,installed:[{id:'org.example.voice',version:'1.0.0',digest:'a'.repeat(64),catalogState:'matched',blocked:true}]},settings:{extensionSecurity:{status:'ready'}}}), + updateExtension: async update => { updates.push(update); return {ok:true,settings:{}} }}) + t.after(()=>page.restore()) + await flush();page.render() + page.button('Configure extension').props.onClick();page.render() + const value=()=>page.find(n=>n.props?.id==='extension-config-speed'&&n.type==='input') + value().props.onChange({target:{value:'1.75'}});page.render() + const toggle=()=>page.find(n=>n.props?.id==='extension-enabled-org.example.voice') + assert.equal(toggle().props.disabled,false) + toggle().props.onChange(false);await flush();page.render({extensions:[extension({enabled:false})]}) + assert.equal(updates[0].enabled,false) + assert.equal(toggle().props.disabled,true) + page.button('Browse').props.onClick();await flush();page.render() + page.button('Installed').props.onClick();page.render() + assert.equal(value().props.value,'1.75') + assert.equal(toggle().props.checked,false) +}) + +test('opening Browse cannot implicitly recover damaged safety state', async t => { + const requests=[] + const page=harness({extensionCatalog:async request=>{requests.push(request);return {ok:true,catalog:{entries:[],cached:true},settings:{extensionSecurity:{status:'unavailable'}}}}}) + t.after(()=>page.restore()) + await flush();page.render() + page.button('Browse').props.onClick();await flush();page.render() + assert.equal(requests.filter(r=>r.action==='recover'||r.action==='refresh').length,0) + page.button('Recover official extension security state').props.onClick();await flush();page.render() + assert.equal(requests.filter(r=>r.action==='recover').length,1) +}) diff --git a/electron/tests/extensionStore.smoke.mjs b/electron/tests/extensionStore.smoke.mjs new file mode 100644 index 00000000..0346eb0f --- /dev/null +++ b/electron/tests/extensionStore.smoke.mjs @@ -0,0 +1,167 @@ +/** Production store React + IPC dispatcher + Electron net + real wheel transactions. */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import https from 'node:https' +import { createHash, X509Certificate } from 'node:crypto' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { app, BrowserWindow, ipcMain, session } from 'electron' +import { DesktopSettingsStore } from '../dist/main/desktopSettings.js' +import { ExtensionInstaller, ExtensionPackageHelper } from '../dist/main/extensions.js' +import { ExtensionCatalog, OFFICIAL_EXTENSION_INDEX } from '../dist/main/extensionCatalog.js' +import { downloadExtension } from '../dist/main/extensionDownloads.js' +const root = fileURLToPath(new URL('../../', import.meta.url)) +const base = path.resolve(process.env.AMADEUS_CATALOG_JOURNEY_ROOT) +assert.ok(path.relative(path.join(root,'build/extension-catalog-journey'),base) && !path.relative(path.join(root,'build/extension-catalog-journey'),base).startsWith('..')) +const fixture = JSON.parse(fs.readFileSync(path.join(base,'fixture.json'))) +const python = process.env.AMADEUS_TEST_PYTHON +const environment = {...process.env, PIP_CONFIG_FILE:process.platform==='win32'?'NUL':'/dev/null'} +const helper = new ExtensionPackageHelper(root,()=>python,environment) +const store = new DesktopSettingsStore(path.join(base,'settings.json'),path.join(base,'.env'),process.platform,helper.validateRecord) +let window, vite, origin, offline=false, badWheel=false, slow=true, confirms=0 +let index = {...fixture.index, entries:fixture.index.entries.filter(e=>e.version==='0.1.0')} +const themeId='com.example.desktop-theme', toneId='com.example.tone-tts' +const malicious=' **plain**' +index.entries.find(e=>e.id===themeId).display.description=malicious +index.entries.find(e=>e.id===themeId).display.homepage='javascript:alert(1)' +index.entries.find(e=>e.id===themeId).display.repository='https://example.test/source' +const report={ok:false, checks:[], artifacts:fixture.index.entries.map(e=>({id:e.id,version:e.version,sha256:e.sha256})), nativeDialogs:false, fixtureAuthority:true, fullBackend:false, screenshots:[]} +const check = (name, value=true)=>{assert.ok(value,name);report.checks.push(name);console.log('store-check:',name)} +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)) +const notify=()=>{if(window&&!window.isDestroyed())window.webContents.send('store.theme.changed')} +const installer = new ExtensionInstaller(path.join(base,'extensions'),root,store,helper,()=>python,environment,undefined,async()=>{confirms++;await pause(450);return true},undefined,undefined,notify) +const network=[];const opened=[] +const server=https.createServer({key:fs.readFileSync(path.join(base,'key.pem')),cert:fs.readFileSync(path.join(base,'cert.pem'))},(request,response)=>{ + network.push(request.url) + if(offline){response.writeHead(503);response.end();return} + if(request.url==='/index.json'){response.end(JSON.stringify(index));return} + const wheel=fixture.wheels[decodeURIComponent(request.url.split('/').at(-1))] + if(!wheel){response.writeHead(404);response.end();return} + let bytes=fs.readFileSync(wheel) + if(badWheel&&wheel.includes('theme-0.2.0'))bytes=Buffer.alloc(bytes.length,1) + response.writeHead(200,{'Content-Length':bytes.length}) + if(slow){const half=Math.floor(bytes.length/2);response.write(bytes.subarray(0,half));const timer=setTimeout(()=>response.end(bytes.subarray(half)),1500);response.on('close',()=>clearTimeout(timer))} + else response.end(bytes) +}) +app.setPath('userData',path.join(base,'ui-profile')) +app.on('window-all-closed',()=>{}) +process.on('uncaughtException',e=>{console.error(e);app.exit(1)}) +app.whenReady().then(async()=> { +const bounded=(promise, label)=>Promise.race([promise,new Promise((_,reject)=>{const timer=setTimeout(()=>reject(new Error(label)),15000);timer.unref()})]) +const ui=source=>bounded(window.webContents.executeJavaScript(source),'Renderer query timed out: '+source) +async function until(source,message){const deadline=Date.now()+90000;while(Date.now(){const n=Array.from(document.querySelectorAll('button')).find(n=>n.getClientRects().length&&!n.disabled&&n.textContent.trim()===${JSON.stringify(label)});if(!n)return false;n.click();return true})()`,'Button unavailable: '+label);await pause(70)} +async function input(selector,value){await ui(`(()=>{const n=document.querySelector(${JSON.stringify(selector)});Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(n,${JSON.stringify(value)});n.dispatchEvent(new Event('input',{bubbles:true}))})()`);await pause(70)} +async function capture(name){const image=await window.webContents.capturePage(undefined,{stayHidden:true,stayAwake:true});fs.writeFileSync(path.join(base,name+'.png'),image.toPNG());report.screenshots.push(name+'.png')} +async function installed(id,version){await until(`document.querySelector('#extension-panel-installed:not([hidden])')&&Array.from(document.querySelectorAll('[data-extension-id]')).some(n=>n.dataset.extensionId===${JSON.stringify(id)}&&n.dataset.extensionVersion===${JSON.stringify(version)})&&!document.querySelector('.extension-download-progress')`,'Install did not finish: '+id)} +async function configure(enabled){await until(`(()=>{const n=document.querySelector('#extension-panel-installed [role="switch"][id^="extension-enabled-"]');if(!n||n.disabled)return false;if((n.getAttribute('aria-checked')==='true')!==${enabled})n.click();return true})()`,'Card switch unavailable');await until(`document.querySelector('#extension-panel-installed [role="switch"][id^="extension-enabled-"]').getAttribute('aria-checked')===${JSON.stringify(String(enabled))}`,'Enable intent did not save')} +async function remove(){await ui("document.querySelector('.extension-details').open=true");await click('Remove')} + +try { + console.log('store phase: ready') + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));origin='https://localhost:'+server.address().port + const fingerprint=new X509Certificate(fs.readFileSync(path.join(base,'cert.pem'))).fingerprint256 + session.fromPartition('amadeus-extension-downloads',{cache:false}).setCertificateVerifyProc((r,done)=>done(r.hostname==='localhost'&&new X509Certificate(r.certificate.data).fingerprint256===fingerprint?0:-3)) + const download=(url,target,limit,signal,progress)=>downloadExtension(url===OFFICIAL_EXTENSION_INDEX?origin+'/index.json':url.startsWith('https://artifact.test/')?origin+'/wheels/'+new URL(url).pathname.split('/').at(-1):url,target,limit,signal,progress) + const catalog=new ExtensionCatalog(path.join(base,'catalog'),store,helper,installer,()=>String(store.snapshot({}).values.AMADEUS_EXTENSION_ARTIFACT_MIRROR||''),notify,download) + const reply=async fn=>{try{return {ok:true,settings:await fn()}}catch(e){return {ok:false,error:e.message}}} + const bind=(name,fn)=>ipcMain.handle(name,(event,...args)=>{assert.equal(event.sender,window.webContents);return fn(...args)}) + bind('store.catalog',request=>catalog.dispatch(request,{},async url=>{opened.push(url)})) + bind('store.settings',()=>store.snapshot({})) + bind('store.settings.update',update=>reply(async()=>{const value=store.update({},update);notify();return value})) + bind('store.update',update=>reply(()=>installer.update(update))) + bind('store.remove',(id,options)=>reply(()=>installer.remove(id,options))) + bind('store.theme',()=>installer.themeState()) + const {createServer}=await import('vite') + vite=await createServer({configFile:path.join(root,'electron/vite.config.ts'),configLoader:'runner',root:path.join(root,'electron'),cacheDir:path.join(base,'vite-cache'),server:{host:'127.0.0.1',port:0,strictPort:false,hmr:false},plugins:[{name:'real-store-fixture',configureServer(server){server.middlewares.use(async(request,response,next)=>{if(request.url?.split('?')[0]!=='/store.html')return next();response.setHeader('Content-Type','text/html');response.end(await server.transformIndexHtml('/store.html','
'))})}}]}) + await vite.listen();const url='http://127.0.0.1:'+vite.httpServer.address().port+'/store.html?real=1&presentation=plugins' + window=new BrowserWindow({width:1120,height:1000,show:false,webPreferences:{preload:path.join(root,'electron/tests/fixtures/extensionStore.preload.cjs'),contextIsolation:true,nodeIntegration:false,backgroundThrottling:false,offscreen:true}}) + const externalRequests=[];window.webContents.session.webRequest.onBeforeRequest((details,callback)=>{if(!details.url.startsWith('http://127.0.0.1:')&&!details.url.startsWith('ws://127.0.0.1:')&&!details.url.startsWith('data:')&&!details.url.startsWith('blob:'))externalRequests.push(details.url);callback({})}) + console.log('store phase: loading renderer') + window.webContents.on('console-message',event=>{if(event.level==='error')console.log('renderer:',event.message)}) + window.webContents.on('preload-error',(_event,_file,error)=>console.log('preload:',error.message)) + await bounded(window.loadURL(url),'Renderer load timed out');console.log('store phase: renderer loaded');await until("document.body.innerText.includes('No community extensions installed.')",'Manager did not load') + check('opening installed does not fetch remote catalog',network.length===0) + await click('Browse');await until("document.body.innerText.includes('Quiet desktop')",'Browse did not load') + await input('input[type=search]',themeId);await click('View plugin details') + check('third-party descriptions are plain text',await ui(`document.body.innerText.includes(${JSON.stringify(malicious)})&&!document.querySelector('.extension-catalog img')&&!globalThis.catalogInjected`)) + await click('Open homepage');await until("document.querySelector('[role=alert]')",'Unsafe homepage was not rejected');check('unsafe homepage rejected in production dispatcher',opened.length===0) + await click('Open source repository');check('HTTPS source link resolved in main',opened[0]==='https://example.test/source') + await capture('store-details-en') + await click('Install plugin…');await until("document.querySelector('progress')?.value>0",'Download progress not shown') + await click('Installed');check('switching views retains active download',await ui("Boolean(document.querySelector('.extension-download-progress'))")) + await capture('store-download-en') + await click('Cancel installation');await until("document.body.innerText.includes('No new package was saved.')",'Cancellation not acknowledged') + check('cancellation leaves no saved install',store.storedExtensionRecords().length===0) + await click('Browse');await until("!document.querySelector('.extension-catalog-toolbar button').disabled",'Refresh not finished') + const before=confirms + await ui("(()=>{const b=Array.from(document.querySelectorAll('button')).find(n=>n.getClientRects().length&&n.textContent==='Install plugin…');b.click();b.click()})()") + await installed(themeId,'0.1.0');check('double click commits one install',confirms===before+1) + check('new install stays disabled',store.storedExtensionRecords()[0].enabled===false) + await configure(true) + await click('Open appearance settings');await until("Array.from(document.querySelectorAll('[role=radio]')).some(n=>n.textContent.includes('Quiet day'))",'Installed theme absent in Appearance') + await ui("Array.from(document.querySelectorAll('[role=radio]')).find(n=>n.textContent.includes('Quiet day')).click()") + await until("document.documentElement.dataset.themeExtension==='com.example.desktop-theme/day'",'Real installed theme did not paint') + check('real theme applies through Appearance',(await installer.themeState()).theme.version==='0.1.0') + await click('Manage plugins');await until("document.body.innerText.includes('Quiet desktop')",'Manager return failed') + index={...index,sequence:2,entries:structuredClone(fixture.index.entries)} + const future=structuredClone(index.entries.find(e=>e.id===themeId&&e.version==='0.2.0'));future.version='0.10.0';future.compatibility.contributions.push({kind:'future',role:'',contract:'future/v1'});index.entries.push(future) + await click('Check updates and security status');await until("document.body.innerText.includes('Compatible update available')",'Update recommendation absent') + check('newer incompatible version is skipped',await ui("document.body.innerText.includes('0.1.0 → 0.2.0')&&!document.body.innerText.includes('0.1.0 → 0.10.0')")) + await click('Review update details') + await ui("(()=>{const n=document.querySelector('.extension-catalog-detail select');n.value='0.10.0';n.dispatchEvent(new Event('change',{bubbles:true}))})()") + await until("document.body.innerText.includes('unsupported contribution contract')",'Future contract explanation missing') + check('future mixed package details remain visible but installation is disabled',await ui("Array.from(document.querySelectorAll('button')).some(n=>n.textContent==='Install selected version…'&&n.disabled)")) + const futureResult=await ui("window.amadeus.extensionCatalog({action:'install',id:'com.example.desktop-theme',version:'0.10.0'})") + check('main rejects forced future-version installation',futureResult.ok===false) + await capture('store-future-version-en') + await ui("document.querySelector('#extension-tab-browse').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowLeft',bubbles:true}))") + check('keyboard switches manager views',await ui("document.querySelector('#extension-tab-installed').getAttribute('aria-selected')==='true'")) + store.update({}, {values:{AMADEUS_EXTENSION_ARTIFACT_MIRROR:origin+'/mirror'}}) + badWheel=true;slow=false + await click('Update plugin…');await until("document.querySelector('[role=alert]')&&!document.querySelector('.extension-download-progress')",'Bad update did not fail') + check('failed update preserves saved and applied old theme',store.storedExtensionRecords()[0].version==='0.1.0'&&(await installer.themeState()).theme.version==='0.1.0') + badWheel=false;await click('Update plugin…');await installed(themeId,'0.2.0') + check('manual update preserves enable intent and applies resource package',store.storedExtensionRecords()[0].enabled&&(await installer.themeState()).theme.version==='0.2.0') + await capture('store-updated-en') + // Offline refresh preserves the installed version and enabled state. + offline=true;await click('Check updates and security status');await until("document.querySelector('[role=alert]')",'Offline refresh failure absent') + check('offline refresh preserves saved version and enable intent',store.storedExtensionRecords()[0].version==='0.2.0'&&store.storedExtensionRecords()[0].enabled) + offline=false;await configure(false) + check('disable restores builtin appearance',!(await installer.themeState()).theme) + const data=path.join(base,'extension-data',createHash('sha256').update(themeId).digest('hex'));fs.writeFileSync(path.join(data,'retained.txt'),'fixture') + await remove();await click('Confirm remove');await until("document.body.innerText.includes('No community extensions installed.')",'Uninstall failed') + check('uninstall retains data by default',fs.existsSync(path.join(data,'retained.txt'))) + await click('Browse');await input('input[type=search]',themeId);await click('View plugin details');await click('Install plugin…');await installed(themeId,'0.2.0') + await remove();await ui("Array.from(document.querySelectorAll('input[type=checkbox]')).find(n=>n.closest('.extension-removal-data')).click()");await click('Confirm remove');await until("document.body.innerText.includes('No community extensions installed.')",'Explicit deletion failed') + check('explicit uninstall data deletion',!fs.existsSync(data)) + // A process package uses the same UI and real offline pip transaction, then its actual Runner. + index={...index,sequence:3} + await click('Browse');await input('input[type=search]',toneId);await click('View plugin details');await ui("(()=>{const n=document.querySelector('.extension-catalog-detail select');n.value='0.1.0';n.dispatchEvent(new Event('change',{bubbles:true}))})()");await click('Install plugin…');await installed(toneId,'0.1.0');await configure(true) + const record=JSON.parse(await installer.launchSnapshot()).find(r=>r.id===toneId);assert.ok(record&&!record.startup_error) + fs.writeFileSync(path.join(base,'tone-record.json'),JSON.stringify(record)) + const tone=fixture.index.entries.find(e=>e.id===toneId&&e.version==='0.1.0') + await new Promise((resolve,reject)=>{const child=spawn(python,['-I',path.join(root,'tools/check_extension_contract.py'),'--execute','--kind','tts','--wheel',fixture.wheels[new URL(tone.url).pathname.split('/').at(-1)],'--record',path.join(base,'tone-record.json'),'--scenario',path.join(root,'examples/extensions/contract-scenarios/tone.json'),'--output',path.join(base,'tone-runtime.json')],{cwd:root,env:environment,windowsHide:true,stdio:'ignore'});child.on('error',reject);child.on('close',code=>code===0?resolve():reject(new Error('Tone contract failed')))}) + check('UI-installed TTS runs through the actual Runner',JSON.parse(fs.readFileSync(path.join(base,'tone-runtime.json'))).ok) + await configure(false);await remove();await click('Confirm remove');await installer.cleanupStoppedBackend() + index={...index,sequence:4,security:[{id:themeId,version:'0.2.0',sha256:fixture.index.entries.find(e=>e.id===themeId&&e.version==='0.2.0').sha256,status:'blocked',sequence:4,reason:'security',evidence:'https://fixture.test/block'}]} + index.entries.find(e=>e.id===themeId&&e.version==='0.1.0').withdrawal={reason:'copyright',evidence:'https://fixture.test/copyright'} + store.update({}, {values:{AMADEUS_UI_LOCALE:'zh-CN',AMADEUS_UI_THEME:'wallpaper-slice'}}) + await window.loadURL(url+'&locale=zh-CN&theme=wallpaper-slice');window.setSize(560,950) + await until("document.body.innerText.includes('浏览')",'Chinese manager missing');await click('浏览');await input('input[type=search]',themeId);await click('查看插件详情');await ui("(()=>{const n=document.querySelector('.extension-catalog-detail select');n.value='0.2.0';n.dispatchEvent(new Event('change',{bubbles:true}))})()");await pause(80) + check('Chinese security block is visible',await ui("document.body.innerText.includes('安全原因被封禁')")) + check('blocked install control is disabled',await ui("Array.from(document.querySelectorAll('button')).some(n=>n.textContent==='安装插件…'&&n.disabled)")) + await ui("(()=>{const n=document.querySelector('.extension-catalog-detail select');n.value='0.1.0';n.dispatchEvent(new Event('change',{bubbles:true}))})()") + await until("document.body.innerText.includes('因版权原因下架')",'Copyright distinction missing') + check('copyright is distinguished from security ban',true) + check('narrow store does not overflow',await ui('document.documentElement.scrollWidth<=innerWidth')) + await capture('store-withdrawal-zh-narrow') + check('third-party catalog never requests remote images',externalRequests.length===0) + check('renderer has no unhandled errors',(await ui('window.extensionFixture.errors')).length===0) + check('staging directories are cleaned',fs.readdirSync(path.join(base,'catalog/staging')).length===0) + report.ok=true +} catch(error){report.error=String(error.stack||error);if(window){report.rendererErrors=await ui('window.extensionFixture?.errors').catch(()=>[]);await bounded(capture('failure'),'Capture timed out').catch(()=>{})}console.error(error)} +finally{fs.writeFileSync(path.join(base,'report.json'),JSON.stringify(report,null,2)+'\n');if(window)window.destroy();await vite?.close();await session.fromPartition('amadeus-extension-downloads').closeAllConnections();server.close();console.log(JSON.stringify({ok:report.ok,checks:report.checks.length,report:path.join(base,'report.json')}));app.exit(report.ok?0:1)} + +}).catch(error=>{console.error(error);app.exit(1)}) diff --git a/electron/tests/extensions.test.mjs b/electron/tests/extensions.test.mjs index b5a4e152..6c4f55b0 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -51,11 +51,11 @@ function fixture(t, options = {}) { const calls = [] const helper = { refreshRecords: async () => {}, call: async (command, args = [], input) => { calls.push({ command, args, input }) - if (command === 'inspect-wheel') { + if (['inspect-wheel', 'inspect-catalog-wheel'].includes(command)) { if (options.inspectionError) throw new Error(options.inspectionError) assert.notEqual(args[0], selectedWheel) return { ok: true, manifest: metadata, digest: createHash('sha256').update(fs.readFileSync(args[0])).digest('hex'), - package_root: 'demo', manifest_member: 'demo/amadeus-extension.json', distribution: 'demo' } + package_root: 'demo', manifest_member: 'demo/amadeus-extension.json', distribution: 'demo', dependency_mode: options.dependencyMode || 'offline' } } if (command === 'validate-installed') { assert.ok(fs.existsSync(path.join(args[0], 'amadeus-extension.json'))) @@ -107,7 +107,7 @@ function fixture(t, options = {}) { fs.renameSync(path.join(directory, 'vendor', 'extension-sdk', name), path.join(sdkDirectory, name)) } const installer = new ExtensionInstaller(root, directory, store, helper, () => 'trusted-python', environment, run, - async review => { calls.push({ command: 'confirm', review }); return options.confirm !== false }, sdkDirectory) + async review => { calls.push({ command: 'confirm', review }); return typeof options.confirm === 'function' ? options.confirm(review, store) : options.confirm !== false }, sdkDirectory) return { directory, root, file, store, installer, selectedWheel, calls, commandCalls, metadata } } @@ -1030,3 +1030,145 @@ test('removed frontend theme keeps its identity and directs the user to Appearan assert.match(current.reason, /Choose another theme/) assert.deepEqual(f.store.snapshot({}).pendingKeys, []) }) + +const { securitySnapshot } = loadTypeScript(new URL('../src/main/extensionSecurity.ts', import.meta.url)) +const securityFor = (record, sequence, blocked = true) => securitySnapshot(sequence, [{ id: record.id, version: record.version, digest: record.digest, sequence, blocked }], '2026-10-21T00:00:00Z') +test('offline dependency classification applies equally to local and catalog installation', async t => { + for (const catalog of [false, true]) { + const f = fixture(t) + const digest = createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex') + await f.installer.install(f.selectedWheel, catalog ? { identity: { id: 'org.example.demo', version: '0.1.0', digest }, assertCurrent() {} } : undefined) + for (const call of f.commandCalls.filter(c => c.args.includes('pip'))) { + assert.ok(call.args.includes('--no-index')) + assert.ok(call.args.includes('--no-deps')) + } + } +}) +test('third-party dependencies retain the local resolver and are rejected by catalog install', async t => { + const f = fixture(t, { dependencyMode: 'resolve' }) + await f.installer.install(f.selectedWheel) + assert.ok(f.commandCalls.filter(c => c.args.includes('pip')).at(-1).args.every(a => a !== '--no-index')) + const g = fixture(t, { dependencyMode: 'resolve' }) + await assert.rejects(g.installer.install(g.selectedWheel, { identity: { id: 'org.example.demo', version: '0.1.0', digest: createHash('sha256').update(fs.readFileSync(g.selectedWheel)).digest('hex') }, assertCurrent() {} }), /dependencies/) + assert.equal(g.commandCalls.length, 0) +}) +test('catalog identity is checked on the installer copy before confirmation or pip', async t => { + for (const changed of ['id', 'version', 'digest']) { + const f = fixture(t) + const expected = { id: 'org.example.demo', version: '0.1.0', digest: createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex') } + expected[changed] = 'wrong' + await assert.rejects(f.installer.install(f.selectedWheel, { identity: expected, assertCurrent() {} }), /identity or digest/) + assert.equal(f.commandCalls.length, 0) + assert.equal(f.store.storedExtensionRecords().length, 0) + } +}) +test('ban during confirmation rejects both installation entries before preparation', async t => { + for (const catalog of [false, true]) { + const f = fixture(t, { confirm: (review, store) => { store.commitExtensionSecurity(securityFor({ ...review.manifest, digest: review.digest }, 1)); return true } }) + const digest = createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex') + await assert.rejects(f.installer.install(f.selectedWheel, catalog ? { identity: { id: 'org.example.demo', version: '0.1.0', digest }, assertCurrent() {} } : undefined), /blocked/) + assert.equal(f.commandCalls.length, 0) + assert.equal(f.store.storedExtensionRecords().length, 0) + } +}) +test('known ban blocks activation and launch without removing enabled intent or credentials', async t => { + const f = fixture(t) + await f.installer.install(f.selectedWheel) + await f.installer.update({ id: 'org.example.demo', enabled: true, secrets: { token: 'fixture-value' } }) + const record = f.store.storedExtensionRecords()[0] + f.store.acceptExtensionApplication(record) + f.store.commitExtensionSecurity(securityFor(record, 1)) + await assert.rejects(f.installer.update({ id: record.id, enabled: true }), /blocked/) + const launch = JSON.parse(await f.installer.launchSnapshot()) + assert.ok(launch[0].startup_error) + assert.deepEqual(launch[0].secrets, {}) + const summary = f.store.snapshot({}).extensions[0] + assert.ok(summary.frontendSuppressed) + assert.ok(summary.backendStillRunning) + assert.match(summary.applicationError, /Stop or restart/) + assert.equal(f.store.storedExtensionRecords()[0].enabled, true) + assert.ok(f.store.storedExtensionRecords()[0].encryptedSecrets.token) + await f.installer.update({ id: record.id, enabled: false }) + await f.installer.remove(record.id) +}) + +for (const blockedVersion of ['desired', 'applied']) test(`security checks ${blockedVersion} identity across pending version changes`, async t => { + const f = fixture(t) + await f.installer.install(f.selectedWheel) + await f.installer.update({ id: f.metadata.id, enabled: true, secrets: { token: 'fixture-value' } }) + const loaded = f.store.storedExtensionRecords()[0] + f.store.acceptExtensionApplication(loaded) + const desired = { ...loaded, version: '0.2.0', digest: 'b'.repeat(64), manifest: { ...loaded.manifest, version: '0.2.0' } } + f.store.saveExtension({}, desired) + f.store.commitExtensionSecurity(securityFor(blockedVersion === 'desired' ? desired : loaded, 1)) + const summary = f.store.snapshot({}).extensions[0] + assert.equal(summary.appliedVersion, '0.1.0') + assert.equal(summary.version, '0.2.0') + assert.equal(summary.applicationError.includes('Stop or restart'), blockedVersion === 'applied') + assert.ok(summary.frontendSuppressed) + assert.ok(summary.backendStillRunning) + assert.match(summary.applicationError, /blocked/) + const launch = JSON.parse(await f.installer.launchSnapshot()) + assert.equal(Boolean(launch[0].startup_error), blockedVersion === 'desired') +}) + +test('replacement of downloaded bytes is rejected; concurrent remove remains serialized behind confirmation', async t => { + const f = fixture(t) + const identity = { id: f.metadata.id, version: f.metadata.version, digest: createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex') } + fs.writeFileSync(f.selectedWheel, 'replacement bytes after download') + await assert.rejects(f.installer.install(f.selectedWheel, { identity, assertCurrent() {} }), /identity or digest/) + assert.equal(f.commandCalls.length, 0) + let entered, release + const reviewing = new Promise(resolve => { entered = resolve }) + const confirmation = new Promise(resolve => { release = resolve }) + const g = fixture(t, { confirm: async () => { entered(); return confirmation } }) + const install = g.installer.install(g.selectedWheel) + await reviewing + const removal = g.installer.remove(g.metadata.id) + assert.equal(g.store.storedExtensionRecords().length, 0) + release(true) + await install; await removal + assert.equal(g.store.storedExtensionRecords()[0].pendingRemoval, true) + assert.ok(fs.existsSync(path.join(g.root, g.store.storedExtensionRecords()[0].installId))) + await g.installer.cleanupStoppedBackend() + assert.equal(g.store.storedExtensionRecords().length, 0) +}) + +test('a blocked active theme falls back immediately with the security reason', async t => { + const f = fixture(t, { manifest: themeManifest() }) + await f.installer.install(f.selectedWheel) + await f.installer.update({ id: f.metadata.id, enabled: true }) + f.store.update({}, { values: { AMADEUS_UI_THEME: `${f.metadata.id}/palette` } }) + assert.ok((await f.installer.themeState()).theme) + f.store.commitExtensionSecurity(securityFor(f.store.storedExtensionRecords()[0], 1)) + const state = await f.installer.themeState() + assert.equal(state.theme, undefined) + assert.match(state.reason, /blocked for security/) + assert.equal(f.store.storedExtensionRecords()[0].enabled, true) + await f.installer.restoreBuiltinTheme() + assert.equal((await f.installer.themeState()).reason, '') +}) + +test('lost durable inventory still reports an actually running blocked backend', async t => { + const f = fixture(t) + await f.installer.install(f.selectedWheel) + await f.installer.update({ id: f.metadata.id, enabled: true, secrets: { token: 'fixture-value' } }) + f.store.acceptExtensionApplication(f.store.storedExtensionRecords()[0]) + for (const file of [f.file, f.file + '.bak']) fs.writeFileSync(file, Buffer.alloc(100)) + const snapshot = f.store.snapshot({}) + assert.deepEqual(snapshot.extensions, []) + assert.equal(snapshot.extensionSecurity.status, 'unavailable') + assert.ok(snapshot.extensionErrors.some(message => message.includes('Stop or restart the backend'))) + assert.equal(f.store.extensionFrontendClosed(f.metadata.id), true) + await f.installer.cleanupStoppedBackend() + assert.ok(!f.store.snapshot({}).extensionErrors.some(message => message.includes('Stop or restart the backend'))) +}) + +test('catalog progress reports inspection, native confirmation and preparation without claiming commit early', async t => { + const phases = [] + const f = fixture(t, { confirm: () => { assert.equal(phases.at(-1), 'confirming'); assert.equal(f.store.storedExtensionRecords().length, 0); return true } }) + const identity = { id: f.metadata.id, version: f.metadata.version, digest: createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex') } + await f.installer.install(f.selectedWheel, { identity, assertCurrent() {}, onStage: phase => phases.push(phase) }) + assert.deepEqual(phases, ['verifying', 'confirming', 'preparing']) + assert.equal(f.store.storedExtensionRecords()[0].digest, identity.digest) +}) diff --git a/electron/tests/fixtures/extensionPublication.cjs b/electron/tests/fixtures/extensionPublication.cjs new file mode 100644 index 00000000..085b49bd --- /dev/null +++ b/electron/tests/fixtures/extensionPublication.cjs @@ -0,0 +1,31 @@ +// Owned demo inspector only; no production URL or trust override is added. +async function setupPublicationTransport(origin, certificate) { + const require = process.getBuiltinModule('module').createRequire(process.execPath) + const {net, session, dialog} = require('electron') + const fs = require('node:fs'), {X509Certificate} = require('node:crypto') + const url = new URL(origin) + if (url.protocol !== 'https:' || url.hostname !== 'localhost' || url.username || url.password) + throw Error('Demo transport requires loopback HTTPS') + const fingerprint = new X509Certificate(fs.readFileSync(certificate)).fingerprint256 + session.fromPartition('amadeus-extension-downloads', {cache:false}) + .setCertificateVerifyProc((r, done) => done( + r.hostname === 'localhost' && new X509Certificate(r.certificate.data).fingerprint256 === fingerprint ? 0 : -3)) + const request = net.request.bind(net) + const state = globalThis.extensionPublicationTransport = {requests:[], confirmations:0} + net.request = options => { + const source = typeof options === 'string' ? options : options.url + const target = source === 'https://raw.githubusercontent.com/Code-Amadeus/Amadeus-Extensions/main/catalog/v1/index.json' + ? origin + '/index.json' : source + state.requests.push({source, target}) + return request(typeof options === 'string' ? target : {...options, url:target}) + } + const confirm = dialog.showMessageBox.bind(dialog) + dialog.showMessageBox = async (...args) => { + if (args.at(-1).title === 'Review extension access') { + state.confirmations++ + return {response:1, checkboxChecked:false} + } + return confirm(...args) + } + return true +} diff --git a/electron/tests/fixtures/extensionRelease.cjs b/electron/tests/fixtures/extensionRelease.cjs new file mode 100644 index 00000000..e327097b --- /dev/null +++ b/electron/tests/fixtures/extensionRelease.cjs @@ -0,0 +1,36 @@ +// Loaded only through the owned smoke process inspector; never shipped. +async function setupReleaseFixture(base, automaticConfirm = true) { + const require = process.getBuiltinModule('module').createRequire(process.execPath) + const {net, session, dialog} = require('electron') + const fs = require('node:fs'), path = require('node:path'), https = require('node:https') + const {X509Certificate} = require('node:crypto') + const fixture = JSON.parse(fs.readFileSync(path.join(base, 'fixture.json'))) + const state = globalThis.extensionReleaseFixture = {fixture, requests:[], confirmations:0, offline:false, badWheel:false, holdWheel:false, + index:{...fixture.index, entries:fixture.index.entries.filter(e=>e.version==='0.1.0')}} + const server = https.createServer({key:fs.readFileSync(path.join(base,'key.pem')), cert:fs.readFileSync(path.join(base,'cert.pem'))}, (req,res)=>{ + state.requests.push(req.url) + if(state.offline){res.writeHead(503);res.end();return} + if(req.url==='/index.json'){res.end(JSON.stringify(state.index));return} + const wheel=fixture.wheels[decodeURIComponent(req.url.split('/').at(-1))] + if(!wheel){res.writeHead(404);res.end();return} + const bytes=fs.readFileSync(wheel);if(state.badWheel)bytes[0]^=255;res.writeHead(200,{'Content-Length':bytes.length});if(state.holdWheel){res.write(bytes.subarray(0,Math.max(1,Math.floor(bytes.length/2))));return}res.end(bytes) + }) + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)) + const origin='https://localhost:'+server.address().port + const fingerprint=new X509Certificate(fs.readFileSync(path.join(base,'cert.pem'))).fingerprint256 + session.fromPartition('amadeus-extension-downloads',{cache:false}).setCertificateVerifyProc((r,done)=>done(r.hostname==='localhost'&&new X509Certificate(r.certificate.data).fingerprint256===fingerprint?0:-3)) + const request=net.request.bind(net) + net.request=options=>{ + const url=typeof options==='string'?options:options.url + const target=url==='https://raw.githubusercontent.com/Code-Amadeus/Amadeus-Extensions/main/catalog/v1/index.json'?origin+'/index.json' + : url.startsWith('https://artifact.test/')||url.startsWith('https://mirror.test/')?origin+new URL(url).pathname:null + return request(target?{...options,url:target}:options) + } + const confirm=dialog.showMessageBox.bind(dialog) + dialog.showMessageBox=async(...args)=>{ + const options=args.at(-1) + if(options.title==='Review extension access'){state.confirmations++;state.lastConfirmation={message:options.message,detail:options.detail};if(automaticConfirm)return {response:1,checkboxChecked:false}} + return confirm(...args) + } + return true +} diff --git a/electron/tests/fixtures/extensionSettings.tsx b/electron/tests/fixtures/extensionSettings.tsx index 2aaaff95..01c73610 100644 --- a/electron/tests/fixtures/extensionSettings.tsx +++ b/electron/tests/fixtures/extensionSettings.tsx @@ -63,12 +63,14 @@ const desktop = { sources: { AMADEUS_UI_LOCALE: 'user', AMADEUS_UI_THEME: 'user', TTS_BACKEND: 'user', TTS_OUTPUT_LANGUAGE: 'user' }, locked: {}, secrets: {}, encryptionAvailable: true, mcpConnections: [], mcpConnectionsLocked: false, restartRequired: false, pendingKeys: [] as string[], pendingRevisions: {} as Record, retired_settings: [], extensions: [voice, tools, skill], + extensionSecurity: { status: query.get('security') === 'damaged' ? 'unavailable' : 'unseen', checkedAt: '' }, } let runtime = desktop.extensions.map(extension => ({ id: extension.id, version: extension.version, enabled: extension.enabled, state: extension.enabled ? 'active' : 'inactive', detail: extension.enabled ? 'Runner is ready' : 'Package is disabled', contributions: Object.entries(extension.manifest.contributes).flatMap(([kind, items]) => items.map(item => ({ kind, id: item.id, qualified_id: `${extension.id}/${item.id}`, health: extension.enabled ? 'ready' : 'disabled' }))), })) let failSave = false +let failCatalog = false if (palette) { desktop.values.AMADEUS_UI_THEME = `${themeManifest.id}/${palette.id}` desktop.extensions.push({ id: themeManifest.id, version: themeManifest.version, revision: 2, appliedRevision: 2, appliedVersion: themeManifest.version, @@ -95,6 +97,7 @@ const send = async (method: string): Promise> => { throw new Error('Unexpected synthetic RPC: ' + method) } host.amadeus = { + getBackendStartupFailure: async () => null, getExtensionIcon: async () => 'data:image/png;base64,' + btoa(String.fromCharCode(...new Uint8Array(await (await fetch(previewBackground)).arrayBuffer()))), getDesktopTheme: async () => { if (!palette) return { selected: theme, base: theme, reason: '' } @@ -107,6 +110,14 @@ host.amadeus = { getDesktopSettings: async () => snapshot(), getCompanionPortraitStatus: async () => ({ installed: false, state: 'not_installed', emotionCount: 0, frameCount: 0, detail: '' }), setTitleBarTheme: async () => true, + extensionCatalog: async (request: { action: string }) => { + calls.push({ method: 'extensions.catalog', params: request }) + if (request.action === 'status') return { ok: true } + if (request.action === 'get') return { ok: true, settings: snapshot(), catalog: { entries: [], cached: true } } + if (failCatalog) return { ok: false, error: 'Synthetic catalog offline', settings: snapshot() } + desktop.extensionSecurity = { status: 'ready', checkedAt: '2026-10-21T00:00:00Z' } + return { ok: true, settings: snapshot(), catalog: { entries: [], cached: false } } + }, installExtension: async () => ({ ok: false, cancelled: true }), updateExtension: async (update: ExtensionUpdate) => { calls.push({ method: 'updateExtension', params: structuredClone(update) }) @@ -141,7 +152,8 @@ host.amadeus = { desktop.restartRequired = false; desktop.pendingKeys = []; desktop.pendingRevisions = {}; emit(); return true }, } -host.extensionFixture = { calls, errors, snapshot, setFailSave: (value: boolean) => { failSave = value }, +if (query.has('real')) Object.assign(host.amadeus, host.extensionStoreFixture) +host.extensionFixture = { calls, errors, snapshot, setFailCatalog: (value: boolean) => { failCatalog = value }, setFailSave: (value: boolean) => { failSave = value }, refreshRuntime: () => { emit() }, setContributionHealth: (health: string, detail: string) => { Object.assign(runtime[0].contributions[0], { health, detail }); emit() } } document.documentElement.style.height = '100%' @@ -154,7 +166,7 @@ function FixtureApp() { // The App sidebar's Settings entry: the smoke uses it once no package card links back. host.extensionFixture.openSettings = openSettings return -
setPage('plugins')} send={send} subscribe={subscribe} connected reconnectBackend={async () => {}} />
+
setPage('plugins')} send={send} subscribe={subscribe} connected={!query.has('real')} reconnectBackend={async () => {}} />
} ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/electron/tests/fixtures/extensionStore.preload.cjs b/electron/tests/fixtures/extensionStore.preload.cjs new file mode 100644 index 00000000..bc25f687 --- /dev/null +++ b/electron/tests/fixtures/extensionStore.preload.cjs @@ -0,0 +1,10 @@ +const { contextBridge, ipcRenderer } = require('electron') +contextBridge.exposeInMainWorld('extensionStoreFixture', { + extensionCatalog: request => ipcRenderer.invoke('store.catalog', request), + getDesktopSettings: () => ipcRenderer.invoke('store.settings'), + updateDesktopSettings: update => ipcRenderer.invoke('store.settings.update', update), + updateExtension: update => ipcRenderer.invoke('store.update', update), + removeExtension: (id, options) => ipcRenderer.invoke('store.remove', id, options), + getDesktopTheme: () => ipcRenderer.invoke('store.theme'), + onDesktopThemeChanged: callback => { const listener = () => callback(); ipcRenderer.on('store.theme.changed', listener); return () => ipcRenderer.removeListener('store.theme.changed', listener) }, +}) diff --git a/examples/extensions/README.md b/examples/extensions/README.md index ce3a0b0e..ac49169d 100644 --- a/examples/extensions/README.md +++ b/examples/extensions/README.md @@ -1,5 +1,8 @@ # Experimental extension packages +Start with the [author guide](../../docs/extension_authoring.md) for a minimal +checker environment and static package checks before installation. + These source packages exercise TTS, MCP, Agent Skill and data-only theme contribution owners. They are not published packages or a claim of a stable SDK. Build local wheels, install them from **Settings → General → Manage plugins** (also in Settings → Extensions), then configure and select/enable @@ -55,7 +58,7 @@ python -m pip wheel --no-build-isolation --no-deps --no-index examples/extension ``` The canonical SDK source and offline build command live in -[Amadeus-Extensions](https://github.com/Code-Amadeus/Amadeus-Extensions/tree/codex/extension-protocol-review/sdk/python). +[Amadeus-Extensions](https://github.com/Code-Amadeus/Amadeus-Extensions/tree/49449a0f3572e74ac7269129c02c5b9a9e685bef/sdk/python). A wheel manifest is inspected without importing package code. The notes package does not need the Runner protocol: its native runtime is MCP, managed by its selected Provider. All share installation identity, configuration, removal and @@ -81,3 +84,26 @@ a release decision; the checked-in wheel has a recorded source commit and digest Native discovery, resource use, restoration, addition, update and removal are exercised in `tests/test_skill_codex_runtime.py` and `tests/test_skill_pi_runtime.py`. + + +## Four author journeys + +Start from `tone-tts` for an offline SDK process, `notes-mcp` for native MCP, +`handoff-skill` for resources read by Pi, or `desktop-theme` for native rendering. +The original MiMo/Fish service examples remain available; a tone is not speech +quality or paid-service qualification. The new offline tone uses the delivered +SDK wheel and standard library only. + +When copying a template change the manifest package ID, distribution name, +import-package directory, version, entry-point key/value, contribution IDs, +author/license/assets and configuration together. For TTS also change runtime.entry. +Build first, run check-wheel on those bytes, then install to an isolated desktop. +Do not import private Host modules in a plugin or copy an existing author's identity. + +[The contract tool](../../docs/extension_authoring.md#explicit-runtime-contract-checks) +accepts a trusted installed record plus its exact wheel and an explicit scenario. +Example scenarios under `contract-scenarios/` contain only synthetic inputs. +The maintainer journey builds/installs two versions of all four references using +the actual installer/store, runs their checks, and verifies disable, data retention, +reinstall and explicit deletion. It supplies confirmation decisions through a +driver; it does not certify manual native dialogs or real-model selection quality. diff --git a/examples/extensions/contract-scenarios/handoff.json b/examples/extensions/contract-scenarios/handoff.json new file mode 100644 index 00000000..8aee30ef --- /dev/null +++ b/examples/extensions/contract-scenarios/handoff.json @@ -0,0 +1,7 @@ +{ + "provider": "pi", + "reads": [ + "skills/handoff-brief/SKILL.md", + "skills/handoff-brief/references/layout.json" + ] +} diff --git a/examples/extensions/contract-scenarios/notes.json b/examples/extensions/contract-scenarios/notes.json new file mode 100644 index 00000000..8869c774 --- /dev/null +++ b/examples/extensions/contract-scenarios/notes.json @@ -0,0 +1,25 @@ +{ + "provider": "pi", + "contribution": "notes", + "calls": [ + { + "tool": "save_note", + "arguments": { + "name": "author-check", + "text": "Synthetic Notes round trip" + } + }, + { + "tool": "read_note", + "arguments": { + "name": "author-check" + }, + "contains": "Synthetic Notes round trip" + }, + { + "tool": "list_notes", + "arguments": {}, + "contains": "author-check" + } + ] +} diff --git a/examples/extensions/contract-scenarios/theme.json b/examples/extensions/contract-scenarios/theme.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/examples/extensions/contract-scenarios/theme.json @@ -0,0 +1 @@ +{} diff --git a/examples/extensions/contract-scenarios/tone.json b/examples/extensions/contract-scenarios/tone.json new file mode 100644 index 00000000..22663556 --- /dev/null +++ b/examples/extensions/contract-scenarios/tone.json @@ -0,0 +1,6 @@ +{ + "contribution": "tone", + "text": "Synthetic tone", + "cancel_text": "Synthetic cancellation", + "language": "en" +} diff --git a/examples/extensions/tone-tts/LICENSE b/examples/extensions/tone-tts/LICENSE new file mode 100644 index 00000000..1cafbebf --- /dev/null +++ b/examples/extensions/tone-tts/LICENSE @@ -0,0 +1,7 @@ +MIT No Attribution + +Copyright (c) 2026 Amadeus contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/examples/extensions/tone-tts/pyproject.toml b/examples/extensions/tone-tts/pyproject.toml new file mode 100644 index 00000000..75d9f228 --- /dev/null +++ b/examples/extensions/tone-tts/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" +[project] +name = "amadeus-example-tone-tts" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = ["amadeus-extension-sdk==0.1.0"] +license = "MIT-0" +[project.entry-points."amadeus.extensions"] +"com.example.tone-tts" = "amadeus_example_tone" +[tool.setuptools.packages.find] +where = ["src"] +[tool.setuptools.package-data] +amadeus_example_tone = ["amadeus-extension.json"] diff --git a/examples/extensions/tone-tts/src/amadeus_example_tone/__init__.py b/examples/extensions/tone-tts/src/amadeus_example_tone/__init__.py new file mode 100644 index 00000000..57e548ea --- /dev/null +++ b/examples/extensions/tone-tts/src/amadeus_example_tone/__init__.py @@ -0,0 +1,19 @@ +"""MIT-0 offline author example, using only the delivered SDK and standard library.""" + +import math +import struct +import time +from amadeus_extension import AudioChunk, Extension + +extension = Extension() + + +@extension.tts("tone") +def synthesize(request, context): + pcm = b"".join( + struct.pack("=0.1,<0.2", + "platforms": [ + "win32", + "darwin", + "linux" + ] + }, + "runtime": { + "kind": "python", + "entry": "amadeus_example_tone:extension", + "requires_python": ">=3.12" + }, + "contributes": { + "voice_backend": [ + { + "id": "tone", + "role": "tts", + "contract": "amadeus.voice.tts/v0", + "deployment": "local", + "languages": [ + "en" + ], + "streaming": true + } + ] + } +} diff --git a/pyproject.toml b/pyproject.toml index d0eac034..3af43c05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,6 +158,10 @@ dev = [ "wheel==0.47.0", ] +[dependency-groups] +# Versions and hashes come from the same uv.lock as the Host environment. +extension-authoring = ["jsonschema", "packaging", "PyYAML"] + # Build selections are mutually exclusive. Optional model interpreters remain # configurable; this only selects the packages in the current project environment. [tool.uv] diff --git a/release/source_release_policy.json b/release/source_release_policy.json index 64d9fc0b..6c4e5987 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -9,6 +9,14 @@ }, "archive_prefix": "amadeus-{version}", "required_files": [ + "tools/extension_package.py", + "tools/check_extension_contract.py", + "tools/probes/extension_contract_pi.py", + "electron/scripts/check-extension-theme.mjs", + "examples/extensions/tone-tts/src/amadeus_example_tone/amadeus-extension.json", + "tools/requirements-extension-authoring.txt", + "docs/extension_authoring.md", + "docs/extension_validation_history_2026-10-13.md", "server/extensions/protocol-source.json", "server/extensions/sdk.py", "vendor/extension-sdk/amadeus_extension_sdk-0.1.0-py3-none-any.whl", @@ -114,6 +122,25 @@ "docs/japanese_context_reading.md", "LICENSES/TSQYOMI.md", "LICENSES/TSQYOMI-MIT.txt", + "server/extensions/catalog.py", + "server/extensions/catalog-source.json", + "vendor/extension-catalog/tools/catalog.py", + "vendor/extension-catalog/catalog/schema.json", + "vendor/extension-catalog/catalog/policy.json", + "vendor/extension-catalog/catalog/toolchain.json", + "vendor/extension-catalog/LICENSE", + "electron/src/main/extensionCatalog.ts", + "electron/src/main/extensionDownloads.ts", + "electron/src/main/extensionSecurity.ts", + "electron/src/renderer/components/ExtensionCatalog.tsx", + "electron/src/renderer/components/extensionCatalogModel.ts", + "electron/src/shared/extensionCatalog.ts", + "docs/extension_catalog.md", + "docs/verification/extensions/store-v1.md", + "tools/probes/extension_store_demo.py", + "tools/probes/extension_store_roundtrip.py", + "electron/tests/fixtures/extensionPublication.cjs", + "docs/extension_store_demo.md", "release/japanese_dictionary.json", "tools/stage_japanese_dictionary.py", "electron/scripts/build-runtime-resources.cjs", @@ -150,6 +177,8 @@ "wallpaper" ], "include_files": [ + "docs/extension_authoring.md", + "docs/extension_validation_history_2026-10-13.md", "docs/japanese_pronunciation_dictionary.md", "docs/mcp_environment.md", "docs/backend_shutdown.md", @@ -280,7 +309,23 @@ "docs/images/extensions/theme-native-mixed-pending.jpg", "docs/images/extensions/theme-native-mixed-chat.jpg", "docs/images/extensions/theme-native-removed-identity.jpg", - "docs/japanese_context_reading.md" + "docs/japanese_context_reading.md", + "docs/extension_catalog.md", + "docs/verification/extensions/store-v1.md", + "docs/verification/extensions/community-preview-batch1-2026-10-21.md", + "docs/verification/extensions/community-preview-batch2-2026-10-21.md", + "docs/verification/extensions/community-preview-batch3-2026-10-21.md", + "docs/verification/extensions/community-preview-batch4-2026-10-21.md", + "docs/verification/extensions/community-preview-batch5-2026-10-21.md", + "docs/extension_store_demo.md", + "docs/verification/extensions/community-preview-batch6-2026-10-21.md", + "docs/verification/extensions/community-preview-batch7-2026-10-21.md", + "docs/verification/extensions/community-preview-batch8-2026-10-22.md", + "docs/verification/extensions/external-review-handoff-2026-10-21.md", + "docs/verification/extensions/community-preview-batch9-2026-10-23.md", + "docs/verification/extensions/community-preview-batch10-2026-10-23.md", + "docs/verification/extensions/community-preview-batch11-2026-10-23.md", + "docs/verification/extensions/community-preview-batch12-2026-10-23.md" ], "exclude_globs": [ "docs/reports/**", diff --git a/server/extensions/catalog-source.json b/server/extensions/catalog-source.json new file mode 100644 index 00000000..74275c38 --- /dev/null +++ b/server/extensions/catalog-source.json @@ -0,0 +1,12 @@ +{ + "repository": "https://github.com/Code-Amadeus/Amadeus-Extensions", + "commit": "58d3892d55c6a7ec6f8626153f503291bb10ab9a", + "files": { + "tools/catalog.py": "746dd13bf6e38992fb69e77cab2708166d78663c7dda0b1961757fac3c8246cc", + "catalog/schema.json": "bcc80b10d9367f9bff0bdfd19c6f675474ae1a646c549fac4bad53224cffa673", + "catalog/policy.json": "a16f51689832d7793fdfcbc8ad1b2ec85237e83b54af5e994329138a7a76812b", + "catalog/toolchain.json": "9e462d4a4ddf41951fae3c193537063af9ec55a6bfa5c926a41ff709daa5335b", + "catalog/fixtures/compatibility.json": "d3e0501a0aa8678156da3944a823fc4de4388871da21ea10cfe2400302bedc21", + "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" + } +} diff --git a/server/extensions/catalog.py b/server/extensions/catalog.py new file mode 100644 index 00000000..80c48698 --- /dev/null +++ b/server/extensions/catalog.py @@ -0,0 +1,79 @@ +"""Host consumer of the independently pinned catalog policy, never an execution owner.""" + +from __future__ import annotations +import importlib.util +import json +import platform +import sys +from functools import lru_cache +from pathlib import Path +from packaging.specifiers import SpecifierSet +from server.extensions.package import HOST_API, matches_version +from server.extensions.profile import PROFILE +from server.extensions.sdk import dependency_mode, sdk_spec + +ROOT = Path(__file__).resolve().parents[2] + + +@lru_cache(maxsize=1) +def policy(): + spec = importlib.util.spec_from_file_location( + "amadeus_catalog_policy", ROOT / "vendor/extension-catalog/tools/catalog.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def read_catalog(path: Path) -> dict: + owner = policy() + index = owner.validate_index(owner.read_json(path)) + schema = json.loads( + (ROOT / "server/extensions/amadeus-extension.v0.schema.json").read_text(encoding="utf-8") + ) + definitions = schema["$defs"] + + def contracts(node): + if "$ref" in node: + yield from contracts(definitions[node["$ref"].rsplit("/", 1)[-1]]) + properties = node.get("properties", {}) + contract = properties.get("contract", {}).get("const") + if contract: + yield properties.get("role", {}).get("const", ""), contract + for branch in ("then", "else"): + if branch in node: + yield from contracts(node[branch]) + + capabilities = { + "delivery": ["python-wheel"], + "runtime": ["none", "python"], + "contributions": [ + {"kind": row["kind"], "role": role, "contract": contract} + for row in PROFILE + if row["status"] == "open" + for role, contract in contracts( + definitions["contributes"]["properties"][row["kind"]]["items"] + ) + if role == row.get("role", "") + ], + } + entries = [] + for entry in index["entries"]: + reasons = owner.incompatible_reasons(entry, capabilities) + c = entry["compatibility"] + try: + if not matches_version(HOST_API, c["host_api"]): + reasons.append("incompatible Host API") + if c["platforms"] and sys.platform not in c["platforms"]: + reasons.append("unsupported platform") + if platform.python_version() not in SpecifierSet(c["python"]): + reasons.append("incompatible Python version") + if c["sdk"] != sdk_spec()["version"]: + reasons.append("incompatible catalog SDK") + if dependency_mode(c["dependencies"]) != "offline": + reasons.append("catalog dependencies are unsupported") + owner.wheel_filename(entry["url"]) + except ValueError: + reasons.append("unsupported environment or delivery requirement") + entries.append({**entry, "incompatible": reasons}) + return {**index, "entries": entries} diff --git a/server/extensions/package.py b/server/extensions/package.py index f82f0598..edc92880 100644 --- a/server/extensions/package.py +++ b/server/extensions/package.py @@ -18,7 +18,7 @@ from server.extensions.manifest import MANIFEST_LIMIT_BYTES, MANIFEST_NAME, _is_link, manifest_issues, package_issues from server.extensions.profile import validate_supported_contributions -from server.extensions.sdk import validate_sdk_requirements +from server.extensions.sdk import dependency_mode, validate_sdk_requirements HOST_API = "0.1" MAX_WHEEL_BYTES = 256 * 1024 * 1024 @@ -193,6 +193,7 @@ def inspect_wheel(path: Path) -> dict[str, Any]: validate_sdk_requirements(metadata.get_all("Requires-Dist", [])) return {"manifest": manifest, "digest": digest, "manifest_member": member.filename, "package_root": package_root, "distribution": metadata["Name"], + "dependency_mode": dependency_mode(metadata.get_all("Requires-Dist", [])), "requires_dist": metadata.get_all("Requires-Dist", []), "requires_python": metadata.get("Requires-Python", "")} diff --git a/server/extensions/sdk.py b/server/extensions/sdk.py index df516850..1b2971bd 100644 --- a/server/extensions/sdk.py +++ b/server/extensions/sdk.py @@ -34,3 +34,18 @@ def validate_sdk_requirements(requirements: list[str]) -> None: raise ValueError("SDK direct URL dependencies are unsupported; Amadeus supplies its pinned SDK") if version not in requirement.specifier: raise ValueError(f"extension requires SDK {requirement.specifier}; Amadeus provides {version}") + + +def dependency_mode(requirements: list[str]) -> str: + """Classify effective requirements in the same interpreter as installation.""" + validate_sdk_requirements(requirements) + for text in requirements: + try: + requirement = Requirement(text) + except InvalidRequirement: + raise ValueError("wheel contains an invalid Python dependency requirement") from None + if requirement.marker and not requirement.marker.evaluate({"extra": ""}): + continue + if canonicalize_name(requirement.name) != "amadeus-extension-sdk" or requirement.extras: + return "resolve" + return "offline" diff --git a/tests/fixtures/pi_mcp_server.py b/tests/fixtures/pi_mcp_server.py index 2008f313..4319808a 100644 --- a/tests/fixtures/pi_mcp_server.py +++ b/tests/fixtures/pi_mcp_server.py @@ -1,18 +1,21 @@ -"""Synthetic stdio MCP peer; all effects stay in the supplied test directory.""" -import asyncio +"""Synthetic MCP wire peer; effects stay in the supplied test directory. + +Use stdlib here so authority/lifecycle tests do not race the import time of an +unrelated Python SDK against the Host's intentionally bounded 3s discovery wait. +Pi's client/transport, tool discovery and permission pipeline are still real. +SDK-backed extension qualification remains in the separate Notes author journey. +""" import json import os from pathlib import Path - -from mcp.server import MCPServer -from mcp.types import ToolAnnotations +import sys root = Path(os.environ["PI_MCP_FIXTURE_ROOT"]) root.mkdir(parents=True, exist_ok=True) (root / "started.json").write_text(json.dumps({"pid": os.getpid()}), encoding="utf-8") with (root / "pids.txt").open("a", encoding="utf-8") as output: output.write(f"{os.getpid()}\n") -server = MCPServer("pi-fixture") +inspect_name = os.environ.get("PI_MCP_INSPECT_NAME", "inspect_fixture") def record(name): @@ -20,28 +23,57 @@ def record(name): output.write(name + "\n") -@server.tool(name=os.environ.get("PI_MCP_INSPECT_NAME", "inspect_fixture"), - annotations=ToolAnnotations(readOnlyHint=True)) -def inspect_fixture() -> str: - record("inspect") - return json.dumps({"literal": os.environ.get("PI_MCP_LITERAL"), - "unrelated": os.environ.get("AMADEUS_PI_TEST_HOST_SECRET"), - "other_connection": os.environ.get("PI_MCP_OTHER_SECRET"), - "host_registry": os.environ.get("AMADEUS_PI_MCP_SERVERS")}) - - -@server.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False)) -def write_note(text: str) -> str: - record("write") - (root / "note.txt").write_text(text, encoding="utf-8") - return "Saved synthetic note" - - -@server.resource("fixture://note") -def read_note() -> str: - record("resource") - return "Synthetic resource note" +def dispatch(method, params): + if method == "initialize": + return {"protocolVersion": "2025-11-25", "serverInfo": {"name": "pi-fixture", "version": "1.0"}, + "capabilities": {"tools": {}, "resources": {}}} + if method == "ping": + return {} + if method == "tools/list": + return {"tools": [ + {"name": inspect_name, "inputSchema": {"type": "object", "properties": {}}, + "annotations": {"readOnlyHint": True}}, + {"name": "write_note", "inputSchema": {"type": "object", + "properties": {"text": {"type": "string"}}, "required": ["text"]}, + "annotations": {"readOnlyHint": False, "destructiveHint": False}}, + ]} + if method == "tools/call": + if params["name"] == inspect_name: + record("inspect") + text = json.dumps({"literal": os.environ.get("PI_MCP_LITERAL"), + "unrelated": os.environ.get("AMADEUS_PI_TEST_HOST_SECRET"), + "other_connection": os.environ.get("PI_MCP_OTHER_SECRET"), + "host_registry": os.environ.get("AMADEUS_PI_MCP_SERVERS")}) + elif params["name"] == "write_note": + record("write") + (root / "note.txt").write_text(params["arguments"]["text"], encoding="utf-8") + text = "Saved synthetic note" + else: + raise ValueError("Unknown fixture tool") + return {"content": [{"type": "text", "text": text}], "isError": False} + if method == "resources/list": + return {"resources": [{"uri": "fixture://note", "name": "read_note", "mimeType": "text/plain"}]} + if method == "resources/templates/list": + return {"resourceTemplates": []} + if method == "resources/read": + if params["uri"] != "fixture://note": + raise ValueError("Unknown fixture resource") + record("resource") + return {"contents": [{"uri": "fixture://note", "mimeType": "text/plain", "text": "Synthetic resource note"}]} + raise NotImplementedError("Unknown fixture method") if __name__ == "__main__": - asyncio.run(server.run_stdio_async()) + # MCP JSONL is UTF-8 regardless of the Windows interpreter locale. + for line in sys.stdin.buffer: + request = json.loads(line.decode("utf-8")) + if "id" not in request: + continue # Initialized/cancelled notifications have no response. + response = {"jsonrpc": "2.0", "id": request["id"]} + try: + response["result"] = dispatch(request["method"], request.get("params", {})) + except NotImplementedError: + response["error"] = {"code": -32601, "message": "Unknown fixture method"} + except (KeyError, ValueError, TypeError): + response["error"] = {"code": -32602, "message": "Invalid fixture request"} + print(json.dumps(response, ensure_ascii=True), flush=True) diff --git a/tests/test_catalog_packaging.py b/tests/test_catalog_packaging.py index 72b03fe4..21b42b60 100644 --- a/tests/test_catalog_packaging.py +++ b/tests/test_catalog_packaging.py @@ -53,3 +53,29 @@ def test_wheel_contains_and_loads_catalog_in_new_nested_folder(tmp_path): for schema in ('amadeus.extension/v0', 'amadeus.extension/v1'): assert _validator(schema).is_valid({}) is False ''', str(installed)], cwd=tmp_path, check=True, capture_output=True, text=True) + + +def test_electron_helper_resource_selection_accepts_v0_and_v1_wheels(tmp_path): + from test_extension_authoring import package + from tools.probes.extension_release_shell import verify_checker_schemas + + root = Path(__file__).parents[1] + resources = json.loads((root / "electron/package.json").read_text(encoding="utf-8"))["build"]["extraResources"] + selection = next(item for item in resources if item["to"] == "extension-host") + source = (root / "electron" / selection["from"]).resolve() + bundled = tmp_path / "resources/extension-host" + for pattern in selection["filter"]: + for file in source.glob(pattern): + if file.is_file(): + destination = bundled / file.relative_to(source) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(file, destination) + _, _, wheel = package(tmp_path) + result = verify_checker_schemas(bundled, wheel) + assert set(result) == {"amadeus.extension/v0", "amadeus.extension/v1"} + assert all(item["ok"] for item in result.values()) + # Prove that source checkout files cannot hide a missing resource in the bundle. + (bundled / "server/extensions/amadeus-extension.v1.schema.json").unlink() + import pytest + with pytest.raises(AssertionError): + verify_checker_schemas(bundled, wheel) diff --git a/tests/test_electron_model_less_smoke.py b/tests/test_electron_model_less_smoke.py index 8f8b4a3a..aabcf703 100644 --- a/tests/test_electron_model_less_smoke.py +++ b/tests/test_electron_model_less_smoke.py @@ -88,6 +88,48 @@ async def evaluate(self, expression): asyncio.run(_wait_desktop_value(Page(), 'AMADEUS_UI_THEME', 'classic', timeout=.02)) + +def test_source_archive_identity_checks_actual_files_without_parent_git(tmp_path): + import hashlib + import json + from tools.smoke_electron_model_less import launcher_code_identity + + source = tmp_path / "unpacked" + source.mkdir() + (source / "main.py").write_bytes(b"print('release')") + manifest = {"schema":"amadeus.source-release-manifest.v1", "release_ready":True, + "commit":"a"*40, "selected_file_count":1, "selected_files":[{ + "path":"main.py", "size":len(b"print('release')"), + "sha256":hashlib.sha256(b"print('release')").hexdigest()}]} + (source / "SOURCE_MANIFEST.json").write_text(json.dumps(manifest)) + identity = launcher_code_identity(source) + assert identity["commit_sha"] == "a"*40 + assert identity["identity_kind"] == "source-release-manifest" + assert not identity["workspace_dirty"] + (source / "main.py").write_bytes(b"print('changed')") + with pytest.raises(ValueError, match="differs"): + launcher_code_identity(source) + (source / "main.py").unlink() + with pytest.raises(ValueError, match="missing"): + launcher_code_identity(source) + manifest["selected_files"][0]["path"] = "../outside.py" + (source / "SOURCE_MANIFEST.json").write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="file path"): + launcher_code_identity(source) + + +@pytest.mark.parametrize("other", [ + ["--extension-fixture", "legacy-fixture"], + ["--extension-failures"], + ["--native-confirmations"], +]) +def test_publication_journey_rejects_mixed_profile_modes_before_launch(other): + from tools.smoke_electron_model_less import _parser, run_smoke + args = _parser().parse_args(["--extension-publication", "demo.json", *other]) + with pytest.raises(ValueError, match="separate complete journey"): + asyncio.run(run_smoke(args)) + + @pytest.mark.parametrize("executable", [ "electron.exe", "electron", "Electron.app/Contents/MacOS/Electron", ]) diff --git a/tests/test_extension_authoring.py b/tests/test_extension_authoring.py new file mode 100644 index 00000000..9223aa65 --- /dev/null +++ b/tests/test_extension_authoring.py @@ -0,0 +1,159 @@ +"""Author tools use the installed-package validator without executing plugin code.""" +from __future__ import annotations + +import copy +import json +import shutil +from pathlib import Path +import subprocess +import sys +import zipfile + +import pytest + +from server.extensions.package import inspect_wheel, validate_installed, validate_manifest +from tools.extension_package import check_wheel + +ROOT = Path(__file__).resolve().parents[1] +CLI = ROOT / 'tools/extension_package.py' +SOURCE = ROOT / 'examples/extensions/handoff-skill/src/amadeus_example_handoff' + + +def package(tmp_path, *, broken=None): + directory = tmp_path / 'amadeus_example_handoff' + directory.mkdir() + manifest = json.loads((SOURCE / 'amadeus-extension.json').read_text(encoding='utf-8')) + (directory / 'amadeus-extension.json').write_text(json.dumps(manifest), encoding='utf-8') + (directory / '__init__.py').write_text('raise AssertionError("package must not be imported")', encoding='utf-8') + skill = directory / 'skills/handoff-brief' + skill.mkdir(parents=True) + if broken != 'missing': + (skill / 'SKILL.md').write_text('---\nname: handoff-brief\ndescription: Static test\n---\nRead references/layout.json.\n' + if broken != 'metadata' else 'not valid skill metadata', encoding='utf-8') + (skill / 'references').mkdir() + (skill / 'references/layout.json').write_text('{}', encoding='utf-8') + wheel = tmp_path / 'example-0.1.0-py3-none-any.whl' + with zipfile.ZipFile(wheel, 'w') as archive: + for path in directory.rglob('*'): + if path.is_file(): + archive.write(path, str(path.relative_to(tmp_path)).replace('\\', '/')) + archive.writestr('example-0.1.0.dist-info/METADATA', 'Name: example\nVersion: 0.1.0\n') + archive.writestr('example-0.1.0.dist-info/entry_points.txt', + f"[amadeus.extensions]\n{manifest['id']} = {directory.name}\n") + return directory, manifest, wheel + + +def cli(*args, input=None): + return subprocess.run([sys.executable, '-I', str(CLI), *map(str, args)], + input=input, capture_output=True, text=True, encoding='utf-8', timeout=20) + + +def test_source_and_wheel_checks_validate_resources_without_import(tmp_path): + directory, manifest, wheel = package(tmp_path) + validate_installed(manifest, directory) + for command, target in [('check-package', directory), ('check-wheel', wheel)]: + result = cli(command, target) + assert result.returncode == 0, result.stderr + result.stdout + payload = json.loads(result.stdout) + assert payload['ok'] and payload['manifest'] == manifest + assert 'skill_metadata' in payload['checked'] + assert {'native_theme', 'runtime', 'catalog_review'} <= set(payload['not_checked']) + assert payload['host_profile']['platform'] == sys.platform + assert check_wheel(wheel)['digest'] == inspect_wheel(wheel)['digest'] + + +@pytest.mark.parametrize('broken', ['missing', 'metadata']) +def test_resource_failure_matches_desktop_installed_validation(tmp_path, broken): + directory, manifest, wheel = package(tmp_path, broken=broken) + # Existing inspection alone intentionally does not promise resource qualification. + inspect_wheel(wheel) + with pytest.raises(ValueError) as host_error: + validate_installed(manifest, directory) + result = cli('check-wheel', wheel) + assert result.returncode == 1 + assert json.loads(result.stdout)['error'] == str(host_error.value) + text = cli('check-package', directory, '--format', 'text') + assert text.returncode == 1 and str(host_error.value) in text.stdout + + +def test_legacy_json_helper_shape_and_secret_redaction(tmp_path): + _, manifest, _ = package(tmp_path) + manifest['config'] = {'token': {'type': 'string', 'title': 'Token', 'secret': True, 'required': True}} + body = json.dumps({'manifest': manifest, 'config': {'token': 'synthetic-private-value'}}) + for output in [[], ['--format', 'text']]: + result = cli('validate-config', *output, input=body) + assert result.returncode == 0 + assert 'synthetic-private-value' not in result.stdout + result.stderr + if not output: + assert json.loads(result.stdout) == {'ok': True, 'config': {}, 'missing': []} + malformed = cli('validate-config', '--format', 'text', input='{"synthetic-private-value":') + assert malformed.returncode == 1 and 'synthetic-private-value' not in malformed.stdout + malformed.stderr + + +def test_profile_reports_actual_host_and_does_not_open_new_contributions(): + payload = json.loads(cli('profile').stdout) + profile = payload['host_profile'] + assert profile['platform'] == sys.platform + assert {(row['kind'], row.get('role')) for row in profile['contributions'] if row['status'] == 'open'} == { + ('voice_backend', 'tts'), ('mcp_server', None), ('skill', None), ('theme', None)} + text = cli('profile', '--format', 'text') + assert text.returncode == 0 and profile['python'] in text.stdout + + +def test_target_admission_matches_native_validator(tmp_path): + directory, manifest, _ = package(tmp_path) + manifest = copy.deepcopy(manifest) + manifest['engines']['platforms'] = ['linux' if sys.platform == 'win32' else 'win32'] + (directory / 'amadeus-extension.json').write_text(json.dumps(manifest), encoding='utf-8') + with pytest.raises(ValueError) as error: + validate_manifest(manifest) + result = cli('check-package', directory) + assert result.returncode == 1 and json.loads(result.stdout)['error'] == str(error.value) + + +@pytest.mark.parametrize('defect', ['version', 'sdk', 'entry_point']) +def test_source_check_discloses_unchecked_wheel_metadata(tmp_path, defect): + source = ROOT / 'examples/extensions/mimo-tts/src/amadeus_example_mimo' + directory = tmp_path / source.name + shutil.copytree(source, directory) + manifest = json.loads((directory / 'amadeus-extension.json').read_text(encoding='utf-8')) + manifest['engines']['platforms'] = [sys.platform] + (directory / 'amadeus-extension.json').write_text(json.dumps(manifest), encoding='utf-8') + version = '9.9.0' if defect == 'version' else manifest['version'] + metadata = f"Name: example\nVersion: {version}\n" + if defect == 'sdk': + metadata += 'Requires-Dist: amadeus-extension-sdk==9.9\n' + wheel = tmp_path / 'example-0.1.0-py3-none-any.whl' + with zipfile.ZipFile(wheel, 'w') as archive: + for resource in directory.rglob('*'): + if resource.is_file(): + archive.write(resource, resource.relative_to(tmp_path).as_posix()) + archive.writestr('example-0.1.0.dist-info/METADATA', metadata) + entry = 'wrong_package' if defect == 'entry_point' else directory.name + archive.writestr('example-0.1.0.dist-info/entry_points.txt', + f"[amadeus.extensions]\n{manifest['id']} = {entry}\n") + source_result = cli('check-package', directory) + assert source_result.returncode == 0 + payload = json.loads(source_result.stdout) + assert 'host_admission' not in payload['checked'] + assert 'wheel_metadata' in payload['not_checked'] + assert 'check-wheel' in payload['next_step'] + source_text = cli('check-package', directory, '--format', 'text') + assert 'Host admission' not in source_text.stdout + assert 'Not checked: wheel metadata' in source_text.stdout + assert 'check-wheel' in source_text.stdout + with pytest.raises(ValueError) as installer_error: + inspect_wheel(wheel) + wheel_result = cli('check-wheel', wheel) + assert wheel_result.returncode == 1 + assert json.loads(wheel_result.stdout)['error'] == str(installer_error.value) + + +def test_wrong_source_directory_is_input_error_not_validator_unavailable(tmp_path): + for directory in [tmp_path, tmp_path / 'missing']: + result = cli('check-package', directory) + payload = json.loads(result.stdout) + assert result.returncode == 1 and not payload['ok'] + assert 'amadeus-extension.json' in payload['error'] + assert 'unavailable' not in payload + assert str(tmp_path) not in result.stdout diff --git a/tests/test_extension_catalog.py b/tests/test_extension_catalog.py new file mode 100644 index 00000000..436de58b --- /dev/null +++ b/tests/test_extension_catalog.py @@ -0,0 +1,164 @@ +"""Pinned catalog reader compatibility, policy separation and dependency classification.""" + +import hashlib +import json +import sys +from pathlib import Path +import pytest +from server.extensions.catalog import policy, read_catalog +from server.extensions.sdk import dependency_mode + +ROOT = Path(__file__).resolve().parents[1] + + +def entry(): + return dict( + id="org.example.test", + version="1.0.0", + sha256="a" * 64, + url="https://example.test/example-1.0.0-py3-none-any.whl", + size=100, + display={"display_name": ""}, + compatibility=dict( + host_api=">=0.1,<0.2", + python=">=3.12", + platforms=[sys.platform], + sdk="0.1.0", + dependencies=[], + delivery="python-wheel", + runtime="none", + contributions=[dict(kind="skill", role="", contract="amadeus.skill/v0")], + ), + publisher=dict( + id="author", + maintainers=["author"], + package_ids=["org.example.test"], + evidence="https://example.test/ownership", + ), + review=dict( + sha256="a" * 64, + policy="curated-v1", + reviewer="independent", + date="2026-10-21", + evidence="https://example.test/review", + ), + withdrawal=None, + ) + + +def index(): + return dict( + format="amadeus.catalog/v1", + sequence=1, + source_commit="a" * 40, + source_time="2026-10-21T00:00:00Z", + generator_commit="b" * 40, + host_commit=policy().PIN["host_commit"], + entries=[entry()], + security=[], + ) + + +def read(tmp_path, value): + file = tmp_path / "catalog.json" + file.write_text(json.dumps(value), encoding="utf-8") + return read_catalog(file) + + +def test_catalog_source_hashes_are_independently_pinned(): + source = json.loads((ROOT / "server/extensions/catalog-source.json").read_text()) + assert source["commit"] == "58d3892d55c6a7ec6f8626153f503291bb10ab9a" + for name, digest in source["files"].items(): + assert ( + hashlib.sha256((ROOT / "vendor/extension-catalog" / name).read_bytes()).hexdigest() + == digest + ) + + +def test_future_type_contract_and_mixed_package_preserve_compatible_rows_and_security(tmp_path): + original = index() + fixture = json.loads( + (ROOT / "vendor/extension-catalog/catalog/fixtures/compatibility.json").read_text() + ) + for case in fixture["cases"]: + future = entry() + future["id"] += "." + case["name"] + future["publisher"]["package_ids"] = [future["id"]] + future["compatibility"].update(case["compatibility"]) + original["entries"].append(future) + original["security"] = [ + dict( + id=entry()["id"], + version="1.0.0", + sha256="a" * 64, + status="blocked", + sequence=1, + reason="security", + evidence="https://example.test/ban", + ) + ] + result = read(tmp_path, original) + assert not result["entries"][0]["incompatible"] + assert [not row["incompatible"] for row in result["entries"][1:]] == [ + case["compatible"] for case in fixture["cases"] + ] + assert result["security"] == original["security"] + + +@pytest.mark.parametrize( + "field,value", [("format", "amadeus.catalog/v2"), ("security", [{}]), ("sequence", -1)] +) +def test_invalid_global_format_or_security_rejects_whole_snapshot(tmp_path, field, value): + original = index() + original[field] = value + with pytest.raises(ValueError): + read(tmp_path, original) + + +@pytest.mark.parametrize( + "field,value", + [ + ("host_api", ">=9.0"), + ("python", ">=99"), + ("platforms", ["future-os"]), + ("sdk", "9.0.0"), + ("dependencies", ["third-party>=1"]), + ], +) +def test_environment_and_dependency_incompatibility_is_per_entry(tmp_path, field, value): + original = index() + original["entries"][0]["compatibility"][field] = value + assert read(tmp_path, original)["entries"][0]["incompatible"] + + +@pytest.mark.parametrize( + "requires,expected", + [ + ([], "offline"), + (["amadeus-extension-sdk==0.1.0"], "offline"), + (['third-party; python_version < "1.0"'], "offline"), + (["third-party>=1"], "resolve"), + (["amadeus-extension-sdk[extra]==0.1.0"], "resolve"), + ], +) +def test_same_interpreter_dependency_classification(requires, expected): + assert dependency_mode(requires) == expected + + +@pytest.mark.parametrize( + "requires", + [ + ["amadeus-extension-sdk==9.9"], + ["amadeus-extension-sdk @ https://example.test/sdk.whl"], + ["invalid requirement !"], + ], +) +def test_invalid_requirements_do_not_become_offline(requires): + with pytest.raises(ValueError): + dependency_mode(requires) + + +def test_empty_platform_summary_means_no_platform_restriction(tmp_path): + original = index() + original["entries"][0]["compatibility"]["platforms"] = [] + assert not read(tmp_path, original)["entries"][0]["incompatible"] diff --git a/tests/test_extension_contract.py b/tests/test_extension_contract.py new file mode 100644 index 00000000..77f3c3fe --- /dev/null +++ b/tests/test_extension_contract.py @@ -0,0 +1,165 @@ +"""Author diagnostics and real installed Notes through the native Pi Provider.""" + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest +from test_pi_runtime import native_pi as native_pi + +ROOT = Path(__file__).resolve().parents[1] + + +def test_explicit_execution_and_missing_input_fail_without_secret_output(tmp_path): + fixture_value = "synthetic-do-not-print" + source = tmp_path / "record.json" + source.write_text(json.dumps({"secrets": {"token": fixture_value}})) + scenario = tmp_path / "scenario.json" + scenario.write_text("{}") + result = subprocess.run( + [ + sys.executable, + "-I", + str(ROOT / "tools/check_extension_contract.py"), + "--kind", + "mcp", + "--record", + str(source), + "--scenario", + str(scenario), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 1 + report = json.loads(result.stdout) + assert not report["ok"] and report["stage"] == "input" + assert set(report["layers"].values()) == {"not_run"} + assert fixture_value not in result.stdout + result.stderr + + +@pytest.fixture +def installed(): + value = os.environ.get("AMADEUS_TEST_AUTHOR_SNAPSHOTS") + if not value: + pytest.skip("run the explicit installer-backed author journey") + return Path(value) + + +@pytest.mark.parametrize("defect", ["unbound", "missing_resource", "timeout"]) +def test_author_failure_identifies_stage_and_exits_nonzero(installed, tmp_path, defect): + name = "notes" if defect == "unbound" else "handoff" + distribution = ( + "amadeus_example_notes_mcp" if name == "notes" else "amadeus_example_handoff_skill" + ) + record = json.loads((installed / f"{name}-0.2.0.json").read_text(encoding="utf-8")) + scenario = json.loads( + (ROOT / f"examples/extensions/contract-scenarios/{name}.json").read_text(encoding="utf-8") + ) + if defect == "unbound": + record["providers"] = [] + if defect == "missing_resource": + scenario["reads"].append("skills/handoff-brief/missing.txt") + source = tmp_path / "record.json" + source.write_text(json.dumps(record)) + plan = tmp_path / "scenario.json" + plan.write_text(json.dumps(scenario)) + result = subprocess.run( + [ + sys.executable, + "-I", + str(ROOT / "tools/check_extension_contract.py"), + "--execute", + "--kind", + "mcp" if name == "notes" else "skill", + "--wheel", + str(installed / f"wheels/{distribution}-0.2.0-py3-none-any.whl"), + "--record", + str(source), + "--scenario", + str(plan), + "--timeout", + "1" if defect == "timeout" else "90", + ], + capture_output=True, + text=True, + timeout=100, + ) + assert result.returncode == 1 + report = json.loads(result.stdout) + assert not report["ok"] and report["layers"]["static"] == "passed" + assert report["stage"] != "complete" and report["error"] + + +async def test_installed_notes_calls_use_real_pi_approval_and_native_transport( + installed, native_pi, tmp_path +): + from agent_host.extension_mcp import project_extension_mcp_connections + from agent_host.provider_contract import ProviderRequirements + from agent_host.provider_types import ProviderRunRequest, ProviderPermissionResponse + + record = json.loads((installed / "notes-0.2.0.json").read_text(encoding="utf-8")) + record["data_path"] = str(tmp_path / "notes-data") + Path(record["data_path"]).mkdir() + adapter, requests, selected, _ = native_pi + adapter.mcp_connections = project_extension_mcp_connections(record) + + def call(tool, arguments): + def step(data): + # Only one configured server. Pi preserves the original tool name in the bounded description. + choices = [ + t["function"]["name"] + for t in data["tools"] + if tool in t["function"]["description"] or tool in t["function"]["name"] + ] + assert len(choices) == 1, [ + (t["function"]["name"], t["function"]["description"][:160]) for t in data["tools"] + ] + return {"tool": choices[0], "args": arguments} + + return step + + selected["steps"] = [ + call("save_note", {"name": "native-author", "text": "Synthetic native Notes round trip"}), + call("read_note", {"name": "native-author"}), + ] + permissions = [] + events = [] + + async def emit(event): + events.append(event) + if event.type == "permission.requested": + permission = event.payload["permissionRequest"] + permissions.append(permission) + assert permission["capability"] == "mcp.tool.call" + await adapter.resolve_permission( + "author-notes", ProviderPermissionResponse(permission["request_id"], True) + ) + + request = ProviderRunRequest( + "pi", + "Exercise the selected synthetic Notes calls.", + cwd=str(tmp_path), + requirements=ProviderRequirements(workspace_access="none", workspace_ownership="caller"), + ) + result = await adapter.run(request, "author-notes", emit) + assert result.status == "done", result + assert len(permissions) == 2 + assert len([e for e in events if e.type == "tool.result" and e.payload.get("ok")]) == 2 + assert "Synthetic native Notes round trip" in str(requests[-1]["messages"]) + + +def test_native_theme_accepts_inherited_background_with_valid_overrides(installed, tmp_path): + from server.extensions.package import validate_manifest + from tools.check_extension_contract import check_theme + + record = json.loads((installed / "theme-0.2.0.json").read_text(encoding="utf-8")) + theme = record["manifest"]["contributes"]["theme"][0] + theme["tokens"].pop("--bg", None) + theme.pop("background", None) + record["manifest"]["contributes"]["theme"] = [theme] + validate_manifest(record["manifest"]) + result = check_theme(record, tmp_path, lambda stage: None) + assert result["nativeDecode"] and result["painted"] and result["builtinRestored"] diff --git a/tests/test_extension_examples.py b/tests/test_extension_examples.py index edcb072e..891906d1 100644 --- a/tests/test_extension_examples.py +++ b/tests/test_extension_examples.py @@ -22,7 +22,7 @@ import amadeus_example_mimo as mimo -@pytest.mark.parametrize('name', ['mimo-tts', 'fish-audio', 'notes-mcp', 'handoff-skill', 'csv-audit-skill', 'desktop-theme']) +@pytest.mark.parametrize('name', ['mimo-tts', 'fish-audio', 'notes-mcp', 'handoff-skill', 'csv-audit-skill', 'desktop-theme', 'tone-tts']) def test_example_package_conforms_to_implemented_host_profile(name): from server.extensions.package import validate_manifest, validate_installed example = ROOT / 'examples/extensions' / name diff --git a/tests/test_pi_mcp.py b/tests/test_pi_mcp.py index 4a163e45..2af9f076 100644 --- a/tests/test_pi_mcp.py +++ b/tests/test_pi_mcp.py @@ -114,13 +114,15 @@ async def test_only_host_bound_servers_load_despite_ambient_configuration(native encoding="utf-8") monkeypatch.setattr(settings, "PI_EXTENSIONS_JSON", json.dumps([str(extension)])) - async def emit(_event): - pass + events = [] + + async def emit(event): + events.append(event) result = await adapter.run(req, "bindings", emit) assert result.status == "done", result names = {tool["function"]["name"] for tool in requests[0]["tools"]} - assert "mcp__fixture__inspect_fixture" in names + assert "mcp__fixture__inspect_fixture" in names, [event for event in events if event.type == "runtime.notice"] assert "codemode" not in names and "tool_search" not in names assert not any(name.startswith(("mcp__extra__", "mcp__disabled__", "mcp__other__")) for name in names) assert (bound / "started.json").exists() diff --git a/tests/test_pi_scoped_approvals.py b/tests/test_pi_scoped_approvals.py index 8b10b427..488cc4f7 100644 --- a/tests/test_pi_scoped_approvals.py +++ b/tests/test_pi_scoped_approvals.py @@ -83,7 +83,8 @@ async def emit(event): approvals[0]["request_id"], False, scope="work")) assert outcome["accepted"] - assert (await adapter.run(request(tmp_path), "revoke", emit)).status == "done" + result = await adapter.run(request(tmp_path), "revoke", emit) + assert result.status == "done", result assert len(approvals) == 2 and len(uses) == 1 assert calls(root) == ["inspect", "inspect"] assert not (await adapter.resolve_permission("revoke", ProviderPermissionResponse( diff --git a/tests/test_release_tooling.py b/tests/test_release_tooling.py index 00529e17..834dfd4b 100644 --- a/tests/test_release_tooling.py +++ b/tests/test_release_tooling.py @@ -391,3 +391,40 @@ def test_first_party_brand_assets_are_release_ready() -> None: assert component["gate_status"] == "ready" assert component["license_expression"] == "AGPL-3.0" assert "LICENSES/FIRST-PARTY-BRAND-ASSETS.md" in component["license_evidence"] + + +def test_packaged_extension_checker_runs_without_source_checkout(tmp_path: Path): + """Copy exactly the configured shipping resources; isolated Python cannot fall back to source.""" + import shutil + import sys + package = json.loads((ROOT / "electron/package.json").read_text(encoding="utf-8")) + resource = next(item for item in package["build"]["extraResources"] if item["to"] == "extension-host") + destination = tmp_path / "resources" / resource["to"] + for pattern in resource["filter"]: + for source in ROOT.glob(pattern): + if source.is_file(): + target = destination / source.relative_to(ROOT) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + checker = destination / "tools/extension_package.py" + for command, arguments in (("profile", []), ("validate-catalog", [])): + if command == "validate-catalog": + # Build a compatible empty index, independent of public availability. + fixture = tmp_path / "index.json" + fixture.write_text(json.dumps({"format":"amadeus.catalog/v1", "sequence":0, + "source_commit":"a"*40, "source_time":"2026-10-21T00:00:00Z", + "generator_commit":"b"*40, "host_commit":"c"*40, + "entries":[], "security":[]}), encoding="utf-8") + arguments = [str(fixture)] + result = subprocess.run([sys.executable, "-I", str(checker), command, *arguments], + cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + result.stdout + assert json.loads(result.stdout)["ok"] + # Validate installed Skill metadata as well as manifests: this imports the + # existing native metadata owner, which must also be shipped. + manifest = next((ROOT / "examples/extensions/handoff-skill/src").glob("*/amadeus-extension.json")) + result = subprocess.run([sys.executable, "-I", str(checker), "check-package", str(manifest.parent)], + cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + result.stdout + assert json.loads(result.stdout)["ok"] + assert not (destination.parent / "server").exists(), "Static resources must not shadow the separately supplied backend" diff --git a/tools/check_extension_contract.py b/tools/check_extension_contract.py new file mode 100644 index 00000000..566d788e --- /dev/null +++ b/tools/check_extension_contract.py @@ -0,0 +1,338 @@ +"""Explicitly execute a trusted installed package in disposable data/session directories. + +Requires a wheel and ONE record exported from an isolated desktop installer snapshot. +This is an author diagnostic, not an OS sandbox or catalog approval. No implicit pip. +""" + +from __future__ import annotations +import argparse +import asyncio +import copy +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from tools.extension_package import check_wheel, host_profile +from config.subprocess_environment import isolated_child_environment +from config.process_tree import ProcessTree + + +def read_json(path): + with Path(path).open("rb") as source: + raw = source.read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024: + raise ValueError("input exceeds 1 MiB") + return json.loads(raw) + + +def checkpoint(path, report, stage): + report["stage"] = stage + path.write_text(json.dumps(report, ensure_ascii=True), encoding="utf-8") + + +def check_tts(record, scenario, mark): + from server.extensions.runner import RunnerClient + from server.extensions.tts import ExtensionTTSBackend + from tts.backend import TTSSynthesisRequest, TTSBackendError + + contribution = next( + c + for c in record["manifest"]["contributes"]["voice_backend"] + if c["id"] == scenario["contribution"] + ) + client = RunnerClient(record) + try: + mark("tts.initialize_ready") + client.start() + expires = time.monotonic() + 35 + while client.state == "starting" and time.monotonic() < expires: + time.sleep(0.02) + mark("tts.initialize_ready", runtime_state=client.state) + if client.state != "active": + raise RuntimeError("Runner not ready") + backend = ExtensionTTSBackend(record["id"], contribution, client) + mark("tts.synthesize") + audio = list( + backend.synthesize_stream( + TTSSynthesisRequest(text=scenario["text"], language=scenario["language"]) + ) + ) + if not audio or not any(chunk.audio.size for chunk in audio): + raise RuntimeError("No audio") + mark("tts.undeclared_language") + try: + list( + backend.synthesize_stream( + TTSSynthesisRequest(text="Synthetic negative check", language="zz") + ) + ) + except TTSBackendError: + pass + else: + raise RuntimeError("Undeclared language admitted") + mark("tts.cancel") + stream = client.stream( + contribution["id"], + text=scenario["cancel_text"], + language=scenario["language"], + speed=1, + deadline_ms=5000, + ) + next(stream) + stream.close() # same cooperative cancellation path as the Host stream owner + mark("tts.close") + finally: + client.close() + return { + "audio_chunks": len(audio), + "cancellation": "Host stream closed; adversarial late-audio cases are separate regressions", + } + + +async def check_mcp(record, scenario, mark): + from agent_host.extension_mcp import ( + project_extension_mcp_connections, + extension_mcp_connection_id, + ) + from agent_host.mcp_connections import open_mcp_connection + + mark("mcp.provider_binding") + provider = scenario["provider"] + connections = project_extension_mcp_connections(record) + connection = next( + c + for c in connections + if c.connection_id == extension_mcp_connection_id(record["id"], scenario["contribution"]) + ) + if not connection.enabled or not connection.supports_provider(provider): + raise ValueError("Selected Provider is not bound") + mark("mcp.discover") + async with open_mcp_connection(connection) as client: + tools = {tool.name for tool in (await client.list_tools()).tools} + for call in scenario["calls"]: + if call["tool"] not in tools: + raise ValueError("Tool not discovered") + mark("mcp.call") + result = await client.call_tool(call["tool"], call.get("arguments", {})) + if result.is_error: + raise RuntimeError("Tool returned an error") + if call.get("contains") and call["contains"] not in str(result): + raise RuntimeError("Tool output differs from synthetic expectation") + mark("mcp.close") + return {"tools": len(tools), "calls": len(scenario["calls"])} + + +async def check_skill(record, scenario, base, mark): + from tools.probes.extension_contract_pi import read_skill_with_pi + + return await read_skill_with_pi(record, scenario, base, mark) + + +def check_theme(record, base, mark): + executable = ROOT / "electron/node_modules/electron/dist/electron.exe" + if sys.platform != "win32" or not executable.is_file(): + raise RuntimeError("Windows Electron build required") + app = base / "theme-app" + app.mkdir() + (app / "package.json").write_text( + json.dumps( + { + "name": "extension-contract-theme", + "version": "0.0.0", + "type": "module", + "main": str(ROOT / "electron/scripts/check-extension-theme.mjs"), + } + ), + encoding="utf-8", + ) + selected = base / "theme-record.json" + selected.write_text( + json.dumps({"manifest": record["manifest"], "package_path": record["package_path"]}), + encoding="utf-8", + ) + mark("theme.native_decode_and_paint") + result = subprocess.run( + [str(executable), str(app)], + env=isolated_child_environment( + extra={ + "AMADEUS_CONTRACT_THEME_RECORD": str(selected), + "AMADEUS_CONTRACT_THEME_ROOT": str(base), + } + ), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=40, + ) + if result.returncode: + raise RuntimeError("Native theme validation failed") + return read_json(base / "theme-result.json") + + +def worker(args): + base = args.report.parent + report = read_json(args.report) + + def mark(stage, **facts): + report.update(facts) + checkpoint(args.report, report, stage) + + try: + from server.extensions.runtime import ExtensionRuntime + + record, missing = ExtensionRuntime._validate_record(read_json(args.record)) + if missing or not record["enabled"]: + raise ValueError("Enable and configure the isolated package first") + scenario = read_json(args.scenario) + mark("record.validated") + if args.kind == "tts": + detail = check_tts(record, scenario, mark) + layer = "process_contract" + elif args.kind == "mcp": + detail = asyncio.run(check_mcp(record, scenario, mark)) + layer = "process_contract" + elif args.kind == "skill": + detail = asyncio.run(check_skill(record, scenario, base, mark)) + layer = "native_agent" + else: + detail = check_theme(record, base, mark) + layer = "gui" + report["layers"][layer] = "passed" + report.update(ok=True, detail=detail) + mark("complete") + return 0 + except Exception as error: + # Arbitrary plugin exceptions/tool output may contain tokens. Report only controlled stages/classes. + report.update(ok=False, error=type(error).__name__) + checkpoint(args.report, report, report["stage"]) + return 1 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path) + parser.add_argument("--record", type=Path, required=True) + parser.add_argument("--scenario", type=Path, required=True) + parser.add_argument("--kind", choices=["tts", "mcp", "skill", "theme"], required=True) + parser.add_argument( + "--execute", action="store_true", help="Acknowledge that this trusted package will execute" + ) + parser.add_argument( + "--allow-remote", + action="store_true", + help="Allow an explicitly selected remote service; it may be billable", + ) + parser.add_argument("--timeout", type=int, default=90) + parser.add_argument("--output", type=Path) + parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--report", type=Path, help=argparse.SUPPRESS) + args = parser.parse_args(argv) + if args.worker: + return worker(args) + report = { + "ok": False, + "stage": "input", + "kind": args.kind, + "layers": dict.fromkeys( + ["static", "process_contract", "native_agent", "gui", "remote_service"], "not_run" + ), + } + try: + if not args.execute or not args.wheel or not 1 <= args.timeout <= 300: + raise ValueError("explicit execution and a wheel are required; timeout must be 1..300") + report["stage"] = "static" + inspection = check_wheel(args.wheel) + record = read_json(args.record) + if ( + record["manifest"] != inspection["manifest"] + or record["digest"] != inspection["digest"] + or record["id"] != inspection["manifest"]["id"] + or record["version"] != inspection["manifest"]["version"] + ): + raise ValueError("record does not identify this wheel") + contribution_kind = { + "tts": "voice_backend", + "mcp": "mcp_server", + "skill": "skill", + "theme": "theme", + }[args.kind] + contributions = record["manifest"]["contributes"].get(contribution_kind, []) + if not contributions: + raise ValueError("selected contribution kind is absent") + remote = any( + c.get("deployment") == "remote" or c.get("transport") == "http" for c in contributions + ) + if remote and not args.allow_remote: + raise ValueError("remote-service execution requires --allow-remote") + report.update( + identity={"id": record["id"], "version": record["version"], "sha256": record["digest"]}, + host=host_profile(), + ) + report["layers"]["static"] = "passed" + with tempfile.TemporaryDirectory(prefix="amadeus-contract-") as temporary: + base = Path(temporary) + record = copy.deepcopy(record) + record["data_path"] = str(base / "data") + Path(record["data_path"]).mkdir() + selected = base / "record.json" + selected.write_text(json.dumps(record), encoding="utf-8") + scenario = base / "scenario.json" + scenario.write_text(json.dumps(read_json(args.scenario)), encoding="utf-8") + progress = base / "report.json" + checkpoint(progress, report, "worker.start") + command = [ + sys.executable, + "-I", + str(Path(__file__).resolve()), + "--worker", + "--record", + str(selected), + "--scenario", + str(scenario), + "--kind", + args.kind, + "--report", + str(progress), + ] + env = isolated_child_environment(extra={"PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}) + process = subprocess.Popen( + command, + cwd=base, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=(0x4 | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0, + start_new_session=os.name != "nt", + ) + tree = ProcessTree(process) + try: + tree.resume() + try: + code = process.wait(timeout=args.timeout) + report = read_json(progress) + report["worker_exit_code"] = code + if code or report.get("stage") != "complete": + report["ok"] = False + except subprocess.TimeoutExpired: + report = read_json(progress) + report.update(ok=False, error="TimeoutExpired") + finally: + tree.terminate() + if remote and report["ok"]: + report["layers"]["remote_service"] = "passed" + except Exception as error: + report.update(ok=False, error=type(error).__name__) + encoded = json.dumps(report, ensure_ascii=True, indent=2) + "\n" + if args.output: + args.output.write_text(encoded, encoding="utf-8") + print(encoded, end="") + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/extension_package.py b/tools/extension_package.py index 2039c4a2..7963e275 100644 --- a/tools/extension_package.py +++ b/tools/extension_package.py @@ -1,61 +1,182 @@ -"""Static extension package validation CLI. JSON only; safe under Python -I.""" +"""Static extension authoring checks; JSON defaults remain compatible with Electron.""" from __future__ import annotations import argparse import json +import platform +import shutil import sys +import tempfile +import tomllib import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from server.extensions.package import inspect_wheel, prepare_data_wheel, validate_config, validate_installed, validate_manifest +from server.extensions.package import ( + HOST_API, MAX_WHEEL_BYTES, inspect_wheel, prepare_data_wheel, + validate_config, validate_installed, validate_manifest, +) +from server.extensions.profile import PROFILE +from server.extensions.sdk import sdk_spec + + +def host_profile() -> dict: + """Describe this checkout/interpreter, not an emulated target environment.""" + source = json.loads((ROOT / 'server/extensions/protocol-source.json').read_text(encoding='utf-8')) + project = tomllib.loads((ROOT / 'pyproject.toml').read_text(encoding='utf-8')) + return { + 'application_version': project['project']['version'], 'host_api': HOST_API, + 'python': platform.python_version(), 'platform': sys.platform, + 'sdk_version': sdk_spec()['version'], 'protocol_commit': source['commit'], + 'contributions': PROFILE, + } + + +def check_package(directory: Path) -> dict: + """Validate resources with the same owner used after desktop installation.""" + manifest_path = directory / 'amadeus-extension.json' + # validate_installed owns path/link/size/content checks. Read only bounded input. + from server.extensions.manifest import MANIFEST_LIMIT_BYTES + try: + with manifest_path.open('rb') as source: + raw = source.read(MANIFEST_LIMIT_BYTES + 1) + except FileNotFoundError: + raise ValueError('amadeus-extension.json was not found in this directory; select the import-package directory') from None + if len(raw) > MANIFEST_LIMIT_BYTES: + raise ValueError('manifest exceeds 256 KiB') + manifest = validate_manifest(json.loads(raw.decode('utf-8'))) + validate_installed(manifest, directory) + return {'manifest': manifest} + + +def check_wheel(path: Path) -> dict: + """Inspect a private snapshot and validate package resources without importing code.""" + if path.suffix.lower() != '.whl' or not path.is_file(): + raise ValueError('an existing local .whl file is required') + if path.stat().st_size > MAX_WHEEL_BYTES: + raise ValueError('wheel exceeds the 256 MiB inspection limit') + with tempfile.TemporaryDirectory(prefix='amadeus-authoring-') as temporary: + root = Path(temporary) + wheel = root / path.name + shutil.copyfile(path, wheel) + inspection = inspect_wheel(wheel) + prefix = inspection['package_root'] + '/' + # inspect_wheel checks all archive paths before any extraction. + with zipfile.ZipFile(wheel) as archive: + for member in archive.infolist(): + if member.filename.startswith(prefix): + archive.extract(member, root / 'resources') + validate_installed(inspection['manifest'], root / 'resources' / inspection['package_root']) + return inspection + + +def emit(payload: dict, command: str, output_format: str) -> None: + if output_format == 'json': + print(json.dumps(payload, ensure_ascii=True, allow_nan=False)) + return + profile = payload.get('host_profile') or host_profile() + print(f"Host {profile['application_version']} / API {profile['host_api']} / SDK {profile['sdk_version']}") + print(f"Target: {profile['platform']} / Python {profile['python']}") + print(f"{'PASS' if payload['ok'] else 'FAIL'}: {command}") + if not payload['ok']: + print(payload['error']) + return + if manifest := payload.get('manifest'): + print(f"Package: {manifest['id']} @ {manifest['version']}") + print('Contributions: ' + ', '.join(manifest['contributes'])) + if command == 'profile': + for row in profile['contributions']: + label = row['kind'] + (f"/{row['role']}" if row.get('role') else '') + print(f" {label}: {row['status']} ({row['owner']}; {row['apply']})") + return + if payload.get('digest'): + print('Wheel SHA-256: ' + payload['digest']) + if payload.get('missing'): + print('Missing configuration: ' + ', '.join(payload['missing'])) + if command in {'check-package', 'validate-installed'}: + print('Checked: manifest, package resources and Skill metadata. No package code executed.') + print('Not checked: wheel metadata (entry points, version consistency and SDK dependencies).') + print('Before installation, run check-wheel on the built wheel.') + elif command in {'check-wheel', 'prepare-data-wheel'}: + print('Checked: static wheel admission, package resources and Skill metadata. No package code executed.') + elif command == 'inspect-wheel': + print('Checked: wheel structure and Host admission. Use check-wheel for package resources.') + else: + print('Checked: static Host manifest/configuration rules.') + print('Not checked: runtime calls, native image/font/contrast validation, remote services or catalog review.') def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=["inspect-wheel", "prepare-data-wheel", "validate-config", "validate-manifest", "validate-installed"]) - parser.add_argument("path", nargs="?") - parser.add_argument("--manifest") - parser.add_argument("--config") + parser.add_argument('command', choices=[ + 'inspect-wheel', 'prepare-data-wheel', 'validate-config', 'validate-manifest', + 'validate-installed', 'profile', 'check-wheel', 'check-package', 'validate-catalog', 'inspect-catalog-wheel', + ]) + parser.add_argument('path', nargs='?') + parser.add_argument('--manifest') + parser.add_argument('--config') parser.add_argument('--destination') - parser.add_argument("--allow-incomplete", action="store_true") + parser.add_argument('--allow-incomplete', action='store_true') + parser.add_argument('--format', choices=['json', 'text'], default='json', + help='JSON is the existing Electron helper protocol; text is for authors.') args = parser.parse_args(argv) try: - if args.command == "inspect-wheel": - result = inspect_wheel(Path(args.path or "")) + if args.command == 'validate-catalog': + from server.extensions.catalog import read_catalog + result = {'catalog': read_catalog(Path(args.path or ''))} + elif args.command == 'inspect-catalog-wheel': + from server.extensions.catalog import policy + result = inspect_wheel(Path(args.path or '')) + policy().check_policy(Path(args.path), result) + elif args.command == 'profile': + result = {'host_profile': host_profile()} + elif args.command in {'check-wheel', 'check-package'}: + if not args.path: + raise ValueError('A wheel or import-package directory is required') + result = check_wheel(Path(args.path)) if args.command == 'check-wheel' else check_package(Path(args.path)) + result.update(host_profile=host_profile(), + checked=['manifest', 'package_resources', 'skill_metadata'], + not_checked=['runtime', 'native_theme', 'remote_services', 'catalog_review']) + if args.command == 'check-wheel': + result['checked'].append('wheel_metadata') + else: + result['not_checked'].append('wheel_metadata') + result['next_step'] = 'Run check-wheel on the built wheel before installation.' + elif args.command == 'inspect-wheel': + result = inspect_wheel(Path(args.path or '')) elif args.command == 'prepare-data-wheel': if not args.destination: raise ValueError('Resource destination is required') result = prepare_data_wheel(Path(args.path or ''), Path(args.destination)) else: if args.manifest: - body = {"manifest": json.loads(Path(args.manifest).read_text(encoding="utf-8")), - "config": json.loads(Path(args.config).read_text(encoding="utf-8")) if args.config else {}, - "allow_incomplete": args.allow_incomplete} + body = {'manifest': json.loads(Path(args.manifest).read_text(encoding='utf-8')), + 'config': json.loads(Path(args.config).read_text(encoding='utf-8')) if args.config else {}, + 'allow_incomplete': args.allow_incomplete} else: raw = sys.stdin.buffer.read(1024 * 1024 + 1) if len(raw) > 1024 * 1024: - raise ValueError("validation input exceeds 1 MiB") - body = json.loads(raw.decode("utf-8")) - manifest = validate_manifest(body["manifest"]) - if args.command == "validate-installed": - validate_installed(manifest, Path(args.path or "")) + raise ValueError('validation input exceeds 1 MiB') + body = json.loads(raw.decode('utf-8')) + manifest = validate_manifest(body['manifest']) + if args.command == 'validate-installed': + validate_installed(manifest, Path(args.path or '')) result = {} - elif args.command == "validate-manifest": - result = {"manifest": manifest} + elif args.command == 'validate-manifest': + result = {'manifest': manifest} else: - resolved, missing = validate_config(manifest, body.get("config", {}), allow_incomplete=bool(body.get("allow_incomplete"))) - result = {"config": {key: value for key, value in resolved.items() if not manifest.get("config", {}).get(key, {}).get("secret")}, "missing": missing} - print(json.dumps({"ok": True, **result}, ensure_ascii=True, allow_nan=False)) + resolved, missing = validate_config(manifest, body.get('config', {}), allow_incomplete=bool(body.get('allow_incomplete'))) + result = {'config': {key: value for key, value in resolved.items() if not manifest.get('config', {}).get(key, {}).get('secret')}, 'missing': missing} + emit({'ok': True, **result}, args.command, args.format) return 0 except (ValueError, KeyError, TypeError, OSError, EOFError, UnicodeError, zipfile.BadZipFile) as exc: - # Config validators never include values; malformed JSON may carry secret input. - error = "invalid JSON input" if isinstance(exc, json.JSONDecodeError) else "invalid UTF-8 input" if isinstance(exc, UnicodeError) else str(exc) if isinstance(exc, ValueError) else "package validation failed" - print(json.dumps({"ok": False, "error": error, **({"unavailable": True} if isinstance(exc, OSError) else {})}, ensure_ascii=True)) + # Keep the existing helper's sanitized error contract, including unavailable. + error = 'invalid JSON input' if isinstance(exc, json.JSONDecodeError) else 'invalid UTF-8 input' if isinstance(exc, UnicodeError) else str(exc) if isinstance(exc, ValueError) else 'package validation failed' + emit({'ok': False, 'error': error, **({'unavailable': True} if isinstance(exc, OSError) else {})}, args.command, args.format) return 1 -if __name__ == "__main__": +if __name__ == '__main__': raise SystemExit(main()) diff --git a/tools/probes/extension_author_journey.py b/tools/probes/extension_author_journey.py new file mode 100644 index 00000000..39eec198 --- /dev/null +++ b/tools/probes/extension_author_journey.py @@ -0,0 +1,168 @@ +"""Offline four-owner author journey using real wheels, installer, SDK and native checks.""" + +from __future__ import annotations +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import uuid + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from config.subprocess_environment import isolated_child_environment +from config.process_tree import ProcessTree +from tools.probes.extension_theme_lifecycle import build_wheel + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=ROOT / "build/extension-author-journey") + args = parser.parse_args() + if sys.platform != "win32": + parser.error("This combined installer/native GUI journey currently qualifies Windows only") + base = args.output_dir.resolve() / uuid.uuid4().hex[:12] + if not base.is_relative_to(ROOT / "build"): + parser.error("Output must remain under this checkout build directory") + base.mkdir(parents=True) + wheels = base / "wheels" + wheels.mkdir() + env = isolated_child_environment( + extra={ + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8", + "AMADEUS_TEST_PYTHON": sys.executable, + "AMADEUS_AUTHOR_JOURNEY_ROOT": str(base), + "PIP_NO_INDEX": "1", + "PIP_CONFIG_FILE": os.devnull, + "PIP_FIND_LINKS": str(ROOT / "vendor/extension-sdk"), + } + ) + cases = [ + ("tone", "tone-tts", "tts", "amadeus_example_tone_tts"), + ("notes", "notes-mcp", "mcp", "amadeus_example_notes_mcp"), + ("handoff", "handoff-skill", "skill", "amadeus_example_handoff_skill"), + ("theme", "desktop-theme", "theme", "amadeus_example_theme"), + ] + report = {"ok": False, "checks": [], "nativeDialogs": False, "remoteServices": False} + + def run(name, command, environment=env, timeout=180): + with (base / (name + ".log")).open("w", encoding="utf-8") as log: + process = subprocess.Popen( + command, + cwd=ROOT, + env=environment, + stdout=log, + stderr=subprocess.STDOUT, + creationflags=(0x4 | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0, + start_new_session=os.name != "nt", + ) + tree = ProcessTree(process) + try: + tree.resume() + code = process.wait(timeout=timeout) + finally: + tree.terminate() + if code: + raise RuntimeError(name) + report["checks"].append(name) + + try: + for name, example, _, _ in cases: + for version in ["0.1.0", "0.2.0"]: + source = base / (name + "-" + version + "-source") + shutil.copytree( + ROOT / "examples/extensions" / example, + source, + ignore=shutil.ignore_patterns("build", "*.egg-info", "__pycache__"), + ) + project = source / "pyproject.toml" + project.write_text( + project.read_text(encoding="utf-8").replace( + 'version = "0.1.0"', f'version = "{version}"' + ), + encoding="utf-8", + ) + (manifest_path,) = source.glob("src/*/amadeus-extension.json") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = version + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + if name == "handoff" and version == "0.2.0": + reference = manifest_path.parent / "skills/handoff-brief/references/layout.json" + reference.write_text( + reference.read_text(encoding="utf-8").replace( + "handoff-layout-v1", "handoff-layout-v2" + ), + encoding="utf-8", + ) + build_wheel(source, wheels, env) + app = base / "installer-app" + app.mkdir() + (app / "package.json").write_text( + json.dumps( + { + "name": "author-installer-probe", + "version": "0.0.0", + "type": "module", + "main": str(ROOT / "electron/tests/extensionAuthoring.smoke.mjs"), + } + ), + encoding="utf-8", + ) + driver = [str(ROOT / "electron/node_modules/electron/dist/electron.exe"), str(app)] + for version, phase in [("0.1.0", "--prepare"), ("0.2.0", "--update")]: + run("installer" + phase, driver + [phase]) + for name, _, kind, distribution in cases: + run( + name + "-" + version, + [ + sys.executable, + "-I", + "tools/check_extension_contract.py", + "--execute", + "--kind", + kind, + "--wheel", + str(wheels / f"{distribution}-{version}-py3-none-any.whl"), + "--record", + str(base / f"{name}-{version}.json"), + "--scenario", + str(ROOT / f"examples/extensions/contract-scenarios/{name}.json"), + "--output", + str(base / f"{name}-{version}-report.json"), + ], + ) + native_env = { + **env, + "AMADEUS_TEST_SKILL_SNAPSHOTS": str(base), + "AMADEUS_TEST_AUTHOR_SNAPSHOTS": str(base), + } + run( + "native-provider-regressions", + [ + sys.executable, + "-m", + "pytest", + "-q", + "--tb=short", + "tests/test_skill_pi_runtime.py", + "tests/test_extension_contract.py", + ], + native_env, + 300, + ) + run("installer--finish", driver + ["--finish"]) + report["ok"] = True + except (RuntimeError, subprocess.SubprocessError) as error: + report["failed_stage"] = ( + str(error) if isinstance(error, RuntimeError) else type(error).__name__ + ) + (base / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"report": str(base / "report.json"), **report}, indent=2)) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/probes/extension_catalog_journey.py b/tools/probes/extension_catalog_journey.py new file mode 100644 index 00000000..39d7895a --- /dev/null +++ b/tools/probes/extension_catalog_journey.py @@ -0,0 +1,199 @@ +"""Explicit local HTTPS/installer/recovery qualification with real reference wheels. + +Requires cryptography for disposable loopback TLS certificates; never changes OS trust. +The Electron driver pins only this generated certificate in its isolated test session. +""" + +from __future__ import annotations +import argparse +import datetime +import hashlib +import ipaddress +import json +import os +from pathlib import Path +import subprocess +import sys +import uuid + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from server.extensions.catalog import policy +from tools.extension_package import host_profile +from config.process_tree import ProcessTree +from config.subprocess_environment import isolated_child_environment + + +def create_certificate(base: Path): + """Create disposable loopback TLS material without changing OS trust.""" + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] + ), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + (base / "key.pem").write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + (base / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--execute", action="store_true", required=True) + parser.add_argument("--wheels", type=Path, required=True) + parser.add_argument( + "--store-ui", + action="store_true", + help="Run the real store UI journey with both reference versions", + ) + parser.add_argument("--prepare-only", action="store_true", help="Prepare an isolated TLS/catalog fixture without starting Electron") + parser.add_argument("--failure-fixture", action="store_true", help="Add a deliberately failing runtime version for shipping-shell qualification") + args = parser.parse_args() + base = ROOT / "build/extension-catalog-journey" / uuid.uuid4().hex[:12] + base.mkdir(parents=True) + create_certificate(base) + entries = [] + wheels = {} + for name, version in [ + (name, version) + for name in ["tone_tts", "notes_mcp", "handoff_skill", "theme"] + for version in (["0.1.0", "0.2.0"] if args.store_ui else ["0.1.0"]) + ]: + wheel = args.wheels.resolve() / f"amadeus_example_{name}-{version}-py3-none-any.whl" + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + source = dict( + wheel=dict(url="https://artifact.test/" + wheel.name, sha256=digest), + publisher=dict( + id="fixture-author", + maintainers=["fixture-author"], + package_ids=[ + { + "tone_tts": "com.example.tone-tts", + "notes_mcp": "com.example.notes-mcp", + "handoff_skill": "com.example.handoff-skill", + "theme": "com.example.desktop-theme", + }[name] + ], + evidence="https://fixture.test/ownership", + ), + review=dict( + sha256=digest, + policy="curated-v1", + reviewer="fixture-reviewer", + date="2026-10-21", + evidence="https://fixture.test/review", + ), + withdrawal=None, + ) + entries.append( + policy().inspect_entry(source, wheel, ROOT, Path(sys.executable), host_profile()) + ) + wheels[wheel.name] = str(wheel) + index = dict( + format="amadeus.catalog/v1", + sequence=1, + source_commit="a" * 40, + source_time="2026-10-21T00:00:00Z", + generator_commit=policy().PIN["host_commit"], + host_commit=policy().PIN["host_commit"], + entries=entries, + security=[], + ) + policy().validate_index(index) + fixture = dict(index=index, wheels=wheels) + if args.failure_fixture: + from tools.probes.extension_release_failures import prepare_startup_failure + failure, wheel = prepare_startup_failure(base, entries[0]) + fixture["startup_failure"] = failure + wheels[wheel.name] = str(wheel) + (base / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8") + if args.prepare_only: + print(json.dumps({"fixture": str(base)})) + return 0 + app = base / "app" + app.mkdir() + (app / "package.json").write_text( + json.dumps( + dict( + name="catalog-qualification", + version="0.0.0", + type="module", + main=str( + ROOT + / ( + "electron/tests/extensionStore.smoke.mjs" + if args.store_ui + else "electron/tests/extensionCatalog.smoke.mjs" + ) + ), + ) + ) + ) + env = isolated_child_environment( + extra={ + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8", + "AMADEUS_CATALOG_JOURNEY_ROOT": str(base), + "AMADEUS_TEST_PYTHON": sys.executable, + } + ) + command = [str(ROOT / "electron/node_modules/electron/dist/electron.exe"), str(app)] + + def run(phase, arguments, timeout): + with (base / (phase + ".log")).open("w", encoding="utf-8") as log: + process = subprocess.Popen( + arguments, + cwd=ROOT, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + creationflags=(0x4 | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0, + start_new_session=os.name != "nt", + ) + tree = ProcessTree(process) + try: + tree.resume() + return process.wait(timeout=timeout) + finally: + tree.terminate() + + if args.store_ui: + subprocess.run( + ["node", "--check", str(ROOT / "electron/tests/extensionStore.smoke.mjs")], + cwd=ROOT, + env=env, + check=True, + timeout=30, + ) + code = run("driver", command, 600) + if code == 0 and not args.store_ui: + code = run("recovery", command + ["--recover"], 240) + print(json.dumps(dict(ok=code == 0, report=str(base / "report.json"), returncode=code))) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/probes/extension_contract_pi.py b/tools/probes/extension_contract_pi.py new file mode 100644 index 00000000..6d787a6c --- /dev/null +++ b/tools/probes/extension_contract_pi.py @@ -0,0 +1,155 @@ +"""Controlled local model driving the real Pi owner; no account or remote service.""" + +from dataclasses import replace +import json +from pathlib import Path +from aiohttp import web + + +async def read_skill_with_pi(record, scenario, base, mark): + from agent_host.adapters.pi import PiAdapter + from agent_host.skills import SkillCatalog, configure_skills + from agent_host.provider_contract import ProviderRequirements + from agent_host.provider_types import ProviderRunRequest + + if scenario["provider"] != "pi" or "pi" not in record["providers"]: + raise ValueError("This native check requires an existing Pi binding") + catalog = SkillCatalog([record]) + skills = catalog.for_provider("pi") + if catalog.errors or not skills: + raise ValueError("Skill catalog rejected the selected package") + reads = [] + package = Path(record["package_path"]).resolve() + for relative in scenario["reads"]: + target = (package / relative).resolve() + if not any(target.is_relative_to(skill.path.parent.resolve()) for skill in skills): + raise ValueError("Requested read is outside the selected Skill resources") + reads.append(str(target)) + if not reads or not all(str(skill.path.resolve()) in reads for skill in skills): + raise ValueError("Read the selected SKILL.md before its resources") + pending = list(reads) + calls = [] + + async def completions(request): + data = await request.json() + calls.append(data) + delta = {"role": "assistant", "content": "Controlled native Skill check complete."} + if pending: + delta = { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": f"call_{len(calls)}", + "type": "function", + "function": { + "name": "read", + "arguments": json.dumps({"path": pending.pop(0)}), + }, + } + ], + } + frames = [ + { + "id": "fixture", + "object": "chat.completion.chunk", + "created": 1, + "model": "fixture", + "choices": [{"index": 0, "delta": delta, "finish_reason": None}], + }, + { + "id": "fixture", + "object": "chat.completion.chunk", + "created": 1, + "model": "fixture", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "tool_calls" if "tool_calls" in delta else "stop", + } + ], + }, + ] + return web.Response( + text="".join("data: " + json.dumps(f) + "\n\n" for f in frames) + "data: [DONE]\n\n", + content_type="text/event-stream", + ) + + service = web.Application() + service.router.add_post("/v1/chat/completions", completions) + runner = web.AppRunner(service) + await runner.setup() + adapter = None + try: + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + agent = base / "pi" + agent.mkdir() + (agent / "models.json").write_text( + json.dumps( + { + "providers": { + "fixture": { + "baseUrl": f"http://127.0.0.1:{port}/v1", + "api": "openai-completions", + "apiKey": "local-fixture", + "models": [{"id": "fixture", "reasoning": False, "input": ["text"]}], + } + } + } + ), + encoding="utf-8", + ) + configure_skills(catalog) + adapter = PiAdapter( + agent_dir=agent, + model_provider="fixture", + model="fixture", + timeout=25, + mcp_connections=(), + ) + mark("skill.native_discovery_and_read") + adapter.require_startup_ready() + events = [] + + async def emit(event): + events.append(event) + + request = ProviderRunRequest( + "pi", + "Read the selected Skill and supplied resources.", + cwd=str(base), + requirements=ProviderRequirements( + workspace_access="none", workspace_ownership="caller" + ), + ) + result = await adapter.run(request, "author-skill-read", emit) + successful = [ + event for event in events if event.type == "tool.result" and event.payload.get("ok") + ] + if ( + result.status != "done" + or len(successful) != len(reads) + or catalog.native_errors + or not result.session.skill_bindings + ): + raise RuntimeError("Native Skill discovery/read failed") + mark("skill.native_resume") + restored = await adapter.run( + replace(request, session=result.session), "author-skill-resume", emit + ) + if restored.status != "done" or restored.session != result.session: + raise RuntimeError("Native Skill session did not resume unchanged") + return { + "provider": "pi", + "reads": len(reads), + "resumed": True, + "model": "controlled local fixture; selection quality not tested", + } + finally: + if adapter: + await adapter.close() + configure_skills(SkillCatalog()) + await runner.cleanup() diff --git a/tools/probes/extension_native_confirmation.py b/tools/probes/extension_native_confirmation.py new file mode 100644 index 00000000..5bd6ef9f --- /dev/null +++ b/tools/probes/extension_native_confirmation.py @@ -0,0 +1,60 @@ +"""Observe real native confirmations; operator clicks are never replaced by a stub.""" +from __future__ import annotations +import asyncio +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +async def exercise_native(product, fixture: Path): + from tools.probes.extension_release_shell import evaluate_main + + report = {"ok": False, "operator": "external UI operator (record separately)", + "fixture_authority": True, "native_confirmation": "real dialog, no automatic response", + "checks": []} + destination = product.run_root / "native-confirmation-report.json" + + def stage(name): + report["stage"] = name + destination.write_text(json.dumps(report, indent=2), encoding="utf-8") + print("Native confirmation: " + name, flush=True) + + async def catalog(request): + return await asyncio.wait_for( + product.page.evaluate("r=>window.amadeus.extensionCatalog(r)", request), 300) + + def inventory(): + return json.loads((product.run_root / "state/electron-user-data/settings.json").read_text())["extensions"] + + try: + code = (ROOT / "electron/tests/fixtures/extensionRelease.cjs").read_text() + await evaluate_main(product, f"(async()=>{{{code};return setupReleaseFixture({json.dumps(str(fixture))},false)}})()") + assert (await catalog({"action": "refresh"}))["ok"] + request = {"action": "install", "id": "com.example.desktop-theme", "version": "0.1.0"} + stage("cancel installation in the real native dialog") + result = await catalog(request) + assert result.get("cancelled") and not inventory(), result + report["checks"].append("native Cancel leaves no installed package") + stage("confirm installation in the real native dialog") + result = await catalog(request) + assert result["ok"] and inventory()[request["id"]]["enabled"] is False, result + report["checks"].append("native Install creates a disabled package") + stage("confirm activation in the real native dialog") + result = await asyncio.wait_for(product.page.evaluate( + "id=>window.amadeus.updateExtension({id,enabled:true})", request["id"]), 300) + assert result["ok"] and inventory()[request["id"]]["enabled"] is True, result + report["checks"].append("native activation confirmation enables the package") + result = await product.page.evaluate("window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_THEME:'com.example.desktop-theme/day'}})") + assert result["ok"] + theme = await product.page.evaluate("window.amadeus.getDesktopTheme()") + assert theme["theme"]["version"] == "0.1.0", theme + report["checks"].append("confirmed theme becomes active") + observed = await evaluate_main(product, "({count:extensionReleaseFixture.confirmations,last:extensionReleaseFixture.lastConfirmation})") + assert observed["count"] == 3, observed + report["observed_dialogs"] = observed + report["ok"] = True + stage("complete") + return report + finally: + destination.write_text(json.dumps(report, indent=2), encoding="utf-8") diff --git a/tools/probes/extension_reader_matrix.py b/tools/probes/extension_reader_matrix.py new file mode 100644 index 00000000..087565a6 --- /dev/null +++ b/tools/probes/extension_reader_matrix.py @@ -0,0 +1,132 @@ +"""Run unmodified pinned catalog readers against a future-entry fixture (no plugin execution).""" + +from __future__ import annotations +import argparse +import copy +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixture", type=Path, required=True) + parser.add_argument("--ref", action="append", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + destination = args.output.resolve() + destination.mkdir(parents=True, exist_ok=False) + index = json.loads(args.fixture.read_text(encoding="utf-8"))["index"] + cases = json.loads( + (ROOT / "vendor/extension-catalog/catalog/fixtures/compatibility.json").read_text() + )["cases"] + baseline = copy.deepcopy(index["entries"][0]) + expected = [] + for case in cases: + entry = copy.deepcopy(baseline) + entry["id"] += "." + case["name"] + entry["publisher"]["package_ids"] = [entry["id"]] + entry["compatibility"].update(case["compatibility"]) + index["entries"].append(entry) + expected.append(case["compatible"]) + index["security"] = [ + dict( + id=baseline["id"], + version=baseline["version"], + sha256=baseline["sha256"], + status="blocked", + sequence=1, + reason="security", + evidence="https://fixture.test/blocked", + ) + ] + inputs = { + "future-entries": index, + "unknown-format": {**index, "format": "amadeus.catalog/v2"}, + "invalid-security": {**index, "security": [{}]}, + } + paths = {} + for name, value in inputs.items(): + path = destination / (name + ".json") + path.write_text(json.dumps(value), encoding="utf-8") + paths[name] = path + resource = json.loads((ROOT / "electron/package.json").read_text())["build"]["extraResources"] + patterns = next(item["filter"] for item in resource if item["to"] == "extension-host") + git_paths = [p.removesuffix("/**/*") for p in patterns] + report = { + "fixture_authority": True, + "python": sys.version.split()[0], + "platform": sys.platform, + "input_sha256": { + name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in paths.items() + }, + "readers": [], + } + for ref in args.ref: + commit = subprocess.check_output( + ["git", "rev-parse", ref + "^{commit}"], cwd=ROOT, text=True + ).strip() + snapshot = destination / commit + snapshot.mkdir() + files = subprocess.check_output( + ["git", "ls-tree", "-r", "--name-only", commit, "--", *git_paths], cwd=ROOT, text=True + ).splitlines() + hashes = {} + for name in files: + data = subprocess.check_output(["git", "show", commit + ":" + name], cwd=ROOT) + target = snapshot / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + hashes[name] = hashlib.sha256(data).hexdigest() + results = {} + for name, path in paths.items(): + process = subprocess.run( + [ + sys.executable, + "-I", + str(snapshot / "tools/extension_package.py"), + "validate-catalog", + str(path), + ], + cwd=snapshot, + capture_output=True, + text=True, + timeout=30, + ) + value = json.loads(process.stdout) + if name == "future-entries": + assert process.returncode == 0 and value["ok"], value + catalog = value["catalog"] + assert [ + not e["incompatible"] for e in catalog["entries"][-len(cases) :] + ] == expected + assert all(not e["incompatible"] for e in catalog["entries"][: -len(cases)]) + assert catalog["security"] == index["security"] + results[name] = { + "compatible_neighbors": len(catalog["entries"]) - len(cases), + "case_results": dict(zip((c["name"] for c in cases), expected)), + "safety_retained": True, + } + else: + assert process.returncode != 0 and not value["ok"], value + results[name] = "rejected" + report["readers"].append({"commit": commit, "files": hashes, "results": results}) + (destination / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print( + json.dumps( + { + "ok": True, + "readers": len(report["readers"]), + "report": str(destination / "report.json"), + } + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/probes/extension_release_failures.py b/tools/probes/extension_release_failures.py new file mode 100644 index 00000000..4496ab0b --- /dev/null +++ b/tools/probes/extension_release_failures.py @@ -0,0 +1,330 @@ +"""Deliberate failures in the shipping app, using only an owned disposable profile.""" + +from __future__ import annotations +import asyncio +import copy +import hashlib +import json +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def prepare_startup_failure(base, reference): + from tools.probes.extension_theme_lifecycle import build_wheel + from tools.extension_package import host_profile + from server.extensions.catalog import policy + from config.subprocess_environment import isolated_child_environment + + source = base / "failing-tone-source" + shutil.copytree( + ROOT / "examples/extensions/tone-tts", + source, + ignore=shutil.ignore_patterns("build", "*.egg-info", "__pycache__"), + ) + package = source / "src/amadeus_example_tone" + manifest_path = package / "amadeus-extension.json" + manifest = json.loads(manifest_path.read_text()) + manifest["version"] = "0.3.0" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + project = source / "pyproject.toml" + project.write_text( + project.read_text().replace('version = "0.1.0"', 'version = "0.3.0"'), encoding="utf-8" + ) + (package / "__init__.py").write_text( + 'raise RuntimeError("synthetic startup rejection")\n', encoding="utf-8" + ) + destination = base / "failing-wheel" + destination.mkdir() + build_wheel(source, destination, isolated_child_environment()) + wheel = next(destination.glob("*.whl")) + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + facts = { + "wheel": {"url": "https://artifact.test/" + wheel.name, "sha256": digest}, + "publisher": reference["publisher"], + "review": {**reference["review"], "sha256": digest}, + "withdrawal": None, + } + entry = policy().inspect_entry(facts, wheel, ROOT, Path(sys.executable), host_profile()) + return entry, wheel + + +async def exercise_failures(product, fixture): + from tools.probes.extension_release_shell import evaluate_main + from tools.smoke_electron_model_less import _wait_enabled + + checks = [] + report = { + "ok": False, + "checks": checks, + "fixture_authority": True, + "native_confirmation": "automated", + "damage": "NUL file injection while app is stopped; not physical power loss", + } + data = json.loads((fixture / "fixture.json").read_text()) + index = copy.deepcopy(data["index"]) + index["sequence"] = 3 + state_path = product.run_root / "state/electron-user-data/settings.json" + assert state_path.resolve().is_relative_to(product.run_root.resolve()) + theme = "com.example.desktop-theme" + tone = "com.example.tone-tts" + + def check(name, value=True): + if not value: + raise AssertionError(name) + checks.append(name) + (product.run_root / "extension-failure-report.json").write_text( + json.dumps(report, indent=2), encoding="utf-8" + ) + + async def main(expression): + return await evaluate_main(product, expression) + + async def catalog(request): + return await asyncio.wait_for( + product.page.evaluate("r=>window.amadeus.extensionCatalog(r)", request), 180 + ) + + async def update(package_id, enabled): + response = await product.page.evaluate( + "r=>window.amadeus.updateExtension(r)", {"id": package_id, "enabled": enabled} + ) + assert response["ok"], response.get("error") + + async def refresh(): + await main("extensionReleaseFixture.index=" + json.dumps(index)) + return await catalog({"action": "refresh"}) + + async def install(package_id, version): + return await catalog({"action": "install", "id": package_id, "version": version}) + + async def restart(damage=None, offline=False): + old_pid = product.process.pid + await product.stop() + assert product.process.poll() is not None + phase = report.get("app_restarts", 0) + 1 + report["app_restarts"] = phase + shutil.copyfile(product.log_path, product.run_root / f"restart-{phase}-previous.log") + if damage: + for path in damage: + assert path.resolve().is_relative_to(product.run_root.resolve()) + path.write_bytes(b"\0" * 1536) + await product.start(startup_timeout=120) + await _wait_enabled( + product.page.locator('textarea[placeholder*="Type a message"]'), timeout=60 + ) + assert product.process.pid != old_pid + code = (ROOT / "electron/tests/fixtures/extensionRelease.cjs").read_text() + await main( + f"(async()=>{{{code};return setupReleaseFixture({json.dumps(str(fixture))})}})()" + ) + await main( + "Object.assign(extensionReleaseFixture," + + json.dumps({"index": index, "offline": offline}) + + ")" + ) + + async def backend_restart(): + assert await product.page.evaluate("window.amadeus.restartBackend()") + + async def runtime_state(version, expected): + for _ in range(80): + runtime = await product.runtime_status() + rows = runtime.get("extensions", []) + assert isinstance(rows, list), "Runtime extension status is unavailable" + found = next((r for r in rows if r["id"] == tone and r["version"] == version), None) + if found and found["state"] in expected: + return found + await asyncio.sleep(0.5) + report["unexpected_runtime"] = rows + (product.run_root / "extension-failure-report.json").write_text( + json.dumps(report, indent=2), encoding="utf-8" + ) + raise AssertionError("Runtime never reached expected plugin state") + + try: + await restart() + check( + "fresh app process completes prior pending removals", + not json.loads(state_path.read_text())["extensions"], + ) + await main("extensionReleaseFixture.holdWheel=true") + pending = asyncio.create_task(install(theme, "0.1.0")) + for _ in range(150): + status = await catalog({"action": "status"}) + if (status.get("progress") or {}).get("received", 0) > 0: + break + await asyncio.sleep(0.1) + else: + raise AssertionError("Download never yielded progress") + await catalog({"action": "cancel"}) + cancelled = await pending + check( + "cancelled streaming download does not install", + cancelled.get("cancelled") and not json.loads(state_path.read_text())["extensions"], + ) + await main("extensionReleaseFixture.holdWheel=false") + assert (await install(theme, "0.1.0"))["ok"] + await update(theme, True) + await product.page.evaluate( + "window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_THEME:'com.example.desktop-theme/day'}})" + ) + await main("extensionReleaseFixture.badWheel=true") + rejected = await install(theme, "0.2.0") + check( + "wrong digest update preserves previous installed and applied version", + not rejected["ok"] + and json.loads(state_path.read_text())["extensions"][theme]["version"] == "0.1.0" + and (await product.page.evaluate("window.amadeus.getDesktopTheme()"))["theme"][ + "version" + ] + == "0.1.0", + ) + await main("extensionReleaseFixture.badWheel=false") + index["sequence"] += 1 + next(e for e in index["entries"] if e["id"] == theme and e["version"] == "0.2.0")[ + "withdrawal" + ] = {"reason": "copyright", "evidence": "https://fixture.test/copyright"} + assert (await refresh())["ok"] + check( + "copyright withdrawal blocks distribution without security ban", + not (await install(theme, "0.2.0"))["ok"] + and not json.loads(state_path.read_text())["extensionSecurity"]["decisions"], + ) + theme_data = ( + state_path.parent / "extension-data" / hashlib.sha256(theme.encode()).hexdigest() + ) + (theme_data / "retained.txt").write_text("synthetic retained data") + assert (await product.page.evaluate("id=>window.amadeus.removeExtension(id)", theme))["ok"] + check("default uninstall retains data", (theme_data / "retained.txt").is_file()) + assert (await install(theme, "0.1.0"))["ok"] + assert ( + await product.page.evaluate( + "id=>window.amadeus.removeExtension(id,{deleteData:true})", theme + ) + )["ok"] + check("explicit uninstall deletes owned data", not theme_data.exists()) + assert (await install(tone, "0.1.0"))["ok"] + await update(tone, True) + await backend_restart() + check( + "real backend activates installed process package", + bool(await runtime_state("0.1.0", {"active"})), + ) + index["sequence"] += 1 + index["entries"].append(data["startup_failure"]) + assert (await refresh())["ok"] + assert (await install(tone, "0.3.0"))["ok"] + await backend_restart() + check( + "updated package startup failure is visible without automatic rollback", + bool(await runtime_state("0.3.0", {"error"})) + and json.loads(state_path.read_text())["extensions"][tone]["version"] == "0.3.0", + ) + await update(tone, False) + assert (await product.page.evaluate("id=>window.amadeus.removeExtension(id)", tone))["ok"] + await backend_restart() + assert (await install(theme, "0.1.0"))["ok"] + await update(theme, True) + await product.page.evaluate( + "window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_THEME:'com.example.desktop-theme/day'}})" + ) + target = next(e for e in index["entries"] if e["id"] == theme and e["version"] == "0.1.0") + index["sequence"] += 1 + index["security"] = [ + { + "id": theme, + "version": "0.1.0", + "sha256": target["sha256"], + "status": "blocked", + "sequence": index["sequence"], + "reason": "security", + "evidence": "https://fixture.test/security", + } + ] + assert (await refresh())["ok"] + check( + "ban suppresses active theme immediately", + not (await product.page.evaluate("window.amadeus.getDesktopTheme()")).get("theme"), + ) + cache = state_path.parent / "extension-catalog/index.json" + cache.unlink() + await restart([state_path], offline=True) + observed = await catalog({"action": "get"}) + check( + "fresh process restores primary NUL damage from backup without cache/network", + observed["catalog"]["installed"][0]["blocked"] + and json.loads(state_path.with_suffix(".json.bak").read_text())["extensionSecurity"][ + "status" + ] + == "ready", + ) + # The same blocked artifact through the existing local picker boundary. + wheel = data["wheels"][Path(target["url"]).name] + await main( + "(()=>{const e=process.getBuiltinModule('module').createRequire(process.execPath)('electron');e.dialog.showOpenDialog=async()=>({canceled:false,filePaths:[" + + json.dumps(wheel) + + "]});return true})()" + ) + local = await product.page.evaluate("window.amadeus.installExtension()") + check( + "offline known ban also blocks local wheel installation", + not local["ok"] and "blocked" in local["error"], + ) + await main("extensionReleaseFixture.offline=false") + rollback = copy.deepcopy(index) + rollback["sequence"] -= 1 + rollback["security"] = [] + await main("extensionReleaseFixture.index=" + json.dumps(rollback)) + check( + "lower sequence cannot erase a recovered ban", + not (await catalog({"action": "refresh"}))["ok"], + ) + await restart([state_path, state_path.with_suffix(".json.bak")], offline=True) + fault = json.loads(state_path.read_text())["extensionSecurity"] + check( + "both NUL copies persist unavailable safety across fresh process", + fault["status"] == "unavailable", + ) + assert ( + await product.page.evaluate( + "window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_LOCALE:'en-US'}})" + ) + )["ok"] + check( + "ordinary settings save cannot clear damaged safety", + json.loads(state_path.read_text())["extensionSecurity"]["status"] == "unavailable", + ) + await main("extensionReleaseFixture.offline=false") + check( + "normal refresh cannot implicitly recover damaged safety", + not (await catalog({"action": "refresh"}))["ok"], + ) + recovered = await catalog({"action": "recover"}) + check( + "explicit official recovery restores safety but not lost inventory", + recovered["ok"] + and not recovered["catalog"]["installed"] + and json.loads(state_path.read_text())["extensionSecurity"]["decisions"][0]["blocked"], + ) + check( + "recovered ban still rejects catalog artifact", + not (await install(theme, "0.1.0"))["ok"], + ) + check( + "failed transactions leave no download staging", + not list((state_path.parent / "extension-catalog/staging").iterdir()), + ) + await product.screenshot("release-failure-recovered") + report["ok"] = True + report["final_catalog_sequence"] = index["sequence"] + report["startup_failure_artifact"] = { + k: data["startup_failure"][k] for k in ("id", "version", "sha256") + } + return report + finally: + (product.run_root / "extension-failure-report.json").write_text( + json.dumps(report, indent=2), encoding="utf-8" + ) diff --git a/tools/probes/extension_release_shell.py b/tools/probes/extension_release_shell.py new file mode 100644 index 00000000..7af117d3 --- /dev/null +++ b/tools/probes/extension_release_shell.py @@ -0,0 +1,379 @@ +"""Exercise extension IPC/UI in the shipping shell; synthetic authority and automated dialogs. + +Only called by the explicit model-less --extension-fixture smoke. The loopback +inspector belongs to its isolated no-credentials process; production has no test API. +""" + +from __future__ import annotations +import asyncio +import base64 +import csv +import io +import hashlib +import http.client +import json +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +async def evaluate_main(product, expression): + import websockets + + def address(): + connection = http.client.HTTPConnection("127.0.0.1", product.main_debug_port, timeout=3) + try: + connection.request("GET", "/json/list") + targets = json.loads(connection.getresponse().read()) + assert len(targets) == 1 + url = targets[0]["webSocketDebuggerUrl"] + assert url.startswith(f"ws://127.0.0.1:{product.main_debug_port}/") + return url + finally: + connection.close() + + guarded = f"(async()=>{{if(process.pid!=={product.process.pid})throw Error('Unexpected process');return await ({expression})}})()" + async with websockets.connect(await asyncio.to_thread(address), open_timeout=3) as socket: + await socket.send( + json.dumps( + { + "id": 1, + "method": "Runtime.evaluate", + "params": {"expression": guarded, "awaitPromise": True, "returnByValue": True}, + } + ) + ) + while True: + response = json.loads(await asyncio.wait_for(socket.recv(), timeout=30)) + if response.get("id") == 1: + result = response["result"] + if result.get("exceptionDetails"): + raise RuntimeError(str(result["exceptionDetails"])) + return result["result"].get("value") + + +def verify_checker_schemas(checker_root: Path, wheel: Path, python: str = sys.executable): + """Run the shipped helper against a real v0 wheel and its v1 icon variant.""" + helper = checker_root / "tools/extension_package.py" + with zipfile.ZipFile(wheel) as archive: + members = {name: archive.read(name) for name in archive.namelist()} + manifest_name, = [name for name in members if name.endswith("/amadeus-extension.json")] + manifest = json.loads(members[manifest_name]) + assert manifest["schema"] == "amadeus.extension/v0" + results = {} + with tempfile.TemporaryDirectory(prefix="extension-bundle-schemas-") as temporary: + target = Path(temporary) / wheel.name + for schema in ("amadeus.extension/v0", "amadeus.extension/v1"): + source = wheel + if schema.endswith("/v1"): + manifest.update(schema=schema, icon="qualification-icon.png") + members[manifest_name] = json.dumps(manifest).encode("utf-8") + members[manifest_name.rsplit("/", 1)[0] + "/qualification-icon.png"] = ( + ROOT / "electron/tests/fixtures/extension-preview.png" + ).read_bytes() + record = next((name for name in members if name.endswith(".dist-info/RECORD")), None) + if record: + rows = io.StringIO(newline="") + writer = csv.writer(rows, lineterminator="\n") + for name, content in sorted(members.items()): + if name != record: + digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode() + writer.writerow((name, "sha256=" + digest, len(content))) + writer.writerow((record, "", "")) + members[record] = rows.getvalue().encode() + with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in members.items(): + archive.writestr(name, content) + source = target + process = subprocess.run( + [python, "-I", str(helper), "check-wheel", str(source)], + cwd=temporary, capture_output=True, text=True, encoding="utf-8", timeout=30, + ) + assert process.returncode == 0, process.stdout + process.stderr + result = json.loads(process.stdout) + assert result["ok"] and result["manifest"]["schema"] == schema + results[schema] = {"ok": True, "digest": result["digest"]} + return results + + +async def exercise(product, fixture: Path): + from playwright.async_api import expect + from config.process_tree import ProcessTree + + page = product.page + report = { + "checks": [], + "fixture_authority": True, + "native_confirmation": "automated", + "backend": "normal authenticated model-less runtime", + "fixture_artifacts": [], + "used_artifacts": [], + } + + def check(name, value=True): + if not value: + raise AssertionError(name) + report["checks"].append(name) + + async def catalog(request): + return await page.evaluate("request=>window.amadeus.extensionCatalog(request)", request) + + async def button(name): + await ( + page.get_by_role("button", name=name, exact=True) + .and_(page.locator("button:enabled")) + .click(timeout=30000) + ) + + def package_card(package_id): + return page.locator("#extension-panel-installed .setting-card").filter( + has=page.locator(f'[data-extension-id="{package_id}"]')) + + async def configure(package_id, enabled, provider=None): + card = package_card(package_id) + if provider: + await card.get_by_role("button", name="Configure extension", exact=True).click() + await (card.locator(".settings-form-provider-choices label") + .filter(has_text=provider).locator("input").check(timeout=30000)) + await button("Save extension") + await expect(page.locator('form[aria-label="Configure extension"]')).to_have_count(0, timeout=30000) + toggle = card.locator('[role="switch"][id^="extension-enabled-"]') + if (await toggle.get_attribute("aria-checked") == "true") != enabled: + await toggle.click() + await expect(toggle).to_have_attribute("aria-checked", str(enabled).lower(), timeout=30000) + + async def install(package_id, version): + await page.get_by_role("tab", name="Browse", exact=True).click() + await page.get_by_role("searchbox").fill(package_id) + await button("View plugin details") + selection = page.locator(".extension-catalog-detail select") + if await selection.count(): + await selection.select_option(version) + await button("Install plugin…") + await expect(page.locator("#extension-panel-installed")).to_be_visible(timeout=120000) + await expect(page.locator(f'[data-extension-id="{package_id}"][data-extension-version="{version}"]')).to_be_visible( + timeout=120000 + ) + await expect(page.locator(".extension-download-progress")).to_have_count(0, timeout=120000) + + resource_root = ( + product.packaged_executable.parent / "resources" if product.packaged_executable else ROOT + ) + checker_root = resource_root / "extension-host" if product.packaged_executable else ROOT + sdk_directory = ( + resource_root / "extension-sdk" + if product.packaged_executable + else ROOT / "vendor/extension-sdk" + ) + report["protocol_pin"] = json.loads( + (checker_root / "server/extensions/protocol-source.json").read_text() + ) + report["catalog_pin"] = json.loads( + (checker_root / "server/extensions/catalog-source.json").read_text() + ) + if product.packaged_executable: + fixture_data = json.loads((fixture / "fixture.json").read_text(encoding="utf-8")) + entry = next(e for e in fixture_data["index"]["entries"] if e["id"] == "com.example.desktop-theme" and e["version"] == "0.1.0") + from urllib.parse import urlsplit + wheel = Path(fixture_data["wheels"][Path(urlsplit(entry["url"]).path).name]) + report["packaged_schema_checks"] = await asyncio.to_thread(verify_checker_schemas, checker_root, wheel) + check("packaged helper accepts both v0 and v1 icon wheels") + report["checker_files_sha256"] = { + p.relative_to(checker_root).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(checker_root.rglob("*")) + if p.is_file() and "__pycache__" not in p.parts + } + report["sdk_sha256"] = { + p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in sdk_directory.glob("*.whl") + } + if product.packaged_executable: + report["build_sha256"] = { + "executable": hashlib.sha256(product.packaged_executable.read_bytes()).hexdigest(), + "app.asar": hashlib.sha256((resource_root / "app.asar").read_bytes()).hexdigest(), + } + settings_path = product.run_root / "state/electron-user-data/settings.json" + initial = json.loads(settings_path.read_text()) + check( + "pre-catalog settings remain readable", + initial["values"].get("AMADEUS_UI_LOCALE") == "en-US", + ) + check( + "startup and installed management leave safety/cache untouched", + "extensionSecurity" not in initial + and not (settings_path.parent / "extension-catalog/index.json").exists(), + ) + code = (ROOT / "electron/tests/fixtures/extensionRelease.cjs").read_text() + check( + "isolated shipping-process fixture installed", + await evaluate_main( + product, + f"(async()=>{{{code};return setupReleaseFixture({json.dumps(str(fixture))})}})()", + ), + ) + try: + await button("Plugins") + theme = "com.example.desktop-theme" + await install(theme, "0.1.0") + check("resource installed through shipping UI and IPC", True) + await configure(theme, True) + await button("Open appearance settings") + await page.get_by_role("radio").filter(has_text="Quiet day").click() + await button("Plugins") + theme_state = await page.evaluate("window.amadeus.getDesktopTheme()") + check("theme applied through native frontend owner", bool(theme_state.get("theme"))) + await product.screenshot("release-store-theme") + result = await page.evaluate( + "window.amadeus.updateDesktopSettings({values:{AMADEUS_EXTENSION_ARTIFACT_MIRROR:'https://mirror.test/artifacts'}})" + ) + check("mirror setting accepted", result.get("ok")) + await evaluate_main( + product, + "(()=>{const s=extensionReleaseFixture;s.index={...s.index,sequence:2,entries:s.fixture.index.entries};return true})()", + ) + await button("Check updates and security status") + await button("Update plugin…") + await expect(page.locator(f'[data-extension-id="{theme}"][data-extension-version="0.2.0"]')).to_be_visible(timeout=120000) + await expect(page.locator(".extension-download-progress")).to_have_count(0, timeout=120000) + theme_state = await page.evaluate("window.amadeus.getDesktopTheme()") + check("manual update retains enabled appearance", bool(theme_state.get("theme"))) + requests = await evaluate_main(product, "extensionReleaseFixture.requests") + check( + "download uses configured artifact mirror", + any(url.startswith("/artifacts/") and url.endswith(".whl") for url in requests), + ) + await evaluate_main(product, "extensionReleaseFixture.offline=true") + failure = await catalog({"action": "refresh"}) + check( + "offline refresh retains installed record", + not failure["ok"] and any(e["id"] == theme for e in failure["catalog"]["installed"]), + ) + await evaluate_main(product, "extensionReleaseFixture.offline=false") + await configure(theme, False) + await package_card(theme).locator(".extension-details > summary").click() + await package_card(theme).get_by_role("button", name="Remove", exact=True).click() + await package_card(theme).get_by_role("button", name="Confirm remove", exact=True).click() + await expect( + page.get_by_text("No community extensions installed.", exact=True) + ).to_be_visible(timeout=30000) + fixture_data = json.loads((fixture / "fixture.json").read_text()) + report["fixture_artifacts"] = [ + {key: e[key] for key in ("id", "version", "sha256")} + for e in fixture_data["index"]["entries"] + ] + used = {(theme, "0.1.0"), (theme, "0.2.0")} + for package_id, kind, name, wheel_name in ( + ( + "com.example.tone-tts", + "tts", + "tone", + "amadeus_example_tone_tts-0.1.0-py3-none-any.whl", + ), + ( + "com.example.notes-mcp", + "mcp", + "notes", + "amadeus_example_notes_mcp-0.1.0-py3-none-any.whl", + ), + ): + await install(package_id, "0.1.0") + await configure(package_id, True, "Pi" if kind == "mcp" else None) + saved = json.loads(settings_path.read_text())["extensions"][package_id] + if kind == "mcp": + check( + "Notes provider binding saved through shipping UI", saved["providers"] == ["pi"] + ) + directory = settings_path.parent / "extensions" / saved["installId"] + record = { + key: saved[key] + for key in ( + "id", + "version", + "revision", + "digest", + "manifest", + "enabled", + "providers", + ) + } + record.update( + package_path=str(directory / "venv" / saved["sitePackages"] / saved["packageRoot"]), + python_path=str(directory / "venv/Scripts/python.exe"), + data_path=str( + settings_path.parent + / "extension-data" + / hashlib.sha256(package_id.encode()).hexdigest() + ), + config=saved["values"], + secrets={}, + ) + record_path = product.run_root / (name + "-record.json") + record_path.write_text(json.dumps(record), encoding="utf-8") + wheel = fixture_data["wheels"][wheel_name] + runtime = product.run_root / (name + "-runtime.json") + + def run_contract(): + with (product.run_root / (name + "-contract.log")).open("w") as log: + process = subprocess.Popen( + [ + sys.executable, + "-I", + str(ROOT / "tools/check_extension_contract.py"), + "--execute", + "--kind", + kind, + "--wheel", + wheel, + "--record", + str(record_path), + "--scenario", + str( + ROOT / ("examples/extensions/contract-scenarios/" + name + ".json") + ), + "--output", + str(runtime), + ], + cwd=ROOT, + env=product._environment(), + stdout=log, + stderr=subprocess.STDOUT, + ) + tree = ProcessTree(process) + try: + return process.wait(timeout=90) + finally: + tree.terminate() + + check( + "shipping-installed " + name + " passes native process contract", + await asyncio.to_thread(run_contract) == 0 + and json.loads(runtime.read_text())["ok"], + ) + used.add((package_id, "0.1.0")) + await configure(package_id, False) + await package_card(package_id).locator(".extension-details > summary").click() + await package_card(package_id).get_by_role("button", name="Remove", exact=True).click() + await package_card(package_id).get_by_role("button", name="Confirm remove", exact=True).click() + report["used_artifacts"] = [ + entry + for entry in report["fixture_artifacts"] + if (entry["id"], entry["version"]) in used + ] + await product.screenshot("release-store-process-removed") + check( + "native access confirmations exercised", + await evaluate_main(product, "extensionReleaseFixture.confirmations") >= 5, + ) + check( + "legacy settings preserve locale after catalog safety writes", + json.loads(settings_path.read_text())["values"]["AMADEUS_UI_LOCALE"] == "en-US", + ) + report["catalog_sequence"] = 2 + return report + finally: + (product.run_root / "extension-release-report.json").write_text( + json.dumps(report, indent=2), encoding="utf-8" + ) diff --git a/tools/probes/extension_store_demo.py b/tools/probes/extension_store_demo.py new file mode 100644 index 00000000..b15fc851 --- /dev/null +++ b/tools/probes/extension_store_demo.py @@ -0,0 +1,352 @@ +"""Local publisher demonstration using unchanged, pinned catalog CLI tools. + +No remote pushes, real approval identities, OS trust changes or production test APIs. +The disposable Git history models the input-review/publication boundary, not GitHub +authorization. Every served snapshot is the exact successfully checked CLI output. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import ssl +import subprocess +import sys +import threading +import uuid +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +PACKAGES = {"notes_mcp": "com.example.notes-mcp", "theme": "com.example.desktop-theme"} + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class Publisher: + def __init__(self, root, config): + from tools.probes.extension_catalog_journey import create_certificate + from tools.smoke_electron_model_less import model_less_backend_environment + + self.root = root + self.root.mkdir(parents=True) + self.config = config + self.repo = root / "repository" + self.artifacts = root / "artifact-cache" + self.phases = [] + self.requests = [] + self.offline = False + self.sequence = 0 + self.current = None + self.command_number = 0 + self.wheels = {} + self.environment = model_less_backend_environment(os.environ, python_executable=sys.executable) + self.environment.update( + GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, + GIT_AUTHOR_NAME="Local demo author", GIT_AUTHOR_EMAIL="demo@example.invalid", + GIT_COMMITTER_NAME="Local demo maintainer", GIT_COMMITTER_EMAIL="demo@example.invalid", + ) + create_certificate(root) + # Only maintenance subprocesses trust this disposable certificate. No OS change. + self.environment["SSL_CERT_FILE"] = str(root / "cert.pem") + self.environment["NO_PROXY"] = "localhost,127.0.0.1" + for name in PACKAGES: + for version in ("0.1.0", "0.2.0"): + filename = f"amadeus_example_{name}-{version}-py3-none-any.whl" + source = Path(config["wheels"]) / filename + destination = root / "wheels" / filename + destination.parent.mkdir(exist_ok=True) + shutil.copyfile(source, destination) + self.wheels[filename] = destination + self.clone(Path(config["protocol_repo"]), self.repo) + self.generator = self.git(self.repo, "rev-parse", "HEAD") + if json.loads((self.repo / "catalog/v1/index.json").read_text())["entries"]: + raise ValueError("Demo expects the experimental empty bootstrap") + self.current = root / "bootstrap.json" + shutil.copyfile(self.repo / "catalog/v1/index.json", self.current) + publisher = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + route = urlsplit(self.path).path + file = None + if route == "/index.json": + file = publisher.current + elif route == "/candidate-index.json": + file = publisher.repo / "catalog/v1/index.json" + elif route.startswith(("/artifacts/", "/mirror/")): + candidate = publisher.wheels.get(route.rsplit("/", 1)[-1]) + if candidate and route in ( + "/artifacts/" + candidate.name, + "/mirror/" + sha256(candidate) + "/" + candidate.name, + ): + file = candidate + status = 503 if publisher.offline else 200 if file else 404 + body = file.read_bytes() if status == 200 else b"unavailable" + publisher.requests.append({ + "sequence": publisher.sequence, "path": route, "status": status, + "sha256": hashlib.sha256(body).hexdigest(), "size": len(body), + "client": "maintainer" if self.headers.get("User-Agent", "").startswith("Python-urllib") else "desktop", + }) + self.send_response(status) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(root / "cert.pem", root / "key.pem") + self.server.socket = context.wrap_socket(self.server.socket, server_side=True) + self.origin = f"https://localhost:{self.server.server_port}" + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def command(self, argv, *, expected=0): + self.command_number += 1 + result = subprocess.run( + [str(x) for x in argv], cwd=ROOT, env=self.environment, + capture_output=True, text=True, encoding="utf-8", timeout=180, + ) + evidence = { + "argv": [str(x) for x in argv], "exit_code": result.returncode, + "expected_exit": expected, "stdout": result.stdout, "stderr": result.stderr, + } + (self.root / f"command-{self.command_number:03}.json").write_text( + json.dumps(evidence, indent=2), encoding="utf-8" + ) + if result.returncode != expected: + raise RuntimeError(f"Publisher command {self.command_number} failed its expected outcome: {result.stdout} {result.stderr}") + return result.stdout.strip() + + def git(self, repo, *args): + return self.command(["git", "-C", repo, *args]) + + def clone(self, source, destination): + self.command(["git", "clone", "-c", "core.longpaths=true", "--shared", "--no-checkout", source, destination]) + self.git(destination, "checkout", "--detach", self.git(source, "rev-parse", "HEAD")) + self.git(destination, "remote", "remove", "origin") + + def commit(self, message): + self.git(self.repo, "add", "catalog") + self.git(self.repo, "commit", "-m", message) + return self.git(self.repo, "rev-parse", "HEAD") + + def tool(self, checkout, name, *args, expected=0): + return self.command([self.config["checker_python"], checkout / "tools" / name, *args], expected=expected) + + def facts(self, name, version): + wheel = self.wheels[f"amadeus_example_{name}-{version}-py3-none-any.whl"] + digest = sha256(wheel) + return { + "wheel": {"url": self.origin + "/artifacts/" + wheel.name, "sha256": digest}, + "publisher": { + "id": "demo-author", "maintainers": ["demo-author"], + "package_ids": [PACKAGES[name]], "evidence": self.origin + "/demo-only/ownership", + }, + "review": { + "sha256": digest, "policy": "curated-v1", "reviewer": "demo-reviewer", + "date": datetime.now(timezone.utc).date().isoformat(), + "evidence": self.origin + "/demo-only/simulated-review", + }, + "withdrawal": None, + } + + def publish(self, label, *, version=None, withdrawal=None, decision=None): + number = self.sequence + 1 + phase = self.root / f"{number:02}-{label}" + phase.mkdir() + base = phase / "protected-base" + self.clone(self.repo, base) + previous_commit = self.git(base, "rev-parse", "HEAD") + previous = base / "catalog/v1/index.json" + if version: + for name in PACKAGES: + path = self.repo / "catalog/packages" / f"{name}-{version}.json" + path.parent.mkdir(exist_ok=True) + path.write_text(json.dumps(self.facts(name, version), indent=2), encoding="utf-8") + self.tool(base, "preflight_submission.py", "--wheel", + self.wheels[f"amadeus_example_{name}-{version}-py3-none-any.whl"], + "--host", self.config["checker_host"], "--python", self.config["checker_python"], + "--output", phase / f"{name}-preflight.json") + args = ["--wheel", self.wheels[f"amadeus_example_{name}-{version}-py3-none-any.whl"], + "--output", phase / f"{name}-review.json"] + if version == "0.2.0": + args += ["--previous-wheel", self.wheels[f"amadeus_example_{name}-0.1.0-py3-none-any.whl"]] + self.tool(base, "check_catalog.py", *args) + if withdrawal is not None: + path = self.repo / "catalog/packages/notes_mcp-0.2.0.json" + facts = json.loads(path.read_text()) + facts["withdrawal"] = ( + {"reason": "copyright", "evidence": self.origin + "/demo-only/copyright"} + if withdrawal else None + ) + path.write_text(json.dumps(facts, indent=2), encoding="utf-8") + if decision: + safety = [ + {"id": PACKAGES[name], "version": "0.2.0", + "sha256": self.facts(name, "0.2.0")["wheel"]["sha256"], + "status": decision, "sequence": number, + "reason": "security" if decision == "blocked" else "security-resolved", + "evidence": self.origin + "/demo-only/" + decision} + for name in PACKAGES + ] + (self.repo / "catalog/security.json").write_text(json.dumps(safety, indent=2), encoding="utf-8") + common = [ + "--host", self.config["checker_host"], "--python", self.config["checker_python"], + "--artifacts", self.artifacts, + ] + revision = None + if number == 1: + path = self.repo / "catalog/packages/notes_mcp-0.1.0.json" + correct = path.read_bytes() + wrong = json.loads(correct) + wrong["wheel"]["sha256"] = "0" * 64 + path.write_text(json.dumps(wrong), encoding="utf-8") + rejected_head = self.commit("DEMO ONLY: submission with incorrect artifact digest") + self.tool(base, "check_catalog_ci.py", "--proposal", self.repo, "--base", previous_commit, + "--evidence", phase / "rejected-input-evidence", *common, expected=1) + path.write_bytes(correct) + revision = {"rejected_head": rejected_head, "reason": "wrong wheel digest", + "same_submission": True, "approval_was_not_granted": True} + input_commit = self.commit(f"DEMO ONLY: {label} input and simulated review") + self.tool(base, "check_catalog_ci.py", "--proposal", self.repo, "--base", previous_commit, + "--evidence", phase / "input-evidence", *common) + # Simulate reviewed-input merge locally; never alter any public branch. + self.git(self.repo, "update-ref", "refs/heads/demo-approved", input_commit) + build = [ + "--source-repo", self.repo, "--source-commit", input_commit, + "--approved-ref", "demo-approved", "--sequence", str(number), + "--previous", previous, "--output", self.repo / "catalog/v1/index.json", + "--fetch", *common, + ] + # Always run the original reviewed generator revision, not submitted input code. + generator = self.root / "generator" + if not generator.exists(): + self.clone(Path(self.config["protocol_repo"]), generator) + self.git(generator, "checkout", "--detach", self.generator) + self.tool(generator, "build_catalog.py", *build) + self.tool(generator, "build_catalog.py", *build, "--check") + self.tool(base, "check_catalog.py", "--index", self.repo / "catalog/v1/index.json", "--previous", previous) + publication = self.commit(f"DEMO ONLY: publish catalog sequence {number}") + self.tool(base, "check_catalog_ci.py", "--proposal", self.repo, "--base", input_commit, + "--evidence", phase / "publication-evidence", *common) + self.tool(generator, "verify_publication.py", "--index-url", self.origin + "/candidate-index.json", + "--expected", self.repo / "catalog/v1/index.json", "--previous", previous, + "--output", phase / "candidate-delivery.json") + snapshot = phase / "index.json" + shutil.copyfile(self.repo / "catalog/v1/index.json", snapshot) + self.current = snapshot + self.sequence = number + self.tool(generator, "verify_publication.py", "--index-url", self.origin + "/index.json", + "--expected", snapshot, "--previous", previous, + "--output", phase / "live-delivery.json") + data = json.loads(snapshot.read_text()) + evidence = { + "label": label, "sequence": number, "input_commit": input_commit, + "publication_commit": publication, "generator_commit": data["generator_commit"], + "index_sha256": sha256(snapshot), "entries": len(data["entries"]), + "input_ci": True, "publication_ci": True, "exact_rebuild": True, + "simulated_review_only": True, "submission_revision": revision, + "candidate_delivery": True, "live_delivery": True, + } + self.phases.append(evidence) + (phase / "publication.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8") + self.save() + return data + + def verify_rejections(self): + """Publication CI must reject hand-edited output; prior safety cannot vanish.""" + before = sha256(self.current) + proposal = self.root / "rejected-proposal" + self.clone(self.repo, proposal) + index = json.loads(self.current.read_text()) + index["sequence"] += 1 + index["entries"][0]["display"]["description"] = "DEMO tampered output" + (proposal / "catalog/v1/index.json").write_text(json.dumps(index), encoding="utf-8") + self.tool(self.repo, "check_catalog_ci.py", "--proposal", proposal, + "--base", self.git(self.repo, "rev-parse", "HEAD"), + "--host", self.config["checker_host"], "--python", self.config["checker_python"], + "--artifacts", self.artifacts, "--evidence", self.root / "rejected-evidence", + expected=1) + index = json.loads(self.current.read_text()) + index["sequence"] += 1 + index["security"] = [] + rejected = self.root / "rejected-missing-safety.json" + rejected.write_text(json.dumps(index), encoding="utf-8") + self.tool(self.repo, "check_catalog.py", "--index", rejected, + "--previous", self.current, expected=1) + assert sha256(self.current) == before + + def save(self): + (self.root / "report.json").write_text(json.dumps({ + "simulated_authority": True, "public_release": False, + "phases": self.phases, "requests": self.requests, + "wheels": [{"file": p.name, "sha256": sha256(p)} for p in self.wheels.values()], + }, indent=2), encoding="utf-8") + + def close(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + self.save() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--execute", action="store_true", required=True) + parser.add_argument("--protocol-repo", type=Path, required=True) + parser.add_argument("--checker-host", type=Path, required=True) + parser.add_argument("--checker-python", type=Path, required=True) + parser.add_argument("--wheels", type=Path, required=True) + parser.add_argument("--publisher-only", action="store_true", help="Run local Git/preflight/revision/distribution tools without launching the desktop") + parser.add_argument("--packaged-executable", type=Path) + parser.add_argument("--report-dir", type=Path, default=ROOT / "build/extension-store-demo") + args = parser.parse_args() + directory = args.report_dir.resolve() / uuid.uuid4().hex[:12] + directory.mkdir(parents=True) + config = {name: str(getattr(args, name).resolve()) + for name in ("protocol_repo", "checker_host", "checker_python", "wheels")} + invocation = directory / "invocation.json" + invocation.write_text(json.dumps(config, indent=2), encoding="utf-8") + if args.publisher_only: + publisher = Publisher(directory / "publisher", config) + try: + for label, changes in ( + ("initial", {"version": "0.1.0"}), + ("update", {"version": "0.2.0"}), + ("copyright-withdrawal", {"withdrawal": True}), + ("security-block", {"decision": "blocked"}), + ("security-unblock", {"decision": "allowed"}), + ("restore-distribution", {"withdrawal": False}), + ): + publisher.publish(label, **changes) + publisher.verify_rejections() + result = {"ok": True, "scope": "publisher-only", "desktop": "not_run", + "phases": publisher.phases, "publication_rejections": "passed"} + (directory / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8") + print(json.dumps({"ok": True, "report": str(directory / "result.json"), "desktop": "not_run"})) + return 0 + finally: + publisher.close() + command = [sys.executable, ROOT / "tools/smoke_electron_model_less.py", + "--extension-publication", invocation, "--report-dir", directory] + if args.packaged_executable: + command += ["--packaged-executable", args.packaged_executable.resolve()] + return subprocess.run([str(x) for x in command], cwd=ROOT).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/probes/extension_store_roundtrip.py b/tools/probes/extension_store_roundtrip.py new file mode 100644 index 00000000..a9ecfe7c --- /dev/null +++ b/tools/probes/extension_store_roundtrip.py @@ -0,0 +1,357 @@ +"""Consume actual publisher output through the shipping desktop and installer.""" +from __future__ import annotations + +import asyncio +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] + + +async def exercise_publication(product, invocation): + from playwright.async_api import expect + from tools.probes.extension_store_demo import Publisher, sha256 + from tools.probes.extension_release_shell import evaluate_main + from tools.smoke_electron_model_less import _wait_enabled + from config.process_tree import ProcessTree + + publisher = await asyncio.to_thread( + Publisher, product.run_root / "publisher", json.loads(invocation.read_text()) + ) + report = { + "ok": False, "checks": [], "observed_catalogs": [], "app_restarts": 0, + "authority": "local simulated review and protected-base refs; not public approval", + "native_confirmation": "automated in owned inspector", + "damage": "NUL bytes injected only while owned application is stopped; not physical power loss", + "runtime_contracts": [], + } + state_path = product.run_root / "state/electron-user-data/settings.json" + cache = state_path.parent / "extension-catalog/index.json" + theme, notes = "com.example.desktop-theme", "com.example.notes-mcp" + + def save(): + (product.run_root / "extension-publication-report.json").write_text( + json.dumps(report, indent=2), encoding="utf-8" + ) + + def check(name, valid=True): + if not valid: + raise AssertionError(name) + report["checks"].append(name) + save() + + def settings(): + return json.loads(state_path.read_text(encoding="utf-8")) + + async def main(expression): + return await evaluate_main(product, expression) + + async def transport(): + code = (ROOT / "electron/tests/fixtures/extensionPublication.cjs").read_text() + assert await main( + f"(async()=>{{{code};return setupPublicationTransport(" + f"{json.dumps(publisher.origin)},{json.dumps(str(publisher.root / 'cert.pem'))})}})()" + ) + + async def catalog(request): + return await asyncio.wait_for( + product.page.evaluate("r=>window.amadeus.extensionCatalog(r)", request), 180 + ) + + async def refresh(action="refresh"): + response = await catalog({"action": action}) + assert response["ok"], response.get("error") + digest = sha256(cache) + assert digest == sha256(publisher.current), "Desktop did not consume exact generated bytes" + assert response["catalog"]["sequence"] == publisher.sequence + report["observed_catalogs"].append({ + "sequence": publisher.sequence, "sha256": digest, "action": action + }) + save() + return response + + async def button(name): + await ( + product.page.get_by_role("button", name=name, exact=True) + .and_(product.page.locator("button:enabled")).click(timeout=30000) + ) + + async def open_manager(): + await button("Plugins") + + async def install_ui(package_id, version): + await product.page.get_by_role("tab", name="Browse", exact=True).click() + await product.page.get_by_role("searchbox").fill(package_id) + await button("View plugin details") + selection = product.page.locator(".extension-catalog-detail select") + if await selection.count(): + await selection.select_option(version) + await button("Install selected version…" if package_id in settings()["extensions"] else "Install plugin…") + await expect(product.page.locator("#extension-panel-installed")).to_be_visible(timeout=120000) + await expect(product.page.locator(f'[data-extension-id="{package_id}"][data-extension-version="{version}"]')).to_be_visible(timeout=120000) + await expect(product.page.locator(".extension-download-progress")).to_have_count(0, timeout=120000) + record = settings()["extensions"][package_id] + entry = next(e for e in json.loads(publisher.current.read_text())["entries"] + if e["id"] == package_id and e["version"] == version) + assert record["digest"] == entry["sha256"] + return record + + async def update_ui(package_id, version): + await product.page.get_by_role("tab", name="Installed", exact=True).click() + old = settings()["extensions"][package_id]["version"] + text = product.page.locator(f'[data-extension-id="{package_id}"][data-extension-version="{old}"]') + card = product.page.locator("#extension-panel-installed .setting-card").filter(has=text) + await card.get_by_role("button", name="Update plugin…", exact=True).click() + await expect(product.page.locator(f'[data-extension-id="{package_id}"][data-extension-version="{version}"]')).to_be_visible(timeout=120000) + await expect(product.page.locator(".extension-download-progress")).to_have_count(0, timeout=120000) + entry = next(e for e in json.loads(publisher.current.read_text())["entries"] + if e["id"] == package_id and e["version"] == version) + assert settings()["extensions"][package_id]["digest"] == entry["sha256"] + + async def configure(package_id): + await product.page.get_by_role("tab", name="Installed", exact=True).click() + card = product.page.locator("#extension-panel-installed .setting-card").filter( + has=product.page.locator(f'[data-extension-id="{package_id}"]')) + if package_id == notes: + await card.get_by_role("button", name="Configure extension", exact=True).click() + await (card.locator(".settings-form-provider-choices label") + .filter(has_text="Pi").locator("input").check()) + await button("Save extension") + await expect(card.locator('form[aria-label="Configure extension"]')).to_have_count(0, timeout=30000) + toggle = card.locator('[role="switch"][id^="extension-enabled-"]') + if await toggle.get_attribute("aria-checked") != "true": + await toggle.click() + await expect(toggle).to_have_attribute("aria-checked", "true", timeout=30000) + + async def select_theme(): + response = await product.page.evaluate( + "window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_THEME:'com.example.desktop-theme/day'}})" + ) + assert response["ok"], response.get("error") + assert (await product.page.evaluate("window.amadeus.getDesktopTheme()")).get("theme") + + async def restart(*, damage=(), offline=False): + previous_pid = product.process.pid + await product.stop() + assert product.process.poll() is not None + report["app_restarts"] += 1 + shutil.copyfile(product.log_path, product.run_root / f"restart-{report['app_restarts']}-previous.log") + for path in damage: + assert path.resolve().is_relative_to(product.run_root.resolve()) + assert not path.is_symlink() + path.write_bytes(b"\0" * 1536) + publisher.offline = offline + await product.start(startup_timeout=120) + await _wait_enabled(product.page.locator('textarea[placeholder*="Type a message"]'), timeout=60) + assert product.process.pid != previous_pid + await transport() + + async def notes_contract(label): + record = settings()["extensions"][notes] + directory = state_path.parent / "extensions" / record["installId"] + saved = {k: record[k] for k in + ("id", "version", "revision", "digest", "manifest", "enabled", "providers")} + saved.update( + package_path=str(directory / "venv" / record["sitePackages"] / record["packageRoot"]), + python_path=str(directory / "venv/Scripts/python.exe"), + data_path=str(state_path.parent / "extension-data" / hashlib.sha256(notes.encode()).hexdigest()), + config=record["values"], secrets={}, + ) + record_path = product.run_root / f"{label}-record.json" + record_path.write_text(json.dumps(saved), encoding="utf-8") + output = product.run_root / f"{label}-contract.json" + wheel = publisher.wheels[f"amadeus_example_notes_mcp-{record['version']}-py3-none-any.whl"] + + def run(): + with (product.run_root / f"{label}-contract.log").open("w", encoding="utf-8") as log: + proc = subprocess.Popen([ + sys.executable, "-I", str(ROOT / "tools/check_extension_contract.py"), "--execute", + "--kind", "mcp", "--wheel", str(wheel), "--record", str(record_path), + "--scenario", str(ROOT / "examples/extensions/contract-scenarios/notes.json"), + "--output", str(output), + ], cwd=ROOT, env=product._environment(), stdout=log, stderr=subprocess.STDOUT) + tree = ProcessTree(proc) + try: + return proc.wait(timeout=90) + finally: + tree.terminate() + + assert await asyncio.to_thread(run) == 0, f"Notes runtime contract failed: {label}" + evidence = json.loads(output.read_text()) + assert evidence["ok"], evidence + report["runtime_contracts"].append({ + "label": label, "id": notes, "version": record["version"], + "sha256": record["digest"], "ok": True, + "scope": "installed wheel through native MCP client save/read/list; no model turn", + }) + save() + + async def rejects_install(package_id): + response = await catalog({"action": "install", "id": package_id, "version": "0.2.0"}) + assert not response["ok"], "Rejected artifact unexpectedly installed" + return response + + try: + await asyncio.to_thread(publisher.publish, "initial", version="0.1.0") + await transport() + await open_manager() + await refresh() + check("initial CLI-generated catalog reaches desktop byte-for-byte") + await install_ui(theme, "0.1.0") + await configure(theme) + await select_theme() + await install_ui(notes, "0.1.0") + await configure(notes) + await notes_contract("initial") + check("real resource and MCP packages install and run from first publication") + await product.screenshot("publication-initial") + + await asyncio.to_thread(publisher.publish, "update", version="0.2.0") + assert (await product.page.evaluate( + "v=>window.amadeus.updateDesktopSettings({values:{AMADEUS_EXTENSION_ARTIFACT_MIRROR:v}})", + publisher.origin + "/mirror", + ))["ok"] + await refresh() + await button("Check updates and security status") + # Exercise the actual Installed-view manual update controls. + await update_ui(theme, "0.2.0") + await update_ui(notes, "0.2.0") + check("both manual updates preserve enablement and Notes binding", + all(settings()["extensions"][p]["enabled"] for p in (theme, notes)) + and settings()["extensions"][notes]["providers"] == ["pi"]) + check("updated wheel bytes downloaded through actual mirror HTTPS", + all(any(r["path"] == "/mirror/" + sha256(publisher.wheels[f"amadeus_example_{name}-0.2.0-py3-none-any.whl"]) + + "/" + f"amadeus_example_{name}-0.2.0-py3-none-any.whl" + and r["status"] == 200 and r["client"] == "desktop" + for r in publisher.requests) for name in ("theme", "notes_mcp"))) + await restart() + check("new app process retains updated installs and enabled theme", + all(settings()["extensions"][p]["version"] == "0.2.0" for p in (theme, notes)) + and bool((await product.page.evaluate("window.amadeus.getDesktopTheme()")).get("theme"))) + await notes_contract("updated-restart") + await refresh() + + await asyncio.to_thread(publisher.publish, "copyright-withdrawal", withdrawal=True) + await refresh() + rejection = await rejects_install(notes) + check("copyright withdrawal stops redistribution without security ban", + "withdrawn" in rejection["error"] and not settings()["extensionSecurity"]["decisions"]) + await notes_contract("withdrawn-installed") + check("existing withdrawn MCP installation remains usable") + + blocked = await asyncio.to_thread(publisher.publish, "security-block", decision="blocked") + await refresh() + check("security publication blocks both exact installed artifacts", + {e["id"]: e["blocked"] for e in (await catalog({"action": "get"}))["catalog"]["installed"]} == {theme: True, notes: True}) + check("security block removes applied theme", + not (await product.page.evaluate("window.amadeus.getDesktopTheme()")).get("theme")) + await rejects_install(theme) + denied = await product.page.evaluate("id=>window.amadeus.updateExtension({id,enabled:true})", notes) + check("blocked MCP cannot be activated", not denied["ok"] and "blocked" in denied["error"]) + await open_manager() + await product.screenshot("publication-blocked") + + cache.unlink() + await restart(damage=[state_path], offline=True) + check("offline new process restores known bans from backup with no display cache", + settings()["extensionSecurity"]["sequence"] == 4 + and {e["id"]: e["blocked"] for e in (await catalog({"action": "get"}))["catalog"]["installed"]} == {theme: True, notes: True} + and not cache.exists()) + wheel = publisher.wheels["amadeus_example_theme-0.2.0-py3-none-any.whl"] + await main( + "(()=>{const e=process.getBuiltinModule('module').createRequire(process.execPath)('electron');" + "e.dialog.showOpenDialog=async()=>({canceled:false,filePaths:[" + json.dumps(str(wheel)) + "]});return true})()" + ) + local = await product.page.evaluate("window.amadeus.installExtension()") + check("known ban rejects same bytes through local picker offline", + not local["ok"] and "blocked" in local["error"]) + + await restart(damage=[state_path, state_path.with_suffix(".json.bak")], offline=True) + check("double NUL damage persists safety unavailable after restart", + settings()["extensionSecurity"]["status"] == "unavailable") + assert (await product.page.evaluate( + "window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_LOCALE:'en-US'}})" + ))["ok"] + publisher.offline = False + check("ordinary save and refresh cannot clear corruption", + not (await catalog({"action": "refresh"}))["ok"] + and settings()["extensionSecurity"]["status"] == "unavailable") + await refresh("recover") + check("explicit recovery restores published bans without fabricating lost inventory", + not settings()["extensions"] + and all(d["blocked"] for d in settings()["extensionSecurity"]["decisions"]) + and len(settings()["extensionSecurity"]["decisions"]) == 2) + await rejects_install(theme) + + await asyncio.to_thread(publisher.publish, "security-unblock", decision="allowed") + await refresh() + check("higher publication explicitly unblocks both artifacts", + settings()["extensionSecurity"]["sequence"] == 5 + and all(not d["blocked"] and d["sequence"] == 5 for d in settings()["extensionSecurity"]["decisions"])) + rejection = await rejects_install(notes) + check("security unblocking does not remove independent copyright withdrawal", + "withdrawn" in rejection["error"]) + await open_manager() + await install_ui(theme, "0.2.0") + await configure(theme) + await select_theme() + + await asyncio.to_thread(publisher.publish, "restore-distribution", withdrawal=False) + await refresh() + await install_ui(notes, "0.2.0") + await configure(notes) + await notes_contract("restored") + check("separate restoration allows real MCP install and execution again") + + # Replay a real previously valid publication, not a mutated test index. + newest = publisher.current + publisher.current = publisher.root / "04-security-block/index.json" + check("old authentic blocked snapshot cannot roll back explicit unban", + not (await catalog({"action": "refresh"}))["ok"] + and settings()["extensionSecurity"]["sequence"] == 6 + and all(not d["blocked"] for d in settings()["extensionSecurity"]["decisions"])) + publisher.current = newest + await refresh() + cache.unlink() + await restart(damage=[state_path], offline=True) + check("unban and maximum publication sequence survive damage and offline restart", + settings()["extensionSecurity"]["sequence"] == 6 + and all(not d["blocked"] for d in settings()["extensionSecurity"]["decisions"]) + and {e["id"]: e["blocked"] for e in (await catalog({"action": "get"}))["catalog"]["installed"]} == {theme: False, notes: False} + and not cache.exists()) + check("restored theme still applies after offline backup recovery", + bool((await product.page.evaluate("window.amadeus.getDesktopTheme()")).get("theme"))) + await notes_contract("restored-offline") + publisher.offline = False + await refresh() + await open_manager() + await product.screenshot("publication-restored") + check("all six snapshots passed input CI, publication CI and exact rebuild", + len(publisher.phases) == 6 and all(p["input_ci"] and p["publication_ci"] and p["exact_rebuild"] for p in publisher.phases)) + check("maintenance checker fetched exact wheels over HTTPS", + all(any(r["path"] == "/artifacts/" + p.name and r["status"] == 200 + and r["client"] == "maintainer" and r["sha256"] == sha256(p) + for r in publisher.requests) for p in publisher.wheels.values())) + check("completed operations leave no download staging", + not list((cache.parent / "staging").iterdir())) + await asyncio.to_thread(publisher.verify_rejections) + check("publication tools reject tampered generated output and missing safety facts") + check("submission revision precedes approval without a second author submission", + publisher.phases[0]["submission_revision"]["same_submission"] + and publisher.phases[0]["submission_revision"]["approval_was_not_granted"]) + check("all six publications verify candidate and live distribution with fresh assets", + all(p["candidate_delivery"] and p["live_delivery"] for p in publisher.phases)) + report["blocked_identities"] = blocked["security"] + report["ok"] = True + return report + except Exception: + await product.screenshot("publication-failure") + raise + finally: + await asyncio.to_thread(publisher.close) + report["publications"] = publisher.phases + save() diff --git a/tools/requirements-extension-authoring.txt b/tools/requirements-extension-authoring.txt new file mode 100644 index 00000000..64825ed1 --- /dev/null +++ b/tools/requirements-extension-authoring.txt @@ -0,0 +1,58 @@ +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 +pyyaml==6.0.3 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications +rpds-py==2026.6.3 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e + # via + # jsonschema + # referencing +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via referencing diff --git a/tools/smoke_electron_model_less.py b/tools/smoke_electron_model_less.py index ab3f985b..b74412ba 100644 --- a/tools/smoke_electron_model_less.py +++ b/tools/smoke_electron_model_less.py @@ -113,6 +113,49 @@ def _frontend_build_identity(directory: Path) -> dict[str, Any]: return {"kind": "packaged-asar" if directory.is_file() else "source-dist", "sha256": digest.hexdigest(), "file_count": len(files)} +def launcher_code_identity(root: Path) -> dict[str, Any]: + """Identify a Git checkout or verify the shipped source-manifest file set. + + A ZIP nested inside another checkout must not inherit that parent's Git HEAD. + Manifest identity describes the release inputs; installed dependencies and built + frontend bytes have their own lock/build evidence. It is not a signature. + """ + from tools.semantic_journey_evidence import code_identity + + manifest_path = root / "SOURCE_MANIFEST.json" + if (root / ".git").exists() or not manifest_path.is_file(): + return code_identity(root) + raw = manifest_path.read_bytes() + manifest = json.loads(raw) + records = manifest.get("selected_files") + commit = manifest.get("commit", "") + if (manifest.get("schema") != "amadeus.source-release-manifest.v1" + or manifest.get("release_ready") is not True + or not re.fullmatch(r"[a-f0-9]{40}", commit) + or not isinstance(records, list) or not records + or manifest.get("selected_file_count") != len(records)): + raise ValueError("Invalid source release identity") + seen: set[str] = set() + for record in records: + name = record.get("path", "") + if (not isinstance(name, str) or not name or "\\" in name + or ":" in name or name.startswith("/") + or any(part in {"", ".", ".."} for part in name.split("/")) + or name in seen): + raise ValueError("Invalid source release file path") + seen.add(name) + file = root / name + if not file.resolve().is_relative_to(root.resolve()) or not file.is_file(): + raise ValueError("Source release input is missing or outside the archive") + data = file.read_bytes() + if len(data) != record.get("size") or hashlib.sha256(data).hexdigest() != record.get("sha256"): + raise ValueError("Source release input differs from its manifest: " + name) + digest = hashlib.sha256(raw).hexdigest() + return {"commit_sha": commit, "workspace_dirty": False, + "workspace_fingerprint": digest, "identity_kind": "source-release-manifest", + "verified_source_files": len(records)} + + async def _press_native_theme_recovery(process_id: int, inspector_port: int) -> None: """Exercise the actual Electron input hook in this isolated owned process.""" import websockets @@ -274,8 +317,14 @@ async def _exercise_renderer(page: Any, *, timeout: float, process_id: int, insp ) checks["project_navigation_rendered"] = True - await page.get_by_role("button", name="Artifacts", exact=True).click() - await page.get_by_role("navigation", name="Chat and artifact navigation").hover() + # The drawer is owned by hover or focus. Retain keyboard focus while its + # contents change instead of moving the pointer across the resizing panel. + await rail.focus() + artifact_mode = rail.get_by_role("button", name="Artifacts", exact=True) + await artifact_mode.click() + from playwright.async_api import expect + await expect(artifact_mode).to_have_attribute("aria-pressed", "true", timeout=timeout_ms) + await rail.focus() await page.get_by_label("Artifact collections", exact=True).wait_for( state="visible", timeout=timeout_ms ) @@ -354,13 +403,18 @@ async def _wait_clean_exit( async def run_smoke(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + if args.extension_publication and (args.extension_fixture or args.extension_failures or args.native_confirmations): + raise ValueError("--extension-publication is a separate complete journey") + if args.native_confirmations and (not args.extension_fixture or args.extension_failures): + raise ValueError("--native-confirmations requires --extension-fixture and excludes --extension-failures") + if args.extension_failures and not args.extension_fixture: + raise ValueError("--extension-failures requires --extension-fixture") from tools.e2e_live_product_journey import ( BACKEND_PORT, ElectronProduct, WsProbe, _free_port, _port_is_open, - code_identity, ) class ModelLessElectronProduct(ElectronProduct): @@ -370,6 +424,9 @@ def _environment(self) -> dict[str, str]: environment = model_less_backend_environment( super()._environment(), python_executable=sys.executable ) + if args.extension_fixture or args.extension_publication: + environment.update(PI_PROVIDER_ENABLED="true", PI_MODEL_PROVIDER="fixture", + PI_MODEL="fixture", PI_AGENT_DIR=str(self.run_root / "state/pi")) if self.packaged_executable: # The current distribution packages the Electron frontend; # supply the separate Python runtime explicitly for this smoke. @@ -390,7 +447,7 @@ def _environment(self) -> dict[str, str]: debug_port=int(args.debug_port or _free_port()), main_debug_port=_free_port(), no_tts=True, - identity=code_identity(ROOT), + identity=launcher_code_identity(ROOT), packaged_executable=args.packaged_executable.resolve() if args.packaged_executable else None, ) report: dict[str, Any] = { @@ -407,6 +464,21 @@ def _environment(self) -> dict[str, str]: "checks": {}, } + if args.extension_fixture or args.extension_publication: + # A synthetic pre-catalog v2 settings file, never a copied user profile. + settings = run_root / "state/electron-user-data/settings.json" + settings.parent.mkdir(parents=True, exist_ok=True) + pi = run_root / "state/pi" + pi.mkdir(parents=True) + # Native Pi auth check is local/no-refresh. No model turn is sent; an + # accidental request can reach only a closed loopback port, never a service. + (pi / "models.json").write_text(json.dumps({"providers":{"fixture":{ + "baseUrl":"http://127.0.0.1:1/v1", "api":"openai-completions", + "apiKey":"local-fixture", "models":[{"id":"fixture", "reasoning":False, "input":["text"]}] + }}}), encoding="utf-8") + settings.write_text(json.dumps({"version":2, "values":{"AMADEUS_UI_LOCALE":"en-US"}, + "encryptedSecrets":{}, "mcpConnections":{}, "extensions":{}, + "pendingRevisions":{}, "nextRevision":1}), encoding="utf-8") try: await product.start(startup_timeout=float(args.startup_timeout)) report["checks"]["electron_started_backend"] = True @@ -446,6 +518,18 @@ def verify_wallpaper_stop() -> None: report["checks"].update( await _exercise_renderer(product.page, timeout=float(args.ui_timeout), process_id=product.process.pid, inspector_port=product.main_debug_port) ) + if args.extension_publication: + from tools.probes.extension_store_roundtrip import exercise_publication + report["publication"] = await exercise_publication(product, args.extension_publication.resolve()) + elif args.native_confirmations: + from tools.probes.extension_native_confirmation import exercise_native + report["native_confirmations"] = await exercise_native(product, args.extension_fixture.resolve()) + elif args.extension_fixture: + from tools.probes.extension_release_shell import exercise + report["extensions"] = await exercise(product, args.extension_fixture.resolve()) + if args.extension_failures: + from tools.probes.extension_release_failures import exercise_failures + report["extension_failures"] = await exercise_failures(product, args.extension_fixture.resolve()) if product.app_page_errors or product.app_console_errors: raise RuntimeError( "Electron renderer reported errors: " @@ -487,6 +571,10 @@ def _parser() -> argparse.ArgumentParser: default=str(RUNTIME / "electron_model_less_smoke"), ) parser.add_argument("--packaged-executable", type=Path, help="Launch the actual packaged frontend with this checkout supplying its external Python runtime") + parser.add_argument("--native-confirmations", action="store_true", help="Wait for an external UI operator to cancel/install/enable through real native dialogs") + parser.add_argument("--extension-failures", action="store_true", help="Run shipping IPC failure and cross-process corruption recovery cases; requires --extension-fixture") + parser.add_argument("--extension-publication", type=Path, help="Isolated full publisher/client demonstration configuration; use extension_store_demo.py") + parser.add_argument("--extension-fixture", type=Path, help="Explicitly execute reference wheels from a prepared isolated catalog fixture") parser.add_argument("--debug-port", type=int, default=0) parser.add_argument("--startup-timeout", type=float, default=120.0) parser.add_argument("--ui-timeout", type=float, default=30.0) diff --git a/uv.lock b/uv.lock index 6040208c..fc21d2c5 100644 --- a/uv.lock +++ b/uv.lock @@ -583,6 +583,13 @@ voice = [ { name = "soundfile" }, ] +[package.dev-dependencies] +extension-authoring = [ + { name = "jsonschema" }, + { name = "packaging" }, + { name = "pyyaml" }, +] + [package.metadata] requires-dist = [ { name = "accelerate", marker = "extra == 'local-models'", specifier = "==1.12.0" }, @@ -699,6 +706,13 @@ requires-dist = [ ] provides-extras = ["acp", "rag", "voice", "vad", "torch-cpu", "local-models", "local-cpu", "local-cu124", "local-cu128", "local-mps", "local-rocm", "dev"] +[package.metadata.requires-dev] +extension-authoring = [ + { name = "jsonschema" }, + { name = "packaging" }, + { name = "pyyaml" }, +] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -2871,14 +2885,14 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.44.0" +version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, ] [[package]] diff --git a/vendor/extension-catalog/LICENSE b/vendor/extension-catalog/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/vendor/extension-catalog/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/extension-catalog/catalog/fixtures/compatibility.json b/vendor/extension-catalog/catalog/fixtures/compatibility.json new file mode 100644 index 00000000..6aee8ac6 --- /dev/null +++ b/vendor/extension-catalog/catalog/fixtures/compatibility.json @@ -0,0 +1,145 @@ +{ + "capabilities": { + "delivery": [ + "python-wheel" + ], + "runtime": [ + "none", + "python" + ], + "contributions": [ + { + "kind": "voice_backend", + "role": "tts", + "contract": "amadeus.voice.tts/v0" + }, + { + "kind": "mcp_server", + "role": "", + "contract": "amadeus.mcp-server/v0" + }, + { + "kind": "skill", + "role": "", + "contract": "amadeus.skill/v0" + }, + { + "kind": "theme", + "role": "", + "contract": "amadeus.ui.theme/v0" + } + ] + }, + "cases": [ + { + "name": "current", + "compatibility": { + "delivery": "python-wheel", + "runtime": "none", + "contributions": [ + { + "kind": "skill", + "role": "", + "contract": "amadeus.skill/v0" + } + ] + }, + "compatible": true + }, + { + "name": "unknown-kind", + "compatibility": { + "delivery": "python-wheel", + "runtime": "none", + "contributions": [ + { + "kind": "future", + "role": "", + "contract": "amadeus.skill/v0" + } + ] + }, + "compatible": false + }, + { + "name": "unknown-contract", + "compatibility": { + "delivery": "python-wheel", + "runtime": "none", + "contributions": [ + { + "kind": "skill", + "role": "", + "contract": "amadeus.skill/v99" + } + ] + }, + "compatible": false + }, + { + "name": "unknown-role", + "compatibility": { + "delivery": "python-wheel", + "runtime": "none", + "contributions": [ + { + "kind": "skill", + "role": "future", + "contract": "amadeus.skill/v0" + } + ] + }, + "compatible": false + }, + { + "name": "unknown-delivery", + "compatibility": { + "delivery": "future", + "runtime": "none", + "contributions": [ + { + "kind": "skill", + "role": "", + "contract": "amadeus.skill/v0" + } + ] + }, + "compatible": false + }, + { + "name": "unknown-runtime", + "compatibility": { + "delivery": "python-wheel", + "runtime": "future", + "contributions": [ + { + "kind": "skill", + "role": "", + "contract": "amadeus.skill/v0" + } + ] + }, + "compatible": false + }, + { + "name": "mixed-package", + "compatibility": { + "delivery": "python-wheel", + "runtime": "none", + "contributions": [ + { + "kind": "skill", + "role": "", + "contract": "amadeus.skill/v0" + }, + { + "kind": "future", + "role": "", + "contract": "future/v0" + } + ] + }, + "compatible": false + } + ] +} diff --git a/vendor/extension-catalog/catalog/policy.json b/vendor/extension-catalog/catalog/policy.json new file mode 100644 index 00000000..6d02cf33 --- /dev/null +++ b/vendor/extension-catalog/catalog/policy.json @@ -0,0 +1,20 @@ +{ + "version": "curated-v1", + "wheel_tag": "py3-none-any", + "version_pattern": "(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)", + "allowed_dependencies": [ + "amadeus-extension-sdk" + ], + "native_suffixes": [ + ".pyd", + ".so", + ".dll", + ".exe", + ".dylib", + ".com", + ".msi", + ".wasm" + ], + "max_wheel_bytes": 268435456, + "max_index_bytes": 4194304 +} diff --git a/vendor/extension-catalog/catalog/schema.json b/vendor/extension-catalog/catalog/schema.json new file mode 100644 index 00000000..fad1e24a --- /dev/null +++ b/vendor/extension-catalog/catalog/schema.json @@ -0,0 +1,337 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "format": { + "const": "amadeus.catalog/v1" + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "source_commit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "source_time": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "generator_commit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "host_commit": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "entries": { + "type": "array", + "maxItems": 4096, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 268435456 + }, + "display": { + "type": "object", + "maxProperties": 16 + }, + "compatibility": { + "type": "object", + "properties": { + "host_api": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "python": { + "type": "string", + "maxLength": 256 + }, + "platforms": { + "type": "array", + "maxItems": 8, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "sdk": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "dependencies": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "contributions": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "role": { + "type": "string", + "maxLength": 128 + }, + "contract": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "role", + "contract" + ], + "additionalProperties": false + } + }, + "delivery": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "runtime": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "host_api", + "python", + "platforms", + "sdk", + "dependencies", + "contributions", + "delivery", + "runtime" + ], + "additionalProperties": false + }, + "publisher": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "maintainers": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "package_ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "evidence": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "id", + "maintainers", + "package_ids", + "evidence" + ], + "additionalProperties": false + }, + "review": { + "type": "object", + "properties": { + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "policy": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "reviewer": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "evidence": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "sha256", + "policy", + "reviewer", + "date", + "evidence" + ], + "additionalProperties": false + }, + "withdrawal": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "reason": { + "enum": [ + "maintainer", + "copyright" + ] + }, + "evidence": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "reason", + "evidence" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "id", + "version", + "sha256", + "url", + "size", + "display", + "compatibility", + "publisher", + "review", + "withdrawal" + ], + "additionalProperties": false + } + }, + "security": { + "type": "array", + "maxItems": 16384, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "status": { + "enum": [ + "blocked", + "allowed" + ] + }, + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "reason": { + "enum": [ + "security", + "security-resolved" + ] + }, + "evidence": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "id", + "version", + "sha256", + "status", + "sequence", + "reason", + "evidence" + ], + "additionalProperties": false + } + } + }, + "required": [ + "format", + "sequence", + "source_commit", + "source_time", + "generator_commit", + "host_commit", + "entries", + "security" + ], + "additionalProperties": false +} diff --git a/vendor/extension-catalog/catalog/toolchain.json b/vendor/extension-catalog/catalog/toolchain.json new file mode 100644 index 00000000..ee3a9ec2 --- /dev/null +++ b/vendor/extension-catalog/catalog/toolchain.json @@ -0,0 +1,7 @@ +{ + "host_repository": "https://github.com/Code-Amadeus/Amadeus", + "host_commit": "1840c8e4f646992ddaebea3139c8895f29743a8f", + "python": "3.12.10", + "platform": "win32", + "requirements_sha256": "eca122787362dc7d39b309b931c5e17ce7fb0507f79524589f2978bb018c17e7" +} diff --git a/vendor/extension-catalog/tools/catalog.py b/vendor/extension-catalog/tools/catalog.py new file mode 100644 index 00000000..44e3d877 --- /dev/null +++ b/vendor/extension-catalog/tools/catalog.py @@ -0,0 +1,541 @@ +"""Curated catalog policy and deterministic generation, separate from Host admission. + +Only the fixed Host checker inspects Host contracts. No wheel is installed/imported. +""" + +from __future__ import annotations + +import difflib +import hashlib +import json +import re +import shutil +import subprocess +import tempfile +import zipfile +from datetime import date +from email.parser import Parser +from pathlib import Path +from urllib.parse import unquote, urlsplit +from urllib.request import HTTPRedirectHandler, build_opener + +from jsonschema import Draft202012Validator +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.utils import canonicalize_name, parse_wheel_filename + +ROOT = Path(__file__).resolve().parents[1] +POLICY = json.loads((ROOT / "catalog/policy.json").read_text()) +PIN = json.loads((ROOT / "catalog/toolchain.json").read_text()) + + +def fail(message): + raise ValueError(message) + + +def canonical(value): + return ( + json.dumps(value, ensure_ascii=True, sort_keys=True, indent=2) + "\n" + ).encode() + + +def parse_json(data, limit=4 * 1024 * 1024): + if len(data) > limit: + fail("JSON exceeds size limit") + + def pairs(items): + result = {} + for key, value in items: + if key in result: + fail("duplicate JSON key") + result[key] = value + return result + + return json.loads(data, object_pairs_hook=pairs) + + +def read_json(path, limit=4 * 1024 * 1024): + with Path(path).open("rb") as source: + return parse_json(source.read(limit + 1), limit) + + +def https_url(value): + if ( + not isinstance(value, str) + or len(value) > 2048 + or any(ord(c) < 33 or ord(c) == 127 for c in value) + ): + fail("URL must be bounded HTTPS without credentials") + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + fail("URL must be HTTPS without credentials or fragments") + _ = parsed.port # reject invalid ports + return value + + +def wheel_filename(url): + https_url(url) + name = unquote(urlsplit(url).path.rsplit("/", 1)[-1]) + if "/" in name or "\\" in name or ":" in name or not name.endswith(".whl"): + fail("wheel URL must identify a concrete wheel filename") + parse_wheel_filename(name) + return name + + +def identity(entry): + return entry["id"], entry["version"], entry["sha256"] + + +def validate_index(index): + if len(canonical(index)) > POLICY["max_index_bytes"]: + fail("catalog exceeds size limit") + validator = Draft202012Validator(read_json(ROOT / "catalog/schema.json")) + if next(validator.iter_errors(index), None): + fail("unsupported catalog format or invalid required structure") + versions = set() + for entry in index["entries"]: + key = entry["id"], entry["version"] + if key in versions: + fail("duplicate or conflicting package identity") + versions.add(key) + https_url(entry["url"]) + validate_review(entry, entry["id"], entry["sha256"]) + if entry["withdrawal"]: + https_url(entry["withdrawal"]["evidence"]) + facts = set() + for fact in index["security"]: + key = identity(fact) + if key in facts or fact["sequence"] > index["sequence"]: + fail("duplicate safety identity or invalid decision sequence") + facts.add(key) + if (fact["status"], fact["reason"]) not in { + ("blocked", "security"), + ("allowed", "security-resolved"), + }: + fail("inconsistent safety decision") + https_url(fact["evidence"]) + return index + + +def validate_transition(previous, current): + validate_index(previous) + validate_index(current) + if current["sequence"] == previous["sequence"] and canonical(current) == canonical( + previous + ): + return # exact rebuild only + if current["sequence"] != previous["sequence"] + 1: + fail("publication sequence must increase by exactly one") + old = {(e["id"], e["version"]): e for e in previous["entries"]} + new = {(e["id"], e["version"]): e for e in current["entries"]} + for key, entry in old.items(): + if key not in new or entry["sha256"] != new[key]["sha256"]: + fail( + "released identity cannot disappear or change bytes; withdraw it explicitly" + ) + old_safety = {identity(e): e for e in previous["security"]} + new_safety = {identity(e): e for e in current["security"]} + if not old_safety.keys() <= new_safety.keys(): + fail("known safety facts cannot disappear") + for key, fact in new_safety.items(): + if old_safety.get(key) != fact and fact["sequence"] != current["sequence"]: + fail("changed safety decision requires the new publication sequence") + + +def incompatible_reasons(entry, capabilities): + """Reference consumer semantics; never call the full manifest validator here.""" + summary = entry["compatibility"] + reasons = [] + if ( + summary["delivery"] not in capabilities["delivery"] + or summary["runtime"] not in capabilities["runtime"] + ): + reasons.append("unsupported delivery or runtime") + allowed = { + (c["kind"], c.get("role", ""), c["contract"]) + for c in capabilities["contributions"] + } + if any( + (c["kind"], c["role"], c["contract"]) not in allowed + for c in summary["contributions"] + ): + reasons.append("unsupported contribution contract") + return reasons + + +def validate_review(source, package_id, digest): + publisher, review = source["publisher"], source["review"] + date.fromisoformat(review["date"]) + if package_id not in publisher["package_ids"] or not publisher["maintainers"]: + fail("publisher ownership does not cover this package ID") + if review["sha256"] != digest or review["policy"] != POLICY["version"]: + fail("this artifact needs its own current-policy review") + if review["reviewer"].casefold() in { + person.casefold() for person in [*publisher["maintainers"], publisher["id"]] + }: + fail("independent reviewer is required, including reference packages") + https_url(publisher["evidence"]) + https_url(review["evidence"]) + + +def check_policy(wheel, inspection): + """Distribution exclusions only: Host/local install rules are unchanged.""" + manifest = inspection["manifest"] + if not re.fullmatch(POLICY["version_pattern"], manifest["version"]): + fail("catalog policy: version must be X.Y.Z without pre-release/build suffix") + distribution, version, build, tags = parse_wheel_filename(wheel.name) + if ( + canonicalize_name(inspection["distribution"]) != distribution + or str(version) != manifest["version"] + ): + fail("catalog policy: wheel filename identity differs from metadata") + if build or {str(t) for t in tags} != {POLICY["wheel_tag"]}: + fail("catalog policy: wheel filename must be py3-none-any without a build tag") + if PIN["python"] not in SpecifierSet(inspection.get("requires_python", "")): + fail("catalog policy: wheel Python requirement excludes the fixed target") + for raw in inspection["requires_dist"]: + requirement = Requirement(raw) + if requirement.url: + fail("catalog policy: direct URL dependencies are excluded") + if requirement.marker and not requirement.marker.evaluate({"extra": ""}): + continue + if ( + canonicalize_name(requirement.name) not in POLICY["allowed_dependencies"] + or requirement.extras + ): + fail("catalog policy: third-party dependencies are excluded from v1") + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + headers = [name for name in names if name.endswith(".dist-info/WHEEL")] + if len(headers) != 1 or archive.getinfo(headers[0]).file_size > 65536: + fail("catalog policy: missing or oversized WHEEL metadata") + if headers[0].removesuffix("WHEEL") + "METADATA" not in names: + fail("catalog policy: WHEEL must belong to the distribution metadata") + metadata = Parser().parsestr(archive.read(headers[0]).decode("utf-8")) + if metadata.get_all("Tag") != [POLICY["wheel_tag"]] or metadata.get_all( + "Root-Is-Purelib" + ) != ["true"]: + fail("catalog policy: WHEEL must declare purelib and exactly py3-none-any") + for info in archive.infolist(): + if info.is_dir(): + continue + with archive.open(info) as member: + magic = member.read(8) + if Path(info.filename).suffix.lower() in POLICY[ + "native_suffixes" + ] or magic.startswith( + ( + b"MZ", + b"\x7fELF", + b"\0asm", + b"\xcf\xfa\xed\xfe", + b"\xfe\xed\xfa\xcf", + b"\xca\xfe\xba\xbe", + b"\xce\xfa\xed\xfe", + b"\xfe\xed\xfa\xce", + ) + ): + fail("catalog policy: native/executable binary payload is excluded") + package = inspection["package_root"] + + def owned_script(value): + if not value.startswith("${package}/"): + return False + suffix = value[len("${package}/") :] + return ( + bool(suffix) + and not any(p in {"", ".", ".."} for p in suffix.split("/")) + and package + "/" + suffix in names + ) + + for server in manifest["contributes"].get("mcp_server", []): + if server["transport"] != "stdio": + continue + command, args = server["command"], server.get("args", []) + if command == "${python}": + args = list(args) + while args and args[0] in {"-I", "-B", "-s", "-E", "-u"}: + args.pop(0) + if len(args) >= 2 and args[0] == "-m": + module = args[1] + location = module.replace(".", "/") + if ( + not re.fullmatch(r"[A-Za-z_]\w*(\.[A-Za-z_]\w*)*", module) + or module.split(".")[0] != package + or not ({location + ".py", location + "/__main__.py"} & names) + ): + fail( + "catalog policy: Python module must belong to the reviewed package" + ) + elif ( + not args or not owned_script(args[0]) or not args[0].endswith(".py") + ): + fail( + "catalog policy: Python must run a reviewed package module or .py script" + ) + elif not owned_script(command) or not command.endswith(".py"): + fail( + "catalog policy: MCP command must be Python or a reviewed package script" + ) + # Actual executable/platform behavior and arbitrary code downloads remain human review. + + +def verify_host(host, python): + def git(*args): + return subprocess.check_output( + ["git", "-C", str(host), *args], text=True + ).strip() + + if git("rev-parse", "HEAD") != PIN["host_commit"]: + fail("Host validator commit does not match toolchain pin") + paths = [ + "tools/extension_package.py", + "server/extensions", + "agent_host/skills.py", + "pyproject.toml", + "vendor/extension-sdk", + ] + if git("status", "--porcelain", "--", *paths): + fail("pinned Host validator inputs are modified") + lock = host / "tools/requirements-extension-authoring.txt" + if hashlib.sha256(lock.read_bytes()).hexdigest() != PIN["requirements_sha256"]: + fail("Host authoring dependency export differs from pin") + result = subprocess.run( + [str(python), "-I", str(host / "tools/extension_package.py"), "profile"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode: + fail("fixed Host checker environment is unavailable") + profile = json.loads(result.stdout)["host_profile"] + if (profile["python"], profile["platform"]) != (PIN["python"], PIN["platform"]): + fail("catalog qualification requires pinned Windows/Python environment") + return profile + + +class BoundedRedirect(HTTPRedirectHandler): + max_redirections = 5 + + def redirect_request(self, req, fp, code, msg, headers, newurl): + https_url(newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def fetch_artifact(source, artifacts): + digest, url = source["wheel"]["sha256"], source["wheel"]["url"] + if not re.fullmatch("[a-f0-9]{64}", digest): + fail("invalid artifact digest") + target = artifacts / digest / wheel_filename(url) + if target.is_file(): + return target + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix(".download") + try: + with ( + build_opener(BoundedRedirect()).open( + https_url(url), timeout=30 + ) as response, + temporary.open("xb") as output, + ): + size, hasher = 0, hashlib.sha256() + while block := response.read(1024 * 1024): + size += len(block) + if size > POLICY["max_wheel_bytes"]: + fail("download exceeds wheel size limit") + hasher.update(block) + output.write(block) + if hasher.hexdigest() != digest: + fail("artifact digest mismatch") + temporary.replace(target) + finally: + temporary.unlink(missing_ok=True) + return target + + +def retained_entry(source, previous): + """Reuse immutable reviewed facts from protected main, never a display/download cache. + + Safety/withdrawal publication must not depend on old asset URLs staying online. + New bytes, publisher facts, reviews or checker pins still need full inspection. + """ + if set(source) != {"wheel", "publisher", "review", "withdrawal"} or set( + source["wheel"] + ) != {"url", "sha256"}: + fail("source contains derived metadata or unknown manual facts") + if previous is None or previous["host_commit"] != PIN["host_commit"]: + return None + for entry in previous["entries"]: + if ( + source["wheel"] == {"url": entry["url"], "sha256": entry["sha256"]} + and source["publisher"] == entry["publisher"] + and source["review"] == entry["review"] + and entry["review"]["policy"] == POLICY["version"] + ): + return {**entry, "withdrawal": source["withdrawal"]} + return None + + +def build_entry(source, artifacts, host, python, profile): + if set(source) != {"wheel", "publisher", "review", "withdrawal"} or set( + source["wheel"] + ) != {"url", "sha256"}: + fail("source contains derived metadata or unknown manual facts") + digest = source["wheel"]["sha256"] + if not re.fullmatch("[a-f0-9]{64}", digest): + fail("invalid artifact digest") + wheel = artifacts / digest / wheel_filename(source["wheel"]["url"]) + if wheel.stat().st_size > POLICY["max_wheel_bytes"]: + fail("artifact exceeds size limit") + with tempfile.TemporaryDirectory(prefix="catalog-artifact-") as temporary: + snapshot = Path(temporary) / wheel.name + shutil.copyfile(wheel, snapshot) + return inspect_entry(source, snapshot, host, python, profile) + + +def artifact_display(manifest): + """Public artifact facts only; never copy configuration defaults or environment values.""" + return { + **{ + k: manifest[k] + for k in ( + "display_name", + "description", + "author", + "license", + "homepage", + "repository", + ) + if k in manifest + }, + "access": { + "os": manifest.get("permissions", {}).get("os", {}), + "services": [ + { + "kind": kind, + **{ + key: item[key] + for key in ( + "id", + "deployment", + "transport", + "command", + "args", + "url", + ) + if key in item + }, + } + for kind in ("voice_backend", "mcp_server") + for item in manifest["contributes"].get(kind, []) + ], + }, + } + + +def inspect_entry(source, wheel, host, python, profile): + digest = source["wheel"]["sha256"] + with wheel.open("rb") as stream: + if hashlib.file_digest(stream, "sha256").hexdigest() != digest: + fail("artifact digest mismatch") + result = subprocess.run( + [ + str(python), + "-I", + str(host / "tools/extension_package.py"), + "check-wheel", + str(wheel), + ], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + if result.returncode: + fail( + "fixed Host check-wheel rejected artifact (run it locally for its sanitized diagnostic)" + ) + inspection = json.loads(result.stdout) + if inspection["digest"] != digest: + fail("artifact changed during inspection") + check_policy(wheel, inspection) + manifest = inspection["manifest"] + validate_review(source, manifest["id"], digest) + contributions = [ + {"kind": kind, "role": item.get("role", ""), "contract": item["contract"]} + for kind, items in manifest["contributes"].items() + for item in items + ] + return { + **{k: source[k] for k in ("publisher", "review", "withdrawal")}, + "id": manifest["id"], + "version": manifest["version"], + "sha256": digest, + "url": source["wheel"]["url"], + "size": wheel.stat().st_size, + "display": artifact_display(manifest), + "compatibility": { + "host_api": manifest["engines"]["amadeus_host_api"], + "python": str( + SpecifierSet(manifest["runtime"].get("requires_python", "")) + & SpecifierSet(inspection.get("requires_python", "")) + ), + "platforms": manifest["engines"].get("platforms", []), + "sdk": profile["sdk_version"], + "dependencies": inspection["requires_dist"], + "contributions": sorted(contributions, key=lambda c: tuple(c.values())), + "delivery": "python-wheel", + "runtime": manifest["runtime"]["kind"], + }, + } + + +def wheel_diff(before, after): + """Bounded per-file text diffs and hashes over the actual reviewed wheel bytes.""" + + def files(path): + if path is None: + return {} + with zipfile.ZipFile(path) as archive: + infos = archive.infolist() + if ( + len(infos) > 10000 + or sum(i.file_size for i in infos) > POLICY["max_wheel_bytes"] + ): + fail("diff archive exceeds limits") + return {i.filename: archive.read(i) for i in infos if not i.is_dir()} + + old, new = files(before), files(after) + changes = [] + for name in sorted(old.keys() | new.keys()): + a, b = old.get(name), new.get(name) + if a == b: + continue + row = { + "path": name, + "status": "added" if a is None else "removed" if b is None else "modified", + "before": hashlib.sha256(a).hexdigest() if a is not None else None, + "after": hashlib.sha256(b).hexdigest() if b is not None else None, + } + try: + if max(len(a or b""), len(b or b"")) <= 65536: + lines = list( + difflib.unified_diff( + (a or b"").decode("utf-8").splitlines(), + (b or b"").decode("utf-8").splitlines(), + lineterm="", + ) + ) + row["text_diff"] = lines[:400] + row["diff_truncated"] = len(lines) > 400 + except UnicodeError: + pass + changes.append(row) + return changes diff --git a/wallpaper/windows/Amadeus.Wallpaper/Amadeus.Wallpaper.csproj b/wallpaper/windows/Amadeus.Wallpaper/Amadeus.Wallpaper.csproj index 05e24e56..bc19d13f 100644 --- a/wallpaper/windows/Amadeus.Wallpaper/Amadeus.Wallpaper.csproj +++ b/wallpaper/windows/Amadeus.Wallpaper/Amadeus.Wallpaper.csproj @@ -5,6 +5,8 @@ enable enable true + true + win-x64 8.0.31 diff --git a/wallpaper/windows/Amadeus.Wallpaper/packages.lock.json b/wallpaper/windows/Amadeus.Wallpaper/packages.lock.json index 7629555a..351e993f 100644 --- a/wallpaper/windows/Amadeus.Wallpaper/packages.lock.json +++ b/wallpaper/windows/Amadeus.Wallpaper/packages.lock.json @@ -35,6 +35,7 @@ "resolved": "4.6.0", "contentHash": "OEkbBQoklHngJ8UD8ez2AERSk2g+/qpAaSWWCBFbpH727HxDq5ydVkuncBaKcKfwRqXGWx64dS6G1SUScMsitg==" } - } + }, + "net8.0-windows7.0/win-x64": {} } } \ No newline at end of file