From af582d93115eb8f6c3bf4f4398e089d8521f37fc Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 03:43:38 +1300 Subject: [PATCH 01/39] feat(extensions): add minimal static authoring checks and guide --- .github/workflows/python-windows.yml | 10 + CONTRIBUTING.md | 7 + README.md | 1 + README_ZH.md | 1 + ROADMAP.md | 34 +- docs/extension_authoring.md | 151 +++++++ docs/extension_host_profile_v0.1.md | 381 ++--------------- ...extension_validation_history_2026-10-13.md | 383 ++++++++++++++++++ examples/extensions/README.md | 5 +- release/source_release_policy.json | 7 + tests/test_extension_authoring.py | 110 +++++ tools/extension_package.py | 154 +++++-- tools/requirements-extension-authoring.in | 4 + tools/requirements-extension-authoring.txt | 237 +++++++++++ 14 files changed, 1088 insertions(+), 397 deletions(-) create mode 100644 docs/extension_authoring.md create mode 100644 docs/extension_validation_history_2026-10-13.md create mode 100644 tests/test_extension_authoring.py create mode 100644 tools/requirements-extension-authoring.in create mode 100644 tools/requirements-extension-authoring.txt diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index b4b7eb8b..856ebd84 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -81,6 +81,16 @@ 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: | + 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 + - 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. 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/README.md b/README.md index f6cb9391..0439edf9 100644 --- a/README.md +++ b/README.md @@ -1004,6 +1004,7 @@ 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. - [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 4bcb8070..e6eedd54 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -886,6 +886,7 @@ 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 支持范围与运行验收边界。
Star History diff --git a/ROADMAP.md b/ROADMAP.md index 7ac08f40..fec7a474 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,25 +36,26 @@ 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 active next step is a community authoring preview: a small checker +environment, clear Host support information, reusable examples and independent +author lifecycle evidence. A curated official catalog can progress alongside +author recruitment after the shared validation entry is ready. It will reuse the +same installer, with exact artifact hashes, per-version review, explicit updates +and durable safety decisions. This catalog is not yet implemented or published. -- 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 +146,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/docs/extension_authoring.md b/docs/extension_authoring.md new file mode 100644 index 00000000..fbb41bdc --- /dev/null +++ b/docs/extension_authoring.md @@ -0,0 +1,151 @@ +# 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. + +Regenerate the hash lock with the project's uv 0.12.8: + +```powershell +uv pip compile --universal --python-version 3.12.10 --generate-hashes --no-emit-index-url --output-file tools/requirements-extension-authoring.txt tools/requirements-extension-authoring.in +``` + +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` accepts the import-package directory containing the manifest, +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 | +| Host static check passes | This checkout/interpreter accepts the manifest and checked resources | +| 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. diff --git a/docs/extension_host_profile_v0.1.md b/docs/extension_host_profile_v0.1.md index 0a359215..ea8e2264 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 | @@ -266,379 +269,51 @@ an upstream draft change does not silently change the Host's accepted subset. ## 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. +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. -| 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) +See [the recorded evidence](extension_validation_history_2026-10-13.md#licensing-and-sdk-delivery-2026-10-13). -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. +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) -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. +See [the recorded evidence](extension_validation_history_2026-10-13.md#merge-follow-up-2026-10-13). +### Explicit backend startup receipts (2026-10-13 audit follow-up) -## Merge follow-up (2026-10-13) +See [the recorded evidence](extension_validation_history_2026-10-13.md#explicit-backend-startup-receipts-2026-10-13-audit-follow-up). + +### 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_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/examples/extensions/README.md b/examples/extensions/README.md index ce3a0b0e..cadb6440 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 diff --git a/release/source_release_policy.json b/release/source_release_policy.json index bf733df4..e5b13547 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -9,6 +9,11 @@ }, "archive_prefix": "amadeus-{version}", "required_files": [ + "tools/extension_package.py", + "tools/requirements-extension-authoring.in", + "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", @@ -145,6 +150,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", diff --git a/tests/test_extension_authoring.py b/tests/test_extension_authoring.py new file mode 100644 index 00000000..901a48fa --- /dev/null +++ b/tests/test_extension_authoring.py @@ -0,0 +1,110 @@ +"""Author tools use the installed-package validator without executing plugin code.""" +from __future__ import annotations + +import copy +import json +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) diff --git a/tools/extension_package.py b/tools/extension_package.py index 2039c4a2..ebde880a 100644 --- a/tools/extension_package.py +++ b/tools/extension_package.py @@ -1,61 +1,163 @@ -"""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 + with manifest_path.open('rb') as source: + raw = source.read(MANIFEST_LIMIT_BYTES + 1) + 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-wheel', 'check-package', 'validate-installed', 'prepare-data-wheel'}: + print('Checked: Host 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', + ]) + 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 == '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=['host_admission', 'package_resources', 'skill_metadata'], + not_checked=['runtime', 'native_theme', 'remote_services', 'catalog_review']) + 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/requirements-extension-authoring.in b/tools/requirements-extension-authoring.in new file mode 100644 index 00000000..72210753 --- /dev/null +++ b/tools/requirements-extension-authoring.in @@ -0,0 +1,4 @@ +# Static author checks only. Build tools and runtime/GUI tests are separate. +jsonschema==4.26.0 +packaging==26.2 +PyYAML==6.0.3 diff --git a/tools/requirements-extension-authoring.txt b/tools/requirements-extension-authoring.txt new file mode 100644 index 00000000..7f4711ec --- /dev/null +++ b/tools/requirements-extension-authoring.txt @@ -0,0 +1,237 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --universal --python-version 3.12.10 --generate-hashes --no-emit-index-url --output-file tools/requirements-extension-authoring.txt tools/requirements-extension-authoring.in +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via -r tools/requirements-extension-authoring.in +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via -r tools/requirements-extension-authoring.in +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via -r tools/requirements-extension-authoring.in +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications +rpds-py==2026.9.1 \ + --hash=sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25 \ + --hash=sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68 \ + --hash=sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495 \ + --hash=sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32 \ + --hash=sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96 \ + --hash=sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834 \ + --hash=sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0 \ + --hash=sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6 \ + --hash=sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e \ + --hash=sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa \ + --hash=sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759 \ + --hash=sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff \ + --hash=sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7 \ + --hash=sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c \ + --hash=sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903 \ + --hash=sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d \ + --hash=sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9 \ + --hash=sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e \ + --hash=sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b \ + --hash=sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a \ + --hash=sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a \ + --hash=sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2 \ + --hash=sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945 \ + --hash=sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91 \ + --hash=sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044 \ + --hash=sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69 \ + --hash=sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e \ + --hash=sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3 \ + --hash=sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4 \ + --hash=sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33 \ + --hash=sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52 \ + --hash=sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9 \ + --hash=sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb \ + --hash=sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35 \ + --hash=sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299 \ + --hash=sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c \ + --hash=sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063 \ + --hash=sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a \ + --hash=sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6 \ + --hash=sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12 \ + --hash=sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14 \ + --hash=sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0 \ + --hash=sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30 \ + --hash=sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31 \ + --hash=sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02 \ + --hash=sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af \ + --hash=sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef \ + --hash=sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0 \ + --hash=sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888 \ + --hash=sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a \ + --hash=sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c \ + --hash=sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b \ + --hash=sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86 \ + --hash=sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f \ + --hash=sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531 \ + --hash=sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6 \ + --hash=sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82 \ + --hash=sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d \ + --hash=sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7 \ + --hash=sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448 \ + --hash=sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391 \ + --hash=sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb \ + --hash=sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4 \ + --hash=sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e \ + --hash=sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c \ + --hash=sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15 \ + --hash=sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f \ + --hash=sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297 \ + --hash=sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36 \ + --hash=sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d \ + --hash=sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b \ + --hash=sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237 \ + --hash=sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb \ + --hash=sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477 \ + --hash=sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c \ + --hash=sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e \ + --hash=sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608 \ + --hash=sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07 \ + --hash=sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c \ + --hash=sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35 \ + --hash=sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0 \ + --hash=sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785 \ + --hash=sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad \ + --hash=sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc \ + --hash=sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9 \ + --hash=sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025 \ + --hash=sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f \ + --hash=sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009 \ + --hash=sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d \ + --hash=sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118 \ + --hash=sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db \ + --hash=sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9 \ + --hash=sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab \ + --hash=sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37 \ + --hash=sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d \ + --hash=sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc \ + --hash=sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9 \ + --hash=sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3 \ + --hash=sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569 \ + --hash=sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69 \ + --hash=sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b \ + --hash=sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018 \ + --hash=sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f \ + --hash=sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c \ + --hash=sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f \ + --hash=sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5 \ + --hash=sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445 \ + --hash=sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1 \ + --hash=sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e \ + --hash=sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429 \ + --hash=sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa \ + --hash=sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b \ + --hash=sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250 \ + --hash=sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e \ + --hash=sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83 \ + --hash=sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f \ + --hash=sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd \ + --hash=sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346 \ + --hash=sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858 \ + --hash=sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871 \ + --hash=sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da \ + --hash=sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0 \ + --hash=sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f \ + --hash=sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050 \ + --hash=sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31 \ + --hash=sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df \ + --hash=sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12 \ + --hash=sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567 + # via + # jsonschema + # referencing +typing-extensions==4.16.0 ; python_full_version < '3.13' \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via referencing From 4818e2ee93a9423aeb9e33e8cc98234473f00f6b Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 03:43:39 +1300 Subject: [PATCH 02/39] docs(extensions): record approved plan and first preview validation --- ...unity_preview_and_store_plan_2026-10-21.md | 578 ++++++++++++++++++ ...g_and_developer_kit_proposal_2026-10-12.md | 81 +++ ...n_module_implementation_plan_2026-10-11.md | 119 ++++ ...tension_pilot_expansion_plan_2026-10-12.md | 413 +++++++++++++ .../community-preview-batch1-2026-10-21.md | 89 +++ 5 files changed, 1280 insertions(+) create mode 100644 docs/extension_community_preview_and_store_plan_2026-10-21.md create mode 100644 docs/extension_licensing_and_developer_kit_proposal_2026-10-12.md create mode 100644 docs/extension_module_implementation_plan_2026-10-11.md create mode 100644 docs/extension_pilot_expansion_plan_2026-10-12.md create mode 100644 docs/verification/extensions/community-preview-batch1-2026-10-21.md 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..b5b64540 --- /dev/null +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -0,0 +1,578 @@ +# 社区插件开发预览与首版插件商店实施计划 + +日期:2026-10-21 + +状态:已批准并开始实验施工;第一批 D0/D1 见[施工记录](verification/extensions/community-preview-batch1-2026-10-21.md)。后续阶段仍按各自退出条件推进,未发布。 + +目标是让外部作者独立完成插件,让用户从精选目录发现、安装和更新插件。先完成 **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;拟提供 `tools/requirements-extension-authoring.in` 与带哈希的 `.txt`,固定全部传递依赖,使用 `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 对同一输入结论一致。 +- 拟新增 `tools/requirements-extension-authoring.in` 与 `.txt`:最小静态依赖及完整哈希锁。 +- `docs/extension_authoring.md`:文档化现有命令及新增选项,明确静态检查不执行包。 +- 协议仓只有在发现实际重复规则时才抽取共享静态规则;遵守规范来源与许可归属,不能把 Host AGPL 代码直接改标为 Apache-2.0。 + +**依赖与验收** + +- 依赖 D0 确定的支持范围;不依赖商店或开发目录模式。 +- 相同有效/无效包在作者 CLI 与安装 helper 得到相同准入结论。 +- 该一致性指相同目标 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/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/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/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 和专属审核项 | 正反运行与混合包生命周期通过后才开放生产类型 | 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..14f8a0ff --- /dev/null +++ b/docs/extension_module_implementation_plan_2026-10-11.md @@ -0,0 +1,119 @@ +# 插件基线收口与记忆模块衔接 + +> 更新: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. 历史与证据 + +原[调研](extension_protocol_research_2026-10-05.md)与[审计](extension_module_implementation_plan_review_2026-10-11.md)保留为带日期的历史材料,不是当前施工指令。 +本次改写前的完整本地文件保存在 `build/extension-doc-closeout-20261014/originals/`,附 SHA-256 清单。 +公开的实现与验收以已合并 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..b9127e8d --- /dev/null +++ b/docs/extension_pilot_expansion_plan_2026-10-12.md @@ -0,0 +1,413 @@ +# 插件基线收口与后续贡献候选 + +> 更新: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);本文件只说明候选贡献及依赖。 +原始审计和旧施工快照已备份在 `build/extension-doc-closeout-20261014/originals/`,不再与当前基线混排。 + +- 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. 下一步优先级与开工门槛 + +当前推荐顺序是:文档收口 → 记忆设计 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/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..9985d0c9 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch1-2026-10-21.md @@ -0,0 +1,89 @@ +# 社区插件开发预览第一批施工记录 + +日期:2026-10-21。状态:本地实验,未推送、未发布。 + +Host 实验分支为 `codex/extension-community-preview`,基线为主线 +`fd17e707d8793480003f929bc8c78a8d134e41b5`,包含 #234。独立协议仓同名分支从 +`bf1a848448a19ed20eca029e8cc3991563e42e95` 开始。日常工作区保持原分支和已有修改。 + +## 本批交付 + +- D0:新增统一作者入口;主仓中英文 README、贡献说明和 ROADMAP 链接到当前路径。 + Host profile 保留当前契约,历史验收移到独立文件并保留旧标题锚点。协议仓首页修正了 + “未合并实验”的过期表述;没有改变规范、SDK 或其来源 pin。 +- 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; +它的设置、会话、账本、缓存与数据独立,但不能与另一实例的后端同时占用该端口。 +关闭实验窗口应走正常退出;先关闭它再启动日常实例。 + +Computer Use 已观察到实际 Chat 窗口;原生控件几何捕获不稳定,因此没有把后续点击尝试 +计作通过,也没有用其他应用的画面作为证据。完整 GUI 结果来自上面的现有生产应用 smoke。 +实验准备脚本曾因顶层等待和错误的启动枚举值失败,修复的是一次性驱动;正式记录仅采用 +修复后成功的准备和调用结果,没有修改生产启动逻辑来适应探针。 + +## 尚未交付 + +- D2 统一作者合约检查 CLI 尚未实现;本批只复用现有进程与 GUI 探针。 +- D3 外部作者验证未开始,不把维护者示例当作社区作者证据。 +- S0 目录规范、公开审核清单和发布流水线尚未实现。 +- S1/S2 的远程下载、镜像、持久安全事实与商店界面尚未实现,当前窗口仍是本地插件管理器。 +- 没有发布公开索引、SDK、软件版本或社区消息;没有合并到主线。 + +下一批从已可用的 D1 入口出发,按批准计划并行推进 D2 和 S0,继续保留首版收录政策与 +Host 运行契约的边界。阶段退出证据以新的精确提交和验收记录更新。 From 1840c8e4f646992ddaebea3139c8895f29743a8f Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 04:09:47 +1300 Subject: [PATCH 03/39] fix(extensions): clarify authoring checks and preserve current contracts --- .github/workflows/python-windows.yml | 10 + docs/extension_authoring.md | 17 +- ...unity_preview_and_store_plan_2026-10-21.md | 7 +- docs/extension_host_profile_v0.1.md | 31 ++- ...n_module_implementation_plan_2026-10-11.md | 9 +- ...tension_pilot_expansion_plan_2026-10-12.md | 7 +- .../community-preview-batch1-2026-10-21.md | 53 ++++- pyproject.toml | 4 + release/source_release_policy.json | 1 - tests/test_extension_authoring.py | 49 ++++ tools/extension_package.py | 22 +- tools/requirements-extension-authoring.in | 4 - tools/requirements-extension-authoring.txt | 219 ++---------------- uv.lock | 14 ++ 14 files changed, 222 insertions(+), 225 deletions(-) delete mode 100644 tools/requirements-extension-authoring.in diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index 856ebd84..7a7d72f7 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -84,12 +84,22 @@ jobs: - 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 diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md index fbb41bdc..d643b3ca 100644 --- a/docs/extension_authoring.md +++ b/docs/extension_authoring.md @@ -37,10 +37,12 @@ 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. -Regenerate the hash lock with the project's uv 0.12.8: +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 pip compile --universal --python-version 3.12.10 --generate-hashes --no-emit-index-url --output-file tools/requirements-extension-authoring.txt tools/requirements-extension-authoring.in +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 @@ -67,8 +69,12 @@ build/extension-authoring-env/Scripts/python.exe -I tools/extension_package.py c 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` accepts the import-package directory containing the manifest, -not the outer project directory containing `pyproject.toml`. +`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 @@ -117,7 +123,8 @@ cache and data directories. Never copy normal user credentials into a fixture. | Result | Scope | | --- | --- | | Protocol-valid | Matches the pinned schema and semantic rules | -| Host static check passes | This checkout/interpreter accepts the manifest and checked resources | +| 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 | 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 index b5b64540..7b8fe2c7 100644 --- a/docs/extension_community_preview_and_store_plan_2026-10-21.md +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -113,7 +113,7 @@ S0 至 S2 使用维护者参考包和 fixture 独立验收。S3 可以收录维 先把已有 `tools/extension_package.py` 整理成对外有文档的目标 Host 校验入口,复用 `validate_manifest`、`inspect_wheel`、`validate_config`、`validate_installed`。作者取得固定 Host 源码快照后,可在最小检查环境中使用工具,不必安装完整 Host。首版不要求作者工具成为独立 PyPI 产品。 -静态检查的直接依赖为 jsonschema、packaging、PyYAML;拟提供 `tools/requirements-extension-authoring.in` 与带哈希的 `.txt`,固定全部传递依赖,使用 `pip --require-hashes` 安装。示例构建另列所需构建工具,实际运行和 GUI 使用 Host 支持的环境,不能把静态检查环境描述为完整执行环境。 +静态检查的直接依赖为 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/实机给出。 @@ -131,14 +131,15 @@ S0 至 S2 使用维护者参考包和 fixture 独立验收。S3 可以收录维 - `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 对同一输入结论一致。 -- 拟新增 `tools/requirements-extension-authoring.in` 与 `.txt`:最小静态依赖及完整哈希锁。 +- `pyproject.toml` 的 `extension-authoring` 依赖组与 `uv.lock`:唯一解析来源;`tools/requirements-extension-authoring.txt` 为带哈希的派生导出,CI 检查漂移。 - `docs/extension_authoring.md`:文档化现有命令及新增选项,明确静态检查不执行包。 - 协议仓只有在发现实际重复规则时才抽取共享静态规则;遵守规范来源与许可归属,不能把 Host AGPL 代码直接改标为 Apache-2.0。 **依赖与验收** - 依赖 D0 确定的支持范围;不依赖商店或开发目录模式。 -- 相同有效/无效包在作者 CLI 与安装 helper 得到相同准入结论。 +- 对同一 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`。 diff --git a/docs/extension_host_profile_v0.1.md b/docs/extension_host_profile_v0.1.md index ea8e2264..33ac735a 100644 --- a/docs/extension_host_profile_v0.1.md +++ b/docs/extension_host_profile_v0.1.md @@ -158,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. @@ -230,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. @@ -312,7 +340,8 @@ See [the recorded evidence](extension_validation_history_2026-10-13.md#merge-fol ### Explicit backend startup receipts (2026-10-13 audit follow-up) -See [the recorded evidence](extension_validation_history_2026-10-13.md#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). ### Why the earlier 14/14 result was invalid diff --git a/docs/extension_module_implementation_plan_2026-10-11.md b/docs/extension_module_implementation_plan_2026-10-11.md index 14f8a0ff..74f6d272 100644 --- a/docs/extension_module_implementation_plan_2026-10-11.md +++ b/docs/extension_module_implementation_plan_2026-10-11.md @@ -1,5 +1,8 @@ # 插件基线收口与记忆模块衔接 +> 历史计划:插件施工顺序已由 [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,不开放新贡献类型,不开始记忆、开发模式或日志功能。 @@ -37,7 +40,7 @@ ## 3. 后续工作排序 -本次插件基建已达到可以规划 Host 内置记忆的门槛。顺序为 **文档收口 → 记忆设计 Issue/验收范围 → M0 → M2 → M1**。 +本次插件基建已达到可以规划 Host 内置记忆的门槛。当时建议顺序为 **文档收口 → 记忆设计 Issue/验收范围 → M0 → M2 → M1**。 M0/M2/M1 沿用既有阶段名,不重新编号;顺序调整为先确立 Host 的记忆语义,再开放外部来源。 作者工具和更多贡献类型是独立候选: @@ -114,6 +117,6 @@ M1 不预造第二套身份或事实 schema,不以安装成功替代运行和 ## 8. 历史与证据 -原[调研](extension_protocol_research_2026-10-05.md)与[审计](extension_module_implementation_plan_review_2026-10-11.md)保留为带日期的历史材料,不是当前施工指令。 -本次改写前的完整本地文件保存在 `build/extension-doc-closeout-20261014/originals/`,附 SHA-256 清单。 +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 index b9127e8d..65aa1053 100644 --- a/docs/extension_pilot_expansion_plan_2026-10-12.md +++ b/docs/extension_pilot_expansion_plan_2026-10-12.md @@ -1,12 +1,15 @@ # 插件基线收口与后续贡献候选 +> 历史计划:插件施工顺序已由 [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);本文件只说明候选贡献及依赖。 -原始审计和旧施工快照已备份在 `build/extension-doc-closeout-20261014/originals/`,不再与当前基线混排。 +原始内部审计和本地备份未随本仓发布,不作为读者前置材料;可复核的历史边界见 [插件验收历史](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` 不变。 @@ -396,7 +399,7 @@ P3/P4/P5 的候选设计不表示当前契约已经开放。ASR final/speculativ ## 11. 下一步优先级与开工门槛 -当前推荐顺序是:文档收口 → 记忆设计 Issue/验收范围 → M0 → M2 → M1,见[记忆衔接计划](extension_module_implementation_plan_2026-10-11.md)。本轮没有启动其中的实现。 +2026-10-14 当时推荐顺序是:文档收口 → 记忆设计 Issue/验收范围 → M0 → M2 → M1,见[记忆衔接计划](extension_module_implementation_plan_2026-10-11.md)。本轮没有启动其中的实现。 | 独立候选 | 技术依赖 | 不应误解为 | |---|---|---| diff --git a/docs/verification/extensions/community-preview-batch1-2026-10-21.md b/docs/verification/extensions/community-preview-batch1-2026-10-21.md index 9985d0c9..44f2f199 100644 --- a/docs/verification/extensions/community-preview-batch1-2026-10-21.md +++ b/docs/verification/extensions/community-preview-batch1-2026-10-21.md @@ -8,9 +8,11 @@ Host 实验分支为 `codex/extension-community-preview`,基线为主线 ## 本批交付 -- D0:新增统一作者入口;主仓中英文 README、贡献说明和 ROADMAP 链接到当前路径。 +- 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。 @@ -70,7 +72,9 @@ MCP 和 Skill 绑定 Pi。窗口没有复制个人凭据,没有配置远程模 启动前确认 17777 空闲,并拒绝覆盖已有后端。实验窗口仍使用生产固定端口 17777; 它的设置、会话、账本、缓存与数据独立,但不能与另一实例的后端同时占用该端口。 -关闭实验窗口应走正常退出;先关闭它再启动日常实例。 +首次验收后曾保持窗口运行;本轮外审修订已停止实验进程,17777 的监听已释放。 +普通关闭只收起了窗口,没有结束实例;核对 PID、命令行和后端父子归属后终止了该实验 +进程树。这次停止不计作正常退出验收,后续启动日常实例前仍须确认端口空闲。 Computer Use 已观察到实际 Chat 窗口;原生控件几何捕获不稳定,因此没有把后续点击尝试 计作通过,也没有用其他应用的画面作为证据。完整 GUI 结果来自上面的现有生产应用 smoke。 @@ -87,3 +91,48 @@ Computer Use 已观察到实际 Chat 窗口;原生控件几何捕获不稳定 下一批从已可用的 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 的功能交付与验收仍按下一批单独记录。 diff --git a/pyproject.toml b/pyproject.toml index 317ca75f..991c94e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,6 +159,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 e5b13547..bf299cca 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -10,7 +10,6 @@ "archive_prefix": "amadeus-{version}", "required_files": [ "tools/extension_package.py", - "tools/requirements-extension-authoring.in", "tools/requirements-extension-authoring.txt", "docs/extension_authoring.md", "docs/extension_validation_history_2026-10-13.md", diff --git a/tests/test_extension_authoring.py b/tests/test_extension_authoring.py index 901a48fa..9223aa65 100644 --- a/tests/test_extension_authoring.py +++ b/tests/test_extension_authoring.py @@ -3,6 +3,7 @@ import copy import json +import shutil from pathlib import Path import subprocess import sys @@ -108,3 +109,51 @@ def test_target_admission_matches_native_validator(tmp_path): 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/tools/extension_package.py b/tools/extension_package.py index ebde880a..fb1990d7 100644 --- a/tools/extension_package.py +++ b/tools/extension_package.py @@ -39,8 +39,11 @@ def check_package(directory: Path) -> dict: 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 - with manifest_path.open('rb') as source: - raw = source.read(MANIFEST_LIMIT_BYTES + 1) + 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'))) @@ -92,8 +95,12 @@ def emit(payload: dict, command: str, output_format: str) -> None: print('Wheel SHA-256: ' + payload['digest']) if payload.get('missing'): print('Missing configuration: ' + ', '.join(payload['missing'])) - if command in {'check-wheel', 'check-package', 'validate-installed', 'prepare-data-wheel'}: - print('Checked: Host admission, package resources and Skill metadata. No package code executed.') + 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: @@ -123,8 +130,13 @@ def main(argv: list[str] | None = None) -> int: 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=['host_admission', 'package_resources', 'skill_metadata'], + 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': diff --git a/tools/requirements-extension-authoring.in b/tools/requirements-extension-authoring.in deleted file mode 100644 index 72210753..00000000 --- a/tools/requirements-extension-authoring.in +++ /dev/null @@ -1,4 +0,0 @@ -# Static author checks only. Build tools and runtime/GUI tests are separate. -jsonschema==4.26.0 -packaging==26.2 -PyYAML==6.0.3 diff --git a/tools/requirements-extension-authoring.txt b/tools/requirements-extension-authoring.txt index 7f4711ec..64825ed1 100644 --- a/tools/requirements-extension-authoring.txt +++ b/tools/requirements-extension-authoring.txt @@ -1,5 +1,3 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile --universal --python-version 3.12.10 --generate-hashes --no-emit-index-url --output-file tools/requirements-extension-authoring.txt tools/requirements-extension-authoring.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 @@ -9,7 +7,6 @@ attrs==26.1.0 \ jsonschema==4.26.0 \ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce - # via -r tools/requirements-extension-authoring.in jsonschema-specifications==2025.9.1 \ --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d @@ -17,221 +14,45 @@ jsonschema-specifications==2025.9.1 \ packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 - # via -r tools/requirements-extension-authoring.in pyyaml==6.0.3 \ - --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ - --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ - --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ - --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ - --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ - --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ - --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ - --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ - --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ - --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ - --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ - --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ - --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ - --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ - --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ - --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ - --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ - --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ - --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ - --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ - --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ - --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ - --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ - --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ - --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ - --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ - --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ - --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ - --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ - --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ - --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ - --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ - --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ - --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ - --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ - --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ - --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ - --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ - --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ - --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ - --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ - --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ - --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ - --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ - --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ - --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ - --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ - --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ - --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ - --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ - --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ - --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ - --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ - --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ - --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ - --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ - --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ - --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ - --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ - --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ - --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ - --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 - # via -r tools/requirements-extension-authoring.in referencing==0.37.0 \ --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 # via # jsonschema # jsonschema-specifications -rpds-py==2026.9.1 \ - --hash=sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25 \ - --hash=sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68 \ - --hash=sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495 \ - --hash=sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32 \ - --hash=sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96 \ - --hash=sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834 \ - --hash=sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0 \ - --hash=sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6 \ - --hash=sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e \ - --hash=sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa \ - --hash=sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759 \ - --hash=sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff \ - --hash=sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7 \ - --hash=sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c \ - --hash=sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903 \ - --hash=sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d \ - --hash=sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9 \ - --hash=sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e \ - --hash=sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b \ - --hash=sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a \ - --hash=sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a \ - --hash=sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2 \ - --hash=sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945 \ - --hash=sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91 \ - --hash=sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044 \ - --hash=sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69 \ - --hash=sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e \ - --hash=sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3 \ - --hash=sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4 \ - --hash=sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33 \ - --hash=sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52 \ - --hash=sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9 \ - --hash=sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb \ - --hash=sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35 \ - --hash=sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299 \ - --hash=sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c \ - --hash=sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063 \ - --hash=sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a \ - --hash=sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6 \ - --hash=sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12 \ - --hash=sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14 \ - --hash=sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0 \ - --hash=sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30 \ - --hash=sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31 \ - --hash=sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02 \ - --hash=sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af \ - --hash=sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef \ - --hash=sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0 \ - --hash=sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888 \ - --hash=sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a \ - --hash=sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c \ - --hash=sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b \ - --hash=sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86 \ - --hash=sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f \ - --hash=sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531 \ - --hash=sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6 \ - --hash=sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82 \ - --hash=sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d \ - --hash=sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7 \ - --hash=sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448 \ - --hash=sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391 \ - --hash=sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb \ - --hash=sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4 \ - --hash=sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e \ - --hash=sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c \ - --hash=sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15 \ - --hash=sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f \ - --hash=sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297 \ - --hash=sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36 \ - --hash=sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d \ - --hash=sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b \ - --hash=sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237 \ - --hash=sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb \ - --hash=sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477 \ - --hash=sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c \ - --hash=sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e \ - --hash=sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608 \ - --hash=sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07 \ - --hash=sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c \ - --hash=sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35 \ - --hash=sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0 \ - --hash=sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785 \ - --hash=sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad \ - --hash=sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc \ - --hash=sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9 \ - --hash=sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025 \ - --hash=sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f \ - --hash=sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009 \ - --hash=sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d \ - --hash=sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118 \ - --hash=sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db \ - --hash=sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9 \ - --hash=sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab \ - --hash=sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37 \ - --hash=sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d \ - --hash=sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc \ - --hash=sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9 \ - --hash=sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3 \ - --hash=sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569 \ - --hash=sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69 \ - --hash=sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b \ - --hash=sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018 \ - --hash=sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f \ - --hash=sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c \ - --hash=sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f \ - --hash=sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5 \ - --hash=sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445 \ - --hash=sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1 \ - --hash=sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e \ - --hash=sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429 \ - --hash=sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa \ - --hash=sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b \ - --hash=sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250 \ - --hash=sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e \ - --hash=sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83 \ - --hash=sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f \ - --hash=sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd \ - --hash=sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346 \ - --hash=sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858 \ - --hash=sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871 \ - --hash=sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da \ - --hash=sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0 \ - --hash=sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f \ - --hash=sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050 \ - --hash=sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31 \ - --hash=sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df \ - --hash=sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12 \ - --hash=sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567 +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.16.0 ; python_full_version < '3.13' \ - --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ - --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via referencing diff --git a/uv.lock b/uv.lock index a4e3d93a..728500a6 100644 --- a/uv.lock +++ b/uv.lock @@ -589,6 +589,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" }, @@ -706,6 +713,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" From 36ae48287779d05608b1bb505ece39391da8eb4a Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 09:13:33 +1300 Subject: [PATCH 04/39] feat(extensions): add executable author contracts and four-owner qualification --- .github/workflows/python-windows.yml | 7 + docs/extension_authoring.md | 58 +++ ...unity_preview_and_store_plan_2026-10-21.md | 2 +- docs/extension_host_profile_v0.1.md | 2 +- .../community-preview-batch1-2026-10-21.md | 3 + .../community-preview-batch2-2026-10-21.md | 100 ++++++ electron/scripts/check-extension-theme.mjs | 45 +++ electron/tests/extensionAuthoring.smoke.mjs | 77 ++++ examples/extensions/README.md | 23 ++ .../contract-scenarios/handoff.json | 7 + .../extensions/contract-scenarios/notes.json | 25 ++ .../extensions/contract-scenarios/theme.json | 1 + .../extensions/contract-scenarios/tone.json | 6 + examples/extensions/tone-tts/LICENSE | 7 + examples/extensions/tone-tts/pyproject.toml | 15 + .../src/amadeus_example_tone/__init__.py | 19 + .../amadeus-extension.json | 38 ++ release/source_release_policy.json | 4 + tests/test_extension_contract.py | 165 +++++++++ tests/test_extension_examples.py | 2 +- tools/check_extension_contract.py | 338 ++++++++++++++++++ tools/probes/extension_author_journey.py | 168 +++++++++ tools/probes/extension_contract_pi.py | 155 ++++++++ 23 files changed, 1264 insertions(+), 3 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch2-2026-10-21.md create mode 100644 electron/scripts/check-extension-theme.mjs create mode 100644 electron/tests/extensionAuthoring.smoke.mjs create mode 100644 examples/extensions/contract-scenarios/handoff.json create mode 100644 examples/extensions/contract-scenarios/notes.json create mode 100644 examples/extensions/contract-scenarios/theme.json create mode 100644 examples/extensions/contract-scenarios/tone.json create mode 100644 examples/extensions/tone-tts/LICENSE create mode 100644 examples/extensions/tone-tts/pyproject.toml create mode 100644 examples/extensions/tone-tts/src/amadeus_example_tone/__init__.py create mode 100644 examples/extensions/tone-tts/src/amadeus_example_tone/amadeus-extension.json create mode 100644 tests/test_extension_contract.py create mode 100644 tools/check_extension_contract.py create mode 100644 tools/probes/extension_author_journey.py create mode 100644 tools/probes/extension_contract_pi.py diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index 7a7d72f7..6fc62e74 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -123,6 +123,10 @@ 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 Electron model-less smoke run: >- uv run --locked --no-sync python -X utf8 tools\smoke_electron_model_less.py @@ -150,6 +154,9 @@ 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/electron-smoke/** build/electron-packaged-smoke/** if-no-files-found: ignore diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md index d643b3ca..d3f12998 100644 --- a/docs/extension_authoring.md +++ b/docs/extension_authoring.md @@ -156,3 +156,61 @@ 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. 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 index 7b8fe2c7..7210a160 100644 --- a/docs/extension_community_preview_and_store_plan_2026-10-21.md +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -2,7 +2,7 @@ 日期:2026-10-21 -状态:已批准并开始实验施工;第一批 D0/D1 见[施工记录](verification/extensions/community-preview-batch1-2026-10-21.md)。后续阶段仍按各自退出条件推进,未发布。 +状态:已批准并开始实验施工;第一批 D0/D1 见[施工记录](verification/extensions/community-preview-batch1-2026-10-21.md),D2/S0 的实现与本地验收见[第二批施工记录](verification/extensions/community-preview-batch2-2026-10-21.md)。D0 跨仓公开入口、D3 外部作者、S1/S2 客户端商店和 S3 发布资格仍待完成;未合并、未发布。 目标是让外部作者独立完成插件,让用户从精选目录发现、安装和更新插件。先完成 **D0 文档收口 → D1 作者校验入口**,再并行推进 **D2 合约与模板 → D3 外部作者验收** 和 **S0 目录规则 → S1 下载与安装 → S2 商店界面**,在 S3 汇合验收。商店开发使用参考包和测试 fixture,不等待作者招募或公开发布。复用已有安装事务、配置存储、运行时和原生能力接口。 diff --git a/docs/extension_host_profile_v0.1.md b/docs/extension_host_profile_v0.1.md index 33ac735a..fc9decbd 100644 --- a/docs/extension_host_profile_v0.1.md +++ b/docs/extension_host_profile_v0.1.md @@ -40,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 diff --git a/docs/verification/extensions/community-preview-batch1-2026-10-21.md b/docs/verification/extensions/community-preview-batch1-2026-10-21.md index 44f2f199..c51c3efc 100644 --- a/docs/verification/extensions/community-preview-batch1-2026-10-21.md +++ b/docs/verification/extensions/community-preview-batch1-2026-10-21.md @@ -136,3 +136,6 @@ Host 合入后完成。当前没有新增插件类型、商店入口、下载能 本轮未重跑 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-batch2-2026-10-21.md b/docs/verification/extensions/community-preview-batch2-2026-10-21.md new file mode 100644 index 00000000..7e1c4ff9 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch2-2026-10-21.md @@ -0,0 +1,100 @@ +# 社区插件开发预览第二批: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。 + +## 阶段状态与下一步 + +- D2:四类作者入口、独立环境参考旅程和 Windows 本地运行验收已交付。其他 Provider、 + 平台、手工 GUI、真实模型和付费服务保持各自原有限制,不统称全部认证。 +- S0:目录格式、收录政策、审核清单、发布工具与本地 fixture 验收已交付;远端 CI 和 + 公开发布仍待相应分支/版本可获取及后续发布决定。 +- D0 仍需先合入 Host,再把协议仓首页改到合入后的作者入口;本轮遵守不合并主线。 +- D3 外部作者验证未开始。维护者模板不能代替两名外部作者的资格。 +- 下一阶段 S1:Electron net 下载与镜像、可信身份传入现有安装事务、按 wheel 依赖选择 + 准备方式,以及设置存储中的封禁/最高序号、备份恢复与启用边界。S2 才提供商店 UI。 +- S3 的真实收录、独立审核和仓库治理没有提前宣称完成。签名、自动更新、自动回滚及新 + 插件类型均未实现。本批没有推送、建 PR、合并或归档;Node 依赖继续独立安装,无主仓联接。 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/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/examples/extensions/README.md b/examples/extensions/README.md index cadb6440..ac49169d 100644 --- a/examples/extensions/README.md +++ b/examples/extensions/README.md @@ -84,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/release/source_release_policy.json b/release/source_release_policy.json index bf299cca..bc458278 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -10,6 +10,10 @@ "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", 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/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/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_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() From f00623a811e001f37a76e4135c03dabe8fd84a17 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 09:55:17 +1300 Subject: [PATCH 05/39] feat(extensions): add catalog downloads and durable security admission --- .env.example | 4 + LICENSES/provenance.json | 32 +- THIRD_PARTY_NOTICES.md | 2 + config/catalog/desktop/extensions.json | 33 ++ electron/src/main/desktopSettings.ts | 84 ++- electron/src/main/extensionCatalog.ts | 116 ++++ electron/src/main/extensionDownloads.ts | 106 ++++ electron/src/main/extensionSecurity.ts | 80 +++ electron/src/main/extensions.ts | 40 +- electron/src/main/index.ts | 16 + electron/src/preload/index.mts | 2 + .../renderer/components/ExtensionSettings.tsx | 20 + .../src/renderer/components/SettingsPage.tsx | 1 + electron/src/renderer/env.d.ts | 2 + electron/src/renderer/locales/zh-CN.json | 7 + .../src/shared/configCatalog.generated.ts | 33 ++ electron/src/shared/extensionCatalog.ts | 16 + .../tests/desktopSettingsDurability.test.mjs | 82 +++ electron/tests/extensionCatalog.smoke.mjs | 183 +++++++ electron/tests/extensionCatalog.test.mjs | 95 ++++ electron/tests/extensionDownloads.test.mjs | 77 +++ electron/tests/extensionSettings.smoke.mjs | 19 + electron/tests/extensions.test.mjs | 108 +++- electron/tests/fixtures/extensionSettings.tsx | 11 +- release/source_release_policy.json | 12 +- server/extensions/catalog-source.json | 12 + server/extensions/catalog.py | 79 +++ server/extensions/package.py | 3 +- server/extensions/sdk.py | 15 + tests/test_extension_catalog.py | 164 ++++++ tools/extension_package.py | 11 +- tools/probes/extension_catalog_journey.py | 161 ++++++ vendor/extension-catalog/LICENSE | 201 +++++++ .../catalog/fixtures/compatibility.json | 145 +++++ vendor/extension-catalog/catalog/policy.json | 20 + vendor/extension-catalog/catalog/schema.json | 337 ++++++++++++ .../extension-catalog/catalog/toolchain.json | 7 + vendor/extension-catalog/tools/catalog.py | 512 ++++++++++++++++++ 38 files changed, 2799 insertions(+), 49 deletions(-) create mode 100644 config/catalog/desktop/extensions.json create mode 100644 electron/src/main/extensionCatalog.ts create mode 100644 electron/src/main/extensionDownloads.ts create mode 100644 electron/src/main/extensionSecurity.ts create mode 100644 electron/src/shared/extensionCatalog.ts create mode 100644 electron/tests/extensionCatalog.smoke.mjs create mode 100644 electron/tests/extensionCatalog.test.mjs create mode 100644 electron/tests/extensionDownloads.test.mjs create mode 100644 server/extensions/catalog-source.json create mode 100644 server/extensions/catalog.py create mode 100644 tests/test_extension_catalog.py create mode 100644 tools/probes/extension_catalog_journey.py create mode 100644 vendor/extension-catalog/LICENSE create mode 100644 vendor/extension-catalog/catalog/fixtures/compatibility.json create mode 100644 vendor/extension-catalog/catalog/policy.json create mode 100644 vendor/extension-catalog/catalog/schema.json create mode 100644 vendor/extension-catalog/catalog/toolchain.json create mode 100644 vendor/extension-catalog/tools/catalog.py 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/LICENSES/provenance.json b/LICENSES/provenance.json index 119bca5a..6242afec 100644 --- a/LICENSES/provenance.json +++ b/LICENSES/provenance.json @@ -10,7 +10,8 @@ "render/web/vendor", "assets/audio/sfx", "vendor/aec-audio-processing", - "vendor/extension-sdk" + "vendor/extension-sdk", + "vendor/extension-catalog" ], "components": [ { @@ -84,11 +85,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", @@ -482,6 +490,24 @@ "release_action": "exclude", "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": "c7f5487a0fb9962b718187eec20428697ce4c244 (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." } ] } diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 98e8b6c8..53426be5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -204,3 +204,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/electron/src/main/desktopSettings.ts b/electron/src/main/desktopSettings.ts index 0958894e..e740b32a 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,20 @@ 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))), + 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, ...(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 policyError = securityBlock(security, record) || (applied?.enabled ? securityBlock(security, applied) : '') 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 + (applied?.enabled && 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 +595,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 +845,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..0af20d4a --- /dev/null +++ b/electron/src/main/extensionCatalog.ts @@ -0,0 +1,116 @@ +import fs from 'node:fs' +import path from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import type { CatalogEntry, CatalogIndex, CatalogRequest, CatalogView } from '../shared/extensionCatalog.js' +import { DesktopSettingsStore } from './desktopSettings.js' +import { ExtensionInstaller, ExtensionPackageHelper } 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/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' + if (!['get', 'refresh', 'recover', 'cancel', 'install', 'homepage'].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) {} + 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 || [], sequence: index?.sequence, cached: !this.trusted, + 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('; ')) + } + cancel(): void { 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 + let stage: ReturnType | undefined + try { + await this.refresh() + const selected = structuredClone(this.entry(id, version)) + const assertCurrent = () => { + if (controller.signal.aborted) throw new Error('Catalog installation cancelled') + 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 } + await this.download(url, wheel, Math.min(MAX_WHEEL_BYTES, selected.size), controller.signal, received => { this.progress = { id, version, received, total: selected.size } }) + 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 }) + } finally { stage?.dispose(); this.progress = undefined; this.active = undefined } + } + homepage(id: string, version: string): string { + const value = this.entry(id, version).display.homepage + 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..7e934bdf --- /dev/null +++ b/electron/src/main/extensionDownloads.ts @@ -0,0 +1,106 @@ +import { net, session } from 'electron' +import fs from 'node:fs' +import path from 'node:path' +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 } + const handle = fs.openSync(destination, 'wx', 0o600) + let request: Electron.ClientRequest | undefined + let finished = false + let received = 0 + let redirects = 0 + const timer = setTimeout(() => finish(new Error('Extension download timed out')), 120_000) + const cancel = () => finish(new Error('Extension download cancelled')) + const finish = (error?: Error) => { + if (finished) return + finished = true + clearTimeout(timer) + signal.removeEventListener('abort', cancel) + try { if (!error) fs.fsyncSync(handle) } catch { error = new Error('Extension download could not be saved') } + try { fs.closeSync(handle) } catch { error ||= new Error('Extension download could not be saved') } + if (error) { + request?.abort() + try { fs.unlinkSync(destination) } catch { /* The owned staging scope retries cleanup. */ } + reject(error) + } else resolve() + } + signal.addEventListener('abort', cancel, { once: true }) + 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 (response.statusCode !== 200) { finish(new Error('Extension download did not return a complete artifact')); return } + // Chromium emits decoded bytes; Content-Length describes wire bytes when compressed. + const header = (name: string): string => { const value = response.headers[name]; return Array.isArray(value) ? value.join(',') : value || '' } + const encoding = header('content-encoding').toLowerCase() + const length = encoding && encoding !== 'identity' ? 0 : Number(header('content-length') || 0) + if (!Number.isFinite(length) || length > limit) { finish(new Error('Extension download exceeds size limit')); return } + response.on('error', () => finish(new Error('Extension download was interrupted'))) + response.on('data', chunk => { + if (finished) return + received += chunk.length + if (received > limit) { finish(new Error('Extension download exceeds size limit')); return } + try { fs.writeFileSync(handle, chunk); progress?.(received) } catch { finish(new Error('Extension download could not be saved')) } + }) + response.on('end', () => finish(received === 0 || (length && received !== length) ? new Error('Incomplete extension download') : 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 dd57c8c9..9ea76acc 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -1,4 +1,6 @@ import fs from 'node:fs' +import 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' @@ -14,9 +16,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 { @@ -54,7 +56,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. @@ -110,10 +112,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 @@ -237,8 +239,10 @@ export class ExtensionInstaller { return result } - install(wheel: string): Promise> { + install(wheel: string, delivery?: { identity: ArtifactIdentity; assertCurrent: () => 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)') } @@ -255,11 +259,20 @@ 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]) + 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') { @@ -275,6 +288,7 @@ export class ExtensionInstaller { digest: inspection.digest, sourceFile: path.basename(wheel) })) { throw new ExtensionChangeCancelled() } + assertAllowed() const env = extensionInstallerEnvironment(this.environment) const venv = path.join(directory, 'venv') let purelib: string @@ -301,10 +315,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) @@ -329,6 +343,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() @@ -360,6 +375,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() } @@ -382,6 +398,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() @@ -527,7 +544,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 { @@ -536,6 +553,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) @@ -597,6 +615,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') @@ -604,6 +623,7 @@ 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. diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 221931e8..721d236b 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -40,6 +40,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, catalogRequest } from './extensionCatalog.js' import { ExtensionThemeResources } from './extensionThemes.js' import { developmentServer, isDevelopmentRendererUrl } from './developmentServer.js' @@ -74,6 +75,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') } @@ -2218,6 +2222,18 @@ 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.' } + try { + const request = catalogRequest(raw) + if (request.action === 'install') return { ok: true, settings: await extensionCatalog.install(request.id, request.version), catalog: await extensionCatalog.view() } + if (request.action === 'homepage') { await shell.openExternal(extensionCatalog.homepage(request.id, request.version)); return { ok: true } } + if (request.action === 'cancel') extensionCatalog.cancel() + const catalog = request.action === 'refresh' || request.action === 'recover' + ? await extensionCatalog.refresh(request.action === 'recover') : await extensionCatalog.view() + return { ok: true, catalog, settings: desktopSettings.snapshot(process.env) } + } catch (error) { return { ok: false, error: error instanceof Error ? error.message : 'Catalog operation failed', catalog: await extensionCatalog.view(), settings: desktopSettings.snapshot(process.env) } } +}) ipcMain.handle('extensions.install', async (event) => { if (!isTrustedBackendRenderer(event.sender)) return { ok: false, error: 'Untrusted extension requester.' } const options: Electron.OpenDialogOptions = { title: 'Install Amadeus extension', properties: ['openFile'], filters: [{ name: 'Python wheel', extensions: ['whl'] }] } diff --git a/electron/src/preload/index.mts b/electron/src/preload/index.mts index d3110e33..04f3c15e 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), installExtension: (): Promise => ipcRenderer.invoke('extensions.install'), updateExtension: (update: ExtensionUpdate): Promise => ipcRenderer.invoke('extensions.update', update), removeExtension: (id: string, options?: ExtensionRemovalOptions): Promise => ipcRenderer.invoke('extensions.remove', id, options), diff --git a/electron/src/renderer/components/ExtensionSettings.tsx b/electron/src/renderer/components/ExtensionSettings.tsx index c883a437..9e4aae68 100644 --- a/electron/src/renderer/components/ExtensionSettings.tsx +++ b/electron/src/renderer/components/ExtensionSettings.tsx @@ -35,6 +35,10 @@ 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' }) + useEffect(() => { void window.amadeus?.extensionCatalog?.({ action: 'get' }).then(result => { + if (result.settings?.extensionSecurity) setSecurity(result.settings.extensionSecurity as { status: string; checkedAt?: string }) + }) }, []) const [removeCandidate, setRemoveCandidate] = useState('') const [deleteData, setDeleteData] = useState(false) const dirty = draft !== null && JSON.stringify(draft) !== initial.current @@ -81,6 +85,20 @@ 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 () => { + if (!window.amadeus || busy) return + setBusy(true); setError(''); setNotice('') + try { + const result = await window.amadeus.extensionCatalog({ action: security.status === 'unavailable' ? 'recover' : 'refresh' }) + if (result.settings) { + onSettingsChanged(result.settings) + setSecurity(result.settings.extensionSecurity as { status: string; checkedAt?: string }) + } + 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 { setBusy(false) } + } const save = async () => { if (!window.amadeus || !selected || !draft) return setBusy(true) @@ -111,6 +129,8 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn
{t('Install a local wheel from an author you trust. Frontend-only packages apply immediately. Packages with backend contributions apply together after backend restart.')}
{t('This pilot supports themes, TTS, MCP servers and Agent Skills. Install once, then choose a theme, voice or compatible Agents. Main Chat has no tool or Skill access.')}
void install()}>{t(busy && !selected ? 'Working…' : 'Install or update local wheel…')}
+
void checkSecurity()}>{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.')}
{!available ?
{t('Extension management is available in the Electron app.')}
: null} {error ?
{t(error)}
: null} diff --git a/electron/src/renderer/components/SettingsPage.tsx b/electron/src/renderer/components/SettingsPage.tsx index ff129ee8..105751df 100644 --- a/electron/src/renderer/components/SettingsPage.tsx +++ b/electron/src/renderer/components/SettingsPage.tsx @@ -1330,6 +1330,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 === 'capabilities' ? ( diff --git a/electron/src/renderer/env.d.ts b/electron/src/renderer/env.d.ts index 9cca8059..d3357d11 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 installExtension: () => Promise updateExtension: (update: ExtensionUpdate) => Promise removeExtension: (id: string, options?: ExtensionRemovalOptions) => Promise diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index c981dce0..26fa6f67 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -140,6 +140,7 @@ "Capture when a planned story beat arrives": "计划剧情节点到达时采集", "Captured text": "取文预览", "Capturing game view…": "正在截取游戏画面…", + "Catalog refresh failed": "目录刷新失败", "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.": "修改将一起保存,重启后端后生效。", @@ -170,6 +171,7 @@ "Chat window": "聊天窗口", "Chats": "对话", "Check a few lines": "核对几句对白", + "Check updates and security status": "检查更新与安全状态", "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.": "此代理打开第一个任务后会显示可选项。将覆盖值留空以使用代理的默认设置。", @@ -384,6 +386,7 @@ "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 startup record is invalid or incompatible": "扩展启动记录无效或不兼容", "Extension unavailable": "扩展不可用", "Extension validation is temporarily unavailable; saved records were retained and activation was skipped.": "扩展验证暂时不可用,已保留保存的记录并跳过启用。", @@ -498,6 +501,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": "启动游戏进程", @@ -635,6 +639,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": "开", @@ -744,6 +750,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.": "回顾先前的剧情细节。", diff --git a/electron/src/shared/configCatalog.generated.ts b/electron/src/shared/configCatalog.generated.ts index 6dd4b7b7..0961d0e6 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..be31d760 --- /dev/null +++ b/electron/src/shared/extensionCatalog.ts @@ -0,0 +1,16 @@ +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[] +} +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' | 'refresh' | 'recover' | 'cancel' } + | { action: 'install'; id: string; version: string } + | { action: 'homepage'; 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?: { id: string; version: string; received: number; total: number } } +export type CatalogReply = { ok: boolean; error?: string; cancelled?: boolean; catalog?: CatalogView; settings?: Record } 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/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..12b3e63f --- /dev/null +++ b/electron/tests/extensionCatalog.test.mjs @@ -0,0 +1,95 @@ +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')), []) +}) diff --git a/electron/tests/extensionDownloads.test.mjs b/electron/tests/extensionDownloads.test.mjs new file mode 100644 index 00000000..f3324abf --- /dev/null +++ b/electron/tests/extensionDownloads.test.mjs @@ -0,0 +1,77 @@ +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 { 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 EventEmitter(); r.statusCode = status; r.headers = length === undefined ? {} : { 'content-length': [String(length)] } + request.emit('response', r) + for (const chunk of chunks) r.emit('data', Buffer.from(chunk)) + r.emit('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 EventEmitter(); r.statusCode = 200; r.headers = { 'content-length': '99', 'content-encoding': 'gzip' }; request.emit('response', r); r.emit('data', Buffer.from('wheel')); r.emit('end') }, true], + ['string header', request => { const r = new EventEmitter(); r.statusCode = 200; r.headers = { 'content-length': '5' }; request.emit('response', r); r.emit('data', Buffer.from('wheel')); r.emit('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() +}) diff --git a/electron/tests/extensionSettings.smoke.mjs b/electron/tests/extensionSettings.smoke.mjs index 425cb7d2..f7d9f9a6 100644 --- a/electron/tests/extensionSettings.smoke.mjs +++ b/electron/tests/extensionSettings.smoke.mjs @@ -55,6 +55,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') @@ -152,6 +162,15 @@ try { await until("document.body.textContent.includes('Community extensions')", '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').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/extensions.test.mjs b/electron/tests/extensions.test.mjs index b5a4e152..3f1f7157 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,105 @@ 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.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) +}) diff --git a/electron/tests/fixtures/extensionSettings.tsx b/electron/tests/fixtures/extensionSettings.tsx index d1a6950b..b7c765eb 100644 --- a/electron/tests/fixtures/extensionSettings.tsx +++ b/electron/tests/fixtures/extensionSettings.tsx @@ -62,12 +62,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, @@ -105,6 +107,13 @@ 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 === '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) }) @@ -139,7 +148,7 @@ host.amadeus = { desktop.restartRequired = false; desktop.pendingKeys = []; desktop.pendingRevisions = {}; emit(); return true }, } -host.extensionFixture = { calls, errors, snapshot, setFailSave: (value: boolean) => { failSave = value }, +host.extensionFixture = { calls, errors, snapshot, setFailCatalog: (value: boolean) => { failCatalog = value }, setFailSave: (value: boolean) => { failSave = value }, refreshRuntime: () => { emit() } } document.documentElement.style.height = '100%' document.body.style.cssText = 'margin:0;height:100%;overflow:hidden' diff --git a/release/source_release_policy.json b/release/source_release_policy.json index bc458278..fcfa905a 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -121,7 +121,17 @@ "tests/fixtures/extension_theme_colors.json", "docs/japanese_context_reading.md", "LICENSES/TSQYOMI.md", - "LICENSES/TSQYOMI-MIT.txt" + "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" ], "include_roots": [ "characters", diff --git a/server/extensions/catalog-source.json b/server/extensions/catalog-source.json new file mode 100644 index 00000000..d4001c43 --- /dev/null +++ b/server/extensions/catalog-source.json @@ -0,0 +1,12 @@ +{ + "repository": "https://github.com/Code-Amadeus/Amadeus-Extensions", + "commit": "c7f5487a0fb9962b718187eec20428697ce4c244", + "files": { + "tools/catalog.py": "b83ca6c415ce3574b54700b55114e6b46b2095c283da3e1c895171a5577d7c82", + "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/test_extension_catalog.py b/tests/test_extension_catalog.py new file mode 100644 index 00000000..2bc07143 --- /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"] == "c7f5487a0fb9962b718187eec20428697ce4c244" + 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/tools/extension_package.py b/tools/extension_package.py index fb1990d7..7963e275 100644 --- a/tools/extension_package.py +++ b/tools/extension_package.py @@ -112,7 +112,7 @@ 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', 'profile', 'check-wheel', 'check-package', + 'validate-installed', 'profile', 'check-wheel', 'check-package', 'validate-catalog', 'inspect-catalog-wheel', ]) parser.add_argument('path', nargs='?') parser.add_argument('--manifest') @@ -123,7 +123,14 @@ def main(argv: list[str] | None = None) -> int: help='JSON is the existing Electron helper protocol; text is for authors.') args = parser.parse_args(argv) try: - if args.command == 'profile': + 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: diff --git a/tools/probes/extension_catalog_journey.py b/tools/probes/extension_catalog_journey.py new file mode 100644 index 00000000..c0a62360 --- /dev/null +++ b/tools/probes/extension_catalog_journey.py @@ -0,0 +1,161 @@ +"""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 main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--execute", action="store_true", required=True) + parser.add_argument("--wheels", type=Path, required=True) + args = parser.parse_args() + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + base = ROOT / "build/extension-catalog-journey" / uuid.uuid4().hex[:12] + base.mkdir(parents=True) + 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)) + entries = [] + wheels = {} + for name in ["tone_tts", "notes_mcp", "handoff_skill", "theme"]: + wheel = args.wheels.resolve() / f"amadeus_example_{name}-0.1.0-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) + (base / "fixture.json").write_text( + json.dumps(dict(index=index, wheels=wheels)), encoding="utf-8" + ) + 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/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() + + code = run("driver", command, 600) + if code == 0: + 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/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..ed258099 --- /dev/null +++ b/vendor/extension-catalog/tools/catalog.py @@ -0,0 +1,512 @@ +"""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 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": { + k: manifest[k] + for k in ( + "display_name", + "description", + "author", + "license", + "homepage", + "repository", + ) + if k in 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 From a7aab2be21bbdf03744131ef8868e5bf09064306 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 09:57:24 +1300 Subject: [PATCH 06/39] fix(extensions): distinguish desired and running security feedback --- electron/src/main/desktopSettings.ts | 5 +++-- electron/src/main/extensions.ts | 10 +++++++--- electron/tests/extensions.test.mjs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/electron/src/main/desktopSettings.ts b/electron/src/main/desktopSettings.ts index e740b32a..a31c0128 100644 --- a/electron/src/main/desktopSettings.ts +++ b/electron/src/main/desktopSettings.ts @@ -564,9 +564,10 @@ export class DesktopSettingsStore { 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 policyError = securityBlock(security, record) || (applied?.enabled ? securityBlock(security, applied) : '') + 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: Boolean(policyError) || this.closedFrontends.has(record.id), applicationError: policyError ? policyError + (applied?.enabled && extensionRequiresRestart(applied.manifest) ? ' Stop or restart the running backend.' : '') : 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, diff --git a/electron/src/main/extensions.ts b/electron/src/main/extensions.ts index 9ea76acc..11ac8bb8 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -1,5 +1,5 @@ import fs from 'node:fs' -import type { ArtifactIdentity } from './extensionSecurity.js' +import { securityBlock, type ArtifactIdentity } from './extensionSecurity.js' import type { CatalogIndex } from '../shared/extensionCatalog.js' import { assertExtensionPath } from './extensionPaths.js' export { assertExtensionPath } from './extensionPaths.js' @@ -579,11 +579,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) { @@ -627,7 +631,7 @@ export class ExtensionInstaller { } 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/tests/extensions.test.mjs b/electron/tests/extensions.test.mjs index 3f1f7157..53d8fb61 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -1104,6 +1104,7 @@ for (const blockedVersion of ['desired', 'applied']) test(`security checks ${blo 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/) @@ -1132,3 +1133,18 @@ test('replacement of downloaded bytes is rejected; concurrent remove remains ser 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, '') +}) From a6fd0c95d6706cdc16f6d7afd178a7d0b2f9fe48 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 10:02:44 +1300 Subject: [PATCH 07/39] fix(extensions): retain running security warning after settings loss --- electron/src/main/desktopSettings.ts | 5 ++++- electron/src/renderer/locales/zh-CN.json | 1 + electron/tests/extensions.test.mjs | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/electron/src/main/desktopSettings.ts b/electron/src/main/desktopSettings.ts index a31c0128..3a645d37 100644 --- a/electron/src/main/desktopSettings.ts +++ b/electron/src/main/desktopSettings.ts @@ -555,7 +555,10 @@ export class DesktopSettingsStore { mcpConnectionsLocked: environment[MCP_CONNECTIONS_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, ...(environment[EXTENSIONS_ENV] !== undefined + 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.'] : [])], } } diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index 26fa6f67..0855e9a7 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -1,6 +1,7 @@ { "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 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 预设。", diff --git a/electron/tests/extensions.test.mjs b/electron/tests/extensions.test.mjs index 53d8fb61..c1272773 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -1148,3 +1148,18 @@ test('a blocked active theme falls back immediately with the security reason', a 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'))) +}) From 39ed76f56da64e0c90f188bb845f08662cdf4b36 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 10:03:10 +1300 Subject: [PATCH 08/39] docs(extensions): record S1 transport and power-loss recovery qualification --- docs/extension_authoring.md | 2 + docs/extension_catalog.md | 134 ++++++++++++++++++ ...unity_preview_and_store_plan_2026-10-21.md | 2 +- .../community-preview-batch2-2026-10-21.md | 2 + .../community-preview-batch3-2026-10-21.md | 129 +++++++++++++++++ 5 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 docs/extension_catalog.md create mode 100644 docs/verification/extensions/community-preview-batch3-2026-10-21.md diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md index d3f12998..7a78bf0a 100644 --- a/docs/extension_authoring.md +++ b/docs/extension_authoring.md @@ -214,3 +214,5 @@ 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 S1 experiment has no published reviewed package list or complete store browser yet. diff --git a/docs/extension_catalog.md b/docs/extension_catalog.md new file mode 100644 index 00000000..a6e51ca4 --- /dev/null +++ b/docs/extension_catalog.md @@ -0,0 +1,134 @@ +# Experimental catalog client and security recovery + +S1 supplies the desktop transport, installer integration and durable security boundary. +S2 discovery, search, version recommendations and the complete store UI remain pending. +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/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. + +## 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. Full browsing/progress UI belongs to S2. + +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 +``` + +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). 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 index 7210a160..41fcd17b 100644 --- a/docs/extension_community_preview_and_store_plan_2026-10-21.md +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -2,7 +2,7 @@ 日期: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)。D0 跨仓公开入口、D3 外部作者、S1/S2 客户端商店和 S3 发布资格仍待完成;未合并、未发布。 +状态:已批准并开始实验施工;第一批 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)。D0 跨仓公开入口、D3 外部作者、S2 完整商店界面和 S3 发布资格仍待完成;未合并、未发布。 目标是让外部作者独立完成插件,让用户从精选目录发现、安装和更新插件。先完成 **D0 文档收口 → D1 作者校验入口**,再并行推进 **D2 合约与模板 → D3 外部作者验收** 和 **S0 目录规则 → S1 下载与安装 → S2 商店界面**,在 S3 汇合验收。商店开发使用参考包和测试 fixture,不等待作者招募或公开发布。复用已有安装事务、配置存储、运行时和原生能力接口。 diff --git a/docs/verification/extensions/community-preview-batch2-2026-10-21.md b/docs/verification/extensions/community-preview-batch2-2026-10-21.md index 7e1c4ff9..2b74c5b6 100644 --- a/docs/verification/extensions/community-preview-batch2-2026-10-21.md +++ b/docs/verification/extensions/community-preview-batch2-2026-10-21.md @@ -86,6 +86,8 @@ python tools/probes/extension_theme_lifecycle.py --output-dir build/extension-pr 测试中的 example.test 审核事实只是 fixture,不进入目录。未来客户端兼容矩阵在本批 确立并验证参考语义;Electron 真正的目录读取、耐久安全事实和安装入口复核属于 S1。 +2026-10-21 后续:S1 已在同一实验分支完成实现与本地验收,见[第三批施工记录](community-preview-batch3-2026-10-21.md)。下面的“下一阶段”保留本批结案时点。 + ## 阶段状态与下一步 - D2:四类作者入口、独立环境参考旅程和 Windows 本地运行验收已交付。其他 Provider、 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..488175ec --- /dev/null +++ b/docs/verification/extensions/community-preview-batch3-2026-10-21.md @@ -0,0 +1,129 @@ +# 社区插件开发预览第三批: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 后更新。本轮遵守不合并主线,签名、自动更新、自动回滚 +和新的插件能力均没有提前实现。 From 24d410e47b920d69445a3ced5163df676180321f Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 10:38:16 +1300 Subject: [PATCH 09/39] feat(extensions): add catalog browsing and manual update journeys --- .github/workflows/python-windows.yml | 13 ++ LICENSES/provenance.json | 2 +- electron/src/main/extensionCatalog.ts | 42 +++-- electron/src/main/extensions.ts | 5 +- electron/src/main/index.ts | 12 +- .../renderer/components/ExtensionCatalog.tsx | 78 ++++++++ .../renderer/components/ExtensionSettings.tsx | 138 ++++++++++++--- .../components/extensionCatalogModel.ts | 54 ++++++ electron/src/renderer/locales/zh-CN.json | 85 +++++++++ electron/src/renderer/styles/index.css | 21 +++ electron/src/shared/extensionCatalog.ts | 11 +- electron/tests/extensionCatalog.test.mjs | 26 +++ electron/tests/extensionCatalogUI.test.mjs | 46 +++++ electron/tests/extensionSettings.smoke.mjs | 2 +- electron/tests/extensionSettings.test.mjs | 23 ++- electron/tests/extensionStore.smoke.mjs | 166 ++++++++++++++++++ electron/tests/extensions.test.mjs | 9 + electron/tests/fixtures/extensionSettings.tsx | 5 +- .../tests/fixtures/extensionStore.preload.cjs | 10 ++ release/source_release_policy.json | 5 +- server/extensions/catalog-source.json | 4 +- tests/test_extension_catalog.py | 2 +- tools/probes/extension_catalog_journey.py | 32 +++- vendor/extension-catalog/tools/catalog.py | 53 ++++-- 24 files changed, 768 insertions(+), 76 deletions(-) create mode 100644 electron/src/renderer/components/ExtensionCatalog.tsx create mode 100644 electron/src/renderer/components/extensionCatalogModel.ts create mode 100644 electron/tests/extensionCatalogUI.test.mjs create mode 100644 electron/tests/extensionStore.smoke.mjs create mode 100644 electron/tests/fixtures/extensionStore.preload.cjs diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index 6fc62e74..5c70edee 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -127,6 +127,15 @@ jobs: 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: Run Electron model-less smoke run: >- uv run --locked --no-sync python -X utf8 tools\smoke_electron_model_less.py @@ -157,6 +166,10 @@ jobs: 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/** if-no-files-found: ignore diff --git a/LICENSES/provenance.json b/LICENSES/provenance.json index 6242afec..0d26f98f 100644 --- a/LICENSES/provenance.json +++ b/LICENSES/provenance.json @@ -499,7 +499,7 @@ "vendor/extension-catalog/**" ], "source_url": "https://github.com/Code-Amadeus/Amadeus-Extensions", - "source_revision": "c7f5487a0fb9962b718187eec20428697ce4c244 (local experimental branch; publication pending)", + "source_revision": "58d3892d55c6a7ec6f8626153f503291bb10ab9a (local experimental branch; publication pending)", "license_expression": "Apache-2.0", "license_evidence": [ "vendor/extension-catalog/LICENSE" diff --git a/electron/src/main/extensionCatalog.ts b/electron/src/main/extensionCatalog.ts index 0af20d4a..4e2a5721 100644 --- a/electron/src/main/extensionCatalog.ts +++ b/electron/src/main/extensionCatalog.ts @@ -1,9 +1,9 @@ import fs from 'node:fs' import path from 'node:path' import { isDeepStrictEqual } from 'node:util' -import type { CatalogEntry, CatalogIndex, CatalogRequest, CatalogView } from '../shared/extensionCatalog.js' +import type { CatalogEntry, CatalogIndex, CatalogRequest, CatalogView, CatalogReply } from '../shared/extensionCatalog.js' import { DesktopSettingsStore } from './desktopSettings.js' -import { ExtensionInstaller, ExtensionPackageHelper } from './extensions.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' @@ -11,8 +11,8 @@ export const OFFICIAL_EXTENSION_INDEX = 'https://raw.githubusercontent.com/Code- 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' - if (!['get', 'refresh', 'recover', 'cancel', 'install', 'homepage'].includes(r.action) + 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 @@ -28,6 +28,19 @@ export class ExtensionCatalog { 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)) { @@ -35,7 +48,7 @@ export class ExtensionCatalog { } const security = this.store.extensionSecurityState() const index = this.trusted || this.cached - return { entries: index?.entries || [], sequence: index?.sequence, cached: !this.trusted, + 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) @@ -80,17 +93,20 @@ export class ExtensionCatalog { 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('; ')) } - cancel(): void { this.active?.abort(); this.refreshController?.abort() } + 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() + await this.refresh().catch(cancelledDownload) const selected = structuredClone(this.entry(id, version)) const assertCurrent = () => { - if (controller.signal.aborted) throw new Error('Catalog installation cancelled') + 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) @@ -101,15 +117,15 @@ export class ExtensionCatalog { 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 } - await this.download(url, wheel, Math.min(MAX_WHEEL_BYTES, selected.size), controller.signal, received => { this.progress = { id, version, received, total: selected.size } }) + 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 }) + 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): string { - const value = this.entry(id, version).display.homepage + 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/extensions.ts b/electron/src/main/extensions.ts index 11ac8bb8..92a05342 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -239,7 +239,7 @@ export class ExtensionInstaller { return result } - install(wheel: string, delivery?: { identity: ArtifactIdentity; assertCurrent: () => void }): 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() @@ -259,6 +259,7 @@ 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) + 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)) { @@ -284,11 +285,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 diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 721d236b..d81bb852 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -40,7 +40,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, catalogRequest } from './extensionCatalog.js' +import { ExtensionCatalog } from './extensionCatalog.js' import { ExtensionThemeResources } from './extensionThemes.js' import { developmentServer, isDevelopmentRendererUrl } from './developmentServer.js' @@ -2224,15 +2224,7 @@ 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.' } - try { - const request = catalogRequest(raw) - if (request.action === 'install') return { ok: true, settings: await extensionCatalog.install(request.id, request.version), catalog: await extensionCatalog.view() } - if (request.action === 'homepage') { await shell.openExternal(extensionCatalog.homepage(request.id, request.version)); return { ok: true } } - if (request.action === 'cancel') extensionCatalog.cancel() - const catalog = request.action === 'refresh' || request.action === 'recover' - ? await extensionCatalog.refresh(request.action === 'recover') : await extensionCatalog.view() - return { ok: true, catalog, settings: desktopSettings.snapshot(process.env) } - } catch (error) { return { ok: false, error: error instanceof Error ? error.message : 'Catalog operation failed', catalog: await extensionCatalog.view(), settings: desktopSettings.snapshot(process.env) } } + return extensionCatalog.dispatch(raw, process.env, url => shell.openExternal(url)) }) ipcMain.handle('extensions.install', async (event) => { if (!isTrustedBackendRenderer(event.sender)) return { ok: false, error: 'Untrusted extension requester.' } diff --git a/electron/src/renderer/components/ExtensionCatalog.tsx b/electron/src/renderer/components/ExtensionCatalog.tsx new file mode 100644 index 00000000..1422f8ec --- /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.' : 'No packages are listed in this catalog 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.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')} · 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}
+
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))).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 9e4aae68..90623a69 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' @@ -36,22 +39,58 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn const [error, setError] = useState('') const [notice, setNotice] = useState('') const [security, setSecurity] = useState<{ status: string; checkedAt?: string }>({ status: 'unseen' }) - useEffect(() => { void window.amadeus?.extensionCatalog?.({ action: 'get' }).then(result => { - if (result.settings?.extensionSecurity) setSecurity(result.settings.extensionSecurity as { status: string; checkedAt?: string }) - }) }, []) + 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 [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(''); setDeleteData(false) } const edit = (extension: ExtensionSummary) => { if (!extension.manifest || !canLeave()) return @@ -86,18 +125,51 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn finally { setBusy(false) } } const checkSecurity = async () => { - if (!window.amadeus || busy) return + if (!window.amadeus || busy || catalogBusy.current) return + catalogBusy.current = true + ++metadataRequest.current setBusy(true); setError(''); setNotice('') try { const result = await window.amadeus.extensionCatalog({ action: security.status === 'unavailable' ? 'recover' : 'refresh' }) - if (result.settings) { - onSettingsChanged(result.settings) - setSecurity(result.settings.extensionSecurity as { status: string; checkedAt?: string }) - } + 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 { setBusy(false) } + 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) void checkSecurity() } const save = async () => { if (!window.amadeus || !selected || !draft) return @@ -121,20 +193,37 @@ 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)) || [] return
+
+ {(['installed', 'browse'] as const).map(value => )} +
+
+ void checkSecurity()}>{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} +
} + const listing = catalog.installed?.find(item => item.id === extension.id && item.version === extension.version && item.digest === extension.digest) + const update = recommendedUpdate(catalog.entries, extension) const observed = connected ? runtime.find(item => item.id === extension.id) : undefined const voices = extension.manifest.contributes.voice_backend || [] const themes = extension.manifest.contributes.theme || [] @@ -164,6 +255,8 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn {t(extension.restartRequired ? 'Restart required' : savedState)}
{extensionText(extension.manifest.description, locale)}
+
{t(listing?.catalogState === 'matched' ? catalog.cached ? 'Reviewed artifact in the cached catalog' : 'Reviewed artifact in the checked catalog' : listing?.catalogState === 'unlisted' ? 'Not listed in the checked catalog' : 'Catalog review status is unknown')}{listing?.withdrawal ? ` · ${t(listing.withdrawal.reason === 'copyright' ? 'Withdrawn for copyright reasons' : 'Withdrawn by the maintainer')}` : ''}{listing?.blocked ? ` · ${t('Blocked by security policy')}` : ''}
+ {update ?
{t('Compatible update available')}: {extension.version} → {update.version} void installCatalog(update)}>{t('Update plugin…')} showCatalog(update.id, update.version)}>{t('Review update details')}
: null}
{t('Declared access and commands')}
{extensionDisclosure(extension.manifest)}
@@ -175,7 +268,7 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn {observed && observed.version !== extension.version ? {t('Running version')}: {observed.version} : null} {extension.frontendSuppressed && extension.backendStillRunning ?
{t('Frontend access is closed; the previous backend still runs until restart.')}
: null} - {extension.applicationError ?
{extension.applicationError}
: null} + {extension.applicationError ?
{t(extension.applicationError)}
: null} {observed?.detail ?
{t(observed.detail)}
: null}
    {themes.map(theme =>
  • @@ -218,9 +311,10 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn {selected?.manifest && draft && selectedMetadataAvailable ?
    { event.preventDefault(); void save() }} aria-label={t('Configure extension')}>

    {extensionText(selected.manifest.display_name, locale, selected.id)}

    {selected.id} · {selected.version}
    + {selectedPolicyBlocked ?
    {t('Security policy prevents activation. Disable this package to save configuration changes.')}
    : null}
    - + {!draft.enabled && (selected.manifest.contributes.voice_backend || []).some(voice => `${selected.id}/${voice.id}` === selectedVoiceBackend) ?
    {t('This package contains the saved speech engine. After restarting, speech is unavailable while text responses continue. Choose an enabled voice to restore speech.')}
    : null} {Object.entries(selected.manifest.config || {}).map(([key, field]) => { @@ -262,8 +356,12 @@ 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')} -
    +
    : null} + + } diff --git a/electron/src/renderer/components/extensionCatalogModel.ts b/electron/src/renderer/components/extensionCatalogModel.ts new file mode 100644 index 00000000..711417a1 --- /dev/null +++ b/electron/src/renderer/components/extensionCatalogModel.ts @@ -0,0 +1,54 @@ +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) => ({ voice_backend: 'Speech synthesis', 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))])].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/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index 0855e9a7..43402320 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -1,6 +1,7 @@ { "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 预设。", @@ -62,6 +63,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": "已配置 · 所有游戏共用", @@ -75,9 +77,11 @@ "Applied": "已生效", "Applied configuration reloaded.": "已重载应用中的配置。", "Applied visual status": "已应用形象的状态", + "Applies after enabling and restarting the backend.": "启用并重启后端后生效。", "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": "需要批准", @@ -86,7 +90,9 @@ "Archiving": "正在归档", "Arguments": "参数", "Arguments — one per line": "参数——每行一个", + "Artifact URL": "产物地址", "Artifact collections": "产物集合", + "Artifact review": "产物审核", "Artifacts": "产物", "Artwork selection": "选择形象", "Ask": "提问", @@ -98,6 +104,7 @@ "Attach game view": "附加游戏画面", "Attach hook agent": "附加 Hook 代理", "Attached game view": "已附加的游戏画面", + "Author": "作者", "Auto Return to Idle": "自动返回待机", "Automatic": "自动", "Automatic (recommended)": "自动(推荐)", @@ -125,6 +132,7 @@ "Base companion": "基础陪玩", "Base theme": "基础样式", "Bearer token environment variable": "Bearer Token 环境变量", + "Blocked by security policy": "安全策略已阻止", "Bridge": "桥接", "Browse": "浏览", "Built-in": "内置", @@ -135,13 +143,23 @@ "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.": "已请求取消,正在等待安全清理。", "Capabilities": "能力", "Capabilities overview": "能力总览", "Capability status legend": "能力状态图例", "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.": "修改将一起保存,重启后端后生效。", @@ -173,6 +191,7 @@ "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.": "此代理打开第一个任务后会显示可选项。将覆盖值留空以使用代理的默认设置。", @@ -209,6 +228,7 @@ "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": "关闭预览", @@ -229,6 +249,7 @@ "Compare extracted text with the game before playing.": "开始前,确认提取的文本与游戏一致。", "Compatibility unavailable": "兼容性状态不可用", "Compatible Work Providers": "兼容的 Work Provider", + "Compatible update available": "有兼容更新", "Compress old history; keep recent frames complete": "压缩旧历史,保持近期帧完整", "Configuration complete": "配置完整", "Configure conversation transcription, wake listening and microphone processing.": "配置对话转录、唤醒聆听和麦克风处理。", @@ -271,6 +292,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": "无法保存控制台语言", @@ -323,6 +345,7 @@ "Detective reasoning": "侦探推理", "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 提供直接图像输入", "Discard changes": "放弃修改", "Discard unsaved connection changes?": "放弃尚未保存的连接修改?", @@ -331,6 +354,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": "草稿", "Drafts": "草稿", "Duplicate configuration name": "配置名称重复", @@ -370,6 +395,8 @@ "Environment references — child variable=Host variable": "环境变量引用——子进程变量=宿主变量", "Environment values": "环境变量值", "Error": "错误", + "Exact artifact match": "产物完全匹配", + "Excludes external services and runtimes.": "不含外部服务和运行时。", "Executable": "可执行文件", "Execution boundary": "执行边界", "Existing companion settings": "现有陪玩设置", @@ -388,11 +415,14 @@ "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": "回退到主对话", @@ -444,6 +474,7 @@ "History": "历史", "Hook": "Hook", "Horizontal position": "水平位置", + "Host API": "Host API", "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。", "Host-governed interaction with applications": "由宿主管理的应用交互", @@ -475,10 +506,13 @@ "Install local wheel…": "安装本地 wheel…", "Install once. Manage contributions and their application state here.": "安装一次,在这里管理所有贡献及其生效状态。", "Install or update local wheel…": "安装或更新本地 wheel…", + "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 version": "已安装版本", "Installed visual resources": "已安装的视觉资源", "Installed voice resources": "已安装的声音资源", "Internal runtime roles": "内部运行时职责", @@ -510,6 +544,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.": "视觉小说实时活动、玩家介入与运行时控制。", @@ -545,6 +580,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.": "管理已安装的扩展包、贡献及其生效状态。", @@ -586,6 +622,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": "原生像素密度", @@ -615,6 +652,10 @@ "No installed Work Provider currently accepts MCP connections.": "当前没有已安装的工作服务提供方支持 MCP 连接。", "No log output yet...": "暂无日志输出…", "No matching chats": "没有匹配的对话", + "No packages are listed in this catalog yet.": "此目录尚未收录插件。", + "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.": "当前没有可选窗口。请打开目标窗口,然后刷新列表。", @@ -622,6 +663,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.": "未安装视频包,沿用原有静态路径。", "None": "无", "None declared": "未声明", @@ -629,6 +671,8 @@ "Not configured": "未配置", "Not included": "不适用", "Not installed": "未安装", + "Not listed in the checked catalog": "未被已检查的目录收录", + "Not provided": "未提供", "Not set": "未设置", "Not used for this game type.": "此游戏类型不使用这项能力。", "Not wired": "尚未接入", @@ -658,11 +702,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", @@ -686,6 +732,7 @@ "PROJECTS": "项目", "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.": "暂停发言,继续跟读和记录剧情。", @@ -699,11 +746,16 @@ "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 type": "插件类型", "Plugins": "插件", "Portrait art is optional. Without the Companion Lite pack, the window shows a simple avatar and captions.": "头像资源为可选项。未安装 Companion Lite 时,小窗显示简洁头像和字幕。", "Portrait overlay": "头像浮层", @@ -711,6 +763,7 @@ "Prefill from current Kurisu Japanese persona": "填入当前红莉栖日语人格文本", "Preparing preview…": "正在准备预览…", "Preparing the final interactive application": "正在准备最终交互应用", + "Preparing the package. It has not been saved yet.": "正在准备插件,尚未保存。", "Presentation": "呈现", "Presentation roles": "呈现职责", "Presentation translation": "呈现翻译", @@ -722,6 +775,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": "配置", @@ -737,6 +791,7 @@ "Provider-only boundary": "仅限服务提供方的边界", "Providers": "服务提供方", "Pure-local Backend Type": "纯本地后端类型", + "Python compatibility": "Python 兼容范围", "Raw events": "原始事件", "React to the current story moment.": "回应当前剧情。", "Read the game view when I ask": "提问时读取游戏画面", @@ -810,9 +865,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": "角色身份", @@ -866,9 +926,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.": "选择模型并打开预览,即可测试表情和嘴型。", @@ -908,6 +970,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)": "平滑时间(毫秒)", @@ -988,6 +1051,8 @@ "Text source has closed": "取文程序已退出", "Text testing needs no full script and makes no companion responses. New games currently start in text capture mode.": "取文测试无需完整剧本,也不会生成陪玩回应。新游戏目前以取文模式启动。", "Text-only model": "仅文本模型", + "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 current dedicated model profile for Visual Novel reasoning and reactions.": "当前用于视觉小说推理与回应的专用模型配置。", @@ -1017,10 +1082,14 @@ "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 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 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": "这是默认的草稿对话", @@ -1034,6 +1103,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.": "该版本已不在目录中,请刷新或选择其他版本。", "Translation and user-facing output transforms": "翻译与面向用户的输出转换", "Trigger reaction decision on non-silent observation": "非静默观察触发反应决策", "Trusted extension validation is temporarily unavailable; saved configuration and credentials were retained.": "可信扩展验证暂时不可用,已保留保存的配置和凭据。", @@ -1055,6 +1125,7 @@ "Unsaved changes": "有未保存的修改", "Untitled chat": "未命名对话", "Unverified": "未验证", + "Update plugin…": "更新插件…", "Updates every 3s": "每 3 秒更新", "Updates sentence splitting, cache keys, and embedded reference selection": "更新分句、缓存键和内置参考音频选择", "Updating": "正在更新", @@ -1088,6 +1159,7 @@ "Video is unavailable on this surface; using the static path.": "当前窗口无法使用视频,已切换为静态路径。", "Video package does not match; using the existing static path.": "视频包不匹配,沿用原有静态路径。", "Video with static heads": "视频与静态入口帧", + "View plugin details": "查看插件详情", "View status reason": "查看状态原因", "Vision Context": "视觉上下文", "Vision Provider": "视觉服务提供方", @@ -1123,8 +1195,13 @@ "Wake and Conversation recognition are independent roles. Selecting a remote backend sends confirmed conversation audio or synthesis text to the configured endpoint; Amadeus never silently falls back from local to remote.": "唤醒识别与对话识别是独立职责。选择远程后端后,已确认的对话音频或合成文本会发送到指定端点;Amadeus 不会静默地从本地切换到远程。", "Wallpaper": "壁纸", "Wallpaper Engine can impose a lower FPS limit. These are configured ceilings, not measured performance.": "Wallpaper Engine 可能施加更低的帧率限制。这里显示的是配置上限,不是实测性能。", + "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 Items": "工作项", @@ -1162,12 +1239,16 @@ "bilingual": "双语", "capabilities": "项能力", "capability": "项能力", + "catalog dependencies are unsupported": "不支持此目录依赖", "chat": "对话", "chats": "对话", "current_window": "当前窗口", "editable in Electron app": "仅可在 Electron 应用中编辑", "full_screen": "全屏", "idle": "空闲", + "incompatible Host API": "Host API 不兼容", + "incompatible Python version": "Python 版本不兼容", + "incompatible catalog SDK": "目录 SDK 不兼容", "inherited": "项继承", "keeps game focus clean": "避免干扰游戏焦点", "last": "上次", @@ -1197,6 +1278,10 @@ "stopping": "停止中", "to open, rename, or delete this chat.": "之后才能打开、重命名或删除此会话。", "translated": "译文", + "unsupported contribution contract": "不支持该插件类型或运行契约", + "unsupported delivery or runtime": "不支持该交付方式或运行时", + "unsupported environment or delivery requirement": "不支持此运行环境或交付要求", + "unsupported platform": "不支持当前平台", "voice lane active": "语音通道活动中", "watching": "持续观察", "{count} unverified": "{count} 项未验证", diff --git a/electron/src/renderer/styles/index.css b/electron/src/renderer/styles/index.css index 5883f46c..e9babfa7 100644 --- a/electron/src/renderer/styles/index.css +++ b/electron/src/renderer/styles/index.css @@ -6258,3 +6258,24 @@ html[data-theme-extension] .settings-inline-action[data-tone='danger']:is(:hover 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-manager-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--card-border); } +.extension-manager-tabs [role="tab"] { padding: 9px 16px; border: 0; background: transparent; color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; } +.extension-manager-tabs [aria-selected="true"] { color: var(--text); border-bottom-color: currentColor; font-weight: 700; } +.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: 10px; } +.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: 12px; } +.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; } +@media (max-width: 600px) { .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/extensionCatalog.ts b/electron/src/shared/extensionCatalog.ts index be31d760..de94d46e 100644 --- a/electron/src/shared/extensionCatalog.ts +++ b/electron/src/shared/extensionCatalog.ts @@ -3,14 +3,15 @@ export type CatalogEntry = { display: Record; compatibility: Record publisher: Record; review: Record withdrawal: null | { reason: 'maintainer' | 'copyright'; evidence: string } - incompatible: 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' | 'refresh' | 'recover' | 'cancel' } +export type CatalogRequest = { action: 'get' | 'status' | 'refresh' | 'recover' | 'cancel' } | { action: 'install'; id: string; version: string } - | { action: 'homepage'; 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?: { id: string; version: string; received: number; total: number } } -export type CatalogReply = { ok: boolean; error?: string; cancelled?: boolean; catalog?: CatalogView; settings?: Record } + | { 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/extensionCatalog.test.mjs b/electron/tests/extensionCatalog.test.mjs index 12b3e63f..acb27126 100644 --- a/electron/tests/extensionCatalog.test.mjs +++ b/electron/tests/extensionCatalog.test.mjs @@ -93,3 +93,29 @@ test('cancel while confirmation is open prevents commit and removes staging', as 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..52e33eb9 --- /dev/null +++ b/electron/tests/extensionCatalogUI.test.mjs @@ -0,0 +1,46 @@ +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']) +}) diff --git a/electron/tests/extensionSettings.smoke.mjs b/electron/tests/extensionSettings.smoke.mjs index f7d9f9a6..7b62d756 100644 --- a/electron/tests/extensionSettings.smoke.mjs +++ b/electron/tests/extensionSettings.smoke.mjs @@ -166,7 +166,7 @@ try { 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').at(-1).params.action"), 'recover') + 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'), []) diff --git a/electron/tests/extensionSettings.test.mjs b/electron/tests/extensionSettings.test.mjs index 0966436b..d06717cb 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) @@ -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)) @@ -265,3 +265,18 @@ 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 policy-blocked packages can be disabled but not activated', async t => { + 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'}}})}) + t.after(()=>page.restore()) + await flush();page.render() + page.button('Configure extension').props.onClick();page.render() + const field=()=>page.find(n=>n.props?.id==='extension-enabled'&&n.type==='input') + assert.equal(field().props.disabled,false) + field().props.onChange({target:{checked:false}});page.render() + assert.equal(field().props.disabled,true) + page.button('Browse').props.onClick();await flush();page.render() + page.button('Installed').props.onClick();page.render() + assert.equal(field().props.checked,false) + assert.ok(page.find(n=>n.props?.role==='alert'&&n.props.children.includes('Security policy'))) +}) diff --git a/electron/tests/extensionStore.smoke.mjs b/electron/tests/extensionStore.smoke.mjs new file mode 100644 index 00000000..ce508ca1 --- /dev/null +++ b/electron/tests/extensionStore.smoke.mjs @@ -0,0 +1,166 @@ +/** 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])')&&document.body.innerText.includes(${JSON.stringify(id+' · '+version)})&&!document.querySelector('.extension-download-progress')`,'Install did not finish: '+id)} +async function configure(enabled){await click('Configure extension');await ui(`(()=>{const n=document.querySelector('#extension-enabled');if(n.checked!==${enabled})n.click()})()`);await click('Save extension');await until("!document.querySelector('form.settings-connection-editor')",'Configuration did not save')} +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') + // A refresh while editing cannot lose a draft or leave a stale success receipt. + await click('Configure extension');await ui("document.querySelector('#extension-enabled').click()") + offline=true;await click('Check updates and security status');await until("document.querySelector('[role=alert]')",'Offline refresh failure absent') + check('offline refresh preserves draft and saved version',await ui("!document.querySelector('#extension-enabled').checked&&!document.body.innerText.includes('Package saved. Configure')")) + offline=false;await click('Save extension');await until("!document.querySelector('form.settings-connection-editor')",'Disable failed') + 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 click('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 click('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 click('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 c1272773..6c4f55b0 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -1163,3 +1163,12 @@ test('lost durable inventory still reports an actually running blocked backend', 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/extensionSettings.tsx b/electron/tests/fixtures/extensionSettings.tsx index b7c765eb..e815bcc0 100644 --- a/electron/tests/fixtures/extensionSettings.tsx +++ b/electron/tests/fixtures/extensionSettings.tsx @@ -96,6 +96,7 @@ const send = async (method: string): Promise> => { throw new Error('Unexpected synthetic RPC: ' + method) } host.amadeus = { + getBackendStartupFailure: async () => null, getDesktopTheme: async () => { if (!palette) return { selected: theme, base: theme, reason: '' } const normalized = validateThemePalette(palette) @@ -109,6 +110,7 @@ host.amadeus = { 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' } @@ -148,6 +150,7 @@ host.amadeus = { desktop.restartRequired = false; desktop.pendingKeys = []; desktop.pendingRevisions = {}; emit(); return true }, } +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() } } document.documentElement.style.height = '100%' @@ -157,7 +160,7 @@ function FixtureApp() { const [page, setPage] = React.useState<'plugins' | 'settings'>(query.get('presentation') === 'plugins' ? 'plugins' : 'settings') const openSettings = (section: string) => { localStorage.setItem('amadeus.settings.section', section); setPage('settings') } 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/release/source_release_policy.json b/release/source_release_policy.json index fcfa905a..a955e88d 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -131,7 +131,10 @@ "vendor/extension-catalog/LICENSE", "electron/src/main/extensionCatalog.ts", "electron/src/main/extensionDownloads.ts", - "electron/src/main/extensionSecurity.ts" + "electron/src/main/extensionSecurity.ts", + "electron/src/renderer/components/ExtensionCatalog.tsx", + "electron/src/renderer/components/extensionCatalogModel.ts", + "electron/src/shared/extensionCatalog.ts" ], "include_roots": [ "characters", diff --git a/server/extensions/catalog-source.json b/server/extensions/catalog-source.json index d4001c43..74275c38 100644 --- a/server/extensions/catalog-source.json +++ b/server/extensions/catalog-source.json @@ -1,8 +1,8 @@ { "repository": "https://github.com/Code-Amadeus/Amadeus-Extensions", - "commit": "c7f5487a0fb9962b718187eec20428697ce4c244", + "commit": "58d3892d55c6a7ec6f8626153f503291bb10ab9a", "files": { - "tools/catalog.py": "b83ca6c415ce3574b54700b55114e6b46b2095c283da3e1c895171a5577d7c82", + "tools/catalog.py": "746dd13bf6e38992fb69e77cab2708166d78663c7dda0b1961757fac3c8246cc", "catalog/schema.json": "bcc80b10d9367f9bff0bdfd19c6f675474ae1a646c549fac4bad53224cffa673", "catalog/policy.json": "a16f51689832d7793fdfcbc8ad1b2ec85237e83b54af5e994329138a7a76812b", "catalog/toolchain.json": "9e462d4a4ddf41951fae3c193537063af9ec55a6bfa5c926a41ff709daa5335b", diff --git a/tests/test_extension_catalog.py b/tests/test_extension_catalog.py index 2bc07143..436de58b 100644 --- a/tests/test_extension_catalog.py +++ b/tests/test_extension_catalog.py @@ -67,7 +67,7 @@ def read(tmp_path, value): def test_catalog_source_hashes_are_independently_pinned(): source = json.loads((ROOT / "server/extensions/catalog-source.json").read_text()) - assert source["commit"] == "c7f5487a0fb9962b718187eec20428697ce4c244" + assert source["commit"] == "58d3892d55c6a7ec6f8626153f503291bb10ab9a" for name, digest in source["files"].items(): assert ( hashlib.sha256((ROOT / "vendor/extension-catalog" / name).read_bytes()).hexdigest() diff --git a/tools/probes/extension_catalog_journey.py b/tools/probes/extension_catalog_journey.py index c0a62360..4820cfd3 100644 --- a/tools/probes/extension_catalog_journey.py +++ b/tools/probes/extension_catalog_journey.py @@ -28,6 +28,11 @@ 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", + ) args = parser.parse_args() from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization @@ -65,8 +70,12 @@ def main(): (base / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM)) entries = [] wheels = {} - for name in ["tone_tts", "notes_mcp", "handoff_skill", "theme"]: - wheel = args.wheels.resolve() / f"amadeus_example_{name}-0.1.0-py3-none-any.whl" + 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), @@ -118,7 +127,14 @@ def main(): name="catalog-qualification", version="0.0.0", type="module", - main=str(ROOT / "electron/tests/extensionCatalog.smoke.mjs"), + main=str( + ROOT + / ( + "electron/tests/extensionStore.smoke.mjs" + if args.store_ui + else "electron/tests/extensionCatalog.smoke.mjs" + ) + ), ) ) ) @@ -150,8 +166,16 @@ def run(phase, arguments, 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: + 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 diff --git a/vendor/extension-catalog/tools/catalog.py b/vendor/extension-catalog/tools/catalog.py index ed258099..44e3d877 100644 --- a/vendor/extension-catalog/tools/catalog.py +++ b/vendor/extension-catalog/tools/catalog.py @@ -400,6 +400,46 @@ def build_entry(source, artifacts, host, python, profile): 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: @@ -440,18 +480,7 @@ def inspect_entry(source, wheel, host, python, profile): "sha256": digest, "url": source["wheel"]["url"], "size": wheel.stat().st_size, - "display": { - k: manifest[k] - for k in ( - "display_name", - "description", - "author", - "license", - "homepage", - "repository", - ) - if k in manifest - }, + "display": artifact_display(manifest), "compatibility": { "host_api": manifest["engines"]["amadeus_host_api"], "python": str( From 41e4836d45095c48a0bb96584a629e7e73e0c8c7 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 10:40:56 +1300 Subject: [PATCH 10/39] fix(extensions): preserve voice roles in catalog presentation --- electron/src/renderer/components/ExtensionCatalog.tsx | 4 ++-- .../src/renderer/components/extensionCatalogModel.ts | 6 ++++-- electron/src/renderer/locales/zh-CN.json | 1 + electron/tests/extensionCatalogUI.test.mjs | 9 +++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/electron/src/renderer/components/ExtensionCatalog.tsx b/electron/src/renderer/components/ExtensionCatalog.tsx index 1422f8ec..673263d5 100644 --- a/electron/src/renderer/components/ExtensionCatalog.tsx +++ b/electron/src/renderer/components/ExtensionCatalog.tsx @@ -40,7 +40,7 @@ export default function ExtensionCatalog({ catalog, installed, busy, selection,
    {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.contract}
    )}
    +
    {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)}
    @@ -69,7 +69,7 @@ export default function ExtensionCatalog({ catalog, installed, busy, selection, return

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

    {entry.version}
    {catalogText(entry.display.description, locale)}
    -
    {catalogContributions(entry).map(c => t(contributionLabel(c.kind))).join(' · ')} · {catalogBytes(entry.size)}
    +
    {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/extensionCatalogModel.ts b/electron/src/renderer/components/extensionCatalogModel.ts index 711417a1..ff32f520 100644 --- a/electron/src/renderer/components/extensionCatalogModel.ts +++ b/electron/src/renderer/components/extensionCatalogModel.ts @@ -19,7 +19,9 @@ export function catalogContributions(entry: CatalogEntry): Array<{ kind: 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) => ({ voice_backend: 'Speech synthesis', mcp_server: 'MCP', skill: 'Skill', theme: 'Theme' }[kind] || kind) +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' @@ -45,7 +47,7 @@ export function catalogGroups(entries: CatalogEntry[], query: string, kind: stri 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))])].join(' ').toLocaleLowerCase(locale).includes(term)) + 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) diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index 43402320..aeee3f96 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -981,6 +981,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": "标准", diff --git a/electron/tests/extensionCatalogUI.test.mjs b/electron/tests/extensionCatalogUI.test.mjs index 52e33eb9..7bdfc33a 100644 --- a/electron/tests/extensionCatalogUI.test.mjs +++ b/electron/tests/extensionCatalogUI.test.mjs @@ -44,3 +44,12 @@ test('filtering a future version of an otherwise compatible package shows that v 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) +}) From a78399dba0bb37cab0befa265f43ce5b1a6d8e04 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 10:45:49 +1300 Subject: [PATCH 11/39] fix(extensions): require explicit catalog safety recovery --- .../src/renderer/components/ExtensionSettings.tsx | 8 ++++---- electron/tests/extensionSettings.test.mjs | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/electron/src/renderer/components/ExtensionSettings.tsx b/electron/src/renderer/components/ExtensionSettings.tsx index 90623a69..d7d226c7 100644 --- a/electron/src/renderer/components/ExtensionSettings.tsx +++ b/electron/src/renderer/components/ExtensionSettings.tsx @@ -124,13 +124,13 @@ 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 () => { + 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: security.status === 'unavailable' ? 'recover' : 'refresh' }) + 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.') @@ -169,7 +169,7 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn const showCatalog = (id: string, version: string) => { setCatalogSelection({ id, version }); setTab('browse') } const switchTab = (next: 'installed' | 'browse') => { setTab(next) - if (next === 'browse' && tab !== next) void checkSecurity() + if (next === 'browse' && tab !== next && security.status !== 'unavailable') void checkSecurity() } const save = async () => { if (!window.amadeus || !selected || !draft) return @@ -203,7 +203,7 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn onClick={() => switchTab(value)}>{t(value === 'installed' ? 'Installed' : 'Browse')})}
    - void checkSecurity()}>{t(security.status === 'unavailable' ? 'Recover official extension security state' : 'Check updates and security status')} + 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} diff --git a/electron/tests/extensionSettings.test.mjs b/electron/tests/extensionSettings.test.mjs index d06717cb..e985b029 100644 --- a/electron/tests/extensionSettings.test.mjs +++ b/electron/tests/extensionSettings.test.mjs @@ -280,3 +280,14 @@ test('catalog view switching retains a draft and policy-blocked packages can be assert.equal(field().props.checked,false) assert.ok(page.find(n=>n.props?.role==='alert'&&n.props.children.includes('Security policy'))) }) + +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) +}) From e579f099ca9a3583591323c386fec0a20bd850ff Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 10:47:46 +1300 Subject: [PATCH 12/39] docs(extensions): record S2 store UI and real plugin qualification --- docs/extension_authoring.md | 2 +- docs/extension_catalog.md | 51 +++++++- ...unity_preview_and_store_plan_2026-10-21.md | 2 +- .../community-preview-batch3-2026-10-21.md | 2 + .../community-preview-batch4-2026-10-21.md | 115 ++++++++++++++++++ 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch4-2026-10-21.md diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md index 7a78bf0a..b0ad5942 100644 --- a/docs/extension_authoring.md +++ b/docs/extension_authoring.md @@ -215,4 +215,4 @@ invalid theme colors/images/contrast and native recovery. The journey additional 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 S1 experiment has no published reviewed package list or complete store browser yet. +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 index a6e51ca4..71d7e2f5 100644 --- a/docs/extension_catalog.md +++ b/docs/extension_catalog.md @@ -1,7 +1,8 @@ # Experimental catalog client and security recovery S1 supplies the desktop transport, installer integration and durable security boundary. -S2 discovery, search, version recommendations and the complete store UI remain pending. +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. @@ -106,12 +107,55 @@ blocks the exact identity through local and store entry points and future activa 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. Full browsing/progress UI belongs to S2. +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 @@ -120,6 +164,7 @@ 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 @@ -132,3 +177,5 @@ 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. 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 index 41fcd17b..8b293e26 100644 --- a/docs/extension_community_preview_and_store_plan_2026-10-21.md +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -2,7 +2,7 @@ 日期: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)。D0 跨仓公开入口、D3 外部作者、S2 完整商店界面和 S3 发布资格仍待完成;未合并、未发布。 +状态:已批准并开始实验施工;第一批 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)。D0 跨仓公开入口、D3 外部作者和 S3 发布资格仍待完成;未合并、未发布。 目标是让外部作者独立完成插件,让用户从精选目录发现、安装和更新插件。先完成 **D0 文档收口 → D1 作者校验入口**,再并行推进 **D2 合约与模板 → D3 外部作者验收** 和 **S0 目录规则 → S1 下载与安装 → S2 商店界面**,在 S3 汇合验收。商店开发使用参考包和测试 fixture,不等待作者招募或公开发布。复用已有安装事务、配置存储、运行时和原生能力接口。 diff --git a/docs/verification/extensions/community-preview-batch3-2026-10-21.md b/docs/verification/extensions/community-preview-batch3-2026-10-21.md index 488175ec..dca978a2 100644 --- a/docs/verification/extensions/community-preview-batch3-2026-10-21.md +++ b/docs/verification/extensions/community-preview-batch3-2026-10-21.md @@ -127,3 +127,5 @@ 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 依赖继续是独立目录。 From c1aa9f0ae696f82a9f2d2c4aa229890e0097e724 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 21:47:58 +1300 Subject: [PATCH 13/39] fix(extensions): ship static checker and qualify store release paths --- .github/workflows/python-windows.yml | 13 ++ README.md | 1 + README_ZH.md | 1 + ROADMAP.md | 13 +- docs/extension_catalog.md | 31 ++++ ...unity_preview_and_store_plan_2026-10-21.md | 2 +- .../community-preview-batch5-2026-10-21.md | 46 ++++++ docs/verification/extensions/store-v1.md | 68 ++++++++ electron/package.json | 68 +++++++- electron/src/main/index.ts | 6 +- electron/tests/fixtures/extensionRelease.cjs | 36 +++++ release/source_release_policy.json | 13 +- tests/test_release_tooling.py | 37 +++++ tools/probes/extension_catalog_journey.py | 4 + tools/probes/extension_reader_matrix.py | 75 +++++++++ tools/probes/extension_release_shell.py | 152 ++++++++++++++++++ tools/smoke_electron_model_less.py | 11 ++ 17 files changed, 559 insertions(+), 18 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch5-2026-10-21.md create mode 100644 docs/verification/extensions/store-v1.md create mode 100644 electron/tests/fixtures/extensionRelease.cjs create mode 100644 tools/probes/extension_reader_matrix.py create mode 100644 tools/probes/extension_release_shell.py diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index 5c70edee..e674d185 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -136,10 +136,22 @@ jobs: 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 --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 }}" - uses: actions/setup-dotnet@v5 with: @@ -156,6 +168,7 @@ 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 }}" - name: Upload Electron smoke diagnostics if: failure() diff --git a/README.md b/README.md index 0439edf9..bbf75b0b 100644 --- a/README.md +++ b/README.md @@ -1005,6 +1005,7 @@ automatic rights to character, model, reference-audio, or external asset packs. - [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 e6eedd54..8f992b69 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -887,6 +887,7 @@ Amadeus 第一方源码和修改依据 - [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 fec7a474..8ad7c4a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,12 +42,13 @@ 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 active next step is a community authoring preview: a small checker -environment, clear Host support information, reusable examples and independent -author lifecycle evidence. A curated official catalog can progress alongside -author recruitment after the shared validation entry is ready. It will reuse the -same installer, with exact artifact hashes, per-version review, explicit updates -and durable safety decisions. This catalog is not yet implemented or published. +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. Catalog admission policy is separate from runtime contracts. New types retain native owners; an older client must disable unsupported packages without losing diff --git a/docs/extension_catalog.md b/docs/extension_catalog.md index 71d7e2f5..5db686a8 100644 --- a/docs/extension_catalog.md +++ b/docs/extension_catalog.md @@ -179,3 +179,34 @@ Transport references: [Electron ClientRequest](https://www.electronjs.org/docs/l [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. 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 --wheels +python tools/smoke_electron_model_less.py --extension-fixture --report-dir build/source-store-smoke +python tools/smoke_electron_model_less.py --extension-fixture --packaged-executable electron/build/win-unpacked/amadeus-desktop.exe --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. 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 index 8b293e26..0e4a40a8 100644 --- a/docs/extension_community_preview_and_store_plan_2026-10-21.md +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -2,7 +2,7 @@ 日期: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)。D0 跨仓公开入口、D3 外部作者和 S3 发布资格仍待完成;未合并、未发布。 +状态:已批准并开始实验施工;第一批 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,不等待作者招募或公开发布。复用已有安装事务、配置存储、运行时和原生能力接口。 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..1dea84ae --- /dev/null +++ b/docs/verification/extensions/community-preview-batch5-2026-10-21.md @@ -0,0 +1,46 @@ +# 社区插件施工记录:第五批 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_20261021T083534Z_45d04b/report.json`,15 项既有桌面检查 + 12 项插件检查通过。 +- 实际 Windows 可执行文件:`build/s3-packaged-shell/electron_model_less_20261021T083642Z_0d97a8/report.json`,同样 15 + 12 通过。两者使用正常认证后端、生产 renderer/preload/IPC,测试通过私有 inspector 提供合成目录 HTTPS 字节和自动原生确认。新建 settings、profile、安装目录,未复制个人配置或已安装环境。 +- 主题 0.1.0 下载、启用、Appearance 选择,镜像更新 0.2.0 保持启用;离线刷新保留已安装;Tone 0.1.0 商店安装后以实际私有 Python 运行 Runner 合约,随后禁用/卸载。Tone 是合成 PCM,不冒称有用的语音服务;完整后台 TTS 路由和有用进程包发行验收仍未据此结案。 +- `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 目标;本轮已还原这个无关生成差异,不把它作为插件修改提交。 + diff --git a/docs/verification/extensions/store-v1.md b/docs/verification/extensions/store-v1.md new file mode 100644 index 00000000..ad9cb032 --- /dev/null +++ b/docs/verification/extensions/store-v1.md @@ -0,0 +1,68 @@ +# Plugin store technical preview: release checklist + +Status: local S3 preparation, **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 + +- [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. +- 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 and real Runner calls. 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. + +## Required before a public technical preview + +| Gate | Current state | Concrete completion evidence | +| --- | --- | --- | +| 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 | Pending clean-machine/manual qualification | 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 | Partial, prior S1/S2 evidence | 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 | 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.json b/electron/package.json index a9d3a848..d082590c 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,7 +1,7 @@ { "name": "amadeus-desktop", "version": "0.17.1-alpha.0", - "description": "Amadeus AI Companion — Electron frontend", + "description": "Amadeus AI Companion \u2014 Electron frontend", "type": "module", "main": "dist/main/index.js", "scripts": { @@ -17,16 +17,70 @@ "electron:build": "npm run build && electron-builder" }, "build": { - "directories": { "output": "build" }, - "files": ["dist/main/**/*", "dist/shared/**/*", "dist/preload/**/*", "dist/renderer/**/*", "package.json"], - "extraResources": [{ "from": "../vendor/extension-sdk", "to": "extension-sdk", "filter": ["*.whl"] }], + "directories": { + "output": "build" + }, + "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/amadeus-extension.v0.schema.json", + "server/extensions/theme-tokens.v0.json", + "agent_host/__init__.py", + "agent_host/skills.py", + "vendor/extension-catalog/**/*" + ] + } + ], "beforePack": "scripts/build-windows-wallpaper.cjs", "win": { "icon": "../assets/icons/app/app_icon.ico", "extraResources": [ - { "from": "../assets/icons/app/app_icon.ico", "to": "assets/icons/app/app_icon.ico" }, - { "from": "../build/windows-wallpaper/host", "to": "windows-wallpaper", "filter": ["**/*", "!*.pdb"] }, - { "from": "../scripts/setup_windows_wallpaper.ps1", "to": "windows-wallpaper/setup_windows_wallpaper.ps1" } + { + "from": "../assets/icons/app/app_icon.ico", + "to": "assets/icons/app/app_icon.ico" + }, + { + "from": "../build/windows-wallpaper/host", + "to": "windows-wallpaper", + "filter": [ + "**/*", + "!*.pdb" + ] + }, + { + "from": "../scripts/setup_windows_wallpaper.ps1", + "to": "windows-wallpaper/setup_windows_wallpaper.ps1" + } ] } }, diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index d81bb852..69b3d184 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -55,14 +55,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}`, diff --git a/electron/tests/fixtures/extensionRelease.cjs b/electron/tests/fixtures/extensionRelease.cjs new file mode 100644 index 00000000..89b82565 --- /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) { + 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, + 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);res.writeHead(200,{'Content-Length':bytes.length});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/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++;return {response:1,checkboxChecked:false}} + return confirm(...args) + } + return true +} diff --git a/release/source_release_policy.json b/release/source_release_policy.json index a955e88d..1a152e35 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -134,7 +134,9 @@ "electron/src/main/extensionSecurity.ts", "electron/src/renderer/components/ExtensionCatalog.tsx", "electron/src/renderer/components/extensionCatalogModel.ts", - "electron/src/shared/extensionCatalog.ts" + "electron/src/shared/extensionCatalog.ts", + "docs/extension_catalog.md", + "docs/verification/extensions/store-v1.md" ], "include_roots": [ "characters", @@ -298,7 +300,14 @@ "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" ], "exclude_globs": [ "docs/reports/**", diff --git a/tests/test_release_tooling.py b/tests/test_release_tooling.py index 517da39c..f5076c0b 100644 --- a/tests/test_release_tooling.py +++ b/tests/test_release_tooling.py @@ -390,3 +390,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/probes/extension_catalog_journey.py b/tools/probes/extension_catalog_journey.py index 4820cfd3..9e9a5801 100644 --- a/tools/probes/extension_catalog_journey.py +++ b/tools/probes/extension_catalog_journey.py @@ -33,6 +33,7 @@ def main(): 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") args = parser.parse_args() from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization @@ -119,6 +120,9 @@ def main(): (base / "fixture.json").write_text( json.dumps(dict(index=index, wheels=wheels)), 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( diff --git a/tools/probes/extension_reader_matrix.py b/tools/probes/extension_reader_matrix.py new file mode 100644 index 00000000..a8fd02ec --- /dev/null +++ b/tools/probes/extension_reader_matrix.py @@ -0,0 +1,75 @@ +"""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_shell.py b/tools/probes/extension_release_shell.py new file mode 100644 index 00000000..094ef6c0 --- /dev/null +++ b/tools/probes/extension_release_shell.py @@ -0,0 +1,152 @@ +"""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 hashlib +import http.client +import json +import subprocess +import sys +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") + + +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).click(timeout=30000) + async def configure(enabled): + await button("Configure extension") + await page.locator("#extension-enabled").set_checked(enabled) + await button("Save extension") + await expect(page.locator("form.settings-connection-editor")).to_have_count(0,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.get_by_text(package_id+" · "+version,exact=True)).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: + 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("Settings");await button("General");await button("Manage plugins") + theme="com.example.desktop-theme" + await install(theme,"0.1.0") + check("resource installed through shipping UI and IPC",True) + await configure(True) + await button("Open appearance settings") + await page.get_by_role("radio").filter(has_text="Quiet day").click() + await button("General");await button("Manage 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.get_by_text(theme+" · 0.2.0",exact=True)).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(False) + await button("Remove");await button("Confirm remove") + await expect(page.get_by_text("No community extensions installed.",exact=True)).to_be_visible(timeout=30000) + tone="com.example.tone-tts" + await install(tone,"0.1.0");await configure(True) + saved=json.loads(settings_path.read_text())["extensions"][tone] + 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(tone.encode()).hexdigest()), + config=saved["values"],secrets={}) + record_path=product.run_root/"tone-record.json";record_path.write_text(json.dumps(record),encoding="utf-8") + fixture_data=json.loads((fixture/"fixture.json").read_text()) + wheel=next(value for name,value in fixture_data["wheels"].items() if name=="amadeus_example_tone_tts-0.1.0-py3-none-any.whl") + report["fixture_artifacts"]=[{key:e[key] for key in ("id","version","sha256")} for e in fixture_data["index"]["entries"]] + report["used_artifacts"]=[entry for entry in report["fixture_artifacts"] if (entry["id"],entry["version"]) in {(theme,"0.1.0"),(theme,"0.2.0"),(tone,"0.1.0")}] + runtime=product.run_root/"tone-runtime.json" + def run_contract(): + with (product.run_root/"tone-contract.log").open("w") as log: + process=subprocess.Popen([sys.executable,"-I",str(ROOT/"tools/check_extension_contract.py"),"--execute","--kind","tts","--wheel",wheel,"--record",str(record_path),"--scenario",str(ROOT/"examples/extensions/contract-scenarios/tone.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 Python package runs through real Runner",await asyncio.to_thread(run_contract)==0 and json.loads(runtime.read_text())["ok"]) + await configure(False);await button("Remove");await button("Confirm remove") + 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/smoke_electron_model_less.py b/tools/smoke_electron_model_less.py index 62b6617e..d1fbda68 100644 --- a/tools/smoke_electron_model_less.py +++ b/tools/smoke_electron_model_less.py @@ -387,6 +387,13 @@ def _environment(self) -> dict[str, str]: "checks": {}, } + if args.extension_fixture: + # 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) + 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 @@ -426,6 +433,9 @@ 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_fixture: + from tools.probes.extension_release_shell import exercise + report["extensions"] = await exercise(product, args.extension_fixture.resolve()) if product.app_page_errors or product.app_console_errors: raise RuntimeError( "Electron renderer reported errors: " @@ -467,6 +477,7 @@ 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("--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) From 3ea67e46d4a58278059b32e8a76e9af08eff99d3 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 21:50:01 +1300 Subject: [PATCH 14/39] test(extensions): exercise Notes MCP in shipping store journeys --- tools/probes/extension_release_shell.py | 57 ++++++++++++++----------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/tools/probes/extension_release_shell.py b/tools/probes/extension_release_shell.py index 094ef6c0..daa1d63e 100644 --- a/tools/probes/extension_release_shell.py +++ b/tools/probes/extension_release_shell.py @@ -54,7 +54,7 @@ def check(name, value=True): 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).click(timeout=30000) + await page.get_by_role("button",name=name,exact=True).and_(page.locator("button:enabled")).click(timeout=30000) async def configure(enabled): await button("Configure extension") await page.locator("#extension-enabled").set_checked(enabled) @@ -118,31 +118,40 @@ async def install(package_id,version): await configure(False) await button("Remove");await button("Confirm remove") await expect(page.get_by_text("No community extensions installed.",exact=True)).to_be_visible(timeout=30000) - tone="com.example.tone-tts" - await install(tone,"0.1.0");await configure(True) - saved=json.loads(settings_path.read_text())["extensions"][tone] - 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(tone.encode()).hexdigest()), - config=saved["values"],secrets={}) - record_path=product.run_root/"tone-record.json";record_path.write_text(json.dumps(record),encoding="utf-8") fixture_data=json.loads((fixture/"fixture.json").read_text()) - wheel=next(value for name,value in fixture_data["wheels"].items() if name=="amadeus_example_tone_tts-0.1.0-py3-none-any.whl") report["fixture_artifacts"]=[{key:e[key] for key in ("id","version","sha256")} for e in fixture_data["index"]["entries"]] - report["used_artifacts"]=[entry for entry in report["fixture_artifacts"] if (entry["id"],entry["version"]) in {(theme,"0.1.0"),(theme,"0.2.0"),(tone,"0.1.0")}] - runtime=product.run_root/"tone-runtime.json" - def run_contract(): - with (product.run_root/"tone-contract.log").open("w") as log: - process=subprocess.Popen([sys.executable,"-I",str(ROOT/"tools/check_extension_contract.py"),"--execute","--kind","tts","--wheel",wheel,"--record",str(record_path),"--scenario",str(ROOT/"examples/extensions/contract-scenarios/tone.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 Python package runs through real Runner",await asyncio.to_thread(run_contract)==0 and json.loads(runtime.read_text())["ok"]) - await configure(False);await button("Remove");await button("Confirm remove") + 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") + if kind == "mcp": + binding=await page.evaluate("id=>window.amadeus.updateExtension({id,providers:['pi']})",package_id) + check("Notes provider binding saved through production IPC",binding.get("ok")) + await configure(True) + saved=json.loads(settings_path.read_text())["extensions"][package_id] + 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(False);await button("Remove");await button("Confirm remove") + 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") From ebcfd009898125fac63b56308eb22d951287f6c9 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 21:58:17 +1300 Subject: [PATCH 15/39] test(extensions): select enabled Pi through release shell settings --- tools/probes/extension_reader_matrix.py | 157 ++++++++---- tools/probes/extension_release_shell.py | 322 ++++++++++++++++++------ tools/smoke_electron_model_less.py | 2 + 3 files changed, 351 insertions(+), 130 deletions(-) diff --git a/tools/probes/extension_reader_matrix.py b/tools/probes/extension_reader_matrix.py index a8fd02ec..087565a6 100644 --- a/tools/probes/extension_reader_matrix.py +++ b/tools/probes/extension_reader_matrix.py @@ -1,4 +1,5 @@ """Run unmodified pinned catalog readers against a future-entry fixture (no plugin execution).""" + from __future__ import annotations import argparse import copy @@ -12,64 +13,120 @@ 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=[] + 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 = 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":[]} + 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={} + 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} + 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")})) + 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__": +if __name__ == "__main__": raise SystemExit(main()) diff --git a/tools/probes/extension_release_shell.py b/tools/probes/extension_release_shell.py index daa1d63e..e7f514ec 100644 --- a/tools/probes/extension_release_shell.py +++ b/tools/probes/extension_release_shell.py @@ -3,6 +3,7 @@ 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 hashlib @@ -17,6 +18,7 @@ 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: @@ -28,10 +30,18 @@ def address(): 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}})) + 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: @@ -44,118 +54,270 @@ def address(): 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":[]} + 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) + 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) - async def configure(enabled): + await ( + page.get_by_role("button", name=name, exact=True) + .and_(page.locator("button:enabled")) + .click(timeout=30000) + ) + + async def configure(enabled, provider=None): await button("Configure extension") await page.locator("#extension-enabled").set_checked(enabled) + if provider: + await ( + page.locator(".settings-form-provider-choices label") + .filter(has_text=provider) + .locator("input") + .check(timeout=30000) + ) await button("Save extension") - await expect(page.locator("form.settings-connection-editor")).to_have_count(0,timeout=30000) - async def install(package_id,version): - await page.get_by_role("tab",name="Browse",exact=True).click() + await expect(page.locator("form.settings-connection-editor")).to_have_count( + 0, 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") + 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.get_by_text(package_id+" · "+version,exact=True)).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 + await expect(page.get_by_text(package_id + " · " + version, exact=True)).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()) + 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: - 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")} + 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))})}})()")) + 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("Settings");await button("General");await button("Manage plugins") - theme="com.example.desktop-theme" - await install(theme,"0.1.0") - check("resource installed through shipping UI and IPC",True) + await button("Settings") + await button("General") + await button("Manage plugins") + theme = "com.example.desktop-theme" + await install(theme, "0.1.0") + check("resource installed through shipping UI and IPC", True) await configure(True) await button("Open appearance settings") await page.get_by_role("radio").filter(has_text="Quiet day").click() - await button("General");await button("Manage plugins") - theme_state=await page.evaluate("window.amadeus.getDesktopTheme()") - check("theme applied through native frontend owner",bool(theme_state.get("theme"))) + await button("General") + await button("Manage 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})()") + 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.get_by_text(theme+" · 0.2.0",exact=True)).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 expect(page.get_by_text(theme + " · 0.2.0", exact=True)).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(False) - await button("Remove");await button("Confirm remove") - 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 button("Remove") + await button("Confirm remove") + 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 install(package_id, "0.1.0") + await configure(True, "Pi" if kind == "mcp" else None) + saved = json.loads(settings_path.read_text())["extensions"][package_id] if kind == "mcp": - binding=await page.evaluate("id=>window.amadeus.updateExtension({id,providers:['pi']})",package_id) - check("Notes provider binding saved through production IPC",binding.get("ok")) - await configure(True) - saved=json.loads(settings_path.read_text())["extensions"][package_id] - 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") + 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) + 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(False);await button("Remove");await button("Confirm remove") - report["used_artifacts"]=[entry for entry in report["fixture_artifacts"] if (entry["id"],entry["version"]) in used] + + 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(False) + await button("Remove") + await button("Confirm remove") + 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 + 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") + (product.run_root / "extension-release-report.json").write_text( + json.dumps(report, indent=2), encoding="utf-8" + ) diff --git a/tools/smoke_electron_model_less.py b/tools/smoke_electron_model_less.py index d1fbda68..b696688e 100644 --- a/tools/smoke_electron_model_less.py +++ b/tools/smoke_electron_model_less.py @@ -350,6 +350,8 @@ def _environment(self) -> dict[str, str]: environment = model_less_backend_environment( super()._environment(), python_executable=sys.executable ) + if args.extension_fixture: + environment["PI_PROVIDER_ENABLED"] = "true" if self.packaged_executable: # The current distribution packages the Electron frontend; # supply the separate Python runtime explicitly for this smoke. From ee45e337428de4ccd21d9f0252497a5c49a32270 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 22:04:09 +1300 Subject: [PATCH 16/39] test(extensions): isolate Pi registration from real model credentials --- tools/smoke_electron_model_less.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tools/smoke_electron_model_less.py b/tools/smoke_electron_model_less.py index b696688e..676634fe 100644 --- a/tools/smoke_electron_model_less.py +++ b/tools/smoke_electron_model_less.py @@ -351,7 +351,8 @@ def _environment(self) -> dict[str, str]: super()._environment(), python_executable=sys.executable ) if args.extension_fixture: - environment["PI_PROVIDER_ENABLED"] = "true" + 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. @@ -393,6 +394,14 @@ def _environment(self) -> dict[str, str]: # 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") From febff4b6efdf07d892ad3cddc361750cddddc5cd Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 22:09:33 +1300 Subject: [PATCH 17/39] docs(extensions): record S3 local qualification and publication gates --- docs/extension_catalog.md | 3 +- .../community-preview-batch5-2026-10-21.md | 31 +++++++++++++++++-- docs/verification/extensions/store-v1.md | 3 +- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/extension_catalog.md b/docs/extension_catalog.md index 5db686a8..10ffc557 100644 --- a/docs/extension_catalog.md +++ b/docs/extension_catalog.md @@ -195,7 +195,8 @@ built renderer, preload and IPC handlers. An explicit `--extension-fixture` inst 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. There is no production test URL/IPC switch, +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). diff --git a/docs/verification/extensions/community-preview-batch5-2026-10-21.md b/docs/verification/extensions/community-preview-batch5-2026-10-21.md index 1dea84ae..a12eb58e 100644 --- a/docs/verification/extensions/community-preview-batch5-2026-10-21.md +++ b/docs/verification/extensions/community-preview-batch5-2026-10-21.md @@ -13,9 +13,9 @@ ## 实际桌面与 reader 检查 -- 源码 shell:`build/s3-source-shell/electron_model_less_20261021T083534Z_45d04b/report.json`,15 项既有桌面检查 + 12 项插件检查通过。 -- 实际 Windows 可执行文件:`build/s3-packaged-shell/electron_model_less_20261021T083642Z_0d97a8/report.json`,同样 15 + 12 通过。两者使用正常认证后端、生产 renderer/preload/IPC,测试通过私有 inspector 提供合成目录 HTTPS 字节和自动原生确认。新建 settings、profile、安装目录,未复制个人配置或已安装环境。 -- 主题 0.1.0 下载、启用、Appearance 选择,镜像更新 0.2.0 保持启用;离线刷新保留已安装;Tone 0.1.0 商店安装后以实际私有 Python 运行 Runner 合约,随后禁用/卸载。Tone 是合成 PCM,不冒称有用的语音服务;完整后台 TTS 路由和有用进程包发行验收仍未据此结案。 +- 源码 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 选择而失败;这是验收脚本漏步骤,补齐真实选择后通过,没有修改产品语义。 @@ -44,3 +44,28 @@ gh api orgs/Code-Amadeus 本地打包钩子会给 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/store-v1.md b/docs/verification/extensions/store-v1.md index ad9cb032..7df963a5 100644 --- a/docs/verification/extensions/store-v1.md +++ b/docs/verification/extensions/store-v1.md @@ -23,7 +23,8 @@ No public index, release, repository permission or main branch was changed. 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 and real Runner calls. The desktop +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, From b3a18270df921aa34d6e429c9a43207cd5f3516e Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 22:36:03 +1300 Subject: [PATCH 18/39] test(extensions): qualify source archives and shipping failure recovery --- .github/workflows/python-windows.yml | 6 +- electron/package-lock.json | 8 +- electron/package.json | 2 +- electron/tests/fixtures/extensionRelease.cjs | 8 +- tests/test_electron_model_less_smoke.py | 30 ++ tools/probes/extension_catalog_journey.py | 11 +- tools/probes/extension_release_failures.py | 326 +++++++++++++++++++ tools/smoke_electron_model_less.py | 52 ++- 8 files changed, 426 insertions(+), 17 deletions(-) create mode 100644 tools/probes/extension_release_failures.py diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index e674d185..c686bea8 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -141,7 +141,7 @@ jobs: 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 --wheels (Join-Path $authorRun.FullName 'wheels') + $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' } @@ -151,7 +151,7 @@ jobs: 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-fixture "${{ env.EXTENSION_RELEASE_FIXTURE }}" --extension-failures - uses: actions/setup-dotnet@v5 with: @@ -168,7 +168,7 @@ 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-fixture "${{ env.EXTENSION_RELEASE_FIXTURE }}" --extension-failures - name: Upload Electron smoke diagnostics if: failure() 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 d082590c..f30e27c6 100644 --- a/electron/package.json +++ b/electron/package.json @@ -98,6 +98,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/tests/fixtures/extensionRelease.cjs b/electron/tests/fixtures/extensionRelease.cjs index 89b82565..f8aa51bd 100644 --- a/electron/tests/fixtures/extensionRelease.cjs +++ b/electron/tests/fixtures/extensionRelease.cjs @@ -1,11 +1,11 @@ // Loaded only through the owned smoke process inspector; never shipped. -async function setupReleaseFixture(base) { +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, + 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) @@ -13,7 +13,7 @@ async function setupReleaseFixture(base) { 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);res.writeHead(200,{'Content-Length':bytes.length});res.end(bytes) + 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 @@ -29,7 +29,7 @@ async function setupReleaseFixture(base) { const confirm=dialog.showMessageBox.bind(dialog) dialog.showMessageBox=async(...args)=>{ const options=args.at(-1) - if(options.title==='Review extension access'){state.confirmations++;return {response:1,checkboxChecked:false}} + 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/tests/test_electron_model_less_smoke.py b/tests/test_electron_model_less_smoke.py index 3612cd59..2da4c8a3 100644 --- a/tests/test_electron_model_less_smoke.py +++ b/tests/test_electron_model_less_smoke.py @@ -86,3 +86,33 @@ async def evaluate(self, expression): return await asyncio.Future() with pytest.raises(TimeoutError): 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) diff --git a/tools/probes/extension_catalog_journey.py b/tools/probes/extension_catalog_journey.py index 9e9a5801..340ddef5 100644 --- a/tools/probes/extension_catalog_journey.py +++ b/tools/probes/extension_catalog_journey.py @@ -34,6 +34,7 @@ def main(): 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() from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization @@ -117,9 +118,13 @@ def main(): security=[], ) policy().validate_index(index) - (base / "fixture.json").write_text( - json.dumps(dict(index=index, wheels=wheels)), encoding="utf-8" - ) + 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 diff --git a/tools/probes/extension_release_failures.py b/tools/probes/extension_release_failures.py new file mode 100644 index 00000000..851998a8 --- /dev/null +++ b/tools/probes/extension_release_failures.py @@ -0,0 +1,326 @@ +"""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) + 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", {}).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/smoke_electron_model_less.py b/tools/smoke_electron_model_less.py index 676634fe..9c0f4fec 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 @@ -334,13 +377,14 @@ async def _wait_clean_exit( async def run_smoke(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + 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): @@ -373,7 +417,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] = { @@ -447,6 +491,9 @@ def verify_wallpaper_stop() -> None: if 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: " @@ -488,6 +535,7 @@ 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("--extension-failures", action="store_true", help="Run shipping IPC failure and cross-process corruption recovery cases; requires --extension-fixture") 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) From f5ea6d4573b9a5d9ed012a307beb8130398bc324 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 22:46:39 +1300 Subject: [PATCH 19/39] test(extensions): handle idle download progress in failure probe --- tools/probes/extension_release_failures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/probes/extension_release_failures.py b/tools/probes/extension_release_failures.py index 851998a8..478c5634 100644 --- a/tools/probes/extension_release_failures.py +++ b/tools/probes/extension_release_failures.py @@ -150,7 +150,7 @@ async def runtime_state(version, expected): pending = asyncio.create_task(install(theme, "0.1.0")) for _ in range(150): status = await catalog({"action": "status"}) - if status.get("progress", {}).get("received", 0) > 0: + if (status.get("progress") or {}).get("received", 0) > 0: break await asyncio.sleep(0.1) else: From 457b1fe70a8707e9e8db3527437ae7ea96204f4f Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 22:53:08 +1300 Subject: [PATCH 20/39] test(extensions): add operator-driven native confirmation qualification --- tools/probes/extension_native_confirmation.py | 60 +++++++++++++++++++ tools/smoke_electron_model_less.py | 8 ++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tools/probes/extension_native_confirmation.py 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/smoke_electron_model_less.py b/tools/smoke_electron_model_less.py index 9c0f4fec..eb9e979a 100644 --- a/tools/smoke_electron_model_less.py +++ b/tools/smoke_electron_model_less.py @@ -377,6 +377,8 @@ async def _wait_clean_exit( async def run_smoke(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + 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 ( @@ -488,7 +490,10 @@ 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_fixture: + if 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: @@ -535,6 +540,7 @@ 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-fixture", type=Path, help="Explicitly execute reference wheels from a prepared isolated catalog fixture") parser.add_argument("--debug-port", type=int, default=0) From 52c15c9d6de5df1664de8729b6e8516699c6bbcb Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 23:09:53 +1300 Subject: [PATCH 21/39] docs(extensions): record isolated qualification and external review blockers --- docs/extension_catalog.md | 27 ++++++- .../community-preview-batch6-2026-10-21.md | 79 +++++++++++++++++++ .../external-review-handoff-2026-10-21.md | 44 +++++++++++ docs/verification/extensions/store-v1.md | 8 +- 4 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch6-2026-10-21.md create mode 100644 docs/verification/extensions/external-review-handoff-2026-10-21.md diff --git a/docs/extension_catalog.md b/docs/extension_catalog.md index 10ffc557..fc693e3b 100644 --- a/docs/extension_catalog.md +++ b/docs/extension_catalog.md @@ -201,9 +201,9 @@ OS trust change or copied developer profile. These results do not establish inde 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 --wheels -python tools/smoke_electron_model_less.py --extension-fixture --report-dir build/source-store-smoke -python tools/smoke_electron_model_less.py --extension-fixture --packaged-executable electron/build/win-unpacked/amadeus-desktop.exe --report-dir build/packaged-store-smoke +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 ``` @@ -211,3 +211,24 @@ 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. 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/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md new file mode 100644 index 00000000..56a500b5 --- /dev/null +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -0,0 +1,44 @@ +# 插件基础设施外审交接:2026-10-21 + +本轮可交付审查,**不建议据此批准合并或发布**。商店的本机源码/打包流程验收通过,Host 的 Pi 集成测试与 Python 依赖审计仍有明确阻断项。没有推送、合并主线或发布索引;用户自行决定将本地材料交给谁。 + +## 范围和入口 + +请先读 [第六批施工记录](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 应用/验收工具代码端点:`457b1fe70a8707e9e8db3527437ae7ea96204f4f`。后续提交仅更新记录;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,不能用解压目录冒充。 + +## 建议优先核查 + +1. **现有阻断项**:`evidence/host-tests.log` 与 `host-final-tests.log` 的不同 Pi 集成失败;`python-audit-summary.json` 的 certifi/Pillow 已知漏洞。没有放宽断言或增加忽略项。最后独立串行 Pi/MCP 与 scoped approvals 共 30 项通过,仍不能推断整组失败根因已消失或只由负载引起。Python 基线修复应另行评估 Pillow 13 API/图像处理影响、更新锁文件、重跑原生 Pi 与图像链路,并重新收口发布资格。 +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 身份检查不应被误称签名或完整机器证明。 + +## 本轮可复核证据 + +- Electron 576 项、Windows 固定 catalog 29 项、Docker 113 项(11 skip)通过;四类真实参考 wheel 作者旅程 12 阶段通过。 +- 源码和打包 shell 各 15 桌面 + 14 插件 + 18 故障检查通过;新建 profile、独立 Python/Node 依赖、实际后台/应用重启;双 settings NUL 故障由显式操作恢复。 +- 实际 native dialog:代理观察后点 Cancel / Install Confirm / Enable Confirm,4 项结果通过。不是自动 response stub,也不是独立人工验收。操作回执单列。 +- 全部测试首次失败与限制保留。`host-isolated-tests.log` 唯一失败来自在无 Git 源码 ZIP 中运行必须 `git ls-files` 的开发测试,不能被归类为产品启动失败;其余 194 通过。原工作树整组 Pi 失败仍独立存在。 +- 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 不替代它。 + +## 包内容与禁止误读 + +`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 index 7df963a5..4983f4b0 100644 --- a/docs/verification/extensions/store-v1.md +++ b/docs/verification/extensions/store-v1.md @@ -15,6 +15,9 @@ No public index, release, repository permission or main branch was changed. 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. +- [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/`. @@ -34,14 +37,15 @@ not restoration of arbitrary old user credentials or profiles. | Gate | Current state | Concrete completion evidence | | --- | --- | --- | +| Host regression and dependency audit | Failed local qualification gates | Resolve the two observed Pi/MCP integration failures without weakening authority assertions; fix or explicitly assess current certifi/Pillow advisories and re-run required checks. See batch 6; store-flow passes do not override these failures. | | 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 | Pending clean-machine/manual qualification | 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 | Partial, prior S1/S2 evidence | 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. | +| 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 | 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. | From 0c8150c973c5dbf45c7d993ded8407a9c388c385 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 23:43:33 +1300 Subject: [PATCH 22/39] fix(deps): update Pillow and certifi with headless Qwen packaging --- LICENSES/provenance.json | 22 +- docs/install_profiles.md | 15 + pyproject.toml | 5 +- tests/test_headless_inference_dependencies.py | 79 + tools/my_utils.py | 6 +- uv.lock | 273 +- vendor/qwen-asr.PROVENANCE.md | 34 + vendor/qwen-asr/LICENSE | 201 + vendor/qwen-asr/MANIFEST.in | 13 + vendor/qwen-asr/README.md | 1441 ++ vendor/qwen-asr/pyproject.toml | 56 + vendor/qwen-asr/qwen_asr/__init__.py | 25 + vendor/qwen-asr/qwen_asr/__main__.py | 26 + vendor/qwen-asr/qwen_asr/cli/demo.py | 536 + .../qwen-asr/qwen_asr/cli/demo_streaming.py | 507 + vendor/qwen-asr/qwen_asr/cli/serve.py | 46 + .../core/transformers_backend/__init__.py | 18 + .../configuration_qwen3_asr.py | 425 + .../modeling_qwen3_asr.py | 1365 + .../processing_qwen3_asr.py | 209 + .../qwen_asr/core/vllm_backend/__init__.py | 16 + .../qwen_asr/core/vllm_backend/qwen3_asr.py | 997 + .../inference/assets/korean_dict_jieba.dict | 21009 ++++++++++++++++ .../qwen-asr/qwen_asr/inference/qwen3_asr.py | 830 + .../inference/qwen3_forced_aligner.py | 483 + vendor/qwen-asr/qwen_asr/inference/utils.py | 497 + vendor/qwen-asr/setup.cfg | 4 + vendor/qwen-asr/upstream-files.json | 22 + 28 files changed, 28920 insertions(+), 240 deletions(-) create mode 100644 tests/test_headless_inference_dependencies.py create mode 100644 vendor/qwen-asr.PROVENANCE.md create mode 100644 vendor/qwen-asr/LICENSE create mode 100644 vendor/qwen-asr/MANIFEST.in create mode 100644 vendor/qwen-asr/README.md create mode 100644 vendor/qwen-asr/pyproject.toml create mode 100644 vendor/qwen-asr/qwen_asr/__init__.py create mode 100644 vendor/qwen-asr/qwen_asr/__main__.py create mode 100644 vendor/qwen-asr/qwen_asr/cli/demo.py create mode 100644 vendor/qwen-asr/qwen_asr/cli/demo_streaming.py create mode 100644 vendor/qwen-asr/qwen_asr/cli/serve.py create mode 100644 vendor/qwen-asr/qwen_asr/core/transformers_backend/__init__.py create mode 100644 vendor/qwen-asr/qwen_asr/core/transformers_backend/configuration_qwen3_asr.py create mode 100644 vendor/qwen-asr/qwen_asr/core/transformers_backend/modeling_qwen3_asr.py create mode 100644 vendor/qwen-asr/qwen_asr/core/transformers_backend/processing_qwen3_asr.py create mode 100644 vendor/qwen-asr/qwen_asr/core/vllm_backend/__init__.py create mode 100644 vendor/qwen-asr/qwen_asr/core/vllm_backend/qwen3_asr.py create mode 100644 vendor/qwen-asr/qwen_asr/inference/assets/korean_dict_jieba.dict create mode 100644 vendor/qwen-asr/qwen_asr/inference/qwen3_asr.py create mode 100644 vendor/qwen-asr/qwen_asr/inference/qwen3_forced_aligner.py create mode 100644 vendor/qwen-asr/qwen_asr/inference/utils.py create mode 100644 vendor/qwen-asr/setup.cfg create mode 100644 vendor/qwen-asr/upstream-files.json diff --git a/LICENSES/provenance.json b/LICENSES/provenance.json index 0d26f98f..fe584cb7 100644 --- a/LICENSES/provenance.json +++ b/LICENSES/provenance.json @@ -11,7 +11,8 @@ "assets/audio/sfx", "vendor/aec-audio-processing", "vendor/extension-sdk", - "vendor/extension-catalog" + "vendor/extension-catalog", + "vendor/qwen-asr" ], "components": [ { @@ -508,6 +509,25 @@ "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)", + "category": "vendored-source", + "paths": [ + "vendor/qwen-asr/**" + ], + "source_url": "https://github.com/Qwen/Qwen3-ASR", + "source_revision": "PyPI 0.0.6 sdist sha256:294893f2340dc2d58d1f1d1cb8ce26ac0a79f254805ab432dda2892bd5c8d13c", + "license_expression": "Apache-2.0", + "license_evidence": [ + "vendor/qwen-asr/LICENSE", + "vendor/qwen-asr.PROVENANCE.md" + ], + "modified": true, + "release_action": "include", + "gate_status": "ready", + "notes": "Inference/CLI Python sources are byte-identical to the pinned upstream sdist. Only pyproject.toml moves gradio from mandatory dependencies to demo extra; source inventory and removal condition are documented adjacent to the package." } ] } diff --git a/docs/install_profiles.md b/docs/install_profiles.md index 89bfe1e2..ea8b0402 100644 --- a/docs/install_profiles.md +++ b/docs/install_profiles.md @@ -85,6 +85,21 @@ including blocked remote requests and successful loading of a tiny local model. Explicit asset acquisition runs separately; remote Chat/ASR/TTS API clients are unchanged. The advisory exceptions remain temporary, not claims of patched packages. +## Security-patched image decoding and optional demo dependencies + +The locked core uses Pillow 13.0.0 and certifi 2026.10.19. Gradio's current +Pillow `<13` constraint is not overridden. Local-model profiles use the original +Qwen-ASR 0.0.6 inference sources with one documented packaging change: Gradio is +an optional `demo` dependency instead of an inference requirement. The locked +source, upstream hashes and removal condition are in +[Qwen-ASR provenance](../vendor/qwen-asr.PROVENANCE.md). + +`load_audio` retains the same FFmpeg decoding path; importing it no longer loads +legacy Gradio/pandas UI helpers. The inherited Gradio demo/WebUI scripts are not +installed by the Host local-model presets. Do not downgrade the Host's image +decoder to run them. This source packaging change does not change ASR/TTS model +code, weights, configured interpreter selection, or GPU qualification. + ## Experimental Linux Voice source build On Ubuntu 24.04, install `build-essential`, `pkg-config` and `portaudio19-dev`, diff --git a/pyproject.toml b/pyproject.toml index 991c94e9..0ccd712e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "numpy==1.26.4", "openai==2.38.0", "openai-codex==0.154.0", - "Pillow==12.3.0", + "Pillow==13.0.0", "packaging==26.2", "playwright==1.60.0", "psutil==7.2.2", @@ -85,7 +85,6 @@ local-models = [ "funasr==1.3.7", "g2p-en==2.1.0", "g2pk2==0.0.3", - "gradio>=6.16.0,<7", "huggingface-hub==0.36.2", "hydra-core>=1.3.4,<2", "jieba==0.42.1", @@ -179,6 +178,8 @@ conflicts = [ ] [tool.uv.sources] +# Upstream 0.0.6 inference sources unchanged; Gradio is a demo-only extra. +qwen-asr = { path = "vendor/qwen-asr" } # Temporary AEC source-build fix; other platforms retain the PyPI artifacts. aec-audio-processing = { path = "vendor/aec-audio-processing", marker = "sys_platform == 'linux'" } torch = [ diff --git a/tests/test_headless_inference_dependencies.py b/tests/test_headless_inference_dependencies.py new file mode 100644 index 00000000..cda53941 --- /dev/null +++ b/tests/test_headless_inference_dependencies.py @@ -0,0 +1,79 @@ +"""Headless inference imports and the exact-source optional demo packaging boundary.""" +from __future__ import annotations +import builtins +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tomllib +import types +import zipfile + +import numpy as np +from packaging.requirements import Requirement + +ROOT = Path(__file__).resolve().parents[1] + + +def test_audio_decoder_does_not_import_legacy_ui_dependencies(tmp_path, monkeypatch): + original_import = builtins.__import__ + + def checked_import(name, *args, **kwargs): + assert name.split('.')[0] not in {'gradio', 'pandas'}, name + return original_import(name, *args, **kwargs) + + calls = [] + decoded = np.array([0.25, -0.5], dtype=np.float32).tobytes() + + class Decoder: + def output(self, *args, **kwargs): + calls.append(('output', args, kwargs)) + return self + + def run(self, **kwargs): + calls.append(('run', kwargs)) + return decoded, b'' + + ffmpeg = types.ModuleType('ffmpeg') + ffmpeg.input = lambda *args, **kwargs: Decoder() + monkeypatch.setitem(sys.modules, 'ffmpeg', ffmpeg) + monkeypatch.setattr(builtins, '__import__', checked_import) + spec = importlib.util.spec_from_file_location('headless_audio_probe', ROOT / 'tools/my_utils.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + audio = tmp_path / 'reference.wav' + audio.touch() + result = module.load_audio(str(audio), 16000) + np.testing.assert_array_equal(result, [0.25, -0.5]) + assert calls[0][2] == {'format': 'f32le', 'acodec': 'pcm_f32le', 'ac': 1, 'ar': 16000} + assert calls[1][1]['cmd'] == ['ffmpeg', '-nostdin'] + + +def test_qwen_inference_sources_match_upstream_and_demo_is_optional(tmp_path): + source = ROOT / 'vendor/qwen-asr' + expected = json.loads((source / 'upstream-files.json').read_text()) + for name, digest in expected.items(): + if name != 'pyproject.toml': + assert hashlib.sha256((source / name).read_bytes()).hexdigest() == digest, name + project = tomllib.loads((source / 'pyproject.toml').read_text())['project'] + assert 'gradio' not in {Requirement(item).name for item in project['dependencies']} + assert {Requirement(item).name for item in project['optional-dependencies']['demo']} == {'gradio'} + # Build in a disposable directory; inspecting distribution metadata imports no model code. + import shutil + work = tmp_path / 'source' + shutil.copytree(source, work, ignore=shutil.ignore_patterns('build', '*.egg-info', '__pycache__')) + output = tmp_path / 'wheels' + output.mkdir() + result = subprocess.run([sys.executable, '-I', '-c', + 'from setuptools.build_meta import build_wheel; import sys; build_wheel(sys.argv[1])', str(output)], + cwd=work, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + with zipfile.ZipFile(next(output.glob('*.whl'))) as wheel: + from email.parser import BytesParser + metadata = BytesParser().parsebytes(wheel.read('qwen_asr-0.0.6.dist-info/METADATA')) + dependencies = [Requirement(item) for item in metadata.get_all('Requires-Dist')] + gradio, = [item for item in dependencies if item.name == 'gradio'] + assert not gradio.marker.evaluate({'extra': ''}) + assert gradio.marker.evaluate({'extra': 'demo'}) diff --git a/tools/my_utils.py b/tools/my_utils.py index 5e3ce74a..08536159 100644 --- a/tools/my_utils.py +++ b/tools/my_utils.py @@ -1,9 +1,7 @@ import platform,os,traceback import ffmpeg import numpy as np -import gradio as gr from tools.i18n.i18n import I18nAuto -import pandas as pd i18n = I18nAuto(language=os.environ.get('language','Auto')) def load_audio(file, sr): @@ -36,6 +34,8 @@ def clean_path(path_str:str): def check_for_existance(file_list:list=None,is_train=False,is_dataset_processing=False): + # Legacy WebUI helper; inference/audio decoding does not require its UI stack. + import gradio as gr files_status=[] if is_train == True and file_list: file_list.append(os.path.join(file_list[0],'2-name2text.txt')) @@ -72,6 +72,7 @@ def check_for_existance(file_list:list=None,is_train=False,is_dataset_processing return True def check_details(path_list=None,is_train=False,is_dataset_processing=False): + import gradio as gr if is_dataset_processing: list_path, audio_path = path_list if (not list_path.endswith('.list')): @@ -108,6 +109,7 @@ def check_details(path_list=None,is_train=False,is_dataset_processing=False): else:gr.Warning(i18n('缺少Hubert数据集')) if os.listdir(wav_path):... else:gr.Warning(i18n('缺少音频数据集')) + import pandas as pd df = pd.read_csv( semantic_path, delimiter="\t", encoding="utf-8" ) diff --git a/uv.lock b/uv.lock index 728500a6..9065f3e7 100644 --- a/uv.lock +++ b/uv.lock @@ -287,7 +287,6 @@ local-cpu = [ { name = "funasr" }, { name = "g2p-en" }, { name = "g2pk2" }, - { name = "gradio" }, { name = "huggingface-hub" }, { name = "hydra-core" }, { name = "jieba" }, @@ -335,7 +334,6 @@ local-cu124 = [ { name = "funasr" }, { name = "g2p-en" }, { name = "g2pk2" }, - { name = "gradio" }, { name = "huggingface-hub" }, { name = "hydra-core" }, { name = "jieba" }, @@ -382,7 +380,6 @@ local-cu128 = [ { name = "funasr", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "g2p-en", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "g2pk2", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gradio", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "huggingface-hub", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "hydra-core", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "jieba", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -428,7 +425,6 @@ local-models = [ { name = "funasr" }, { name = "g2p-en" }, { name = "g2pk2" }, - { name = "gradio" }, { name = "huggingface-hub" }, { name = "hydra-core" }, { name = "jieba" }, @@ -470,7 +466,6 @@ local-mps = [ { name = "funasr", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "g2p-en", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "g2pk2", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, - { name = "gradio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "huggingface-hub", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "hydra-core", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "jieba", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, @@ -514,7 +509,6 @@ local-rocm = [ { name = "funasr", marker = "sys_platform == 'win32'" }, { name = "g2p-en", marker = "sys_platform == 'win32'" }, { name = "g2pk2", marker = "sys_platform == 'win32'" }, - { name = "gradio", marker = "sys_platform == 'win32'" }, { name = "huggingface-hub", marker = "sys_platform == 'win32'" }, { name = "hydra-core", marker = "sys_platform == 'win32'" }, { name = "jieba", marker = "sys_platform == 'win32'" }, @@ -623,7 +617,6 @@ requires-dist = [ { name = "g2p-en", marker = "extra == 'local-models'", specifier = "==2.1.0" }, { name = "g2pk2", marker = "extra == 'local-models'", specifier = "==0.0.3" }, { name = "google-genai", specifier = "==2.6.0" }, - { name = "gradio", marker = "extra == 'local-models'", specifier = ">=6.16.0,<7" }, { name = "httpx", specifier = "==0.28.1" }, { name = "huggingface-hub", marker = "extra == 'local-models'", specifier = "==0.36.2" }, { name = "huggingface-hub", marker = "extra == 'rag'", specifier = "==0.36.2" }, @@ -648,7 +641,7 @@ requires-dist = [ { name = "opencc", marker = "extra == 'local-models'", specifier = "==1.3.1" }, { name = "packaging", specifier = "==26.2" }, { name = "peft", marker = "extra == 'local-models'", specifier = "==0.19.1" }, - { name = "pillow", specifier = "==12.3.0" }, + { name = "pillow", specifier = "==13.0.0" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = "==2.10.1" }, { name = "playwright", specifier = "==1.60.0" }, { name = "psutil", specifier = "==7.2.2" }, @@ -661,7 +654,7 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.2.3" }, { name = "pytorch-lightning", marker = "extra == 'local-models'", specifier = "==2.6.4" }, { name = "pyyaml", specifier = "==6.0.3" }, - { name = "qwen-asr", marker = "extra == 'local-models'", specifier = "==0.0.6" }, + { name = "qwen-asr", marker = "extra == 'local-models'", directory = "vendor/qwen-asr" }, { name = "requests", specifier = "==2.34.2" }, { name = "rocm", marker = "sys_platform == 'win32' and extra == 'local-rocm'", url = "https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm-7.2.1.tar.gz" }, { name = "rocm-sdk-core", marker = "sys_platform == 'win32' and extra == 'local-rocm'", url = "https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_core-7.2.1-py3-none-win_amd64.whl" }, @@ -850,24 +843,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/59/eb95d57ea576feaf1693f5e914f9f27ff9961429cf9ecbc85cef610a6d84/botocore-1.43.86-py3-none-any.whl", hash = "sha256:4efc7fbd6e7616edbd55623bb585a044906149b9a5d3d8558420506526e5a5ac", size = 15751556, upload-time = "2026-09-01T19:23:54.013Z" }, ] -[[package]] -name = "brotli" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, - { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, -] - [[package]] name = "budoux" version = "0.9.1" @@ -897,11 +872,11 @@ filecache = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, ] [[package]] @@ -1511,60 +1486,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/9e/e8ba4e58a9d5daf42343f3ea1cb0efb721eba36a1d6624e9873d039a5c1e/google_genai-2.6.0-py3-none-any.whl", hash = "sha256:272b6f6320f5d355735241ad441f972af095ec80dc10cb075cb430d96721648a", size = 821003, upload-time = "2026-05-22T01:34:31.55Z" }, ] -[[package]] -name = "gradio" -version = "6.17.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "brotli" }, - { name = "fastapi" }, - { name = "gradio-client" }, - { name = "groovy" }, - { name = "hf-gradio" }, - { name = "httpx" }, - { name = "huggingface-hub" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "numpy" }, - { name = "orjson" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pillow" }, - { name = "pydantic" }, - { name = "pydub" }, - { name = "python-multipart" }, - { name = "pytz" }, - { name = "pyyaml" }, - { name = "safehttpx" }, - { name = "semantic-version" }, - { name = "starlette" }, - { name = "tomlkit" }, - { name = "typer" }, - { name = "typing-extensions" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/c1/40e4fffb75a558481ed975c6a9571464403ed61af5c8af0329e5469fcfe0/gradio-6.17.3.tar.gz", hash = "sha256:3822c3ac3e2a5fcbde7821cf6437a01c88592e484efd5f4cd369581b0ce258fb", size = 48557999, upload-time = "2026-06-07T22:05:25.645Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/c9/e179c9b3211b34e139e0998e835dd15fd2fcdd4a18cd99449e3c4600e260/gradio-6.17.3-py3-none-any.whl", hash = "sha256:7e52c65bfbb7bd75ac1c28cb38f93b01e5f6a2ff013224e6213533451bfee517", size = 32329363, upload-time = "2026-06-07T22:05:21.759Z" }, -] - -[[package]] -name = "gradio-client" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fsspec" }, - { name = "httpx" }, - { name = "huggingface-hub" }, - { name = "packaging" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e8/e6/6b6029f5fe2ad7f1211105d530e34d991014c2cae463f9223033031cfc4f/gradio_client-2.5.0.tar.gz", hash = "sha256:4cde99bad62149595c30c90876ca2e405e3a13687ecf895474f3412cb476673d", size = 59013, upload-time = "2026-04-20T23:16:21.518Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/81/0a861b8e1ff42960139c6cd4c7dd591292fa09ea1ae2d87677441cba4c00/gradio_client-2.5.0-py3-none-any.whl", hash = "sha256:d43e2179c29076292a76485ad7ed2e6eaa19d14ac58283bd7f5beabfe4ca958c", size = 59952, upload-time = "2026-04-20T23:16:20.186Z" }, -] - [[package]] name = "greenlet" version = "3.5.5" @@ -1583,15 +1504,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, ] -[[package]] -name = "groovy" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/36/bbdede67400277bef33d3ec0e6a31750da972c469f75966b4930c753218f/groovy-0.1.2.tar.gz", hash = "sha256:25c1dc09b3f9d7e292458aa762c6beb96ea037071bf5e917fc81fb78d2231083", size = 17325, upload-time = "2025-02-28T20:24:56.068Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/27/3d6dcadc8a3214d8522c1e7f6a19554e33659be44546d44a2f7572ac7d2a/groovy-0.1.2-py3-none-any.whl", hash = "sha256:7f7975bab18c729a257a8b1ae9dcd70b7cafb1720481beae47719af57c35fa64", size = 14090, upload-time = "2025-02-28T20:24:55.152Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1601,19 +1513,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "hf-gradio" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gradio-client" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/86/c9694b7cfada5780e75769e60dc161a161f4dd7fc91b61db5e3a3338bef9/hf_gradio-0.4.1.tar.gz", hash = "sha256:a017d942618f0d495a58ee4563047fa04bef614c00e0cb789a9a6d0633cffa7b", size = 6560, upload-time = "2026-04-22T14:01:32.334Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/2d/afff2ee87e75d8eb85c92bb8cf0e15b05c23c2ebd8fd8dec781d8601ed7f/hf_gradio-0.4.1-py3-none-any.whl", hash = "sha256:76b8cb8be6abe62d74c1ad2d35b42f0629db89aa9e1a8d033cecfe7c856eeab3", size = 4482, upload-time = "2026-04-17T19:53:31.827Z" }, -] - [[package]] name = "hf-xet" version = "1.6.0" @@ -2996,25 +2895,6 @@ 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" }, ] -[[package]] -name = "orjson" -version = "3.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, - { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, - { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, - { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, - { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, -] - [[package]] name = "oss2" version = "2.19.1" @@ -3047,28 +2927,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "pandas" -version = "3.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32' or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-local-cu124') or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-local-cu128') or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-local-mps') or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-local-rocm') or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-torch-cpu') or (extra == 'extra-7-amadeus-local-cu124' and extra == 'extra-7-amadeus-local-cu128') or (extra == 'extra-7-amadeus-local-cu124' and extra == 'extra-7-amadeus-local-mps') or (extra == 'extra-7-amadeus-local-cu124' and extra == 'extra-7-amadeus-local-rocm') or (extra == 'extra-7-amadeus-local-cu124' and extra == 'extra-7-amadeus-torch-cpu') or (extra == 'extra-7-amadeus-local-cu128' and extra == 'extra-7-amadeus-local-mps') or (extra == 'extra-7-amadeus-local-cu128' and extra == 'extra-7-amadeus-local-rocm') or (extra == 'extra-7-amadeus-local-cu128' and extra == 'extra-7-amadeus-torch-cpu') or (extra == 'extra-7-amadeus-local-mps' and extra == 'extra-7-amadeus-local-rocm') or (extra == 'extra-7-amadeus-local-mps' and extra == 'extra-7-amadeus-torch-cpu') or (extra == 'extra-7-amadeus-local-rocm' and extra == 'extra-7-amadeus-torch-cpu')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, - { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, - { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, - { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, - { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, -] - [[package]] name = "peft" version = "0.19.1" @@ -3097,19 +2955,24 @@ wheels = [ [[package]] name = "pillow" -version = "12.3.0" +version = "13.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" }, ] [[package]] @@ -3400,15 +3263,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] -[[package]] -name = "pydub" -version = "0.25.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, -] - [[package]] name = "pyee" version = "13.0.1" @@ -3620,11 +3474,10 @@ wheels = [ [[package]] name = "qwen-asr" version = "0.0.6" -source = { registry = "https://pypi.org/simple" } +source = { directory = "vendor/qwen-asr" } dependencies = [ { name = "accelerate" }, { name = "flask" }, - { name = "gradio" }, { name = "librosa" }, { name = "nagisa" }, { name = "pytz" }, @@ -3634,10 +3487,23 @@ dependencies = [ { name = "soynlp" }, { name = "transformers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/5b/56c5175d1a4d6ed8602003385570304305e4ae9c40b999d12d75a70c0561/qwen_asr-0.0.6.tar.gz", hash = "sha256:294893f2340dc2d58d1f1d1cb8ce26ac0a79f254805ab432dda2892bd5c8d13c", size = 146666, upload-time = "2026-01-30T03:24:02.697Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/12/d3027a7e4dc2eea0b12a4bf8414a7109f055004e1771666e01d8859d3ca0/qwen_asr-0.0.6-py3-none-any.whl", hash = "sha256:b9c55a38413298f3a990a4475467399daec6e8f4172363053fc42e2166c2dfd3", size = 141603, upload-time = "2026-01-30T03:24:00.82Z" }, + +[package.metadata] +requires-dist = [ + { name = "accelerate", specifier = "==1.12.0" }, + { name = "flask" }, + { name = "gradio", marker = "extra == 'demo'", specifier = ">=6.16.0,<7" }, + { name = "librosa" }, + { name = "nagisa", specifier = "==0.2.11" }, + { name = "pytz" }, + { name = "qwen-omni-utils" }, + { name = "soundfile" }, + { name = "sox" }, + { name = "soynlp", specifier = "==0.0.493" }, + { name = "transformers", specifier = "==4.57.6" }, + { name = "vllm", marker = "extra == 'vllm'", specifier = "==0.14.0" }, ] +provides-extras = ["demo", "vllm"] [[package]] name = "qwen-omni-utils" @@ -3860,18 +3726,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl", hash = "sha256:5b9827d1044159bbb01b86ef8902760ea39281927f5de31de75e1d657177bf4c", size = 88264, upload-time = "2026-05-26T19:45:00.452Z" }, ] -[[package]] -name = "safehttpx" -version = "0.1.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/d1/4282284d9cf1ee873607a46442da977fc3c985059315ab23610be31d5885/safehttpx-0.1.7.tar.gz", hash = "sha256:db201c0978c41eddb8bb480f3eee59dd67304fdd91646035e9d9a720049a9d23", size = 10385, upload-time = "2025-10-24T18:30:09.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/a3/0f0b7d78e2f1eb9e8e1afbff1d2bff8d60144aee17aca51c065b516743dd/safehttpx-0.1.7-py3-none-any.whl", hash = "sha256:c4f4a162db6993464d7ca3d7cc4af0ffc6515a606dfd220b9f82c6945d869cde", size = 8959, upload-time = "2025-10-24T18:30:08.733Z" }, -] - [[package]] name = "safetensors" version = "0.8.0" @@ -3938,15 +3792,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, ] -[[package]] -name = "semantic-version" -version = "2.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, -] - [[package]] name = "sentence-transformers" version = "5.5.1" @@ -3996,15 +3841,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "silero-vad" version = "6.2.1" @@ -4374,15 +4210,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] -[[package]] -name = "tomlkit" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, -] - [[package]] name = "torch" version = "2.6.0" @@ -4916,21 +4743,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, ] -[[package]] -name = "typer" -version = "0.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-local-cu124') or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-local-cu128') or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-local-mps') or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-local-rocm') or (extra == 'extra-7-amadeus-local-cpu' and extra == 'extra-7-amadeus-torch-cpu') or (extra == 'extra-7-amadeus-local-cu124' and extra == 'extra-7-amadeus-local-cu128') or (extra == 'extra-7-amadeus-local-cu124' and extra == 'extra-7-amadeus-local-mps') or (extra == 'extra-7-amadeus-local-cu124' and extra == 'extra-7-amadeus-local-rocm') or (extra == 'extra-7-amadeus-local-cu124' and extra == 'extra-7-amadeus-torch-cpu') or (extra == 'extra-7-amadeus-local-cu128' and extra == 'extra-7-amadeus-local-mps') or (extra == 'extra-7-amadeus-local-cu128' and extra == 'extra-7-amadeus-local-rocm') or (extra == 'extra-7-amadeus-local-cu128' and extra == 'extra-7-amadeus-torch-cpu') or (extra == 'extra-7-amadeus-local-mps' and extra == 'extra-7-amadeus-local-rocm') or (extra == 'extra-7-amadeus-local-mps' and extra == 'extra-7-amadeus-torch-cpu') or (extra == 'extra-7-amadeus-local-rocm' and extra == 'extra-7-amadeus-torch-cpu')" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -4952,15 +4764,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] -[[package]] -name = "tzdata" -version = "2026.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, -] - [[package]] name = "umap-learn" version = "0.5.12" diff --git a/vendor/qwen-asr.PROVENANCE.md b/vendor/qwen-asr.PROVENANCE.md new file mode 100644 index 00000000..240f491e --- /dev/null +++ b/vendor/qwen-asr.PROVENANCE.md @@ -0,0 +1,34 @@ +# Qwen-ASR packaging provenance + +Upstream: https://github.com/Qwen/Qwen3-ASR, PyPI release `qwen-asr==0.0.6`. +Original sdist: `https://files.pythonhosted.org/packages/7f/5b/56c5175d1a4d6ed8602003385570304305e4ae9c40b999d12d75a70c0561/qwen_asr-0.0.6.tar.gz`. +SHA-256: `294893f2340dc2d58d1f1d1cb8ce26ac0a79f254805ab432dda2892bd5c8d13c`. +License: Apache-2.0; original LICENSE, copyright headers, README and manifest retained. + +Amadeus modification (2026-10-21): `pyproject.toml` moves `gradio` from mandatory +requirements to the `demo` extra (`gradio>=6.16.0,<7`). All `qwen_asr/**/*.py` files +and other shipped upstream inputs are byte-identical to the sdist, recorded in +`qwen-asr/upstream-files.json`. Generated upstream egg-info/PKG-INFO are omitted so +build metadata comes from the patched pyproject. The upstream release version +remains 0.0.6 for advisory lookup; `uv.lock` records the local directory source. +The Host uses its locked uv source selection, not a metadata override or `--no-deps` +runtime installation. No inference logic or model weights are bundled or changed. + +Why: Qwen-ASR imports Gradio only in `qwen_asr/cli/demo.py`. Its normal inference +entry point does not use Gradio. Supported Gradio currently caps Pillow below 13, +preventing the security update of the Host's actual image decoder. Removing just +the Host Gradio constraint instead lets resolution select an obsolete Gradio; that +is not retained. `tools/my_utils.py` likewise imports Gradio/pandas only in its +legacy UI helpers, not when local TTS imports `load_audio`. + +The Host local-model profiles install inference dependencies. Qwen's Gradio demo +and the inherited `tools/subfix_webui.py` are not part of those profiles; do not add +an old Gradio/Pillow to the Host environment to run them. A supported separate demo +environment needs compatible upstream releases. This does not disable or move the +Host's configured in-process or sidecar ASR/TTS execution. + +Removal condition: an upstream Qwen-ASR release separates optional UI dependencies +(or a supported Gradio release permits the patched Pillow and the Host's other +pinned dependencies). Then replace this directory source with the reviewed upstream +release, remove this packaging patch, and repeat inference import/lock/image tests. +No upstream bug report is claimed: this is a local packaging compatibility change. diff --git a/vendor/qwen-asr/LICENSE b/vendor/qwen-asr/LICENSE new file mode 100644 index 00000000..c347eb5e --- /dev/null +++ b/vendor/qwen-asr/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 2026 Alibaba Cloud + + 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/qwen-asr/MANIFEST.in b/vendor/qwen-asr/MANIFEST.in new file mode 100644 index 00000000..1027c2aa --- /dev/null +++ b/vendor/qwen-asr/MANIFEST.in @@ -0,0 +1,13 @@ +global-exclude * + +recursive-include qwen_asr *.py *.pyi py.typed +recursive-include qwen_asr *.dict + +include LICENSE +include MANIFEST.in +include pyproject.toml + +prune assets +prune examples +prune finetuning +prune qwen_asr.egg-info \ No newline at end of file diff --git a/vendor/qwen-asr/README.md b/vendor/qwen-asr/README.md new file mode 100644 index 00000000..974607cf --- /dev/null +++ b/vendor/qwen-asr/README.md @@ -0,0 +1,1441 @@ +# Qwen3-ASR + +
    + +

    + +

    + +

    +  🤗 Hugging Face   |   🤖 ModelScope   |   📑 Blog   |   📑 Paper   +
    +🖥️ Hugging Face Demo   |    🖥️ ModelScope Demo   |   💬 WeChat (微信)   |   🫨 Discord   |   📑 API + +

    + +We release **Qwen3-ASR**, a family that includes two powerful all-in-one speech recognition models that support language identification and ASR for 52 languages and dialects, as well as a novel non-autoregressive speech forced-alignment model that can align text–speech pairs in 11 languages. + + +## News +* 2026.1.29: 🎉🎉🎉 We have released the [Qwen3-ASR](https://huggingface.co/collections/Qwen/qwen3-asr) series (0.6B/1.7B) and the Qwen3-ForcedAligner-0.6B model. Please check out our [blog](https://qwen.ai/blog?id=qwen3asr)! + + +## Contents + +- [Overview](#overview) + - [Introduction](#introduction) + - [Model Architecture](#model-architecture) + - [Released Models Description and Download](#released-models-description-and-download) +- [Quickstart](#quickstart) + - [Environment Setup](#environment-setup) + - [Python Package Usage](#python-package-usage) + - [Quick Inference](#quick-inference) + - [vLLM Backend](#vllm-backend) + - [Streaming Inference](#streaming-inference) + - [ForcedAligner Usage](#forcedaligner-usage) + - [DashScope API Usage](#dashscope-api-usage) +- [Launch Local Web UI Demo](#launch-local-web-ui-demo) + - [Gradio Demo](#gradio-demo) + - [Streaming Demo](#streaming-demo) +- [Deployment with vLLM](#deployment-with-vllm) +- [Fine Tuning](#fine-tuning) +- [Docker](#docker) +- [Evaluation](#evaluation) +- [Citation](#citation) + + +## Overview + +### Introduction + +

    + +

    + +The Qwen3-ASR family includes Qwen3-ASR-1.7B and Qwen3-ASR-0.6B, which support language identification and ASR for 52 languages and dialects. Both leverage large-scale speech training data and the strong audio understanding capability of their foundation model, Qwen3-Omni. Experiments show that the 1.7B version achieves state-of-the-art performance among open-source ASR models and is competitive with the strongest proprietary commercial APIs. Here are the main features: + +* **All-in-one**: Qwen3-ASR-1.7B and Qwen3-ASR-0.6B support language identification and speech recognition for 30 languages and 22 Chinese dialects, so as to English accents from multiple countries and regions. + +* **Excellent and Fast**: The Qwen3-ASR family ASR models maintains high-quality and robust recognition under complex acoustic environments and challenging text patterns. Qwen3-ASR-1.7B achieves strong performance on both open-sourced and internal benchmarks. While the 0.6B version achieves accuracy-efficient trade-off, it reaches 2000 times throughput at a concurrency of 128. They both achieve streaming / offline unified inference with single model and support transcribe long audio. + +* **Novel and strong forced alignment Solution**: We introduce Qwen3-ForcedAligner-0.6B, which supports timestamp prediction for arbitrary units within up to 5 minutes of speech in 11 languages. Evaluations show its timestamp accuracy surpasses E2E based forced-alignment models. + +* **Comprehensive inference toolkit**: In addition to open-sourcing the architectures and weights of the Qwen3-ASR series, we also release a powerful, full-featured inference framework that supports vLLM-based batch inference, asynchronous serving, streaming inference, timestamp prediction, and more. + +### Model Architecture + +

    + +

    + + +### Released Models Description and Download + +Below is an introduction and download information for the Qwen3-ASR models. Please select and download the model that fits your needs. + +| Model | Supported Languages | Supported Dialects | Inference Mode | Audio Types | +|---|---|---|---|---| +| Qwen3-ASR-1.7B & Qwen3-ASR-0.6B | Chinese (zh), English (en), Cantonese (yue), Arabic (ar), German (de), French (fr), Spanish (es), Portuguese (pt), Indonesian (id), Italian (it), Korean (ko), Russian (ru), Thai (th), Vietnamese (vi), Japanese (ja), Turkish (tr), Hindi (hi), Malay (ms), Dutch (nl), Swedish (sv), Danish (da), Finnish (fi), Polish (pl), Czech (cs), Filipino (fil), Persian (fa), Greek (el), Hungarian (hu), Macedonian (mk), Romanian (ro) | Anhui, Dongbei, Fujian, Gansu, Guizhou, Hebei, Henan, Hubei, Hunan, Jiangxi, Ningxia, Shandong, Shaanxi, Shanxi, Sichuan, Tianjin, Yunnan, Zhejiang, Cantonese (Hong Kong accent), Cantonese (Guangdong accent), Wu language, Minnan language. | Offline / Streaming | Speech, Singing Voice, Songs with BGM | +| Qwen3-ForcedAligner-0.6B | Chinese, English, Cantonese, French, German, Italian, Japanese, Korean, Portuguese, Russian, Spanish | -- | NAR | Speech | + +During model loading in the `qwen-asr` package or vLLM, model weights will be downloaded automatically based on the model name. However, if your runtime environment does not allow downloading weights during execution, you can use the following commands to manually download the model weights to a local directory: + +```bash +# Download through ModelScope (recommended for users in Mainland China) +pip install -U modelscope +modelscope download --model Qwen/Qwen3-ASR-1.7B --local_dir ./Qwen3-ASR-1.7B +modelscope download --model Qwen/Qwen3-ASR-0.6B --local_dir ./Qwen3-ASR-0.6B +modelscope download --model Qwen/Qwen3-ForcedAligner-0.6B --local_dir ./Qwen3-ForcedAligner-0.6B +# Download through Hugging Face +pip install -U "huggingface_hub[cli]" +huggingface-cli download Qwen/Qwen3-ASR-1.7B --local-dir ./Qwen3-ASR-1.7B +huggingface-cli download Qwen/Qwen3-ASR-0.6B --local-dir ./Qwen3-ASR-0.6B +huggingface-cli download Qwen/Qwen3-ForcedAligner-0.6B --local-dir ./Qwen3-ForcedAligner-0.6B +``` + + +## Quickstart + +### Environment Setup + +The easiest way to use Qwen3-ASR is to install the `qwen-asr` Python package from PyPI. This will pull in the required runtime dependencies and allow you to load any released Qwen3-ASR model. If you’d like to simplify environment setup further, you can also use our official [Docker image](#docker). The `qwen-asr` package provides two backends: the transformers backend and the vLLM backend. For usage instructions for different backends, please refer to [Python Package Usage](#python-package-usage). We recommend using a **fresh, isolated environment** to avoid dependency conflicts with existing packages. You can create a clean Python 3.12 environment like this: + +```bash +conda create -n qwen3-asr python=3.12 -y +conda activate qwen3-asr +``` + +Run the following command to get the minimal installation with transformers-backend support: + +```bash +pip install -U qwen-asr +``` + +To enable the vLLM backend for faster inference and streaming support, run: + +```bash +pip install -U qwen-asr[vllm] +``` + +If you want to develop or modify the code locally, install from source in editable mode: + +```bash +git clone https://github.com/QwenLM/Qwen3-ASR.git +cd Qwen3-ASR +pip install -e . +# support vLLM backend +# pip install -e ".[vllm]" +``` + +Additionally, we recommend using FlashAttention 2 to reduce GPU memory usage and accelerate inference speed, especially for long inputs and large batch sizes. + +```bash +pip install -U flash-attn --no-build-isolation +``` + +If your machine has less than 96GB of RAM and lots of CPU cores, run: + +```bash +MAX_JOBS=4 pip install -U flash-attn --no-build-isolation +``` + +Also, you should have hardware that is compatible with FlashAttention 2. Read more about it in the official documentation of the [FlashAttention repository](https://github.com/Dao-AILab/flash-attention). FlashAttention 2 can only be used when a model is loaded in `torch.float16` or `torch.bfloat16`. + +### Python Package Usage + +#### Quick Inference + +The `qwen-asr` package provides two backends: **transformers backend** and **vLLM backend**. You can pass audio inputs as a local path, a URL, base64 data, or a `(np.ndarray, sr)` tuple, and run batch inference. To quickly try Qwen3-ASR, you can use `Qwen3ASRModel.from_pretrained(...)` for the transformers backend with the following code: + +```python +import torch +from qwen_asr import Qwen3ASRModel + +model = Qwen3ASRModel.from_pretrained( + "Qwen/Qwen3-ASR-1.7B", + dtype=torch.bfloat16, + device_map="cuda:0", + # attn_implementation="flash_attention_2", + max_inference_batch_size=32, # Batch size limit for inference. -1 means unlimited. Smaller values can help avoid OOM. + max_new_tokens=256, # Maximum number of tokens to generate. Set a larger value for long audio input. +) + +results = model.transcribe( + audio="https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav", + language=None, # set "English" to force the language +) + +print(results[0].language) +print(results[0].text) +``` + +If you want to return timestamps, pass `forced_aligner` and its init kwargs. Here is an example of batch inference with timestamps output: + +```python +import torch +from qwen_asr import Qwen3ASRModel + +model = Qwen3ASRModel.from_pretrained( + "Qwen/Qwen3-ASR-1.7B", + dtype=torch.bfloat16, + device_map="cuda:0", + # attn_implementation="flash_attention_2", + max_inference_batch_size=32, # Batch size limit for inference. -1 means unlimited. Smaller values can help avoid OOM. + max_new_tokens=256, # Maximum number of tokens to generate. Set a larger value for long audio input. + forced_aligner="Qwen/Qwen3-ForcedAligner-0.6B", + forced_aligner_kwargs=dict( + dtype=torch.bfloat16, + device_map="cuda:0", + # attn_implementation="flash_attention_2", + ), +) + +results = model.transcribe( + audio=[ + "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_zh.wav", + "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav", + ], + language=["Chinese", "English"], # can also be set to None for automatic language detection + return_time_stamps=True, +) + +for r in results: + print(r.language, r.text, r.time_stamps[0]) +``` + +For more detailed usage examples, please refer to the [example code](https://github.com/QwenLM/Qwen3-ASR/blob/main/examples/example_qwen3_asr_transformers.py) for the transformers backend. + +#### vLLM Backend + +If you want the fastest inference speed with Qwen3-ASR, we strongly recommend using the vLLM backend by initializing the model with `Qwen3ASRModel.LLM(...)`. Example code is provided below. Note that you must install it via `pip install -U qwen-asr[vllm]`. If you want the model to output timestamps, it’s best to install FlashAttention via `pip install -U flash-attn --no-build-isolation` to speed up inference for the forced aligner model. Remember to wrap your code under `if __name__ == '__main__':` to avoid the `spawn` error described in [vLLM Troubleshooting](https://docs.vllm.ai/en/latest/usage/troubleshooting/#python-multiprocessing). + +```python +import torch +from qwen_asr import Qwen3ASRModel + +if __name__ == '__main__': + model = Qwen3ASRModel.LLM( + model="Qwen/Qwen3-ASR-1.7B", + gpu_memory_utilization=0.7, + max_inference_batch_size=128, # Batch size limit for inference. -1 means unlimited. Smaller values can help avoid OOM. + max_new_tokens=4096, # Maximum number of tokens to generate. Set a larger value for long audio input. + forced_aligner="Qwen/Qwen3-ForcedAligner-0.6B", + forced_aligner_kwargs=dict( + dtype=torch.bfloat16, + device_map="cuda:0", + # attn_implementation="flash_attention_2", + ), + ) + + results = model.transcribe( + audio=[ + "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_zh.wav", + "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav", + ], + language=["Chinese", "English"], # can also be set to None for automatic language detection + return_time_stamps=True, + ) + + for r in results: + print(r.language, r.text, r.time_stamps[0]) +``` + +For more detailed usage examples, please refer to the [example code](https://github.com/QwenLM/Qwen3-ASR/blob/main/examples/example_qwen3_asr_vllm.py) for the vLLM backend. In addition, you can start a vLLM server via the `qwen-asr-serve` command, which is a wrapper around `vllm serve`. You can pass any arguments supported by `vllm serve`, for example: + +```bash +qwen-asr-serve Qwen/Qwen3-ASR-1.7B --gpu-memory-utilization 0.8 --host 0.0.0.0 --port 8000 +``` + +And send requests to the server via: + +```python +import requests + +url = "http://localhost:8000/v1/chat/completions" +headers = {"Content-Type": "application/json"} + +data = { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "audio_url", + "audio_url": { + "url": "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav" + }, + } + ], + } + ] +} + +response = requests.post(url, headers=headers, json=data, timeout=300) +response.raise_for_status() +content = response.json()['choices'][0]['message']['content'] +print(content) + +# parse ASR output if you want +from qwen_asr import parse_asr_output +language, text = parse_asr_output(content) +print(language) +print(text) +``` + +#### Streaming Inference + +Qwen3-ASR fully supports streaming inference. Currently, streaming inference is only available with the vLLM backend. Note that streaming inference does not support batch inference or returning timestamps. Please refer to the [example code](https://github.com/QwenLM/Qwen3-ASR/blob/main/examples/example_qwen3_asr_vllm_streaming.py) for details. You can also launch a streaming web demo through the [guide](#streaming-demo) to experience Qwen3-ASR’s streaming transcription capabilities. + +#### ForcedAligner Usage + +`Qwen3-ForcedAligner-0.6B` can align text–speech pairs and return word or character level timestamps. Here is an example of using the forced aligner directly: + +```python +import torch +from qwen_asr import Qwen3ForcedAligner + +model = Qwen3ForcedAligner.from_pretrained( + "Qwen/Qwen3-ForcedAligner-0.6B", + dtype=torch.bfloat16, + device_map="cuda:0", + # attn_implementation="flash_attention_2", +) + +results = model.align( + audio="https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_zh.wav", + text="甚至出现交易几乎停滞的情况。", + language="Chinese", +) + +print(results[0]) +print(results[0][0].text, results[0][0].start_time, results[0][0].end_time) +``` + +In addition, the forced aligner supports local paths / URLs / base64 data / `(np.ndarray, sr)` inputs and batch inference. Please refer to the [example code](https://github.com/QwenLM/Qwen3-ASR/blob/main/examples/example_qwen3_forced_aligner.py) for details. + +### DashScope API Usage + +To further explore Qwen3-ASR, we encourage you to try our DashScope API for a faster and more efficient experience. For detailed API information and documentation, please refer to the following: + +| API Description | API Documentation (Mainland China) | API Documentation (International) | +|------------------|-----------------------------------|------------------------------------| +| Real-time API for Qwen3-ASR. | [https://help.aliyun.com/zh/model-studio/qwen-real-time-speech-recognition](https://help.aliyun.com/zh/model-studio/qwen-real-time-speech-recognition) | [https://www.alibabacloud.com/help/en/model-studio/qwen-real-time-speech-recognition](https://www.alibabacloud.com/help/en/model-studio/qwen-real-time-speech-recognition) | +| FileTrans API for Qwen3-ASR. | [https://help.aliyun.com/zh/model-studio/qwen-speech-recognition](https://help.aliyun.com/zh/model-studio/qwen-speech-recognition) | [https://www.alibabacloud.com/help/en/model-studio/qwen-speech-recognition](https://www.alibabacloud.com/help/en/model-studio/qwen-speech-recognition) | + + +## Launch Local Web UI Demo + +### Gradio Demo + +To launch the Qwen3-ASR web UI gradio demo, install the `qwen-asr` package and run `qwen-asr-demo`. Use the command below for help: + +```bash +qwen-asr-demo --help +``` + +To launch the demo, you can use the following commands: + +```bash +# Transformers backend +qwen-asr-demo \ + --asr-checkpoint Qwen/Qwen3-ASR-1.7B \ + --backend transformers \ + --cuda-visible-devices 0 \ + --ip 0.0.0.0 --port 8000 + +# Transformers backend + Forced Aligner (enable timestamps) +qwen-asr-demo \ + --asr-checkpoint Qwen/Qwen3-ASR-1.7B \ + --aligner-checkpoint Qwen/Qwen3-ForcedAligner-0.6B \ + --backend transformers \ + --cuda-visible-devices 0 \ + --backend-kwargs '{"device_map":"cuda:0","dtype":"bfloat16","max_inference_batch_size":8,"max_new_tokens":256}' \ + --aligner-kwargs '{"device_map":"cuda:0","dtype":"bfloat16"}' \ + --ip 0.0.0.0 --port 8000 + +# vLLM backend + Forced Aligner (enable timestamps) +qwen-asr-demo \ + --asr-checkpoint Qwen/Qwen3-ASR-1.7B \ + --aligner-checkpoint Qwen/Qwen3-ForcedAligner-0.6B \ + --backend vllm \ + --cuda-visible-devices 0 \ + --backend-kwargs '{"gpu_memory_utilization":0.7,"max_inference_batch_size":8,"max_new_tokens":2048}' \ + --aligner-kwargs '{"device_map":"cuda:0","dtype":"bfloat16"}' \ + --ip 0.0.0.0 --port 8000 +``` + +Then open `http://:8000`, or access it via port forwarding in tools like VS Code. + +#### Backend Notes + +This demo supports two backends: transformers and vLLM. All backend-specific initialization parameters should be passed via `--backend-kwargs` as a JSON dict. If not provided, the demo will use sensible defaults. + +```bash +# Example: override transformers init args with flash attention +--backend-kwargs '{"device_map":"cuda:0","dtype":"bfloat16","attn_implementation":"flash_attention_2"}' + +# Example: override vLLM init args with 65% GPU memory +--backend-kwargs '{"gpu_memory_utilization":0.65}' +``` + +#### CUDA Device Notes + +Because vLLM does not follow `cuda:0` style device selection, this demo selects GPUs by setting `CUDA_VISIBLE_DEVICES` via `--cuda-visible-devices`. + +```bash +# Use GPU 0 +--cuda-visible-devices 0 + +# Use GPU 1 +--cuda-visible-devices 1 +``` + +#### Timestamps Notes + +Timestamps are only available when `--aligner-checkpoint` is provided. If you launch the demo without a forced aligner, the timestamps UI will be hidden automatically. + +```bash +# No forced aligner +qwen-asr-demo --asr-checkpoint Qwen/Qwen3-ASR-1.7B + +# With forced aligner +qwen-asr-demo \ + --asr-checkpoint Qwen/Qwen3-ASR-1.7B \ + --aligner-checkpoint Qwen/Qwen3-ForcedAligner-0.6B +``` + +#### HTTPS Notes + +To avoid browser microphone permission issues after deploying the server, it is recommended/required to run the gradio service over HTTPS (especially when accessed remotely or behind modern browsers/gateways). Use `--ssl-certfile` and `--ssl-keyfile` to enable HTTPS. First, generate a private key and a self-signed certificate (valid for 365 days): + +```bash +openssl req -x509 -newkey rsa:2048 \ + -keyout key.pem -out cert.pem \ + -days 365 -nodes \ + -subj "/CN=localhost" +``` + +Then run the demo with HTTPS: + +```bash +qwen-asr-demo \ + --asr-checkpoint Qwen/Qwen3-ASR-1.7B \ + --backend transformers \ + --cuda-visible-devices 0 \ + --ip 0.0.0.0 --port 8000 \ + --ssl-certfile cert.pem \ + --ssl-keyfile key.pem \ + --no-ssl-verify +``` + +Then open `https://:8000` to use it. If your browser shows a warning, that’s expected for self-signed certificates. For production, use a real certificate. + +### Streaming Demo + +To experience Qwen3-ASR’s streaming transcription capability in a web UI, we provide a minimal Flask-based streaming demo. The demo captures microphone audio in the browser, resamples it to 16,000 Hz, and continuously pushes PCM chunks to the model. Run the demo with the following command: + +```bash +qwen-asr-demo-streaming \ + --asr-model-path Qwen/Qwen3-ASR-1.7B \ + --gpu-memory-utilization 0.9 \ + --host 0.0.0.0 \ + --port 8000 +``` + +Then open `http://:8000`, or access it via port forwarding in tools like VS Code. + +## Deployment with vLLM + +vLLM officially provides day-0 model support for Qwen3-ASR for efficient inference. + +### Installation +You can run Qwen3-ASR with vLLM nightly wheel or docker image. To install the nightly version of vLLM, we recommend using `uv` as the environment manager +```bash +uv venv +source .venv/bin/activate +uv pip install -U vllm --pre \ + --extra-index-url https://wheels.vllm.ai/nightly/cu129 \ + --extra-index-url https://download.pytorch.org/whl/cu129 \ + --index-strategy unsafe-best-match +uv pip install "vllm[audio]" # For additional audio dependencies +``` + +### Online Serving +You can easily deploy Qwen3-ASR with vLLM by running the following command +```bash +vllm serve Qwen/Qwen3-ASR-1.7B +``` +After the model server is successfully deployed, you can interact with it in multiple ways. + +#### Using OpenAI SDK +```python +import base64 +import httpx +from openai import OpenAI + +# Initialize client +client = OpenAI( + base_url="http://localhost:8000/v1", + api_key="EMPTY" +) + +# Create multimodal chat completion request +response = client.chat.completions.create( + model="Qwen/Qwen3-ASR-1.7B", + messages=[ + { + "role": "user", + "content": [ + { + "type": "audio_url", + "audio_url": { + {"url": "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav"} + } + } + ] + } + ], +) + +print(response.choices[0].message.content) +``` +This model is also supported on vLLM with OpenAI transcription API. +```python +import httpx +from openai import OpenAI + +# Initialize client +client = OpenAI( + base_url="http://localhost:8000/v1", + api_key="EMPTY" +) +audio_url = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav" +audio_file = httpx.get(audio_url).content + +transcription = client.audio.transcriptions.create( + model="Qwen/Qwen3-ASR-1.7B", + file=audio_file, +) + +print(transcription.text) +``` + +#### Using cURL +```bash +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + {"role": "user", "content": [ + {"type": "audio_url", "audio_url": {"url": "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav"}} + ]} + ] + }' +``` + +### Offline Inference +See the following example on using vLLM to run offline inference with Qwen3-ASR +```python +from vllm import LLM, SamplingParams +from vllm.assets.audio import AudioAsset +import base64 +import requests + +# Initialize the LLM +llm = LLM( + model="Qwen/Qwen3-ASR-1.7B" +) + +# Load audio +audio_asset = AudioAsset("winning_call") + +# Create conversation with audio content +conversation = [ + { + "role": "user", + "content": [ + { + "type": "audio_url", + "audio_url": {"url": audio_asset.url} + } + ] + } +] + +sampling_params = SamplingParams(temperature=0.01, max_tokens=256) + +# Run inference using .chat() +outputs = llm.chat(conversation, sampling_params=sampling_params) +print(outputs[0].outputs[0].text) +``` + + +## Fine Tuning + +Please refer to [Qwen3-ASR-Finetuning](finetuning/) for detailed instructions on fine-tuning Qwen3-ASR. + + +## Docker + +To make it easier to use our `qwen-asr` Python package, we provide a pre-built Docker image: [qwenllm/qwen3-asr](https://hub.docker.com/r/qwenllm/qwen3-asr). You only need to install the GPU driver and download the model files to run the code. Please follow the [NVIDIA Container Toolkit installation guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) to ensure Docker can access your GPU. If you are in Mainland China and have trouble reaching Docker Hub, you may use a registry mirror to accelerate image pulls. + +First, pull the image and start a container: + +```bash +LOCAL_WORKDIR=/path/to/your/workspace +HOST_PORT=8000 +CONTAINER_PORT=80 +docker run --gpus all --name qwen3-asr \ + -v /var/run/docker.sock:/var/run/docker.sock -p $HOST_PORT:$CONTAINER_PORT \ + --mount type=bind,source=$LOCAL_WORKDIR,target=/data/shared/Qwen3-ASR \ + --shm-size=4gb \ + -it qwenllm/qwen3-asr:latest +``` + +After running the command, you will enter the container’s bash shell. Your local workspace (**replace** `/path/to/your/workspace` **with the actual path**) will be mounted inside the container at `/data/shared/Qwen3-ASR`. Port `8000` on the host is mapped to port `80` in the container, so you can access services running in the container via `http://:8000`. Note that services inside the container must bind to `0.0.0.0` (not `127.0.0.1`) for port forwarding to work. + +If you exit the container, you can start it again and re-enter it with: + +```bash +docker start qwen3-asr +docker exec -it qwen3-asr bash +``` + +To remove the container completely, run: + +```bash +docker rm -f qwen3-asr +``` + + +## Evaluation + +During evaluation, we ran inference for all models with `dtype=torch.bfloat16` and set `max_new_tokens=1024` using vLLM. Greedy search was used for all decoding, and none of the tests specified a language parameter. The detailed evaluation results are shown below. + +

    +ASR Benchmarks on Public Datasets (WER ↓) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    GPT-4o
    -Transcribe
    Gemini-2.5
    -Pro
    Doubao-ASRWhisper
    -large-v3
    Fun-ASR
    -MLT-Nano
    Qwen3-ASR
    -0.6B
    Qwen3-ASR
    -1.7B
    English (en)
    Librispeech
    clean | other
    1.39 | 3.752.89 | 3.562.78 | 5.701.51 | 3.971.68 | 4.032.11 | 4.551.63 | 3.38
    GigaSpeech25.509.379.559.76-8.888.45
    CV-en9.0814.4913.789.909.909.927.39
    Fleurs-en2.402.946.314.085.494.393.35
    MLS-en5.123.687.094.87-6.004.58
    Tedlium7.696.154.916.84-3.854.50
    VoxPopuli10.2911.3612.1212.05-9.969.15
    Chinese (zh)
    WenetSpeech
    net | meeting
    15.30 | 32.2714.43 | 13.47N/A9.86 | 19.116.35 | -5.97 | 6.884.97 | 5.88
    AISHELL-2-test4.2411.622.855.06-3.152.71
    SpeechIO12.865.302.937.56-3.442.88
    Fleurs-zh2.442.712.694.093.512.882.41
    CV-zh6.327.705.9512.916.206.895.35
    Chinese Dialect
    KeSpeech26.8724.715.2728.79-7.085.10
    Fleurs-yue4.989.434.989.18-5.793.98
    CV-yue11.3618.7613.2016.23-9.507.57
    CV-zh-tw6.327.314.067.84-5.593.77
    WenetSpeech-Yue
    short | long
    15.62 | 25.2925.19 | 11.239.74 | 11.4032.26 | 46.64- | -7.54 | 9.925.82 | 8.85
    WenetSpeech-Chuan
    easy | hard
    34.81 | 53.9843.79 | 67.3011.40 | 20.2014.35 | 26.80- | -13.92 | 24.4511.99 | 21.63
    + +
    + +
    +ASR Benchmarks on Internal Datasets (WER ↓) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    GPT-4o
    -Transcribe
    Gemini-2.5
    -Pro
    Doubao-ASRWhisper
    -large-v3
    Fun-ASR
    -MLT-Nano
    Qwen3-ASR
    -0.6B
    Qwen3-ASR
    -1.7B
    Accented English
    Dialog-Accented English28.5623.8520.4121.3019.9616.6216.07
    Chinese Mandarin
    Elders&Kids14.2736.934.1710.614.544.483.81
    ExtremeNoise36.1129.0617.0463.1736.5517.8816.17
    TongueTwister20.874.973.4716.639.024.062.44
    Dialog-Mandarin20.7312.506.6114.017.327.066.54
    Chinese Dialect
    Dialog-Cantonese16.0514.987.5631.045.854.804.12
    Dialog-Chinese Dialects45.3747.7019.8544.5519.4118.2415.94
    +

    Dialect coverage: Results for Dialog-Accented English are averaged over 16 accents, and results for Dialog-Chinese Dialects are averaged over 22 Chinese dialects.

    + +
    + +
    +Multilingual ASR Benchmarks (WER ↓) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    GLM-ASR
    -Nano-2512
    Whisper
    -large-v3
    Fun-ASR
    -MLT-Nano
    Qwen3-ASR
    -0.6B
    Qwen3-ASR
    -1.7B
    Open-sourced Benchmarks
    MLS13.328.6228.7013.198.55
    CommonVoice19.4010.7717.2512.759.18
    MLC-SLM34.9315.6829.9415.8412.74
    Fleurs16.085.2710.037.574.90
    Fleurs†20.056.8531.8910.376.62
    Fleurs††24.838.1647.8421.8012.60
    Qwen-ASR Internal Benchmarks
    News-Multilingual49.4014.8065.0717.3912.80
    +

    Language coverage: MLS includes 8 languages: {da, de, en, es, fr, it, pl, pt}.
    CommonVoice includes 13 languages: {en, zh, yue, zh_TW, ar, de, es, fr, it, ja, ko, pt, ru}.
    MLC-SLM includes 11 languages: {en, fr, de, it, pt, es, ja, ko, ru, th, vi}.
    Fleurs includes 12 languages: {en, zh, yue, ar, de, es, fr, it, ja, ko, pt, ru }.
    Fleurs† includes 8 additional languages beyond Fleurs: {hi, id, ms, nl, pl, th, tr, vi}.
    Fleurs†† includes 10 additional languages beyond Fleurs†: {cs, da, el, fa, fi, fil, hu, mk, ro, sv}.
    News-Multilingual includes 15 languages: {ar, de, es, fr, hi, id, it, ja, ko, nl, pl, pt, ru, th, vi}.

    + +
    + +
    +Language Identification Accuracy (%) ↑ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Whisper-large-v3Qwen3-ASR-0.6BQwen3-ASR-1.7B
    MLS99.999.399.9
    CommonVoice92.798.298.7
    MLC-SLM89.292.794.1
    Fleurs94.697.198.7
    Avg.94.196.897.9
    +

    Language coverage: The language sets follow Multilingual ASR Benchmarks. Here, Fleurs corresponds to Fleurs†† in Multilingual ASR Benchmarks and covers 30 languages.

    + +
    + +
    +Singing Voice & Song Transcription (WER ↓) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    GPT-4o
    -Transcribe
    Gemini-2.5
    -Pro
    Doubao-ASR
    -1.0
    Whisper
    -large-v3
    Fun-ASR-MLT
    -Nano
    Qwen3-ASR
    -1.7B
    Singing
    M4Singer16.7720.887.8813.587.295.98
    MIR-1k-vocal11.879.856.5611.718.176.25
    Opencpop7.936.493.809.522.983.08
    Popcs32.8415.138.9713.779.428.52
    Songs with BGM
    EntireSongs-en30.7112.1833.51N/AN/A14.60
    EntireSongs-zh34.8618.6823.99N/AN/A13.91
    + +
    + +
    +ASR Inference Mode Performance (WER ↓) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ModelInfer. ModeLibrispeechFleurs-enFleurs-zhAvg.
    Qwen3-ASR-1.7BOffline1.63 | 3.383.352.412.69
    Streaming1.95 | 4.514.022.843.33
    Qwen3-ASR-0.6BOffline2.11 | 4.554.392.883.48
    Streaming2.54 | 6.275.383.404.40
    + +
    + +
    +Forced Alignment Benchmarks (AAS ms ↓) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Monotonic-AlignerNFAWhisperXQwen3-ForcedAligner-0.6B
    MFA-Labeled Raw
    Chinese161.1109.8-33.1
    English-107.592.137.5
    French-100.7145.341.7
    German-122.7165.146.5
    Italian-142.7155.575.5
    Japanese---42.2
    Korean---37.2
    Portuguese---38.4
    Russian-200.7-40.2
    Spanish-124.7108.036.8
    Avg.161.1129.8133.242.9
    MFA-Labeled Concat-300s
    Chinese1742.4235.0-36.5
    English-226.7227.258.6
    French-230.62052.253.4
    German-220.3993.462.4
    Italian-290.55719.481.6
    Japanese---81.3
    Korean---42.2
    Portuguese---50.0
    Russian-283.3-43.0
    Spanish-240.24549.939.6
    Cross-lingual---34.2
    Avg.1742.4246.72708.452.9
    Human-Labeled
    Raw49.988.6-27.8
    Raw-Noisy53.389.5-41.8
    Concat-60s51.186.7-25.3
    Concat-300s410.8140.0-24.8
    Concat-Cross-lingual---42.5
    Avg.141.3101.2-32.4
    + +
    + + +## Citation + +If you find our paper and code useful in your research, please consider giving a star :star: and citation :pencil: :) + +```BibTeX +@article{Qwen3-ASR, + title={Qwen3-ASR Technical Report}, + author={Xian Shi, Xiong Wang, Zhifang Guo, Yongqi Wang, Pei Zhang, Xinyu Zhang, Zishan Guo, Hongkun Hao, Yu Xi, Baosong Yang, Jin Xu, Jingren Zhou, Junyang Lin}, + journal={arXiv preprint arXiv:2601.21337}, + year={2026} +} +``` + + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=QwenLM/Qwen3-ASR&type=Date)](https://star-history.com/#QwenLM/Qwen3-ASR&Date) + +
    \ No newline at end of file diff --git a/vendor/qwen-asr/pyproject.toml b/vendor/qwen-asr/pyproject.toml new file mode 100644 index 00000000..b2daa0de --- /dev/null +++ b/vendor/qwen-asr/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "qwen-asr" +version = "0.0.6" +description = "Qwen-ASR python package" +readme = "README.md" +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +license = { text = "Apache-2.0" } +authors = [{ name = "Alibaba Qwen Team" }] + +dependencies = [ + "transformers==4.57.6", + "nagisa==0.2.11", + "soynlp==0.0.493", + "accelerate==1.12.0", + "qwen-omni-utils", + "librosa", + "soundfile", + "sox", + "flask", + "pytz", +] + +[project.optional-dependencies] +# Amadeus packaging patch: UI demo dependencies are not inference requirements. +demo = ["gradio>=6.16.0,<7"] +vllm = [ + "vllm==0.14.0", +] + +[project.urls] +Homepage = "https://github.com/Qwen/Qwen3-ASR" +Repository = "https://github.com/Qwen/Qwen3-ASR" + +[project.scripts] +qwen-asr-demo = "qwen_asr.cli.demo:main" +qwen-asr-demo-streaming = "qwen_asr.cli.demo_streaming:main" +qwen-asr-serve = "qwen_asr.cli.serve:main" + +[tool.setuptools] +packages = { find = { where = ["."] , include = ["qwen_asr*"] } } +include-package-data = true + +[tool.setuptools.package-data] +qwen_tts = ["py.typed", "**/*.dict"] \ No newline at end of file diff --git a/vendor/qwen-asr/qwen_asr/__init__.py b/vendor/qwen-asr/qwen_asr/__init__.py new file mode 100644 index 00000000..b8f0320e --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/__init__.py @@ -0,0 +1,25 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +""" +qwen_asr: Qwen3-ASR package. +""" + +from .inference.qwen3_asr import Qwen3ASRModel +from .inference.qwen3_forced_aligner import Qwen3ForcedAligner + +from .inference.utils import parse_asr_output + +__all__ = ["__version__"] diff --git a/vendor/qwen-asr/qwen_asr/__main__.py b/vendor/qwen-asr/qwen_asr/__main__.py new file mode 100644 index 00000000..91bf6fbf --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/__main__.py @@ -0,0 +1,26 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +def main(): + print( + "qwen_asr package.\n" + "Use CLI entrypoints:\n" + " - qwen-asr-demo\n" + " - qwen-asr-demo-streaming\n" + " - qwen-asr-serve\n" + ) + +if __name__ == "__main__": + main() diff --git a/vendor/qwen-asr/qwen_asr/cli/demo.py b/vendor/qwen-asr/qwen_asr/cli/demo.py new file mode 100644 index 00000000..5f93ba5c --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/cli/demo.py @@ -0,0 +1,536 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +""" +A gradio demo for Qwen3 ASR models. +""" + +import argparse +import base64 +import io +import json +import os +from typing import Any, Dict, List, Optional, Tuple, Union + +import gradio as gr +import numpy as np +import torch +from qwen_asr import Qwen3ASRModel +from scipy.io.wavfile import write as wav_write + + +def _title_case_display(s: str) -> str: + s = (s or "").strip() + s = s.replace("_", " ") + return " ".join([w[:1].upper() + w[1:] if w else "" for w in s.split()]) + + +def _build_choices_and_map(items: Optional[List[str]]) -> Tuple[List[str], Dict[str, str]]: + if not items: + return [], {} + display = [_title_case_display(x) for x in items] + mapping = {d: r for d, r in zip(display, items)} + return display, mapping + + +def _dtype_from_str(s: str) -> torch.dtype: + s = (s or "").strip().lower() + if s in ("bf16", "bfloat16"): + return torch.bfloat16 + if s in ("fp16", "float16", "half"): + return torch.float16 + if s in ("fp32", "float32"): + return torch.float32 + raise ValueError(f"Unsupported torch dtype: {s}. Use bfloat16/float16/float32.") + + +def _normalize_audio(wav, eps=1e-12, clip=True): + x = np.asarray(wav) + + if np.issubdtype(x.dtype, np.integer): + info = np.iinfo(x.dtype) + if info.min < 0: + y = x.astype(np.float32) / max(abs(info.min), info.max) + else: + mid = (info.max + 1) / 2.0 + y = (x.astype(np.float32) - mid) / mid + elif np.issubdtype(x.dtype, np.floating): + y = x.astype(np.float32) + m = np.max(np.abs(y)) if y.size else 0.0 + if m > 1.0 + 1e-6: + y = y / (m + eps) + else: + raise TypeError(f"Unsupported dtype: {x.dtype}") + + if clip: + y = np.clip(y, -1.0, 1.0) + + if y.ndim > 1: + y = np.mean(y, axis=-1).astype(np.float32) + + return y + + +def _audio_to_tuple(audio: Any) -> Optional[Tuple[np.ndarray, int]]: + """ + Accept gradio audio: + - {"sampling_rate": int, "data": np.ndarray} + - (sr, np.ndarray) [some gradio versions] + Return: (wav_float32_mono, sr) + """ + if audio is None: + return None + + if isinstance(audio, dict) and "sampling_rate" in audio and "data" in audio: + sr = int(audio["sampling_rate"]) + wav = _normalize_audio(audio["data"]) + return wav, sr + + if isinstance(audio, tuple) and len(audio) == 2: + a0, a1 = audio + if isinstance(a0, int): + sr = int(a0) + wav = _normalize_audio(a1) + return wav, sr + if isinstance(a1, int): + wav = _normalize_audio(a0) + sr = int(a1) + return wav, sr + + return None + + +def _parse_audio_any(audio: Any) -> Union[str, Tuple[np.ndarray, int]]: + if audio is None: + raise ValueError("Audio is required.") + at = _audio_to_tuple(audio) + if at is not None: + return at + raise ValueError("Unsupported audio input format.") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="qwen-asr-demo", + description=( + "Launch a Gradio demo for Qwen3 ASR models (Transformers / vLLM).\n\n" + "Examples:\n" + " qwen-asr-demo --asr-checkpoint Qwen/Qwen3-ASR-1.7B\n" + " qwen-asr-demo --asr-checkpoint Qwen/Qwen3-ASR-1.7B --aligner-checkpoint Qwen/Qwen3-ForcedAligner-0.6B\n" + " qwen-asr-demo --backend vllm --cuda-visible-devices 0\n" + " qwen-asr-demo --backend transformers --backend-kwargs '{\"device_map\":\"cuda:0\",\"dtype\":\"bfloat16\",\"attn_implementation\":\"flash_attention_2\"}'\n" + " qwen-asr-demo --backend vllm --backend-kwargs '{\"gpu_memory_utilization\":0.85}'\n" + ), + formatter_class=argparse.RawTextHelpFormatter, + add_help=True, + ) + + parser.add_argument("--asr-checkpoint", required=True, help="Qwen3-ASR model checkpoint path or HF repo id.") + parser.add_argument( + "--aligner-checkpoint", + default=None, + help="Qwen3-ForcedAligner checkpoint path or HF repo id (optional; enables timestamps when provided).", + ) + + parser.add_argument( + "--backend", + default="transformers", + choices=["transformers", "vllm"], + help="Backend for ASR model loading (default: transformers).", + ) + + parser.add_argument( + "--cuda-visible-devices", + default="0", + help=( + "Set CUDA_VISIBLE_DEVICES for the demo process (default: 0). " + "Use e.g. '0' or '1'" + ), + ) + + parser.add_argument( + "--backend-kwargs", + default=None, + help=( + "JSON dict for backend-specific kwargs excluding checkpoints.\n" + "Examples:\n" + " transformers: '{\"device_map\":\"cuda:0\",\"dtype\":\"bfloat16\",\"attn_implementation\":\"flash_attention_2\",\"max_inference_batch_size\":32}'\n" + " vllm : '{\"gpu_memory_utilization\":0.8,\"max_inference_batch_size\":32}'\n" + ), + ) + parser.add_argument( + "--aligner-kwargs", + default=None, + help=( + "JSON dict for forced aligner kwargs (only used when --aligner-checkpoint is set).\n" + "Example: '{\"dtype\":\"bfloat16\",\"device_map\":\"cuda:0\"}'\n" + ), + ) + + # Gradio server args + parser.add_argument("--ip", default="0.0.0.0", help="Server bind IP for Gradio (default: 0.0.0.0).") + parser.add_argument("--port", type=int, default=8000, help="Server port for Gradio (default: 8000).") + parser.add_argument( + "--share/--no-share", + dest="share", + default=False, + action=argparse.BooleanOptionalAction, + help="Whether to create a public Gradio link (default: disabled).", + ) + parser.add_argument("--concurrency", type=int, default=16, help="Gradio queue concurrency (default: 16).") + + # HTTPS args + parser.add_argument("--ssl-certfile", default=None, help="Path to SSL certificate file for HTTPS (optional).") + parser.add_argument("--ssl-keyfile", default=None, help="Path to SSL key file for HTTPS (optional).") + parser.add_argument( + "--ssl-verify/--no-ssl-verify", + dest="ssl_verify", + default=True, + action=argparse.BooleanOptionalAction, + help="Whether to verify SSL certificate (default: enabled).", + ) + + return parser + + +def _parse_json_dict(s: Optional[str], *, name: str) -> Dict[str, Any]: + if s is None or not str(s).strip(): + return {} + try: + obj = json.loads(s) + except Exception as e: + raise ValueError(f"Invalid JSON for {name}: {e}") + if not isinstance(obj, dict): + raise ValueError(f"{name} must be a JSON object (dict).") + return obj + + +def _apply_cuda_visible_devices(cuda_visible_devices: str) -> None: + v = (cuda_visible_devices or "").strip() + if not v: + return + os.environ["CUDA_VISIBLE_DEVICES"] = v + + +def _default_backend_kwargs(backend: str) -> Dict[str, Any]: + if backend == "transformers": + return dict( + dtype=torch.bfloat16, + device_map="cuda:0", + max_inference_batch_size=4, + max_new_tokens=512, + ) + else: + return dict( + gpu_memory_utilization=0.8, + max_inference_batch_size=4, + max_new_tokens=4096, + ) + + +def _default_aligner_kwargs() -> Dict[str, Any]: + return dict( + dtype=torch.bfloat16, + device_map="cuda:0", + ) + + +def _merge_dicts(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: + out = dict(base) + out.update(override) + return out + + +def _coerce_special_types(d: Dict[str, Any]) -> Dict[str, Any]: + out: Dict[str, Any] = {} + for k, v in d.items(): + if k == "dtype" and isinstance(v, str): + out[k] = _dtype_from_str(v) + else: + out[k] = v + return out + + +def _make_timestamp_html(audio_upload: Any, timestamps: Any) -> str: + """ + Build HTML with per-token audio slices, using base64 data URLs. + Expect timestamps as list[dict] with keys: text, start_time, end_time (ms). + """ + at = _audio_to_tuple(audio_upload) + if at is None: + raise ValueError("Audio input is required for visualization.") + audio, sr = at + + if not timestamps: + return "
    No timestamps to visualize.
    " + if not isinstance(timestamps, list): + raise ValueError("Timestamps must be a list (JSON array).") + + html_content = """ + + """ + + html_content += """ +
    + Timestamps Visualization (时间戳可视化结果) +
    + """ + + for item in timestamps: + if not isinstance(item, dict): + continue + word = str(item.get("text", "") or "") + start = item.get("start_time", None) + end = item.get("end_time", None) + if start is None or end is None: + continue + + start = float(start) + end = float(end) + if end <= start: + continue + + start_sample = max(0, int(start * sr)) + end_sample = min(len(audio), int(end * sr)) + if end_sample <= start_sample: + continue + + seg = audio[start_sample:end_sample] + seg_i16 = (np.clip(seg, -1.0, 1.0) * 32767.0).astype(np.int16) + + mem = io.BytesIO() + wav_write(mem, sr, seg_i16) + mem.seek(0) + b64 = base64.b64encode(mem.read()).decode("utf-8") + audio_src = f"data:audio/wav;base64,{b64}" + + html_content += f""" +
    +
    {word}
    +
    {start} - {end} s
    +
    + +
    +
    + """ + + html_content += "
    " + return html_content + + +def build_demo( + asr: Qwen3ASRModel, + asr_ckpt: str, + backend: str, + aligner_ckpt: Optional[str] = None, +) -> gr.Blocks: + supported_langs_raw = asr.get_supported_languages() + lang_choices_disp, lang_map = _build_choices_and_map([x for x in supported_langs_raw]) + lang_choices = ["Auto"] + lang_choices_disp + + has_aligner = bool(aligner_ckpt) + + theme = gr.themes.Soft( + font=[gr.themes.GoogleFont("Source Sans Pro"), "Arial", "sans-serif"], + ) + css = ".gradio-container {max-width: none !important;}" + + with gr.Blocks(theme=theme, css=css) as demo: + gr.Markdown( + f""" +# Qwen3 ASR Demo +**Backend:** `{backend}` +**ASR Checkpoint:** `{asr_ckpt}` +**Forced Aligner:** `{aligner_ckpt if aligner_ckpt else "(none)"}` +""" + ) + + with gr.Row(): + with gr.Column(scale=2): + audio_in = gr.Audio(label="Audio Input (上传音频)", type="numpy") + lang_in = gr.Dropdown( + label="Language (语种)", + choices=lang_choices, + value="Auto", + interactive=True, + ) + if has_aligner: + ts_in = gr.Checkbox( + label="Return Timestamps (是否返回时间戳)", + value=True, + ) + else: + ts_in = gr.State(False) + + btn = gr.Button("Transcribe (识别)", variant="primary") + + with gr.Column(scale=2): + out_lang = gr.Textbox(label="Detected Language", lines=1) + out_text = gr.Textbox(label="Result Text", lines=12) + + if has_aligner: + with gr.Column(scale=3): + out_ts = gr.JSON(label="Timestamps(时间戳结果)") + viz_btn = gr.Button("Visualize Timestamps (可视化时间戳)", variant="secondary") + else: + with gr.Column(scale=3): + out_ts = gr.State(None) + viz_btn = gr.State(None) + + # Put the visualization panel below the three columns + if has_aligner: + with gr.Row(): + out_ts_html = gr.HTML(label="Timestamps Visualization (时间戳可视化结果)") + else: + out_ts_html = gr.State("") + + def run(audio_upload: Any, lang_disp: str, return_ts: bool): + audio_obj = _parse_audio_any(audio_upload) + + language = None + if lang_disp and lang_disp != "Auto": + language = lang_map.get(lang_disp, lang_disp) + + return_ts = bool(return_ts) and has_aligner + + results = asr.transcribe( + audio=audio_obj, + language=language, + return_time_stamps=return_ts, + ) + if not isinstance(results, list) or len(results) != 1: + raise RuntimeError( + f"Unexpected result size: {type(results)} " + f"len={len(results) if isinstance(results, list) else 'N/A'}" + ) + + r = results[0] + + if has_aligner: + ts_payload = None + if return_ts: + ts_payload = [ + dict( + text=getattr(t, "text", None), + start_time=getattr(t, "start_time", None), + end_time=getattr(t, "end_time", None), + ) + for t in (getattr(r, "time_stamps", None) or []) + ] + return ( + getattr(r, "language", "") or "", + getattr(r, "text", "") or "", + gr.update(value=ts_payload) if return_ts else gr.update(value=None), + gr.update(value=""), # clear html on each transcribe + ) + else: + return ( + getattr(r, "language", "") or "", + getattr(r, "text", "") or "", + ) + + def visualize(audio_upload: Any, timestamps_json: Any): + return _make_timestamp_html(audio_upload, timestamps_json) + + if has_aligner: + btn.click( + run, + inputs=[audio_in, lang_in, ts_in], + outputs=[out_lang, out_text, out_ts, out_ts_html], + ) + viz_btn.click( + visualize, + inputs=[audio_in, out_ts], + outputs=[out_ts_html], + ) + else: + btn.click( + run, + inputs=[audio_in, lang_in, ts_in], + outputs=[out_lang, out_text], + ) + + return demo + + +def main(argv=None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + _apply_cuda_visible_devices(args.cuda_visible_devices) + + backend = args.backend + asr_ckpt = args.asr_checkpoint + aligner_ckpt = args.aligner_checkpoint + + user_backend_kwargs = _parse_json_dict(args.backend_kwargs, name="--backend-kwargs") + user_aligner_kwargs = _parse_json_dict(args.aligner_kwargs, name="--aligner-kwargs") + + backend_kwargs = _merge_dicts(_default_backend_kwargs(backend), user_backend_kwargs) + backend_kwargs = _coerce_special_types(backend_kwargs) + + forced_aligner = None + forced_aligner_kwargs = None + if aligner_ckpt: + forced_aligner = aligner_ckpt + aligner_kwargs = _merge_dicts(_default_aligner_kwargs(), user_aligner_kwargs) + forced_aligner_kwargs = _coerce_special_types(aligner_kwargs) + + if backend == "transformers": + asr = Qwen3ASRModel.from_pretrained( + asr_ckpt, + forced_aligner=forced_aligner, + forced_aligner_kwargs=forced_aligner_kwargs, + **backend_kwargs, + ) + else: + asr = Qwen3ASRModel.LLM( + model=asr_ckpt, + forced_aligner=forced_aligner, + forced_aligner_kwargs=forced_aligner_kwargs, + **backend_kwargs, + ) + + demo = build_demo(asr, asr_ckpt, backend, aligner_ckpt=aligner_ckpt) + + launch_kwargs: Dict[str, Any] = dict( + server_name=args.ip, + server_port=args.port, + share=args.share, + ssl_verify=True if args.ssl_verify else False, + ) + if args.ssl_certfile is not None: + launch_kwargs["ssl_certfile"] = args.ssl_certfile + if args.ssl_keyfile is not None: + launch_kwargs["ssl_keyfile"] = args.ssl_keyfile + + demo.queue(default_concurrency_limit=int(args.concurrency)).launch(**launch_kwargs) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/qwen-asr/qwen_asr/cli/demo_streaming.py b/vendor/qwen-asr/qwen_asr/cli/demo_streaming.py new file mode 100644 index 00000000..ca6da637 --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/cli/demo_streaming.py @@ -0,0 +1,507 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +""" +Minimal web demo for Qwen3ASRModel Streaming Inference (vLLM backend). + +Install: + pip install qwen-asr[vllm] + +Run: + python streaming/demo_qwen3_asr_vllm_streaming.py +Open: + http://127.0.0.1:7860 +""" +import argparse +import time +import uuid +from dataclasses import dataclass +from typing import Dict, Optional + +import numpy as np +from flask import Flask, Response, jsonify, request +from qwen_asr import Qwen3ASRModel + + +@dataclass +class Session: + state: object + created_at: float + last_seen: float + + +app = Flask(__name__) + +global asr +global UNFIXED_CHUNK_NUM +global UNFIXED_TOKEN_NUM +global CHUNK_SIZE_SEC + +SESSIONS: Dict[str, Session] = {} +SESSION_TTL_SEC = 10 * 60 + + +def _gc_sessions(): + now = time.time() + dead = [sid for sid, s in SESSIONS.items() if now - s.last_seen > SESSION_TTL_SEC] + for sid in dead: + try: + asr.finish_streaming_transcribe(SESSIONS[sid].state) + except Exception: + pass + SESSIONS.pop(sid, None) + + +def _get_session(session_id: str) -> Optional[Session]: + _gc_sessions() + s = SESSIONS.get(session_id) + if s: + s.last_seen = time.time() + return s + + +INDEX_HTML = r""" + + + + + Qwen3-ASR Streaming + + + +
    +
    +

    Qwen3-ASR Streaming

    + +
    + + + Idle / 未开始 + Clear / 清空 +
    + +
    +
    Language / 语言
    +
    —
    +
    + +
    +
    Text / 文本
    +
    +
    +
    +
    + + + + +""" + + +@app.get("/") +def index(): + return Response(INDEX_HTML, mimetype="text/html; charset=utf-8") + + +@app.post("/api/start") +def api_start(): + session_id = uuid.uuid4().hex + state = asr.init_streaming_state( + unfixed_chunk_num=UNFIXED_CHUNK_NUM, + unfixed_token_num=UNFIXED_TOKEN_NUM, + chunk_size_sec=CHUNK_SIZE_SEC, + ) + now = time.time() + SESSIONS[session_id] = Session(state=state, created_at=now, last_seen=now) + return jsonify({"session_id": session_id}) + + +@app.post("/api/chunk") +def api_chunk(): + session_id = request.args.get("session_id", "") + s = _get_session(session_id) + if not s: + return jsonify({"error": "invalid session_id"}), 400 + + if request.mimetype != "application/octet-stream": + return jsonify({"error": "expect application/octet-stream"}), 400 + + raw = request.get_data(cache=False) + if len(raw) % 4 != 0: + return jsonify({"error": "float32 bytes length not multiple of 4"}), 400 + + wav = np.frombuffer(raw, dtype=np.float32).reshape(-1) + + asr.streaming_transcribe(wav, s.state) + + return jsonify( + { + "language": getattr(s.state, "language", "") or "", + "text": getattr(s.state, "text", "") or "", + } + ) + + +@app.post("/api/finish") +def api_finish(): + session_id = request.args.get("session_id", "") + s = _get_session(session_id) + if not s: + return jsonify({"error": "invalid session_id"}), 400 + + asr.finish_streaming_transcribe(s.state) + out = { + "language": getattr(s.state, "language", "") or "", + "text": getattr(s.state, "text", "") or "", + } + SESSIONS.pop(session_id, None) + return jsonify(out) + + +def parse_args(): + p = argparse.ArgumentParser(description="Qwen3-ASR Streaming Web Demo (vLLM backend)") + p.add_argument("--asr-model-path", default="Qwen/Qwen3-ASR-1.7B", help="Model name or local path") + p.add_argument("--host", default="0.0.0.0", help="Bind host") + p.add_argument("--port", type=int, default=8000, help="Bind port") + p.add_argument("--gpu-memory-utilization", type=float, default=0.8, help="vLLM GPU memory utilization") + + p.add_argument("--unfixed-chunk-num", type=int, default=4) + p.add_argument("--unfixed-token-num", type=int, default=5) + p.add_argument("--chunk-size-sec", type=float, default=1.0) + return p.parse_args() + + +def main(): + args = parse_args() + + global asr + global UNFIXED_CHUNK_NUM + global UNFIXED_TOKEN_NUM + global CHUNK_SIZE_SEC + + UNFIXED_CHUNK_NUM = args.unfixed_chunk_num + UNFIXED_TOKEN_NUM = args.unfixed_token_num + CHUNK_SIZE_SEC = args.chunk_size_sec + + asr = Qwen3ASRModel.LLM( + model=args.asr_model_path, + gpu_memory_utilization=args.gpu_memory_utilization, + max_new_tokens=32, + ) + print("Model loaded.") + app.run(host=args.host, port=args.port, debug=False, use_reloader=False, threaded=True) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/vendor/qwen-asr/qwen_asr/cli/serve.py b/vendor/qwen-asr/qwen_asr/cli/serve.py new file mode 100644 index 00000000..af0961c0 --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/cli/serve.py @@ -0,0 +1,46 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +import sys + +from qwen_asr.core.transformers_backend import ( + Qwen3ASRConfig, + Qwen3ASRForConditionalGeneration, + Qwen3ASRProcessor, +) +from transformers import AutoConfig, AutoModel, AutoProcessor + +AutoConfig.register("qwen3_asr", Qwen3ASRConfig) +AutoModel.register(Qwen3ASRConfig, Qwen3ASRForConditionalGeneration) +AutoProcessor.register(Qwen3ASRConfig, Qwen3ASRProcessor) + +try: + from qwen_asr.core.vllm_backend import Qwen3ASRForConditionalGeneration + from vllm import ModelRegistry + ModelRegistry.register_model("Qwen3ASRForConditionalGeneration", Qwen3ASRForConditionalGeneration) +except Exception as e: + raise ImportError( + "vLLM is not available, to use qwen-asr-serve, please install with: pip install qwen-asr[vllm]" + ) from e + +from vllm.entrypoints.cli.main import main as vllm_main + +def main(): + sys.argv.insert(1, "serve") + vllm_main() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/vendor/qwen-asr/qwen_asr/core/transformers_backend/__init__.py b/vendor/qwen-asr/qwen_asr/core/transformers_backend/__init__.py new file mode 100644 index 00000000..375ae1de --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/core/transformers_backend/__init__.py @@ -0,0 +1,18 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +from .configuration_qwen3_asr import Qwen3ASRConfig +from .modeling_qwen3_asr import Qwen3ASRForConditionalGeneration +from .processing_qwen3_asr import Qwen3ASRProcessor \ No newline at end of file diff --git a/vendor/qwen-asr/qwen_asr/core/transformers_backend/configuration_qwen3_asr.py b/vendor/qwen-asr/qwen_asr/core/transformers_backend/configuration_qwen3_asr.py new file mode 100644 index 00000000..17d7263a --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/core/transformers_backend/configuration_qwen3_asr.py @@ -0,0 +1,425 @@ +# coding=utf-8 +# Copyright 2026 The Qwen team, Alibaba Group and the HuggingFace Inc. team. All rights reserved. +# +# 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. +from transformers.configuration_utils import PretrainedConfig +from transformers.utils import logging + + +logger = logging.get_logger(__name__) + + +class Qwen3ASRAudioEncoderConfig(PretrainedConfig): + r""" + This is the configuration class to store the configuration of a [`Qwen3ASRAudioEncoder`]. It is used to instantiate a + Qwen3-ASR audio encoder according to the specified arguments, defining the model architecture. Instantiating a + configuration with the defaults will yield a similar configuration to that of the audio encoder of the Qwen2-Audio + architecture. + + e.g. [Qwen/Qwen3-ASR-1.7B](https://huggingface.co/Qwen/Qwen3-ASR-1.7B) + + Configuration objects inherit from [`PretrainedConfig`] and can be used to control the model outputs. Read the + documentation from [`PretrainedConfig`] for more information. + + Args: + num_mel_bins (`int`, *optional*, defaults to 128): + Number of mel features used per input features. Should correspond to the value used in the + `Qwen3ASRProcessor` class. + encoder_layers (`int`, *optional*, defaults to 32): + Number of encoder layers. + encoder_attention_heads (`int`, *optional*, defaults to 20): + Number of attention heads for each attention layer in the Transformer encoder. + encoder_ffn_dim (`int`, *optional*, defaults to 5120): + Dimensionality of the "intermediate" (often named feed-forward) layer in encoder. + d_model (`int`, *optional*, defaults to 1280): + Dimensionality of the layers. + dropout (`float`, *optional*, defaults to 0.0): + The dropout probability for all fully connected layers in the embeddings, encoder, and pooler. + attention_dropout (`float`, *optional*, defaults to 0.0): + The dropout ratio for the attention probabilities. + activation_function (`str`, *optional*, defaults to `"gelu"`): + The non-linear activation function (function or string) in the encoder and pooler. If string, `"gelu"`, + `"relu"`, `"silu"` and `"gelu_new"` are supported. + activation_dropout (`float`, *optional*, defaults to 0.0): + The dropout ratio for activations inside the fully connected layer. + scale_embedding (`bool`, *optional*, defaults to `False`): + Scale embeddings by diving by sqrt(d_model). + initializer_range (`float`, *optional*, defaults to 0.02): + The standard deviation of the truncated_normal_initializer for initializing all weight matrices. + max_source_positions (`int`, *optional*, defaults to 1500): + The maximum sequence length of log-mel filter-bank features that this model might ever be used with. + n_window (`int`, *optional*, defaults to 100): + The chunk for conv and flash attn in AudioEncoder. + output_dim (`int`, *optional*, defaults to 3584): + The output dimension of AudioEncoder. + + Example: + + ```python + >>> from transformers import Qwen3ASRAudioEncoderConfig, Qwen3ASRAudioEncoder + + >>> # Initializing a Qwen3ASRAudioEncoderConfig + >>> configuration = Qwen3ASRAudioEncoderConfig() + + >>> # Initializing a Qwen3ASRAudioEncoder (with random weights) + >>> model = Qwen3ASRAudioEncoder(configuration) + + >>> # Accessing the model configuration + >>> configuration = model.config + ```""" + + model_type = "qwen3_asr_audio_encoder" + + def __init__( + self, + num_mel_bins=128, + encoder_layers=32, + encoder_attention_heads=20, + encoder_ffn_dim=5120, + d_model=1280, + dropout=0, + attention_dropout=0, + activation_function="gelu", + activation_dropout=0, + scale_embedding=False, + initializer_range=0.02, + max_source_positions=1500, + n_window=100, + output_dim=3584, + n_window_infer=400, + conv_chunksize=500, + downsample_hidden_size=480, + **kwargs, + ): + super().__init__(**kwargs) + + self.num_mel_bins = num_mel_bins + self.d_model = d_model + self.encoder_layers = encoder_layers + self.encoder_attention_heads = encoder_attention_heads + self.encoder_ffn_dim = encoder_ffn_dim + self.dropout = dropout + self.attention_dropout = attention_dropout + self.activation_function = activation_function + self.activation_dropout = activation_dropout + self.num_hidden_layers = encoder_layers + self.initializer_range = initializer_range + self.scale_embedding = scale_embedding # scale factor will be sqrt(d_model) if True + self.max_source_positions = max_source_positions + self.n_window = n_window + self.output_dim = output_dim + self.n_window_infer = n_window_infer + self.conv_chunksize = conv_chunksize + self.downsample_hidden_size = downsample_hidden_size + + +class Qwen3ASRTextConfig(PretrainedConfig): + r""" + This is the configuration class to store the configuration of a [`Qwen3ASRTextModel`]. It is used to instantiate a + Qwen3-ASR model according to the specified arguments, defining the model architecture. Instantiating a configuration + with the defaults will yield a similar configuration to that of + Qwen3-ASR-1.7B [Qwen/Qwen3-ASR-1.7B](https://huggingface.co/Qwen/Qwen3-ASR-1.7B) + + Configuration objects inherit from [`PretrainedConfig`] and can be used to control the model outputs. Read the + documentation from [`PretrainedConfig`] for more information. + + Args: + vocab_size (`int`, *optional*, defaults to 151936): + Vocabulary size of the Qwen3ASR model. Defines the number of different tokens that can be represented by the + `inputs_ids` passed when calling [`Qwen3ASRModel`] + hidden_size (`int`, *optional*, defaults to 4096): + Dimension of the hidden representations. + intermediate_size (`int`, *optional*, defaults to 22016): + Dimension of the MLP representations. + num_hidden_layers (`int`, *optional*, defaults to 32): + Number of hidden layers in the Transformer encoder. + num_attention_heads (`int`, *optional*, defaults to 32): + Number of attention heads for each attention layer in the Transformer encoder. + num_key_value_heads (`int`, *optional*, defaults to 32): + This is the number of key_value heads that should be used to implement Grouped Query Attention. If + `num_key_value_heads=num_attention_heads`, the model will use Multi Head Attention (MHA), if + `num_key_value_heads=1` the model will use Multi Query Attention (MQA) otherwise GQA is used. When + converting a multi-head checkpoint to a GQA checkpoint, each group key and value head should be constructed + by meanpooling all the original heads within that group. For more details, check out [this + paper](https://huggingface.co/papers/2305.13245). If it is not specified, will default to `32`. + head_dim (`int`, *optional*, defaults to 128): + The dimension of the head. If not specified, will default to `hidden_size // num_attention_heads`. + hidden_act (`str` or `function`, *optional*, defaults to `"silu"`): + The non-linear activation function (function or string) in the decoder. + max_position_embeddings (`int`, *optional*, defaults to 128000): + The maximum sequence length that this model might ever be used with. + initializer_range (`float`, *optional*, defaults to 0.02): + The standard deviation of the truncated_normal_initializer for initializing all weight matrices. + rms_norm_eps (`float`, *optional*, defaults to 1e-06): + The epsilon used by the rms normalization layers. + use_cache (`bool`, *optional*, defaults to `True`): + Whether or not the model should return the last key/values attentions (not used by all models). Only + relevant if `config.is_decoder=True`. + tie_word_embeddings (`bool`, *optional*, defaults to `False`): + Whether the model's input and output word embeddings should be tied. + rope_theta (`float`, *optional*, defaults to 5000000.0): + The base period of the RoPE embeddings. + rope_scaling (`Dict`, *optional*): + Dictionary containing the scaling configuration for the RoPE embeddings. NOTE: if you apply new rope type + and you expect the model to work on longer `max_position_embeddings`, we recommend you to update this value + accordingly. + Expected contents: + `rope_type` (`str`): + The sub-variant of RoPE to use. Can be one of ['default', 'linear', 'dynamic', 'yarn', 'longrope', + 'llama3'], with 'default' being the original RoPE implementation. + `factor` (`float`, *optional*): + Used with all rope types except 'default'. The scaling factor to apply to the RoPE embeddings. In + most scaling types, a `factor` of x will enable the model to handle sequences of length x * + original maximum pre-trained length. + `original_max_position_embeddings` (`int`, *optional*): + Used with 'dynamic', 'longrope' and 'llama3'. The original max position embeddings used during + pretraining. + `attention_factor` (`float`, *optional*): + Used with 'yarn' and 'longrope'. The scaling factor to be applied on the attention + computation. If unspecified, it defaults to value recommended by the implementation, using the + `factor` field to infer the suggested value. + `beta_fast` (`float`, *optional*): + Only used with 'yarn'. Parameter to set the boundary for extrapolation (only) in the linear + ramp function. If unspecified, it defaults to 32. + `beta_slow` (`float`, *optional*): + Only used with 'yarn'. Parameter to set the boundary for interpolation (only) in the linear + ramp function. If unspecified, it defaults to 1. + `short_factor` (`list[float]`, *optional*): + Only used with 'longrope'. The scaling factor to be applied to short contexts (< + `original_max_position_embeddings`). Must be a list of numbers with the same length as the hidden + size divided by the number of attention heads divided by 2 + `long_factor` (`list[float]`, *optional*): + Only used with 'longrope'. The scaling factor to be applied to long contexts (< + `original_max_position_embeddings`). Must be a list of numbers with the same length as the hidden + size divided by the number of attention heads divided by 2 + `low_freq_factor` (`float`, *optional*): + Only used with 'llama3'. Scaling factor applied to low frequency components of the RoPE + `high_freq_factor` (`float`, *optional*): + Only used with 'llama3'. Scaling factor applied to high frequency components of the RoPE + attention_bias (`bool`, defaults to `False`, *optional*, defaults to `False`): + Whether to use a bias in the query, key, value and output projection layers during self-attention. + attention_dropout (`float`, *optional*, defaults to 0.0): + The dropout ratio for the attention probabilities. + + ```python + >>> from transformers import Qwen3ASRTextModel, Qwen3ASRTextConfig + + >>> # Initializing a Qwen3ASR style configuration + >>> configuration = Qwen3ASRTextConfig() + + >>> # Initializing a model from the Qwen3-VL-7B style configuration + >>> model = Qwen3ASRTextModel(configuration) + + >>> # Accessing the model configuration + >>> configuration = model.config + ```""" + + model_type = "qwen3_asr_text" + base_config_key = "text_config" + + def __init__( + self, + vocab_size=151936, + hidden_size=4096, + intermediate_size=22016, + num_hidden_layers=32, + num_attention_heads=32, + num_key_value_heads=32, + head_dim=128, + hidden_act="silu", + max_position_embeddings=128000, + initializer_range=0.02, + rms_norm_eps=1e-6, + use_cache=True, + tie_word_embeddings=False, + rope_theta=5000000.0, + rope_scaling=None, + attention_bias=False, + attention_dropout=0.0, + **kwargs, + ): + self.vocab_size = vocab_size + self.max_position_embeddings = max_position_embeddings + self.hidden_size = hidden_size + self.intermediate_size = intermediate_size + self.num_hidden_layers = num_hidden_layers + self.num_attention_heads = num_attention_heads + + # for backward compatibility + if num_key_value_heads is None: + num_key_value_heads = num_attention_heads + + self.num_key_value_heads = num_key_value_heads + self.head_dim = head_dim + self.hidden_act = hidden_act + self.initializer_range = initializer_range + self.rms_norm_eps = rms_norm_eps + self.use_cache = use_cache + self.rope_theta = rope_theta + self.rope_scaling = rope_scaling + self.attention_bias = attention_bias + self.attention_dropout = attention_dropout + # Validate the correctness of rotary position embeddings parameters + # BC: if there is a 'type' field, move it to 'rope_type'. + if self.rope_scaling is not None and "type" in self.rope_scaling: + self.rope_scaling["rope_type"] = self.rope_scaling["type"] + + super().__init__(tie_word_embeddings=tie_word_embeddings, **kwargs) + + +class Qwen3ASRThinkerConfig(PretrainedConfig): + r""" + This is the configuration class to store the configuration of a [`Qwen3ASRThinker`]. It is used to instantiate a + Qwen3-ASR-Thinker model according to the specified arguments, defining the model architecture. Instantiating a + configuration with the defaults will yield a similar configuration to that of the thinker component of the Qwen3-Omni + architecture. + + e.g. [Qwen/Qwen3-ASR-1.7B](https://huggingface.co/Qwen/Qwen3-ASR-1.7B) + + Configuration objects inherit from [`PretrainedConfig`] and can be used to control the model outputs. Read the + documentation from [`PretrainedConfig`] for more information. + + Args: + audio_config (`dict`, *optional*): + The config dictionary of the audio backbone. + text_config (`dict`, *optional*): + The config dictionary of the text backbone. + audio_token_id (`int`, *optional*, defaults to 151646): + The audio token id to encode the audio prompt. + audio_start_token_id (`int`, *optional*, defaults to 151647): + The audio start token id to encode the audio prompt. + user_token_id (`int`, *optional*, defaults to 872): + The user token id to encode the user token. + initializer_range (`float`, *optional*, defaults to 0.02): + The standard deviation of the truncated_normal_initializer for initializing all weight matrices. + + Example: + + ```python + >>> from transformers import Qwen3ASRThinkerModel, Qwen3ASRThinkerConfig + + >>> # Initializing a default Qwen3ASRThinkerConfig + >>> configuration = Qwen3ASRThinkerConfig() + + >>> # Initializing a model (with random weights) from the default configuration + >>> model = Qwen3ASRThinkerModel(configuration) + + >>> # Accessing the model configuration + >>> configuration = model.config + ```""" + + model_type = "qwen3_asr_thinker" + + attribute_map = {} + sub_configs = { + "audio_config": Qwen3ASRAudioEncoderConfig, + "text_config": Qwen3ASRTextConfig, + } + + def __init__( + self, + audio_config=None, + text_config=None, + audio_token_id=151646, + audio_start_token_id=151647, + user_token_id=872, + initializer_range=0.02, + **kwargs, + ): + super().__init__(**kwargs) + self.user_token_id = user_token_id + self.audio_start_token_id = audio_start_token_id + self.initializer_range = initializer_range + + if isinstance(audio_config, dict): + audio_config = Qwen3ASRAudioEncoderConfig(**audio_config) + elif audio_config is None: + audio_config = Qwen3ASRAudioEncoderConfig() + self.audio_config = audio_config + + if isinstance(text_config, dict): + text_config = Qwen3ASRTextConfig(**text_config) + elif text_config is None: + text_config = Qwen3ASRTextConfig() + self.text_config = text_config + self.audio_token_id = audio_token_id + + +class Qwen3ASRConfig(PretrainedConfig): + """ + This is the configuration class to store the configuration of a [`Qwen3ASRForConditionalGeneration`]. It is used to instantiate a Qwen3ASR + model according to the specified sub-models configurations, defining the model architecture. + + Instantiating a configuration with the defaults will yield a similar configuration to that of the + [Qwen/Qwen3-ASR-1.7B](https://huggingface.co/Qwen/Qwen3-ASR-1.7B) architecture. + + Configuration objects inherit from [`PretrainedConfig`] and can be used to control the model outputs. Read the + documentation from [`PretrainedConfig`] for more information. + + Args: + thinker_config (`dict`, *optional*): Configuration of the underlying thinker sub-model. + support_languages (`List[str]`, *optional*): The languages supported by the model. + + Example: + + ```python + >>> from transformers import ( + ... Qwen3ASRThinkerConfig, + ... Qwen3ASRForConditionalGeneration, + ... Qwen3ASRConfig, + ... ) + + >>> # Initializing a Qwen3ASR style configuration + >>> configuration = Qwen3ASRConfig() + + >>> # Initializing a model from the configuration + >>> model = Qwen3ASRForConditionalGeneration(configuration) + + >>> # Accessing the model configuration + >>> configuration = model.config + ```""" + + model_type = "qwen3_asr" + sub_configs = { + "thinker_config": Qwen3ASRThinkerConfig, + } + + def __init__( + self, + thinker_config=None, + support_languages=None, + **kwargs, + ): + super().__init__(**kwargs) + if thinker_config is None: + thinker_config = {} + + self.thinker_config = Qwen3ASRThinkerConfig(**thinker_config) + self.support_languages = support_languages + + def get_text_config(self, decoder=False) -> "PretrainedConfig": + """ + Returns the config that is meant to be used with text IO. On most models, it is the original config instance + itself. On specific composite models, it is under a set of valid names. + + Args: + decoder (`Optional[bool]`, *optional*, defaults to `False`): + If set to `True`, then only search for decoder config names. + """ + # Overridden for deeply nested config like Qwen2.5-Omni. We don't have any omni model + # except for Qwen yet. This has to be generalized if more deeply nested configs are + # added. NOTE: currently method used only by vLLM + return self.thinker_config.get_text_config() + + +__all__ = ["Qwen3ASRConfig", "Qwen3ASRThinkerConfig", "Qwen3ASRAudioEncoderConfig"] diff --git a/vendor/qwen-asr/qwen_asr/core/transformers_backend/modeling_qwen3_asr.py b/vendor/qwen-asr/qwen_asr/core/transformers_backend/modeling_qwen3_asr.py new file mode 100644 index 00000000..75bef860 --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/core/transformers_backend/modeling_qwen3_asr.py @@ -0,0 +1,1365 @@ +# coding=utf-8 +# Copyright 2026 The Qwen team, Alibaba Group and the HuggingFace Inc. team. All rights reserved. +# +# 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. +import math +from dataclasses import dataclass +from typing import Callable, Optional, Union + +import numpy as np +import torch +from torch import nn +from torch.nn import functional as F + +from transformers.activations import ACT2FN +from transformers.cache_utils import Cache, DynamicCache +from transformers.generation import GenerationMixin +from transformers.integrations import use_kernel_forward_from_hub +from transformers.masking_utils import create_causal_mask +from transformers.modeling_flash_attention_utils import FlashAttentionKwargs +from transformers.modeling_layers import GradientCheckpointingLayer +from transformers.modeling_outputs import ( + BaseModelOutput, + BaseModelOutputWithPast, + MoeCausalLMOutputWithPast, +) +from transformers.modeling_rope_utils import ROPE_INIT_FUNCTIONS, dynamic_rope_update +from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS, PreTrainedModel +from transformers.processing_utils import Unpack +from transformers.utils import auto_docstring, can_return_tuple +from transformers.utils.deprecation import deprecate_kwarg +from transformers.utils.generic import TransformersKwargs, check_model_inputs + +from .configuration_qwen3_asr import ( + Qwen3ASRAudioEncoderConfig, + Qwen3ASRConfig, + Qwen3ASRThinkerConfig, +) + + +@use_kernel_forward_from_hub("RMSNorm") +class Qwen3ASRTextRMSNorm(nn.Module): + def __init__(self, hidden_size, eps: float = 1e-6) -> None: + """ + Qwen3ASRTextRMSNorm is equivalent to T5LayerNorm + """ + super().__init__() + self.weight = nn.Parameter(torch.ones(hidden_size)) + self.variance_epsilon = eps + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + input_dtype = hidden_states.dtype + hidden_states = hidden_states.to(torch.float32) + variance = hidden_states.pow(2).mean(-1, keepdim=True) + hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon) + return self.weight * hidden_states.to(input_dtype) + + def extra_repr(self): + return f"{tuple(self.weight.shape)}, eps={self.variance_epsilon}" + + +def rotate_half(x): + """Rotates half the hidden dims of the input.""" + x1 = x[..., : x.shape[-1] // 2] + x2 = x[..., x.shape[-1] // 2 :] + return torch.cat((-x2, x1), dim=-1) + + +def repeat_kv(hidden_states: torch.Tensor, n_rep: int) -> torch.Tensor: + """ + This is the equivalent of torch.repeat_interleave(x, dim=1, repeats=n_rep). The hidden states go from (batch, + num_key_value_heads, seqlen, head_dim) to (batch, num_attention_heads, seqlen, head_dim) + """ + batch, num_key_value_heads, slen, head_dim = hidden_states.shape + if n_rep == 1: + return hidden_states + hidden_states = hidden_states[:, :, None, :, :].expand(batch, num_key_value_heads, n_rep, slen, head_dim) + return hidden_states.reshape(batch, num_key_value_heads * n_rep, slen, head_dim) + + +def eager_attention_forward( + module: nn.Module, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attention_mask: Optional[torch.Tensor], + scaling: float, + dropout: float = 0.0, + **kwargs: Unpack[TransformersKwargs], +): + key_states = repeat_kv(key, module.num_key_value_groups) + value_states = repeat_kv(value, module.num_key_value_groups) + + attn_weights = torch.matmul(query, key_states.transpose(2, 3)) * scaling + if attention_mask is not None: + causal_mask = attention_mask[:, :, :, : key_states.shape[-2]] + attn_weights = attn_weights + causal_mask + + attn_weights = nn.functional.softmax(attn_weights, dim=-1, dtype=torch.float32).to(query.dtype) + attn_weights = nn.functional.dropout(attn_weights, p=dropout, training=module.training) + attn_output = torch.matmul(attn_weights, value_states) + attn_output = attn_output.transpose(1, 2).contiguous() + + return attn_output, attn_weights + + +def apply_rotary_pos_emb(q, k, cos, sin, position_ids=None, unsqueeze_dim=1): + """Applies Rotary Position Embedding to the query and key tensors. + + Args: + q (`torch.Tensor`): The query tensor. + k (`torch.Tensor`): The key tensor. + cos (`torch.Tensor`): The cosine part of the rotary embedding. + sin (`torch.Tensor`): The sine part of the rotary embedding. + position_ids (`torch.Tensor`, *optional*): + Deprecated and unused. + unsqueeze_dim (`int`, *optional*, defaults to 1): + The 'unsqueeze_dim' argument specifies the dimension along which to unsqueeze cos[position_ids] and + sin[position_ids] so that they can be properly broadcasted to the dimensions of q and k. For example, note + that cos[position_ids] and sin[position_ids] have the shape [batch_size, seq_len, head_dim]. Then, if q and + k have the shape [batch_size, heads, seq_len, head_dim], then setting unsqueeze_dim=1 makes + cos[position_ids] and sin[position_ids] broadcastable to the shapes of q and k. Similarly, if q and k have + the shape [batch_size, seq_len, heads, head_dim], then set unsqueeze_dim=2. + Returns: + `tuple(torch.Tensor)` comprising of the query and key tensors rotated using the Rotary Position Embedding. + """ + cos = cos.unsqueeze(unsqueeze_dim) + sin = sin.unsqueeze(unsqueeze_dim) + q_embed = (q * cos) + (rotate_half(q) * sin) + k_embed = (k * cos) + (rotate_half(k) * sin) + return q_embed, k_embed + + +class Qwen3ASRTextAttention(nn.Module): + """Multi-headed attention from 'Attention Is All You Need' paper""" + + def __init__(self, config: Qwen3ASRConfig, layer_idx: int): + super().__init__() + self.config = config + self.layer_idx = layer_idx + self.head_dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads) + self.num_key_value_groups = config.num_attention_heads // config.num_key_value_heads + self.scaling = self.head_dim**-0.5 + self.attention_dropout = config.attention_dropout + self.is_causal = True + + self.q_proj = nn.Linear( + config.hidden_size, config.num_attention_heads * self.head_dim, bias=config.attention_bias + ) + self.k_proj = nn.Linear( + config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias + ) + self.v_proj = nn.Linear( + config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias + ) + self.o_proj = nn.Linear( + config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.attention_bias + ) + self.q_norm = Qwen3ASRTextRMSNorm( + self.head_dim, eps=config.rms_norm_eps + ) # unlike olmo, only on the head dim! + self.k_norm = Qwen3ASRTextRMSNorm( + self.head_dim, eps=config.rms_norm_eps + ) # thus post q_norm does not need reshape + + @deprecate_kwarg("past_key_value", new_name="past_key_values", version="4.58") + def forward( + self, + hidden_states: torch.Tensor, + position_embeddings: tuple[torch.Tensor, torch.Tensor], + attention_mask: Optional[torch.Tensor], + past_key_values: Optional[Cache] = None, + cache_position: Optional[torch.LongTensor] = None, + **kwargs: Unpack[FlashAttentionKwargs], + ) -> tuple[torch.Tensor, Optional[torch.Tensor]]: + input_shape = hidden_states.shape[:-1] + hidden_shape = (*input_shape, -1, self.head_dim) + + query_states = self.q_norm(self.q_proj(hidden_states).view(hidden_shape)).transpose(1, 2) + key_states = self.k_norm(self.k_proj(hidden_states).view(hidden_shape)).transpose(1, 2) + value_states = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2) + + cos, sin = position_embeddings + query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin) + + if past_key_values is not None: + # sin and cos are specific to RoPE models; cache_position needed for the static cache + cache_kwargs = {"sin": sin, "cos": cos, "cache_position": cache_position} + key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx, cache_kwargs) + + attention_interface: Callable = eager_attention_forward + if self.config._attn_implementation != "eager": + attention_interface = ALL_ATTENTION_FUNCTIONS[self.config._attn_implementation] + + attn_output, attn_weights = attention_interface( + self, + query_states, + key_states, + value_states, + attention_mask, + dropout=0.0 if not self.training else self.attention_dropout, + scaling=self.scaling, + **kwargs, + ) + + attn_output = attn_output.reshape(*input_shape, -1).contiguous() + attn_output = self.o_proj(attn_output) + return attn_output, attn_weights + + +class Qwen3ASRTextMLP(nn.Module): + def __init__(self, config): + super().__init__() + self.config = config + self.hidden_size = config.hidden_size + self.intermediate_size = config.intermediate_size + self.gate_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False) + self.up_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False) + self.down_proj = nn.Linear(self.intermediate_size, self.hidden_size, bias=False) + self.act_fn = ACT2FN[config.hidden_act] + + def forward(self, x): + down_proj = self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x)) + return down_proj + + +class Qwen3ASRThinkerTextDecoderLayer(GradientCheckpointingLayer): + def __init__(self, config: Qwen3ASRConfig, layer_idx: int): + super().__init__() + self.hidden_size = config.hidden_size + + self.self_attn = Qwen3ASRTextAttention(config=config, layer_idx=layer_idx) + + self.mlp = Qwen3ASRTextMLP(config) + self.input_layernorm = Qwen3ASRTextRMSNorm(config.hidden_size, eps=config.rms_norm_eps) + self.post_attention_layernorm = Qwen3ASRTextRMSNorm(config.hidden_size, eps=config.rms_norm_eps) + + @deprecate_kwarg("past_key_value", new_name="past_key_values", version="4.58") + def forward( + self, + hidden_states: torch.Tensor, + position_embeddings: tuple[torch.Tensor, torch.Tensor], + attention_mask: Optional[torch.Tensor] = None, + position_ids: Optional[torch.LongTensor] = None, + past_key_values: Optional[Cache] = None, + use_cache: Optional[bool] = False, + cache_position: Optional[torch.LongTensor] = None, + **kwargs: Unpack[TransformersKwargs], + ) -> torch.Tensor: + residual = hidden_states + hidden_states = self.input_layernorm(hidden_states) + # Self Attention + hidden_states, _ = self.self_attn( + hidden_states=hidden_states, + attention_mask=attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + use_cache=use_cache, + cache_position=cache_position, + position_embeddings=position_embeddings, + **kwargs, + ) + hidden_states = residual + hidden_states + + # Fully Connected + residual = hidden_states + hidden_states = self.post_attention_layernorm(hidden_states) + hidden_states = self.mlp(hidden_states) + hidden_states = residual + hidden_states + return hidden_states + + +@auto_docstring +class Qwen3ASRPreTrainedModel(PreTrainedModel): + config: Qwen3ASRConfig + base_model_prefix = "model" + supports_gradient_checkpointing = True + _skip_keys_device_placement = "past_key_values" + _supports_flash_attn = True + _supports_sdpa = True + + _can_compile_fullgraph = True + _supports_attention_backend = True + _can_record_outputs = { + "attentions": Qwen3ASRTextAttention, + } + + +@dataclass +class Qwen3ASRThinkerCausalLMOutputWithPast(MoeCausalLMOutputWithPast): + r""" + Args: + rope_deltas (`torch.LongTensor` of shape `(batch_size, )`, *optional*): + The rope index difference between sequence length and multimodal rope. + """ + + rope_deltas: Optional[torch.LongTensor] = None + + +def _get_feat_extract_output_lengths(input_lengths): + """ + Computes the output length of the convolutional layers and the output length of the audio encoder + """ + + input_lengths_leave = input_lengths % 100 + feat_lengths = (input_lengths_leave - 1) // 2 + 1 + output_lengths = ((feat_lengths - 1) // 2 + 1 - 1) // 2 + 1 + (input_lengths // 100) * 13 + return output_lengths + + +class Qwen3ASRPreTrainedModelForConditionalGeneration(Qwen3ASRPreTrainedModel): + def _prepare_4d_causal_attention_mask_with_cache_position( + self, + attention_mask: torch.Tensor, + sequence_length: int, + target_length: int, + dtype: torch.dtype, + device: torch.device, + min_dtype: float, + cache_position: torch.Tensor, + batch_size: int, + ): + """ + Creates a causal 4D mask of shape `(batch_size, 1, query_length, key_value_length)` from a 2D mask of shape + `(batch_size, key_value_length)`, or if the input `attention_mask` is already 4D, do nothing. + + Args: + attention_mask (`torch.Tensor`): + A 2D attention mask of shape `(batch_size, key_value_length)` or a 4D attention mask of shape `(batch_size, 1, query_length, key_value_length)`. + sequence_length (`int`): + The sequence length being processed. + target_length (`int`): + The target length: when generating with static cache, the mask should be as long as the static cache, to account for the 0 padding, the part of the cache that is not filled yet. + dtype (`torch.dtype`): + The dtype to use for the 4D attention mask. + device (`torch.device`): + The device to place the 4D attention mask on. + min_dtype (`float`): + The minimum value representable with the dtype `dtype`. + cache_position (`torch.Tensor`): + Indices depicting the position of the input sequence tokens in the sequence. + batch_size (`torch.Tensor`): + Batch size. + """ + if attention_mask is not None and attention_mask.dim() == 4: + # In this case we assume that the mask comes already in inverted form and requires no inversion or slicing. + causal_mask = attention_mask + else: + causal_mask = torch.full( + (sequence_length, target_length), fill_value=min_dtype, dtype=dtype, device=device + ) + if sequence_length != 1: + causal_mask = torch.triu(causal_mask, diagonal=1) + causal_mask *= torch.arange(target_length, device=device) > cache_position.reshape(-1, 1) + causal_mask = causal_mask[None, None, :, :].expand(batch_size, 1, -1, -1) + if attention_mask is not None: + causal_mask = causal_mask.clone() # copy to contiguous memory for in-place edit + mask_length = attention_mask.shape[-1] + padding_mask = causal_mask[:, :, :, :mask_length] + attention_mask[:, None, None, :] + padding_mask = padding_mask == 0 + causal_mask[:, :, :, :mask_length] = causal_mask[:, :, :, :mask_length].masked_fill( + padding_mask, min_dtype + ) + + return causal_mask + + + def get_chunked_index( + self, token_indices: torch.Tensor, tokens_per_chunk: int, remove_index: int + ) -> list[tuple[int, int]]: + """ + Splits token index list into chunks based on token value ranges. + + Given a list of token indices, returns a list of (start, end) index tuples representing + slices of the list where the token values fall within successive ranges of `t_ntoken_per_chunk`. + + For example, if `t_ntoken_per_chunk` is 1000, the function will create chunks such that: + - the first chunk contains token values < 1000, + - the second chunk contains values >= 1000 and < 2000, and so on. + + Parameters: + token_indices (`torch.Tensor` of shape `(seq_len, )`): A monotonically increasing list of + token index values. + t_ntoken_per_chunk (`int`): Number of tokens per chunk (used as the chunk size threshold). + remove_index (`int`) An index id to subtract from `token_indices` before chunking + + Returns: + `list[tuple[int, int]]`: A list of tuples, each representing the start (inclusive) + and end (exclusive) indices of a chunk in `token_indices`. + """ + + def _iter(): + i, start_idx = 0, 0 # skip bos token + current_chunk = 1 + while i < len(token_indices): # skip eos token + if token_indices[i] - remove_index >= current_chunk * tokens_per_chunk: + yield (start_idx, i) + start_idx = i + current_chunk += 1 + i += 1 + yield (start_idx, len(token_indices)) + + return list(_iter()) + + def get_rope_index( + self, + attention_mask: Optional[torch.Tensor] = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + """ + Calculate the rope index in LLM. + + Explanation: + Each embedding sequence contains text embedding. + + Args: + input_ids (`torch.LongTensor` of shape `(batch_size, sequence_length)`): + Indices of input sequence tokens in the vocabulary. Padding will be ignored by default should you provide + it. + attention_mask (`torch.Tensor` of shape `(batch_size, sequence_length)`, *optional*): + Mask to avoid performing attention on padding token indices. Mask values selected in `[0, 1]`: + + - 1 for tokens that are **not masked**, + - 0 for tokens that are **masked**. + audio_seqlens (`torch.LongTensor` of shape `(num_audios)`, *optional*): + The length of feature shape of each audio in LLM. + + Returns: + position_ids (`torch.LongTensor` of shape `(3, batch_size, sequence_length)`) + mrope_position_deltas (`torch.Tensor` of shape `(batch_size)`) + """ + mrope_position_deltas = [] + + position_ids = attention_mask.float().cumsum(-1) - 1 + position_ids.masked_fill_(attention_mask == 0, 1) + position_ids = position_ids.unsqueeze(0).expand(3, -1, -1).to(attention_mask.device) + max_position_ids = position_ids.max(0, keepdim=False)[0].max(-1, keepdim=True)[0] + mrope_position_deltas = max_position_ids + 1 - torch.sum(attention_mask, dim=-1, keepdim=True) + + return position_ids, mrope_position_deltas + + +class Qwen3ASRAudioAttention(nn.Module): + """Multi-headed attention from 'Attention Is All You Need' paper""" + + def __init__(self, config): + super().__init__() + self.embed_dim = config.d_model + self.num_heads = config.encoder_attention_heads + self.dropout = config.attention_dropout + self.head_dim = self.embed_dim // self.num_heads + self.num_key_value_groups = 1 # needed for eager attention + self.config = config + + if (self.head_dim * self.num_heads) != self.embed_dim: + raise ValueError( + f"embed_dim must be divisible by num_heads (got `embed_dim`: {self.embed_dim}" + f" and `num_heads`: {self.num_heads})." + ) + self.scaling = self.head_dim**-0.5 + self.attention_dropout = 0.0 + self.is_decoder = False + self.is_causal = False + self.k_proj = nn.Linear(self.embed_dim, self.embed_dim, bias=True) + self.v_proj = nn.Linear(self.embed_dim, self.embed_dim, bias=True) + self.q_proj = nn.Linear(self.embed_dim, self.embed_dim, bias=True) + self.out_proj = nn.Linear(self.embed_dim, self.embed_dim, bias=True) + + def forward( + self, + hidden_states: torch.Tensor, + cu_seqlens: Optional[torch.Tensor] = None, + attention_mask: Optional[torch.Tensor] = None, + **kwargs, + ) -> tuple[torch.Tensor, Optional[torch.Tensor], Optional[tuple[torch.Tensor]]]: + """Input shape: Batch x Time x Channel""" + + seq_length, _ = hidden_states.size() + + query_states = self.q_proj(hidden_states).reshape(seq_length, self.num_heads, -1) + key_states = self.k_proj(hidden_states).reshape(seq_length, self.num_heads, -1) + value_states = self.v_proj(hidden_states).reshape(seq_length, self.num_heads, -1) + + query_states = query_states.transpose(0, 1).unsqueeze(0) + key_states = key_states.transpose(0, 1).unsqueeze(0) + value_states = value_states.transpose(0, 1).unsqueeze(0) + max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max() + + attention_interface: Callable = eager_attention_forward + if self.config._attn_implementation != "eager": + attention_interface = ALL_ATTENTION_FUNCTIONS[self.config._attn_implementation] + + attn_output, _ = attention_interface( + self, + query_states, + key_states, + value_states, + attention_mask=attention_mask, + dropout=0.0 if not self.training else self.attention_dropout, + scaling=self.scaling, + cu_seq_lens_q=cu_seqlens, # pass cu seq lens for FA2 + cu_seq_lens_k=cu_seqlens, + max_length_q=max_seqlen, + max_length_k=max_seqlen, + is_causal=False, + **kwargs, + ) + + attn_output = attn_output.reshape(seq_length, -1).contiguous() + attn_output = self.out_proj(attn_output) + + return attn_output + + +class Qwen3ASRAudioEncoderLayer(GradientCheckpointingLayer): + def __init__(self, config: Qwen3ASRAudioEncoderConfig): + super().__init__() + self.embed_dim = config.d_model + self.self_attn = Qwen3ASRAudioAttention(config) + self.self_attn_layer_norm = nn.LayerNorm(self.embed_dim) + self.dropout = config.dropout + self.activation_fn = ACT2FN[config.activation_function] + self.activation_dropout = config.activation_dropout + self.fc1 = nn.Linear(self.embed_dim, config.encoder_ffn_dim) + self.fc2 = nn.Linear(config.encoder_ffn_dim, self.embed_dim) + self.final_layer_norm = nn.LayerNorm(self.embed_dim) + + def forward( + self, + hidden_states: torch.Tensor, + cu_seqlens: torch.Tensor, + attention_mask: Optional[torch.Tensor] = None, + **kwargs, + ) -> torch.Tensor: + """ + Args: + hidden_states (`torch.FloatTensor`): input to the layer of shape `(batch, seq_len, embed_dim)` + attention_mask (`torch.FloatTensor`): attention mask of size + `(batch, 1, tgt_len, src_len)` where padding elements are indicated by very large negative values. + layer_head_mask (`torch.FloatTensor`): mask for attention heads in a given layer of size + `(encoder_attention_heads,)`. + output_attentions (`bool`, *optional*): + Whether or not to return the attentions tensors of all attention layers. See `attentions` under + returned tensors for more detail. + """ + residual = hidden_states + hidden_states = self.self_attn_layer_norm(hidden_states) + hidden_states = self.self_attn( + hidden_states=hidden_states, + cu_seqlens=cu_seqlens, + attention_mask=attention_mask, + **kwargs, + ) + hidden_states = residual + hidden_states + residual = hidden_states + hidden_states = self.final_layer_norm(hidden_states) + hidden_states = self.fc1(hidden_states) + hidden_states = self.activation_fn(hidden_states) + hidden_states = self.fc2(hidden_states) + hidden_states = residual + hidden_states + + if hidden_states.dtype == torch.float16: + clamp_value = torch.finfo(hidden_states.dtype).max - 1000 + hidden_states = torch.clamp(hidden_states, min=-clamp_value, max=clamp_value) + + outputs = (hidden_states,) + + return outputs + + +class SinusoidsPositionEmbedding(nn.Module): + def __init__(self, length, channels, max_timescale=10000): + super().__init__() + if channels % 2 != 0: + raise ValueError("SinusoidsPositionEmbedding needs even channels input") + log_timescale_increment = np.log(max_timescale) / (channels // 2 - 1) + inv_timescales = torch.exp(-log_timescale_increment * torch.arange(channels // 2).float()) + scaled_time = torch.arange(length)[:, np.newaxis] * inv_timescales[np.newaxis, :] + self.register_buffer( + "positional_embedding", + torch.cat([torch.sin(scaled_time), torch.cos(scaled_time)], dim=1), + persistent=False, + ) + + def forward(self, seqlen: int): + return self.positional_embedding[:seqlen, :] + + +@auto_docstring( + custom_intro=""" + Transformer encoder consisting of *config.encoder_layers* self attention layers. Each layer is a + [`Qwen3ASRAudioEncoderLayer`]. + """ +) +class Qwen3ASRAudioEncoder(Qwen3ASRPreTrainedModel): + config: Qwen3ASRAudioEncoderConfig + main_input_name = "input_features" + _no_split_modules = ["Qwen3ASRAudioEncoderLayer"] + _supports_sdpa = True + + def __init__(self, config: Qwen3ASRAudioEncoderConfig): + super().__init__(config) + self.dropout = config.dropout + + embed_dim = config.d_model + self.num_mel_bins = config.num_mel_bins + self.max_source_positions = config.max_source_positions + self.embed_scale = math.sqrt(embed_dim) if config.scale_embedding else 1.0 + self.n_window = config.n_window + self.positional_embedding = SinusoidsPositionEmbedding(self.max_source_positions, embed_dim) + self.layers = nn.ModuleList([Qwen3ASRAudioEncoderLayer(config) for _ in range(config.encoder_layers)]) + self.ln_post = nn.LayerNorm(config.d_model) + self.gradient_checkpointing = False + self.conv2d1 = nn.Conv2d(1, config.downsample_hidden_size, 3, 2, padding=1) + self.conv2d2 = nn.Conv2d(config.downsample_hidden_size, config.downsample_hidden_size, 3, 2, padding=1) + self.conv2d3 = nn.Conv2d(config.downsample_hidden_size, config.downsample_hidden_size, 3, 2, padding=1) + self.conv_out = nn.Linear( + config.downsample_hidden_size * ((((config.num_mel_bins + 1) // 2 + 1) // 2 + 1) // 2), + config.d_model, + bias=False, + ) + self.proj1 = nn.Linear(config.d_model, config.d_model) + self.act = ACT2FN[config.activation_function] + self.proj2 = nn.Linear(config.d_model, config.output_dim) + self.n_window_infer = self.config.n_window_infer + self.conv_chunksize = self.config.conv_chunksize + # Initialize weights and apply final processing + self.post_init() + + def _freeze_parameters(self): + for param in self.parameters(): + param.requires_grad = False + self._requires_grad = False + + def get_input_embeddings(self) -> nn.Module: + return self.conv1 + + def set_input_embeddings(self, value: nn.Module): + self.conv1 = value + + def _prepare_attention_mask(self, inputs_tensor: torch.Tensor, cu_seqlens: torch.Tensor) -> torch.Tensor: + # Flash Attention 2 doesn't need a 4D mask and relies on `cu_seqlens/max_seqlen` + # NOTE: the created attention masl only approximates the ragged FA2 attention by + # allowing bidirectional attention within `cu_seqlens` blocks, and not attending between + # blocks. Though it will not be a 100% match for FA2's `varlen` path + if self.config._attn_implementation == "flash_attention_2": + return None + + seq_length = inputs_tensor.shape[0] + attention_mask = torch.full( + [1, 1, seq_length, seq_length], + torch.finfo(inputs_tensor.dtype).min, + device=inputs_tensor.device, + dtype=inputs_tensor.dtype, + ) + for i in range(1, len(cu_seqlens)): + attention_mask[..., cu_seqlens[i - 1] : cu_seqlens[i], cu_seqlens[i - 1] : cu_seqlens[i]] = 0 + return attention_mask + + @auto_docstring + def forward( + self, + input_features, + feature_lens=None, + aftercnn_lens=None, + ): + r""" + feature_lens (`torch.LongTensor` of shape `(batch_size,)`): + mel length + aftercnn_lens (`torch.LongTensor` of shape `(batch_size,)`): + mel length after cnn + """ + aftercnn_lens = _get_feat_extract_output_lengths(feature_lens) + chunk_num = torch.ceil(feature_lens / (self.n_window * 2)).long() + + chunk_lengths = torch.tensor( + [self.n_window * 2] * chunk_num.sum(), + dtype=torch.long, + device=feature_lens.device, + ) + tail_chunk_index = F.pad(chunk_num, (1, 0), value=-1).cumsum(0)[1:] + chunk_lengths[tail_chunk_index] = feature_lens % (self.n_window * 2) + chunk_lengths[chunk_lengths == 0] = self.n_window * 2 + + chunk_list = input_features.T.split(chunk_lengths.tolist(), dim=0) + padded_feature = nn.utils.rnn.pad_sequence(chunk_list, batch_first=True).transpose(1, 2) + feature_lens_after_cnn = _get_feat_extract_output_lengths(chunk_lengths) + padded_mask_after_cnn = nn.utils.rnn.pad_sequence( + [torch.ones(length, dtype=torch.bool, device=padded_feature.device) for length in feature_lens_after_cnn], + batch_first=True, + ) + padded_feature = padded_feature.unsqueeze(1) + # Split to chunk to avoid OOM during convolution + padded_embeds = [] + for chunk in padded_feature.split(self.conv_chunksize, dim=0): + padded_embed = F.gelu(self.conv2d1(chunk)) + padded_embed = F.gelu(self.conv2d2(padded_embed)) + padded_embed = F.gelu(self.conv2d3(padded_embed)) + padded_embeds.append(padded_embed) + padded_embed = torch.cat(padded_embeds, dim=0) + b, c, f, t = padded_embed.size() + padded_embed = self.conv_out(padded_embed.permute(0, 3, 1, 2).contiguous().view(b, t, c * f)) + + positional_embedding = ( + self.positional_embedding.positional_embedding[: padded_embed.shape[1], :] + .unsqueeze(0) + .to(padded_embed.dtype) + ) + padded_embed = padded_embed + positional_embedding + hidden_states = padded_embed[padded_mask_after_cnn] + cu_chunk_lens = [0] + window_aftercnn = padded_mask_after_cnn.shape[-1] * (self.n_window_infer // (self.n_window * 2)) + for cnn_len in aftercnn_lens: + cu_chunk_lens += [window_aftercnn] * (cnn_len // window_aftercnn) + remainder = cnn_len % window_aftercnn + if remainder != 0: + cu_chunk_lens += [remainder] + cu_seqlens = torch.tensor(cu_chunk_lens, device=aftercnn_lens.device).cumsum(-1, dtype=torch.int32) + + for encoder_layer in self.layers: + layer_outputs = encoder_layer( + hidden_states, + cu_seqlens, + ) + + hidden_states = layer_outputs[0] + + hidden_states = self.ln_post(hidden_states) + hidden_states = self.proj1(hidden_states) + hidden_states = self.act(hidden_states) + hidden_states = self.proj2(hidden_states) + return BaseModelOutput(last_hidden_state=hidden_states) + + def padded_and_mask_function(self, tensor_list, tensor_len, padding_value=0, padding_side="right"): + """ + Pads a sequence of tensors to their maximum length on indicated `padding_side`. + Then prepares a mask so that pad tokens are not attended to. + """ + max_len = tensor_len.max() + dim = tensor_list[0].shape[0] + padded_tensor = torch.full( + size=(len(tensor_list), dim, max_len), + fill_value=padding_value, + dtype=self.dtype, + device=tensor_list[0].device, + ) + + batch_mask = torch.zeros( + (len(tensor_len), max_len), + dtype=torch.long, + device=padded_tensor.device, + ) + for i, length in enumerate(tensor_len): + batch_mask[i, :length] = 1 + padded_tensor[i, :, :length] = tensor_list[i] + + feature_lens_after_cnn = (tensor_len - 1) // 2 + 1 + max_len_after_cnn = feature_lens_after_cnn.max() + batch_mask_after_cnn = torch.zeros( + (len(tensor_len), max_len_after_cnn), + dtype=torch.long, + device=padded_tensor.device, + ) + for i, length in enumerate(feature_lens_after_cnn): + batch_mask_after_cnn[i, :length] = 1 + return ( + padded_tensor, + batch_mask.unsqueeze(1), + batch_mask_after_cnn.bool(), + ) + + +class Qwen3ASRThinkerTextRotaryEmbedding(nn.Module): + inv_freq: torch.Tensor # fix linting for `register_buffer` + + def __init__(self, config: Qwen3ASRConfig, device=None): + super().__init__() + if hasattr(config, "rope_scaling") and config.rope_scaling is not None: + self.rope_type = config.rope_scaling.get("rope_type", "default") + else: + self.rope_type = "default" + self.max_seq_len_cached = config.max_position_embeddings + self.original_max_seq_len = config.max_position_embeddings + + self.config = config + self.rope_init_fn = ROPE_INIT_FUNCTIONS[self.rope_type] + + inv_freq, self.attention_scaling = self.rope_init_fn(self.config, device) + self.register_buffer("inv_freq", inv_freq, persistent=False) + self.original_inv_freq = self.inv_freq + + self.mrope_section = config.rope_scaling.get("mrope_section", [24, 20, 20]) + + def apply_interleaved_mrope(self, freqs, mrope_section): + """Apply interleaved MRoPE to 3D rotary embeddings. + Reorganizes frequency layout from chunked [TTT...HHH...WWW] to + interleaved [THTHWHTHW...TT], preserving frequency continuity. + args: + x: (3, bs, seq_len, head_dim // 2) + mrope_section: (3,) + returns: + x_t: (bs, seq_len, head_dim // 2) + """ + freqs_t = freqs[0] # just overwrite the first dimension T + for dim, offset in enumerate((1, 2), start=1): # H, W + length = mrope_section[dim] * 3 + idx = slice(offset, length, 3) + freqs_t[..., idx] = freqs[dim, ..., idx] + return freqs_t + + @torch.no_grad() + @dynamic_rope_update # power user: used with advanced RoPE types (e.g. dynamic rope) + def forward(self, x, position_ids): + # In contrast to other models, Qwen3ASRThinker has different position ids for the grids + # So we expand the inv_freq to shape (3, ...) + if position_ids.ndim == 2: + position_ids = position_ids[None, ...].expand(3, position_ids.shape[0], -1) + inv_freq_expanded = self.inv_freq[None, None, :, None].float().expand(3, position_ids.shape[1], -1, 1) + position_ids_expanded = position_ids[:, :, None, :].float() # shape (3, bs, 1, positions) + + device_type = x.device.type if isinstance(x.device.type, str) and x.device.type != "mps" else "cpu" + with torch.autocast(device_type=device_type, enabled=False): # Force float32 + freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(2, 3) + freqs = self.apply_interleaved_mrope(freqs, self.mrope_section) + emb = torch.cat((freqs, freqs), dim=-1) + cos = emb.cos() * self.attention_scaling + sin = emb.sin() * self.attention_scaling + + return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype) + + +class Qwen3ASRThinkerTextMLP(nn.Module): + def __init__(self, config, intermediate_size=None): + super().__init__() + self.config = config + self.hidden_size = config.hidden_size + self.intermediate_size = intermediate_size if intermediate_size is not None else config.intermediate_size + self.gate_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False) + self.up_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False) + self.down_proj = nn.Linear(self.intermediate_size, self.hidden_size, bias=False) + self.act_fn = ACT2FN[config.hidden_act] + + def forward(self, x): + down_proj = self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x)) + return down_proj + + +@use_kernel_forward_from_hub("RMSNorm") +class Qwen3ASRThinkerTextRMSNorm(nn.Module): + def __init__(self, hidden_size, eps=1e-6): + """ + Qwen3ASRThinkerTextRMSNorm is equivalent to T5LayerNorm + """ + super().__init__() + self.weight = nn.Parameter(torch.ones(hidden_size)) + self.variance_epsilon = eps + + def forward(self, hidden_states): + input_dtype = hidden_states.dtype + hidden_states = hidden_states.to(torch.float32) + variance = hidden_states.pow(2).mean(-1, keepdim=True) + hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon) + return self.weight * hidden_states.to(input_dtype) + + def extra_repr(self): + return f"{tuple(self.weight.shape)}, eps={self.variance_epsilon}" + + +class Qwen3ASRThinkerTextAttention(nn.Module): + """Multi-headed attention from 'Attention Is All You Need' paper""" + + def __init__(self, config, layer_idx): + super().__init__() + self.config = config + self.layer_idx = layer_idx + self.head_dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads) + self.num_key_value_groups = config.num_attention_heads // config.num_key_value_heads + self.scaling = self.head_dim**-0.5 + self.attention_dropout = config.attention_dropout + self.is_causal = True + + self.q_proj = nn.Linear( + config.hidden_size, config.num_attention_heads * self.head_dim, bias=config.attention_bias + ) + self.k_proj = nn.Linear( + config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias + ) + self.v_proj = nn.Linear( + config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias + ) + self.o_proj = nn.Linear( + config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.attention_bias + ) + self.q_norm = Qwen3ASRThinkerTextRMSNorm( + self.head_dim, eps=config.rms_norm_eps + ) # unlike olmo, only on the head dim! + self.k_norm = Qwen3ASRThinkerTextRMSNorm( + self.head_dim, eps=config.rms_norm_eps + ) # thus post q_norm does not need reshape + self.sliding_window = None + + @deprecate_kwarg("past_key_value", new_name="past_key_values", version="4.58") + def forward( + self, + hidden_states: torch.Tensor, + position_embeddings: tuple[torch.Tensor, torch.Tensor], + attention_mask: Optional[torch.Tensor], + past_key_values: Optional[Cache] = None, + cache_position: Optional[torch.LongTensor] = None, + **kwargs: Unpack[FlashAttentionKwargs], + ) -> tuple[torch.Tensor, Optional[torch.Tensor]]: + input_shape = hidden_states.shape[:-1] + hidden_shape = (*input_shape, -1, self.head_dim) + + query_states = self.q_norm(self.q_proj(hidden_states).view(hidden_shape)).transpose(1, 2) + key_states = self.k_norm(self.k_proj(hidden_states).view(hidden_shape)).transpose(1, 2) + value_states = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2) + + cos, sin = position_embeddings + query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin) + + if past_key_values is not None: + # sin and cos are specific to RoPE models; cache_position needed for the static cache + cache_kwargs = {"sin": sin, "cos": cos, "cache_position": cache_position} + key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx, cache_kwargs) + + attention_interface: Callable = eager_attention_forward + if self.config._attn_implementation != "eager": + attention_interface = ALL_ATTENTION_FUNCTIONS[self.config._attn_implementation] + + attn_output, attn_weights = attention_interface( + self, + query_states, + key_states, + value_states, + attention_mask, + dropout=0.0 if not self.training else self.attention_dropout, + scaling=self.scaling, + sliding_window=self.sliding_window, # diff with Llama + **kwargs, + ) + + attn_output = attn_output.reshape(*input_shape, -1).contiguous() + attn_output = self.o_proj(attn_output) + return attn_output, attn_weights + + +@auto_docstring( + custom_intro=( + "Text part of Qwen3ASRThinker, " + ) +) +class Qwen3ASRThinkerTextModel(Qwen3ASRPreTrainedModel): + config: Qwen3ASRConfig + _no_split_modules = ["Qwen3ASRThinkerTextDecoderLayer"] + config_class = Qwen3ASRConfig + _can_record_outputs = { + "hidden_states": Qwen3ASRThinkerTextDecoderLayer, + "attentions": Qwen3ASRThinkerTextAttention, + } + + def __init__(self, config: Qwen3ASRConfig): + super().__init__(config) + self.padding_idx = config.pad_token_id + self.vocab_size = config.vocab_size + + self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size, self.padding_idx) + self.layers = nn.ModuleList( + [Qwen3ASRThinkerTextDecoderLayer(config, layer_idx) for layer_idx in range(config.num_hidden_layers)] + ) + self.norm = Qwen3ASRTextRMSNorm(config.hidden_size, eps=config.rms_norm_eps) + self.rotary_emb = Qwen3ASRThinkerTextRotaryEmbedding(config) + self.gradient_checkpointing = False + + # Initialize weights and apply final processing + self.post_init() + + @check_model_inputs() + @auto_docstring + def forward( + self, + input_ids: Optional[torch.LongTensor] = None, + attention_mask: Optional[torch.Tensor] = None, + position_ids: Optional[torch.LongTensor] = None, + past_key_values: Optional[Cache] = None, + inputs_embeds: Optional[torch.FloatTensor] = None, + use_cache: Optional[bool] = None, + cache_position: Optional[torch.LongTensor] = None, + **kwargs: Unpack[FlashAttentionKwargs], + ) -> Union[tuple, BaseModelOutputWithPast]: + if (input_ids is None) ^ (inputs_embeds is not None): + raise ValueError("You must specify exactly one of input_ids or inputs_embeds") + + # torch.jit.trace() doesn't support cache objects in the output + if use_cache and past_key_values is None and not torch.jit.is_tracing(): + past_key_values = DynamicCache(config=self.config) + + if inputs_embeds is None: + inputs_embeds = self.embed_tokens(input_ids) + + if cache_position is None: + past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0 + cache_position = torch.arange( + past_seen_tokens, past_seen_tokens + inputs_embeds.shape[1], device=inputs_embeds.device + ) + + # the hard coded `3` is for temporal, height and width. + if position_ids is None: + position_ids = cache_position.view(1, 1, -1).expand(3, inputs_embeds.shape[0], -1) + elif position_ids.ndim == 2: + position_ids = position_ids[None, ...].expand(3, position_ids.shape[0], -1) + + if position_ids.ndim == 3 and position_ids.shape[0] == 4: + text_position_ids = position_ids[0] + position_ids = position_ids[1:] + else: + text_position_ids = position_ids[0] + + attention_mask = create_causal_mask( + config=self.config, + input_embeds=inputs_embeds, + attention_mask=attention_mask, + cache_position=cache_position, + past_key_values=past_key_values, + position_ids=text_position_ids, + ) + + hidden_states = inputs_embeds + + # create position embeddings to be shared across the decoder layers + position_embeddings = self.rotary_emb(hidden_states, position_ids) + + # decoder layers + for layer_idx, decoder_layer in enumerate(self.layers): + layer_outputs = decoder_layer( + hidden_states, + attention_mask=attention_mask, + position_ids=text_position_ids, + past_key_values=past_key_values, + cache_position=cache_position, + position_embeddings=position_embeddings, + **kwargs, + ) + hidden_states = layer_outputs + + hidden_states = self.norm(hidden_states) + + return BaseModelOutputWithPast( + last_hidden_state=hidden_states, + past_key_values=past_key_values, + ) + + +@auto_docstring( + custom_intro=""" + The Qwen3ASRThinker model which consists of a audio backbone and a language model. + """ +) +class Qwen3ASRThinkerForConditionalGeneration(Qwen3ASRPreTrainedModelForConditionalGeneration, GenerationMixin): + config: Qwen3ASRThinkerConfig + base_model_prefix = "thinker" + _tied_weights_keys = ["model.embed_tokens.weight", "lm_head.weight"] + _no_split_modules = [ + "Qwen3ASRAudioEncoderLayer", + "Qwen3ASRThinkerTextDecoderLayer", + ] + _can_record_outputs = { + "hidden_states": Qwen3ASRThinkerTextDecoderLayer, + "attentions": Qwen3ASRThinkerTextAttention, + } + + def __init__(self, config): + super().__init__(config) + self.audio_tower = Qwen3ASRAudioEncoder._from_config(config.audio_config) + self.vocab_size = config.text_config.vocab_size + self.model = Qwen3ASRThinkerTextModel._from_config(config.text_config) + if "forced_aligner" in config.model_type: + self.lm_head = nn.Linear(config.text_config.hidden_size, config.classify_num, bias=False) + else: + self.lm_head = nn.Linear(config.text_config.hidden_size, config.text_config.vocab_size, bias=False) + self.pad_token_id = self.config.pad_token_id if self.config.pad_token_id is not None else -1 + self.rope_deltas = None + self.post_init() + + def get_input_embeddings(self): + return self.model.get_input_embeddings() + + def set_input_embeddings(self, value): + self.model.set_input_embeddings(value) + + def get_audio_features( + self, + input_features: torch.FloatTensor, + feature_attention_mask: Optional[torch.LongTensor] = None, + audio_feature_lengths: Optional[torch.LongTensor] = None, + ): + """ + Encodes audios into continuous embeddings that can be forwarded to the language model. + + Args: + input_features (`torch.FloatTensor`): + The tensors corresponding to the input audios. + feature_attention_mask (`torch.LongTensor`, *optional*): + Mask to avoid performing attention on padding feature indices. Mask values selected in `[0, 1]`: + audio_feature_lengths (`torch.LongTensor` of shape `(num_audios)`, *optional*): + The length of feature shape of each audio in LLM. + """ + if feature_attention_mask is not None: + audio_feature_lengths = torch.sum(feature_attention_mask, dim=1) + else: + audio_feature_lengths = None + feature_lens = audio_feature_lengths if audio_feature_lengths is not None else feature_attention_mask.sum(-1) + + # audio encoder do not support batch inference to keep precision + audio_features = [] + for input_feature, feature_len in zip(input_features, feature_lens): + audio_output = self.audio_tower( + input_feature[:, :feature_len], + feature_lens=feature_len.unsqueeze(0), + ) + audio_feature = audio_output.last_hidden_state + audio_features.append(audio_feature) + audio_features = torch.cat(audio_features, dim=0) + + return audio_features + + def get_placeholder_mask( + self, + input_ids: torch.LongTensor, + inputs_embeds: torch.FloatTensor, + ): + """ + Obtains multimodal placeholder mask from `input_ids` or `inputs_embeds`, and checks that the placeholder token count is + equal to the length of multimodal features. If the lengths are different, an error is raised. + """ + if input_ids is None: + special_audio_mask = ( + inputs_embeds + == self.get_input_embeddings()( + torch.tensor(self.config.audio_token_id, dtype=torch.long, device=inputs_embeds.device) + ) + ).all(-1) + else: + special_audio_mask = input_ids == self.config.audio_token_id + + special_audio_mask = special_audio_mask.unsqueeze(-1).expand_as(inputs_embeds).to(inputs_embeds.device) + return special_audio_mask + + @can_return_tuple + @auto_docstring + def forward( + self, + input_ids=None, + input_features=None, + attention_mask=None, + feature_attention_mask=None, + audio_feature_lengths=None, + position_ids=None, + past_key_values=None, + inputs_embeds=None, + rope_deltas=None, + labels=None, + use_cache=None, + cache_position=None, + **kwargs, + ) -> Union[tuple, Qwen3ASRThinkerCausalLMOutputWithPast]: + r""" + feature_attention_mask (`torch.Tensor` of shape `(batch_size, feature_sequence_length)`, *optional*): + Mask to avoid performing attention on padding feature indices. Mask values selected in `[0, 1]`: + - 1 for tokens that are **not masked**, + - 0 for tokens that are **masked**. + audio_feature_lengths (`torch.LongTensor` of shape `(num_audios)`, *optional*): + The length of feature shape of each audio in LLM. + rope_deltas (`torch.LongTensor` of shape `(batch_size, )`, *optional*): + The rope index difference between sequence length and multimodal rope. + labels (`torch.LongTensor` of shape `(batch_size, sequence_length)`, *optional*): + Labels for computing the masked language modeling loss. Indices should either be in `[0, ..., + config.vocab_size]` or -100 (see `input_ids` docstring). Tokens with indices set to `-100` are ignored + (masked), the loss is only computed for the tokens with labels in `[0, ..., config.vocab_size]`. + """ + + if inputs_embeds is None: + # 1. Extract the input embeddings + inputs_embeds = self.get_input_embeddings()(input_ids) + + # 2. Merge text, audios + if input_features is not None: + audio_features = self.get_audio_features( + input_features, + feature_attention_mask=feature_attention_mask, + audio_feature_lengths=audio_feature_lengths, + ) + audio_features = audio_features.to(inputs_embeds.device, inputs_embeds.dtype) + audio_mask = self.get_placeholder_mask(input_ids, inputs_embeds=inputs_embeds) + inputs_embeds = inputs_embeds.masked_scatter(audio_mask, audio_features) + + if feature_attention_mask is not None: + audio_feature_lengths = torch.sum(feature_attention_mask, dim=1) + else: + audio_feature_lengths = None + + if attention_mask is not None and position_ids is None: + if ( + cache_position is None + or (cache_position is not None and cache_position[0] == 0) + or self.rope_deltas is None + ): + delta0 = (1 - attention_mask).sum(dim=-1).unsqueeze(1) + position_ids, rope_deltas = self.get_rope_index( + attention_mask, + ) + rope_deltas = rope_deltas - delta0 + self.rope_deltas = rope_deltas + else: + batch_size, seq_length = input_ids.shape + delta = cache_position[0] + self.rope_deltas if cache_position is not None else 0 + position_ids = torch.arange(seq_length, device=input_ids.device) + position_ids = position_ids.view(1, -1).expand(batch_size, -1) + position_ids = position_ids.add(delta) + position_ids = position_ids.unsqueeze(0).expand(3, -1, -1) + + outputs = self.model( + attention_mask=attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + inputs_embeds=inputs_embeds, + use_cache=use_cache, + cache_position=cache_position, + **kwargs, + ) + + hidden_states = outputs[0] + logits = self.lm_head(hidden_states) + + loss = None + if labels is not None: + loss = self.loss_function( + logits=logits, labels=labels, vocab_size=self.config.get_text_config().vocab_size + ) + + return Qwen3ASRThinkerCausalLMOutputWithPast( + loss=loss, + logits=logits, + hidden_states=outputs.hidden_states, + attentions=outputs.attentions, + past_key_values=outputs.past_key_values, + rope_deltas=self.rope_deltas, + ) + + def prepare_inputs_for_generation( + self, + input_ids, + past_key_values=None, + attention_mask=None, + inputs_embeds=None, + cache_position=None, + position_ids=None, + use_cache=True, + input_features=None, + feature_attention_mask=None, + **kwargs, + ): + model_inputs = super().prepare_inputs_for_generation( + input_ids, + past_key_values=past_key_values, + attention_mask=attention_mask, + inputs_embeds=inputs_embeds, + cache_position=cache_position, + position_ids=position_ids, + use_cache=use_cache, + input_features=input_features, + feature_attention_mask=feature_attention_mask, + **kwargs, + ) + + model_inputs["position_ids"] = None + + if cache_position[0] != 0: + model_inputs["input_features"] = None + + return model_inputs + + +@auto_docstring +class Qwen3ASRThinkerTextPreTrainedModel(PreTrainedModel): + config = Qwen3ASRConfig + base_model_prefix = "model" + supports_gradient_checkpointing = True + _no_split_modules = ["Qwen3ASRThinkerTextDecoderLayer"] + _skip_keys_device_placement = ["past_key_values"] + _supports_flash_attn = True + _supports_sdpa = True + _supports_flex_attn = True + _can_compile_fullgraph = False # MoE models don't work with torch.compile (`torch.where(condition)` not supported) + _supports_attention_backend = True + _can_record_outputs = { + "hidden_states": Qwen3ASRThinkerTextDecoderLayer, + "attentions": Qwen3ASRThinkerTextAttention, + } + config_class = Qwen3ASRConfig + + +class Qwen3ASRForConditionalGeneration(Qwen3ASRPreTrainedModel, GenerationMixin): + config_class = Qwen3ASRConfig + + def __init__(self, config: Qwen3ASRConfig): + super().__init__(config) + self.config = config + + self.thinker = Qwen3ASRThinkerForConditionalGeneration._from_config(config.thinker_config) + self.post_init() + + def get_support_languages(self): + return self.config.support_languages + + @torch.no_grad() + def generate( + self, + input_ids: Optional[torch.Tensor] = None, + max_new_tokens: int = 4096, + eos_token_id: int | list[int] = [151645, 151643], + **kwargs, + ): + shared_kwargs = {} + thinker_kwargs = { + "max_new_tokens": max_new_tokens, + "eos_token_id": eos_token_id, + } + + for key, value in kwargs.items(): + # Process special input values + if key == "feature_attention_mask": + thinker_kwargs[key] = value + elif key in ("input_features", "attention_mask"): + thinker_kwargs[key] = value + # Put other key to shared kwargs + else: + shared_kwargs[key] = value + + # Merge kwargs + for key, value in shared_kwargs.items(): + if key not in thinker_kwargs: + thinker_kwargs[key] = value + + thinker_result = self.thinker.generate(input_ids=input_ids, return_dict_in_generate=True, **thinker_kwargs) + + return thinker_result + + +__all__ = [ + "Qwen3ASRForConditionalGeneration", + "Qwen3ASRThinkerTextModel", + "Qwen3ASRThinkerForConditionalGeneration", + "Qwen3ASRPreTrainedModel", + "Qwen3ASRPreTrainedModelForConditionalGeneration", + "Qwen3ASRThinkerTextPreTrainedModel", +] diff --git a/vendor/qwen-asr/qwen_asr/core/transformers_backend/processing_qwen3_asr.py b/vendor/qwen-asr/qwen_asr/core/transformers_backend/processing_qwen3_asr.py new file mode 100644 index 00000000..b63f0c0f --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/core/transformers_backend/processing_qwen3_asr.py @@ -0,0 +1,209 @@ +# coding=utf-8 +# Copyright 2026 The Qwen team, Alibaba Group and the HuggingFace Inc. team. All rights reserved. +# +# 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. +import re + +import numpy as np + +from transformers.audio_utils import AudioInput +from transformers.feature_extraction_utils import BatchFeature +from transformers.processing_utils import ProcessingKwargs, ProcessorMixin +from transformers.tokenization_utils_base import TextInput + + +class Qwen3ASRProcessorKwargs(ProcessingKwargs, total=False): + _defaults = { + "text_kwargs": { + "padding": False, + "padding_side": "left", + }, + "audio_kwargs": { + "sampling_rate": 16000, + "padding": True, + "return_attention_mask": True, + }, + } + + +def _get_feat_extract_output_lengths(input_lengths): + """ + Computes the output length of the convolutional layers and the output length of the audio encoder + """ + + input_lengths_leave = input_lengths % 100 + feat_lengths = (input_lengths_leave - 1) // 2 + 1 + output_lengths = ((feat_lengths - 1) // 2 + 1 - 1) // 2 + 1 + (input_lengths // 100) * 13 + return output_lengths + + +class Qwen3ASRProcessor(ProcessorMixin): + r""" + Constructs a Qwen3ASR processor. + [`Qwen3ASRProcessor`] offers all the functionalities of [`WhisperFeatureExtractor`], and [`Qwen2TokenizerFast`]. See the + [`~Qwen3ASRProcessor.__call__`] and [`~Qwen3ASRProcessor.decode`] for more information. + + Args: + feature_extractor ([`WhisperFeatureExtractor`], *optional*): + The audio feature extractor. + tokenizer ([`Qwen2TokenizerFast`], *optional*): + The text tokenizer. + chat_template (`Optional[str]`, *optional*): + The Jinja template to use for formatting the conversation. If not provided, the default chat template is used. + """ + + attributes = ["feature_extractor", "tokenizer"] + feature_extractor_class = "WhisperFeatureExtractor" + tokenizer_class = ("Qwen2Tokenizer", "Qwen2TokenizerFast") + + def __init__( + self, feature_extractor=None, tokenizer=None, chat_template=None + ): + super().__init__(feature_extractor, tokenizer, chat_template=chat_template) + self.audio_token = self.tokenizer.audio_token + self.audio_bos_token = self.tokenizer.audio_bos_token + self.audio_eos_token = self.tokenizer.audio_eos_token + + def __call__( + self, + text: TextInput = None, + audio: AudioInput = None, + **kwargs, + ) -> BatchFeature: + """ + Main method to prepare for the model one or several sequences(s) and audio(s). This method forwards the `text` + and `kwargs` arguments to Qwen2TokenizerFast's [`~Qwen2TokenizerFast.__call__`] if `text` is not `None` to encode + the text. To prepare the audio(s), this method forwards the `audio` and `kwargs` arguments to + WhisperFeatureExtractor's [`~WhisperFeatureExtractor.__call__`] if `audio` is not `None`. Please refer to the doctsring + of the above two methods for more information. + + Args: + text (`str`, `List[str]`, `List[List[str]]`): + The sequence or batch of sequences to be encoded. Each sequence can be a string or a list of strings + (pretokenized string). If the sequences are provided as list of strings (pretokenized), you must set + `is_split_into_words=True` (to lift the ambiguity with a batch of sequences). + audio (`np.ndarray`, `List[np.ndarray]`): + The audio or batch of audio to be prepared. Each audio can be a NumPy array. + """ + + if text is None: + raise ValueError("You need to specify either a `text` input to process.") + + output_kwargs = self._merge_kwargs( + Qwen3ASRProcessorKwargs, + tokenizer_init_kwargs=self.tokenizer.init_kwargs, + **kwargs, + ) + + if audio is not None: + output_kwargs["audio_kwargs"]["padding"] = True + output_kwargs["audio_kwargs"]["truncation"] = False + audio_inputs = self.feature_extractor(audio, **output_kwargs["audio_kwargs"]) + audio_inputs["feature_attention_mask"] = audio_inputs.pop( + "attention_mask" + ) # rename feature_attention_mask to prevent conflicts later on + audio_inputs["input_features"] = audio_inputs.pop( + "input_features" + ) # rename input_features to prevent conflicts later on + audio_lengths = iter(_get_feat_extract_output_lengths(audio_inputs["feature_attention_mask"].sum(-1))) + else: + audio_inputs = {} + audio_lengths = iter([]) + + if not isinstance(text, list): + text = [text] + + text = self.replace_multimodal_special_tokens( + text, + audio_lengths, + ) + + texts_inputs = self.tokenizer(text, **output_kwargs["text_kwargs"]) + + return BatchFeature( + data={**texts_inputs, **audio_inputs}, + tensor_type=kwargs.get("return_tensors"), + ) + + def replace_multimodal_special_tokens( + self, + text, + audio_lengths, + ): + + processed_text = [] + for sample in text: + positions = [] + special_tokens = [re.escape(tok) for tok in [self.audio_token]] + pattern = "|".join(special_tokens) + positions = sorted([(match.start(), match.group()) for match in re.finditer(pattern, sample)]) + positions.sort(key=lambda x: x[0]) + + for _, special_token in positions: + if special_token == self.audio_token: + sample = sample.replace(self.audio_token, "<|audio_placeholder|>" * next(audio_lengths), 1) + + sample = sample.replace("<|audio_placeholder|>", self.audio_token) + processed_text.append(sample) + return processed_text + + def get_chunked_index(self, token_indices: np.ndarray, tokens_per_chunk: int) -> list[tuple[int, int]]: + """ + Splits token index list into chunks based on token value ranges. + + Given a list of token indices, returns a list of (start, end) index tuples representing + slices of the list where the token values fall within successive ranges of `t_ntoken_per_chunk`. + + For example, if `t_ntoken_per_chunk` is 1000, the function will create chunks such that: + - the first chunk contains token values < 1000, + - the second chunk contains values >= 1000 and < 2000, and so on. + + Parameters: + token_indices (`np.ndarray`): A monotonically increasing list of token index values. + t_ntoken_per_chunk (`int`): Number of tokens per chunk (used as the chunk size threshold). + + Returns: + `list[tuple[int, int]]`: A list of tuples, each representing the start (inclusive) + and end (exclusive) indices of a chunk in `token_indices`. + """ + + def _iter(): + i, start_idx = 0, 0 # skip bos token + current_chunk = 1 + while i < len(token_indices): # skip eos token + if token_indices[i] >= current_chunk * tokens_per_chunk: + yield (start_idx, i) + start_idx = i + current_chunk += 1 + i += 1 + yield (start_idx, len(token_indices)) + + return list(_iter()) + + def apply_chat_template(self, conversations, chat_template=None, **kwargs): + return super().apply_chat_template(conversations, chat_template, **kwargs) + + @property + def model_input_names(self): + tokenizer_input_names = self.tokenizer.model_input_names + feature_extractor_input_names = self.feature_extractor.model_input_names + return list( + dict.fromkeys( + tokenizer_input_names + + feature_extractor_input_names + + ["feature_attention_mask"] + ) + ) + + +__all__ = ["Qwen3ASRProcessor"] diff --git a/vendor/qwen-asr/qwen_asr/core/vllm_backend/__init__.py b/vendor/qwen-asr/qwen_asr/core/vllm_backend/__init__.py new file mode 100644 index 00000000..0eef629a --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/core/vllm_backend/__init__.py @@ -0,0 +1,16 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +from .qwen3_asr import Qwen3ASRForConditionalGeneration \ No newline at end of file diff --git a/vendor/qwen-asr/qwen_asr/core/vllm_backend/qwen3_asr.py b/vendor/qwen-asr/qwen_asr/core/vllm_backend/qwen3_asr.py new file mode 100644 index 00000000..db36219d --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/core/vllm_backend/qwen3_asr.py @@ -0,0 +1,997 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Copyright 2026 The Qwen team. +# Copyright 2023 The vLLM team. +# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved. +# +# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX +# and OPT implementations in this library. It has been modified from its +# original forms to accommodate minor architectural differences compared +# to GPT-NeoX and OPT used by the Meta AI team that trained the model. +# +# 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. +"""Inference-only Qwen3-ASR model.""" + +from collections.abc import Iterable, Mapping, Sequence +from typing import Any, Literal, cast + +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +from transformers.feature_extraction_utils import BatchFeature +from transformers.models.whisper import WhisperFeatureExtractor + +from vllm.config import MultiModalConfig, ModelConfig, SpeechToTextConfig, VllmConfig +from vllm.config.multimodal import BaseDummyOptions +from vllm.distributed import get_tensor_model_parallel_world_size +from vllm.inputs.data import PromptType +from vllm.logger import init_logger +from vllm.model_executor.layers.activation import _ACTIVATION_REGISTRY +from vllm.model_executor.layers.attention.mm_encoder_attention import ( + MMEncoderAttention, +) +from vllm.model_executor.layers.linear import ( + ColumnParallelLinear, + QKVParallelLinear, + RowParallelLinear, +) +from vllm.model_executor.model_loader.weight_utils import default_weight_loader +from vllm.model_executor.models.interfaces import ( + MultiModalEmbeddings, + SupportsMRoPE, + SupportsMultiModal, + SupportsPP, + SupportsTranscription, +) +from vllm.model_executor.models.module_mapping import MultiModelKeys +from vllm.model_executor.models.qwen3 import Qwen3ForCausalLM +from vllm.model_executor.models.qwen3_omni_moe_thinker import ( + Qwen2_5OmniAudioFeatureInputs, + Qwen3OmniMoeThinkerMultiModalProcessor, +) +from vllm.model_executor.models.utils import ( + AutoWeightsLoader, + WeightsMapper, + _merge_multimodal_embeddings, + maybe_prefix, +) +from vllm.model_executor.models.whisper import ISO639_1_SUPPORTED_LANGS +from vllm.multimodal import MULTIMODAL_REGISTRY +from vllm.multimodal.inputs import ( + AudioItem, + ModalityData, + MultiModalDataDict, + MultiModalFeatureSpec, + MultiModalFieldConfig, + MultiModalKwargsItems, +) +from vllm.multimodal.parse import ( + AudioProcessorItems, + DictEmbeddingItems, + ModalityDataItems, + MultiModalDataItems, + MultiModalDataParser, +) +from vllm.multimodal.processing import ( + BaseProcessingInfo, + PromptReplacement, + PromptUpdate, +) +from vllm.sequence import IntermediateTensors +from vllm.v1.attention.backends.registry import AttentionBackendEnum +from vllm.tokenizers import cached_tokenizer_from_config +from vllm.transformers_utils.processor import cached_processor_from_config +from vllm.model_executor.models.vision import ( + get_vit_attn_backend, +) +from ..transformers_backend.configuration_qwen3_asr import ( + Qwen3ASRConfig, + Qwen3ASRThinkerConfig, + Qwen3ASRAudioEncoderConfig +) +from ..transformers_backend.processing_qwen3_asr import ( + Qwen3ASRProcessor, +) + +try: + from vllm.multimodal.profiling import BaseDummyInputsBuilder +except: + from vllm.multimodal.processing import BaseDummyInputsBuilder + +logger = init_logger(__name__) + + +def _get_feat_extract_output_lengths(input_lengths: torch.Tensor): + input_lengths_leave = input_lengths % 100 + feat_lengths = (input_lengths_leave - 1) // 2 + 1 + output_lengths = ( + ((feat_lengths - 1) // 2 + 1 - 1) // 2 + 1 + (input_lengths // 100) * 13 + ) + return output_lengths + + +# ============= Audio Encoder Components ============= + + +class SinusoidsPositionEmbedding(nn.Module): + """Sinusoidal position embedding for audio encoder.""" + + def __init__(self, length: int, channels: int, max_timescale: int = 10000): + super().__init__() + self.length = length + self.channels = channels + self.max_timescale = max_timescale + + if channels % 2 != 0: + raise ValueError("SinusoidsPositionEmbedding needs even channels input") + + log_timescale_increment = np.log(max_timescale) / (channels // 2 - 1) + inv_timescales = torch.exp( + -log_timescale_increment * torch.arange(channels // 2).float() + ) + scaled_time = ( + torch.arange(length)[:, np.newaxis] * inv_timescales[np.newaxis, :] + ) + positional_embedding = torch.cat( + [torch.sin(scaled_time), torch.cos(scaled_time)], dim=1 + ) + self.register_buffer( + "positional_embedding", positional_embedding, persistent=False + ) + + def forward(self, seqlen: int) -> torch.Tensor: + return self.positional_embedding[:seqlen, :] + + +class Qwen3ASRAudioAttention(nn.Module): + """Multi-headed attention for Qwen3-Omni Audio Encoder using MMEncoderAttention.""" + + def __init__( + self, + config: Qwen3ASRAudioEncoderConfig, + multimodal_config: MultiModalConfig | None = None, + prefix: str = "", + ): + super().__init__() + self.embed_dim = config.d_model + self.num_heads = config.encoder_attention_heads + self.head_dim = self.embed_dim // self.num_heads + tp_size = get_tensor_model_parallel_world_size() + self.num_local_heads = self.num_heads // tp_size + + if (self.head_dim * self.num_heads) != self.embed_dim: + raise ValueError( + f"embed_dim must be divisible by num_heads (got `embed_dim`: " + f"{self.embed_dim} and `num_heads`: {self.num_heads})." + ) + + self.scaling = self.head_dim**-0.5 + + self.qkv = QKVParallelLinear( + hidden_size=self.embed_dim, + head_size=self.head_dim, + total_num_heads=self.num_heads, + total_num_kv_heads=self.num_heads, + bias=True, + prefix=f"{prefix}.qkv", + ) + + self.out_proj = RowParallelLinear( + input_size=self.embed_dim, + output_size=self.embed_dim, + bias=True, + prefix=f"{prefix}.out_proj", + ) + + self.attn = MMEncoderAttention( + num_heads=self.num_local_heads, + head_size=self.head_dim, + scale=self.scaling, + multimodal_config=multimodal_config, + ) + + def forward( + self, + hidden_states: torch.Tensor, + cu_seqlens: torch.Tensor, + max_seqlen: torch.Tensor | None, + ) -> torch.Tensor: + seq_length, _ = hidden_states.size() + qkv, _ = self.qkv(hidden_states) + q, k, v = qkv.chunk(3, dim=-1) + q = q.view(1, seq_length, -1, self.head_dim) + k = k.view(1, seq_length, -1, self.head_dim) + v = v.view(1, seq_length, -1, self.head_dim) + + attn_output = self.attn( + query=q, + key=k, + value=v, + cu_seqlens=cu_seqlens, + max_seqlen=max_seqlen, + ) + + attn_output = attn_output.view(seq_length, -1) + output, _ = self.out_proj(attn_output) + return output + + +class Qwen3ASRAudioEncoderLayer(nn.Module): + """Transformer encoder layer for Qwen3-Omni Audio Encoder.""" + + def __init__( + self, + config: Qwen3ASRAudioEncoderConfig, + multimodal_config: MultiModalConfig | None = None, + prefix: str = "", + ): + super().__init__() + self.embed_dim = config.d_model + self.self_attn = Qwen3ASRAudioAttention( + config, multimodal_config=multimodal_config, prefix=f"{prefix}.self_attn" + ) + self.self_attn_layer_norm = nn.LayerNorm(self.embed_dim) + self.activation_fn = _ACTIVATION_REGISTRY[config.activation_function] + self.fc1 = ColumnParallelLinear( + self.embed_dim, + config.encoder_ffn_dim, + bias=True, + prefix=f"{prefix}.fc1", + ) + self.fc2 = RowParallelLinear( + config.encoder_ffn_dim, + self.embed_dim, + bias=True, + prefix=f"{prefix}.fc2", + ) + self.final_layer_norm = nn.LayerNorm(self.embed_dim) + + def forward( + self, + hidden_states: torch.Tensor, + cu_seqlens: torch.Tensor, + max_seqlen: torch.Tensor | None, + ) -> torch.Tensor: + """ + Args: + hidden_states: Input tensor of shape (seq_len, hidden_size) + cu_seqlens: Cumulative sequence lengths + max_seqlen: Maximum sequence length in the batch + """ + residual = hidden_states + hidden_states = self.self_attn_layer_norm(hidden_states) + hidden_states = self.self_attn( + hidden_states=hidden_states, + cu_seqlens=cu_seqlens, + max_seqlen=max_seqlen, + ) + hidden_states = residual + hidden_states + + residual = hidden_states + hidden_states = self.final_layer_norm(hidden_states) + hidden_states, _ = self.fc1(hidden_states) + hidden_states = self.activation_fn(hidden_states) + hidden_states, _ = self.fc2(hidden_states) + hidden_states = residual + hidden_states + + # Clamp for numerical stability with fp16 + if hidden_states.dtype == torch.float16: + clamp_value = torch.finfo(hidden_states.dtype).max - 1000 + hidden_states = torch.clamp( + hidden_states, min=-clamp_value, max=clamp_value + ) + + return hidden_states + + +class Qwen3ASRAudioEncoder(nn.Module): + """vLLM-native Qwen3-ASR Audio Encoder.""" + + def __init__( + self, + config: Qwen3ASRAudioEncoderConfig, + multimodal_config: MultiModalConfig | None = None, + prefix: str = "", + ): + super().__init__() + + embed_dim = config.d_model + self.num_mel_bins = config.num_mel_bins + self.max_source_positions = config.max_source_positions + self.n_window = config.n_window + self.n_window_infer = config.n_window_infer + self.conv_chunksize = config.conv_chunksize + + # Position embedding + self.positional_embedding = SinusoidsPositionEmbedding( + self.max_source_positions, embed_dim + ) + + # Convolutional layers for mel-spectrogram processing + self.conv2d1 = nn.Conv2d(1, config.downsample_hidden_size, 3, 2, padding=1) + self.conv2d2 = nn.Conv2d( + config.downsample_hidden_size, + config.downsample_hidden_size, + 3, + 2, + padding=1, + ) + self.conv2d3 = nn.Conv2d( + config.downsample_hidden_size, + config.downsample_hidden_size, + 3, + 2, + padding=1, + ) + + conv_out_dim = config.downsample_hidden_size * ( + (((config.num_mel_bins + 1) // 2 + 1) // 2 + 1) // 2 + ) + self.conv_out = nn.Linear(conv_out_dim, config.d_model, bias=False) + + # Transformer encoder layers + self.layers = nn.ModuleList( + [ + Qwen3ASRAudioEncoderLayer( + config, + multimodal_config=multimodal_config, + prefix=f"{prefix}.layers.{i}", + ) + for i in range(config.encoder_layers) + ] + ) + + # Output layers + self.ln_post = nn.LayerNorm(config.d_model) + self.proj1 = nn.Linear(config.d_model, config.d_model) + self.act = _ACTIVATION_REGISTRY[config.activation_function] + self.proj2 = nn.Linear(config.d_model, config.output_dim) + + # Get attention backend + attn_backend_override = ( + multimodal_config.mm_encoder_attn_backend + if multimodal_config is not None + else None + ) + self.attn_backend = get_vit_attn_backend( + head_size=config.d_model // config.encoder_attention_heads, + dtype=torch.get_default_dtype(), + attn_backend_override=attn_backend_override, + ) + + def compute_attn_mask_seqlen(self, cu_seqlens: torch.Tensor) -> torch.Tensor | None: + """Compute max_seqlen only for flash attention backends.""" + max_seqlen = None + if self.attn_backend in { + AttentionBackendEnum.FLASH_ATTN, + AttentionBackendEnum.ROCM_AITER_FA, + }: + max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max() + return max_seqlen + + @property + def dtype(self) -> torch.dtype: + return self.conv2d1.weight.dtype + + @property + def device(self) -> torch.device: + return self.conv2d1.weight.device + + def forward( + self, + input_features: torch.Tensor, + feature_lens: torch.Tensor, + aftercnn_lens: torch.Tensor, + ): + # Compute chunk information + chunk_num = torch.ceil(feature_lens / (self.n_window * 2)).long() + + chunk_lengths = torch.tensor( + [self.n_window * 2] * chunk_num.sum(), + dtype=torch.long, + device=feature_lens.device, + ) + tail_chunk_index = F.pad(chunk_num, (1, 0), value=-1).cumsum(0)[1:] + chunk_lengths[tail_chunk_index] = feature_lens % (self.n_window * 2) + chunk_lengths[chunk_lengths == 0] = self.n_window * 2 + + # Split input features into chunks and pad + chunk_list = input_features.T.split(chunk_lengths.tolist(), dim=0) + padded_feature = nn.utils.rnn.pad_sequence( + chunk_list, batch_first=True + ).transpose(1, 2) + + # Compute feature lengths after CNN + feature_lens_after_cnn = self._get_cnn_output_lengths(chunk_lengths) + # Vectorized mask creation: avoid creating many small tensors + max_len_after_cnn = feature_lens_after_cnn.max().item() + indices = torch.arange(max_len_after_cnn, device=padded_feature.device) + padded_mask_after_cnn = indices.unsqueeze(0) < feature_lens_after_cnn.unsqueeze( + 1 + ) + + # Add channel dimension for conv2d + padded_feature = padded_feature.unsqueeze(1) + + # Apply convolutional layers (chunk if needed to avoid OOM) + if padded_feature.size(0) <= self.conv_chunksize: + # Fast path: no chunking needed + padded_embed = F.gelu(self.conv2d1(padded_feature)) + padded_embed = F.gelu(self.conv2d2(padded_embed)) + padded_embed = F.gelu(self.conv2d3(padded_embed)) + else: + # Chunked processing to avoid OOM + padded_embeds = [] + for chunk in padded_feature.split(self.conv_chunksize, dim=0): + padded_embed = F.gelu(self.conv2d1(chunk)) + padded_embed = F.gelu(self.conv2d2(padded_embed)) + padded_embed = F.gelu(self.conv2d3(padded_embed)) + padded_embeds.append(padded_embed) + padded_embed = torch.cat(padded_embeds, dim=0) + + # (batch, channels, freq, time) -> (batch, time, channels*freq) + b, c, f, t = padded_embed.size() + padded_embed = self.conv_out( + padded_embed.permute(0, 3, 1, 2).contiguous().view(b, t, c * f) + ) + + # Add positional embedding + positional_embedding = ( + self.positional_embedding.positional_embedding[: padded_embed.shape[1], :] + .unsqueeze(0) + .to(padded_embed.dtype) + ) + padded_embed = padded_embed + positional_embedding + + # Extract valid hidden states and compute cu_seqlens + hidden_states = padded_embed[padded_mask_after_cnn] + + # Compute cumulative sequence lengths for chunked attention + cu_chunk_lens = [0] + window_aftercnn = padded_mask_after_cnn.shape[-1] * ( + self.n_window_infer // (self.n_window * 2) + ) + # Use tolist() for efficient batch conversion from tensor to Python + for cnn_len in aftercnn_lens.tolist(): + num_full_chunks = cnn_len // window_aftercnn + remainder = cnn_len % window_aftercnn + cu_chunk_lens.extend([window_aftercnn] * num_full_chunks) + if remainder: + cu_chunk_lens.append(remainder) + cu_seqlens = torch.tensor(cu_chunk_lens, device=aftercnn_lens.device).cumsum( + -1, dtype=torch.int32 + ) + + max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens) + + # Apply transformer layers + for encoder_layer in self.layers: + hidden_states = encoder_layer( + hidden_states, + cu_seqlens, + max_seqlen, + ) + + # Apply output layers + hidden_states = self.ln_post(hidden_states) + hidden_states = self.proj1(hidden_states) + hidden_states = self.act(hidden_states) + hidden_states = self.proj2(hidden_states) + + return hidden_states + + def _get_cnn_output_lengths(self, input_lengths: torch.Tensor) -> torch.Tensor: + """Compute output lengths after the three conv2d layers.""" + lengths = input_lengths + for _ in range(3): + lengths = (lengths - 1) // 2 + 1 + return lengths + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + """Load weights with mapping from HuggingFace format.""" + stacked_params_mapping = [ + # (param_name, shard_name, shard_id) + ("self_attn.qkv.", "self_attn.q_proj.", "q"), + ("self_attn.qkv.", "self_attn.k_proj.", "k"), + ("self_attn.qkv.", "self_attn.v_proj.", "v"), + ] + params_dict = dict(self.named_parameters(remove_duplicate=False)) + loaded_params: set[str] = set() + + for name, loaded_weight in weights: + for param_name, weight_name, shard_id in stacked_params_mapping: + if weight_name not in name: + continue + name = name.replace(weight_name, param_name) + + param = params_dict[name] + weight_loader = param.weight_loader + weight_loader(param, loaded_weight, shard_id) + break + else: + param = params_dict.get(name) + if param is not None: + weight_loader = getattr( + param, "weight_loader", default_weight_loader + ) + weight_loader(param, loaded_weight) + loaded_params.add(name) + return loaded_params + + +class Qwen3ASRProcessingInfo(BaseProcessingInfo): + def get_hf_config(self): + return self.ctx.get_hf_config(Qwen3ASRConfig).thinker_config + + def get_hf_processor(self, **kwargs: object) -> Qwen3ASRProcessor: + processor = self.ctx.get_hf_processor( + Qwen3ASRProcessor, + use_fast=kwargs.pop("use_fast", True), + **kwargs, + ) + if not hasattr(processor, "audio_token"): + processor.audio_token = "<|audio_pad|>" + return processor + + def get_feature_extractor(self, **kwargs: object) -> WhisperFeatureExtractor: + hf_processor = self.get_hf_processor(**kwargs) + feature_extractor = hf_processor.feature_extractor + assert isinstance(feature_extractor, WhisperFeatureExtractor) + return feature_extractor + + def get_supported_mm_limits(self) -> Mapping[str, int | None]: + return {"audio": None} + + +class Qwen3ASRDummyInputsBuilder(BaseDummyInputsBuilder[Qwen3ASRProcessingInfo]): + def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str: + num_audios = mm_counts.get("audio", 0) + + hf_processor = self.info.get_hf_processor() + audio_token = hf_processor.audio_token + + return audio_token * num_audios + + def get_dummy_mm_data( + self, + seq_len: int, + mm_counts: Mapping[str, int], + mm_options: Mapping[str, BaseDummyOptions] | None = None, + ) -> MultiModalDataDict: + num_audios = mm_counts.get("audio", 0) + + feature_extractor = self.info.get_feature_extractor() + + target_audio_length = ( + min( + feature_extractor.chunk_length, + 30, + ) + * feature_extractor.sampling_rate + ) + + audio_overrides = mm_options.get("audio") if mm_options else None + + return { + "audio": self._get_dummy_audios( + length=target_audio_length, + num_audios=num_audios, + overrides=audio_overrides, + ), + } + + +def _qwen3asr_field_config(hf_inputs: Mapping[str, torch.Tensor]): + audio_feature_lengths = hf_inputs.get("audio_feature_lengths", torch.empty((0,))) + return dict( + input_audio_features=MultiModalFieldConfig.flat_from_sizes( + "audio", audio_feature_lengths, dim=1 + ), + feature_attention_mask=MultiModalFieldConfig.batched("audio"), + audio_feature_lengths=MultiModalFieldConfig.batched("audio"), + ) + + +class Qwen3ASRMultiModalDataParser(MultiModalDataParser): + def _parse_audio_data( + self, + data: dict[str, torch.Tensor] | ModalityData[AudioItem], + ) -> ModalityDataItems[Any, Any] | None: + if isinstance(data, dict): + return DictEmbeddingItems( + data, + modality="audio", + required_fields={"input_audio_features", "audio_feature_lengths"}, + fields_factory=_qwen3asr_field_config, + ) + + return super()._parse_audio_data(data) + + +class Qwen3ASRMultiModalProcessor( + Qwen3OmniMoeThinkerMultiModalProcessor, +): + def _get_data_parser(self) -> MultiModalDataParser: + feature_extractor = self.info.get_feature_extractor() + return Qwen3ASRMultiModalDataParser( + target_sr=feature_extractor.sampling_rate, + ) + + def _get_mm_fields_config( + self, + hf_inputs: BatchFeature, + hf_processor_mm_kwargs: Mapping[str, object], + ) -> Mapping[str, MultiModalFieldConfig]: + return _qwen3asr_field_config(hf_inputs) + + def _get_prompt_updates( + self, + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, Any], + out_mm_kwargs: MultiModalKwargsItems, + ) -> Sequence[PromptUpdate]: + processor = self.info.get_hf_processor(**hf_processor_mm_kwargs) + tokenizer = self.info.get_tokenizer() + vocab = tokenizer.get_vocab() + + audio_token = processor.audio_token + audio_token_id = vocab[audio_token] + + out_mm_data = out_mm_kwargs.get_data() + audio_feature_lengths = out_mm_data.get("audio_feature_lengths") + feature_attention_mask = out_mm_data.get("feature_attention_mask") + if audio_feature_lengths is None and feature_attention_mask is None: + audio_output_lengths = [] + elif audio_feature_lengths is not None: + audio_output_lens = _get_feat_extract_output_lengths(audio_feature_lengths) + audio_output_lengths = audio_output_lens.tolist() + elif feature_attention_mask is not None: + assert isinstance(feature_attention_mask, torch.Tensor) + audio_output_lens = _get_feat_extract_output_lengths( + feature_attention_mask.sum(-1) + ) + audio_output_lengths = audio_output_lens.tolist() + + def get_replacement_qwen2_audio(item_idx: int): + num_features = audio_output_lengths[item_idx] + if num_features == 0: + audios = mm_items.get_items("audio", AudioProcessorItems) + audio = audios.get(item_idx) + raise ValueError( + f"The audio {audio} (len={len(audio)}) is too short " + "to be represented inside the model" + ) + + return [audio_token_id] * num_features + + return [ + PromptReplacement( + modality="audio", + target=audio_token, + replacement=get_replacement_qwen2_audio, + ), + ] + + +@MULTIMODAL_REGISTRY.register_processor( + Qwen3ASRMultiModalProcessor, + info=Qwen3ASRProcessingInfo, + dummy_inputs=Qwen3ASRDummyInputsBuilder, +) +class Qwen3ASRForConditionalGeneration( + nn.Module, + SupportsMultiModal, + SupportsPP, + SupportsMRoPE, + SupportsTranscription, +): + supported_languages = ISO639_1_SUPPORTED_LANGS + + hf_to_vllm_mapper = WeightsMapper( + orig_to_new_prefix={ + "thinker.lm_head.": "language_model.lm_head.", + "thinker.model.": "language_model.model.", + "thinker.": "", + } + ) + + @classmethod + def get_placeholder_str(cls, modality: str, i: int) -> str | None: + if modality.startswith("audio"): + return "<|audio_start|><|audio_pad|><|audio_end|>" + + raise ValueError("Only audio modality is supported") + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + self.vllm_config = vllm_config # needed for torch compile forward context + thinker_config: Qwen3ASRThinkerConfig = ( + vllm_config.model_config.hf_config.thinker_config + ) + quant_config = vllm_config.quant_config + multimodal_config = vllm_config.model_config.multimodal_config + self.config = thinker_config + self.multimodal_config = multimodal_config + + self.audio_tower = Qwen3ASRAudioEncoder( + thinker_config.audio_config, + multimodal_config=multimodal_config, + prefix=maybe_prefix(prefix, "audio_tower"), + ) + self.quant_config = quant_config + + self.language_model = Qwen3ForCausalLM( + vllm_config=vllm_config.with_hf_config( + thinker_config.text_config, architectures=["Qwen3ForCausalLM"] + ), + prefix=maybe_prefix(prefix, "language_model"), + ) + + self.make_empty_intermediate_tensors = ( + self.language_model.make_empty_intermediate_tensors + ) + + def _parse_and_validate_audio_input( + self, **kwargs: object + ) -> Qwen2_5OmniAudioFeatureInputs | None: + input_audio_features = kwargs.pop("input_audio_features", None) + audio_feature_lengths = kwargs.pop("audio_feature_lengths", None) + feature_attention_mask = kwargs.pop("feature_attention_mask", None) + if input_audio_features is None: + return None + + return Qwen2_5OmniAudioFeatureInputs( + type="audio_features", + input_features=input_audio_features, + audio_feature_lengths=audio_feature_lengths, + feature_attention_mask=feature_attention_mask, + ) + + def _parse_and_validate_multimodal_inputs(self, **kwargs: object) -> dict: + mm_input_by_modality = {} + + # Preserve the order of modalities if there are multiple of them + # from the order of kwargs. + for input_key in kwargs: + if ( + input_key in ("input_audio_features") + and "audio" not in mm_input_by_modality + ): + mm_input_by_modality["audio"] = self._parse_and_validate_audio_input( + **kwargs + ) + return mm_input_by_modality + + def _process_audio_input( + self, + audio_input: Qwen2_5OmniAudioFeatureInputs, + audio_hashes: list[str] | None = None, + cached_audio_features: torch.Tensor | None = None, + ) -> torch.Tensor: + input_features = audio_input["input_features"] + audio_feature_lengths = audio_input["audio_feature_lengths"] + + audio_output_lengths = _get_feat_extract_output_lengths(audio_feature_lengths) + + audio_features = self.audio_tower( + input_features.to(self.audio_tower.dtype), + feature_lens=audio_feature_lengths, + aftercnn_lens=audio_output_lengths, + ) + return audio_features.split(audio_output_lengths.tolist()) + + def get_language_model(self) -> torch.nn.Module: + return self.language_model + + def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings | None: + mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs) + if not mm_input_by_modality: + return [] + + # The result multimodal_embeddings is tuple of tensors, with each + # tensor correspoending to a multimodal data item (image or video). + multimodal_embeddings: tuple[torch.Tensor, ...] = () + + # NOTE: It is important to iterate over the keys in this dictionary + # to preserve the order of the modalities. + for modality in mm_input_by_modality: + multimodal_input = mm_input_by_modality[modality] + if modality == "audio": + audio_embeddings = self._process_audio_input(multimodal_input) + multimodal_embeddings += tuple(audio_embeddings) + return multimodal_embeddings + + def embed_input_ids( + self, + input_ids: torch.Tensor, + multimodal_embeddings: MultiModalEmbeddings | None = None, + *, + is_multimodal: torch.Tensor | None = None, + handle_oov_mm_token: bool = False, + ) -> torch.Tensor: + inputs_embeds = self._embed_text_input_ids( + input_ids, + self.language_model.embed_input_ids, + is_multimodal=is_multimodal, + handle_oov_mm_token=handle_oov_mm_token, + ) + + if multimodal_embeddings is None or len(multimodal_embeddings) == 0: + return inputs_embeds + + inputs_embeds = _merge_multimodal_embeddings( + inputs_embeds=inputs_embeds, + multimodal_embeddings=multimodal_embeddings, + is_multimodal=is_multimodal, + ) + + return inputs_embeds + + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + intermediate_tensors: IntermediateTensors | None = None, + inputs_embeds: torch.Tensor | None = None, + **kwargs: object, + ) -> torch.Tensor | IntermediateTensors: + if intermediate_tensors is not None: + inputs_embeds = None + + hidden_states = self.language_model.model( + input_ids, + positions, + intermediate_tensors, + inputs_embeds=inputs_embeds, + ) + + return hidden_states + + def compute_logits( + self, + hidden_states: torch.Tensor, + ) -> torch.Tensor | None: + return self.language_model.compute_logits(hidden_states) + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + loader = AutoWeightsLoader( + self, + skip_prefixes=["talker.", "code2wav."], + ) + loaded_weights = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper) + + return loaded_weights + + def get_mrope_input_positions( + self, + input_tokens: list[int], + mm_features: list[MultiModalFeatureSpec], + ) -> tuple[torch.Tensor, int]: + seq_len = len(input_tokens) + + if not mm_features: + # No audio features, just return linear positions + llm_positions = ( + torch.arange(seq_len, dtype=torch.long).view(1, -1).expand(3, -1) + ) + return llm_positions.clone(), 0 + + llm_pos_ids_list: list[torch.Tensor] = [] + st = 0 + + for mm_feature in sorted(mm_features, key=lambda f: f.mm_position.offset): + offset = mm_feature.mm_position.offset + + # Get audio feature length from mm_feature data + audio_feature_length = mm_feature.data["audio_feature_lengths"].data + if isinstance(audio_feature_length, torch.Tensor): + audio_feature_length = audio_feature_length.item() + audio_len = _get_feat_extract_output_lengths( + torch.tensor(audio_feature_length) + ).item() + + # Text segment before audio (includes audio_start token) + text_len = offset - st + st_idx = llm_pos_ids_list[-1].max() + 1 if llm_pos_ids_list else 0 + text_positions = ( + torch.arange(text_len, dtype=torch.long).view(1, -1).expand(3, -1) + + st_idx + ) + llm_pos_ids_list.append(text_positions) + st_idx = st_idx + text_len + + # Audio token segment + audio_positions = ( + torch.arange(audio_len, dtype=torch.long).view(1, -1).expand(3, -1) + + st_idx + ) + llm_pos_ids_list.append(audio_positions) + + st = offset + audio_len + + # Handle remaining text (includes audio_end and any trailing text) + if st < seq_len: + st_idx = llm_pos_ids_list[-1].max() + 1 if llm_pos_ids_list else 0 + text_len = seq_len - st + final_text_positions = ( + torch.arange(text_len, dtype=torch.long).view(1, -1).expand(3, -1) + + st_idx + ) + llm_pos_ids_list.append(final_text_positions) + + llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1) + if llm_positions.shape[1] != seq_len: + raise RuntimeError("Position ids length mismatch with input ids length") + + mrope_position_delta = (llm_positions.max() + 1 - seq_len).item() + return llm_positions, mrope_position_delta + + def get_mm_mapping(self) -> MultiModelKeys: + """ + Get the module prefix in multimodal models + """ + return MultiModelKeys.from_string_field( + language_model="language_model", + tower_model=["audio_tower."], + ) + + @classmethod + def get_speech_to_text_config( + cls, model_config: ModelConfig, task_type: str + ) -> SpeechToTextConfig: + processor = cached_processor_from_config(model_config) + feature_extractor: WhisperFeatureExtractor = processor.feature_extractor + return SpeechToTextConfig( + max_audio_clip_s=feature_extractor.chunk_length, + sample_rate=feature_extractor.sampling_rate, + ) + + @classmethod + def get_generation_prompt( + cls, + audio: np.ndarray, + model_config: ModelConfig, + stt_config: SpeechToTextConfig, + language: str | None, + task_type: Literal["transcribe", "translate"], + request_prompt: str, + to_language: str | None, + ) -> PromptType: + """Get the generation prompt to be used for transcription requests.""" + tokenizer = cached_tokenizer_from_config(model_config) + audio_placeholder = cls.get_placeholder_str("audio", 0) + + if task_type not in ("transcribe", "translate"): + raise ValueError( + f"Unsupported task_type '{task_type}'. " + "Supported task types are 'transcribe' and 'translate'." + ) + full_lang_name_to = cls.supported_languages.get(to_language, to_language) + if to_language is None: + prompt = ( + f"<|im_start|>user\n{audio_placeholder}<|im_end|>\n" + f"<|im_start|>assistant\n" + ) + else: + prompt = ( + f"<|im_start|>user\n{audio_placeholder}<|im_end|>\n" + f"<|im_start|>assistant\nlanguage {full_lang_name_to}" + ) + + prompt_token_ids = tokenizer.encode(prompt) + prompt_dict = { + "prompt_token_ids": prompt_token_ids, + "multi_modal_data": {"audio": audio}, + } + return cast(PromptType, prompt_dict) \ No newline at end of file diff --git a/vendor/qwen-asr/qwen_asr/inference/assets/korean_dict_jieba.dict b/vendor/qwen-asr/qwen_asr/inference/assets/korean_dict_jieba.dict new file mode 100644 index 00000000..42b57068 --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/inference/assets/korean_dict_jieba.dict @@ -0,0 +1,21009 @@ +a 10 x +b 10 x +c 10 x +d 10 x +d 10 x +e 10 x +f 10 x +g 10 x +i 10 x +k 10 x +m 10 x +n 10 x +o 10 x +p 10 x +r 10 x +s 10 x +t 10 x +t 10 x +u 10 x +v 10 x +가 10 x +가게 10 x +가격 10 x +가격 10 x +가계 10 x +가공 10 x +가공품 10 x +가구 10 x +가구주 10 x +가급적 10 x +가급적 10 x +가까스로 10 x +가까운 10 x +가까워 10 x +가까워요 10 x +가까웠 10 x +가까이 10 x +가깝 10 x +가깝 10 x +가꾸 10 x +가끔 10 x +가나 10 x +가나가와현 10 x +가난 10 x +가늠 10 x +가능 10 x +가다듬 10 x +가닥 10 x +가닥 10 x +가담 10 x +가도 10 x +가독성 10 x +가동 10 x +가두 10 x +가득 10 x +가득 10 x +가득찬 10 x +가디언 10 x +가디언 10 x +가뜩이나 10 x +가라사대 10 x +가라앉 10 x +가라앉히 10 x +가라앉히 10 x +가래 10 x +가량 10 x +가려 10 x +가려내 10 x +가려낸 10 x +가려서 10 x +가려야 10 x +가려운 10 x +가려워 10 x +가령 10 x +가로 10 x +가로등 10 x +가로막 10 x +가로막 10 x +가로막혀 10 x +가로지르 10 x +가로질러 10 x +가로축 10 x +가루 10 x +가르 10 x +가르쳐 10 x +가르쳤 10 x +가르치 10 x +가르칠 10 x +가르침 10 x +가르칩니다 10 x +가름 10 x +가리 10 x +가리켜 10 x +가리켰 10 x +가리키 10 x +가린 10 x +가릴 10 x +가림막 10 x +가림막 10 x +가마 10 x +가마솥 10 x +가마솥 10 x +가만 10 x +가만히 10 x +가만히 10 x +가망 10 x +가맹 10 x +가맹점 10 x +가명 10 x +가문 10 x +가뭄 10 x +가발 10 x +가방 10 x +가벼운 10 x +가벼워 10 x +가벼워졌 10 x +가볍 10 x +가볍 10 x +가본 10 x +가비 10 x +가비아 10 x +가사 10 x +가산점 10 x +가상 10 x +가설 10 x +가성 10 x +가세 10 x +가속 10 x +가수 10 x +가수요 10 x +가스 10 x +가스공사 10 x +가스프롬 10 x +가슴 10 x +가슴뼈 10 x +가슴살 10 x +가슴속 10 x +가습기 10 x +가습기 10 x +가시 10 x +가신 10 x +가실 10 x +가업 10 x +가연 10 x +가열 10 x +가엾 10 x +가외 10 x +가요 10 x +가용 10 x +가운데 10 x +가위 10 x +가위개미 10 x +가위바위보 10 x +가을 10 x +가의 10 x +가이드 10 x +가이드라인 10 x +가입 10 x +가입 10 x +가입자 10 x +가입자 10 x +가장 10 x +가장자리 10 x +가전 10 x +가전제품 10 x +가정 10 x +가정주부 10 x +가져 10 x +가져가 10 x +가져간 10 x +가져갈 10 x +가져다 10 x +가져다준 10 x +가져라 10 x +가져야 10 x +가져오 10 x +가져온 10 x +가져온다고 10 x +가져온다는 10 x +가져올 10 x +가져올게요 10 x +가져와 10 x +가져와라 10 x +가져왔 10 x +가져왔 10 x +가졌 10 x +가졌 10 x +가족 10 x +가족 10 x +가족사 10 x +가죽 10 x +가중 10 x +가중치 10 x +가지 10 x +가진 10 x +가진다 10 x +가질 10 x +가짜 10 x +가처분 10 x +가축 10 x +가축 10 x +가치 10 x +가치관 10 x +가토 10 x +가톨릭 10 x +가톨릭교 10 x +가톨릭교도 10 x +가톨릭교회 10 x +가파르 10 x +가평 10 x +가하 10 x +가한 10 x +가해 10 x +가해자 10 x +가했 10 x +가했 10 x +가혹 10 x +각 10 x +각 10 x +각각 10 x +각각 10 x +각개 10 x +각계 10 x +각광 10 x +각국 10 x +각국 10 x +각급 10 x +각급 10 x +각기 10 x +각도 10 x +각동 10 x +각론 10 x +각료 10 x +각별 10 x +각별히 10 x +각본 10 x +각성 10 x +각시탈 10 x +각양각색 10 x +각양각색 10 x +각오 10 x +각자 10 x +각종 10 x +각지 10 x +각층 10 x +각하 10 x +간 10 x +간간히 10 x +간간히 10 x +간격 10 x +간곡히 10 x +간과 10 x +간난 10 x +간다 10 x +간다고 10 x +간다는 10 x +간다면서 10 x +간단 10 x +간단히 10 x +간단히 10 x +간담회 10 x +간디 10 x +간만 10 x +간만에 10 x +간몰 10 x +간밤 10 x +간부 10 x +간사 10 x +간사이 10 x +간사인 10 x +간사장 10 x +간선제 10 x +간섭 10 x +간섭 10 x +간소 10 x +간수 10 x +간식 10 x +간식 10 x +간식거리 10 x +간신히 10 x +간신히 10 x +간여 10 x +간절 10 x +간절히 10 x +간접 10 x +간접 10 x +간접세 10 x +간주 10 x +간지 10 x +간지러워 10 x +간직 10 x +간직 10 x +간첩 10 x +간첩 10 x +간청 10 x +간탄 10 x +간통 10 x +간파 10 x +간판 10 x +간편 10 x +간호 10 x +간호부 10 x +간호사 10 x +간혹 10 x +갇혀 10 x +갇힌 10 x +갈 10 x +갈게 10 x +갈게요 10 x +갈고 10 x +갈까 10 x +갈까요 10 x +갈대 10 x +갈등 10 x +갈라 10 x +갈래 10 x +갈래요 10 x +갈려 10 x +갈리 10 x +갈망 10 x +갈무리 10 x +갈비 10 x +갈비찜 10 x +갈색 10 x +갈수록 10 x +갈수록 10 x +갈아입 10 x +갈아입 10 x +갈아타 10 x +갈증 10 x +갈지 10 x +갈탄 10 x +감 10 x +감가상각 10 x +감각 10 x +감개무량 10 x +감격 10 x +감경 10 x +감귤 10 x +감금 10 x +감기 10 x +감내 10 x +감당 10 x +감독 10 x +감독 10 x +감독원 10 x +감돌 10 x +감동 10 x +감리 10 x +감면 10 x +감명 10 x +감명깊 10 x +감명깊 10 x +감방 10 x +감별사 10 x +감봉 10 x +감사 10 x +감사원 10 x +감상 10 x +감성 10 x +감세 10 x +감소 10 x +감수 10 x +감시 10 x +감시초 10 x +감싸 10 x +감안 10 x +감액 10 x +감액 10 x +감염 10 x +감염자 10 x +감옥 10 x +감옥 10 x +감원 10 x +감자 10 x +감자 10 x +감정 10 x +감찰관 10 x +감청 10 x +감추 10 x +감축 10 x +감축 10 x +감춘 10 x +감출 10 x +감췄 10 x +감탄 10 x +감퇴 10 x +감행 10 x +감히 10 x +갑 10 x +갑각류 10 x +갑각류 10 x +갑니다 10 x +갑시다 10 x +갑자기 10 x +갑자기 10 x +갑작 10 x +갑작스런 10 x +갑작스럽 10 x +갑작스럽 10 x +갑판 10 x +값 10 x +값비싼 10 x +값싸 10 x +값싼 10 x +값진 10 x +갓 10 x +갓 10 x +갔 10 x +갔 10 x +갔었 10 x +갔으면 10 x +강 10 x +강가 10 x +강간 10 x +강경 10 x +강경파 10 x +강고 10 x +강공 10 x +강구 10 x +강국 10 x +강국 10 x +강남 10 x +강남구 10 x +강남역 10 x +강남역 10 x +강당 10 x +강대국 10 x +강도 10 x +강도높게 10 x +강력 10 x +강력부 10 x +강력히 10 x +강렬 10 x +강령 10 x +강릉 10 x +강릉시 10 x +강모 10 x +강물 10 x +강박 10 x +강변 10 x +강북 10 x +강북 10 x +강북구 10 x +강사 10 x +강서 10 x +강서구 10 x +강성 10 x +강세 10 x +강수 10 x +강수량 10 x +강습 10 x +강습 10 x +강신명 10 x +강아지 10 x +강연 10 x +강연회 10 x +강요 10 x +강용석 10 x +강원 10 x +강원도 10 x +강원도지사 10 x +강의 10 x +강의식 10 x +강의실 10 x +강일원 10 x +강장제 10 x +강점 10 x +강점 10 x +강정 10 x +강제 10 x +강제력 10 x +강조 10 x +강좌 10 x +강직 10 x +강진 10 x +강철 10 x +강철웅 10 x +강추 10 x +강타자 10 x +강퇴 10 x +강풍 10 x +강하 10 x +강한 10 x +강해 10 x +강해서 10 x +강해요 10 x +강했 10 x +강행 10 x +강행군 10 x +강현민 10 x +강호동 10 x +강화 10 x +갖 10 x +갖 10 x +갖가지 10 x +갖가지 10 x +갖추 10 x +갖춘 10 x +갖출 10 x +갖춰 10 x +갖춰야 10 x +갖춰져 10 x +갖춰진 10 x +갖췄 10 x +갖췄 10 x +같 10 x +같 10 x +같이 10 x +갚 10 x +갚 10 x +개 10 x +개각 10 x +개각 10 x +개개인 10 x +개관 10 x +개교 10 x +개구리 10 x +개국 10 x +개국 10 x +개그 10 x +개년 10 x +개념 10 x +개당 10 x +개도국 10 x +개똥 10 x +개량 10 x +개막 10 x +개막 10 x +개명 10 x +개미 10 x +개미집 10 x +개발 10 x +개발제한구역 10 x +개방 10 x +개별 10 x +개봉 10 x +개사 10 x +개선 10 x +개선책 10 x +개설 10 x +개성 10 x +개소 10 x +개시 10 x +개신교 10 x +개업 10 x +개연 10 x +개울 10 x +개원 10 x +개월 10 x +개위 10 x +개인 10 x +개인사 10 x +개인주의 10 x +개인택시 10 x +개입 10 x +개입 10 x +개장 10 x +개점 10 x +개정 10 x +개정안 10 x +개조 10 x +개종 10 x +개주 10 x +개진 10 x +개척 10 x +개척 10 x +개천 10 x +개최 10 x +개칭 10 x +개탄 10 x +개토 10 x +개통 10 x +개판 10 x +개편 10 x +개표 10 x +개함 10 x +개헌 10 x +개헌안 10 x +개혁 10 x +개혁 10 x +개혁파 10 x +객가 10 x +객관 10 x +객실 10 x +갠 10 x +갤 10 x +갤러리 10 x +갤럭시 10 x +갯 10 x +갯 10 x +갯벌 10 x +갱 10 x +걍 10 x +걔 10 x +거 10 x +거국 10 x +거기 10 x +거꾸로 10 x +거나 10 x +거느리 10 x +거니 10 x +거대 10 x +거두 10 x +거둔 10 x +거둔다 10 x +거둔다면 10 x +거둘 10 x +거둬 10 x +거뒀 10 x +거뒀 10 x +거든 10 x +거든요 10 x +거들 10 x +거듭 10 x +거듭 10 x +거라 10 x +거래 10 x +거래량 10 x +거래법 10 x +거래법 10 x +거래선 10 x +거래소 10 x +거론 10 x +거룩 10 x +거르 10 x +거리 10 x +거리 10 x +거만 10 x +거머쥐 10 x +거명 10 x +거미 10 x +거미줄 10 x +거부 10 x +거북 10 x +거세 10 x +거센 10 x +거셉니다 10 x +거셨 10 x +거스르 10 x +거스른 10 x +거스름돈 10 x +거슬러 10 x +거시경제 10 x +거식증 10 x +거신 10 x +거실 10 x +거액 10 x +거액 10 x +거울 10 x +거위 10 x +거의 10 x +거장 10 x +거절 10 x +거점 10 x +거제 10 x +거제시 10 x +거져 10 x +거주 10 x +거주자 10 x +거주지 10 x +거지 10 x +거짓 10 x +거짓 10 x +거짓말 10 x +거창 10 x +거처 10 x +거쳐 10 x +거쳐서 10 x +거쳐야 10 x +거쳤 10 x +거쳤 10 x +거취 10 x +거치 10 x +거친 10 x +거칠 10 x +거침없이 10 x +거품 10 x +거행 10 x +걱정 10 x +걱정 10 x +건 10 x +건가 10 x +건가요 10 x +건강 10 x +건교부 10 x +건국 10 x +건국대 10 x +건내주 10 x +건너 10 x +건너가 10 x +건너편 10 x +건넌 10 x +건널 10 x +건네 10 x +건넨 10 x +건넨다 10 x +건대 10 x +건데 10 x +건데요 10 x +건드리 10 x +건립 10 x +건립 10 x +건물 10 x +건배 10 x +건보 10 x +건설 10 x +건설사 10 x +건설업 10 x +건수 10 x +건영 10 x +건의 10 x +건전 10 x +건졌 10 x +건조 10 x +건지 10 x +건진 10 x +건축 10 x +건축 10 x +건축물 10 x +건폐율 10 x +건희 10 x +건희 10 x +걷 10 x +걷 10 x +걷어차 10 x +걷잡 10 x +걸 10 x +걸까 10 x +걸레질 10 x +걸려 10 x +걸려서 10 x +걸려요 10 x +걸렸 10 x +걸렸 10 x +걸로 10 x +걸리 10 x +걸린 10 x +걸린다 10 x +걸릴 10 x +걸림돌 10 x +걸립니다 10 x +걸맞 10 x +걸맞 10 x +걸어가 10 x +걸어서 10 x +걸어야 10 x +걸어온 10 x +걸어왔 10 x +걸요 10 x +걸으며 10 x +걸으면서 10 x +걸음 10 x +걸음걸이 10 x +걸쳐 10 x +걸쳤 10 x +걸쳤 10 x +걸친 10 x +걸프 10 x +걸프만 10 x +검 10 x +검거 10 x +검문 10 x +검문소 10 x +검붉 10 x +검사 10 x +검사장 10 x +검사제 10 x +검색 10 x +검소 10 x +검열 10 x +검은색 10 x +검인정 10 x +검장 10 x +검정 10 x +검증 10 x +검진 10 x +검찰 10 x +검출 10 x +검토 10 x +겁 10 x +겁나 10 x +겁니까 10 x +겁니다 10 x +겁박 10 x +것 10 x +것 10 x +겄 10 x +겉 10 x +겉 10 x +겉돌 10 x +게 10 x +게끔 10 x +게나 10 x +게놈 10 x +게다가 10 x +게리맨더링 10 x +게릴라 10 x +게스트 10 x +게시판 10 x +게요 10 x +게으른 10 x +게을리 10 x +게이고 10 x +게이츠 10 x +게이트 10 x +게임 10 x +게임방 10 x +게재 10 x +겐 10 x +겐나디 10 x +겐나디 10 x +겔 10 x +겔라 10 x +겟 10 x +겟 10 x +겠 10 x +겠 10 x +겠다 10 x +겠다며 10 x +겠어 10 x +겠죠 10 x +겨 10 x +겨냥 10 x +겨누 10 x +겨눈 10 x +겨드랑 10 x +겨레 10 x +겨루 10 x +겨루기 10 x +겨우 10 x +겨울 10 x +겨울맞이 10 x +겨울철 10 x +격 10 x +격 10 x +격감 10 x +격돌 10 x +격려 10 x +격렬 10 x +격론 10 x +격리 10 x +격분 10 x +격상 10 x +격차 10 x +격차 10 x +격추 10 x +격파 10 x +격해 10 x +격해진 10 x +격화 10 x +겪 10 x +겪 10 x +견 10 x +견강부회 10 x +견뎌 10 x +견디 10 x +견디 10 x +견딜 10 x +견인 10 x +견적 10 x +견적 10 x +견제 10 x +견학 10 x +견학 10 x +견해 10 x +견해 10 x +결 10 x +결과 10 x +결과물 10 x +결국 10 x +결국 10 x +결근 10 x +결단 10 x +결론 10 x +결말 10 x +결백 10 x +결별 10 x +결부 10 x +결빙 10 x +결사 10 x +결산 10 x +결석 10 x +결선 10 x +결성 10 x +결속 10 x +결승 10 x +결실 10 x +결심 10 x +결여 10 x +결의 10 x +결의안 10 x +결재 10 x +결점 10 x +결정 10 x +결정짓 10 x +결정짓 10 x +결정판 10 x +결제 10 x +결집 10 x +결코 10 x +결탁 10 x +결함 10 x +결합 10 x +결합 10 x +결행 10 x +결혼 10 x +결혼기념일 10 x +결혼식 10 x +결혼식 10 x +겸 10 x +겸손 10 x +겸임 10 x +겸직 10 x +겸해 10 x +겸허 10 x +겹겹 10 x +겹겹 10 x +겹쳐 10 x +겹치 10 x +겹치 10 x +경 10 x +경각심 10 x +경감 10 x +경계 10 x +경계심 10 x +경고 10 x +경과 10 x +경관 10 x +경기 10 x +경기 10 x +경기도 10 x +경기장 10 x +경남 10 x +경남기업 10 x +경내 10 x +경대 10 x +경력 10 x +경력 10 x +경력자 10 x +경로 10 x +경륜 10 x +경리 10 x +경매 10 x +경무관 10 x +경복 10 x +경복궁 10 x +경부고속도로 10 x +경북 10 x +경북 10 x +경비 10 x +경비병 10 x +경비실 10 x +경비원 10 x +경사 10 x +경사 10 x +경산 10 x +경상 10 x +경색 10 x +경선 10 x +경솔 10 x +경수로 10 x +경신 10 x +경실련 10 x +경영 10 x +경영계 10 x +경영난 10 x +경영인 10 x +경영자 10 x +경영진 10 x +경영학 10 x +경영학 10 x +경외 10 x +경외심 10 x +경우 10 x +경원 10 x +경위 10 x +경유 10 x +경이 10 x +경인 10 x +경작 10 x +경작지 10 x +경작지 10 x +경장 10 x +경재 10 x +경쟁 10 x +경쟁국 10 x +경쟁력 10 x +경쟁력 10 x +경쟁사 10 x +경쟁자 10 x +경적 10 x +경정 10 x +경제 10 x +경제난 10 x +경제력 10 x +경제력 10 x +경제사 10 x +경제성장 10 x +경제학 10 x +경제학자 10 x +경제활동인구 10 x +경조사 10 x +경주 10 x +경직 10 x +경질 10 x +경차 10 x +경찰 10 x +경찰관 10 x +경찰대 10 x +경찰법 10 x +경찰봉 10 x +경찰서 10 x +경찰차 10 x +경찰청 10 x +경첩 10 x +경첩 10 x +경청 10 x +경추 10 x +경치 10 x +경쾌 10 x +경탄 10 x +경품 10 x +경향 10 x +경향신문 10 x +경험 10 x +경험자 10 x +경협 10 x +경호실 10 x +경호원 10 x +곁 10 x +곁 10 x +곁눈 10 x +곁들여 10 x +계 10 x +계곡 10 x +계곡 10 x +계구 10 x +계급 10 x +계급 10 x +계기 10 x +계기판 10 x +계단 10 x +계란 10 x +계부 10 x +계산 10 x +계산법 10 x +계산법 10 x +계산서 10 x +계상 10 x +계세요 10 x +계셔 10 x +계셨 10 x +계셨 10 x +계속 10 x +계속 10 x +계수 10 x +계승 10 x +계시 10 x +계시록 10 x +계신 10 x +계신가요 10 x +계신다 10 x +계실 10 x +계십니까 10 x +계십니다 10 x +계약 10 x +계약 10 x +계약서 10 x +계약직 10 x +계약직 10 x +계열 10 x +계열사 10 x +계장 10 x +계절 10 x +계정 10 x +계좌 10 x +계집애 10 x +계책 10 x +계층 10 x +계통 10 x +계파 10 x +계획 10 x +계획 10 x +계획법 10 x +계획서 10 x +계획안 10 x +고 10 x +고가 10 x +고각 10 x +고갈 10 x +고개 10 x +고객 10 x +고객 10 x +고검 10 x +고고 10 x +고고씽 10 x +고고학자 10 x +고과 10 x +고관 10 x +고교 10 x +고급 10 x +고급 10 x +고기 10 x +고기 10 x +고기압 10 x +고난 10 x +고는 10 x +고단 10 x +고달플 10 x +고대 10 x +고도 10 x +고독 10 x +고든 10 x +고등 10 x +고등교육 10 x +고등교육 10 x +고등어 10 x +고등학교 10 x +고등학생 10 x +고락 10 x +고래 10 x +고려 10 x +고려대 10 x +고령 10 x +고령화 10 x +고루 10 x +고르 10 x +고른 10 x +고를 10 x +고리 10 x +고리타분 10 x +고릴라 10 x +고립 10 x +고마움 10 x +고마워 10 x +고마워요 10 x +고마웠 10 x +고만 10 x +고맙 10 x +고맙 10 x +고메스 10 x +고모 10 x +고무 10 x +고문 10 x +고민 10 x +고민거리 10 x +고밀 10 x +고바야시 10 x +고발 10 x +고발인 10 x +고발자 10 x +고발장 10 x +고백 10 x +고백 10 x +고베 10 x +고부 10 x +고비 10 x +고사 10 x +고사총 10 x +고상 10 x +고생 10 x +고서 10 x +고선 10 x +고성 10 x +고성군 10 x +고소 10 x +고소득 10 x +고소인 10 x +고속 10 x +고속 10 x +고속도 10 x +고속도 10 x +고속도로 10 x +고속버스 10 x +고수 10 x +고수부지 10 x +고스란히 10 x +고스란히 10 x +고승 10 x +고시 10 x +고심 10 x +고아 10 x +고아원 10 x +고액 10 x +고액 10 x +고양이 10 x +고여 10 x +고영주 10 x +고영태 10 x +고요 10 x +고용 10 x +고용주 10 x +고운 10 x +고위 10 x +고위직 10 x +고위직 10 x +고유 10 x +고의 10 x +고인 10 x +고임금 10 x +고자 10 x +고작 10 x +고작 10 x +고장 10 x +고장 10 x +고장난 10 x +고전 10 x +고정 10 x +고정익 10 x +고정익 10 x +고조 10 x +고종실록 10 x +고지 10 x +고질 10 x +고집 10 x +고집 10 x +고차 10 x +고착 10 x +고체 10 x +고쳐 10 x +고쳐라 10 x +고쳐야 10 x +고쳤 10 x +고초 10 x +고추 10 x +고추장 10 x +고춧가루 10 x +고춧가루 10 x +고충 10 x +고층 10 x +고치 10 x +고치현 10 x +고칠 10 x +고통 10 x +고파 10 x +고파요 10 x +고프 10 x +고픈 10 x +고함 10 x +고향 10 x +고흥길 10 x +고희 10 x +곡 10 x +곡 10 x +곡괭이 10 x +곡물 10 x +곡선 10 x +곡성 10 x +곡소리 10 x +곤 10 x +곤두세우 10 x +곤드라지 10 x +곤란 10 x +곤욕 10 x +곤충 10 x +곤혹 10 x +곤혹 10 x +곧 10 x +곧 10 x +곧바로 10 x +곧바로 10 x +곧이어 10 x +곧잘 10 x +곧장 10 x +골 10 x +골 10 x +골격 10 x +골격 10 x +골고루 10 x +골다공증 10 x +골라 10 x +골랐 10 x +골랐 10 x +골로빈 10 x +골머리 10 x +골목 10 x +골목 10 x +골목길 10 x +골몰 10 x +골반 10 x +골뱅이 10 x +골자 10 x +골절 10 x +골절상 10 x +골조 10 x +골치 10 x +골프 10 x +골프장 10 x +곰 10 x +곰곰이 10 x +곰돌이 10 x +곰팡내 10 x +곰팡이 10 x +곱 10 x +곱 10 x +곱창 10 x +곱하 10 x +곱해 10 x +곳 10 x +곳 10 x +곳곳 10 x +곳곳 10 x +공 10 x +공 10 x +공간 10 x +공갈 10 x +공감 10 x +공감대 10 x +공개 10 x +공개경쟁 10 x +공개서한 10 x +공격 10 x +공격 10 x +공경 10 x +공고 10 x +공공 10 x +공공시설 10 x +공공연 10 x +공공연히 10 x +공공연히 10 x +공과 10 x +공과금 10 x +공관 10 x +공교 10 x +공교 10 x +공구 10 x +공군 10 x +공군기지 10 x +공군력 10 x +공권력 10 x +공권력 10 x +공급 10 x +공급 10 x +공급원 10 x +공급자 10 x +공기 10 x +공기 10 x +공기업 10 x +공기업 10 x +공기총 10 x +공단 10 x +공대 10 x +공동 10 x +공동구 10 x +공동묘지 10 x +공동선 10 x +공동체 10 x +공략 10 x +공략 10 x +공론 10 x +공론화 10 x +공룡 10 x +공매도 10 x +공명선거 10 x +공모 10 x +공모자 10 x +공무원 10 x +공문 10 x +공방 10 x +공백 10 x +공백 10 x +공범 10 x +공법 10 x +공법 10 x +공보 10 x +공보처 10 x +공복 10 x +공부 10 x +공분 10 x +공사 10 x +공사장 10 x +공산 10 x +공산당 10 x +공산주의 10 x +공산주의자 10 x +공생 10 x +공성 10 x +공세 10 x +공소 10 x +공소시효 10 x +공손 10 x +공수부대 10 x +공수사 10 x +공습 10 x +공습 10 x +공시 10 x +공식 10 x +공식 10 x +공신력 10 x +공안 10 x +공약 10 x +공약 10 x +공언 10 x +공업 10 x +공업 10 x +공업용수 10 x +공여 10 x +공연 10 x +공연장 10 x +공영 10 x +공원 10 x +공유 10 x +공의 10 x +공익 10 x +공익 10 x +공익사업 10 x +공인 10 x +공인회계사 10 x +공임 10 x +공자 10 x +공자 10 x +공작 10 x +공작 10 x +공장 10 x +공전 10 x +공정 10 x +공정거래위 10 x +공정위 10 x +공제 10 x +공조 10 x +공족 10 x +공존 10 x +공존공영 10 x +공주 10 x +공중 10 x +공중부양 10 x +공중분해 10 x +공중분해 10 x +공지 10 x +공직 10 x +공직 10 x +공직자 10 x +공직자 10 x +공짜 10 x +공창 10 x +공채 10 x +공책 10 x +공책 10 x +공천 10 x +공청회 10 x +공터 10 x +공통 10 x +공통점 10 x +공판 10 x +공평 10 x +공포 10 x +공포심 10 x +공포증 10 x +공표 10 x +공표죄 10 x +공학 10 x +공학 10 x +공항 10 x +공해 10 x +공허 10 x +공헌 10 x +공화 10 x +공화국 10 x +공화국 10 x +공황 10 x +공휴일 10 x +곶 10 x +과 10 x +과감 10 x +과감히 10 x +과거 10 x +과거사 10 x +과격 10 x +과격파 10 x +과다 10 x +과당 10 x +과대 10 x +과도 10 x +과도기 10 x +과목 10 x +과민 10 x +과반 10 x +과반수 10 x +과세 10 x +과소비 10 x +과수원 10 x +과시 10 x +과식 10 x +과식 10 x +과실 10 x +과언 10 x +과연 10 x +과열 10 x +과외 10 x +과일 10 x +과일나무 10 x +과잉 10 x +과자 10 x +과장 10 x +과정 10 x +과제 10 x +과태료 10 x +과하 10 x +과학 10 x +과학 10 x +과학고 10 x +과학관 10 x +과학부 10 x +과학자 10 x +곽 10 x +관 10 x +관개 10 x +관객 10 x +관객 10 x +관건 10 x +관격 10 x +관계 10 x +관계없이 10 x +관계자 10 x +관공서 10 x +관광 10 x +관광객 10 x +관광객 10 x +관광지 10 x +관권 10 x +관념 10 x +관대 10 x +관동 10 x +관람 10 x +관람객 10 x +관련 10 x +관련자 10 x +관례 10 x +관록 10 x +관료 10 x +관료주의 10 x +관리 10 x +관리법 10 x +관리인 10 x +관리자 10 x +관리직 10 x +관망 10 x +관문 10 x +관변 10 x +관보 10 x +관사 10 x +관상 10 x +관세 10 x +관세청 10 x +관습 10 x +관습 10 x +관심 10 x +관심사 10 x +관악구 10 x +관여 10 x +관영 10 x +관용 10 x +관위 10 x +관장 10 x +관저 10 x +관점 10 x +관제 10 x +관제탑 10 x +관제탑 10 x +관중 10 x +관찰 10 x +관찰자 10 x +관철 10 x +관청 10 x +관측 10 x +관측 10 x +관하 10 x +관하 10 x +관한 10 x +관한 10 x +관할 10 x +관할 10 x +관해 10 x +관해 10 x +관해서 10 x +관해서 10 x +관행 10 x +관행 10 x +괌 10 x +광 10 x +광경 10 x +광고 10 x +광교 10 x +광구 10 x +광대 10 x +광대뼈 10 x +광둥 10 x +광명 10 x +광물 10 x +광범 10 x +광범위 10 x +광복 10 x +광복 10 x +광복절 10 x +광부 10 x +광산 10 x +광양시 10 x +광역 10 x +광역 10 x +광역권 10 x +광역시 10 x +광역자치단체 10 x +광열 10 x +광우병 10 x +광장 10 x +광주 10 x +광주시 10 x +광주시장 10 x +광진구 10 x +광채 10 x +광해군 10 x +광현 10 x +광화문 10 x +광활 10 x +괘씸 10 x +괘씸죄 10 x +괜찮 10 x +괜히 10 x +괜히 10 x +괴 10 x +괴담 10 x +괴로우 10 x +괴로운 10 x +괴로움 10 x +괴롭 10 x +괴롭히 10 x +괴롭힘 10 x +괴뢰 10 x +괴리 10 x +괴문서 10 x +괴산 10 x +괴상 10 x +굉장 10 x +굉장히 10 x +교 10 x +교가 10 x +교각 10 x +교과목 10 x +교과서 10 x +교관 10 x +교구 10 x +교내 10 x +교단 10 x +교대 10 x +교도 10 x +교도관 10 x +교도소 10 x +교두보 10 x +교란 10 x +교류 10 x +교리 10 x +교만 10 x +교묘 10 x +교문 10 x +교민 10 x +교보 10 x +교복 10 x +교복 10 x +교분 10 x +교사 10 x +교사 10 x +교생 10 x +교섭 10 x +교섭 10 x +교수 10 x +교습 10 x +교시 10 x +교신 10 x +교실 10 x +교양 10 x +교역 10 x +교외 10 x +교원 10 x +교위 10 x +교육 10 x +교육 10 x +교육감 10 x +교육령 10 x +교육부 10 x +교육부 10 x +교육자 10 x +교육자 10 x +교육청 10 x +교육학 10 x +교육학 10 x +교인 10 x +교장 10 x +교재 10 x +교전 10 x +교정 10 x +교제 10 x +교조 10 x +교주 10 x +교직 10 x +교직원 10 x +교차로 10 x +교착 10 x +교체 10 x +교토 10 x +교통 10 x +교통량 10 x +교통부 10 x +교통사고 10 x +교통정리 10 x +교통카드 10 x +교포 10 x +교화 10 x +교환 10 x +교황 10 x +교황청 10 x +교회 10 x +교회당 10 x +교훈 10 x +구 10 x +구간 10 x +구강 10 x +구개 10 x +구경 10 x +구교 10 x +구교도 10 x +구구단 10 x +구국 10 x +구글 10 x +구급대 10 x +구급차 10 x +구기 10 x +구기동 10 x +구나 10 x +구난 10 x +구단 10 x +구단주 10 x +구당 10 x +구덩이 10 x +구도 10 x +구도심 10 x +구독 10 x +구독 10 x +구독자 10 x +구동 10 x +구두 10 x +구두 10 x +구라 10 x +구랍 10 x +구로구 10 x +구름 10 x +구리 10 x +구린 10 x +구만 10 x +구매 10 x +구매력 10 x +구매자 10 x +구멍 10 x +구멘 10 x +구명 10 x +구미 10 x +구민 10 x +구별 10 x +구본영 10 x +구부리 10 x +구분 10 x +구비 10 x +구사 10 x +구상 10 x +구상도 10 x +구석 10 x +구석 10 x +구석구석 10 x +구석구석 10 x +구석기 10 x +구성 10 x +구성원 10 x +구세군 10 x +구소련 10 x +구속 10 x +구속 10 x +구속영장 10 x +구술 10 x +구슬 10 x +구시대 10 x +구실 10 x +구심 10 x +구심점 10 x +구여 10 x +구역 10 x +구역 10 x +구요 10 x +구원 10 x +구월 10 x +구의역 10 x +구의역 10 x +구인 10 x +구임 10 x +구입 10 x +구입 10 x +구장 10 x +구점 10 x +구정 10 x +구제 10 x +구조 10 x +구조 10 x +구조물 10 x +구직자 10 x +구찌 10 x +구천 10 x +구청 10 x +구청장 10 x +구체 10 x +구체안 10 x +구축 10 x +구축 10 x +구축함 10 x +구충제 10 x +구치감 10 x +구치소 10 x +구타 10 x +구토 10 x +구하 10 x +구한 10 x +구한다면 10 x +구할 10 x +구함 10 x +구합니다 10 x +구해 10 x +구해서 10 x +구해야 10 x +구했 10 x +구했 10 x +구현 10 x +구형 10 x +구형량 10 x +구호 10 x +구호품 10 x +국 10 x +국 10 x +국가 10 x +국감 10 x +국경 10 x +국공 10 x +국군 10 x +국기 10 x +국난 10 x +국내 10 x +국내법 10 x +국내외 10 x +국당 10 x +국당 10 x +국력 10 x +국력 10 x +국립 10 x +국립 10 x +국마 10 x +국면 10 x +국명 10 x +국무 10 x +국무부 10 x +국무원 10 x +국무총리 10 x +국문학 10 x +국문학 10 x +국문학 10 x +국물 10 x +국민 10 x +국민당 10 x +국민운동 10 x +국민정당 10 x +국민카드 10 x +국민학교 10 x +국민학교 10 x +국민학생 10 x +국민학생 10 x +국민회 10 x +국민회의 10 x +국방 10 x +국방 10 x +국방부 10 x +국방부 10 x +국방비 10 x +국방위 10 x +국부 10 x +국부 10 x +국사 10 x +국사학 10 x +국산 10 x +국세청 10 x +국수 10 x +국악 10 x +국악 10 x +국어 10 x +국영 10 x +국영석 10 x +국영석 10 x +국왕 10 x +국익 10 x +국장 10 x +국장 10 x +국적 10 x +국적 10 x +국적 10 x +국정 10 x +국정 10 x +국정원 10 x +국제 10 x +국제 10 x +국제공항 10 x +국제공항 10 x +국제기구 10 x +국제노동기구 10 x +국제법 10 x +국제선 10 x +국지 10 x +국채 10 x +국책 10 x +국책 10 x +국토 10 x +국토 10 x +국판 10 x +국판 10 x +국한 10 x +국해 10 x +국회 10 x +국회법 10 x +국회법 10 x +국회의원 10 x +군 10 x +군것질 10 x +군고 10 x +군구 10 x +군국주의 10 x +군내 10 x +군단 10 x +군대 10 x +군데 10 x +군량미 10 x +군민 10 x +군복 10 x +군복 10 x +군부 10 x +군부대 10 x +군비 10 x +군사 10 x +군사력 10 x +군사령관 10 x +군사분계선 10 x +군속 10 x +군수 10 x +군심 10 x +군요 10 x +군인 10 x +군정 10 x +군주 10 x +군중 10 x +군청 10 x +군포 10 x +군포시 10 x +군함 10 x +군함 10 x +굳 10 x +굳 10 x +굳어지 10 x +굳이 10 x +굳히 10 x +굳히 10 x +굳힌 10 x +굳힌 10 x +굴 10 x +굴곡 10 x +굴곡 10 x +굴러 10 x +굴러가 10 x +굴려 10 x +굴복 10 x +굴욕 10 x +굴지 10 x +굵 10 x +굵 10 x +굵기 10 x +굵직 10 x +굶 10 x +굶주림 10 x +굽 10 x +굽 10 x +굽혀 10 x +굽히 10 x +굿 10 x +굿 10 x +굿모닝 10 x +굿윈 10 x +궁 10 x +궁극 10 x +궁금 10 x +궁금증 10 x +궁리 10 x +궁색 10 x +궁정 10 x +궁핍 10 x +궈 10 x +권 10 x +권고 10 x +권내현 10 x +권노갑 10 x +권단 10 x +권력 10 x +권력 10 x +권력자 10 x +권리 10 x +권모 10 x +권위 10 x +권위주의 10 x +권유 10 x +권익 10 x +권익 10 x +권장 10 x +권주 10 x +권총 10 x +권투 10 x +권하 10 x +권하 10 x +권한 10 x +권한 10 x +권합니다 10 x +권합니다 10 x +권했 10 x +권했 10 x +궤도 10 x +귀 10 x +귀가 10 x +귀감 10 x +귀걸이 10 x +귀결 10 x +귀국 10 x +귀국 10 x +귀농 10 x +귀띔 10 x +귀속 10 x +귀신 10 x +귀여니 10 x +귀여운 10 x +귀여움 10 x +귀여워 10 x +귀여워서 10 x +귀엽 10 x +귀엽 10 x +귀요미 10 x +귀재 10 x +귀족 10 x +귀족 10 x +귀중 10 x +귀중품 10 x +귀찮 10 x +귀하 10 x +귀한 10 x +귀환 10 x +귓가 10 x +규 10 x +규격 10 x +규명 10 x +규모 10 x +규범 10 x +규약 10 x +규율 10 x +규정 10 x +규제 10 x +규제책 10 x +규칙 10 x +규칙 10 x +규탄 10 x +규합 10 x +규현 10 x +균 10 x +균등 10 x +균류 10 x +균형 10 x +균형 10 x +귤 10 x +그 10 x +그간 10 x +그거 10 x +그건 10 x +그걸 10 x +그걸로 10 x +그것 10 x +그것 10 x +그게 10 x +그곳 10 x +그곳 10 x +그나마 10 x +그날 10 x +그냥 10 x +그네 10 x +그녀 10 x +그늘 10 x +그니 10 x +그니까 10 x +그다지 10 x +그대 10 x +그대로 10 x +그동안 10 x +그때 10 x +그라 10 x +그라초프 10 x +그람 10 x +그래 10 x +그래도 10 x +그래서 10 x +그래야 10 x +그래요 10 x +그래프 10 x +그래픽 10 x +그랜드 10 x +그랜저 10 x +그램 10 x +그랬 10 x +그랬 10 x +그랬었 10 x +그랬었 10 x +그러 10 x +그러나 10 x +그러니까 10 x +그러다 10 x +그러다가 10 x +그러면 10 x +그러면은 10 x +그러므로 10 x +그러하 10 x +그런 10 x +그런가 10 x +그런대로 10 x +그런데 10 x +그런지 10 x +그럴 10 x +그럴까요 10 x +그럴듯한 10 x +그럴만한 10 x +그럴만한 10 x +그럼 10 x +그럼에도 10 x +그렇 10 x +그렇게 10 x +그렇지만 10 x +그레이 10 x +그려 10 x +그려냈 10 x +그려냈 10 x +그려진 10 x +그렸 10 x +그렸 10 x +그렸었 10 x +그로즈니 10 x +그루 10 x +그루지야 10 x +그룹 10 x +그룹 10 x +그름 10 x +그릇 10 x +그릇 10 x +그리 10 x +그리고 10 x +그리스 10 x +그리스도교 10 x +그리스도인 10 x +그리움 10 x +그리워 10 x +그리하여 10 x +그린 10 x +그린벨트 10 x +그릴 10 x +그림 10 x +그림자 10 x +그립 10 x +그립 10 x +그만 10 x +그만두 10 x +그만둔 10 x +그만둬 10 x +그만뒀 10 x +그만큼 10 x +그만한 10 x +그만한 10 x +그물 10 x +그분 10 x +그야말로 10 x +그으면서 10 x +그윽 10 x +그저 10 x +그제서야 10 x +그제야 10 x +그죠 10 x +그중 10 x +그쪽 10 x +그쪽 10 x +그쯤 10 x +그쳐 10 x +그쳤 10 x +그쳤 10 x +그쵸 10 x +그치 10 x +그치만 10 x +그친 10 x +그칠 10 x +그토록 10 x +그해 10 x +극 10 x +극단 10 x +극단 10 x +극단주의 10 x +극대 10 x +극도 10 x +극도 10 x +극동 10 x +극렬 10 x +극명 10 x +극복 10 x +극복 10 x +극복 10 x +극소수 10 x +극심 10 x +극우 10 x +극우파 10 x +극장 10 x +극장 10 x +극진히 10 x +극진히 10 x +극한 10 x +극히 10 x +근 10 x +근간 10 x +근거 10 x +근거지 10 x +근검절약 10 x +근교 10 x +근대 10 x +근데 10 x +근로 10 x +근로자 10 x +근면 10 x +근면성 10 x +근무 10 x +근본 10 x +근본주의 10 x +근사 10 x +근성 10 x +근세 10 x +근소 10 x +근속 10 x +근신 10 x +근엄 10 x +근원 10 x +근육 10 x +근절 10 x +근접 10 x +근조 10 x +근처 10 x +근황 10 x +글 10 x +글귀 10 x +글라스 10 x +글라이더 10 x +글로벌 10 x +글루텐 10 x +글린 10 x +글쎄 10 x +글쎄요 10 x +글쓰기 10 x +글씨 10 x +글자 10 x +긁 10 x +긁 10 x +금 10 x +금값 10 x +금강 10 x +금강기획 10 x +금고 10 x +금괴 10 x +금권 10 x +금년 10 x +금리 10 x +금메달 10 x +금명간 10 x +금문 10 x +금방 10 x +금배지 10 x +금사 10 x +금세 10 x +금속 10 x +금속 10 x +금수 10 x +금액 10 x +금액 10 x +금연 10 x +금요일 10 x +금융 10 x +금융업 10 x +금융업 10 x +금전 10 x +금정면 10 x +금주 10 x +금지 10 x +금지령 10 x +금지법 10 x +금치 10 x +금포 10 x +금품 10 x +금하 10 x +금할 10 x +금호 10 x +급 10 x +급 10 x +급감 10 x +급격 10 x +급격 10 x +급격 10 x +급격히 10 x +급급 10 x +급급 10 x +급기야 10 x +급기야 10 x +급등 10 x +급등 10 x +급락 10 x +급랭 10 x +급박 10 x +급변 10 x +급부상 10 x +급상승 10 x +급성 10 x +급성장 10 x +급속 10 x +급속도 10 x +급속히 10 x +급수 10 x +급식 10 x +급식 10 x +급식비 10 x +급여 10 x +급증 10 x +급증 10 x +급진 10 x +급진주의자 10 x +급진주의자 10 x +급진파 10 x +급하 10 x +급한 10 x +급할수록 10 x +급해서 10 x +급히 10 x +긋 10 x +긋 10 x +긍정 10 x +긍지 10 x +긔 10 x +기 10 x +기각 10 x +기간 10 x +기간제 10 x +기강 10 x +기계 10 x +기계공 10 x +기고 10 x +기관 10 x +기관사 10 x +기관장 10 x +기관총 10 x +기구 10 x +기금 10 x +기기 10 x +기꺼이 10 x +기껏 10 x +기껏 10 x +기내 10 x +기내식 10 x +기냥 10 x +기념 10 x +기념식 10 x +기념일 10 x +기념품 10 x +기념행사 10 x +기능 10 x +기다란 10 x +기다려 10 x +기다려라 10 x +기다려야 10 x +기다려야지 10 x +기다렸 10 x +기다렸 10 x +기다리 10 x +기다린 10 x +기대 10 x +기대감 10 x +기도 10 x +기독교 10 x +기독교인 10 x +기동대 10 x +기동민 10 x +기동성 10 x +기둥 10 x +기득 10 x +기득 10 x +기력 10 x +기력 10 x +기록 10 x +기록 10 x +기록물 10 x +기류 10 x +기르 10 x +기를 10 x +기름 10 x +기리 10 x +기막힌 10 x +기말 10 x +기무라 10 x +기무사 10 x +기미 10 x +기민 10 x +기반 10 x +기발 10 x +기백 10 x +기법 10 x +기복 10 x +기본 10 x +기본법 10 x +기부 10 x +기부금 10 x +기부장 10 x +기분 10 x +기뻐서 10 x +기뻐요 10 x +기뻐하 10 x +기쁘 10 x +기쁨 10 x +기쁩니다 10 x +기사 10 x +기상 10 x +기상청 10 x +기색 10 x +기생 10 x +기생충 10 x +기설 10 x +기성 10 x +기세 10 x +기소 10 x +기소독점주의 10 x +기수 10 x +기숙사 10 x +기술 10 x +기술력 10 x +기술자 10 x +기스 10 x +기습 10 x +기습 10 x +기승 10 x +기실 10 x +기아 10 x +기아그룹 10 x +기아그룹 10 x +기아차 10 x +기약 10 x +기어 10 x +기어코 10 x +기억 10 x +기억 10 x +기억나 10 x +기억력 10 x +기업 10 x +기업 10 x +기업가 10 x +기업어음 10 x +기업인 10 x +기업체 10 x +기에 10 x +기여 10 x +기영 10 x +기온 10 x +기요 10 x +기요시 10 x +기우 10 x +기운 10 x +기울 10 x +기울어지 10 x +기울여 10 x +기울여온 10 x +기울였 10 x +기울이 10 x +기울인다 10 x +기울일 10 x +기원 10 x +기원전 10 x +기이 10 x +기인 10 x +기자 10 x +기장 10 x +기재 10 x +기저귀 10 x +기적 10 x +기절 10 x +기점 10 x +기정 10 x +기정사실 10 x +기조 10 x +기존 10 x +기종 10 x +기준 10 x +기준치 10 x +기중 10 x +기증자 10 x +기지 10 x +기차 10 x +기처 10 x +기천 10 x +기초 10 x +기치 10 x +기침 10 x +기타 10 x +기피 10 x +기하 10 x +기한 10 x +기행 10 x +기현상 10 x +기협 10 x +기형 10 x +기호 10 x +기혼 10 x +기회 10 x +기획 10 x +기획 10 x +기후 10 x +긴 10 x +긴급 10 x +긴급 10 x +긴밀 10 x +긴요 10 x +긴장 10 x +긴장감 10 x +긴축 10 x +긴축 10 x +긴팔 10 x +길 10 x +길가 10 x +길거리 10 x +길들여 10 x +길들이 10 x +길래 10 x +길목 10 x +길목 10 x +길이 10 x +김 10 x +김 10 x +김관진 10 x +김광림 10 x +김광진 10 x +김근태 10 x +김기찬 10 x +김기춘 10 x +김능진 10 x +김대중 10 x +김대통령 10 x +김대환 10 x +김동윤 10 x +김동한 10 x +김동현 10 x +김만복 10 x +김만복 10 x +김명호 10 x +김무성 10 x +김문성 10 x +김문수 10 x +김미경 10 x +김밥 10 x +김밥 10 x +김범석 10 x +김범석 10 x +김범수 10 x +김병욱 10 x +김병욱 10 x +김부겸 10 x +김상곤 10 x +김상균 10 x +김상만 10 x +김상무 10 x +김상훈 10 x +김선홍 10 x +김성근 10 x +김성완 10 x +김성현 10 x +김세윤 10 x +김수남 10 x +김수민 10 x +김수현 10 x +김승연 10 x +김신혜 10 x +김심 10 x +김양배 10 x +김양제 10 x +김여정 10 x +김연아 10 x +김영귀 10 x +김영나 10 x +김영대 10 x +김영란 10 x +김영삼 10 x +김영석 10 x +김영한 10 x +김영환 10 x +김영희 10 x +김예리 10 x +김완일 10 x +김용국 10 x +김우석 10 x +김우주 10 x +김우중 10 x +김운환 10 x +김윤환 10 x +김의원 10 x +김일성 10 x +김정 10 x +김정남 10 x +김정민 10 x +김정부 10 x +김정은 10 x +김정일 10 x +김정주 10 x +김제동 10 x +김종덕 10 x +김종인 10 x +김종필 10 x +김주열 10 x +김진재 10 x +김진태 10 x +김진혁 10 x +김진혁 10 x +김진혁 10 x +김천 10 x +김철 10 x +김초원 10 x +김치 10 x +김치찌개 10 x +김태현 10 x +김태홍 10 x +김태환 10 x +김포 10 x +김필수 10 x +김하기 10 x +김한길 10 x +김해 10 x +김현정 10 x +김현종 10 x +김현철 10 x +김혜수 10 x +김화남 10 x +김희석 10 x +깁니다 10 x +깃발 10 x +깃발 10 x +깅이 10 x +깊 10 x +깊 10 x +깊숙이 10 x +깊이 10 x +까 10 x +까다로운 10 x +까다로워 10 x +까다롭 10 x +까다롭 10 x +까닭 10 x +까라 10 x +까리 10 x +까마귀 10 x +까만 10 x +까맣 10 x +까먹 10 x +까스 10 x +까요 10 x +까지 10 x +까진 10 x +까치 10 x +까칠 10 x +깍두기 10 x +깎 10 x +깎 10 x +깎인 10 x +깐 10 x +깔 10 x +깔끔 10 x +깔려 10 x +깔린 10 x +깜빡 10 x +깜빡이 10 x +깜빡했 10 x +깜빡했 10 x +깜짝 10 x +깜짝 10 x +깡 10 x +깡마른 10 x +깡통 10 x +깡패 10 x +깨 10 x +깨끗 10 x +깨끗이 10 x +깨놓 10 x +깨놓 10 x +깨닫 10 x +깨닫 10 x +깨달 10 x +깨달음 10 x +깨뜨렸 10 x +깨뜨리 10 x +깨뜨릴 10 x +깨문 10 x +깨물 10 x +깨어나 10 x +깨우쳐 10 x +깨워 10 x +깨져 10 x +깨지 10 x +깨진 10 x +깬 10 x +깼 10 x +깼 10 x +꺼 10 x +꺼내 10 x +꺼낸 10 x +꺼냈 10 x +꺼리 10 x +꺼져 10 x +꺼졌 10 x +꺼지 10 x +꺾 10 x +꺾기 10 x +꺾이 10 x +껀 10 x +껌 10 x +껍데기 10 x +껍질 10 x +껐 10 x +껐 10 x +껑충 10 x +께 10 x +께서 10 x +껴 10 x +껴도 10 x +껴서 10 x +껴요 10 x +꼬 10 x +꼬드겼 10 x +꼬리 10 x +꼬마 10 x +꼬박꼬박 10 x +꼬여 10 x +꼬옥 10 x +꼬이 10 x +꼬집 10 x +꼬투리 10 x +꼭 10 x +꼭 10 x +꼭대기 10 x +꼭두각시 10 x +꼭지 10 x +꼰 10 x +꼴 10 x +꼼꼼 10 x +꼼꼼히 10 x +꼼짝 10 x +꼼짝 10 x +꼽 10 x +꼽 10 x +꼽혔 10 x +꼽혔 10 x +꼽히 10 x +꼽힌다 10 x +꽁꽁 10 x +꽁치 10 x +꽂 10 x +꽂 10 x +꽂혀 10 x +꽃 10 x +꽃 10 x +꽃가루 10 x +꽃가루 10 x +꽃길 10 x +꽃병 10 x +꽃병 10 x +꽃잎 10 x +꽃잎 10 x +꽃피 10 x +꽈배기 10 x +꽉 10 x +꽉 10 x +꽝 10 x +꽤 10 x +꽥꽥 10 x +꾀하 10 x +꾸 10 x +꾸려 10 x +꾸리 10 x +꾸며 10 x +꾸미 10 x +꾸밀 10 x +꾸준 10 x +꾸준히 10 x +꾸준히 10 x +꾸중 10 x +꾸짖 10 x +꾸짖 10 x +꾹 10 x +꾹 10 x +꾼 10 x +꿀 10 x +꿀벌 10 x +꿇 10 x +꿈 10 x +꿈꾸 10 x +꿈꾼다 10 x +꿈꿀 10 x +꿈꿔 10 x +꿔 10 x +꿨 10 x +꿰 10 x +뀌 10 x +끄 10 x +끄덕끄덕 10 x +끄덕여 10 x +끄덕이 10 x +끄집어내 10 x +끅 10 x +끅 10 x +끈 10 x +끈기 10 x +끈끈 10 x +끈다 10 x +끈질기 10 x +끈질긴 10 x +끊 10 x +끊겼 10 x +끊겼 10 x +끊기 10 x +끊어졌 10 x +끊이 10 x +끊임없 10 x +끊임없이 10 x +끌 10 x +끌려 10 x +끌려가 10 x +끌려간 10 x +끌린다 10 x +끌어내 10 x +끌어들여 10 x +끌어들이 10 x +끌어안 10 x +끌어온 10 x +끌어올려야 10 x +끌어올리 10 x +끌어왔 10 x +끌어왔 10 x +끓 10 x +끓여 10 x +끓이 10 x +끓인 10 x +끔찍 10 x +끝 10 x +끝 10 x +끝나 10 x +끝난 10 x +끝난다 10 x +끝난지 10 x +끝날 10 x +끝내 10 x +끝냈 10 x +끝없이 10 x +끼 10 x +끼니 10 x +끼리 10 x +끼어들 10 x +끼얹 10 x +끼여 10 x +끼워 10 x +끼쳐서 10 x +끼쳤 10 x +끼치 10 x +끼칠 10 x +끼칠까 10 x +낀 10 x +낌새 10 x +나 10 x +나 10 x +나가 10 x +나가 10 x +나간 10 x +나간 10 x +나간다 10 x +나간다 10 x +나간다는 10 x +나간다는 10 x +나간다면 10 x +나간다면 10 x +나갈 10 x +나갈 10 x +나갑니다 10 x +나갔 10 x +나갔 10 x +나갔 10 x +나갔었 10 x +나갔었 10 x +나갔었 10 x +나기 10 x +나나미 10 x +나날이 10 x +나누 10 x +나누 10 x +나눈 10 x +나눈 10 x +나눌 10 x +나눌 10 x +나눔 10 x +나눠 10 x +나눠 10 x +나눠야 10 x +나눴 10 x +나눴 10 x +나눴 10 x +나뉘 10 x +나뉜다 10 x +나뉜다 10 x +나니 10 x +나대 10 x +나대 10 x +나도 10 x +나돈 10 x +나돌 10 x +나돌 10 x +나들목 10 x +나들목 10 x +나들이 10 x +나라 10 x +나라 10 x +나란히 10 x +나란히 10 x +나란히 10 x +나르 10 x +나름 10 x +나름 10 x +나마 10 x +나머지 10 x +나머지 10 x +나머진 10 x +나면 10 x +나면 10 x +나무 10 x +나무 10 x +나물 10 x +나뭇가지 10 x +나뭇가지 10 x +나뭇잎 10 x +나뭇잎 10 x +나미 10 x +나미 10 x +나비 10 x +나비 10 x +나빠 10 x +나빠 10 x +나빠서 10 x +나빠서 10 x +나빠요 10 x +나빠져 10 x +나빠져 10 x +나빠졌 10 x +나빠졌 10 x +나빠진 10 x +나빠진 10 x +나빠질 10 x +나쁘 10 x +나쁘 10 x +나쁜 10 x +나쁜 10 x +나산 10 x +나서 10 x +나서 10 x +나선 10 x +나선 10 x +나설 10 x +나설 10 x +나섰 10 x +나섰 10 x +나섰 10 x +나섰 10 x +나성 10 x +나아 10 x +나아가 10 x +나아가 10 x +나아갈 10 x +나아갑시다 10 x +나아갑시다 10 x +나아졌 10 x +나아지 10 x +나아지 10 x +나아진 10 x +나아진 10 x +나아질 10 x +나아질 10 x +나약 10 x +나약 10 x +나영 10 x +나영 10 x +나오 10 x +나오 10 x +나온 10 x +나온 10 x +나온다 10 x +나온다 10 x +나온다고 10 x +나온다고 10 x +나올 10 x +나올 10 x +나올까 10 x +나올까 10 x +나올지 10 x +나올지 10 x +나옴 10 x +나옵니다 10 x +나옵니다 10 x +나와 10 x +나와 10 x +나와라 10 x +나와라 10 x +나와서 10 x +나와서 10 x +나와야 10 x +나와요 10 x +나왔 10 x +나왔 10 x +나왔 10 x +나왔 10 x +나왔었 10 x +나왔었 10 x +나요 10 x +나요 10 x +나우 10 x +나우 10 x +나웅배 10 x +나위 10 x +나을 10 x +나을 10 x +나이 10 x +나이 10 x +나이키 10 x +나이키 10 x +나이트 10 x +나인 10 x +나인 10 x +나잇 10 x +나잇 10 x +나중 10 x +나중 10 x +나지 10 x +나지 10 x +나진 10 x +나치 10 x +나치 10 x +나카소네 10 x +나카소네 10 x +나크 10 x +나타 10 x +나타나 10 x +나타나 10 x +나타난 10 x +나타난 10 x +나타난다 10 x +나타난다 10 x +나타날 10 x +나타납니다 10 x +나타납니다 10 x +나타났 10 x +나타났 10 x +나타났 10 x +나타났 10 x +나타내 10 x +나타내 10 x +나타낸다 10 x +나타낸다 10 x +나타낼 10 x +나타낼 10 x +나타냈 10 x +나타냈 10 x +나타냈 10 x +나타냈 10 x +나포 10 x +나포 10 x +나흘 10 x +나흘 10 x +낙관 10 x +낙관 10 x +낙담 10 x +낙동강 10 x +낙동강 10 x +낙동강 10 x +낙마 10 x +낙서 10 x +낙서 10 x +낙선 10 x +낙선 10 x +낙엽 10 x +낙오자 10 x +낙오자 10 x +낙인 10 x +낙인 10 x +낙전 10 x +낙전 10 x +낙점 10 x +낙찰 10 x +낙찰 10 x +낙찰 10 x +낙찰가 10 x +낙찰가 10 x +낙찰자 10 x +낙찰자 10 x +낙천 10 x +낙천 10 x +낙천 10 x +낙타털 10 x +낙타털 10 x +낙타털 10 x +낙폭 10 x +낙폭 10 x +낙폭 10 x +낙폭 10 x +낙하산 10 x +낙하산 10 x +낙후 10 x +낙후 10 x +낚시 10 x +낚시 10 x +난 10 x +난 10 x +난감 10 x +난감 10 x +난개발 10 x +난관 10 x +난국 10 x +난국 10 x +난국 10 x +난다 10 x +난다 10 x +난다고 10 x +난다고 10 x +난다는 10 x +난다는 10 x +난다든가 10 x +난다든가 10 x +난동 10 x +난로 10 x +난로 10 x +난리 10 x +난리 10 x +난립 10 x +난민 10 x +난민 10 x +난민촌 10 x +난방 10 x +난방 10 x +난색 10 x +난소암 10 x +난소암 10 x +난이 10 x +난장판 10 x +난제 10 x +난제 10 x +난지 10 x +난지 10 x +난지도 10 x +난징 10 x +난처 10 x +난치병 10 x +난퉁 10 x +난퉁 10 x +난폭 10 x +난폭 10 x +난폭 10 x +난해 10 x +난해 10 x +날 10 x +날 10 x +날개 10 x +날개 10 x +날갯짓 10 x +날갯짓 10 x +날뛰 10 x +날뛰 10 x +날려 10 x +날려 10 x +날려서 10 x +날렵 10 x +날렵 10 x +날렸 10 x +날로 10 x +날로 10 x +날리 10 x +날리 10 x +날씨 10 x +날씨 10 x +날씬 10 x +날씬 10 x +날아가 10 x +날아가 10 x +날아간 10 x +날아간 10 x +날아갔 10 x +날아갔 10 x +날아오르 10 x +날아오르 10 x +날짜 10 x +날짜 10 x +날치기 10 x +날카로운 10 x +날카로운 10 x +날카로워 10 x +날카롭 10 x +낡 10 x +낡 10 x +낡 10 x +남 10 x +남 10 x +남겨 10 x +남겨 10 x +남겨져 10 x +남겼 10 x +남겼 10 x +남겼 10 x +남경 10 x +남구 10 x +남구 10 x +남극 10 x +남극 10 x +남극 10 x +남기 10 x +남기 10 x +남긴 10 x +남긴 10 x +남길 10 x +남길 10 x +남녀 10 x +남녀 10 x +남녀노소 10 x +남녀노소 10 x +남다르 10 x +남다른 10 x +남다른 10 x +남다를 10 x +남다를 10 x +남대 10 x +남대문 10 x +남동생 10 x +남동생 10 x +남동쪽 10 x +남동쪽 10 x +남루 10 x +남매 10 x +남미 10 x +남미 10 x +남발 10 x +남방 10 x +남방 10 x +남부 10 x +남부 10 x +남북 10 x +남북 10 x +남북 10 x +남북교역 10 x +남북교역 10 x +남북한 10 x +남북한 10 x +남산 10 x +남성 10 x +남성 10 x +남아메리카 10 x +남아메리카 10 x +남아시아 10 x +남아시아 10 x +남아프리카 10 x +남아프리카 10 x +남양주 10 x +남양주 10 x +남용 10 x +남용 10 x +남원 10 x +남이면 10 x +남자 10 x +남자 10 x +남중 10 x +남중 10 x +남중국해 10 x +남중국해 10 x +남짓 10 x +남쪽 10 x +남쪽 10 x +남쪽 10 x +남측 10 x +남측 10 x +남측 10 x +남편 10 x +남편 10 x +남학생 10 x +남학생 10 x +남한 10 x +남한 10 x +납니다 10 x +납니다 10 x +납득 10 x +납득 10 x +납득 10 x +납득 10 x +납부 10 x +납부 10 x +납입 10 x +납입 10 x +납작 10 x +납작 10 x +납치 10 x +납치 10 x +납치 10 x +납품 10 x +낫 10 x +낫 10 x +낫 10 x +났 10 x +났 10 x +났 10 x +났 10 x +났었 10 x +났었 10 x +났었 10 x +낭만 10 x +낭만 10 x +낭비 10 x +낭비 10 x +낮 10 x +낮 10 x +낮 10 x +낮 10 x +낮술 10 x +낮술 10 x +낮잠 10 x +낮추 10 x +낮추 10 x +낮출 10 x +낮출 10 x +낮춰 10 x +낯 10 x +낯 10 x +낯 10 x +낯선 10 x +낯선 10 x +낯설 10 x +낯설 10 x +낯익 10 x +낯익 10 x +낳 10 x +낳 10 x +낳 10 x +낳 10 x +내 10 x +내 10 x +내가 10 x +내가 10 x +내각 10 x +내각 10 x +내각 10 x +내건 10 x +내건 10 x +내걸 10 x +내걸 10 x +내게 10 x +내게 10 x +내게서 10 x +내게서 10 x +내과 10 x +내국 10 x +내국 10 x +내규 10 x +내규 10 x +내내 10 x +내내 10 x +내년 10 x +내년 10 x +내년도 10 x +내노 10 x +내놓 10 x +내놓 10 x +내놓 10 x +내놓 10 x +내놔 10 x +내다보 10 x +내다보 10 x +내다봤 10 x +내다봤 10 x +내다봤 10 x +내달 10 x +내달 10 x +내달릴 10 x +내달릴 10 x +내두르 10 x +내두르 10 x +내라 10 x +내란 10 x +내란죄 10 x +내려 10 x +내려 10 x +내려가 10 x +내려가 10 x +내려갈 10 x +내려갈 10 x +내려갔 10 x +내려갔 10 x +내려놓 10 x +내려놓 10 x +내려놓 10 x +내려놓 10 x +내려놨 10 x +내려놨 10 x +내려다 10 x +내려다보 10 x +내려도 10 x +내려서 10 x +내려서 10 x +내려앉 10 x +내려앉 10 x +내려야 10 x +내려야 10 x +내려오 10 x +내려오 10 x +내려올 10 x +내려올 10 x +내려왔 10 x +내려왔 10 x +내려졌 10 x +내려진 10 x +내려진 10 x +내려질 10 x +내려질 10 x +내렸 10 x +내렸 10 x +내렸 10 x +내렸 10 x +내륙 10 x +내리 10 x +내리 10 x +내리치 10 x +내리치 10 x +내린 10 x +내린 10 x +내린다 10 x +내린다 10 x +내릴 10 x +내릴 10 x +내막 10 x +내막 10 x +내막 10 x +내면 10 x +내면 10 x +내몰 10 x +내몰 10 x +내몰렸 10 x +내몰렸 10 x +내무 10 x +내무부 10 x +내밀 10 x +내밀 10 x +내버려 10 x +내보내 10 x +내보내 10 x +내복 10 x +내복 10 x +내복 10 x +내부 10 x +내부 10 x +내부자 10 x +내부자 10 x +내분 10 x +내분 10 x +내비쳤 10 x +내비치 10 x +내비치 10 x +내비친 10 x +내비친 10 x +내빈 10 x +내빈 10 x +내뿜 10 x +내뿜 10 x +내세우 10 x +내세우 10 x +내세운 10 x +내세운 10 x +내세울 10 x +내세워 10 x +내세워 10 x +내세웠 10 x +내세웠 10 x +내세웠 10 x +내세웠 10 x +내셔널 10 x +내셔널 10 x +내셔널리즘 10 x +내수 10 x +내수 10 x +내쉬 10 x +내쉬 10 x +내실 10 x +내실화 10 x +내실화 10 x +내심 10 x +내역 10 x +내역 10 x +내역 10 x +내역 10 x +내연 10 x +내연 10 x +내오 10 x +내외 10 x +내외 10 x +내용 10 x +내용 10 x +내일 10 x +내일 10 x +내자 10 x +내장 10 x +내장 10 x +내전 10 x +내전 10 x +내정 10 x +내정 10 x +내정자 10 x +내정자 10 x +내주 10 x +내주 10 x +내줍니다 10 x +내줬 10 x +내줬 10 x +내줬 10 x +내지 10 x +내지 10 x +내쫓 10 x +내쫓 10 x +내키 10 x +내키 10 x +내통 10 x +내통 10 x +내포 10 x +낸 10 x +낸 10 x +낸다 10 x +낸다면 10 x +낼 10 x +낼 10 x +낼게요 10 x +낼까 10 x +냄비 10 x +냄비 10 x +냄새 10 x +냄새 10 x +냅니다 10 x +냇물 10 x +냈 10 x +냈 10 x +냈 10 x +냈 10 x +냈으나 10 x +냈으나 10 x +냉기류 10 x +냉담 10 x +냉담 10 x +냉동 10 x +냉동 10 x +냉동식품 10 x +냉동식품 10 x +냉동육 10 x +냉동육 10 x +냉랭 10 x +냉랭 10 x +냉면 10 x +냉면 10 x +냉방 10 x +냉방 10 x +냉엄 10 x +냉장고 10 x +냉장고 10 x +냉전 10 x +냉전 10 x +냉정 10 x +냉정 10 x +냉정히 10 x +냐 10 x +냐 10 x +냐 10 x +냐고 10 x +냐고 10 x +냐는 10 x +냐는 10 x +냐는 10 x +냐며 10 x +냐며 10 x +냐면 10 x +냐면 10 x +냐면서 10 x +냐면서 10 x +냐면은 10 x +냥 10 x +너 10 x +너 10 x +너머 10 x +너모 10 x +너무 10 x +너무 10 x +너무나 10 x +너무나 10 x +너무너무 10 x +너비 10 x +너비 10 x +너야 10 x +너야 10 x +너울 10 x +너울 10 x +너희 10 x +너희 10 x +넉 10 x +넉 10 x +넉넉 10 x +넉넉 10 x +넌 10 x +넌 10 x +널 10 x +널 10 x +널리 10 x +널리 10 x +넓 10 x +넓 10 x +넓이 10 x +넓이 10 x +넓혀 10 x +넓혀 10 x +넓히 10 x +넓힐 10 x +넘 10 x +넘 10 x +넘겨 10 x +넘겨 10 x +넘겨야 10 x +넘겨야 10 x +넘겼 10 x +넘겼 10 x +넘겼으나 10 x +넘기 10 x +넘기 10 x +넘긴 10 x +넘길 10 x +넘길 10 x +넘어가 10 x +넘어가 10 x +넘어갈 10 x +넘어갈 10 x +넘어갈지 10 x +넘어갈지 10 x +넘어갔 10 x +넘어갔 10 x +넘어갔 10 x +넘어서 10 x +넘어선 10 x +넘어선다 10 x +넘어선다 10 x +넘어섰 10 x +넘어온 10 x +넘어온 10 x +넘어왔 10 x +넘어왔 10 x +넘어져서 10 x +넘어져서 10 x +넘어졌 10 x +넘어졌 10 x +넘어지 10 x +넘어지 10 x +넘쳐 10 x +넘쳐나 10 x +넘쳐나 10 x +넘쳤 10 x +넘쳤 10 x +넘치 10 x +넘치 10 x +넘친 10 x +넣 10 x +넣 10 x +넣 10 x +넣 10 x +네 10 x +네 10 x +네 10 x +네 10 x +네 10 x +네 10 x +네 10 x +네거티브 10 x +네거티브 10 x +네거티브 10 x +네네 10 x +네네 10 x +네덜란드 10 x +네덜란드 10 x +네덜란드인 10 x +네덜란드인 10 x +네안데르탈 10 x +네안데르탈 10 x +네안데르탈인 10 x +네안데르탈인 10 x +네여 10 x +네요 10 x +네요 10 x +네이버 10 x +네이버 10 x +네일 10 x +네카 10 x +네트 10 x +네트 10 x +네트워크 10 x +네트워크 10 x +네티즌 10 x +네티즌 10 x +네티즌 10 x +네티즌 10 x +넥슨 10 x +넥슨 10 x +넥타이 10 x +넥타이 10 x +넥타이 10 x +넵 10 x +넵 10 x +넷 10 x +넷 10 x +넷 10 x +넷 10 x +넷째 10 x +넷째 10 x +넷플릭스 10 x +넷플릭스 10 x +넷플릭스 10 x +넷플릭스 10 x +넹 10 x +녀 10 x +녀석 10 x +녀석 10 x +년 10 x +년 10 x +년 10 x +녕 10 x +노 10 x +노 10 x +노경 10 x +노계 10 x +노골 10 x +노골 10 x +노관규 10 x +노관규 10 x +노구 10 x +노구 10 x +노년층 10 x +노년층 10 x +노동 10 x +노동 10 x +노동계 10 x +노동계 10 x +노동당 10 x +노동당 10 x +노동력 10 x +노동력 10 x +노동력 10 x +노동력 10 x +노동법 10 x +노동법 10 x +노동법 10 x +노동부 10 x +노동자 10 x +노동자 10 x +노동자당 10 x +노동자당 10 x +노동절 10 x +노동절 10 x +노동조합 10 x +노동조합주의 10 x +노동청 10 x +노동청 10 x +노드 10 x +노드 10 x +노란 10 x +노란 10 x +노란색 10 x +노란색 10 x +노랗 10 x +노래 10 x +노래 10 x +노래방 10 x +노래방 10 x +노래자 10 x +노랫소리 10 x +노랫소리 10 x +노려 10 x +노려 10 x +노력 10 x +노력 10 x +노력 10 x +노력 10 x +노렸 10 x +노렸 10 x +노로 10 x +노로 10 x +노르 10 x +노르 10 x +노르말 10 x +노르웨이 10 x +노르웨이 10 x +노릇 10 x +노릇 10 x +노릇 10 x +노리 10 x +노리 10 x +노린 10 x +노린 10 x +노모 10 x +노무현 10 x +노무현 10 x +노바 10 x +노바크 10 x +노바크 10 x +노벨 10 x +노벨 10 x +노벨상 10 x +노벨상 10 x +노보 10 x +노부부 10 x +노부부 10 x +노부인 10 x +노부인 10 x +노비 10 x +노비 10 x +노사 10 x +노사 10 x +노사정 10 x +노사정 10 x +노선 10 x +노선 10 x +노소영 10 x +노소영 10 x +노숙 10 x +노숙 10 x +노숙 10 x +노숙인 10 x +노숙인 10 x +노식 10 x +노식 10 x +노심 10 x +노아 10 x +노아 10 x +노엘 10 x +노엘 10 x +노역 10 x +노역 10 x +노역장 10 x +노역장 10 x +노예 10 x +노예 10 x +노원구 10 x +노원구 10 x +노인 10 x +노인 10 x +노인정 10 x +노인정 10 x +노임 10 x +노임 10 x +노정 10 x +노조 10 x +노조 10 x +노지 10 x +노지 10 x +노천탕 10 x +노천탕 10 x +노총 10 x +노총 10 x +노출 10 x +노출 10 x +노키아 10 x +노태우 10 x +노태우 10 x +노트 10 x +노트 10 x +노트북 10 x +노트북 10 x +노패 10 x +노프 10 x +노피 10 x +노피 10 x +노하우 10 x +노하우 10 x +노화 10 x +노화 10 x +노회찬 10 x +노회찬 10 x +노효 10 x +노효 10 x +노후 10 x +노후 10 x +녹 10 x +녹 10 x +녹 10 x +녹기 10 x +녹내장 10 x +녹내장 10 x +녹록 10 x +녹록 10 x +녹병균 10 x +녹병균 10 x +녹색 10 x +녹색 10 x +녹색 10 x +녹색 10 x +녹색환경 10 x +녹색환경 10 x +녹십자 10 x +녹십자 10 x +녹여서 10 x +녹여서 10 x +녹음 10 x +녹음 10 x +녹조 10 x +녹조 10 x +녹지 10 x +녹지 10 x +녹지 10 x +녹취 10 x +녹취 10 x +녹화 10 x +녹화 10 x +논 10 x +논 10 x +논객 10 x +논객 10 x +논객 10 x +논거 10 x +논거 10 x +논란 10 x +논란 10 x +논란거리 10 x +논리 10 x +논리 10 x +논문 10 x +논문 10 x +논의 10 x +논의 10 x +논쟁 10 x +논쟁 10 x +논평 10 x +논평 10 x +논하 10 x +논하 10 x +논하 10 x +논할 10 x +논할 10 x +논할 10 x +논현동 10 x +논현동 10 x +논현동 10 x +논현동 10 x +놀 10 x +놀 10 x +놀라 10 x +놀라 10 x +놀라운 10 x +놀라운 10 x +놀라울 10 x +놀라울 10 x +놀라움 10 x +놀라움 10 x +놀라웠 10 x +놀라웠 10 x +놀란 10 x +놀란 10 x +놀란다 10 x +놀랄 10 x +놀랄 10 x +놀랍 10 x +놀랍 10 x +놀랍 10 x +놀랐 10 x +놀랐 10 x +놀랐 10 x +놀랐 10 x +놀러 10 x +놀러와 10 x +놀러왔 10 x +놀러왔 10 x +놀리 10 x +놀리 10 x +놀이 10 x +놀이 10 x +놀이방 10 x +놀이터 10 x +놈 10 x +놈 10 x +농 10 x +농 10 x +농 10 x +농가 10 x +농가 10 x +농경지 10 x +농구 10 x +농구 10 x +농구장 10 x +농구장 10 x +농단 10 x +농단 10 x +농담 10 x +농담 10 x +농담 10 x +농담 10 x +농도 10 x +농도 10 x +농땡이 10 x +농락 10 x +농림부 10 x +농민 10 x +농민 10 x +농번기 10 x +농번기 10 x +농부 10 x +농부 10 x +농사 10 x +농사 10 x +농사일 10 x +농산 10 x +농산물 10 x +농산물 10 x +농성 10 x +농성 10 x +농수 10 x +농심 10 x +농약 10 x +농업 10 x +농업 10 x +농업 10 x +농업용 10 x +농업용 10 x +농작물 10 x +농작물 10 x +농장 10 x +농장 10 x +농장주 10 x +농장주 10 x +농지 10 x +농지 10 x +농촌 10 x +농촌 10 x +농축 10 x +농협 10 x +농협 10 x +농활 10 x +높 10 x +높 10 x +높 10 x +높여 10 x +높여 10 x +높였 10 x +높였 10 x +높이 10 x +높이 10 x +높인 10 x +높인다는 10 x +높일 10 x +높일 10 x +놓 10 x +놓 10 x +놓 10 x +놓 10 x +놓아두 10 x +놓아두 10 x +놓아둔 10 x +놓아둔 10 x +놓아주 10 x +놓아주 10 x +놓여 10 x +놓여 10 x +놓였 10 x +놓였 10 x +놓였 10 x +놓였 10 x +놓인 10 x +놓쳐서 10 x +놓쳤 10 x +놓쳤 10 x +놓쳤 10 x +놓쳤 10 x +놓치 10 x +놓치 10 x +놓칠 10 x +놔 10 x +놔 10 x +놔라 10 x +놨다 10 x +뇌 10 x +뇌 10 x +뇌 10 x +뇌 10 x +뇌물 10 x +뇌물 10 x +뇬 10 x +누 10 x +누 10 x +누가 10 x +누가 10 x +누구 10 x +누구 10 x +누군가 10 x +누군가 10 x +누군지 10 x +누군지 10 x +누굴 10 x +누굴 10 x +누그러뜨리 10 x +누그러뜨리 10 x +누나 10 x +누나 10 x +누님 10 x +누락 10 x +누락 10 x +누르 10 x +누르 10 x +누리 10 x +누리 10 x +누리꾼 10 x +누리꾼 10 x +누릴 10 x +누릴 10 x +누비 10 x +누비 10 x +누수 10 x +누수 10 x +누우 10 x +누우 10 x +누운 10 x +누워 10 x +누워 10 x +누워서 10 x +누워서 10 x +누이 10 x +누적 10 x +누적 10 x +누진 10 x +누진 10 x +누진제 10 x +누진제 10 x +누차 10 x +누차 10 x +누출 10 x +누출 10 x +눈 10 x +눈 10 x +눈 10 x +눈 10 x +눈가 10 x +눈가 10 x +눈가루 10 x +눈가루 10 x +눈감 10 x +눈감 10 x +눈길 10 x +눈길 10 x +눈높이 10 x +눈높이 10 x +눈덩이 10 x +눈덩이 10 x +눈동자 10 x +눈동자 10 x +눈모양 10 x +눈물 10 x +눈물 10 x +눈물겨운 10 x +눈물겨운 10 x +눈물나 10 x +눈병 10 x +눈병 10 x +눈부셔 10 x +눈부셔 10 x +눈부시 10 x +눈부시 10 x +눈부신 10 x +눈부신 10 x +눈비 10 x +눈비 10 x +눈빛 10 x +눈빛 10 x +눈빛 10 x +눈빛 10 x +눈살 10 x +눈살 10 x +눈송이 10 x +눈썹 10 x +눈썹 10 x +눈앞 10 x +눈앞 10 x +눈엣가시 10 x +눈엣가시 10 x +눈여겨보 10 x +눈여겨보 10 x +눈초리 10 x +눈초리 10 x +눈치 10 x +눈치 10 x +눌러 10 x +눌러 10 x +눕 10 x +눕 10 x +뉴 10 x +뉴 10 x +뉴스 10 x +뉴스 10 x +뉴스 10 x +뉴스타파 10 x +뉴스타파 10 x +뉴스타파 10 x +뉴스투데이 10 x +뉴시스 10 x +뉴시스 10 x +뉴시스 10 x +뉴욕 10 x +뉴욕 10 x +뉴욕 10 x +뉴욕 10 x +뉴욕 10 x +뉴욕 10 x +뉴욕한인회 10 x +뉴이스트 10 x +뉴이스트 10 x +뉴코아 10 x +뉴코아 10 x +뉴트리아 10 x +뉴트리아 10 x +느 10 x +느 10 x +느긋 10 x +느긋 10 x +느껴 10 x +느껴 10 x +느껴요 10 x +느껴져 10 x +느껴져 10 x +느껴져요 10 x +느껴져요 10 x +느껴졌 10 x +느껴졌 10 x +느껴진다 10 x +느껴진다 10 x +느껴짐 10 x +느껴짐 10 x +느꼈 10 x +느꼈 10 x +느꼈 10 x +느꼈 10 x +느끼 10 x +느끼 10 x +느낀 10 x +느낀 10 x +느낀다고 10 x +느낀다고 10 x +느낀다는 10 x +느낀다는 10 x +느낀다면 10 x +느낀다면 10 x +느낄 10 x +느낄 10 x +느낄지 10 x +느낄지 10 x +느낌 10 x +느낌 10 x +느낍니다 10 x +느냐 10 x +느냐 10 x +느냐고 10 x +느냐고 10 x +느냐는 10 x +느냐는 10 x +느냐며 10 x +느냐며 10 x +느냐면서 10 x +느니 10 x +느니라 10 x +느니라 10 x +느라 10 x +느라고 10 x +느려 10 x +느려 10 x +느려요 10 x +느려요 10 x +느려졌 10 x +느려졌 10 x +느렸 10 x +느렸 10 x +느릅나무 10 x +느릅나무 10 x +느리 10 x +느리 10 x +느린 10 x +느린 10 x +느슨 10 x +느슨 10 x +는 10 x +는 10 x +는가 10 x +는가 10 x +는구나 10 x +는군요 10 x +는다 10 x +는다 10 x +는다고 10 x +는다고 10 x +는다는 10 x +는다던지 10 x +는다며 10 x +는다면 10 x +는대 10 x +는데 10 x +는데 10 x +는데도 10 x +는데요 10 x +는데요 10 x +는지 10 x +는지 10 x +늘 10 x +늘 10 x +늘려 10 x +늘려 10 x +늘려야 10 x +늘려야 10 x +늘렸 10 x +늘렸 10 x +늘렸 10 x +늘리 10 x +늘리 10 x +늘린다는 10 x +늘린다는 10 x +늘릴 10 x +늘릴 10 x +늘상 10 x +늘상 10 x +늘어나 10 x +늘어나 10 x +늘어난 10 x +늘어난 10 x +늘어난다는 10 x +늘어날 10 x +늘어날 10 x +늘어남 10 x +늘어남 10 x +늘어났 10 x +늘어났 10 x +늘어났으며 10 x +늘어놓 10 x +늘어놓 10 x +늘어놓 10 x +늘어놨 10 x +늘어놨 10 x +늘어놨 10 x +늘어져 10 x +늘어지 10 x +늘어지 10 x +늘어진 10 x +늘어진 10 x +늘품 10 x +늘품 10 x +늙 10 x +늙 10 x +늙 10 x +능가 10 x +능동 10 x +능동 10 x +능력 10 x +능력 10 x +능력 10 x +능력 10 x +능력주의 10 x +능력주의 10 x +능력주의 10 x +능률 10 x +능선 10 x +능선 10 x +능숙 10 x +능숙 10 x +늦 10 x +늦 10 x +늦 10 x +늦 10 x +늦깎이 10 x +늦깎이 10 x +늦깎이 10 x +늦잠 10 x +늦잠 10 x +늦추 10 x +늦추 10 x +늦출 10 x +늦출 10 x +늦춰 10 x +늦춰 10 x +늪 10 x +늪 10 x +니 10 x +니 10 x +니 10 x +니까 10 x +니까 10 x +니까요 10 x +니깐 10 x +니네베 10 x +니다 10 x +니당 10 x +니라 10 x +니라 10 x +니모 10 x +니모 10 x +니미츠 10 x +니미츠 10 x +니스 10 x +니스 10 x +니스 10 x +니제 10 x +니제 10 x +니코니코 10 x +니코니코 10 x +니트 10 x +니트 10 x +닉 10 x +닉 10 x +닉네임 10 x +닉네임 10 x +닐리리 10 x +닐리리 10 x +님 10 x +님 10 x +님 10 x +닛 10 x +닛 10 x +닝 10 x +다 10 x +다가 10 x +다가가 10 x +다가간 10 x +다가서 10 x +다가앉 10 x +다가오 10 x +다가온 10 x +다가올 10 x +다가왔 10 x +다각 10 x +다각 10 x +다각화 10 x +다간 10 x +다고 10 x +다고들 10 x +다과 10 x +다구요 10 x +다국적 10 x +다국적 10 x +다기 10 x +다네 10 x +다녀 10 x +다녀가 10 x +다녀도 10 x +다녀야 10 x +다녀오 10 x +다녀온 10 x +다녀올 10 x +다녀왔 10 x +다녀왔 10 x +다녀요 10 x +다년간 10 x +다녔 10 x +다녔 10 x +다는 10 x +다는데 10 x +다니 10 x +다니까 10 x +다닌다 10 x +다닐 10 x +다다랐 10 x +다단계 10 x +다당제 10 x +다더라 10 x +다던 10 x +다드 10 x +다듬 10 x +다라 10 x +다락 10 x +다란 10 x +다량 10 x +다루 10 x +다룬 10 x +다룰 10 x +다뤄야 10 x +다뤄진 10 x +다뤘 10 x +다뤘 10 x +다르 10 x +다른 10 x +다른가 10 x +다를 10 x +다름 10 x +다름아닌 10 x +다름없 10 x +다름없 10 x +다릅니다 10 x +다리 10 x +다만 10 x +다며 10 x +다면 10 x +다면서 10 x +다목 10 x +다문 10 x +다물 10 x +다미아 10 x +다발 10 x +다섯 10 x +다섯 10 x +다섯째 10 x +다세대 10 x +다소 10 x +다솔 10 x +다수 10 x +다수결 10 x +다스 10 x +다스리 10 x +다시 10 x +다시피 10 x +다아 10 x +다양 10 x +다운 10 x +다움 10 x +다윈 10 x +다윗 10 x +다윗 10 x +다음 10 x +다이너스 10 x +다이브 10 x +다이빙 10 x +다이아몬드 10 x +다이어트 10 x +다자 10 x +다자간 10 x +다잖아 10 x +다정 10 x +다져 10 x +다중 10 x +다짐 10 x +다쳤 10 x +다쳤 10 x +다치 10 x +다친 10 x +다칠까 10 x +다카유키 10 x +다큐 10 x +다투 10 x +다툼 10 x +다퉈 10 x +다행 10 x +다행히 10 x +닥쳐 10 x +닥쳤 10 x +닥쳤 10 x +닥치 10 x +닥치 10 x +닥친 10 x +닥친 10 x +닥터 10 x +닦 10 x +닦 10 x +단 10 x +단가 10 x +단거리 10 x +단결 10 x +단결권 10 x +단계 10 x +단골 10 x +단과 10 x +단군 10 x +단급 10 x +단기 10 x +단기간 10 x +단념 10 x +단단 10 x +단단히 10 x +단단히 10 x +단독 10 x +단독 10 x +단둥 10 x +단락 10 x +단락 10 x +단란 10 x +단막 10 x +단말기 10 x +단맛 10 x +단맛 10 x +단면 10 x +단발 10 x +단백질 10 x +단백질 10 x +단번 10 x +단번에 10 x +단상 10 x +단세포 10 x +단속 10 x +단속 10 x +단수 10 x +단순 10 x +단순히 10 x +단순히 10 x +단식 10 x +단식 10 x +단어 10 x +단언 10 x +단연 10 x +단원 10 x +단위 10 x +단일 10 x +단장 10 x +단절 10 x +단점 10 x +단정 10 x +단종 10 x +단죄 10 x +단지 10 x +단체 10 x +단체장 10 x +단추 10 x +단축 10 x +단칸 10 x +단칸방 10 x +단통 10 x +단편 10 x +단풍 10 x +단합 10 x +단합 10 x +단행 10 x +단행 10 x +단호 10 x +단호히 10 x +단화 10 x +닫 10 x +닫 10 x +닫혀 10 x +닫혀 10 x +닫히 10 x +닫히 10 x +닫힌 10 x +닫힌 10 x +달 10 x +달걀 10 x +달달 10 x +달라 10 x +달라요 10 x +달라졌 10 x +달라졌 10 x +달라지 10 x +달라진 10 x +달라진다 10 x +달라질 10 x +달랐 10 x +달랐 10 x +달래 10 x +달랜 10 x +달러 10 x +달러화 10 x +달려 10 x +달려가 10 x +달려갔 10 x +달려들 10 x +달려라 10 x +달려오 10 x +달려온 10 x +달려왔 10 x +달력 10 x +달렸 10 x +달리 10 x +달리기 10 x +달린 10 x +달릴 10 x +달빛 10 x +달성 10 x +달아나 10 x +달아난 10 x +달아났 10 x +달아났 10 x +달아오르 10 x +달째 10 x +달콤 10 x +달하 10 x +달한 10 x +달한다 10 x +달한다고 10 x +달할 10 x +달해 10 x +달했 10 x +달했 10 x +닭 10 x +닭 10 x +닭고기 10 x +닭도리탕 10 x +닮 10 x +닳 10 x +담 10 x +담가 10 x +담겨 10 x +담겼 10 x +담그 10 x +담긴 10 x +담당 10 x +담당관 10 x +담당자 10 x +담론 10 x +담배 10 x +담뱃값 10 x +담뱃값 10 x +담보 10 x +담수화 10 x +담요 10 x +담임 10 x +담장 10 x +담주 10 x +담합 10 x +담화 10 x +담화문 10 x +답 10 x +답 10 x +답니다 10 x +답답 10 x +답답 10 x +답변 10 x +답변서 10 x +답사 10 x +답안 10 x +답장 10 x +답한 10 x +닷 10 x +닷 10 x +닷새 10 x +닷컴 10 x +닷컴 10 x +당 10 x +당겨 10 x +당겨서 10 x +당과 10 x +당구 10 x +당구장 10 x +당국 10 x +당국 10 x +당국자 10 x +당권 10 x +당근 10 x +당기 10 x +당긴 10 x +당내 10 x +당뇨 10 x +당뇨병 10 x +당당 10 x +당당히 10 x +당대 10 x +당도 10 x +당락 10 x +당락 10 x +당론 10 x +당면 10 x +당명 10 x +당무 10 x +당번 10 x +당번제 10 x +당부 10 x +당분 10 x +당분간 10 x +당비 10 x +당사 10 x +당사국 10 x +당사국 10 x +당사자 10 x +당선 10 x +당선자 10 x +당설 10 x +당수 10 x +당시 10 x +당신 10 x +당연 10 x +당연히 10 x +당연히 10 x +당원 10 x +당위 10 x +당은 10 x +당인 10 x +당일 10 x +당장 10 x +당정 10 x +당좌 10 x +당지 10 x +당직 10 x +당직 10 x +당직자 10 x +당직자 10 x +당창 10 x +당첨 10 x +당첨자 10 x +당청 10 x +당초 10 x +당파 10 x +당풍 10 x +당하 10 x +당한 10 x +당한다 10 x +당할 10 x +당할까봐 10 x +당해 10 x +당했 10 x +당했 10 x +당헌 10 x +당호 10 x +당혹 10 x +당황 10 x +닿 10 x +닿 10 x +대 10 x +대가 10 x +대가 10 x +대강 10 x +대개 10 x +대거 10 x +대검 10 x +대검찰청 10 x +대결 10 x +대공 10 x +대구 10 x +대구시 10 x +대국 10 x +대국민 10 x +대국주의 10 x +대권 10 x +대금 10 x +대기 10 x +대기실 10 x +대남 10 x +대내외 10 x +대니얼 10 x +대니엘 10 x +대다수 10 x +대단 10 x +대단위 10 x +대단히 10 x +대단히 10 x +대담 10 x +대답 10 x +대답 10 x +대당 10 x +대대 10 x +대덕연구단지 10 x +대도 10 x +대동 10 x +대두 10 x +대들 10 x +대뜸 10 x +대란 10 x +대략 10 x +대략 10 x +대량 10 x +대령 10 x +대로 10 x +대류 10 x +대륙 10 x +대륙 10 x +대리 10 x +대리운전 10 x +대리인 10 x +대리점 10 x +대림 10 x +대립 10 x +대만 10 x +대망 10 x +대면 10 x +대명사 10 x +대목 10 x +대목 10 x +대문 10 x +대미 10 x +대민 10 x +대박 10 x +대박 10 x +대법원 10 x +대변 10 x +대변인 10 x +대부분 10 x +대북 10 x +대북 10 x +대비 10 x +대사 10 x +대사관 10 x +대상 10 x +대상자 10 x +대상지 10 x +대생 10 x +대선 10 x +대설주의보 10 x +대세 10 x +대손 10 x +대신 10 x +대신동 10 x +대안 10 x +대양 10 x +대여 10 x +대여료 10 x +대여섯 10 x +대열 10 x +대왕 10 x +대외 10 x +대외비 10 x +대요 10 x +대우 10 x +대우그룹 10 x +대우그룹 10 x +대우중공업 10 x +대월 10 x +대위 10 x +대응 10 x +대응책 10 x +대응책 10 x +대의 10 x +대의원 10 x +대인 10 x +대일 10 x +대입 10 x +대입 10 x +대자보 10 x +대장 10 x +대장균 10 x +대장암 10 x +대장정 10 x +대저 10 x +대전 10 x +대전환 10 x +대접 10 x +대제 10 x +대제사장 10 x +대조 10 x +대졸 10 x +대주교 10 x +대주주 10 x +대중 10 x +대중교통 10 x +대지 10 x +대책 10 x +대책 10 x +대책위 10 x +대처 10 x +대청소 10 x +대체 10 x +대체로 10 x +대초 10 x +대축전 10 x +대출 10 x +대출금 10 x +대출자 10 x +대충 10 x +대치 10 x +대타협 10 x +대통령 10 x +대통령궁 10 x +대통일 10 x +대폭 10 x +대폭 10 x +대표 10 x +대표단 10 x +대표부 10 x +대표자 10 x +대피 10 x +대하 10 x +대학 10 x +대학 10 x +대학가 10 x +대학교 10 x +대학생 10 x +대학원 10 x +대학원생 10 x +대한 10 x +대한무역진흥공사 10 x +대한민국 10 x +대한민국 10 x +대한항공 10 x +대한항공 10 x +대항 10 x +대해 10 x +대해서 10 x +대해선 10 x +대했 10 x +대행 10 x +대혁명 10 x +대형 10 x +대형주 10 x +대형트럭 10 x +대화 10 x +대회 10 x +대회장 10 x +댁 10 x +댁 10 x +댄스 10 x +댈 10 x +댐 10 x +댐피어 10 x +댑니다 10 x +댓글 10 x +댓글 10 x +댔 10 x +댕 10 x +댜 10 x +댜 10 x +더 10 x +더구나 10 x +더구먼 10 x +더군다나 10 x +더군요 10 x +더글러스 10 x +더니 10 x +더더욱 10 x +더듬 10 x +더딘 10 x +더딘 10 x +더라 10 x +더라고 10 x +더라고요 10 x +더라구요 10 x +더라는 10 x +더라도 10 x +더라면 10 x +더러 10 x +더러운 10 x +더러워 10 x +더럽 10 x +더럽 10 x +더럽히 10 x +더미 10 x +더부살이 10 x +더불 10 x +더블 10 x +더빙 10 x +더샵 10 x +더스트 10 x +더없이 10 x +더우 10 x +더욱 10 x +더욱 10 x +더욱더 10 x +더욱이 10 x +더운 10 x +더워 10 x +더워도 10 x +더워서 10 x +더웠 10 x +더웠 10 x +더위 10 x +더해져 10 x +덕 10 x +덕 10 x +덕분 10 x +덕분 10 x +덕진동 10 x +덕호 10 x +던 10 x +던가 10 x +던데 10 x +던데요 10 x +던져 10 x +던졌 10 x +던지 10 x +던진 10 x +던진다고 10 x +던질 10 x +던컨 10 x +덜 10 x +덜컥 10 x +덜컥 10 x +덜컹 10 x +덤 10 x +덤덤 10 x +덤불 10 x +덥 10 x +덧붙였 10 x +덧붙였 10 x +덧붙였 10 x +덧붙였 10 x +덧붙이 10 x +덧붙인 10 x +덧씌워진 10 x +덩 10 x +덩달 10 x +덩샤오핑 10 x +덩어리 10 x +덩치 10 x +덫 10 x +덫 10 x +덮 10 x +덮개 10 x +덮개 10 x +덮여 10 x +덮쳐 10 x +덮친 10 x +데 10 x +데니 10 x +데려 10 x +데려가 10 x +데려갔 10 x +데려다 10 x +데려오 10 x +데려와 10 x +데리 10 x +데모 10 x +데뷔 10 x +데스 10 x +데스크 10 x +데운 10 x +데워 10 x +데이 10 x +데이비드 10 x +데이비스 10 x +데이터 10 x +데이터베이스 10 x +데이트 10 x +데일리 10 x +덴 10 x +덴마크 10 x +델 10 x +뎌 10 x +뎌 10 x +도 10 x +도 10 x +도구 10 x +도금 10 x +도깨비 10 x +도난 10 x +도달 10 x +도대체 10 x +도덕 10 x +도덕관 10 x +도둑 10 x +도둑 10 x +도라산 10 x +도래 10 x +도려내 10 x +도령 10 x +도로 10 x +도로공사 10 x +도로는 10 x +도로포장 10 x +도록 10 x +도록 10 x +도루 10 x +도리 10 x +도리 10 x +도리어 10 x +도마 10 x +도마뱀 10 x +도망 10 x +도망가 10 x +도망간다 10 x +도망갔 10 x +도모 10 x +도무지 10 x +도미 10 x +도민 10 x +도박 10 x +도박 10 x +도발 10 x +도보 10 x +도봉구 10 x +도봉산 10 x +도사리 10 x +도산 10 x +도살 10 x +도색 10 x +도서 10 x +도서관 10 x +도시 10 x +도시락 10 x +도시락 10 x +도심 10 x +도약 10 x +도약 10 x +도와 10 x +도와야 10 x +도왔 10 x +도외 10 x +도요타 10 x +도우러 10 x +도우미 10 x +도울 10 x +도움 10 x +도의 10 x +도입 10 x +도입 10 x +도장 10 x +도저히 10 x +도전 10 x +도전장 10 x +도정 10 x +도중 10 x +도지사 10 x +도착 10 x +도착 10 x +도처 10 x +도청 10 x +도축장 10 x +도출 10 x +도쿄 10 x +도태 10 x +도평동 10 x +도표 10 x +도피 10 x +독 10 x +독 10 x +독감 10 x +독과점 10 x +독도 10 x +독려 10 x +독립 10 x +독립 10 x +독립성 10 x +독립영화 10 x +독립운동 10 x +독산 10 x +독생자 10 x +독서 10 x +독선 10 x +독설 10 x +독수리 10 x +독식 10 x +독일 10 x +독일군 10 x +독일어 10 x +독일인 10 x +독자 10 x +독재 10 x +독재 10 x +독점 10 x +독주 10 x +독창 10 x +독촉 10 x +독촉 10 x +독침 10 x +독특 10 x +독특 10 x +독특 10 x +독특 10 x +독하 10 x +독한 10 x +돈 10 x +돈 10 x +돈다 10 x +돈벌이 10 x +돈줄 10 x +돈토 10 x +돋 10 x +돋 10 x +돋보여 10 x +돋보여 10 x +돋보이 10 x +돋보이 10 x +돌 10 x +돌 10 x +돌고래 10 x +돌다리 10 x +돌덩이 10 x +돌려 10 x +돌려보내 10 x +돌려서 10 x +돌려야 10 x +돌려주 10 x +돌려준 10 x +돌렸 10 x +돌렸 10 x +돌리 10 x +돌릴 10 x +돌림 10 x +돌멩이 10 x +돌발 10 x +돌변 10 x +돌보 10 x +돌본 10 x +돌봄 10 x +돌아가 10 x +돌아갈 10 x +돌아갔 10 x +돌아다녔 10 x +돌아다니 10 x +돌아보 10 x +돌아본 10 x +돌아볼 10 x +돌아서 10 x +돌아선 10 x +돌아섰 10 x +돌아오 10 x +돌아온 10 x +돌아온다는 10 x +돌아올 10 x +돌아옵니다 10 x +돌아와 10 x +돌아와서 10 x +돌아왔 10 x +돌아왔 10 x +돌연변이 10 x +돌이켜 10 x +돌입 10 x +돌잔치 10 x +돌진 10 x +돌출 10 x +돌파 10 x +돌파구 10 x +돌팔매 10 x +돌풍 10 x +돐 10 x +돔 10 x +돕 10 x +돕 10 x +돗자리 10 x +동 10 x +동 10 x +동감 10 x +동강 10 x +동거 10 x +동거녀 10 x +동결 10 x +동경 10 x +동계 10 x +동고동락 10 x +동고동락 10 x +동광리 10 x +동교 10 x +동교동 10 x +동구 10 x +동굴 10 x +동그란 10 x +동그랗 10 x +동그래 10 x +동글동글 10 x +동기 10 x +동남아 10 x +동남아시아 10 x +동네 10 x +동대문 10 x +동덕 10 x +동도 10 x +동독 10 x +동독 10 x +동등 10 x +동래구 10 x +동력 10 x +동료 10 x +동맹 10 x +동맹국 10 x +동무 10 x +동문회 10 x +동물 10 x +동물원 10 x +동반 10 x +동반자 10 x +동백꽃 10 x +동백꽃 10 x +동병상련 10 x +동부 10 x +동북 10 x +동북아 10 x +동사 10 x +동산 10 x +동산면 10 x +동생 10 x +동서 10 x +동선 10 x +동성애 10 x +동성애자 10 x +동수 10 x +동시 10 x +동식물 10 x +동아 10 x +동아리 10 x +동아시아 10 x +동아일보 10 x +동안 10 x +동양 10 x +동양인 10 x +동양제과 10 x +동언 10 x +동영상 10 x +동요 10 x +동우 10 x +동원 10 x +동원산업 10 x +동유럽 10 x +동의 10 x +동이 10 x +동인 10 x +동일 10 x +동일시 10 x +동작 10 x +동작 10 x +동전 10 x +동정 10 x +동정녀 10 x +동정심 10 x +동조 10 x +동족 10 x +동종 10 x +동지 10 x +동쪽 10 x +동쪽 10 x +동참 10 x +동창 10 x +동창회 10 x +동체 10 x +동탄 10 x +동태 10 x +동포 10 x +동하 10 x +동한 10 x +동해 10 x +동행 10 x +동향 10 x +동혁 10 x +동현 10 x +동호 10 x +동호회 10 x +동화 10 x +동화은행 10 x +동화은행 10 x +동화책 10 x +동화책 10 x +돛 10 x +돼 10 x +돼버렸 10 x +돼서 10 x +돼야 10 x +돼온 10 x +돼왔 10 x +돼왔 10 x +돼요 10 x +돼지 10 x +돼지고기 10 x +됐 10 x +됐 10 x +됐으나 10 x +됐으니 10 x +됐으며 10 x +됐을까 10 x +되 10 x +되게 10 x +되돌려 10 x +되돌리 10 x +되돌아오 10 x +되물 10 x +되살리 10 x +되살아나 10 x +되새기 10 x +되짚 10 x +되찾 10 x +되찾 10 x +되풀이 10 x +된 10 x +된다 10 x +된다고 10 x +된다는 10 x +된다며 10 x +된다면 10 x +된다면은 10 x +된대 10 x +된장 10 x +된장찌개 10 x +될 10 x +될까 10 x +될까요 10 x +될지 10 x +됨 10 x +됩니까 10 x +됩니다 10 x +됬 10 x +됬 10 x +두 10 x +두근거렸 10 x +두근거리 10 x +두근두근 10 x +두꺼비 10 x +두꺼운 10 x +두껍 10 x +두껍 10 x +두께 10 x +두뇌 10 x +두둑 10 x +두드러졌 10 x +두드러졌 10 x +두드러지 10 x +두드러진 10 x +두드러진다 10 x +두드려 10 x +두드리 10 x +두들겨 10 x +두려움 10 x +두려워 10 x +두려워하 10 x +두려워한 10 x +두렵 10 x +두렵 10 x +두루 10 x +두른 10 x +두만 10 x +두만강 10 x +두말 10 x +두목 10 x +두목 10 x +두부 10 x +두산 10 x +두세 10 x +두어 10 x +두툼 10 x +두피 10 x +둑 10 x +둑 10 x +둔 10 x +둔갑 10 x +둔갑 10 x +둔기 10 x +둔다 10 x +둔다면 10 x +둔치 10 x +둔화 10 x +둘 10 x +둘러보 10 x +둘러볼 10 x +둘러싸 10 x +둘러싸여 10 x +둘러싼 10 x +둘러쌌 10 x +둘레 10 x +둘째 10 x +둥 10 x +둥그렇 10 x +둥그렇 10 x +둥근 10 x +둥글 10 x +둥지 10 x +둬 10 x +둬라 10 x +둬야 10 x +뒀 10 x +뒀 10 x +뒤 10 x +뒤 10 x +뒤끝 10 x +뒤끝 10 x +뒤늦 10 x +뒤늦 10 x +뒤덮 10 x +뒤돌아보 10 x +뒤따라 10 x +뒤따르 10 x +뒤따른다 10 x +뒤따를 10 x +뒤바뀌 10 x +뒤섞이 10 x +뒤숭숭 10 x +뒤지 10 x +뒤집 10 x +뒤집 10 x +뒤집혀져 10 x +뒤쪽 10 x +뒤쪽 10 x +뒤쫓 10 x +뒤쫓 10 x +뒤처지 10 x +뒤편 10 x +뒷 10 x +뒷 10 x +뒷거래 10 x +뒷걸음질 10 x +뒷골목 10 x +뒷골목 10 x +뒷날 10 x +뒷다리 10 x +뒷말 10 x +뒷면 10 x +뒷문 10 x +뒷받침 10 x +뒷받침 10 x +뒷부분 10 x +뒷부분 10 x +뒷전 10 x +듀 10 x +듀 10 x +듀오 10 x +듀오 10 x +듕 10 x +듕 10 x +드 10 x +드나들 10 x +드디어 10 x +드디어 10 x +드라 10 x +드라마 10 x +드라마틱 10 x +드라이 10 x +드라이버 10 x +드라이브 10 x +드러나 10 x +드러난 10 x +드러난다 10 x +드러납니다 10 x +드러났 10 x +드러났 10 x +드러내 10 x +드러낸 10 x +드러냈 10 x +드러냈 10 x +드러머 10 x +드레스 10 x +드렌 10 x +드려 10 x +드려도 10 x +드려라 10 x +드려요 10 x +드렸 10 x +드렸 10 x +드론 10 x +드리 10 x +드린 10 x +드릴 10 x +드릴게요 10 x +드릴까요 10 x +드림 10 x +드립니다 10 x +드문 10 x +드물 10 x +드세요 10 x +드셔 10 x +드셨 10 x +드셨 10 x +드시 10 x +드실 10 x +드실래요 10 x +드십 10 x +드십 10 x +득 10 x +득 10 x +득세 10 x +득실 10 x +득표 10 x +득표율 10 x +득표율 10 x +든 10 x +든다 10 x +든다고 10 x +든다며 10 x +든든 10 x +든지 10 x +듣 10 x +듣 10 x +들 10 x +들 10 x +들꽃 10 x +들끓 10 x +들떠 10 x +들러 10 x +들러리 10 x +들러붙 10 x +들렀 10 x +들렀 10 x +들려 10 x +들려오 10 x +들려올 10 x +들려왔 10 x +들려왔 10 x +들려요 10 x +들려주 10 x +들렸 10 x +들르 10 x +들를 10 x +들리 10 x +들린다 10 x +들릴 10 x +들어 10 x +들어가 10 x +들어간 10 x +들어간다 10 x +들어갈 10 x +들어갔 10 x +들어갔 10 x +들어도 10 x +들어라 10 x +들어서 10 x +들어선 10 x +들어설 10 x +들어섰 10 x +들어앉 10 x +들어야 10 x +들어오 10 x +들어온 10 x +들어온다 10 x +들어올 10 x +들어와 10 x +들어와라 10 x +들어와서 10 x +들어와요 10 x +들어왔 10 x +들어왔 10 x +들어줄 10 x +들여 10 x +들여놓 10 x +들여놓 10 x +들여다 10 x +들여다보 10 x +들여다볼 10 x +들여다봐도 10 x +들여도 10 x +들여왔 10 x +들여왔 10 x +들였 10 x +들였 10 x +들으니 10 x +들으며 10 x +들으면 10 x +들으면서 10 x +들을수록 10 x +들이 10 x +들이대 10 x +들이받 10 x +들이받 10 x +들이켜 10 x +들키 10 x +들판 10 x +듬 10 x +듭니다 10 x +듯 10 x +듯 10 x +듯이 10 x +등 10 x +등골 10 x +등급 10 x +등급 10 x +등기 10 x +등등 10 x +등딱지 10 x +등락 10 x +등록 10 x +등록 10 x +등록금 10 x +등록제 10 x +등록증 10 x +등받이 10 x +등받이 10 x +등뼈 10 x +등산 10 x +등산객 10 x +등산객 10 x +등산복 10 x +등식 10 x +등유 10 x +등장 10 x +등재 10 x +등지 10 x +듸 10 x +디 10 x +디 10 x +디딘 10 x +디딘 10 x +디딜 10 x +디딜 10 x +디램 10 x +디스크 10 x +디스크 10 x +디스패치 10 x +디아 10 x +디야 10 x +디에이 10 x +디엠 10 x +디자이너 10 x +디자이너 10 x +디자인 10 x +디자인 10 x +디저트 10 x +디제이 10 x +디제이 10 x +디즈니 10 x +디지털 10 x +디지털 10 x +디테일 10 x +디플레 10 x +디플레 10 x +디플레이션 10 x +디플레이션 10 x +딘 10 x +딛 10 x +딛 10 x +딜러 10 x +딜레마 10 x +딜리버리 10 x +딜리버리 10 x +딥 10 x +딥 10 x +딥 10 x +딩 10 x +따 10 x +따가워 10 x +따님 10 x +따따따따 10 x +따뜻 10 x +따뜻 10 x +따라 10 x +따라가 10 x +따라갈 10 x +따라갈게 10 x +따라다니 10 x +따라서 10 x +따라선 10 x +따라야 10 x +따라오 10 x +따라잡 10 x +따랐 10 x +따러 10 x +따려 10 x +따로 10 x +따르 10 x +따른 10 x +따른다 10 x +따를 10 x +따름 10 x +따분 10 x +따숩 10 x +따스 10 x +따온 10 x +따위 10 x +따져 10 x +따졌 10 x +따졌 10 x +따조 10 x +따지 10 x +따진 10 x +딱 10 x +딱 10 x +딱딱 10 x +딱딱 10 x +딱지 10 x +딱지 10 x +딱히 10 x +딴 10 x +딴지 10 x +딴판 10 x +딸 10 x +딸기 10 x +딸아이 10 x +땀 10 x +땄 10 x +땄 10 x +땄었 10 x +땄었 10 x +땅 10 x +땅값 10 x +땅값 10 x +땅콩 10 x +때 10 x +때늦 10 x +때때로 10 x +때려 10 x +때려잡 10 x +때렸 10 x +때렸 10 x +때로 10 x +때론 10 x +때리 10 x +때린 10 x +때마침 10 x +때문 10 x +때우 10 x +땐 10 x +땡 10 x +땡처리 10 x +땡큐 10 x +떠 10 x +떠나 10 x +떠나가 10 x +떠나보내 10 x +떠난 10 x +떠난다 10 x +떠난다는 10 x +떠날 10 x +떠났 10 x +떠났 10 x +떠도 10 x +떠돌 10 x +떠든다고 10 x +떠들 10 x +떠들썩 10 x +떠맡 10 x +떠밀리 10 x +떠보 10 x +떠서 10 x +떠안 10 x +떠오르 10 x +떠오른 10 x +떠오른다 10 x +떠올라요 10 x +떠올랐 10 x +떠올랐 10 x +떠올리 10 x +떠올린다 10 x +떡 10 x +떡 10 x +떡국 10 x +떡볶이 10 x +떨 10 x +떨렸 10 x +떨리 10 x +떨릴 10 x +떨림 10 x +떨어뜨렸 10 x +떨어뜨렸 10 x +떨어뜨리 10 x +떨어뜨린 10 x +떨어져 10 x +떨어져서 10 x +떨어져요 10 x +떨어졌 10 x +떨어졌 10 x +떨어지 10 x +떨어진 10 x +떨어진다 10 x +떨어진다고 10 x +떨어질 10 x +떳떳 10 x +떴 10 x +떴 10 x +떼 10 x +뗄 10 x +또 10 x +또는 10 x +또래 10 x +또한 10 x +똑같 10 x +똑같 10 x +똑같이 10 x +똑똑 10 x +똑바로 10 x +똥 10 x +똥개 10 x +뚜껑 10 x +뚜렷 10 x +뚜렷이 10 x +뚝 10 x +뚝 10 x +뚝섬 10 x +뚫 10 x +뚫렸 10 x +뚫렸 10 x +뚱뚱 10 x +뛰 10 x +뛰놀 10 x +뛰어가 10 x +뛰어나 10 x +뛰어난 10 x +뛰어났 10 x +뛰어났 10 x +뛰어내려 10 x +뛰어넘 10 x +뛰어놀 10 x +뛰어다니 10 x +뛰어드 10 x +뛰어든 10 x +뛰어들 10 x +뛰어오른 10 x +뛰어왔 10 x +뛰쳐 10 x +뛰쳐나가 10 x +뛰쳐나온 10 x +뛴다 10 x +뛸 10 x +뜨 10 x +뜨거우 10 x +뜨거운 10 x +뜨거움 10 x +뜨거워졌 10 x +뜨거워졌 10 x +뜨거웠 10 x +뜨거웠 10 x +뜨겁 10 x +뜨겁 10 x +뜬 10 x +뜬다 10 x +뜯 10 x +뜯 10 x +뜰 10 x +뜸 10 x +뜻 10 x +뜻 10 x +뜻대로 10 x +뜻밖 10 x +뜻밖 10 x +뜻밖에 10 x +뜻밖에 10 x +뜻한다 10 x +띄 10 x +띄우 10 x +띄워 10 x +띄웠 10 x +띈다 10 x +띠 10 x +띤 10 x +띨 10 x +띵 10 x +라 10 x +라고 10 x +라고요 10 x +라구 10 x +라구람 10 x +라구요 10 x +라그랑 10 x +라기 10 x +라네 10 x +라는 10 x +라니 10 x +라니까 10 x +라던가 10 x +라던지 10 x +라도 10 x +라든가 10 x +라든지 10 x +라디오 10 x +라린 10 x +라마 10 x +라멘 10 x +라며 10 x +라면 10 x +라면서 10 x +라모스 10 x +라바 10 x +라벨 10 x +라빈 10 x +라산 10 x +라서 10 x +라스 10 x +라스코 10 x +라시 10 x +라야 10 x +라오스 10 x +라운드 10 x +라울 10 x +라유 10 x +라이 10 x +라이드 10 x +라이몬디 10 x +라이브 10 x +라이징 10 x +라이터 10 x +라이트 10 x +라이프 10 x +라이히 10 x +라인 10 x +라잔 10 x +라즈 10 x +라지 10 x +라티 10 x +라틴 10 x +라틴 10 x +라파엘 10 x +락 10 x +락 10 x +락스타 10 x +란 10 x +란다 10 x +랄 10 x +랄까 10 x +람 10 x +랍니다 10 x +랏 10 x +랑 10 x +래 10 x +래리 10 x +래야 10 x +래요 10 x +랜덤 10 x +랜드 10 x +랜섬 10 x +램프 10 x +랩 10 x +랩 10 x +랩퍼 10 x +랭 10 x +랴 10 x +량 10 x +러 10 x +러니 10 x +러닝메이트 10 x +러버 10 x +러브 10 x +러쉬 10 x +러스 10 x +러시 10 x +러시아 10 x +러시아군 10 x +러시아인 10 x +러요 10 x +럭셔리 10 x +럭키 10 x +런닝맨 10 x +런던 10 x +런던데리 10 x +런드 10 x +럼 10 x +럽 10 x +럽 10 x +렀 10 x +렀다 10 x +렁 10 x +레 10 x +레고 10 x +레드 10 x +레몬 10 x +레바논 10 x +레버 10 x +레베 10 x +레벨 10 x +레스토랑 10 x +레슨 10 x +레슬리 10 x +레시페 10 x +레알 10 x +레어 10 x +레오나르도 10 x +레옹 10 x +레이 10 x +레이건 10 x +레이더 10 x +레이스 10 x +레이시온 10 x +레이저 10 x +레이첼 10 x +레이트 10 x +레인 10 x +레일 10 x +레임덕 10 x +레임덕 10 x +레저 10 x +레전드 10 x +레제프 10 x +레즈 10 x +레즐리 10 x +레츠 10 x +레코드 10 x +레킷벤키저 10 x +레티 10 x +레티 10 x +레포트 10 x +레핑 10 x +렉 10 x +렉 10 x +렌 10 x +렌즈 10 x +렌치 10 x +렌탈 10 x +렌터카 10 x +렘 10 x +렛 10 x +렛 10 x +려 10 x +려고 10 x +려고요 10 x +려구요 10 x +려나 10 x +려는 10 x +려는데 10 x +려니 10 x +려다 10 x +려다가 10 x +려던 10 x +려면 10 x +력 10 x +력 10 x +력사 10 x +련 10 x +렴 10 x +령 10 x +로 10 x +로고 10 x +로그아웃 10 x +로널드 10 x +로다 10 x +로드 10 x +로또 10 x +로마 10 x +로마인 10 x +로미오 10 x +로바트 10 x +로버트 10 x +로봇 10 x +로부터 10 x +로브 10 x +로비 10 x +로빈 10 x +로샤 10 x +로서 10 x +로선 10 x +로스앤젤레스 10 x +로스쿨 10 x +로써 10 x +로얄 10 x +로열 10 x +로열티 10 x +로우 10 x +로운 10 x +로움 10 x +로워 10 x +로웠 10 x +로웠 10 x +로이 10 x +로이드 10 x +로이터 10 x +로즈 10 x +로즈니 10 x +로컬 10 x +로켓 10 x +로켓 10 x +로프 10 x +록 10 x +록 10 x +록웰 10 x +록히드 10 x +론 10 x +롤 10 x +롬 10 x +롭 10 x +롭 10 x +롯 10 x +롯 10 x +롯데 10 x +롯데마트 10 x +롯데복지재단 10 x +롯데월드 10 x +롱 10 x +롸 10 x +료 10 x +료의 10 x +룡 10 x +루 10 x +루마니아 10 x +루머 10 x +루블 10 x +루슬란 10 x +루시 10 x +루웨 10 x +루이 10 x +루저 10 x +루치아노 10 x +루키 10 x +루트 10 x +루피 10 x +루피아 10 x +룩 10 x +룬 10 x +룰 10 x +룰라 10 x +룸 10 x +룸살롱 10 x +류 10 x +류머티즘 10 x +류머티즘 10 x +류타로 10 x +류현진 10 x +륙 10 x +륜 10 x +률 10 x +르 10 x +르망 10 x +르완다 10 x +르포 10 x +륵 10 x +를 10 x +릅니다 10 x +릉 10 x +리 10 x +리가 10 x +리그 10 x +리니 10 x +리더 10 x +리더십 10 x +리덩후이 10 x +리듬 10 x +리라 10 x +리라고 10 x +리라는 10 x +리먼 10 x +리모 10 x +리모컨 10 x +리베이트 10 x +리벤지 10 x +리본 10 x +리뷰 10 x +리비아 10 x +리서치 10 x +리스 10 x +리스크 10 x +리스트 10 x +리신 10 x +리알 10 x +리언 10 x +리얼 10 x +리얼미터 10 x +리오넬 10 x +리조트 10 x +리즈 10 x +리처드 10 x +리카 10 x +리콜 10 x +리쿠 10 x +리키 10 x +리터 10 x +리턴 10 x +리튬 10 x +리튬 10 x +리티 10 x +리포터 10 x +리포트 10 x +리프 10 x +리허설 10 x +린 10 x +린다 10 x +린이 10 x +린지 10 x +릴 10 x +릴레이 10 x +림 10 x +림프 10 x +림프계 10 x +립니 10 x +릿 10 x +링 10 x +링겐 10 x +링기트 10 x +링크 10 x +마 10 x +마가 10 x +마감 10 x +마개 10 x +마구 10 x +마구잡이 10 x +마그네 10 x +마그마 10 x +마냥 10 x +마네킹 10 x +마녀 10 x +마누라 10 x +마늘 10 x +마니 10 x +마닐라 10 x +마다 10 x +마당 10 x +마드리드 10 x +마디 10 x +마디 10 x +마땅 10 x +마땅히 10 x +마라톤 10 x +마력 10 x +마력 10 x +마련 10 x +마로니에 10 x +마루 10 x +마르 10 x +마르샤 10 x +마르코스 10 x +마르크 10 x +마르틴 10 x +마르틴 10 x +마른 10 x +마리 10 x +마리아 10 x +마리안 10 x +마리오 10 x +마리코 10 x +마릿수 10 x +마모 10 x +마무리 10 x +마무리지 10 x +마미 10 x +마보이 10 x +마블 10 x +마비 10 x +마사유키 10 x +마사지 10 x +마사회 10 x +마산 10 x +마설 10 x +마세요 10 x +마셔 10 x +마셔라 10 x +마셔요 10 x +마셨 10 x +마셨 10 x +마술 10 x +마스 10 x +마스크 10 x +마스터 10 x +마스터키 10 x +마스터플랜 10 x +마시 10 x +마시마로 10 x +마신 10 x +마실 10 x +마실래 10 x +마십니다 10 x +마십시오 10 x +마씨 10 x +마약 10 x +마약 10 x +마약상 10 x +마요네즈 10 x +마우리 10 x +마우스 10 x +마을 10 x +마을버스 10 x +마음 10 x +마음가짐 10 x +마음껏 10 x +마음껏 10 x +마음대로 10 x +마음먹 10 x +마음속 10 x +마음속 10 x +마음씨 10 x +마이 10 x +마이너 10 x +마이너스 10 x +마이스터 10 x +마이크 10 x +마이클 10 x +마이클잭슨 10 x +마인 10 x +마인드 10 x +마일 10 x +마저 10 x +마주 10 x +마주쳤 10 x +마주치 10 x +마중 10 x +마지막 10 x +마지막 10 x +마진 10 x +마차 10 x +마찬가지 10 x +마찰 10 x +마쳤 10 x +마쳤 10 x +마추 10 x +마취 10 x +마치 10 x +마친 10 x +마침 10 x +마침내 10 x +마침표 10 x +마카 10 x +마카오 10 x +마케팅 10 x +마케팅 10 x +마켓 10 x +마켓 10 x +마코토 10 x +마크 10 x +마크맨 10 x +마테오 10 x +마트 10 x +마틴 10 x +마틴 10 x +마포구 10 x +마하 10 x +마흔 10 x +막 10 x +막 10 x +막강 10 x +막걸리 10 x +막국수 10 x +막내 10 x +막내딸 10 x +막다른 10 x +막대 10 x +막둥이 10 x +막론 10 x +막막 10 x +막말 10 x +막무가내 10 x +막바지 10 x +막바지 10 x +막상 10 x +막스 10 x +막아내 10 x +막아주 10 x +막연 10 x +막장 10 x +막장 10 x +막중 10 x +막판 10 x +막혀서 10 x +막혀요 10 x +막혔 10 x +막히 10 x +막힌 10 x +만 10 x +만기 10 x +만끽 10 x +만나 10 x +만난 10 x +만난다 10 x +만날 10 x +만남 10 x +만납니다 10 x +만났 10 x +만났 10 x +만드 10 x +만드세요 10 x +만든 10 x +만든다 10 x +만들 10 x +만료 10 x +만만 10 x +만만찮 10 x +만무 10 x +만사 10 x +만성 10 x +만세 10 x +만수대 10 x +만약 10 x +만약 10 x +만연 10 x +만이천 10 x +만인 10 x +만일 10 x +만장굴 10 x +만장일치 10 x +만전 10 x +만점 10 x +만져 10 x +만졌 10 x +만졌 10 x +만족 10 x +만족 10 x +만족감 10 x +만지 10 x +만찬 10 x +만큼 10 x +만한 10 x +만한 10 x +만행 10 x +만행 10 x +만화 10 x +만화책 10 x +만회 10 x +많 10 x +많이 10 x +맏형 10 x +맏형 10 x +말 10 x +말기 10 x +말끔 10 x +말끝 10 x +말다툼 10 x +말대꾸 10 x +말똥말똥 10 x +말라 10 x +말라리아 10 x +말라서 10 x +말랐 10 x +말랐 10 x +말레이시아 10 x +말려 10 x +말렸 10 x +말렸 10 x +말리 10 x +말린 10 x +말문 10 x +말미암 10 x +말벌 10 x +말복 10 x +말살 10 x +말썽 10 x +말쑥 10 x +말쑥 10 x +말씀 10 x +말없이 10 x +말장난 10 x +말투 10 x +맑 10 x +맑 10 x +맘 10 x +맘루크 10 x +맛 10 x +맛 10 x +맛난 10 x +맛보 10 x +맛보 10 x +맛없 10 x +맛있 10 x +맛있 10 x +맛집 10 x +망 10 x +망가진 10 x +망각 10 x +망라 10 x +망명 10 x +망설이 10 x +망언 10 x +망원경 10 x +망원동 10 x +망의 10 x +망쳐 10 x +망쳤 10 x +망치 10 x +망하 10 x +망할 10 x +망해 10 x +망했 10 x +망했 10 x +맞 10 x +맞 10 x +맞닿 10 x +맞닿 10 x +맞대 10 x +맞물려 10 x +맞물리 10 x +맞물린 10 x +맞벌이 10 x +맞벌이 10 x +맞붙 10 x +맞붙 10 x +맞붙 10 x +맞서 10 x +맞섰 10 x +맞아 10 x +맞은편 10 x +맞이 10 x +맞추 10 x +맞춘 10 x +맞출 10 x +맞춤 10 x +맞춰 10 x +맞춰서 10 x +맞춰야 10 x +맞춰요 10 x +맞춰져 10 x +맞혀 10 x +맞혔 10 x +맞히 10 x +맡 10 x +맡 10 x +맡겨 10 x +맡겨야 10 x +맡겼 10 x +맡기 10 x +맡기 10 x +맡긴 10 x +맡긴 10 x +맡길 10 x +맡길 10 x +매 10 x +매각 10 x +매각 10 x +매개 10 x +매거진 10 x +매너 10 x +매년 10 x +매뉴얼 10 x +매니지먼트 10 x +매단 10 x +매달 10 x +매달려 10 x +매달렸 10 x +매달렸 10 x +매달리 10 x +매도 10 x +매들린 10 x +매듭 10 x +매듭 10 x +매듭지 10 x +매력 10 x +매력 10 x +매료 10 x +매립 10 x +매립 10 x +매립장 10 x +매립장 10 x +매립지 10 x +매매 10 x +매몰 10 x +매번 10 x +매사 10 x +매사추세츠 10 x +매사추세츠주 10 x +매서운 10 x +매설 10 x +매수 10 x +매슈 10 x +매스컴 10 x +매연 10 x +매우 10 x +매운 10 x +매워 10 x +매일 10 x +매입 10 x +매입 10 x +매장 10 x +매제 10 x +매주 10 x +매직 10 x +매진 10 x +매체 10 x +매춘 10 x +매출 10 x +매출액 10 x +매출액 10 x +매치 10 x +매트리스 10 x +맥 10 x +맥 10 x +맥거핀 10 x +맥도날드 10 x +맥도널 10 x +맥락 10 x +맥밀런 10 x +맥박 10 x +맥북 10 x +맥북 10 x +맥스 10 x +맥없이 10 x +맥주 10 x +맥주 10 x +맨 10 x +맨날 10 x +맨들 10 x +맨해튼 10 x +맨해튼 10 x +맴돌 10 x +맵 10 x +맹공 10 x +맹렬히 10 x +맹목 10 x +맹세 10 x +맺 10 x +맺 10 x +맻 10 x +맻 10 x +머 10 x +머구리 10 x +머니 10 x +머리 10 x +머리카락 10 x +머릿속 10 x +머무 10 x +머무르 10 x +머무를 10 x +머문 10 x +머물 10 x +머물러 10 x +머물렀 10 x +머뭇거리 10 x +머뭇거리 10 x +머스캣 10 x +머스캣 10 x +머스크 10 x +머신 10 x +머지않 10 x +먹 10 x +먹 10 x +먹거리 10 x +먹구름 10 x +먹방 10 x +먹방 10 x +먹성 10 x +먹여 10 x +먹음직 10 x +먹이 10 x +먹일 10 x +먹잇감 10 x +먹잇감 10 x +먹지 10 x +먹혀 10 x +먹히 10 x +먼 10 x +먼바다 10 x +먼저 10 x +먼지 10 x +멀 10 x +멀다하고 10 x +멀리 10 x +멀리서 10 x +멀쩡 10 x +멀티 10 x +멀티 10 x +멀티숍 10 x +멀티숍 10 x +멈추 10 x +멈춘 10 x +멈출 10 x +멈춰 10 x +멈춰서 10 x +멈췄 10 x +멋 10 x +멋 10 x +멋있 10 x +멋있 10 x +멋져요 10 x +멋졌 10 x +멋졌 10 x +멋지 10 x +멋진 10 x +멍 10 x +멍석 10 x +멍청 10 x +멍하니 10 x +멎 10 x +멎 10 x +메 10 x +메가 10 x +메넴 10 x +메뉴 10 x +메뉴판 10 x +메달 10 x +메드 10 x +메디 10 x +메디 10 x +메디나 10 x +메디나 10 x +메디컬 10 x +메르스 10 x +메리 10 x +메릴린치 10 x +메마른 10 x +메모 10 x +메밀 10 x +메세지 10 x +메스 10 x +메시 10 x +메시지 10 x +메신저 10 x +메이 10 x +메이드 10 x +메이저 10 x +메이지 10 x +메이커 10 x +메인 10 x +메일 10 x +메카 10 x +메커니즘 10 x +메탄 10 x +메트로 10 x +멕시코 10 x +멕시코인 10 x +멘 10 x +멘션 10 x +멘탈 10 x +멘토 10 x +멜 10 x +멜러니 10 x +멜로디 10 x +멜론 10 x +멤버 10 x +멧돼지 10 x +며 10 x +며 10 x +며느리 10 x +며느리 10 x +며칠 10 x +며칠 10 x +면 10 x +면 10 x +면담 10 x +면담 10 x +면도 10 x +면도 10 x +면모 10 x +면밀 10 x +면밀 10 x +면발 10 x +면서 10 x +면서 10 x +면서도 10 x +면세 10 x +면세 10 x +면세점 10 x +면세점 10 x +면역 10 x +면역 10 x +면역 10 x +면역력 10 x +면역력 10 x +면역력 10 x +면역력 10 x +면역성 10 x +면역성 10 x +면옥 10 x +면옥 10 x +면옥 10 x +면은 10 x +면장 10 x +면적 10 x +면적 10 x +면적 10 x +면접 10 x +면접 10 x +면접 10 x +면접 10 x +면제 10 x +면제 10 x +면직 10 x +면직 10 x +면책 10 x +면책 10 x +면책 10 x +면치 10 x +면할 10 x +면할 10 x +면할 10 x +면했 10 x +면했 10 x +면했 10 x +면허 10 x +면허 10 x +면허 10 x +면회 10 x +면회 10 x +멸 10 x +멸망 10 x +멸망 10 x +멸시 10 x +멸시 10 x +멸실 10 x +멸실 10 x +멸종 10 x +멸종 10 x +멸치 10 x +멸치 10 x +명 10 x +명 10 x +명기 10 x +명기 10 x +명단 10 x +명단 10 x +명동 10 x +명동 10 x +명동성당 10 x +명동성당 10 x +명령 10 x +명령 10 x +명망가 10 x +명맥 10 x +명맥 10 x +명명백백 10 x +명명백백 10 x +명명백백 10 x +명명백백 10 x +명목 10 x +명목 10 x +명목 10 x +명문 10 x +명문 10 x +명문가 10 x +명문화 10 x +명물 10 x +명백 10 x +명백히 10 x +명보 10 x +명부 10 x +명분 10 x +명사 10 x +명사 10 x +명성 10 x +명세서 10 x +명세서 10 x +명소 10 x +명시 10 x +명실상부 10 x +명실상부 10 x +명언 10 x +명예 10 x +명예 10 x +명예훼손 10 x +명예훼손 10 x +명운 10 x +명의 10 x +명의 10 x +명의자 10 x +명인 10 x +명작 10 x +명작 10 x +명작 10 x +명작 10 x +명절 10 x +명절 10 x +명철 10 x +명철 10 x +명칭 10 x +명칭 10 x +명품 10 x +명품 10 x +명함 10 x +명함 10 x +명확 10 x +명확 10 x +명확 10 x +명확히 10 x +명확히 10 x +명회 10 x +몇 10 x +몇 10 x +몇 10 x +몇 10 x +몇몇 10 x +몇몇 10 x +몇몇 10 x +모 10 x +모가지 10 x +모건 10 x +모국 10 x +모국 10 x +모금 10 x +모기 10 x +모기장 10 x +모나 10 x +모나코 10 x +모녀 10 x +모니터 10 x +모닝 10 x +모델 10 x +모독 10 x +모두 10 x +모드 10 x +모든 10 x +모래 10 x +모래시계 10 x +모래알 10 x +모랫바닥 10 x +모랫바닥 10 x +모랫바닥 10 x +모략 10 x +모레 10 x +모로 10 x +모로코 10 x +모르 10 x +모르쇠 10 x +모른 10 x +모른다 10 x +모른다고 10 x +모른다는 10 x +모를 10 x +모를까 10 x +모릅니다 10 x +모리토모 10 x +모바일 10 x +모발 10 x +모방 10 x +모범 10 x +모색 10 x +모색 10 x +모선 10 x +모세 10 x +모셔다 10 x +모셔야 10 x +모순 10 x +모스 10 x +모스크 10 x +모스크바 10 x +모습 10 x +모습 10 x +모시 10 x +모씨 10 x +모아 10 x +모아서 10 x +모아야 10 x +모아졌 10 x +모아졌 10 x +모았 10 x +모았 10 x +모양 10 x +모양새 10 x +모여 10 x +모여든 10 x +모여들 10 x +모여서 10 x +모였 10 x +모였 10 x +모욕 10 x +모유 10 x +모으 10 x +모은 10 x +모은다 10 x +모을 10 x +모음 10 x +모이 10 x +모인 10 x +모임 10 x +모자 10 x +모자 10 x +모자라 10 x +모자란 10 x +모자랄 10 x +모조리 10 x +모종 10 x +모직물 10 x +모진 10 x +모질 10 x +모집 10 x +모집 10 x +모처럼 10 x +모체 10 x +모터보트 10 x +모텔 10 x +모토 10 x +모퉁이 10 x +모포 10 x +모하메드 10 x +모험 10 x +모형 10 x +모호 10 x +목 10 x +목 10 x +목감 10 x +목걸이 10 x +목격 10 x +목격 10 x +목격자 10 x +목과 10 x +목구멍 10 x +목도리 10 x +목동 10 x +목록 10 x +목발 10 x +목사 10 x +목소리 10 x +목수 10 x +목숨 10 x +목요일 10 x +목욕 10 x +목욕 10 x +목욕탕 10 x +목재 10 x +목적 10 x +목적 10 x +목적 10 x +목적지 10 x +목질 10 x +목차 10 x +목포 10 x +목표 10 x +목표 10 x +목화 10 x +목회 10 x +몫 10 x +몬 10 x +몰 10 x +몰고가 10 x +몰다비아 10 x +몰도바 10 x +몰두 10 x +몰디브 10 x +몰디브 10 x +몰라 10 x +몰라도 10 x +몰라서 10 x +몰라요 10 x +몰락 10 x +몰랐 10 x +몰랐 10 x +몰래 10 x +몰려 10 x +몰려든 10 x +몰려들 10 x +몰리 10 x +몰린 10 x +몰수금 10 x +몰아가 10 x +몰아내 10 x +몰아넣 10 x +몰아넣 10 x +몰아주 10 x +몰아친다 10 x +몰입 10 x +몸 10 x +몸가짐 10 x +몸값 10 x +몸담 10 x +몸매 10 x +몸무게 10 x +몸살 10 x +몸집 10 x +몸짓 10 x +몹시 10 x +못 10 x +못 10 x +못 10 x +못 10 x +못내 10 x +못생겼 10 x +못지 10 x +못지않 10 x +못하 10 x +못한 10 x +못한다 10 x +못한다는 10 x +못할 10 x +못할지 10 x +못합니다 10 x +못해 10 x +못해요 10 x +못했 10 x +못했 10 x +못했으니 10 x +몽 10 x +몽골 10 x +몽골어 10 x +몽둥이 10 x +몽땅 10 x +몽탄 10 x +묘 10 x +묘 10 x +묘사 10 x +묘사 10 x +묘수 10 x +묘수 10 x +묘책 10 x +묘책 10 x +묘한 10 x +묘한 10 x +무 10 x +무 10 x +무감각 10 x +무개 10 x +무거우 10 x +무거운 10 x +무거워 10 x +무거워서 10 x +무겁 10 x +무게 10 x +무고 10 x +무관 10 x +무궁무진 10 x +무궁화 10 x +무궁화호 10 x +무기 10 x +무기 10 x +무기명 10 x +무기수 10 x +무너뜨리 10 x +무너져 10 x +무너졌 10 x +무너지 10 x +무너진 10 x +무능 10 x +무늬 10 x +무단 10 x +무당 10 x +무대 10 x +무더기 10 x +무더운 10 x +무더웠 10 x +무더위 10 x +무덤 10 x +무뎌진 10 x +무뎌진 10 x +무도회 10 x +무드 10 x +무뚝뚝 10 x +무라 10 x +무라야마 10 x +무려 10 x +무력 10 x +무력 10 x +무렵 10 x +무례 10 x +무료 10 x +무릅쓰 10 x +무릇 10 x +무릎 10 x +무릎 10 x +무리 10 x +무리뉴 10 x +무리니 10 x +무린 10 x +무마 10 x +무명 10 x +무모 10 x +무방비 10 x +무부 10 x +무분별 10 x +무비 10 x +무사 10 x +무사 10 x +무사시 10 x +무사히 10 x +무산 10 x +무상 10 x +무상급식 10 x +무상급식 10 x +무색 10 x +무서우 10 x +무서운 10 x +무서울 10 x +무서워 10 x +무서워서 10 x +무서워요 10 x +무서워하 10 x +무서웠 10 x +무선 10 x +무섭 10 x +무섭 10 x +무성 10 x +무소 10 x +무속 10 x +무솔리니 10 x +무수 10 x +무수히 10 x +무슨 10 x +무슬림 10 x +무승부 10 x +무시 10 x +무시무시 10 x +무식 10 x +무심 10 x +무안군 10 x +무어 10 x +무언가 10 x +무얼 10 x +무엇 10 x +무엇 10 x +무역 10 x +무역 10 x +무역업 10 x +무용 10 x +무용 10 x +무용론 10 x +무원칙 10 x +무의미 10 x +무인 10 x +무인기 10 x +무일 10 x +무자 10 x +무장 10 x +무제 10 x +무조건 10 x +무죄 10 x +무지 10 x +무지개 10 x +무지무지 10 x +무참히 10 x +무척 10 x +무척 10 x +무크지 10 x +무한 10 x +무한도전 10 x +무한정 10 x +무혐 10 x +무효 10 x +묵 10 x +묵살 10 x +묵인 10 x +묵주 10 x +묵주 10 x +묶 10 x +묶 10 x +묶여 10 x +묶음 10 x +문 10 x +문건 10 x +문고 10 x +문고리 10 x +문과 10 x +문구 10 x +문단 10 x +문득 10 x +문득 10 x +문란 10 x +문명 10 x +문민 10 x +문민정부 10 x +문밖 10 x +문법 10 x +문부 10 x +문사 10 x +문서 10 x +문신 10 x +문어 10 x +문어발 10 x +문예 10 x +문의 10 x +문인 10 x +문자 10 x +문장 10 x +문재 10 x +문재인 10 x +문정수 10 x +문제 10 x +문제삼 10 x +문제점 10 x +문제지 10 x +문죄 10 x +문진 10 x +문체 10 x +문학 10 x +문학 10 x +문학 10 x +문학 10 x +문학사 10 x +문혁 10 x +문혁 10 x +문호준 10 x +문화 10 x +문화방송 10 x +문화원 10 x +문화유산 10 x +문화일보 10 x +문화재 10 x +문화청 10 x +묻 10 x +묻 10 x +묻어나 10 x +묻혀 10 x +묻혀 10 x +묻혔 10 x +묻혔 10 x +묻히 10 x +묻히 10 x +물 10 x +물가 10 x +물갈이 10 x +물건 10 x +물결 10 x +물고기 10 x +물꼬 10 x +물끄러미 10 x +물난리 10 x +물놀이 10 x +물동량 10 x +물든 10 x +물들 10 x +물떼새 10 x +물량 10 x +물러나 10 x +물러난 10 x +물러서 10 x +물러섰 10 x +물레 10 x +물려 10 x +물려받 10 x +물려줘야 10 x +물렸 10 x +물론 10 x +물류 10 x +물류비용 10 x +물리 10 x +물리학 10 x +물린 10 x +물릴 10 x +물법 10 x +물법 10 x +물산 10 x +물색 10 x +물소 10 x +물속 10 x +물어봐야 10 x +물어야 10 x +물위 10 x +물으니까 10 x +물으면 10 x +물음 10 x +물자 10 x +물증 10 x +물질 10 x +물체 10 x +물총 10 x +물통 10 x +물품 10 x +묽 10 x +뭉치 10 x +뭐 10 x +뭐니뭐니 10 x +뭔 10 x +뭔가 10 x +뭔가요 10 x +뭔데 10 x +뭔지 10 x +뭘 10 x +뭘까 10 x +뭘까요 10 x +뭘로 10 x +뭡니까 10 x +뮤 10 x +뮤 10 x +뮤지컬 10 x +뮤직 10 x +뮤직 10 x +므로 10 x +믄 10 x +믈 10 x +미 10 x +미 10 x +미공개 10 x +미관 10 x +미국 10 x +미국 10 x +미국 10 x +미국 10 x +미국인 10 x +미국인 10 x +미군 10 x +미군 10 x +미궁 10 x +미궁 10 x +미끄러져 10 x +미끄러져 10 x +미끄러져서 10 x +미끄러져서 10 x +미끄러지 10 x +미끄러지 10 x +미끄럽 10 x +미끄럽 10 x +미끼 10 x +미네르바 10 x +미네르바 10 x +미네소타 10 x +미녀 10 x +미니 10 x +미니 10 x +미니밴 10 x +미니어처 10 x +미니어처 10 x +미담 10 x +미담 10 x +미덕 10 x +미드 10 x +미드 10 x +미드나잇 10 x +미드나잇 10 x +미디어 10 x +미디어 10 x +미디어 10 x +미디어 10 x +미라 10 x +미래 10 x +미래 10 x +미래상 10 x +미러 10 x +미련 10 x +미련 10 x +미련없이 10 x +미루 10 x +미룬 10 x +미룰 10 x +미룰 10 x +미뤄 10 x +미뤄 10 x +미뤘 10 x +미뤘 10 x +미르 10 x +미륵 10 x +미륵 10 x +미륵 10 x +미리 10 x +미리 10 x +미립자 10 x +미립자 10 x +미만 10 x +미만 10 x +미명 10 x +미모 10 x +미묘 10 x +미묘 10 x +미미 10 x +미미 10 x +미복 10 x +미복 10 x +미복 10 x +미분양 10 x +미분양 10 x +미비 10 x +미비 10 x +미사일 10 x +미사일 10 x +미생 10 x +미생 10 x +미생물 10 x +미생물 10 x +미성년자 10 x +미성년자 10 x +미세 10 x +미세 10 x +미세먼지 10 x +미셀 10 x +미셀 10 x +미소 10 x +미소 10 x +미수 10 x +미수 10 x +미숙 10 x +미숙 10 x +미술 10 x +미술 10 x +미술관 10 x +미술관 10 x +미스 10 x +미스 10 x +미스터리 10 x +미스터리 10 x +미식가 10 x +미식가 10 x +미식축구 10 x +미식축구 10 x +미신 10 x +미신 10 x +미쓰 10 x +미쓰 10 x +미아 10 x +미아 10 x +미안 10 x +미안 10 x +미야기 10 x +미약 10 x +미약 10 x +미역국 10 x +미역국 10 x +미역국 10 x +미옥 10 x +미옥 10 x +미온 10 x +미온 10 x +미완 10 x +미완 10 x +미용 10 x +미용 10 x +미용실 10 x +미용실 10 x +미운 10 x +미운 10 x +미움 10 x +미움 10 x +미워 10 x +미워 10 x +미워하 10 x +미워하 10 x +미워할 10 x +미워할 10 x +미원 10 x +미인 10 x +미인 10 x +미일 10 x +미적분 10 x +미적분 10 x +미적분 10 x +미적분 10 x +미제 10 x +미제 10 x +미족 10 x +미족 10 x +미주 10 x +미지 10 x +미지 10 x +미지근 10 x +미지수 10 x +미지수 10 x +미처 10 x +미쳐 10 x +미쳤 10 x +미쳤 10 x +미쳤 10 x +미치 10 x +미치 10 x +미치오 10 x +미치오 10 x +미친 10 x +미친 10 x +미친년 10 x +미친년 10 x +미칠 10 x +미칠 10 x +미터 10 x +미터 10 x +미테랑 10 x +미테랑 10 x +미팅 10 x +미팅 10 x +미행 10 x +미행 10 x +미혼 10 x +미혼 10 x +미화 10 x +미화 10 x +미확인 10 x +미확인 10 x +미흡 10 x +미흡 10 x +믹 10 x +믹 10 x +믹 10 x +믹 10 x +민 10 x +민 10 x +민간 10 x +민간 10 x +민간단체 10 x +민간인 10 x +민간인 10 x +민감 10 x +민감 10 x +민관 10 x +민노총 10 x +민노총 10 x +민망 10 x +민망 10 x +민병대 10 x +민병대 10 x +민부 10 x +민사 10 x +민사 10 x +민생 10 x +민생 10 x +민선 10 x +민속 10 x +민수 10 x +민수 10 x +민심 10 x +민아 10 x +민아 10 x +민영 10 x +민영 10 x +민영규 10 x +민영규 10 x +민영화 10 x +민영화 10 x +민우 10 x +민우 10 x +민원 10 x +민원 10 x +민원인 10 x +민원인 10 x +민유 10 x +민음사 10 x +민음사 10 x +민자 10 x +민자 10 x +민자당 10 x +민자당 10 x +민정 10 x +민정 10 x +민족 10 x +민족 10 x +민족 10 x +민족 10 x +민족사 10 x +민족주의 10 x +민족주의 10 x +민족주의자 10 x +민족진영 10 x +민주 10 x +민주 10 x +민주당 10 x +민주당 10 x +민주자유당 10 x +민주주의 10 x +민주주의 10 x +민중 10 x +민중 10 x +민통 10 x +민한 10 x +민한 10 x +민한 10 x +민항 10 x +민항 10 x +민현 10 x +민현 10 x +민현 10 x +민회 10 x +믾 10 x +믿 10 x +믿 10 x +믿 10 x +믿 10 x +믿기 10 x +믿기 10 x +믿기 10 x +믿음 10 x +믿음 10 x +밀 10 x +밀 10 x +밀 10 x +밀 10 x +밀가루 10 x +밀가루 10 x +밀도 10 x +밀란 10 x +밀러 10 x +밀레시 10 x +밀려 10 x +밀려 10 x +밀려났 10 x +밀려났 10 x +밀려났 10 x +밀려들 10 x +밀렸 10 x +밀렸 10 x +밀로셰비치 10 x +밀로셰비치 10 x +밀리 10 x +밀리 10 x +밀리미터 10 x +밀리미터 10 x +밀림 10 x +밀림 10 x +밀반입 10 x +밀반입 10 x +밀반출 10 x +밀수 10 x +밀수꾼 10 x +밀수품 10 x +밀실 10 x +밀실 10 x +밀양 10 x +밀양 10 x +밀어내 10 x +밀어내 10 x +밀어붙였 10 x +밀어붙였 10 x +밀어붙였 10 x +밀어붙였 10 x +밀어붙이 10 x +밀어붙이 10 x +밀어주 10 x +밀월 10 x +밀접 10 x +밀접 10 x +밀집 10 x +밀집 10 x +밀착 10 x +밀착 10 x +밀카 10 x +밀항 10 x +밀항 10 x +밈 10 x +밋 10 x +밋 10 x +밍 10 x +밍 10 x +및 10 x +및 10 x +및 10 x +및 10 x +밑 10 x +밑 10 x +밑 10 x +밑거름 10 x +밑그림 10 x +밑그림 10 x +밑바닥 10 x +밑바탕 10 x +밑줄 10 x +밑줄 10 x +밑창 10 x +밑창 10 x +밑천 10 x +바 10 x +바가지 10 x +바구니 10 x +바깥 10 x +바꾸 10 x +바꾼 10 x +바꿀 10 x +바꿔 10 x +바꿔서 10 x +바꿔야 10 x +바꿨 10 x +바꿨 10 x +바뀌 10 x +바뀐 10 x +바뀔 10 x +바나나 10 x +바누아투 10 x +바늘 10 x +바다 10 x +바닥 10 x +바닥 10 x +바닷가 10 x +바닷가 10 x +바닷물 10 x +바닷바람 10 x +바둑 10 x +바라 10 x +바라보 10 x +바라본다 10 x +바라티 10 x +바라티 10 x +바란 10 x +바란다 10 x +바란다고 10 x +바랄 10 x +바람 10 x +바람직 10 x +바람직 10 x +바랍니다 10 x +바랐 10 x +바램 10 x +바로 10 x +바로잡 10 x +바로잡 10 x +바르 10 x +바르가스 10 x +바른 10 x +바를 10 x +바리 10 x +바보 10 x +바비큐 10 x +바빌론 10 x +바빠 10 x +바빠서 10 x +바빠요 10 x +바빴 10 x +바빴 10 x +바쁘 10 x +바쁜 10 x +바쁠 10 x +바쁩니다 10 x +바삐 10 x +바싹 10 x +바오 10 x +바오로 10 x +바울 10 x +바위 10 x +바이 10 x +바이러스 10 x +바이에른 10 x +바이오 10 x +바이올린 10 x +바지 10 x +바지선 10 x +바짝 10 x +바짝 10 x +바쳐 10 x +바쳤 10 x +바치 10 x +바칼로레아 10 x +바퀴 10 x +바탄 10 x +바탕 10 x +바트 10 x +박 10 x +박 10 x +박경림 10 x +박경민 10 x +박계 10 x +박과 10 x +박광온 10 x +박근 10 x +박근혜 10 x +박근혜 10 x +박리다매 10 x +박멸 10 x +박명재 10 x +박모 10 x +박문약례 10 x +박물 10 x +박물관 10 x +박범계 10 x +박사 10 x +박사모 10 x +박상진 10 x +박상천 10 x +박성범 10 x +박수 10 x +박스 10 x +박영선 10 x +박영수 10 x +박완서 10 x +박우진 10 x +박원순 10 x +박원택 10 x +박원택 10 x +박유천 10 x +박인숙 10 x +박인숙 10 x +박인호 10 x +박일 10 x +박재완 10 x +박재윤 10 x +박재홍 10 x +박정희 10 x +박종규 10 x +박종철 10 x +박종훈 10 x +박준영 10 x +박지성 10 x +박지원 10 x +박차 10 x +박창근 10 x +박창진 10 x +박탈 10 x +박태환 10 x +박테리아 10 x +박해 10 x +박해상 10 x +박혜자 10 x +박히 10 x +박힌 10 x +박힐 10 x +밖 10 x +밖 10 x +밖에 10 x +밖엔 10 x +반 10 x +반가운 10 x +반가웠 10 x +반가웠 10 x +반가이 10 x +반감 10 x +반갑 10 x +반갑 10 x +반값 10 x +반값 10 x +반값 10 x +반걸음 10 x +반겨 10 x +반격 10 x +반군 10 x +반기 10 x +반기문 10 x +반나절 10 x +반납 10 x +반대 10 x +반대급부 10 x +반대말 10 x +반대파 10 x +반대편 10 x +반덤핑 10 x +반도 10 x +반도체 10 x +반드시 10 x +반란 10 x +반려 10 x +반론 10 x +반리 10 x +반말 10 x +반면 10 x +반목 10 x +반문 10 x +반미 10 x +반민주 10 x +반박 10 x +반박 10 x +반발 10 x +반복 10 x +반복 10 x +반사 10 x +반상 10 x +반상회 10 x +반석 10 x +반석 10 x +반성 10 x +반세기 10 x +반송 10 x +반수 10 x +반시 10 x +반역 10 x +반역 10 x +반영 10 x +반월 10 x +반응 10 x +반이민법 10 x +반이민법 10 x +반입 10 x +반입 10 x +반장 10 x +반전 10 x +반점 10 x +반정부 10 x +반죽 10 x +반증 10 x +반지 10 x +반짝 10 x +반짝거리 10 x +반짝반짝 10 x +반짝이 10 x +반쪽 10 x +반찬 10 x +반칙 10 x +반투 10 x +반품 10 x +반한 10 x +반한 10 x +반항 10 x +반항 10 x +반항심 10 x +반항아 10 x +반항아 10 x +반해 10 x +반해 10 x +반해서 10 x +반해서 10 x +반핵 10 x +반핵 10 x +반했 10 x +반했 10 x +반했 10 x +반향 10 x +반향 10 x +반환 10 x +반환점 10 x +받 10 x +받 10 x +받들 10 x +받아들여 10 x +받아들여져야 10 x +받아들여졌 10 x +받아들였 10 x +받아들이 10 x +받아들인 10 x +받아들인다 10 x +받아들인다고 10 x +받아들일 10 x +받아쳤 10 x +받아쳤 10 x +받침 10 x +발 10 x +발가락 10 x +발각 10 x +발간 10 x +발걸음 10 x +발견 10 x +발군 10 x +발굴 10 x +발급 10 x +발급 10 x +발기 10 x +발기부전 10 x +발길 10 x +발끈하 10 x +발끈하 10 x +발끝 10 x +발단 10 x +발달 10 x +발돋움 10 x +발동 10 x +발등 10 x +발라 10 x +발라라 10 x +발랄 10 x +발레 10 x +발령 10 x +발리 10 x +발매 10 x +발명 10 x +발명품 10 x +발목 10 x +발바닥 10 x +발발 10 x +발병률 10 x +발부 10 x +발비 10 x +발뺌 10 x +발사 10 x +발사대 10 x +발산 10 x +발상 10 x +발생 10 x +발생학 10 x +발송 10 x +발암 10 x +발언 10 x +발연기 10 x +발원 10 x +발음 10 x +발자국 10 x +발자취 10 x +발전 10 x +발전소 10 x +발제 10 x +발족 10 x +발주 10 x +발주자 10 x +발진 10 x +발짝 10 x +발짝 10 x +발찌 10 x +발칸 10 x +발탁 10 x +발탁 10 x +발톱 10 x +발표 10 x +발표회 10 x +발행 10 x +발행인 10 x +발현 10 x +발효 10 x +발휘 10 x +밝 10 x +밝 10 x +밝혀 10 x +밝혀내 10 x +밝혀냈 10 x +밝혀져 10 x +밝혀져야 10 x +밝혀졌 10 x +밝혀졌 10 x +밝혀질 10 x +밝혔 10 x +밝혔 10 x +밝혔으나 10 x +밝히 10 x +밝힌 10 x +밝힌다 10 x +밝힐 10 x +밝힘 10 x +밟 10 x +밟 10 x +밤 10 x +밤낮 10 x +밤낮 10 x +밤늦 10 x +밤마실 10 x +밤새 10 x +밤샘 10 x +밤중 10 x +밤하늘 10 x +밥 10 x +밥 10 x +밥그릇 10 x +밥그릇 10 x +밥맛 10 x +밥상 10 x +밥솥 10 x +밥집 10 x +밥집 10 x +밧줄 10 x +방 10 x +방공 10 x +방금 10 x +방대 10 x +방류 10 x +방류수 10 x +방만 10 x +방망 10 x +방면 10 x +방문 10 x +방문객 10 x +방문자 10 x +방미 10 x +방바닥 10 x +방범 10 x +방법 10 x +방법 10 x +방법론 10 x +방북 10 x +방사능 10 x +방사선량 10 x +방사성 10 x +방산 10 x +방상훈 10 x +방송 10 x +방송국 10 x +방송법 10 x +방송사 10 x +방승 10 x +방식 10 x +방식 10 x +방아 10 x +방아쇠 10 x +방안 10 x +방어 10 x +방역 10 x +방역 10 x +방영 10 x +방용석 10 x +방용석 10 x +방울 10 x +방위 10 x +방위군 10 x +방정식 10 x +방조 10 x +방조제 10 x +방증 10 x +방지 10 x +방직기 10 x +방천 10 x +방청객 10 x +방출 10 x +방치 10 x +방침 10 x +방탄 10 x +방탄소년단 10 x +방통 10 x +방파제 10 x +방편 10 x +방학 10 x +방학 10 x +방한 10 x +방해 10 x +방향 10 x +방화복 10 x +방황 10 x +밭 10 x +밭 10 x +배 10 x +배경 10 x +배고파 10 x +배고파서 10 x +배고픈데 10 x +배고플 10 x +배구 10 x +배급 10 x +배급 10 x +배기가스 10 x +배기량 10 x +배꼽 10 x +배꼽 10 x +배낭 10 x +배달 10 x +배당 10 x +배드 10 x +배드민턴 10 x +배딱지 10 x +배딱지 10 x +배럴 10 x +배려 10 x +배반 10 x +배병렬 10 x +배분 10 x +배불러 10 x +배불러서 10 x +배상 10 x +배상액 10 x +배설물 10 x +배송 10 x +배수량 10 x +배승희 10 x +배신 10 x +배신감 10 x +배신자 10 x +배심 10 x +배아 10 x +배양 10 x +배열 10 x +배영 10 x +배우 10 x +배우자 10 x +배운 10 x +배운다 10 x +배울 10 x +배워 10 x +배워도 10 x +배워라 10 x +배워야 10 x +배웠 10 x +배웠 10 x +배재 10 x +배정 10 x +배제 10 x +배진영 10 x +배추 10 x +배출 10 x +배치 10 x +배치기 10 x +배타 10 x +배탈 10 x +배터리 10 x +배트 10 x +배포 10 x +배후 10 x +백 10 x +백 10 x +백두산 10 x +백두산 10 x +백만 10 x +백모 10 x +백미 10 x +백산 10 x +백서 10 x +백성 10 x +백악관 10 x +백억 10 x +백억 10 x +백업 10 x +백인 10 x +백작 10 x +백작 10 x +백작 10 x +백제 10 x +백제 10 x +백종원 10 x +백종원 10 x +백주 10 x +백주 10 x +백지 10 x +백합 10 x +백합 10 x +백화점 10 x +밴드 10 x +밴쿠버 10 x +밸 10 x +뱀 10 x +뱃머리 10 x +뱃속 10 x +뱃속 10 x +뱅 10 x +뱅크 10 x +뱉 10 x +뱉 10 x +버 10 x +버드 10 x +버락 10 x +버락 10 x +버려 10 x +버려라 10 x +버려요 10 x +버려져 10 x +버려진 10 x +버렸 10 x +버렸 10 x +버렸으면 10 x +버릇 10 x +버릇 10 x +버리 10 x +버린 10 x +버린다 10 x +버릴 10 x +버림 10 x +버립니다 10 x +버마 10 x +버뮤다 10 x +버벌진트 10 x +버섯 10 x +버섯 10 x +버스 10 x +버전 10 x +버젓이 10 x +버즈 10 x +버쩍 10 x +버킷 10 x +버터 10 x +버텨야 10 x +버텨야 10 x +버텼 10 x +버텼 10 x +버텼 10 x +버텼 10 x +버튼 10 x +버티 10 x +버티 10 x +버팀목 10 x +버팀목 10 x +버팀목 10 x +버팔로 10 x +버퍼 10 x +벅차 10 x +벅차오르 10 x +벅찬 10 x +번 10 x +번갈 10 x +번개 10 x +번거 10 x +번방 10 x +번번이 10 x +번복 10 x +번복 10 x +번식 10 x +번식 10 x +번역 10 x +번역 10 x +번역자 10 x +번역자 10 x +번역판 10 x +번역판 10 x +번영 10 x +번잡 10 x +번져 10 x +번졌 10 x +번졌 10 x +번지 10 x +번질 10 x +번쩍 10 x +번창 10 x +번호 10 x +번화가 10 x +벌 10 x +벌 10 x +벌금 10 x +벌금형 10 x +벌꿀 10 x +벌떡 10 x +벌떼처럼 10 x +벌레 10 x +벌려 10 x +벌리 10 x +벌림 10 x +벌목 10 x +벌목 10 x +벌써 10 x +벌어들여 10 x +벌어들인 10 x +벌어져 10 x +벌어졌 10 x +벌어졌 10 x +벌어지 10 x +벌어진 10 x +벌어질 10 x +벌여 10 x +벌여야 10 x +벌여온 10 x +벌여왔 10 x +벌였 10 x +벌이 10 x +벌인 10 x +벌인다 10 x +벌집 10 x +벌충 10 x +벌침 10 x +벌컥 10 x +벌키 10 x +범 10 x +범민련 10 x +범법 10 x +범법자 10 x +범양 10 x +범위 10 x +범인 10 x +범죄 10 x +범죄자 10 x +범행 10 x +법 10 x +법 10 x +법관 10 x +법국 10 x +법대 10 x +법령 10 x +법률 10 x +법리 10 x +법무 10 x +법무부 10 x +법복 10 x +법복 10 x +법상 10 x +법석 10 x +법안 10 x +법원 10 x +법인 10 x +법인격 10 x +법인격 10 x +법인세 10 x +법전 10 x +법정 10 x +법정 10 x +법조 10 x +법조인 10 x +법칙 10 x +법칙 10 x +법칙 10 x +법학 10 x +법학자 10 x +벗 10 x +벗 10 x +벗겨 10 x +벗겨 10 x +벗기 10 x +벗긴 10 x +벗긴 10 x +벗어나 10 x +벗어난 10 x +벗어날 10 x +벗어났 10 x +벙커 10 x +벚꽃 10 x +벚나무 10 x +베 10 x +베가 10 x +베개 10 x +베껴 10 x +베네수엘라 10 x +베넷 10 x +베넷 10 x +베니스 10 x +베델 10 x +베들레헴 10 x +베르 10 x +베르디 10 x +베르크 10 x +베를린 10 x +베를린시 10 x +베리 10 x +베리아 10 x +베스트 10 x +베스트셀러 10 x +베오그라드 10 x +베이스 10 x +베이징 10 x +베이커리 10 x +베일 10 x +베테랑 10 x +베토벤 10 x +베트남 10 x +베푸 10 x +베풀 10 x +벡 10 x +벤 10 x +벤처 10 x +벤츠 10 x +벤치마킹 10 x +벨 10 x +벨기에 10 x +벨라스케스 10 x +벨트 10 x +벨파스트 10 x +벴 10 x +벵 10 x +벼 10 x +벼락 10 x +벼락 10 x +벼락치기 10 x +벼랑 10 x +벽 10 x +벽 10 x +벽돌 10 x +벽면 10 x +벽보 10 x +벽산 10 x +벽체 10 x +벽화 10 x +변 10 x +변경 10 x +변국 10 x +변국 10 x +변기 10 x +변덕 10 x +변동 10 x +변론 10 x +변명 10 x +변모 10 x +변방 10 x +변변 10 x +변비 10 x +변수 10 x +변신 10 x +변이 10 x +변인 10 x +변장 10 x +변주 10 x +변칙 10 x +변하 10 x +변하 10 x +변한 10 x +변한 10 x +변한다 10 x +변한다 10 x +변할 10 x +변할 10 x +변해 10 x +변해 10 x +변해서 10 x +변했 10 x +변했 10 x +변협 10 x +변협 10 x +변형 10 x +변형 10 x +변호 10 x +변호사 10 x +변호인 10 x +변화 10 x +변환봉 10 x +변희재 10 x +변희재 10 x +별 10 x +별 10 x +별개 10 x +별거 10 x +별걱정 10 x +별다른 10 x +별도 10 x +별로 10 x +별명 10 x +별세 10 x +별일 10 x +별칭 10 x +병 10 x +병가 10 x +병균 10 x +병동 10 x +병든 10 x +병력 10 x +병력 10 x +병리 10 x +병사 10 x +병식 10 x +병실 10 x +병아리 10 x +병역 10 x +병역 10 x +병역법 10 x +병영 10 x +병우 10 x +병원 10 x +병원균 10 x +병원장 10 x +병장 10 x +병충 10 x +병폐 10 x +병행 10 x +볕 10 x +보 10 x +보강 10 x +보건 10 x +보건복지부 10 x +보건비 10 x +보건소 10 x +보고 10 x +보고서 10 x +보관 10 x +보급 10 x +보내 10 x +보내왔 10 x +보낸 10 x +보낼 10 x +보냅니다 10 x +보냈 10 x +보냈 10 x +보너스 10 x +보다 10 x +보단 10 x +보답 10 x +보도 10 x +보도진 10 x +보드 10 x +보따리 10 x +보라색 10 x +보람 10 x +보료 10 x +보루 10 x +보류 10 x +보르 10 x +보르도 10 x +보름 10 x +보리 10 x +보리스 10 x +보미 10 x +보병 10 x +보복 10 x +보복 10 x +보비 10 x +보살 10 x +보살폈 10 x +보상 10 x +보상금 10 x +보상비 10 x +보석 10 x +보수 10 x +보수 10 x +보수당 10 x +보수주의자 10 x +보수파 10 x +보스 10 x +보스니아 10 x +보스턴 10 x +보습 10 x +보안 10 x +보안국 10 x +보안법 10 x +보약 10 x +보약 10 x +보양 10 x +보여 10 x +보여도 10 x +보여서 10 x +보여야 10 x +보여요 10 x +보여준 10 x +보여준다 10 x +보여줘 10 x +보여집니다 10 x +보였 10 x +보였 10 x +보였으나 10 x +보완 10 x +보유 10 x +보육 10 x +보육 10 x +보이 10 x +보이스 10 x +보이콧 10 x +보인 10 x +보인다 10 x +보인다고 10 x +보인다며 10 x +보일 10 x +보일러 10 x +보일지 10 x +보임 10 x +보입니다 10 x +보잉 10 x +보자기 10 x +보장 10 x +보전 10 x +보정 10 x +보조 10 x +보조금 10 x +보존 10 x +보좌 10 x +보좌관 10 x +보증 10 x +보증금 10 x +보직 10 x +보직 10 x +보천보 10 x +보충 10 x +보컬 10 x +보태 10 x +보탬 10 x +보톡스 10 x +보통 10 x +보트 10 x +보편 10 x +보필 10 x +보험 10 x +보험금 10 x +보험료 10 x +보험업 10 x +보호 10 x +보호법 10 x +보호자 10 x +보호책 10 x +복 10 x +복 10 x +복건 10 x +복고주의 10 x +복구 10 x +복권 10 x +복귀 10 x +복근 10 x +복도 10 x +복무 10 x +복부 10 x +복사 10 x +복사본 10 x +복수 10 x +복습 10 x +복식 10 x +복식 10 x +복싱 10 x +복안 10 x +복역 10 x +복용 10 x +복원 10 x +복음 10 x +복잡 10 x +복잡 10 x +복장 10 x +복제 10 x +복종 10 x +복종 10 x +복주머니 10 x +복지 10 x +복직 10 x +복직 10 x +복판 10 x +복판 10 x +복합 10 x +복합 10 x +볶 10 x +본 10 x +본격 10 x +본격 10 x +본관 10 x +본국 10 x +본능 10 x +본다 10 x +본다고 10 x +본다는 10 x +본다면 10 x +본래 10 x +본론 10 x +본방 10 x +본보 10 x +본부 10 x +본사 10 x +본선 10 x +본성 10 x +본업 10 x +본연 10 x +본의 10 x +본인 10 x +본지 10 x +본질 10 x +본청 10 x +본토 10 x +본회 10 x +볼 10 x +볼게요 10 x +볼까 10 x +볼까요 10 x +볼라 10 x +볼래 10 x +볼려고 10 x +볼륨 10 x +볼리비아 10 x +볼링 10 x +볼일 10 x +볼펜 10 x +봄 10 x +봄봄 10 x +봅니다 10 x +봅시다 10 x +봇 10 x +봇 10 x +봉 10 x +봉건 10 x +봉급 10 x +봉급 10 x +봉사 10 x +봉사자 10 x +봉성 10 x +봉쇄 10 x +봉승 10 x +봉준호 10 x +봉지 10 x +봉투 10 x +봐 10 x +봐도 10 x +봐라 10 x +봐서 10 x +봐야 10 x +봐야지 10 x +봐요 10 x +봤 10 x +봤 10 x +봤었 10 x +봤었 10 x +봬요 10 x +뵈 10 x +뵌 10 x +뵐 10 x +뵙 10 x +뵙 10 x +뵤 10 x +부 10 x +부 10 x +부가 10 x +부가가치세 10 x +부가세 10 x +부가티 10 x +부각 10 x +부결 10 x +부곡동 10 x +부과 10 x +부과금 10 x +부근 10 x +부기장 10 x +부끄 10 x +부끄러움 10 x +부끄러워 10 x +부끄러웠 10 x +부끄럽 10 x +부끄럽 10 x +부녀 10 x +부담 10 x +부담감 10 x +부담금 10 x +부담없이 10 x +부당 10 x +부대 10 x +부대찌개 10 x +부도 10 x +부도 10 x +부도심 10 x +부동산 10 x +부동층 10 x +부두 10 x +부드러운 10 x +부드러워 10 x +부드럽 10 x +부드럽 10 x +부들부들 10 x +부디 10 x +부디 10 x +부딪쳐 10 x +부딪쳤 10 x +부딪쳤 10 x +부딪치 10 x +부딪치 10 x +부딪친 10 x +부딪혔 10 x +부딪혔 10 x +부딪힌 10 x +부딪힐 10 x +부뚜막 10 x +부뚜막 10 x +부러 10 x +부러운 10 x +부러움 10 x +부러워 10 x +부러져 10 x +부러졌 10 x +부러졌 10 x +부러진 10 x +부럽 10 x +부룬디 10 x +부류 10 x +부르 10 x +부르짖 10 x +부르크 10 x +부른 10 x +부른다 10 x +부를 10 x +부를까 10 x +부릅니다 10 x +부릅뜨 10 x +부리 10 x +부림 10 x +부모 10 x +부문 10 x +부문장 10 x +부부 10 x +부분 10 x +부사장 10 x +부산 10 x +부산물 10 x +부산시 10 x +부상 10 x +부상 10 x +부상자 10 x +부서 10 x +부서원 10 x +부서장 10 x +부서진 10 x +부속 10 x +부속 10 x +부속물 10 x +부수 10 x +부수입 10 x +부시 10 x +부시장 10 x +부실 10 x +부심 10 x +부양 10 x +부업 10 x +부엌 10 x +부여 10 x +부여받 10 x +부용 10 x +부원장 10 x +부위 10 x +부유 10 x +부유물 10 x +부으 10 x +부으며 10 x +부응 10 x +부의장 10 x +부인 10 x +부인 10 x +부임 10 x +부자 10 x +부자 10 x +부작용 10 x +부잣집 10 x +부잣집 10 x +부장 10 x +부재 10 x +부재자 10 x +부재중 10 x +부적격 10 x +부적절 10 x +부적절 10 x +부적합 10 x +부적합 10 x +부전 10 x +부정 10 x +부정 10 x +부정부패 10 x +부정행위 10 x +부조리 10 x +부조종사 10 x +부족 10 x +부족 10 x +부종 10 x +부주의 10 x +부지 10 x +부지런 10 x +부지런히 10 x +부지런히 10 x +부진 10 x +부쩍 10 x +부쩍 10 x +부차 10 x +부착 10 x +부채 10 x +부처 10 x +부천역 10 x +부청 10 x +부쳐 10 x +부쳐서 10 x +부쳤 10 x +부쳤 10 x +부총리 10 x +부총장 10 x +부총재 10 x +부추기 10 x +부추긴 10 x +부추김 10 x +부축 10 x +부취 10 x +부측 10 x +부치 10 x +부친 10 x +부탁 10 x +부탁 10 x +부탁드립니다 10 x +부탁드립니다 10 x +부탄 10 x +부터 10 x +부턴 10 x +부토 10 x +부통령 10 x +부패 10 x +부페 10 x +부풀 10 x +부풀리 10 x +부풀린 10 x +부풀릴 10 x +부품 10 x +부피 10 x +부하 10 x +부하 10 x +부합 10 x +부화 10 x +부활 10 x +부회장 10 x +북 10 x +북 10 x +북경 10 x +북구 10 x +북극곰 10 x +북내면 10 x +북단 10 x +북단 10 x +북대 10 x +북미 10 x +북방 10 x +북부 10 x +북부 10 x +북상 10 x +북새통 10 x +북아일랜드 10 x +북아일랜드인 10 x +북적대 10 x +북진 10 x +북쪽 10 x +북쪽 10 x +북쪽 10 x +북측 10 x +북카페 10 x +북한 10 x +북한산 10 x +북한이탈주민 10 x +분 10 x +분간 10 x +분개 10 x +분규 10 x +분기 10 x +분노 10 x +분다 10 x +분단 10 x +분담 10 x +분당 10 x +분당선 10 x +분량 10 x +분류 10 x +분리 10 x +분리수거 10 x +분립 10 x +분만 10 x +분명 10 x +분명히 10 x +분배 10 x +분산 10 x +분석 10 x +분석 10 x +분식 10 x +분신 10 x +분실 10 x +분야 10 x +분양 10 x +분열 10 x +분위기 10 x +분쟁 10 x +분주 10 x +분출 10 x +분파 10 x +분포 10 x +분풀이 10 x +분필 10 x +분할 10 x +분할 10 x +분홍빛 10 x +분홍빛 10 x +분홍색 10 x +분화 10 x +불 10 x +불 10 x +불가 10 x +불가피 10 x +불거져 10 x +불거지 10 x +불거진 10 x +불경기 10 x +불고기 10 x +불공정 10 x +불과 10 x +불교 10 x +불구 10 x +불금 10 x +불기소 10 x +불길 10 x +불꽃 10 x +불꽃 10 x +불꽃놀이 10 x +불도저 10 x +불똥 10 x +불량 10 x +불량배 10 x +불러 10 x +불러다가 10 x +불러서 10 x +불러오 10 x +불러올 10 x +불러요 10 x +불러일으켰 10 x +불러일으키 10 x +불렀 10 x +불렀 10 x +불려 10 x +불렸 10 x +불로 10 x +불리 10 x +불리우 10 x +불린다 10 x +불만 10 x +불매 10 x +불명 10 x +불문 10 x +불발 10 x +불법 10 x +불법 10 x +불변 10 x +불빛 10 x +불사 10 x +불상사 10 x +불성실 10 x +불순종 10 x +불신 10 x +불쌍 10 x +불안 10 x +불안감 10 x +불어나 10 x +불어넣 10 x +불어넣 10 x +불어야 10 x +불연성 10 x +불우 10 x +불응 10 x +불참 10 x +불출마 10 x +불친절 10 x +불쾌 10 x +불쾌감 10 x +불타 10 x +불타오르 10 x +불태워 10 x +불편 10 x +불평 10 x +불행 10 x +불행히 10 x +불허 10 x +불협화음 10 x +불화 10 x +불황 10 x +붉 10 x +붉 10 x +붐 10 x +붐벼요 10 x +붐볐 10 x +붐비 10 x +붓 10 x +붓 10 x +붕 10 x +붕가 10 x +붕괴 10 x +붕대 10 x +붙 10 x +붙 10 x +붙들 10 x +붙여 10 x +붙여진 10 x +붙였 10 x +붙였 10 x +붙이 10 x +붙인 10 x +붙잡 10 x +붙잡 10 x +붙잡혔 10 x +뷔페 10 x +뷰 10 x +뷰티 10 x +브 10 x +브라더 10 x +브라더스 10 x +브라스 10 x +브라우저 10 x +브라질 10 x +브라티슬라바 10 x +브라티슬라바 10 x +브래스 10 x +브랜드 10 x +브런치 10 x +브레이커 10 x +브레이크 10 x +브렉 10 x +브렉 10 x +브로커 10 x +브로트 10 x +브루스 10 x +브룬 10 x +브뤼셀 10 x +브리핑 10 x +브이 10 x +블 10 x +블라우스 10 x +블랙 10 x +블랙 10 x +블랙리스트 10 x +블랙박스 10 x +블랙박스 10 x +블러 10 x +블럭 10 x +블로그 10 x +블록 10 x +블록 10 x +블루 10 x +비 10 x +비겁 10 x +비결 10 x +비겼 10 x +비계 10 x +비관 10 x +비교 10 x +비교적 10 x +비교적 10 x +비굴 10 x +비극 10 x +비극 10 x +비글 10 x +비꼬 10 x +비난 10 x +비누 10 x +비닐 10 x +비닐봉지 10 x +비단 10 x +비단 10 x +비대 10 x +비델라 10 x +비둘기 10 x +비디아 10 x +비디오 10 x +비디오 10 x +비뚤 10 x +비력 10 x +비례 10 x +비로소 10 x +비록 10 x +비록 10 x +비롯 10 x +비롯 10 x +비료 10 x +비리 10 x +비만 10 x +비명 10 x +비무장 10 x +비문 10 x +비밀 10 x +비밀리 10 x +비박 10 x +비박 10 x +비방 10 x +비범 10 x +비법 10 x +비벼서 10 x +비비 10 x +비빔밥 10 x +비상 10 x +비상 10 x +비상계단 10 x +비상국무회의 10 x +비상사태 10 x +비서 10 x +비서관 10 x +비서실 10 x +비선 10 x +비슷 10 x +비슷 10 x +비시 10 x +비싸 10 x +비싼 10 x +비싼가 10 x +비씨 10 x +비아 10 x +비애 10 x +비약 10 x +비오 10 x +비옥 10 x +비올라 10 x +비와 10 x +비욘 10 x +비용 10 x +비우 10 x +비웃 10 x +비워 10 x +비워도 10 x +비워야 10 x +비웠 10 x +비웠 10 x +비유 10 x +비율 10 x +비자 10 x +비자금 10 x +비전 10 x +비전동 10 x +비정 10 x +비정규직 10 x +비정규직 10 x +비제 10 x +비젼 10 x +비주류 10 x +비준 10 x +비중 10 x +비즈니스 10 x +비지니스 10 x +비참 10 x +비창 10 x +비쳐 10 x +비쳤 10 x +비추 10 x +비축 10 x +비축 10 x +비춰 10 x +비춰볼 10 x +비취 10 x +비치 10 x +비친 10 x +비친다 10 x +비켜 10 x +비크 10 x +비키 10 x +비키니 10 x +비타 10 x +비타민 10 x +비토 10 x +비토리아 10 x +비통 10 x +비틀 10 x +비티 10 x +비판 10 x +비평가 10 x +비포 10 x +비하 10 x +비하인드 10 x +비할 10 x +비해 10 x +비해서 10 x +비핵화 10 x +비행 10 x +비행기 10 x +비행장 10 x +비협조 10 x +비호 10 x +비화 10 x +비확산 10 x +빅 10 x +빅 10 x +빅딜 10 x +빈 10 x +빈곤 10 x +빈곤층 10 x +빈대 10 x +빈도 10 x +빈발 10 x +빈방 10 x +빈번 10 x +빈부 10 x +빈슨 10 x +빈약 10 x +빈칸 10 x +빌 10 x +빌딩 10 x +빌라 10 x +빌려 10 x +빌려야 10 x +빌렸 10 x +빌리 10 x +빌린 10 x +빌린다 10 x +빌릴 10 x +빌립보 10 x +빌미 10 x +빗 10 x +빗 10 x +빗물 10 x +빗발쳤 10 x +빗발쳤 10 x +빗방울 10 x +빗자루 10 x +빗장 10 x +빙 10 x +빙빙 10 x +빙의 10 x +빙하 10 x +빚 10 x +빚 10 x +빚어낸 10 x +빚어졌 10 x +빚어졌 10 x +빚어지 10 x +빚어질 10 x +빛 10 x +빛 10 x +빛깔 10 x +빛깔 10 x +빛나 10 x +빛바랜 10 x +빠 10 x +빠뜨렸 10 x +빠뜨린 10 x +빠르 10 x +빠른 10 x +빠이 10 x +빠져 10 x +빠져나가 10 x +빠져나간 10 x +빠져나갔 10 x +빠져나온 10 x +빠져나왔 10 x +빠져도 10 x +빠져들 10 x +빠져요 10 x +빠졌 10 x +빠졌 10 x +빠지 10 x +빠진 10 x +빠진다 10 x +빠진다는 10 x +빠질 10 x +빠짐없이 10 x +빡 10 x +빡 10 x +빤히 10 x +빨 10 x +빨간 10 x +빨간색 10 x +빨개 10 x +빨갱이 10 x +빨라 10 x +빨라서 10 x +빨라야 10 x +빨라요 10 x +빨라졌 10 x +빨라졌 10 x +빨라질 10 x +빨랐 10 x +빨랐 10 x +빨래 10 x +빨리 10 x +빨치산 10 x +빵 10 x +빵빵 10 x +빵집 10 x +빼 10 x +빼낼 10 x +빼놓 10 x +빼놓 10 x +빼돌리 10 x +빼돌린 10 x +빼앗 10 x +빼앗 10 x +빼앗겼 10 x +빼앗겼 10 x +빼앗긴 10 x +빼앗긴 10 x +빼어난 10 x +뺀 10 x +뺀다 10 x +뺏 10 x +뺏 10 x +뺏겼 10 x +뺏겼 10 x +뺏겼 10 x +뺏기 10 x +뺏기 10 x +뺏길 10 x +뺐 10 x +뺑소니 10 x +뺨 10 x +뻔 10 x +뻔뻔 10 x +뻔하 10 x +뻔하 10 x +뻔한 10 x +뻔히 10 x +뻔히 10 x +뻗 10 x +뻗 10 x +뻘 10 x +뻘개서 10 x +뼈 10 x +뼈대 10 x +뼈저리 10 x +뽀 10 x +뽀로로 10 x +뽀뽀 10 x +뽑 10 x +뽑 10 x +뽑혔 10 x +뽑힌 10 x +뽕 10 x +뽕짝 10 x +뽕짝 10 x +뾰족 10 x +뿌 10 x +뿌려 10 x +뿌려서 10 x +뿌렸 10 x +뿌리 10 x +뿌리깊 10 x +뿌리내리 10 x +뿌리치 10 x +뿌린 10 x +뿐 10 x +뿜 10 x +삐 10 x +삐끗 10 x +사 10 x +사가미하라 10 x +사각 10 x +사각 10 x +사간 10 x +사개 10 x +사거리 10 x +사건 10 x +사격 10 x +사견 10 x +사계절 10 x +사고 10 x +사고기 10 x +사고방식 10 x +사고방식 10 x +사골 10 x +사과 10 x +사과드립니다 10 x +사과문 10 x +사관 10 x +사교육 10 x +사교육 10 x +사교육비 10 x +사교육비 10 x +사국 10 x +사귀 10 x +사귄 10 x +사귈 10 x +사그라졌 10 x +사근 10 x +사기 10 x +사기업 10 x +사기죄 10 x +사내 10 x +사냥 10 x +사다리 10 x +사단법인 10 x +사당 10 x +사대 10 x +사대부가 10 x +사도 10 x +사돈 10 x +사드 10 x +사들여 10 x +사등면 10 x +사라 10 x +사라져 10 x +사라졌 10 x +사라지 10 x +사라진 10 x +사라질 10 x +사라집니다 10 x +사람 10 x +사랑 10 x +사랑니 10 x +사령관 10 x +사령부 10 x +사례 10 x +사로잡 10 x +사로잡혀 10 x +사로잡혔 10 x +사로잡히 10 x +사로잡힌 10 x +사료 10 x +사립 10 x +사립 10 x +사막 10 x +사막 10 x +사막여우 10 x +사만 10 x +사망 10 x +사망자 10 x +사면 10 x +사명 10 x +사명감 10 x +사모 10 x +사모님 10 x +사무 10 x +사무관 10 x +사무국 10 x +사무소 10 x +사무실 10 x +사무장 10 x +사무직 10 x +사무직 10 x +사무처 10 x +사무총장 10 x +사문서 10 x +사물 10 x +사뭇 10 x +사뭇 10 x +사민 10 x +사바토 10 x +사방 10 x +사범 10 x +사법 10 x +사법 10 x +사법연수원 10 x +사벨 10 x +사보 10 x +사복 10 x +사복 10 x +사본 10 x +사사건건 10 x +사상 10 x +사상자 10 x +사생 10 x +사생활 10 x +사석 10 x +사설 10 x +사세요 10 x +사셨 10 x +사셨 10 x +사소 10 x +사수 10 x +사슬 10 x +사슴 10 x +사시 10 x +사신 10 x +사실 10 x +사실상 10 x +사실혼 10 x +사심 10 x +사악 10 x +사안 10 x +사업 10 x +사업 10 x +사업가 10 x +사업단 10 x +사업비 10 x +사업소 10 x +사업자 10 x +사업자 10 x +사업장 10 x +사연 10 x +사옥 10 x +사옥 10 x +사와 10 x +사외 10 x +사용 10 x +사용량 10 x +사용법 10 x +사우나 10 x +사우디 10 x +사우디 10 x +사우디아라비아 10 x +사우스 10 x +사우스오스트레일리아 10 x +사운드 10 x +사원 10 x +사월 10 x +사위 10 x +사유 10 x +사은품 10 x +사이 10 x +사이고 10 x +사이다 10 x +사이버 10 x +사이비 10 x +사이언 10 x +사이즈 10 x +사이트 10 x +사인 10 x +사인 10 x +사인회 10 x +사임 10 x +사임당 10 x +사자 10 x +사장 10 x +사재 10 x +사재기 10 x +사저 10 x +사전 10 x +사절 10 x +사절단 10 x +사점 10 x +사정 10 x +사제 10 x +사조 10 x +사조직 10 x +사죄 10 x +사증 10 x +사지 10 x +사직 10 x +사직 10 x +사진 10 x +사진가 10 x +사진기 10 x +사진사 10 x +사차원 10 x +사찰 10 x +사처 10 x +사천 10 x +사체 10 x +사촌 10 x +사춘기 10 x +사치 10 x +사카 10 x +사쿠라 10 x +사쿠라지마 10 x +사탄 10 x +사탕 10 x +사태 10 x +사택 10 x +사택 10 x +사퇴 10 x +사투리 10 x +사평 10 x +사표 10 x +사하 10 x +사학 10 x +사학자 10 x +사합 10 x +사항 10 x +사행 10 x +사형 10 x +사활 10 x +사회 10 x +사회단체 10 x +사회당 10 x +사회봉사 10 x +사회생활 10 x +사회주의 10 x +사회학 10 x +사후 10 x +사흘 10 x +삭 10 x +삭 10 x +삭감 10 x +삭제 10 x +삭제 10 x +산 10 x +산간 10 x +산국 10 x +산국 10 x +산꼭대기 10 x +산다 10 x +산다는 10 x +산다면 10 x +산더미 10 x +산맥 10 x +산맥 10 x +산모 10 x +산물 10 x +산발 10 x +산본 10 x +산봉 10 x +산부인과 10 x +산성화 10 x +산소 10 x +산술 10 x +산심 10 x +산악 10 x +산악 10 x +산액 10 x +산양 10 x +산업 10 x +산업 10 x +산업체 10 x +산유국 10 x +산유국 10 x +산자부 10 x +산적 10 x +산정 10 x +산지 10 x +산짐승 10 x +산책 10 x +산책로 10 x +산출 10 x +산타아나 10 x +산타카타리나 10 x +산타크루스 10 x +산토리니 10 x +산티아고 10 x +산하 10 x +산하 10 x +산행 10 x +산행 10 x +산후 10 x +산후조리원 10 x +살 10 x +살게 10 x +살게요 10 x +살균제 10 x +살려 10 x +살려야 10 x +살리 10 x +살린 10 x +살릴 10 x +살림 10 x +살림살이 10 x +살상 10 x +살생부 10 x +살아가 10 x +살아갈 10 x +살아나 10 x +살아날 10 x +살아남 10 x +살아오 10 x +살아온 10 x +살아왔 10 x +살아왔 10 x +살인 10 x +살인범 10 x +살인자 10 x +살인죄 10 x +살짝 10 x +살코기 10 x +살펴 10 x +살펴보 10 x +살펴본다 10 x +살펴볼 10 x +살펴볼게 10 x +살펴봐야 10 x +살펴봤 10 x +살펴야 10 x +살폈 10 x +살포 10 x +살피 10 x +살핀 10 x +살해 10 x +삶 10 x +삼 10 x +삼가 10 x +삼각 10 x +삼각 10 x +삼각김밥 10 x +삼겹살 10 x +삼계탕 10 x +삼국 10 x +삼국 10 x +삼국유사 10 x +삼국지 10 x +삼국지 10 x +삼당 10 x +삼룡 10 x +삼류 10 x +삼륜 10 x +삼부 10 x +삼선 10 x +삼성 10 x +삼성그룹 10 x +삼성그룹 10 x +삼성동 10 x +삼성문화재단 10 x +삼성물산 10 x +삼성생명 10 x +삼성전자 10 x +삼성증권 10 x +삼성카드 10 x +삼엄 10 x +삼월 10 x +삼인 10 x +삼진 10 x +삼촌 10 x +삼킬 10 x +삼행시 10 x +삽 10 x +삽 10 x +삽니다 10 x +샀 10 x +샀 10 x +상 10 x +상가 10 x +상거래 10 x +상견례 10 x +상고 10 x +상공 10 x +상관 10 x +상관없 10 x +상관없이 10 x +상국 10 x +상국 10 x +상권 10 x +상규 10 x +상금 10 x +상급 10 x +상기 10 x +상납 10 x +상납 10 x +상담 10 x +상당 10 x +상당수 10 x +상당히 10 x +상대 10 x +상대국 10 x +상대방 10 x +상도 10 x +상도동 10 x +상록수 10 x +상류 10 x +상륙 10 x +상륙 10 x +상륙전 10 x +상륙함 10 x +상명하복 10 x +상명하복 10 x +상무 10 x +상무대 10 x +상무위원 10 x +상반기 10 x +상봉 10 x +상사 10 x +상상 10 x +상상력 10 x +상상력 10 x +상생 10 x +상선 10 x +상세 10 x +상세히 10 x +상소문 10 x +상속세 10 x +상수 10 x +상수도 10 x +상수리 10 x +상수원 10 x +상스러워 10 x +상습 10 x +상승 10 x +상승세 10 x +상시 10 x +상식 10 x +상신서 10 x +상실 10 x +상어 10 x +상업 10 x +상업 10 x +상여금 10 x +상영 10 x +상오 10 x +상온 10 x +상용차 10 x +상원 10 x +상위 10 x +상응 10 x +상의 10 x +상인 10 x +상임 10 x +상임위 10 x +상자 10 x +상장 10 x +상장법인 10 x +상점 10 x +상정 10 x +상주 10 x +상주시 10 x +상진 10 x +상징 10 x +상처 10 x +상추 10 x +상추쌈 10 x +상충 10 x +상층 10 x +상쾌 10 x +상큼 10 x +상태 10 x +상파울루 10 x +상표 10 x +상품 10 x +상품권 10 x +상하 10 x +상하수도 10 x +상하이 10 x +상한 10 x +상해 10 x +상했 10 x +상했 10 x +상호 10 x +상호주의 10 x +상환 10 x +상황 10 x +상황실 10 x +상회 10 x +새 10 x +새겨져 10 x +새겼 10 x +새길 10 x +새끼 10 x +새로 10 x +새로운 10 x +새로이 10 x +새롭 10 x +새롭 10 x +새뮤얼 10 x +새벽 10 x +새벽 10 x +새벽녘 10 x +새벽녘 10 x +새삼 10 x +새우 10 x +새워 10 x +새웠 10 x +새해 10 x +색 10 x +색깔 10 x +색다른 10 x +색상 10 x +색채 10 x +샌 10 x +샌더스 10 x +샌드위치 10 x +샌디에이고 10 x +샌즈 10 x +샌프란시스코 10 x +샐 10 x +샐러드 10 x +샐비지 10 x +샘 10 x +샘플 10 x +생 10 x +생각 10 x +생각 10 x +생각나 10 x +생각난 10 x +생각난다 10 x +생각났 10 x +생겨 10 x +생겨나 10 x +생겨난 10 x +생겨났 10 x +생겨서 10 x +생겼 10 x +생겼 10 x +생겼어요 10 x +생계 10 x +생계비 10 x +생긋 10 x +생긋 10 x +생기 10 x +생긴 10 x +생긴다 10 x +생길 10 x +생깁니다 10 x +생략 10 x +생리 10 x +생명 10 x +생명력 10 x +생명력 10 x +생명체 10 x +생명표 10 x +생물 10 x +생물학 10 x +생방 10 x +생방송 10 x +생사 10 x +생산 10 x +생산량 10 x +생산자 10 x +생색내 10 x +생생 10 x +생생히 10 x +생선 10 x +생신 10 x +생애 10 x +생일 10 x +생전 10 x +생존 10 x +생체 10 x +생크림 10 x +생태 10 x +생태학 10 x +생태학 10 x +생필품 10 x +생활 10 x +생활고 10 x +생활비 10 x +생활상 10 x +생활지 10 x +생후 10 x +샤 10 x +샤라 10 x +샤오 10 x +샤오미 10 x +샤워 10 x +샤이니 10 x +샤인 10 x +샬린 10 x +샴 10 x +샴푸 10 x +샵 10 x +샵 10 x +샷 10 x +샷 10 x +서 10 x +서강대 10 x +서거 10 x +서경석 10 x +서경석 10 x +서곡 10 x +서구 10 x +서구화 10 x +서귀포 10 x +서귀포시 10 x +서기 10 x +서기관 10 x +서너 10 x +서늘 10 x +서다 10 x +서대문구 10 x +서독 10 x +서독 10 x +서동 10 x +서두 10 x +서두르 10 x +서둘 10 x +서둘러 10 x +서둘러야 10 x +서랍 10 x +서랍 10 x +서로 10 x +서류 10 x +서른 10 x +서리 10 x +서린 10 x +서면 10 x +서명 10 x +서문시장 10 x +서민 10 x +서방 10 x +서버 10 x +서범석 10 x +서부 10 x +서북 10 x +서브 10 x +서비스 10 x +서비스업 10 x +서빙 10 x +서서히 10 x +서석구 10 x +서석재 10 x +서술 10 x +서슴 10 x +서슴없이 10 x +서식 10 x +서안 10 x +서약 10 x +서양 10 x +서양인 10 x +서열 10 x +서요 10 x +서운 10 x +서울 10 x +서울대 10 x +서울숲 10 x +서울숲 10 x +서울시 10 x +서울역 10 x +서울은행 10 x +서울은행 10 x +서원 10 x +서유럽 10 x +서유럽 10 x +서일 10 x +서장 10 x +서적 10 x +서적 10 x +서점 10 x +서종환 10 x +서진 10 x +서쪽 10 x +서쪽 10 x +서초 10 x +서초구 10 x +서킷 10 x +서킷 10 x +서툴러요 10 x +서핑 10 x +서한 10 x +서해 10 x +서해안 10 x +석 10 x +석 10 x +석강 10 x +석권 10 x +석기 10 x +석도 10 x +석명 10 x +석방 10 x +석사 10 x +석상 10 x +석실 10 x +석양 10 x +석연 10 x +석유 10 x +석유공사 10 x +석촌 10 x +석탄 10 x +석학 10 x +섞 10 x +섞여 10 x +섞이 10 x +섞인 10 x +선 10 x +선가 10 x +선거 10 x +선거관리위원회 10 x +선거구 10 x +선거법 10 x +선거법 10 x +선거비 10 x +선거전 10 x +선경 10 x +선고 10 x +선곡 10 x +선과 10 x +선관위 10 x +선교 10 x +선교사 10 x +선글라스 10 x +선내 10 x +선대 10 x +선대인 10 x +선데이 10 x +선도 10 x +선동 10 x +선두 10 x +선뜩 10 x +선뜻 10 x +선량 10 x +선례 10 x +선마 10 x +선망 10 x +선명 10 x +선물 10 x +선미 10 x +선민의식 10 x +선박 10 x +선박 10 x +선반 10 x +선발 10 x +선배 10 x +선별 10 x +선보여 10 x +선보였 10 x +선보인다 10 x +선봉 10 x +선비 10 x +선사 10 x +선상 10 x +선생 10 x +선소 10 x +선수 10 x +선수금 10 x +선수촌 10 x +선심 10 x +선심 10 x +선심성 10 x +선언 10 x +선열 10 x +선원 10 x +선유 10 x +선의 10 x +선인 10 x +선임 10 x +선자 10 x +선장 10 x +선전 10 x +선전 10 x +선정 10 x +선제 10 x +선조 10 x +선진 10 x +선진국 10 x +선진국 10 x +선착장 10 x +선착장 10 x +선천성 10 x +선체 10 x +선출 10 x +선타임스 10 x +선택 10 x +선택 10 x +선택지 10 x +선팅 10 x +선포 10 x +선풍 10 x +선풍기 10 x +선하 10 x +선하 10 x +선행 10 x +선행 10 x +선호 10 x +선홍색 10 x +선회 10 x +섣불리 10 x +섣불리 10 x +설 10 x +설 10 x +설거지 10 x +설계 10 x +설계도 10 x +설계사 10 x +설계서 10 x +설날 10 x +설도 10 x +설득 10 x +설득 10 x +설득력 10 x +설득력 10 x +설렁탕 10 x +설레 10 x +설렌다 10 x +설령 10 x +설로 10 x +설립 10 x +설립 10 x +설명 10 x +설명서 10 x +설문 10 x +설비 10 x +설사 10 x +설사병 10 x +설악산 10 x +설왕설래 10 x +설움 10 x +설인 10 x +설전 10 x +설정 10 x +설치 10 x +설킨 10 x +설탕 10 x +설파 10 x +설화 10 x +섬 10 x +섬기 10 x +섬세 10 x +섬유 10 x +섭 10 x +섭 10 x +섭섭 10 x +섭외 10 x +섭이 10 x +섭취 10 x +섰 10 x +성 10 x +성 10 x +성격 10 x +성격 10 x +성경 10 x +성공 10 x +성과 10 x +성과급 10 x +성구 10 x +성구 10 x +성균관 10 x +성금 10 x +성급 10 x +성급 10 x +성난 10 x +성남 10 x +성남시 10 x +성냥 10 x +성능 10 x +성당 10 x +성대 10 x +성도 10 x +성도덕 10 x +성동 10 x +성령 10 x +성례 10 x +성룡 10 x +성립 10 x +성립 10 x +성매매 10 x +성명 10 x +성명서 10 x +성모병원 10 x +성미 10 x +성범죄 10 x +성별 10 x +성병 10 x +성북구 10 x +성북동 10 x +성분 10 x +성비 10 x +성빈 10 x +성사 10 x +성서 10 x +성수기 10 x +성숙 10 x +성실 10 x +성씨 10 x +성악가 10 x +성역 10 x +성역 10 x +성완종 10 x +성우 10 x +성원 10 x +성의 10 x +성인 10 x +성인 10 x +성장 10 x +성장세 10 x +성적 10 x +성적 10 x +성적 10 x +성적 10 x +성전 10 x +성주 10 x +성주군 10 x +성지 10 x +성직자 10 x +성질 10 x +성찰 10 x +성체 10 x +성추행 10 x +성취 10 x +성큼 10 x +성탄 10 x +성토장 10 x +성폭행 10 x +성함 10 x +성행 10 x +성향 10 x +성혜랑 10 x +성혜림 10 x +성희 10 x +성희롱 10 x +세 10 x +세간 10 x +세계 10 x +세계관 10 x +세계무역기구 10 x +세계인 10 x +세관 10 x +세균 10 x +세균전 10 x +세금 10 x +세기 10 x +세대 10 x +세대주 10 x +세력 10 x +세력 10 x +세련 10 x +세례 10 x +세로 10 x +세르비 10 x +세르비아 10 x +세르비아인 10 x +세리프 10 x +세무 10 x +세무서 10 x +세미나 10 x +세발 10 x +세법 10 x +세법 10 x +세부 10 x +세상 10 x +세션 10 x +세수 10 x +세습 10 x +세실 10 x +세심 10 x +세액 10 x +세액 10 x +세여 10 x +세요 10 x +세용 10 x +세우 10 x +세운 10 x +세울 10 x +세워 10 x +세워둔 10 x +세워라 10 x +세워야 10 x +세워졌 10 x +세워진 10 x +세원 10 x +세월 10 x +세웠 10 x +세웠 10 x +세웠었 10 x +세웠었 10 x +세율 10 x +세일 10 x +세입자 10 x +세정 10 x +세제 10 x +세제곱미터 10 x +세종 10 x +세차 10 x +세탁 10 x +세탁 10 x +세탁기 10 x +세탁소 10 x +세태 10 x +세트 10 x +세포 10 x +세피아 10 x +섹스 10 x +섹시 10 x +센 10 x +센스 10 x +센젠 10 x +센터 10 x +센트 10 x +센티 10 x +센티미터 10 x +센티미터 10 x +셀 10 x +셀카 10 x +셀프 10 x +셈 10 x +셈 10 x +셋 10 x +셋 10 x +셋째 10 x +셔 10 x +셔도 10 x +셔먼 10 x +셔서 10 x +셔야 10 x +셔야죠 10 x +셔요 10 x +셔츠 10 x +션 10 x +셨 10 x +셨 10 x +셨었 10 x +셨었 10 x +셨으니 10 x +셰리던 10 x +셰이 10 x +셰프 10 x +소 10 x +소각 10 x +소각장 10 x +소감 10 x +소개 10 x +소개팅 10 x +소개팅 10 x +소견 10 x +소고기 10 x +소관 10 x +소굴 10 x +소규모 10 x +소극 10 x +소금 10 x +소나기 10 x +소나무 10 x +소나타 10 x +소녀 10 x +소년 10 x +소담 10 x +소도 10 x +소도시 10 x +소독 10 x +소동 10 x +소득 10 x +소득 10 x +소득세 10 x +소득세법 10 x +소라 10 x +소라넷 10 x +소라넷 10 x +소란 10 x +소람 10 x +소련 10 x +소령 10 x +소록 10 x +소르 10 x +소름 10 x +소리 10 x +소리쳤 10 x +소리치 10 x +소마 10 x +소만 10 x +소망 10 x +소매 10 x +소맥 10 x +소멸 10 x +소모 10 x +소모적 10 x +소모적 10 x +소문 10 x +소문나 10 x +소믈리에 10 x +소박 10 x +소방 10 x +소방관 10 x +소방차 10 x +소변 10 x +소비 10 x +소비량 10 x +소비문화 10 x +소비액 10 x +소비액 10 x +소비에트 10 x +소비자 10 x +소비재 10 x +소상 10 x +소서 10 x +소선거구제 10 x +소설 10 x +소설가 10 x +소세지 10 x +소셜 10 x +소소 10 x +소속 10 x +소속 10 x +소송 10 x +소송법 10 x +소송법 10 x +소수 10 x +소수 10 x +소스 10 x +소스라치 10 x +소시지 10 x +소식 10 x +소식 10 x +소식통 10 x +소식통 10 x +소신 10 x +소실 10 x +소심 10 x +소액 10 x +소액 10 x +소양 10 x +소양인 10 x +소영 10 x +소외 10 x +소요 10 x +소용 10 x +소용없 10 x +소원 10 x +소원 10 x +소위 10 x +소유 10 x +소유자 10 x +소음 10 x +소음인 10 x +소이다 10 x +소장 10 x +소재 10 x +소주 10 x +소주잔 10 x +소중 10 x +소중히 10 x +소지 10 x +소지품 10 x +소진 10 x +소질 10 x +소집 10 x +소집 10 x +소청 10 x +소총 10 x +소추 10 x +소탕 10 x +소통 10 x +소파 10 x +소포 10 x +소폭 10 x +소폭 10 x +소품 10 x +소풍 10 x +소프라노 10 x +소프트 10 x +소프트웨어 10 x +소피 10 x +소행 10 x +소형 10 x +소형주 10 x +소홀 10 x +소홀히 10 x +소화 10 x +소화제 10 x +소환 10 x +속 10 x +속 10 x +속담 10 x +속도 10 x +속뜻 10 x +속마음 10 x +속박 10 x +속삭이 10 x +속상해 10 x +속상해요 10 x +속성 10 x +속셈 10 x +속속 10 x +속속 10 x +속아 10 x +속여 10 x +속일 10 x +속임수 10 x +속전속결 10 x +속조 10 x +속지주의 10 x +속초 10 x +속출 10 x +속하 10 x +속한다 10 x +속해 10 x +손 10 x +손가락 10 x +손가락 10 x +손괴 10 x +손길 10 x +손꼽히 10 x +손끝 10 x +손녀 10 x +손님 10 x +손대 10 x +손댄 10 x +손등 10 x +손목 10 x +손목 10 x +손목시계 10 x +손바닥 10 x +손발 10 x +손보 10 x +손뼉 10 x +손상 10 x +손수건 10 x +손쉬운 10 x +손쉽 10 x +손쉽 10 x +손실 10 x +손자 10 x +손잡 10 x +손잡이 10 x +손재주 10 x +손질 10 x +손짓 10 x +손톱 10 x +손해 10 x +손해 10 x +손흥민 10 x +솔 10 x +솔 10 x +솔깃 10 x +솔로 10 x +솔로몬 10 x +솔루션 10 x +솔직 10 x +솔직 10 x +솔직히 10 x +솜 10 x +솜씨 10 x +솟 10 x +솟구쳐 10 x +솟구쳐 10 x +송 10 x +송광수 10 x +송구 10 x +송금 10 x +송본 10 x +송사 10 x +송아지 10 x +송악산 10 x +송언종 10 x +송영오 10 x +송이 10 x +송장 10 x +송진섭 10 x +송태호 10 x +송파 10 x +송파구 10 x +송편 10 x +송풍기 10 x +송혜진 10 x +송환 10 x +송희영 10 x +쇄도 10 x +쇄신 10 x +쇠 10 x +쇠고기 10 x +쇠락 10 x +쇠사슬 10 x +쇠약 10 x +쇠퇴 10 x +쇤베르크 10 x +쇼 10 x +쇼브 10 x +쇼크 10 x +쇼핑 10 x +쇼핑몰 10 x +숀 10 x +수 10 x +수 10 x +수감 10 x +수강 10 x +수개월 10 x +수거 10 x +수경 10 x +수계 10 x +수고 10 x +수공학 10 x +수공학 10 x +수교 10 x +수구 10 x +수그러들 10 x +수근 10 x +수급 10 x +수급 10 x +수긍 10 x +수년 10 x +수뇌 10 x +수능 10 x +수다 10 x +수다떨 10 x +수단 10 x +수당 10 x +수도 10 x +수도권 10 x +수도꼭지 10 x +수돗물 10 x +수동 10 x +수락 10 x +수락 10 x +수량 10 x +수레 10 x +수레바퀴 10 x +수력 10 x +수련 10 x +수렴 10 x +수령 10 x +수령자 10 x +수록 10 x +수록 10 x +수료 10 x +수류탄 10 x +수리 10 x +수리 10 x +수리비 10 x +수림 10 x +수립 10 x +수립 10 x +수만 10 x +수많 10 x +수면 10 x +수명 10 x +수문 10 x +수문학 10 x +수문학 10 x +수박 10 x +수박 10 x +수반 10 x +수배자 10 x +수백 10 x +수백 10 x +수백만 10 x +수법 10 x +수부 10 x +수분 10 x +수비 10 x +수비대 10 x +수비수 10 x +수사 10 x +수산 10 x +수산물 10 x +수상 10 x +수상자 10 x +수색 10 x +수서 10 x +수석 10 x +수석 10 x +수선 10 x +수성 10 x +수성구 10 x +수세미 10 x +수소 10 x +수소문 10 x +수송 10 x +수송기 10 x +수송로 10 x +수수 10 x +수수께끼 10 x +수수료 10 x +수순 10 x +수술 10 x +수술실 10 x +수습 10 x +수습 10 x +수습책 10 x +수습책 10 x +수시 10 x +수시로 10 x +수심 10 x +수십 10 x +수십 10 x +수십만 10 x +수십억 10 x +수십억 10 x +수억 10 x +수억 10 x +수업 10 x +수업 10 x +수없이 10 x +수염 10 x +수영 10 x +수영복 10 x +수영장 10 x +수요 10 x +수요일 10 x +수욕장 10 x +수용 10 x +수용소 10 x +수원 10 x +수원지 10 x +수위 10 x +수유 10 x +수의사 10 x +수익 10 x +수익 10 x +수임 10 x +수입 10 x +수입 10 x +수입국 10 x +수입액 10 x +수입품 10 x +수자원 10 x +수재 10 x +수정 10 x +수정란 10 x +수주 10 x +수준 10 x +수줍음 10 x +수중 10 x +수증기 10 x +수지 10 x +수직 10 x +수질 10 x +수집 10 x +수집 10 x +수차례 10 x +수천 10 x +수첩 10 x +수첩 10 x +수출 10 x +수출국 10 x +수출국 10 x +수출액 10 x +수출액 10 x +수출품 10 x +수취 10 x +수치 10 x +수컷 10 x +수컷 10 x +수탈 10 x +수평 10 x +수표 10 x +수풀 10 x +수프 10 x +수하물 10 x +수학 10 x +수학 10 x +수학 10 x +수학 10 x +수학여행 10 x +수해 10 x +수행 10 x +수행원 10 x +수협 10 x +수호 10 x +수화기 10 x +수확 10 x +수확 10 x +숙 10 x +숙 10 x +숙녀 10 x +숙련 10 x +숙박 10 x +숙박업 10 x +숙성 10 x +숙소 10 x +숙여 10 x +숙였 10 x +숙원 10 x +숙의 10 x +숙이 10 x +숙인 10 x +숙인다 10 x +숙제 10 x +숙제 10 x +숙주 10 x +숙주 10 x +숙지 10 x +숙청 10 x +순 10 x +순간 10 x +순감 10 x +순결 10 x +순금 10 x +순덕 10 x +순록 10 x +순박 10 x +순방 10 x +순사 10 x +순서 10 x +순수 10 x +순수익 10 x +순식간 10 x +순위 10 x +순이익 10 x +순이익 10 x +순익 10 x +순익 10 x +순자 10 x +순전히 10 x +순전히 10 x +순조 10 x +순종 10 x +순진 10 x +순차 10 x +순찰 10 x +순찰대 10 x +순창 10 x +순천 10 x +순치 10 x +순하 10 x +순하 10 x +순한 10 x +순항 10 x +순항 10 x +순환 10 x +숟가락 10 x +숟가락 10 x +술 10 x +술렁이 10 x +술맛 10 x +술맛 10 x +술병 10 x +술자리 10 x +술잔 10 x +술집 10 x +술판 10 x +숨 10 x +숨겨 10 x +숨겨져 10 x +숨기 10 x +숨긴 10 x +숨졌 10 x +숨졌 10 x +숨지 10 x +숨진 10 x +숨통 10 x +숫자 10 x +숭 10 x +숭배 10 x +숮 10 x +숱한 10 x +숲 10 x +숲 10 x +쉬 10 x +쉬쉬 10 x +쉬우 10 x +쉬운 10 x +쉬워 10 x +쉬워요 10 x +쉬웠 10 x +쉰 10 x +쉰다 10 x +쉴 10 x +쉼 10 x +쉼터 10 x +쉽 10 x +쉽 10 x +쉽사리 10 x +쉿 10 x +쉿 10 x +슈 10 x +슈거 10 x +슈크림 10 x +슈트 10 x +슈퍼 10 x +슈퍼마켓 10 x +슈퍼맨 10 x +슈퍼주니어 10 x +슐 10 x +슐츠 10 x +슛 10 x +스 10 x +스기야마 10 x +스낵 10 x +스노 10 x +스눕 10 x +스님 10 x +스러우 10 x +스러운 10 x +스러워 10 x +스러웠 10 x +스러웠 10 x +스런 10 x +스럽 10 x +스럽 10 x +스로틀 10 x +스르르 10 x +스리 10 x +스리랑카 10 x +스릴 10 x +스마트 10 x +스마트폰 10 x +스며 10 x +스며드 10 x +스무 10 x +스물 10 x +스물세 10 x +스미토모 10 x +스스로 10 x +스승 10 x +스웨덴 10 x +스웨터 10 x +스위스 10 x +스위치 10 x +스윙 10 x +스지 10 x +스쳐 10 x +스치 10 x +스카 10 x +스카우트 10 x +스카프 10 x +스캔들 10 x +스커드 10 x +스커트 10 x +스케일 10 x +스케줄 10 x +스케치 10 x +스코트 10 x +스코틀랜드 10 x +스쿠버 10 x +스쿨 10 x +스크린 10 x +스키 10 x +스키장 10 x +스킨 10 x +스타 10 x +스타일 10 x +스타킹 10 x +스타토일 10 x +스타트 10 x +스탈 10 x +스탑 10 x +스태그플레이션 10 x +스탠드바 10 x +스탠딩 10 x +스탠포드 10 x +스터디 10 x +스터디 10 x +스테이지 10 x +스테이크 10 x +스텍 10 x +스텍 10 x +스텔라 10 x +스텔스 10 x +스토리 10 x +스토리텔링 10 x +스토어 10 x +스톤 10 x +스튜디오 10 x +스튜디오 10 x +스튜디오 10 x +스튜디오 10 x +스트레스 10 x +스트레칭 10 x +스트림 10 x +스티븐 10 x +스티커 10 x +스티커 10 x +스틱 10 x +스파 10 x +스파크 10 x +스퍼드 10 x +스펀지 10 x +스페셜 10 x +스페인 10 x +스페인어 10 x +스포츠 10 x +스포츠맨 10 x +스포츠카 10 x +스푼 10 x +스프래틀리 10 x +스프링필드 10 x +스피드 10 x +스피리도노프 10 x +슥 10 x +슥 10 x +슨 10 x +슬 10 x +슬그머니 10 x +슬기 10 x +슬라보니아 10 x +슬라브 10 x +슬라브주의 10 x +슬라이드 10 x +슬로건 10 x +슬로베니아 10 x +슬리퍼 10 x +슬슬 10 x +슬쩍 10 x +슬쩍 10 x +슬퍼 10 x +슬퍼하 10 x +슬프 10 x +슬픈 10 x +슬플 10 x +슬픔 10 x +습 10 x +습격 10 x +습관 10 x +습관 10 x +습기 10 x +습기 10 x +습니까 10 x +습니다 10 x +습니당 10 x +습도 10 x +습득 10 x +습득 10 x +습득 10 x +습자 10 x +습자 10 x +습하 10 x +승 10 x +승강장 10 x +승객 10 x +승객 10 x +승격 10 x +승계 10 x +승낙 10 x +승려 10 x +승리 10 x +승마 10 x +승무원 10 x +승복 10 x +승부 10 x +승부사 10 x +승부수 10 x +승부처 10 x +승수 10 x +승용 10 x +승용차 10 x +승인 10 x +승자 10 x +승진 10 x +승차 10 x +승패 10 x +승하 10 x +승합차 10 x +승희 10 x +시 10 x +시가지 10 x +시각 10 x +시각 10 x +시각 10 x +시각 10 x +시간 10 x +시간대 10 x +시계 10 x +시골 10 x +시공 10 x +시공사 10 x +시국 10 x +시군 10 x +시그 10 x +시금치 10 x +시급 10 x +시급히 10 x +시기 10 x +시기 10 x +시기상조 10 x +시기심 10 x +시끄러운 10 x +시끄러워 10 x +시끄러워서 10 x +시끄럽 10 x +시끄럽 10 x +시나리오 10 x +시내 10 x +시냇물 10 x +시너지 10 x +시늉 10 x +시닝 10 x +시달려 10 x +시달리 10 x +시답잖 10 x +시답잖 10 x +시대 10 x +시덥잖 10 x +시덥잖 10 x +시도 10 x +시디 10 x +시라크 10 x +시래기 10 x +시려 10 x +시력 10 x +시력 10 x +시련 10 x +시리아 10 x +시리즈 10 x +시립 10 x +시립 10 x +시마네 10 x +시마무라 10 x +시멘트 10 x +시몬 10 x +시무룩 10 x +시뮬레이션 10 x +시민 10 x +시민운동 10 x +시발 10 x +시발 10 x +시베리아 10 x +시부모 10 x +시비 10 x +시사 10 x +시사저널 10 x +시사점 10 x +시사회 10 x +시선 10 x +시설 10 x +시설물 10 x +시세 10 x +시속 10 x +시속 10 x +시술 10 x +시스템 10 x +시시 10 x +시시각각 10 x +시시각각 10 x +시식 10 x +시신 10 x +시신기증 10 x +시아 10 x +시아버지 10 x +시안 10 x +시야 10 x +시어머니 10 x +시에 10 x +시엠 10 x +시오 10 x +시오노 10 x +시외버스 10 x +시요 10 x +시원 10 x +시월 10 x +시위 10 x +시위대 10 x +시음 10 x +시읏 10 x +시읏 10 x +시인 10 x +시인 10 x +시일 10 x +시작 10 x +시작 10 x +시작 10 x +시작 10 x +시작점 10 x +시작점 10 x +시장 10 x +시장 10 x +시절 10 x +시점 10 x +시정 10 x +시정책 10 x +시제품 10 x +시종일관 10 x +시중 10 x +시즌 10 x +시집 10 x +시집가 10 x +시집갈 10 x +시집갈 10 x +시찰 10 x +시책 10 x +시책 10 x +시청 10 x +시청료 10 x +시청자 10 x +시체 10 x +시초 10 x +시카고 10 x +시켜 10 x +시켜서 10 x +시켜야 10 x +시켰 10 x +시켰 10 x +시켰으며 10 x +시크릿 10 x +시키 10 x +시킨 10 x +시킨다 10 x +시킨다는 10 x +시킬 10 x +시킬지 10 x +시킴 10 x +시트 10 x +시판 10 x +시프트 10 x +시한부 10 x +시합 10 x +시행 10 x +시행령 10 x +시행착오 10 x +시험 10 x +시험관 10 x +시험대 10 x +시험지 10 x +시혜 10 x +시화 10 x +시화호 10 x +시효 10 x +시흥 10 x +식 10 x +식 10 x +식견 10 x +식구 10 x +식기 10 x +식당 10 x +식당 10 x +식량 10 x +식량난 10 x +식료품 10 x +식물 10 x +식민 10 x +식민주의자 10 x +식민지 10 x +식별 10 x +식사 10 x +식상 10 x +식성 10 x +식수 10 x +식수난 10 x +식신 10 x +식욕 10 x +식용 10 x +식용유 10 x +식중독 10 x +식중독 10 x +식탁 10 x +식탁 10 x +식품 10 x +식품 10 x +식히 10 x +신 10 x +신 10 x +신가요 10 x +신격호 10 x +신경 10 x +신경외과 10 x +신경질 10 x +신경통 10 x +신경화 10 x +신고 10 x +신곡 10 x +신교 10 x +신교도 10 x +신규 10 x +신기 10 x +신기록 10 x +신기록 10 x +신기수 10 x +신나 10 x +신년 10 x +신념 10 x +신논현역 10 x +신논현역 10 x +신다 10 x +신다고 10 x +신다면 10 x +신당 10 x +신동빈 10 x +신동욱 10 x +신동인 10 x +신동주 10 x +신라 10 x +신라면 10 x +신랄 10 x +신랑 10 x +신뢰 10 x +신림동 10 x +신망 10 x +신명 10 x +신문 10 x +신문사 10 x +신문지 10 x +신민 10 x +신민아 10 x +신박 10 x +신박 10 x +신발 10 x +신변 10 x +신병 10 x +신부 10 x +신분 10 x +신분증 10 x +신빙 10 x +신사 10 x +신사동 10 x +신상 10 x +신상진 10 x +신상품 10 x +신생 10 x +신생아 10 x +신선 10 x +신선호 10 x +신설 10 x +신성 10 x +신세 10 x +신속 10 x +신스케 10 x +신앙 10 x +신앙심 10 x +신업태 10 x +신연식 10 x +신영자 10 x +신용 10 x +신용카드 10 x +신원 10 x +신윤복 10 x +신인 10 x +신임 10 x +신입 10 x +신입 10 x +신자 10 x +신장 10 x +신장 10 x +신정 10 x +신조 10 x +신종 10 x +신종균 10 x +신중 10 x +신중히 10 x +신지 10 x +신진 10 x +신차 10 x +신창현 10 x +신천 10 x +신천지 10 x +신철 10 x +신청 10 x +신청서 10 x +신체 10 x +신촌 10 x +신축 10 x +신카이 10 x +신탁 10 x +신탁 10 x +신통 10 x +신페인 10 x +신페인당 10 x +신풍 10 x +신학 10 x +신학 10 x +신학 10 x +신학자 10 x +신학자 10 x +신한 10 x +신한 10 x +신한은행 10 x +신한은행 10 x +신협 10 x +신협 10 x +신협 10 x +신호 10 x +신호등 10 x +신호탄 10 x +신혼 10 x +신화 10 x +신화폐 10 x +신흥 10 x +싣 10 x +싣 10 x +실 10 x +실 10 x +실감 10 x +실과 10 x +실권 10 x +실까 10 x +실까요 10 x +실내 10 x +실눈 10 x +실라 10 x +실랑이 10 x +실래요 10 x +실려 10 x +실려가 10 x +실력 10 x +실력 10 x +실력자 10 x +실례 10 x +실로 10 x +실루엣 10 x +실리 10 x +실리콘밸리 10 x +실린 10 x +실린다 10 x +실링 10 x +실마리 10 x +실망 10 x +실명 10 x +실무 10 x +실무자 10 x +실물 10 x +실사 10 x +실상 10 x +실생활 10 x +실세 10 x +실소 10 x +실수 10 x +실습 10 x +실습 10 x +실시 10 x +실시간 10 x +실업 10 x +실업 10 x +실업자 10 x +실에 10 x +실용 10 x +실용주의 10 x +실은 10 x +실장 10 x +실재 10 x +실적 10 x +실적 10 x +실전 10 x +실정 10 x +실제 10 x +실제로 10 x +실존 10 x +실종 10 x +실종자 10 x +실증 10 x +실지 10 x +실직 10 x +실질 10 x +실천 10 x +실체 10 x +실추 10 x +실측 10 x +실컷 10 x +실컷 10 x +실타래 10 x +실탄 10 x +실태 10 x +실토 10 x +실패 10 x +실핏줄 10 x +실행 10 x +실험 10 x +실현 10 x +실형 10 x +실화 10 x +실효 10 x +싫 10 x +싫어하 10 x +싫어한다 10 x +싫증 10 x +심 10 x +심각 10 x +심각 10 x +심경 10 x +심근 10 x +심기 10 x +심리 10 x +심리전 10 x +심리학 10 x +심리학자 10 x +심문 10 x +심부 10 x +심부름 10 x +심사 10 x +심사위 10 x +심상 10 x +심신 10 x +심심 10 x +심심찮 10 x +심심타파 10 x +심야 10 x +심오 10 x +심의 10 x +심이 10 x +심장 10 x +심장병 10 x +심장사 10 x +심재덕 10 x +심정 10 x +심지 10 x +심지어 10 x +심층 10 x +심판 10 x +심폐 10 x +심포지엄 10 x +심하 10 x +심한 10 x +심합니다 10 x +심해 10 x +심해서 10 x +심해요 10 x +심해져 10 x +심해졌 10 x +심해졌 10 x +심했 10 x +심했 10 x +심혈 10 x +심화 10 x +심히 10 x +십 10 x +십 10 x +십니까 10 x +십니다 10 x +십상 10 x +십쇼 10 x +십시오 10 x +십시요 10 x +십이월 10 x +십일월 10 x +싯 10 x +싯 10 x +싱 10 x +싱가포르 10 x +싱거워 10 x +싱글 10 x +싱글 10 x +싱싱 10 x +싱크대 10 x +싶 10 x +싶 10 x +싸 10 x +싸구려 10 x +싸늘 10 x +싸락눈 10 x +싸우 10 x +싸운 10 x +싸울 10 x +싸움 10 x +싸움터 10 x +싸워 10 x +싸웠 10 x +싸잡 10 x +싸잡 10 x +싹 10 x +싹 10 x +싼 10 x +싼타페 10 x +쌀 10 x +쌀값 10 x +쌀쌀 10 x +쌀알 10 x +쌌 10 x +쌍 10 x +쌍둥이 10 x +쌍무 10 x +쌍방 10 x +쌍방향 10 x +쌍용 10 x +쌓 10 x +쌓 10 x +쌓여 10 x +쌓였 10 x +쌓이 10 x +쌓인 10 x +쌤 10 x +써 10 x +써도 10 x +써라 10 x +써서 10 x +써야 10 x +써요 10 x +썩 10 x +썬 10 x +썰 10 x +썰렁 10 x +썼 10 x +썼 10 x +쎄 10 x +쎈 10 x +쏘 10 x +쏘나타 10 x +쏘렌토 10 x +쏘였 10 x +쏜 10 x +쏜다 10 x +쏟 10 x +쏟 10 x +쏟아부 10 x +쏟아져 10 x +쏟아졌 10 x +쏟아졌 10 x +쏟아지 10 x +쏟아질 10 x +쏠 10 x +쏠려 10 x +쏠렸 10 x +쏠렸 10 x +쏠리 10 x +쏠린다 10 x +쏠릴 10 x +쐐기 10 x +쐬 10 x +쑤 10 x +쑥 10 x +쑥스럽 10 x +쑥스럽 10 x +쒸 10 x +쓰 10 x +쓰다듬 10 x +쓰래 10 x +쓰러졌 10 x +쓰러지 10 x +쓰러질 10 x +쓰레기 10 x +쓰레기통 10 x +쓰리 10 x +쓰여 10 x +쓰이 10 x +쓰인 10 x +쓰인다 10 x +쓰촨 10 x +쓰키지 10 x +쓴 10 x +쓴다 10 x +쓴다고 10 x +쓴다는 10 x +쓸 10 x +쓸까요 10 x +쓸데없 10 x +쓸데없 10 x +쓸데없이 10 x +쓸모 10 x +쓸쓸 10 x +씁니다 10 x +씁쓸 10 x +씌 10 x +씌워 10 x +씨 10 x +씨름 10 x +씨방 10 x +씨비 10 x +씨앗 10 x +씨이 10 x +씨티 10 x +씩 10 x +씩 10 x +씩씩 10 x +씬 10 x +씹 10 x +씻 10 x +씻 10 x +아 10 x +아가 10 x +아가씨 10 x +아계 10 x +아고 10 x +아고라 10 x +아군 10 x +아그레망 10 x +아그리콜라 10 x +아기 10 x +아까 10 x +아까운 10 x +아까울 10 x +아까워 10 x +아까워서 10 x +아깝 10 x +아깝 10 x +아껴 10 x +아끼 10 x +아낄 10 x +아나 10 x +아나운서 10 x +아날로그 10 x +아내 10 x +아냐 10 x +아노 10 x +아뇨 10 x +아니 10 x +아니면 10 x +아니면은 10 x +아니야 10 x +아니요 10 x +아니하 10 x +아닌 10 x +아닌가 10 x +아닌가요 10 x +아닌데 10 x +아닌데요 10 x +아닌지 10 x +아닐 10 x +아닐까 10 x +아닐지라도 10 x +아님 10 x +아닙니까 10 x +아닙니다 10 x +아담 10 x +아당 10 x +아도 10 x +아동 10 x +아드 10 x +아득 10 x +아득히 10 x +아들 10 x +아디 10 x +아디 10 x +아따 10 x +아라 10 x +아라비아 10 x +아람 10 x +아랍 10 x +아랍 10 x +아랍에미리트 10 x +아랍제국 10 x +아랏 10 x +아래 10 x +아래쪽 10 x +아래층 10 x +아랫목 10 x +아랫목 10 x +아랫입술 10 x +아련 10 x +아르 10 x +아르바이트 10 x +아르헨티나 10 x +아르헨티나 10 x +아름 10 x +아름다운 10 x +아름다울 10 x +아름다움 10 x +아름다워요 10 x +아름다웠 10 x +아름답 10 x +아름답 10 x +아리마 10 x +아마 10 x +아마겟돈 10 x +아마도 10 x +아마존 10 x +아마추어 10 x +아메리카 10 x +아메리칸 10 x +아메바 10 x +아모레 10 x +아무 10 x +아무개 10 x +아무거 10 x +아무것 10 x +아무것 10 x +아무데 10 x +아무래도 10 x +아무런 10 x +아무렇 10 x +아무리 10 x +아무튼 10 x +아바 10 x +아버님 10 x +아버지 10 x +아베 10 x +아벨 10 x +아부 10 x +아부다비 10 x +아빠 10 x +아산 10 x +아서 10 x +아서요 10 x +아성 10 x +아세요 10 x +아쉬운 10 x +아쉬움 10 x +아쉬워하 10 x +아쉬웠 10 x +아쉬웠 10 x +아쉽 10 x +아쉽 10 x +아스 10 x +아스팔트 10 x +아스페르거 10 x +아시 10 x +아시나 10 x +아시아 10 x +아시아나항공 10 x +아시안 10 x +아실 10 x +아십니까 10 x +아싸 10 x +아씨 10 x +아아 10 x +아앙 10 x +아야 10 x +아야지 10 x +아역 10 x +아역 10 x +아영 10 x +아예 10 x +아오 10 x +아요 10 x +아용 10 x +아우 10 x +아우디 10 x +아우라 10 x +아우슈비츠 10 x +아울러 10 x +아웃 10 x +아웃 10 x +아웃렛 10 x +아웃렛 10 x +아웅산 10 x +아유 10 x +아의 10 x +아이 10 x +아이고 10 x +아이구 10 x +아이돌 10 x +아이디 10 x +아이디어 10 x +아이러니 10 x +아이비리그 10 x +아이스링크 10 x +아이스크림 10 x +아이엘오 10 x +아이오 10 x +아이젠 10 x +아이콘 10 x +아이템 10 x +아이패드 10 x +아이폰 10 x +아이핀 10 x +아일랜드 10 x +아자 10 x +아잠 10 x +아저씨 10 x +아젠다 10 x +아주 10 x +아주머니 10 x +아줌마 10 x +아직 10 x +아직 10 x +아직껏 10 x +아차 10 x +아치 10 x +아침 10 x +아카데미 10 x +아카마 10 x +아키 10 x +아키노 10 x +아키에 10 x +아태 10 x +아태재단 10 x +아태평화재단 10 x +아트 10 x +아티 10 x +아파 10 x +아파서 10 x +아파요 10 x +아파트 10 x +아팠 10 x +아프 10 x +아프가니스탄 10 x +아프리카 10 x +아픈 10 x +아플 10 x +아픔 10 x +아하 10 x +아홉 10 x +아홉 10 x +아휴 10 x +아흔 10 x +악 10 x +악 10 x +악기 10 x +악단 10 x +악도 10 x +악독 10 x +악독 10 x +악마 10 x +악명 10 x +악몽 10 x +악성 10 x +악수 10 x +악순환 10 x +악어 10 x +악영향 10 x +악용 10 x +악재 10 x +악취 10 x +악화 10 x +안 10 x +안간힘 10 x +안간힘 10 x +안개 10 x +안건 10 x +안겨 10 x +안겨준 10 x +안겼 10 x +안경 10 x +안과 10 x +안기 10 x +안기부 10 x +안내 10 x +안내문 10 x +안내인 10 x +안녕 10 x +안녕히 10 x +안다 10 x +안다고 10 x +안덕면 10 x +안데스 10 x +안동일 10 x +안드레 10 x +안드레아 10 x +안드레아스 10 x +안락 10 x +안락사 10 x +안명 10 x +안목 10 x +안무 10 x +안민석 10 x +안민석 10 x +안방 10 x +안배 10 x +안보 10 x +안보투쟁 10 x +안봉근 10 x +안부 10 x +안산 10 x +안산시장 10 x +안식처 10 x +안심 10 x +안쓰러워 10 x +안쓰럽 10 x +안양 10 x +안위 10 x +안일 10 x +안장 10 x +안전 10 x +안전교육 10 x +안전교육 10 x +안전도 10 x +안전띠 10 x +안전벨트 10 x +안전성 10 x +안전장치 10 x +안전판 10 x +안정 10 x +안정세 10 x +안종범 10 x +안주 10 x +안중근 10 x +안쪽 10 x +안쪽 10 x +안착 10 x +안철수 10 x +안타 10 x +안타까운 10 x +안타까워 10 x +안타까워했 10 x +안타까워했 10 x +안타까웠 10 x +안타까웠 10 x +안타깝 10 x +안타깝 10 x +안토니 10 x +안티 10 x +안티 10 x +안팎 10 x +안희정 10 x +안희정 10 x +앉 10 x +앉힌 10 x +앉힌 10 x +않 10 x +알 10 x +알래스카 10 x +알랭 10 x +알레 10 x +알레르기 10 x +알렉상드르 10 x +알려 10 x +알려야 10 x +알려져 10 x +알려졌 10 x +알려졌 10 x +알려준 10 x +알려줘 10 x +알려줘야 10 x +알려진 10 x +알렸 10 x +알론 10 x +알루미늄 10 x +알리 10 x +알릴 10 x +알림 10 x +알림판 10 x +알맞 10 x +알맞 10 x +알맹이 10 x +알바니아어 10 x +알바로 10 x +알버트 10 x +알베르 10 x +알사탕 10 x +알아낸 10 x +알아냈 10 x +알아냈 10 x +알아듣 10 x +알아듣 10 x +알아보 10 x +알아볼 10 x +알아주 10 x +알약 10 x +알지 10 x +알코올 10 x +알티 10 x +알파 10 x +알파카 10 x +알폰신 10 x +알프스 10 x +앓 10 x +암 10 x +암매장 10 x +암벽 10 x +암소 10 x +암술머리 10 x +암시 10 x +암요 10 x +암울 10 x +암웨이 10 x +암점 10 x +암초 10 x +암컷 10 x +암컷 10 x +암튼 10 x +암호 10 x +암흑 10 x +암흑 10 x +압 10 x +압 10 x +압도 10 x +압둘라 10 x +압력 10 x +압력 10 x +압류 10 x +압박 10 x +압박 10 x +압수 10 x +압수수색 10 x +앗 10 x +앗 10 x +았 10 x +았 10 x +았었 10 x +앙 10 x +앙금 10 x +앙상 10 x +앙수 10 x +앙주 10 x +앙헬레스 10 x +앞 10 x +앞 10 x +앞길 10 x +앞날 10 x +앞당겨졌 10 x +앞당기 10 x +앞두 10 x +앞두 10 x +앞둔 10 x +앞둔 10 x +앞뒤 10 x +앞머리 10 x +앞부분 10 x +앞서 10 x +앞선 10 x +앞섰 10 x +앞세우 10 x +앞세운 10 x +앞세워 10 x +앞자리 10 x +앞자리 10 x +앞잡이 10 x +앞장서 10 x +앞장섰 10 x +앞질러 10 x +앞질렀 10 x +앞질렀 10 x +앞질렀 10 x +앞쪽 10 x +앞쪽 10 x +애 10 x +애경 10 x +애교 10 x +애국 10 x +애국가 10 x +애국심 10 x +애국전선 10 x +애기 10 x +애꿎 10 x +애니메이션 10 x +애매 10 x +애벌레 10 x +애사심 10 x +애써 10 x +애썼 10 x +애썼 10 x +애쓰 10 x +애씀 10 x +애완 10 x +애요 10 x +애인 10 x +애잔 10 x +애절 10 x +애정 10 x +애처로울 10 x +애초 10 x +애태우 10 x +애틀랜타 10 x +애플 10 x +애호가 10 x +애환 10 x +액 10 x +액 10 x +액면 10 x +액면가 10 x +액세서리 10 x +액션 10 x +액수 10 x +액자 10 x +액제 10 x +액제 10 x +액체 10 x +액체 10 x +액화천연가스 10 x +앤 10 x +앤더슨 10 x +앤서니 10 x +앨범 10 x +앱 10 x +앴 10 x +앵글 10 x +앵커 10 x +야 10 x +야간 10 x +야겠다 10 x +야경 10 x +야경국가 10 x +야구 10 x +야구장 10 x +야권 10 x +야근 10 x +야기 10 x +야단 10 x +야단법석 10 x +야당 10 x +야대 10 x +야마구치 10 x +야무지 10 x +야밤 10 x +야산 10 x +야생 10 x +야성 10 x +야스쿠니신사 10 x +야심 10 x +야영 10 x +야외 10 x +야윈 10 x +야인 10 x +야자유 10 x +야전 10 x +야지 10 x +야채 10 x +야쿠르트 10 x +야한 10 x +야후 10 x +약 10 x +약 10 x +약간 10 x +약관 10 x +약국 10 x +약국 10 x +약대 10 x +약물 10 x +약사 10 x +약서 10 x +약세 10 x +약속 10 x +약속 10 x +약식 10 x +약식 10 x +약자 10 x +약점 10 x +약진 10 x +약초 10 x +약탈 10 x +약품 10 x +약하 10 x +약한 10 x +약해 10 x +약해서 10 x +약해요 10 x +약혼 10 x +약화 10 x +얄 10 x +얄미워 10 x +얇 10 x +얌 10 x +얌전 10 x +양 10 x +양고기 10 x +양국 10 x +양국 10 x +양궁 10 x +양극 10 x +양념 10 x +양념치킨 10 x +양다리 10 x +양당 10 x +양대 10 x +양도 10 x +양력 10 x +양말 10 x +양면 10 x +양모 10 x +양반 10 x +양보 10 x +양복 10 x +양부 10 x +양부남 10 x +양산 10 x +양상 10 x +양성 10 x +양손 10 x +양식 10 x +양식 10 x +양심 10 x +양씨 10 x +양아치 10 x +양양 10 x +양육 10 x +양육권 10 x +양자 10 x +양주 10 x +양주시 10 x +양지 10 x +양질 10 x +양쪽 10 x +양쪽 10 x +양천구 10 x +양측 10 x +양측 10 x +양치 10 x +양치질 10 x +양파 10 x +양판점 10 x +양팔 10 x +양해 10 x +양형 10 x +양호 10 x +얕 10 x +얘 10 x +얘기 10 x +얘긴지 10 x +얘길 10 x +어 10 x +어겼 10 x +어겼 10 x +어긋나 10 x +어긋난다 10 x +어긋난다는 10 x +어기 10 x +어길 10 x +어김없이 10 x +어깨 10 x +어눌 10 x +어느 10 x +어느덧 10 x +어느덧 10 x +어느새 10 x +어도 10 x +어두운 10 x +어두워 10 x +어두워서 10 x +어두워졌 10 x +어둠 10 x +어둡 10 x +어디 10 x +어디 10 x +어딘가 10 x +어딘가 10 x +어딘지 10 x +어딘지 10 x +어딜 10 x +어딜 10 x +어딨 10 x +어딨 10 x +어딨나 10 x +어딨나 10 x +어때 10 x +어때요 10 x +어땠 10 x +어땠 10 x +어땠어 10 x +어떠 10 x +어떠세요 10 x +어떡 10 x +어떡 10 x +어떤 10 x +어떤가 10 x +어떤지 10 x +어떨까 10 x +어떻 10 x +어떻 10 x +어떻게 10 x +어떻든 10 x +어뗘 10 x +어라 10 x +어려 10 x +어려서 10 x +어려요 10 x +어려우 10 x +어려운 10 x +어려운지 10 x +어려울 10 x +어려움 10 x +어려워 10 x +어려워야 10 x +어려워졌 10 x +어려워진 10 x +어려워질 10 x +어려웠 10 x +어려웠 10 x +어려웠어요 10 x +어렵 10 x +어렵 10 x +어렸 10 x +어록 10 x +어뢰 10 x +어르신 10 x +어른 10 x +어리 10 x +어리둥절 10 x +어리석 10 x +어리석 10 x +어린 10 x +어린아이 10 x +어린애 10 x +어린이 10 x +어린이집 10 x +어린이집 10 x +어릴 10 x +어릴수록 10 x +어릴수록 10 x +어머 10 x +어머니 10 x +어머님 10 x +어묵 10 x +어문학 10 x +어문학 10 x +어문학 10 x +어미 10 x +어미 10 x +어민 10 x +어바인 10 x +어버이 10 x +어부 10 x +어부지리 10 x +어색 10 x +어서 10 x +어선 10 x +어야 10 x +어야죠 10 x +어야지 10 x +어업 10 x +어여 10 x +어요 10 x +어용 10 x +어우 10 x +어울려 10 x +어울리 10 x +어울릴 10 x +어유 10 x +어음 10 x +어이 10 x +어이없 10 x +어이없 10 x +어정어정 10 x +어정쩡 10 x +어제 10 x +어젯밤 10 x +어젯밤 10 x +어지 10 x +어지러워 10 x +어지럼증 10 x +어지럽 10 x +어짜피 10 x +어째서 10 x +어쨌 10 x +어쨌거나 10 x +어쨌든 10 x +어쩌 10 x +어쩌다 10 x +어쩌다가 10 x +어쩌면 10 x +어쩔 10 x +어쩜 10 x +어찌 10 x +어찌나 10 x +어차피 10 x +어처구니 10 x +어처구니없 10 x +어촌 10 x +어치 10 x +어필 10 x +어학 10 x +어회 10 x +어획량 10 x +억 10 x +억 10 x +억류 10 x +억수 10 x +억압 10 x +억울 10 x +억제 10 x +억지 10 x +억지 10 x +억지로 10 x +언 10 x +언급 10 x +언급 10 x +언니 10 x +언덕 10 x +언덕 10 x +언뜻 10 x +언뜻 10 x +언론 10 x +언론사 10 x +언론인 10 x +언성 10 x +언어 10 x +언어폭력 10 x +언어폭력 10 x +언쟁 10 x +언제 10 x +언제나 10 x +언젠 10 x +언젠가 10 x +언중 10 x +얹 10 x +얻 10 x +얻 10 x +얻어먹 10 x +얼 10 x +얼 10 x +얼굴 10 x +얼룩 10 x +얼룩진 10 x +얼룩진 10 x +얼른 10 x +얼리 10 x +얼마 10 x +얼마간 10 x +얼마나 10 x +얼스터 10 x +얼어붙 10 x +얼어붙 10 x +얼음 10 x +얼핏 10 x +얼핏 10 x +얽매이 10 x +얽혀 10 x +얽히 10 x +얽힌 10 x +엄 10 x +엄격 10 x +엄격 10 x +엄격히 10 x +엄단 10 x +엄두 10 x +엄마 10 x +엄밀 10 x +엄살 10 x +엄숙 10 x +엄연히 10 x +엄연히 10 x +엄정 10 x +엄중 10 x +엄중히 10 x +엄청 10 x +엄청나 10 x +엄청난 10 x +엄포 10 x +엄한 10 x +업 10 x +업 10 x +업간 10 x +업계 10 x +업계 10 x +업과 10 x +업그레이드 10 x +업데이트 10 x +업로드 10 x +업무 10 x +업소 10 x +업자 10 x +업자 10 x +업적 10 x +업종 10 x +업종 10 x +업주 10 x +업체 10 x +업체 10 x +없 10 x +없 10 x +없애 10 x +없앨 10 x +없이 10 x +엇 10 x +엇갈려 10 x +엇갈려 10 x +엇갈리 10 x +엇갈리 10 x +엇갈린 10 x +엇갈린 10 x +었 10 x +었 10 x +었었 10 x +었었 10 x +엉 10 x +엉덩이 10 x +엉뚱 10 x +엉망 10 x +엉성 10 x +엉엉 10 x +엉켜 10 x +엉터리 10 x +엎 10 x +엎드려 10 x +엎드려서 10 x +엎드렸 10 x +엎드렸 10 x +엎드렸 10 x +엎어지 10 x +엎질러진 10 x +에 10 x +에게 10 x +에게서 10 x +에겐 10 x +에고 10 x +에구 10 x +에너지 10 x +에다 10 x +에다가 10 x +에두아르도 10 x +에둘러 10 x +에디 10 x +에라 10 x +에로 10 x +에르 10 x +에르네스토 10 x +에르바 10 x +에릭 10 x +에미 10 x +에밀리 10 x +에베레스트 10 x +에서 10 x +에서부터 10 x +에선 10 x +에스 10 x +에스에스 10 x +에스에프 10 x +에스토니아 10 x +에스프레소 10 x +에어 10 x +에어컨 10 x +에요 10 x +에워싸 10 x +에이 10 x +에이비 10 x +에이스 10 x +에이즈 10 x +에이치 10 x +에코 10 x +에콜 10 x +에쿠스 10 x +에티오피아 10 x +에티오피아 10 x +에프 10 x +에핑 10 x +에휴 10 x +엑 10 x +엑셀 10 x +엑스 10 x +엑스레이 10 x +엔 10 x +엔가 10 x +엔딩 10 x +엔지니어 10 x +엔지니어링 10 x +엔진 10 x +엘 10 x +엘니뇨 10 x +엘라 10 x +엘란 10 x +엘란트라 10 x +엘리베이터 10 x +엘리자베스 10 x +엘리트 10 x +엘시티 10 x +엘시티 10 x +엘에이 10 x +엘지 10 x +엠 10 x +엠마 10 x +엠블랙 10 x +엠알 10 x +엡스타인 10 x +엣지 10 x +엥 10 x +여 10 x +여가 10 x +여간 10 x +여객기 10 x +여객선 10 x +여건 10 x +여겨 10 x +여겨온 10 x +여겨왔 10 x +여겨진 10 x +여고 10 x +여공 10 x +여관 10 x +여권 10 x +여기 10 x +여기저기 10 x +여긴 10 x +여념 10 x +여느 10 x +여담 10 x +여당 10 x +여대 10 x +여대생 10 x +여덜 10 x +여덟 10 x +여도 10 x +여동생 10 x +여드름 10 x +여든 10 x +여라 10 x +여러 10 x +여러분 10 x +여럿 10 x +여럿 10 x +여력 10 x +여론 10 x +여름 10 x +여름날 10 x +여름철 10 x +여름휴가 10 x +여배우 10 x +여보 10 x +여보세요 10 x +여부 10 x +여분 10 x +여사 10 x +여서 10 x +여섯 10 x +여섯 10 x +여성 10 x +여세 10 x +여소 10 x +여수 10 x +여신 10 x +여야 10 x +여어 10 x +여왕 10 x +여우 10 x +여유 10 x +여의도 10 x +여인 10 x +여인숙 10 x +여자 10 x +여전 10 x +여전히 10 x +여전히 10 x +여주 10 x +여주인 10 x +여주인공 10 x +여지 10 x +여직 10 x +여질 10 x +여쭤 10 x +여차 10 x +여천 10 x +여초 10 x +여타 10 x +여태 10 x +여태껏 10 x +여태껏 10 x +여파 10 x +여하 10 x +여학생 10 x +여행 10 x +여행객 10 x +여행사 10 x +여행자 10 x +여행지 10 x +여호 10 x +여호와 10 x +역 10 x +역 10 x +역겹 10 x +역경 10 x +역관 10 x +역기 10 x +역대 10 x +역동 10 x +역량 10 x +역력 10 x +역명 10 x +역부족 10 x +역사 10 x +역사관 10 x +역설 10 x +역세 10 x +역습 10 x +역시 10 x +역임 10 x +역전 10 x +역전승 10 x +역점 10 x +역정 10 x +역제 10 x +역주행 10 x +역풍 10 x +역학 10 x +역할 10 x +역행 10 x +역효과 10 x +엮 10 x +연 10 x +연간 10 x +연결 10 x +연결망 10 x +연계 10 x +연고 10 x +연골 10 x +연공서열 10 x +연관 10 x +연구 10 x +연구관 10 x +연구생 10 x +연구소 10 x +연구원 10 x +연구자 10 x +연구회 10 x +연극 10 x +연극 10 x +연금 10 x +연기 10 x +연기 10 x +연기력 10 x +연기력 10 x +연기자 10 x +연길 10 x +연내 10 x +연단 10 x +연대 10 x +연도 10 x +연동 10 x +연락 10 x +연락 10 x +연락처 10 x +연락처 10 x +연락회 10 x +연령 10 x +연령층 10 x +연례 10 x +연료 10 x +연료비 10 x +연루 10 x +연륜 10 x +연립 10 x +연립 10 x +연마 10 x +연말 10 x +연맹 10 x +연면적 10 x +연면적 10 x +연명 10 x +연못 10 x +연방 10 x +연변 10 x +연봉 10 x +연봉제 10 x +연사 10 x +연산수 10 x +연상 10 x +연설 10 x +연설문 10 x +연설자 10 x +연설회 10 x +연세 10 x +연세대 10 x +연소 10 x +연속 10 x +연속 10 x +연쇄 10 x +연수 10 x +연수생 10 x +연수원 10 x +연습 10 x +연습 10 x +연안 10 x +연애 10 x +연연 10 x +연예계 10 x +연예인 10 x +연원 10 x +연유 10 x +연이 10 x +연인 10 x +연일 10 x +연장 10 x +연장선 10 x +연정 10 x +연주 10 x +연주자 10 x +연준 10 x +연중 10 x +연차 10 x +연착륙 10 x +연찬회 10 x +연체 10 x +연초 10 x +연출 10 x +연탄 10 x +연평 10 x +연평균 10 x +연필 10 x +연하 10 x +연하 10 x +연한 10 x +연한 10 x +연합 10 x +연합 10 x +연합 10 x +연합 10 x +연합뉴스 10 x +연합뉴스 10 x +연합체 10 x +연합회 10 x +연합회 10 x +연해 10 x +연해 10 x +연해주 10 x +연해주 10 x +연행 10 x +연행 10 x +연호 10 x +연회비 10 x +연휴 10 x +연휴 10 x +열 10 x +열광 10 x +열기 10 x +열대림 10 x +열도 10 x +열등 10 x +열등감 10 x +열띤 10 x +열려 10 x +열려요 10 x +열렸 10 x +열렸 10 x +열리 10 x +열린 10 x +열린다 10 x +열릴 10 x +열릴까 10 x +열립니다 10 x +열망 10 x +열매 10 x +열배 10 x +열병식 10 x +열부 10 x +열사 10 x +열성 10 x +열쇠 10 x +열심히 10 x +열악 10 x +열역학 10 x +열역학 10 x +열연 10 x +열일 10 x +열정 10 x +열중 10 x +열차 10 x +열풍 10 x +열흘 10 x +엷 10 x +염 10 x +염과 10 x +염동열 10 x +염두 10 x +염려 10 x +염색 10 x +염소 10 x +염원 10 x +염좌 10 x +엽 10 x +엽기 10 x +엽기 10 x +엽서 10 x +엿 10 x +엿 10 x +엿 10 x +엿 10 x +엿보 10 x +엿볼 10 x +엿볼 10 x +였 10 x +였 10 x +였으나 10 x +였으며 10 x +였을 10 x +였잖 10 x +영 10 x +영감 10 x +영결식 10 x +영공 10 x +영과 10 x +영광 10 x +영구 10 x +영국 10 x +영국 10 x +영국인 10 x +영남 10 x +영농 10 x +영도 10 x +영동 10 x +영동고속도로 10 x +영등포 10 x +영등포구 10 x +영락없 10 x +영문 10 x +영미 10 x +영민 10 x +영사관 10 x +영상 10 x +영상미 10 x +영서 10 x +영세 10 x +영수 10 x +영수증 10 x +영숙 10 x +영스 10 x +영신 10 x +영양 10 x +영양사 10 x +영양소 10 x +영어 10 x +영업 10 x +영업 10 x +영업망 10 x +영업부 10 x +영업자 10 x +영업직 10 x +영업직 10 x +영역 10 x +영역 10 x +영연방 10 x +영영 10 x +영욕 10 x +영웅 10 x +영원 10 x +영원히 10 x +영원히 10 x +영위 10 x +영입 10 x +영입 10 x +영장 10 x +영재 10 x +영정 10 x +영종 10 x +영종대교 10 x +영주 10 x +영창 10 x +영천시 10 x +영토 10 x +영하 10 x +영향 10 x +영향력 10 x +영향력 10 x +영호남 10 x +영혼 10 x +영화 10 x +영화계 10 x +영화관 10 x +영화배우 10 x +영화사 10 x +영화제 10 x +영훈 10 x +영희 10 x +옆 10 x +옆 10 x +옆구리 10 x +옆방 10 x +옆집 10 x +옆집 10 x +예 10 x +예 10 x +예감 10 x +예견 10 x +예결 10 x +예고 10 x +예금 10 x +예금보험공사 10 x +예기 10 x +예년 10 x +예능 10 x +예대 10 x +예루살렘 10 x +예리 10 x +예매 10 x +예민 10 x +예방 10 x +예배당 10 x +예보 10 x +예비 10 x +예비군 10 x +예비비 10 x +예비역 10 x +예뻐 10 x +예뻐보여 10 x +예뻐서 10 x +예뻐요 10 x +예쁘 10 x +예쁜 10 x +예산 10 x +예산안 10 x +예상 10 x +예선 10 x +예수 10 x +예순 10 x +예술 10 x +예술가 10 x +예술단 10 x +예술인 10 x +예스 10 x +예습 10 x +예시 10 x +예식장 10 x +예약 10 x +예약 10 x +예언 10 x +예언자 10 x +예외 10 x +예요 10 x +예의 10 x +예전 10 x +예절 10 x +예정 10 x +예정이율 10 x +예정자 10 x +예정지 10 x +예진 10 x +예체능 10 x +예측 10 x +예측 10 x +예치 10 x +예컨대 10 x +예하 10 x +옐친 10 x +옛 10 x +옛 10 x +옛날 10 x +옛적 10 x +오 10 x +오 10 x +오가 10 x +오개 10 x +오공 10 x +오구 10 x +오글거리 10 x +오너 10 x +오년 10 x +오늘 10 x +오늘날 10 x +오다 10 x +오대양 10 x +오두 10 x +오디션 10 x +오디오 10 x +오뚝 10 x +오락 10 x +오락가락 10 x +오래 10 x +오래간만 10 x +오랜 10 x +오랜만 10 x +오랫동안 10 x +오랫만 10 x +오량 10 x +오렌지 10 x +오로지 10 x +오류 10 x +오르 10 x +오르간 10 x +오르내리 10 x +오른 10 x +오른다 10 x +오른손 10 x +오른쪽 10 x +오른쪽 10 x +오른편 10 x +오를 10 x +오름세 10 x +오릅니다 10 x +오리 10 x +오리발 10 x +오리엔트 10 x +오리온 10 x +오리지널 10 x +오릭스 10 x +오마이 10 x +오만 10 x +오만 10 x +오명 10 x +오모 10 x +오바 10 x +오바마 10 x +오버 10 x +오봉 10 x +오븐 10 x +오비 10 x +오빠 10 x +오사카 10 x +오색 10 x +오색 10 x +오성홍기 10 x +오세 10 x +오션 10 x +오소리 10 x +오송읍 10 x +오스터 10 x +오스트레일리아 10 x +오스트리아 10 x +오싹 10 x +오아시스 10 x +오악 10 x +오악사카 10 x +오악사카주 10 x +오염 10 x +오염도 10 x +오예 10 x +오오 10 x +오우 10 x +오월 10 x +오이 10 x +오이씨 10 x +오이카와 10 x +오인 10 x +오일 10 x +오자 10 x +오전 10 x +오존 10 x +오죽 10 x +오준영 10 x +오줌 10 x +오직 10 x +오직 10 x +오징어 10 x +오차 10 x +오찬 10 x +오케스트라 10 x +오케이 10 x +오쿠노 10 x +오크 10 x +오클랜드 10 x +오키 10 x +오타 10 x +오토 10 x +오토바이 10 x +오판 10 x +오페라 10 x +오프 10 x +오픈 10 x +오피스텔 10 x +오해 10 x +오향 10 x +오호 10 x +오호 10 x +오홍준 10 x +오회 10 x +오후 10 x +오히려 10 x +옥 10 x +옥 10 x +옥도 10 x +옥바라지 10 x +옥상 10 x +옥수수 10 x +옥시 10 x +옥시나 10 x +옥천 10 x +옥천 10 x +옥천군 10 x +온 10 x +온 10 x +온갖 10 x +온갖 10 x +온건 10 x +온건파 10 x +온기 10 x +온난 10 x +온난화 10 x +온다 10 x +온다고 10 x +온다는 10 x +온다면 10 x +온대 10 x +온도 10 x +온도계 10 x +온돌 10 x +온두라스 10 x +온라인 10 x +온몸 10 x +온수동 10 x +온실 10 x +온실가스 10 x +온전 10 x +온전히 10 x +온전히 10 x +온종일 10 x +온통 10 x +올 10 x +올게 10 x +올게요 10 x +올까요 10 x +올라 10 x +올라가 10 x +올라간다 10 x +올라갈 10 x +올라감 10 x +올라갔 10 x +올라서 10 x +올라섰 10 x +올라오 10 x +올라온 10 x +올라온다 10 x +올라와 10 x +올라왔 10 x +올라왔 10 x +올라타 10 x +올랐 10 x +올랐 10 x +올래 10 x +올레 10 x +올려 10 x +올려져 10 x +올렸 10 x +올렸 10 x +올리 10 x +올리고당 10 x +올리브유 10 x +올린 10 x +올린다 10 x +올린다는 10 x +올린다면서 10 x +올릴 10 x +올림픽 10 x +올림픽 10 x +올립니다 10 x +올바로 10 x +올바르 10 x +올바른 10 x +올보르 10 x +올브라이트 10 x +올빼미 10 x +올스타 10 x +올시다 10 x +올해 10 x +옮 10 x +옮겨 10 x +옮겨서 10 x +옮겨야 10 x +옮겨와 10 x +옮겨져 10 x +옮겨졌 10 x +옮겨졌 10 x +옮겼 10 x +옮기 10 x +옮긴 10 x +옮길 10 x +옮길까요 10 x +옮아가 10 x +옳 10 x +옴 10 x +옴마 10 x +옴짝달싹 10 x +옴짝달싹 10 x +옵 10 x +옵니다 10 x +옷 10 x +옷 10 x +옷장 10 x +옷차림 10 x +옹 10 x +옹진군 10 x +옹호 10 x +와 10 x +와도 10 x +와라 10 x +와서 10 x +와야 10 x +와요 10 x +와이 10 x +와이드먼 10 x +와이셔츠 10 x +와인 10 x +와중 10 x +와타나베 10 x +와해 10 x +완 10 x +완강 10 x +완공 10 x +완도 10 x +완료 10 x +완만 10 x +완벽 10 x +완벽 10 x +완성 10 x +완승 10 x +완장 10 x +완전 10 x +완전히 10 x +완전히 10 x +완치 10 x +완쾌 10 x +완화 10 x +왈 10 x +왈라스 10 x +왓 10 x +왓 10 x +왓슨 10 x +왔 10 x +왔 10 x +왔어요 10 x +왔었 10 x +왔으나 10 x +왔으며 10 x +왕 10 x +왕관 10 x +왕국 10 x +왕국 10 x +왕권 10 x +왕뚜껑 10 x +왕립 10 x +왕립 10 x +왕복 10 x +왕복 10 x +왕비 10 x +왕성 10 x +왕세자 10 x +왕실 10 x +왕왕 10 x +왕융 10 x +왕자 10 x +왕정 10 x +왕조 10 x +왕좌 10 x +왕창 10 x +왜 10 x +왜곡 10 x +왜곡 10 x +왜냐 10 x +왜냐면 10 x +왜냐면은 10 x +왜냐하면 10 x +왜냐하면은 10 x +왜소 10 x +왠 10 x +왠지 10 x +왤케 10 x +외 10 x +외계인 10 x +외고 10 x +외과 10 x +외곽 10 x +외관 10 x +외교 10 x +외교관 10 x +외교부 10 x +외교사 10 x +외국 10 x +외국 10 x +외국산 10 x +외국어 10 x +외국인 10 x +외도 10 x +외래 10 x +외로울 10 x +외로움 10 x +외로워 10 x +외롭 10 x +외르 10 x +외면 10 x +외모 10 x +외무 10 x +외무부 10 x +외무성 10 x +외부 10 x +외산 10 x +외상 10 x +외세 10 x +외식 10 x +외식 10 x +외신 10 x +외아들 10 x +외압 10 x +외압 10 x +외양 10 x +외양간 10 x +외우 10 x +외워서 10 x +외웠 10 x +외인 10 x +외자 10 x +외제 10 x +외주 10 x +외증조부 10 x +외채 10 x +외쳐 10 x +외쳤 10 x +외출 10 x +외치 10 x +외침 10 x +외투 10 x +외할머니 10 x +외형 10 x +외화 10 x +외환 10 x +외환보유고 10 x +왼 10 x +왼발 10 x +왼손 10 x +왼손잡이 10 x +왼쪽 10 x +왼쪽 10 x +왼편 10 x +요 10 x +요 10 x +요가 10 x +요강 10 x +요건 10 x +요구 10 x +요구르트 10 x +요구사항 10 x +요금 10 x +요금제 10 x +요나단 10 x +요동칠 10 x +요라고 10 x +요란 10 x +요르단 10 x +요르단강 10 x +요리 10 x +요리사 10 x +요번 10 x +요법 10 x +요상한 10 x +요새 10 x +요셉 10 x +요소 10 x +요약 10 x +요양 10 x +요원 10 x +요인 10 x +요일 10 x +요전 10 x +요점 10 x +요정 10 x +요즈음 10 x +요즘 10 x +요지 10 x +요지부동 10 x +요직 10 x +요청 10 x +요청서 10 x +요체 10 x +요충 10 x +요충지 10 x +요컨대 10 x +요코야마 10 x +요코하마 10 x +요크 10 x +요하 10 x +요한 10 x +욕 10 x +욕 10 x +욕구 10 x +욕망 10 x +욕설 10 x +욕실 10 x +욕심 10 x +욜 10 x +욤 10 x +용 10 x +용감 10 x +용객 10 x +용기 10 x +용기 10 x +용납 10 x +용납 10 x +용달 10 x +용도 10 x +용돈 10 x +용량 10 x +용맹 10 x +용모 10 x +용목 10 x +용목 10 x +용변 10 x +용산 10 x +용산구 10 x +용서 10 x +용선료 10 x +용시 10 x +용암 10 x +용어 10 x +용역 10 x +용역 10 x +용연 10 x +용의자 10 x +용이 10 x +용인 10 x +용자 10 x +용적 10 x +용적 10 x +용지 10 x +용품 10 x +우 10 x +우간다 10 x +우경화 10 x +우기 10 x +우남 10 x +우노 10 x +우대 10 x +우두머리 10 x +우두커니 10 x +우드 10 x +우뚝 10 x +우뚝 10 x +우라늄 10 x +우량 10 x +우러러 10 x +우려 10 x +우롱 10 x +우루과이 10 x +우루루 10 x +우르스 10 x +우리 10 x +우리나라 10 x +우린 10 x +우림 10 x +우방 10 x +우벼 10 x +우비 10 x +우산 10 x +우선 10 x +우선시 10 x +우성 10 x +우세 10 x +우셨 10 x +우수 10 x +우스 10 x +우스개 10 x +우스꽝 10 x +우스워 10 x +우습 10 x +우습 10 x +우승 10 x +우승자 10 x +우승컵 10 x +우아 10 x +우어 10 x +우에마쓰 10 x +우여곡절 10 x +우연 10 x +우연히 10 x +우연히 10 x +우와 10 x +우왕 10 x +우왕좌왕 10 x +우울 10 x +우울증 10 x +우울해 10 x +우월주의 10 x +우위 10 x +우유 10 x +우유니 10 x +우육 10 x +우익 10 x +우잣 10 x +우잣 10 x +우정 10 x +우정 10 x +우주 10 x +우주비행사 10 x +우주선 10 x +우즈베키스탄 10 x +우진 10 x +우쭐대 10 x +우체국 10 x +우체국 10 x +우체부 10 x +우체통 10 x +우크라 10 x +우파 10 x +우편 10 x +우편물 10 x +우표 10 x +우현 10 x +우호 10 x +우회 10 x +우후죽순 10 x +욱 10 x +욱 10 x +운 10 x +운동 10 x +운동가 10 x +운동원 10 x +운동장 10 x +운동화 10 x +운명 10 x +운반 10 x +운반 10 x +운반체 10 x +운송 10 x +운수 10 x +운영 10 x +운영비 10 x +운용 10 x +운운 10 x +운전 10 x +운전기사 10 x +운전병 10 x +운전석 10 x +운전자 10 x +운증 10 x +운하 10 x +운하 10 x +운항 10 x +운항 10 x +운행 10 x +운행 10 x +울 10 x +울렸 10 x +울리 10 x +울산 10 x +울산시 10 x +울음 10 x +울진 10 x +울퉁불퉁 10 x +움 10 x +움베르토 10 x +움직여 10 x +움직여요 10 x +움직이 10 x +움직일 10 x +움직임 10 x +움찔 10 x +움켜쥐 10 x +움켜쥔 10 x +웁 10 x +웁 10 x +웃 10 x +웃 10 x +웃겨 10 x +웃겨 10 x +웃기 10 x +웃긴 10 x +웃긴 10 x +웃도 10 x +웃돈 10 x +웃돌 10 x +웃음 10 x +웅 10 x +웅웅 10 x +웅장 10 x +웅크리 10 x +워 10 x +워낙 10 x +워낙 10 x +워너 10 x +워너비 10 x +워라 10 x +워런 10 x +워스 10 x +워싱턴 10 x +워치 10 x +워치콘 10 x +워커힐 10 x +워크 10 x +워크숍 10 x +워크아웃 10 x +워홀 10 x +원 10 x +원가 10 x +원고 10 x +원내 10 x +원내대표 10 x +원단 10 x +원당 10 x +원대 10 x +원도연 10 x +원동력 10 x +원두 10 x +원래 10 x +원로 10 x +원론 10 x +원료 10 x +원룸 10 x +원리 10 x +원리주의 10 x +원만 10 x +원망 10 x +원문 10 x +원봉 10 x +원비 10 x +원생 10 x +원서 10 x +원수 10 x +원숭이 10 x +원시 10 x +원심력 10 x +원심력 10 x +원액 10 x +원영 10 x +원외 10 x +원유 10 x +원인 10 x +원자 10 x +원자력 10 x +원자력 10 x +원자로 10 x +원자재 10 x +원작 10 x +원작 10 x +원장 10 x +원전 10 x +원제 10 x +원조 10 x +원주 10 x +원주민 10 x +원주시 10 x +원증 10 x +원천 10 x +원초 10 x +원치 10 x +원칙 10 x +원칙 10 x +원탑 10 x +원탑 10 x +원폭 10 x +원폭 10 x +원하 10 x +원하 10 x +원한 10 x +원한 10 x +원한다 10 x +원한다 10 x +원한다면 10 x +원한다면 10 x +원할 10 x +원할 10 x +원합니다 10 x +원합니다 10 x +원해 10 x +원해요 10 x +원했 10 x +원했 10 x +원했 10 x +원했 10 x +원화 10 x +원활 10 x +원회 10 x +월 10 x +월간 10 x +월간지 10 x +월경 10 x +월급 10 x +월급 10 x +월남 10 x +월드 10 x +월드컵 10 x +월드컵 10 x +월등히 10 x +월로 10 x +월마트 10 x +월말 10 x +월성 10 x +월세 10 x +월스트리트 10 x +월요일 10 x +월초 10 x +웡 10 x +웨 10 x +웨딩 10 x +웨딩 10 x +웨스트민스터 10 x +웨스틴 10 x +웨스팅하우스 10 x +웨어 10 x +웨이 10 x +웨인 10 x +웨일스 10 x +웬 10 x +웬만 10 x +웬일 10 x +웹 10 x +위 10 x +위구르 10 x +위급 10 x +위기 10 x +위기감 10 x +위기관리 10 x +위대 10 x +위력 10 x +위력 10 x +위로 10 x +위로금 10 x +위반 10 x +위배 10 x +위법 10 x +위법 10 x +위비 10 x +위상 10 x +위생 10 x +위서 10 x +위성 10 x +위세 10 x +위스콘신 10 x +위스키 10 x +위아래 10 x +위안 10 x +위안부 10 x +위약금 10 x +위엄 10 x +위염 10 x +위원 10 x +위원장 10 x +위원회 10 x +위임 10 x +위자 10 x +위자료 10 x +위장 10 x +위조 10 x +위주 10 x +위증죄 10 x +위쪽 10 x +위촉 10 x +위축 10 x +위축 10 x +위층 10 x +위치 10 x +위크 10 x +위태 10 x +위트 10 x +위트스톡 10 x +위트스톡 10 x +위패 10 x +위풍당당 10 x +위하 10 x +위한 10 x +위해 10 x +위해서 10 x +위해선 10 x +위험 10 x +위험부담 10 x +위협 10 x +윈 10 x +윈도 10 x +윌리엄 10 x +윗 10 x +윗배 10 x +윗배 10 x +윗선 10 x +윙 10 x +윙크 10 x +유 10 x +유가 10 x +유가족 10 x +유가족 10 x +유감 10 x +유고 10 x +유고슬라비아 10 x +유골 10 x +유공 10 x +유교 10 x +유권자 10 x +유기 10 x +유기물 10 x +유기준 10 x +유난히 10 x +유난히 10 x +유네스코 10 x +유념 10 x +유노 10 x +유능 10 x +유니 10 x +유니버스 10 x +유닛 10 x +유닛 10 x +유달리 10 x +유대 10 x +유대인 10 x +유도 10 x +유독 10 x +유독 10 x +유동 10 x +유래 10 x +유럽 10 x +유럽 10 x +유럽연합 10 x +유럽연합 10 x +유럽연합 10 x +유럽연합 10 x +유력 10 x +유력 10 x +유력시 10 x +유령 10 x +유례 10 x +유례없 10 x +유로 10 x +유료 10 x +유리 10 x +유리 10 x +유리창 10 x +유리컵 10 x +유리컵 10 x +유린 10 x +유림 10 x +유망 10 x +유망주 10 x +유머 10 x +유명 10 x +유명무실 10 x +유모차 10 x +유물 10 x +유민 10 x +유발 10 x +유방암 10 x +유배 10 x +유병언 10 x +유보 10 x +유비 10 x +유사 10 x +유산 10 x +유상 10 x +유서 10 x +유선 10 x +유세 10 x +유속 10 x +유수 10 x +유승민 10 x +유신 10 x +유실 10 x +유아 10 x +유약 10 x +유언 10 x +유언비어 10 x +유엔 10 x +유엔군 10 x +유역 10 x +유역 10 x +유연 10 x +유열 10 x +유영하 10 x +유예 10 x +유용 10 x +유월 10 x +유유히 10 x +유익 10 x +유익 10 x +유인 10 x +유일 10 x +유입 10 x +유입 10 x +유재환 10 x +유적 10 x +유적지 10 x +유전 10 x +유전자 10 x +유족 10 x +유족 10 x +유종헌 10 x +유죄 10 x +유지 10 x +유지비 10 x +유진룡 10 x +유착 10 x +유찰 10 x +유창 10 x +유책 10 x +유추 10 x +유축 10 x +유축 10 x +유출 10 x +유치 10 x +유치원 10 x +유치원생 10 x +유쾌 10 x +유탄 10 x +유태인 10 x +유통 10 x +유통망 10 x +유통점 10 x +유투 10 x +유튜브 10 x +유튜브 10 x +유틸리티 10 x +유틸리티 10 x +유포 10 x +유학 10 x +유학생 10 x +유한 10 x +유해 10 x +유행 10 x +유혈 10 x +유형 10 x +유혹 10 x +유혹 10 x +유화 10 x +유효 10 x +유훈 10 x +유흥업소 10 x +육 10 x +육 10 x +육공 10 x +육군 10 x +육대주 10 x +육만 10 x +육상 10 x +육성 10 x +육수 10 x +육아 10 x +육이오 10 x +육중 10 x +육지 10 x +육질 10 x +육질 10 x +육체 10 x +육체 10 x +윤 10 x +윤곽 10 x +윤기 10 x +윤리 10 x +윤여준 10 x +윤영대 10 x +윤유선 10 x +윤제문 10 x +윤종규 10 x +윤택 10 x +윤한수 10 x +윤한수 10 x +율 10 x +율곡 10 x +율곡 10 x +융 10 x +융성 10 x +융자 10 x +융통 10 x +융합 10 x +으 10 x +으깨 10 x +으나 10 x +으냐 10 x +으냐고 10 x +으니 10 x +으니까 10 x +으니까요 10 x +으니라 10 x +으라고 10 x +으러 10 x +으려 10 x +으려고 10 x +으려는 10 x +으려면 10 x +으로 10 x +으로부터 10 x +으로서 10 x +으로선 10 x +으로써 10 x +으론 10 x +으리라 10 x +으리라고 10 x +으리라는 10 x +으며 10 x +으면 10 x +으면서 10 x +으면은 10 x +으므로 10 x +으세요 10 x +으셔도 10 x +으셔야 10 x +으셨 10 x +으셨 10 x +으셨어요 10 x +으시 10 x +으신 10 x +으실 10 x +으십니다 10 x +으십시오 10 x +으쌰 10 x +으아 10 x +으악 10 x +으악 10 x +으앙 10 x +으흠 10 x +윽 10 x +은 10 x +은가 10 x +은감 10 x +은근히 10 x +은근히 10 x +은닉 10 x +은닉 10 x +은데 10 x +은데요 10 x +은밀 10 x +은밀히 10 x +은색 10 x +은연중 10 x +은은 10 x +은즉 10 x +은즉 10 x +은지 10 x +은퇴 10 x +은평 10 x +은폐 10 x +은하 10 x +은하 10 x +은행 10 x +은행 10 x +은행장 10 x +은행장 10 x +은혜 10 x +은혜 10 x +은희 10 x +은희 10 x +을 10 x +을걸요 10 x +을게요 10 x +을까 10 x +을까요 10 x +을래 10 x +을래요 10 x +을수록 10 x +을수록 10 x +을지 10 x +을지언정 10 x +음 10 x +음 10 x +음 10 x +음 10 x +음란 10 x +음력 10 x +음력 10 x +음료 10 x +음료수 10 x +음모 10 x +음반 10 x +음성 10 x +음식 10 x +음식 10 x +음식물 10 x +음식점 10 x +음식점 10 x +음악 10 x +음악 10 x +음악가 10 x +음악회 10 x +음압 10 x +음원 10 x +음주 10 x +음지 10 x +음직 10 x +음흉 10 x +읍 10 x +읍니다 10 x +읍시다 10 x +읏 10 x +읏 10 x +응 10 x +응급 10 x +응급실 10 x +응답 10 x +응답자 10 x +응당 10 x +응시 10 x +응시자 10 x +응애 10 x +응원 10 x +응접실 10 x +응집력 10 x +응징 10 x +응하 10 x +의 10 x +의거 10 x +의견 10 x +의결 10 x +의경 10 x +의구심 10 x +의기투합 10 x +의논 10 x +의당 10 x +의도 10 x +의례 10 x +의로운 10 x +의뢰 10 x +의료 10 x +의료법 10 x +의료비 10 x +의료진 10 x +의류 10 x +의리 10 x +의무 10 x +의무감 10 x +의문 10 x +의미 10 x +의미심장 10 x +의사 10 x +의사 10 x +의사소통 10 x +의사일정 10 x +의상 10 x +의석 10 x +의석수 10 x +의식 10 x +의식 10 x +의심 10 x +의아 10 x +의약 10 x +의약 10 x +의약품 10 x +의약품 10 x +의연 10 x +의왕 10 x +의외 10 x +의외로 10 x +의원 10 x +의의 10 x +의인 10 x +의자 10 x +의장 10 x +의정 10 x +의정부 10 x +의제 10 x +의존 10 x +의준 10 x +의중 10 x +의지 10 x +의지 10 x +의하 10 x +의학 10 x +의학 10 x +의한 10 x +의해 10 x +의해서 10 x +의혹 10 x +의혹 10 x +의회 10 x +이 10 x +이 10 x +이각범 10 x +이강희 10 x +이개호 10 x +이거 10 x +이건 10 x +이걸 10 x +이걸로 10 x +이겁니다 10 x +이것 10 x +이것 10 x +이것저것 10 x +이것저것 10 x +이게 10 x +이겨 10 x +이겨낼 10 x +이겨야 10 x +이견 10 x +이겼 10 x +이겼 10 x +이경식 10 x +이경식 10 x +이계 10 x +이곳 10 x +이곳 10 x +이곳저곳 10 x +이곳저곳 10 x +이과 10 x +이관 10 x +이구아나 10 x +이궁 10 x +이권 10 x +이글라 10 x +이기 10 x +이기주 10 x +이기주의 10 x +이기택 10 x +이긴 10 x +이길 10 x +이끄 10 x +이끌 10 x +이끌어냈 10 x +이나 10 x +이나마 10 x +이날 10 x +이남신 10 x +이내 10 x +이념 10 x +이노근 10 x +이놈 10 x +이니셔티브 10 x +이니셔티브 10 x +이니셜 10 x +이달 10 x +이달초 10 x +이당 10 x +이대 10 x +이대로 10 x +이대순 10 x +이데올로기 10 x +이도운 10 x +이동 10 x +이동식 10 x +이동식 10 x +이동하 10 x +이동해 10 x +이득 10 x +이든 10 x +이듬해 10 x +이따 10 x +이따가 10 x +이따위 10 x +이딴 10 x +이때 10 x +이라든가 10 x +이라크 10 x +이란 10 x +이랑 10 x +이래 10 x +이래도 10 x +이래서 10 x +이래저래 10 x +이랬 10 x +이랬 10 x +이러 10 x +이런 10 x +이런다고 10 x +이럴 10 x +이렇 10 x +이렇 10 x +이렇게 10 x +이력 10 x +이력서 10 x +이례 10 x +이로울 10 x +이론 10 x +이루 10 x +이루어져 10 x +이루어져야 10 x +이루어지 10 x +이루어진 10 x +이루어진다 10 x +이루어질 10 x +이루어질지 10 x +이루펀트 10 x +이룩 10 x +이룬 10 x +이룬다 10 x +이룰 10 x +이뤄 10 x +이뤄져야 10 x +이뤄졌 10 x +이뤄졌 10 x +이뤄지고 10 x +이뤄지기 10 x +이뤄지기를 10 x +이뤄지길 10 x +이뤄지는 10 x +이뤄지면 10 x +이뤄지지 10 x +이뤄진 10 x +이뤄진다는 10 x +이뤄질 10 x +이뤘 10 x +이륙 10 x +이르 10 x +이른 10 x +이른다 10 x +이른바 10 x +이를 10 x +이를지 10 x +이를테면 10 x +이름 10 x +이리 10 x +이리로 10 x +이리저리 10 x +이마 10 x +이마트 10 x +이만 10 x +이만섭 10 x +이만오천 10 x +이만저만 10 x +이만큼 10 x +이맘때 10 x +이메이 10 x +이메일 10 x +이면 10 x +이명 10 x +이명박 10 x +이명박 10 x +이모 10 x +이목 10 x +이목구비 10 x +이무 10 x +이미 10 x +이미지 10 x +이민 10 x +이민법 10 x +이민법 10 x +이민자 10 x +이반 10 x +이발소 10 x +이번 10 x +이벤트 10 x +이변 10 x +이별 10 x +이봉련 10 x +이부영 10 x +이북 10 x +이북 10 x +이분 10 x +이불 10 x +이브스 10 x +이븐 10 x +이빨 10 x +이뻐 10 x +이뻐요 10 x +이쁘 10 x +이쁜 10 x +이사 10 x +이사 10 x +이사국 10 x +이사장 10 x +이사회 10 x +이산화 10 x +이삿짐 10 x +이상 10 x +이상일 10 x +이상형 10 x +이색 10 x +이석채 10 x +이석태 10 x +이성 10 x +이성만 10 x +이세돌 10 x +이세영 10 x +이세우 10 x +이송 10 x +이수 10 x +이수석 10 x +이수석 10 x +이수성 10 x +이순신 10 x +이슈 10 x +이스 10 x +이스라엘 10 x +이스케 10 x +이스턴 10 x +이스트 10 x +이슬 10 x +이슬람 10 x +이슬람교도 10 x +이승만 10 x +이신범 10 x +이싱 10 x +이씨 10 x +이야기 10 x +이야깁니다 10 x +이야말로 10 x +이양 10 x +이어 10 x +이어서 10 x +이어져 10 x +이어졌 10 x +이어지 10 x +이어진 10 x +이어진다 10 x +이어질 10 x +이어질지 10 x +이억 10 x +이억 10 x +이에스에이 10 x +이엔에스 10 x +이여 10 x +이영 10 x +이영호 10 x +이오니아 10 x +이온 10 x +이완구 10 x +이외 10 x +이용 10 x +이용구 10 x +이용료 10 x +이용만 10 x +이용자 10 x +이용한 10 x +이우재 10 x +이웃 10 x +이웃 10 x +이웃집 10 x +이웃집 10 x +이원범 10 x +이원조 10 x +이원종 10 x +이원화 10 x +이월 10 x +이월호 10 x +이유 10 x +이윤 10 x +이윤성 10 x +이은주 10 x +이이 10 x +이익 10 x +이익 10 x +이인자 10 x +이자 10 x +이자율 10 x +이재만 10 x +이재명 10 x +이재용 10 x +이적 10 x +이전 10 x +이점 10 x +이정무 10 x +이정미 10 x +이정원 10 x +이정현 10 x +이정희 10 x +이제 10 x +이제껏 10 x +이제서야 10 x +이제야 10 x +이젠 10 x +이조 10 x +이종걸 10 x +이주자 10 x +이주해 10 x +이준삼 10 x +이준호 10 x +이중 10 x +이쥔 10 x +이즈미르 10 x +이지스 10 x +이직률 10 x +이집트 10 x +이집트 10 x +이쪽 10 x +이쪽 10 x +이착륙 10 x +이착륙 10 x +이찬열 10 x +이천년 10 x +이철 10 x +이철재 10 x +이체 10 x +이춘석 10 x +이츠하크 10 x +이치 10 x +이치로 10 x +이케 10 x +이코 10 x +이크 10 x +이타 10 x +이탈 10 x +이탈리아 10 x +이태원 10 x +이태원동 10 x +이토록 10 x +이토록 10 x +이튿날 10 x +이틀 10 x +이프 10 x +이피 10 x +이하 10 x +이한구 10 x +이한동 10 x +이한열 10 x +이한영 10 x +이해 10 x +이해관계 10 x +이해력 10 x +이해찬 10 x +이행 10 x +이현숙 10 x +이혼 10 x +이홍구 10 x +이화동 10 x +이화여대 10 x +이회 10 x +이회창 10 x +이후 10 x +익 10 x +익사 10 x +익선동 10 x +익숙 10 x +익숙 10 x +익스 10 x +익스플로러 10 x +익혀 10 x +익히 10 x +인 10 x +인가 10 x +인가라는 10 x +인가요 10 x +인간 10 x +인건비 10 x +인격 10 x +인격 10 x +인격체 10 x +인격체 10 x +인공 10 x +인공강우 10 x +인공섬 10 x +인공지능 10 x +인구 10 x +인권 10 x +인권위 10 x +인근 10 x +인기 10 x +인내 10 x +인내심 10 x +인단 10 x +인데 10 x +인데요 10 x +인도 10 x +인도네시아 10 x +인도인 10 x +인도주의 10 x +인들 10 x +인디 10 x +인디고 10 x +인라인스케이트 10 x +인력 10 x +인력 10 x +인력거 10 x +인료 10 x +인류 10 x +인류사 10 x +인류애 10 x +인만 10 x +인망 10 x +인맥 10 x +인명 10 x +인문학 10 x +인문학 10 x +인물 10 x +인민 10 x +인민공화국 10 x +인민공화국 10 x +인민주의 10 x +인민해방군 10 x +인방 10 x +인본주의 10 x +인봉 10 x +인분 10 x +인사 10 x +인사말 10 x +인삼 10 x +인상 10 x +인생 10 x +인센티브 10 x +인센티브 10 x +인쇄 10 x +인쇄소 10 x +인수 10 x +인스 10 x +인스턴트 10 x +인식 10 x +인식 10 x +인심 10 x +인야 10 x +인양 10 x +인연 10 x +인용 10 x +인원 10 x +인위 10 x +인재 10 x +인접 10 x +인정 10 x +인정받 10 x +인정받 10 x +인종 10 x +인증 10 x +인지 10 x +인지도 10 x +인질 10 x +인척 10 x +인천 10 x +인천시 10 x +인체 10 x +인출 10 x +인치 10 x +인터 10 x +인터넷 10 x +인터넷 10 x +인터뷰 10 x +인턴 10 x +인테리어 10 x +인파 10 x +인포 10 x +인포메이션 10 x +인풋 10 x +인프라 10 x +인플레 10 x +인하 10 x +인하 10 x +인한 10 x +인한 10 x +인해 10 x +인해 10 x +인해서 10 x +인형 10 x +인형 10 x +인회 10 x +인후 10 x +일 10 x +일 10 x +일가 10 x +일각 10 x +일각 10 x +일간지 10 x +일거 10 x +일거리 10 x +일곱 10 x +일곱 10 x +일과 10 x +일관 10 x +일괄 10 x +일교차 10 x +일군 10 x +일궈 10 x +일급 10 x +일기 10 x +일까 10 x +일까요 10 x +일깨워 10 x +일단 10 x +일당 10 x +일대 10 x +일동 10 x +일러 10 x +일러스트레이터 10 x +일련 10 x +일례 10 x +일류 10 x +일률 10 x +일리 10 x +일만 10 x +일반 10 x +일반인 10 x +일반직 10 x +일반직 10 x +일방 10 x +일방통행 10 x +일벌레 10 x +일베저장소 10 x +일변도 10 x +일본 10 x +일본식 10 x +일본식 10 x +일본어 10 x +일본인 10 x +일부 10 x +일부러 10 x +일산 10 x +일삼 10 x +일상 10 x +일상용어 10 x +일색 10 x +일생 10 x +일선 10 x +일세 10 x +일손 10 x +일수록 10 x +일수록 10 x +일시 10 x +일식 10 x +일식집 10 x +일식집 10 x +일식집 10 x +일신 10 x +일쑤 10 x +일어나 10 x +일어난 10 x +일어난다 10 x +일어날 10 x +일어날지 10 x +일어났 10 x +일어났 10 x +일어서 10 x +일어섰 10 x +일억 10 x +일억 10 x +일언이폐지 10 x +일요일 10 x +일용 10 x +일용직 10 x +일용직 10 x +일원 10 x +일월 10 x +일위 10 x +일으켜 10 x +일으켰 10 x +일으키 10 x +일으킨 10 x +일으킬 10 x +일인 10 x +일일이 10 x +일자 10 x +일자리 10 x +일정 10 x +일제 10 x +일제히 10 x +일종 10 x +일주일 10 x +일지 10 x +일직선 10 x +일찌감치 10 x +일찍 10 x +일찍 10 x +일찍이 10 x +일차 10 x +일천 10 x +일체 10 x +일축 10 x +일축 10 x +일출 10 x +일층 10 x +일치 10 x +일컫 10 x +일컫 10 x +일컬 10 x +일탈 10 x +일파만파 10 x +일평생 10 x +일행 10 x +일화 10 x +일확천금 10 x +일환 10 x +일회용 10 x +일회용품 10 x +일흔 10 x +읽 10 x +읽 10 x +잃 10 x +잃어버려서 10 x +잃어버렸 10 x +잃어버린 10 x +임 10 x +임관 10 x +임금 10 x +임금 10 x +임금제 10 x +임기 10 x +임나 10 x +임당 10 x +임대 10 x +임대료 10 x +임대인 10 x +임령 10 x +임마 10 x +임명 10 x +임무 10 x +임박 10 x +임박 10 x +임산부 10 x +임수경 10 x +임슬옹 10 x +임시 10 x +임신 10 x +임야 10 x +임영민 10 x +임영애 10 x +임옥상 10 x +임용 10 x +임원 10 x +임원수 10 x +임의 10 x +임의수사 10 x +임직원 10 x +임차 10 x +임채정 10 x +임팔라 10 x +임하 10 x +임한 10 x +임현수 10 x +임화섭 10 x +임화섭 10 x +입 10 x +입 10 x +입가 10 x +입각 10 x +입간판 10 x +입간판 10 x +입건 10 x +입건 10 x +입구 10 x +입구 10 x +입국 10 x +입국 10 x +입국 10 x +입국 10 x +입금 10 x +입금 10 x +입김 10 x +입김 10 x +입꼬리 10 x +입니까 10 x +입니다 10 x +입당 10 x +입대 10 x +입력 10 x +입맛 10 x +입문 10 x +입법 10 x +입법권 10 x +입북 10 x +입북 10 x +입사 10 x +입산 10 x +입소문 10 x +입수 10 x +입술 10 x +입시 10 x +입씨름 10 x +입양 10 x +입원 10 x +입장 10 x +입장 10 x +입장료 10 x +입점 10 x +입주 10 x +입주 10 x +입주자 10 x +입주자 10 x +입증 10 x +입지 10 x +입찰 10 x +입찰 10 x +입체 10 x +입체 10 x +입학 10 x +입해 10 x +입히 10 x +입힌 10 x +잇 10 x +잇 10 x +잇단 10 x +잇달 10 x +잇따라 10 x +잇따랐 10 x +잇따랐 10 x +잇따르 10 x +잇몸 10 x +있 10 x +있 10 x +잉 10 x +잉가 10 x +잉글랜드 10 x +잊 10 x +잊 10 x +잊어버려 10 x +잊어버렸 10 x +잊어버리 10 x +잊어버린 10 x +잊혀 10 x +잊혀질 10 x +잎 10 x +자 10 x +자가용 10 x +자감 10 x +자격 10 x +자격 10 x +자격정지 10 x +자격증 10 x +자격증 10 x +자결권 10 x +자고 10 x +자구요 10 x +자국 10 x +자국 10 x +자국민 10 x +자궁 10 x +자그마치 10 x +자극 10 x +자금 10 x +자금난 10 x +자급 10 x +자기 10 x +자기계 10 x +자기반성 10 x +자기장 10 x +자길 10 x +자꾸 10 x +자네 10 x +자녀 10 x +자는 10 x +자니 10 x +자동 10 x +자동차 10 x +자동차세 10 x +자라 10 x +자라났 10 x +자란 10 x +자란다 10 x +자랍니다 10 x +자랐 10 x +자랑 10 x +자력 10 x +자료 10 x +자료실 10 x +자료집 10 x +자루 10 x +자르 10 x +자를 10 x +자리 10 x +자리매김 10 x +자립 10 x +자마자 10 x +자막 10 x +자막 10 x +자말 10 x +자매 10 x +자며 10 x +자면 10 x +자면은 10 x +자명 10 x +자무 10 x +자문 10 x +자물쇠 10 x +자민 10 x +자민련 10 x +자밀 10 x +자바섬 10 x +자박 10 x +자발 10 x +자백 10 x +자백 10 x +자보 10 x +자본 10 x +자본금 10 x +자본주의 10 x +자부 10 x +자부심 10 x +자분 10 x +자비 10 x +자빠졌 10 x +자빠졌 10 x +자사 10 x +자산 10 x +자살 10 x +자상 10 x +자생 10 x +자생력 10 x +자서전 10 x +자석 10 x +자선 10 x +자선냄비 10 x +자성 10 x +자세 10 x +자세히 10 x +자수 10 x +자시 10 x +자식 10 x +자식 10 x +자신 10 x +자신감 10 x +자아내 10 x +자알 10 x +자양 10 x +자연 10 x +자연재해 10 x +자연환경 10 x +자연히 10 x +자연히 10 x +자영업 10 x +자영업 10 x +자오웨이 10 x +자외선 10 x +자요 10 x +자욱 10 x +자원 10 x +자위 10 x +자위권 10 x +자위대 10 x +자유 10 x +자유경 10 x +자유민주주의 10 x +자유영 10 x +자유주의 10 x +자유총연맹 10 x +자율 10 x +자의 10 x +자이레 10 x +자이언츠 10 x +자인 10 x +자임 10 x +자자 10 x +자장면 10 x +자재 10 x +자전거 10 x +자정 10 x +자제 10 x +자제력 10 x +자존 10 x +자존심 10 x +자주 10 x +자주포 10 x +자지 10 x +자진 10 x +자질 10 x +자차 10 x +자책 10 x +자처 10 x +자청 10 x +자체 10 x +자초 10 x +자축 10 x +자취 10 x +자치 10 x +자치구 10 x +자칫 10 x +자칫 10 x +자칭 10 x +자켓 10 x +자크 10 x +자태 10 x +자택 10 x +자택 10 x +자판기 10 x +자폐증 10 x +자학 10 x +자해 10 x +자행 10 x +자화상 10 x +작 10 x +작 10 x +작가 10 x +작곡 10 x +작곡 10 x +작곡가 10 x +작금 10 x +작년 10 x +작대기 10 x +작동 10 x +작물 10 x +작별 10 x +작사 10 x +작성 10 x +작성자 10 x +작심 10 x +작아지 10 x +작업 10 x +작업 10 x +작업선 10 x +작업자 10 x +작용 10 x +작전 10 x +작정 10 x +작태 10 x +작품 10 x +작품 10 x +작화 10 x +잔 10 x +잔고 10 x +잔다 10 x +잔돈 10 x +잔디 10 x +잔뜩 10 x +잔뜩 10 x +잔류 10 x +잔소리 10 x +잔액 10 x +잔액 10 x +잔인 10 x +잔잔 10 x +잔재 10 x +잔존 10 x +잔존물 10 x +잔치 10 x +잔혹 10 x +잖 10 x +잖아 10 x +잖아요 10 x +잘 10 x +잘난 10 x +잘라 10 x +잘라야 10 x +잘랐 10 x +잘렸 10 x +잘리 10 x +잘린 10 x +잘못 10 x +잘못 10 x +잘생긴 10 x +잠 10 x +잠갔 10 x +잠겨 10 x +잠겼 10 x +잠겼 10 x +잠그 10 x +잠기 10 x +잠긴 10 x +잠깐 10 x +잠드 10 x +잠들 10 x +잠복 10 x +잠복 10 x +잠수 10 x +잠수선 10 x +잠수함 10 x +잠시 10 x +잠식 10 x +잠실 10 x +잠아 10 x +잠언 10 x +잠옷 10 x +잠원 10 x +잠원동 10 x +잠자 10 x +잠자리 10 x +잠자리 10 x +잠자코 10 x +잠잠 10 x +잠재 10 x +잠재력 10 x +잠재울 10 x +잠정 10 x +잡 10 x +잡 10 x +잡니다 10 x +잡다 10 x +잡담 10 x +잡스 10 x +잡아가 10 x +잡아내 10 x +잡아먹 10 x +잡음 10 x +잡일 10 x +잡종 10 x +잡종 10 x +잡지 10 x +잡지 10 x +잡초 10 x +잡혀 10 x +잡혔 10 x +잡히 10 x +잡힌 10 x +잡힐 10 x +잣 10 x +잣대 10 x +잤 10 x +장 10 x +장가 10 x +장갑 10 x +장갑 10 x +장갑차 10 x +장개석 10 x +장거리 10 x +장고 10 x +장관 10 x +장관실 10 x +장교 10 x +장군 10 x +장기 10 x +장기간 10 x +장기채 10 x +장난 10 x +장난감 10 x +장남 10 x +장년 10 x +장단 10 x +장담 10 x +장동련 10 x +장래 10 x +장려 10 x +장례 10 x +장례식 10 x +장례식 10 x +장로 10 x +장르 10 x +장마 10 x +장마철 10 x +장막 10 x +장막 10 x +장면 10 x +장모 10 x +장모님 10 x +장물 10 x +장미 10 x +장밋빛 10 x +장밋빛 10 x +장밋빛 10 x +장벽 10 x +장벽 10 x +장병 10 x +장병완 10 x +장보 10 x +장보고 10 x +장본인 10 x +장부 10 x +장비 10 x +장사 10 x +장서 10 x +장성 10 x +장세 10 x +장세동 10 x +장소 10 x +장수 10 x +장수 10 x +장시간 10 x +장시호 10 x +장식 10 x +장식 10 x +장실 10 x +장쑤성 10 x +장악 10 x +장악 10 x +장악력 10 x +장애 10 x +장애물 10 x +장영자 10 x +장외 10 x +장윤호 10 x +장의사 10 x +장인 10 x +장자연 10 x +장전 10 x +장점 10 x +장제원 10 x +장주 10 x +장중 10 x +장지 10 x +장진수 10 x +장쩌민 10 x +장차 10 x +장철 10 x +장체 10 x +장치 10 x +장판 10 x +장학금 10 x +잦 10 x +잦 10 x +잦아지 10 x +재 10 x +재개 10 x +재건 10 x +재건축 10 x +재검토 10 x +재계 10 x +재고 10 x +재난 10 x +재능 10 x +재단 10 x +재떨이 10 x +재래 10 x +재래식 10 x +재래식 10 x +재료 10 x +재목 10 x +재무 10 x +재무제표 10 x +재물 10 x +재미 10 x +재미난 10 x +재미없 10 x +재미없 10 x +재미있 10 x +재미있 10 x +재밌 10 x +재밌기만 10 x +재발 10 x +재방 10 x +재배 10 x +재벌 10 x +재범 10 x +재빨리 10 x +재산 10 x +재생 10 x +재선 10 x +재소자 10 x +재수 10 x +재심 10 x +재앙 10 x +재야 10 x +재야인사 10 x +재연 10 x +재외 10 x +재우 10 x +재원 10 x +재일 10 x +재임 10 x +재임용 10 x +재정 10 x +재조명 10 x +재주 10 x +재즈 10 x +재직 10 x +재질 10 x +재차 10 x +재채기 10 x +재촉 10 x +재킷 10 x +재킷 10 x +재탕 10 x +재판 10 x +재판관 10 x +재판부 10 x +재판소 10 x +재판장 10 x +재팬 10 x +재편 10 x +재학 10 x +재해 10 x +재현 10 x +재혼 10 x +재화 10 x +재활 10 x +잭 10 x +잭 10 x +잰 10 x +잴 10 x +잼 10 x +잽 10 x +쟁이 10 x +쟁점 10 x +쟁취 10 x +쟈 10 x +쟤 10 x +저 10 x +저거 10 x +저건 10 x +저것 10 x +저것 10 x +저게 10 x +저고리 10 x +저곳 10 x +저기 10 x +저기요 10 x +저널 10 x +저녁 10 x +저녁 10 x +저녁때 10 x +저능 10 x +저돌 10 x +저래 10 x +저랬 10 x +저러 10 x +저런 10 x +저렇 10 x +저렇게 10 x +저력 10 x +저렴 10 x +저리 10 x +저마다 10 x +저물 10 x +저버리 10 x +저버린 10 x +저번 10 x +저변 10 x +저서 10 x +저성장 10 x +저소득층 10 x +저소득층 10 x +저속 10 x +저술가 10 x +저우융캉 10 x +저울 10 x +저유 10 x +저으 10 x +저자 10 x +저작 10 x +저작 10 x +저장 10 x +저절로 10 x +저점 10 x +저조 10 x +저주 10 x +저주파 10 x +저지 10 x +저지르 10 x +저지른 10 x +저질러 10 x +저질러졌 10 x +저질렀 10 x +저질렀 10 x +저쪽 10 x +저체온증 10 x +저축 10 x +저축 10 x +저출산 10 x +저택 10 x +저편 10 x +저하 10 x +저항 10 x +저항감 10 x +저해 10 x +저희 10 x +적 10 x +적 10 x +적격 10 x +적극 10 x +적극 10 x +적금 10 x +적나라 10 x +적당 10 x +적당 10 x +적당히 10 x +적대 10 x +적령기 10 x +적립 10 x +적립 10 x +적발 10 x +적발 10 x +적법 10 x +적성 10 x +적수 10 x +적시 10 x +적십자 10 x +적어도 10 x +적외선 10 x +적용 10 x +적응 10 x +적응력 10 x +적응력 10 x +적임자 10 x +적자 10 x +적잖 10 x +적잖 10 x +적절 10 x +적절 10 x +적절히 10 x +적정 10 x +적폐 10 x +적폐 10 x +적합 10 x +적합 10 x +적혀 10 x +적환장 10 x +적힌 10 x +전 10 x +전 10 x +전가 10 x +전개 10 x +전격 10 x +전격 10 x +전경 10 x +전경련 10 x +전공 10 x +전과 10 x +전교조 10 x +전구 10 x +전국 10 x +전국 10 x +전국 10 x +전국 10 x +전극 10 x +전기 10 x +전기료 10 x +전나무 10 x +전날 10 x +전남 10 x +전년 10 x +전념 10 x +전능 10 x +전단 10 x +전단지 10 x +전달 10 x +전담 10 x +전담반 10 x +전당 10 x +전대 10 x +전대통령 10 x +전동 10 x +전두환 10 x +전등 10 x +전라 10 x +전락 10 x +전락 10 x +전략 10 x +전략 10 x +전량 10 x +전력 10 x +전력 10 x +전례 10 x +전망 10 x +전망대 10 x +전매 10 x +전면 10 x +전몰 10 x +전무 10 x +전문 10 x +전문가 10 x +전문점 10 x +전민주 10 x +전반 10 x +전반전 10 x +전방 10 x +전범 10 x +전법 10 x +전복 10 x +전부 10 x +전북 10 x +전북 10 x +전사 10 x +전산 10 x +전산망 10 x +전생 10 x +전선 10 x +전선 10 x +전설 10 x +전성기 10 x +전성시대 10 x +전세 10 x +전세 10 x +전셋값 10 x +전셋값 10 x +전속 10 x +전술 10 x +전승 10 x +전시 10 x +전시관 10 x +전시장 10 x +전시회 10 x +전신 10 x +전심 10 x +전압 10 x +전액 10 x +전언 10 x +전업 10 x +전역 10 x +전역 10 x +전염 10 x +전염병 10 x +전용 10 x +전용기 10 x +전원 10 x +전월 10 x +전월세 10 x +전인 10 x +전일제 10 x +전임 10 x +전자 10 x +전자사전 10 x +전작 10 x +전작 10 x +전장 10 x +전쟁 10 x +전쟁터 10 x +전적 10 x +전전 10 x +전전긍긍 10 x +전제 10 x +전조 10 x +전주 10 x +전직 10 x +전직 10 x +전직 10 x +전진 10 x +전차 10 x +전철 10 x +전체 10 x +전통 10 x +전통문화 10 x +전통술 10 x +전투 10 x +전투기 10 x +전파 10 x +전폭 10 x +전폭 10 x +전폭기 10 x +전표 10 x +전하 10 x +전하 10 x +전한 10 x +전한 10 x +전할 10 x +전할 10 x +전합 10 x +전합 10 x +전합니다 10 x +전합니다 10 x +전해 10 x +전해 10 x +전해졌 10 x +전해졌 10 x +전해졌 10 x +전해진 10 x +전해진 10 x +전해진다 10 x +전해진다 10 x +전했 10 x +전했 10 x +전했 10 x +전했 10 x +전향 10 x +전향 10 x +전혀 10 x +전혀 10 x +전형 10 x +전형 10 x +전화 10 x +전화기 10 x +전화벨 10 x +전화통 10 x +전환 10 x +전환기 10 x +전횡 10 x +전후 10 x +절 10 x +절감 10 x +절개 10 x +절단 10 x +절대 10 x +절대로 10 x +절로 10 x +절망 10 x +절망감 10 x +절박 10 x +절반 10 x +절벽 10 x +절봉 10 x +절세 10 x +절실 10 x +절약 10 x +절정 10 x +절제 10 x +절차 10 x +절필 10 x +절하 10 x +젊 10 x +젊은이 10 x +젊음 10 x +젋음의 10 x +점 10 x +점가 10 x +점거 10 x +점검 10 x +점령 10 x +점성술 10 x +점수 10 x +점심 10 x +점심때 10 x +점액 10 x +점원 10 x +점유 10 x +점이 10 x +점잖 10 x +점점 10 x +점주 10 x +점진 10 x +점찍 10 x +점차 10 x +점철 10 x +점쳐 10 x +점치 10 x +점포 10 x +점프 10 x +접 10 x +접견 10 x +접경 10 x +접근 10 x +접근 10 x +접대 10 x +접대 10 x +접대비 10 x +접목 10 x +접속 10 x +접속 10 x +접수 10 x +접수대 10 x +접수인 10 x +접시 10 x +접어든 10 x +접어들 10 x +접전 10 x +접종 10 x +접촉 10 x +접촉 10 x +접촉 10 x +접촉 10 x +접하 10 x +접한 10 x +접할 10 x +접합제 10 x +접해 10 x +접혀 10 x +젓가락 10 x +정 10 x +정가 10 x +정각 10 x +정개련 10 x +정거장 10 x +정경 10 x +정계 10 x +정관 10 x +정관용 10 x +정교 10 x +정국 10 x +정국 10 x +정권 10 x +정규 10 x +정규직 10 x +정기 10 x +정기태 10 x +정남 10 x +정년 10 x +정답 10 x +정답 10 x +정당 10 x +정당 10 x +정당방위 10 x +정대철 10 x +정대협 10 x +정도 10 x +정동영 10 x +정동채 10 x +정량 10 x +정력 10 x +정력 10 x +정류장 10 x +정릉 10 x +정리 10 x +정립 10 x +정립 10 x +정말 10 x +정말로 10 x +정면 10 x +정무 10 x +정무제 10 x +정문 10 x +정문수 10 x +정문현 10 x +정문현 10 x +정밀 10 x +정박 10 x +정반대 10 x +정법 10 x +정변 10 x +정병국 10 x +정보 10 x +정보국 10 x +정보국 10 x +정보력 10 x +정보원 10 x +정복 10 x +정복 10 x +정부 10 x +정부과천청사 10 x +정부군 10 x +정비 10 x +정비소 10 x +정산 10 x +정상 10 x +정상 10 x +정상부 10 x +정상회의 10 x +정서 10 x +정석 10 x +정설 10 x +정성 10 x +정성호 10 x +정세 10 x +정세균 10 x +정세운 10 x +정소민 10 x +정수 10 x +정수근 10 x +정수기 10 x +정승 10 x +정식 10 x +정식 10 x +정신 10 x +정신병 10 x +정씨 10 x +정연국 10 x +정열 10 x +정예 10 x +정오 10 x +정옥현 10 x +정용욱 10 x +정원 10 x +정월 10 x +정유 10 x +정유라 10 x +정육면체 10 x +정윤식 10 x +정윤식 10 x +정의 10 x +정의당 10 x +정인 10 x +정작 10 x +정작 10 x +정장 10 x +정쟁 10 x +정적 10 x +정적 10 x +정전 10 x +정점 10 x +정종 10 x +정준영 10 x +정준하 10 x +정준하 10 x +정지 10 x +정직 10 x +정진석 10 x +정진용 10 x +정착 10 x +정착 10 x +정찰 10 x +정책 10 x +정책 10 x +정청 10 x +정청래 10 x +정체 10 x +정체 10 x +정취 10 x +정치 10 x +정치국 10 x +정치국 10 x +정치범 10 x +정치인 10 x +정치판 10 x +정치학 10 x +정치학 10 x +정태 10 x +정태우 10 x +정통 10 x +정통성 10 x +정파 10 x +정평 10 x +정하 10 x +정한 10 x +정한다 10 x +정할 10 x +정해 10 x +정해서 10 x +정해야 10 x +정해져 10 x +정해진 10 x +정했 10 x +정했 10 x +정형외과 10 x +정호 10 x +정확 10 x +정확 10 x +정확히 10 x +정황 10 x +정회 10 x +정훈석 10 x +정훈석 10 x +정희창 10 x +젖 10 x +젖 10 x +젖줄 10 x +제 10 x +제각각 10 x +제각기 10 x +제거 10 x +제게 10 x +제게는 10 x +제격 10 x +제고 10 x +제곱 10 x +제곱 10 x +제곱미터 10 x +제공 10 x +제국 10 x +제국주의 10 x +제기 10 x +제너럴 10 x +제네바 10 x +제네시스 10 x +제대로 10 x +제도 10 x +제도 10 x +제동 10 x +제라드 10 x +제랄드 10 x +제로 10 x +제르 10 x +제리 10 x +제목 10 x +제목 10 x +제반 10 x +제발 10 x +제법 10 x +제법 10 x +제보 10 x +제복 10 x +제비 10 x +제사 10 x +제삼국 10 x +제삼자 10 x +제삿날 10 x +제선 10 x +제소 10 x +제스처 10 x +제시 10 x +제안 10 x +제압 10 x +제압 10 x +제약 10 x +제어 10 x +제왕 10 x +제외 10 x +제원 10 x +제의 10 x +제이 10 x +제이차 10 x +제이콥스 10 x +제인 10 x +제일 10 x +제일모직 10 x +제일모직 10 x +제일제당 10 x +제자 10 x +제자리 10 x +제작 10 x +제작 10 x +제작비 10 x +제작비 10 x +제작사 10 x +제작진 10 x +제재 10 x +제정 10 x +제조 10 x +제조업 10 x +제조업 10 x +제주 10 x +제주도 10 x +제주시 10 x +제주조릿대 10 x +제지 10 x +제창 10 x +제청 10 x +제쳐 10 x +제초제 10 x +제출 10 x +제치 10 x +제트 10 x +제품 10 x +제하 10 x +제한 10 x +제휴 10 x +젠 10 x +젠슨 10 x +젤 10 x +져 10 x +져도 10 x +져라 10 x +져서 10 x +져야 10 x +져요 10 x +젼 10 x +졌 10 x +졌 10 x +졌었 10 x +졌었 10 x +졌으나 10 x +졌으니 10 x +졌으면 10 x +조 10 x +조각 10 x +조각 10 x +조갑제 10 x +조개 10 x +조건 10 x +조계사 10 x +조계종 10 x +조국 10 x +조국 10 x +조그마 10 x +조그만 10 x +조금 10 x +조기 10 x +조나슨 10 x +조난 10 x +조니뎁 10 x +조니뎁 10 x +조달 10 x +조두순 10 x +조례 10 x +조롱 10 x +조류 10 x +조르 10 x +조리 10 x +조리 10 x +조리사 10 x +조리용 10 x +조립 10 x +조립 10 x +조만간 10 x +조망 10 x +조명 10 x +조모 10 x +조문 10 x +조사 10 x +조산사 10 x +조상 10 x +조선 10 x +조선 10 x +조선소 10 x +조선업 10 x +조선업 10 x +조선인 10 x +조선족 10 x +조성 10 x +조성준 10 x +조세 10 x +조속히 10 x +조수 10 x +조수석 10 x +조순 10 x +조심 10 x +조심히 10 x +조아 10 x +조야 10 x +조약 10 x +조양호 10 x +조언 10 x +조업 10 x +조업 10 x +조용 10 x +조용민 10 x +조용히 10 x +조원극 10 x +조원진 10 x +조윤선 10 x +조율 10 x +조은석 10 x +조응천 10 x +조이 10 x +조작 10 x +조작 10 x +조작극 10 x +조작극 10 x +조잡 10 x +조장 10 x +조절 10 x +조정 10 x +조정식 10 x +조정식 10 x +조제 10 x +조종 10 x +조종사 10 x +조중 10 x +조지 10 x +조직 10 x +조직 10 x +조직범죄 10 x +조직범죄 10 x +조직원 10 x +조짐 10 x +조차 10 x +조찬회 10 x +조처 10 x +조치 10 x +조카 10 x +조타수 10 x +조폭 10 x +조합 10 x +조합원 10 x +조항 10 x +조현아 10 x +조현오 10 x +조형 10 x +조형래 10 x +조홍근 10 x +조화 10 x +조회 10 x +조회장 10 x +족 10 x +족 10 x +족내혼 10 x +족쇄 10 x +족장 10 x +족히 10 x +존 10 x +존경 10 x +존나 10 x +존속 10 x +존속 10 x +존슨 10 x +존엄 10 x +존자 10 x +존재 10 x +존중 10 x +존중심 10 x +존치 10 x +존폐 10 x +졸 10 x +졸라 10 x +졸려 10 x +졸려요 10 x +졸속 10 x +졸속 10 x +졸업 10 x +졸업식 10 x +졸업식 10 x +졸음 10 x +졸이 10 x +졸인 10 x +졸작 10 x +졸작 10 x +졸지 10 x +좀 10 x +좀처럼 10 x +좁 10 x +좁 10 x +좁혀 10 x +좁히 10 x +종 10 x +종 10 x +종강 10 x +종교 10 x +종교계 10 x +종군 10 x +종기 10 x +종단 10 x +종래 10 x +종량제 10 x +종로 10 x +종로구 10 x +종료 10 x +종류 10 x +종말 10 x +종목 10 x +종목 10 x +종북 10 x +종북 10 x +종사 10 x +종사자 10 x +종속 10 x +종식 10 x +종식 10 x +종신 10 x +종신형 10 x +종양 10 x +종업원 10 x +종용 10 x +종이 10 x +종일 10 x +종잇장 10 x +종잇조각 10 x +종자 10 x +종전 10 x +종정 10 x +종족 10 x +종종 10 x +종지부 10 x +종파 10 x +종합 10 x +좋 10 x +좋 10 x +좋아하 10 x +좋아한다 10 x +좋아할 10 x +좋아합니다 10 x +좋아해 10 x +좋아했 10 x +좌 10 x +좌경 10 x +좌불안석 10 x +좌불안석 10 x +좌석 10 x +좌우 10 x +좌익 10 x +좌절 10 x +좌지우지 10 x +좌파 10 x +좌회전 10 x +죄 10 x +죄다 10 x +죄명 10 x +죄송 10 x +죄수 10 x +죄수복 10 x +죄악 10 x +죄질 10 x +죠 10 x +주 10 x +주가 10 x +주가노프 10 x +주간 10 x +주간지 10 x +주거 10 x +주거비 10 x +주거지 10 x +주고받 10 x +주고받 10 x +주관 10 x +주교 10 x +주국 10 x +주권 10 x +주기 10 x +주네 10 x +주년 10 x +주눅 10 x +주니 10 x +주니퍼 10 x +주당 10 x +주도 10 x +주동자 10 x +주둔 10 x +주둔군 10 x +주력 10 x +주력 10 x +주례 10 x +주로 10 x +주류 10 x +주름 10 x +주름살 10 x +주립 10 x +주립 10 x +주말 10 x +주머니 10 x +주먹 10 x +주먹구구식 10 x +주목 10 x +주목 10 x +주무 10 x +주무관 10 x +주무세요 10 x +주무셨 10 x +주무시 10 x +주문 10 x +주민 10 x +주바 10 x +주방 10 x +주방장 10 x +주범 10 x +주변 10 x +주보 10 x +주본 10 x +주부 10 x +주불 10 x +주비 10 x +주사 10 x +주사위 10 x +주석 10 x +주석 10 x +주선 10 x +주세 10 x +주세요 10 x +주소 10 x +주술 10 x +주스 10 x +주승용 10 x +주시 10 x +주식 10 x +주식 10 x +주신 10 x +주심 10 x +주어지 10 x +주어진 10 x +주어진다 10 x +주역 10 x +주역 10 x +주연 10 x +주요 10 x +주워 10 x +주워서 10 x +주웠 10 x +주웠 10 x +주위 10 x +주유 10 x +주유소 10 x +주의 10 x +주의 10 x +주의깊 10 x +주의보 10 x +주의자 10 x +주인 10 x +주인공 10 x +주일 10 x +주일 10 x +주임 10 x +주입식 10 x +주입식 10 x +주자 10 x +주장 10 x +주재 10 x +주재원 10 x +주저 10 x +주저앉 10 x +주전자 10 x +주점 10 x +주제 10 x +주조 10 x +주주 10 x +주중 10 x +주지사 10 x +주차 10 x +주차료 10 x +주차장 10 x +주창 10 x +주체 10 x +주체사상 10 x +주총 10 x +주최 10 x +주축 10 x +주춤 10 x +주치의 10 x +주택 10 x +주택 10 x +주택가 10 x +주파 10 x +주포 10 x +주필 10 x +주한 10 x +주행 10 x +주호영 10 x +죽 10 x +죽 10 x +죽여 10 x +죽여버린다고 10 x +죽였 10 x +죽음 10 x +죽이 10 x +죽인 10 x +죽일 10 x +죽임 10 x +준 10 x +준강간 10 x +준거 10 x +준공 10 x +준다 10 x +준다고 10 x +준다는 10 x +준다며 10 x +준대 10 x +준법 10 x +준비 10 x +준선 10 x +준설 10 x +준위 10 x +준호 10 x +줄 10 x +줄거리 10 x +줄게 10 x +줄곧 10 x +줄곧 10 x +줄기 10 x +줄기세포 10 x +줄까 10 x +줄께 10 x +줄께요 10 x +줄다리기 10 x +줄래 10 x +줄래요 10 x +줄리엣 10 x +줄무늬 10 x +줄어드 10 x +줄어든 10 x +줄어든다고 10 x +줄어들 10 x +줄여 10 x +줄여야 10 x +줄였 10 x +줄이 10 x +줄일 10 x +줄줄이 10 x +줄지 10 x +줌 10 x +줍 10 x +줍 10 x +줍니다 10 x +중 10 x +중간 10 x +중개 10 x +중개업 10 x +중개업자 10 x +중개인 10 x +중견 10 x +중계 10 x +중계방송 10 x +중고 10 x +중고차 10 x +중공업 10 x +중공업 10 x +중구 10 x +중국 10 x +중국 10 x +중국식 10 x +중국어 10 x +중국인 10 x +중국적 10 x +중금속 10 x +중기 10 x +중남미 10 x +중년 10 x +중단 10 x +중대 10 x +중도 10 x +중도금 10 x +중독 10 x +중독 10 x +중동 10 x +중랑천 10 x +중력 10 x +중력파 10 x +중력파 10 x +중립 10 x +중립 10 x +중립국 10 x +중반 10 x +중복 10 x +중부 10 x +중사 10 x +중산 10 x +중산동 10 x +중상 10 x +중상자 10 x +중선거구 10 x +중세 10 x +중소 10 x +중소기업 10 x +중소기업 10 x +중소기업청 10 x +중소형 10 x +중순 10 x +중시 10 x +중심 10 x +중심가 10 x +중심부 10 x +중심지 10 x +중심축 10 x +중앙 10 x +중앙부 10 x +중앙선관위 10 x +중앙은행 10 x +중앙은행 10 x +중앙지 10 x +중얼거리 10 x +중엽 10 x +중요 10 x +중원 10 x +중위 10 x +중장비 10 x +중재 10 x +중저가 10 x +중점 10 x +중지 10 x +중진 10 x +중진국 10 x +중진국 10 x +중폭 10 x +중폭 10 x +중학 10 x +중학교 10 x +중학생 10 x +중형 10 x +중형차 10 x +중화 10 x +중화기 10 x +중화요리 10 x +줘 10 x +줘도 10 x +줘서 10 x +줘야 10 x +줘요 10 x +줬 10 x +줬 10 x +쥐 10 x +쥐어짜 10 x +쥐페 10 x +쥔 10 x +쥔다 10 x +쥘 10 x +쥬 10 x +쥬얼 10 x +쥴 10 x +즈 10 x +즈음 10 x +즉 10 x +즉 10 x +즉각 10 x +즉각 10 x +즉석 10 x +즉석 10 x +즉시 10 x +즌스호텔 10 x +즐 10 x +즐거운 10 x +즐거움 10 x +즐거워요 10 x +즐거워하 10 x +즐거웠 10 x +즐거웠 10 x +즐겁 10 x +즐겁 10 x +즐겨 10 x +즐기 10 x +즐길 10 x +즐비 10 x +증 10 x +증가 10 x +증가세 10 x +증거 10 x +증거인 10 x +증권 10 x +증권가 10 x +증권사 10 x +증기 10 x +증기선 10 x +증대 10 x +증말 10 x +증명 10 x +증명서 10 x +증발 10 x +증빙 10 x +증상 10 x +증서 10 x +증설 10 x +증세 10 x +증시 10 x +증언 10 x +증여 10 x +증여세 10 x +증오 10 x +증인 10 x +증자 10 x +증진 10 x +증축 10 x +증폭 10 x +증폭 10 x +증호 10 x +증후군 10 x +지 10 x +지가 10 x +지각 10 x +지각력 10 x +지갑 10 x +지검 10 x +지겨워 10 x +지겹 10 x +지겹 10 x +지경 10 x +지구 10 x +지구당 10 x +지구대 10 x +지구촌 10 x +지극히 10 x +지금 10 x +지금껏 10 x +지금껏 10 x +지급 10 x +지급 10 x +지급기 10 x +지급기 10 x +지기 10 x +지나 10 x +지나가 10 x +지나간 10 x +지나갈 10 x +지나갔 10 x +지나쳤 10 x +지나치 10 x +지나친 10 x +지나친다 10 x +지나칠 10 x +지난 10 x +지난날 10 x +지난달 10 x +지난번 10 x +지난주 10 x +지난해 10 x +지난해 10 x +지날수록 10 x +지날수록 10 x +지났 10 x +지났 10 x +지내 10 x +지낸 10 x +지낼 10 x +지냅니다 10 x +지냈 10 x +지냈 10 x +지냈으며 10 x +지네 10 x +지능 10 x +지니 10 x +지닌 10 x +지대 10 x +지도 10 x +지도급 10 x +지도력 10 x +지도력 10 x +지도부 10 x +지도자 10 x +지도층 10 x +지독 10 x +지라 10 x +지라도 10 x +지렁 10 x +지렁이 10 x +지력 10 x +지로 10 x +지론 10 x +지뢰 10 x +지뢰밭 10 x +지루 10 x +지르 10 x +지름 10 x +지름길 10 x +지리 10 x +지리산 10 x +지만 10 x +지만은 10 x +지매 10 x +지면 10 x +지명 10 x +지명도 10 x +지목 10 x +지목 10 x +지문 10 x +지민 10 x +지바 10 x +지방 10 x +지방세 10 x +지방채 10 x +지배 10 x +지배자 10 x +지배층 10 x +지배했 10 x +지법 10 x +지법 10 x +지부 10 x +지부장 10 x +지분 10 x +지불 10 x +지붕 10 x +지사 10 x +지사장 10 x +지상 10 x +지상주의 10 x +지상파 10 x +지성 10 x +지성사 10 x +지속 10 x +지속 10 x +지수 10 x +지시 10 x +지식 10 x +지식 10 x +지식인 10 x +지양 10 x +지어 10 x +지에스 10 x +지엔피 10 x +지역 10 x +지역 10 x +지역구 10 x +지역민 10 x +지연 10 x +지오 10 x +지옥 10 x +지요 10 x +지우 10 x +지운 10 x +지울 10 x +지워 10 x +지원 10 x +지원금 10 x +지원병 10 x +지원자 10 x +지웠 10 x +지위 10 x +지음 10 x +지이 10 x +지인 10 x +지자제 10 x +지자체 10 x +지장 10 x +지저분 10 x +지적 10 x +지적 10 x +지적 10 x +지적도 10 x +지점 10 x +지정 10 x +지정학 10 x +지주 10 x +지주회사 10 x +지중해 10 x +지지 10 x +지지율 10 x +지지자 10 x +지지층 10 x +지진 10 x +지진파 10 x +지체 10 x +지쳐 10 x +지쳤 10 x +지출 10 x +지치 10 x +지친 10 x +지침 10 x +지칭 10 x +지카 10 x +지켜 10 x +지켜라 10 x +지켜보 10 x +지켜본 10 x +지켜볼 10 x +지켜서 10 x +지켜야 10 x +지켜준다 10 x +지켰 10 x +지키 10 x +지킨 10 x +지킨다는 10 x +지킬 10 x +지탄 10 x +지탱 10 x +지퍼 10 x +지폐 10 x +지표 10 x +지표면 10 x +지프 10 x +지핀 10 x +지하 10 x +지하경제 10 x +지하도 10 x +지하수 10 x +지하실 10 x +지하철 10 x +지하철역 10 x +지향 10 x +지혜 10 x +지호 10 x +지회 10 x +지훈 10 x +지휘 10 x +지휘부 10 x +지휘자 10 x +직 10 x +직 10 x +직각 10 x +직간접 10 x +직감 10 x +직거래 10 x +직결 10 x +직계 10 x +직공 10 x +직구 10 x +직군 10 x +직권 10 x +직급 10 x +직급 10 x +직면 10 x +직무 10 x +직물 10 x +직불 10 x +직선 10 x +직선제 10 x +직설 10 x +직속 10 x +직속 10 x +직시 10 x +직업 10 x +직업 10 x +직업훈련 10 x +직영 10 x +직원 10 x +직위 10 x +직장 10 x +직장 10 x +직장인 10 x +직전 10 x +직전 10 x +직접 10 x +직접 10 x +직접 10 x +직접 10 x +직종 10 x +직진 10 x +직진 10 x +직항 10 x +직행 10 x +직후 10 x +진 10 x +진경준 10 x +진균류 10 x +진급 10 x +진다 10 x +진다고 10 x +진다는 10 x +진다면 10 x +진단 10 x +진도 10 x +진도군 10 x +진동 10 x +진두지휘 10 x +진력 10 x +진로 10 x +진로그룹 10 x +진료 10 x +진료실 10 x +진리 10 x +진보 10 x +진부 10 x +진상 10 x +진상 10 x +진성 10 x +진솔 10 x +진수 10 x +진수희 10 x +진술 10 x +진술서 10 x +진실 10 x +진심 10 x +진압 10 x +진압 10 x +진앙 10 x +진에어 10 x +진열 10 x +진열장 10 x +진영 10 x +진욱 10 x +진욱 10 x +진위 10 x +진입 10 x +진입 10 x +진작 10 x +진작 10 x +진전 10 x +진절머리 10 x +진정 10 x +진정서 10 x +진정한 10 x +진제 10 x +진주 10 x +진중 10 x +진지 10 x +진짜 10 x +진짜로 10 x +진찰 10 x +진척 10 x +진천군 10 x +진출 10 x +진출로 10 x +진통 10 x +진퇴 10 x +진하 10 x +진하 10 x +진학 10 x +진학 10 x +진학 10 x +진학률 10 x +진학률 10 x +진한 10 x +진한 10 x +진해서 10 x +진해서 10 x +진행 10 x +진화 10 x +진화론 10 x +진흥 10 x +질 10 x +질끈 10 x +질러 10 x +질렀 10 x +질렸 10 x +질리 10 x +질린다 10 x +질문 10 x +질병 10 x +질서 10 x +질수록 10 x +질수록 10 x +질의 10 x +질주 10 x +질질 10 x +질책 10 x +질타 10 x +질투 10 x +질투심 10 x +질환 10 x +짊어진 10 x +짐 10 x +짐 10 x +짐바브웨 10 x +짐작 10 x +짐작 10 x +집 10 x +집 10 x +집값 10 x +집값 10 x +집결 10 x +집계 10 x +집권 10 x +집권 10 x +집권당 10 x +집권당 10 x +집기 10 x +집념 10 x +집니다 10 x +집단 10 x +집단 10 x +집단주의 10 x +집들이 10 x +집들이 10 x +집무실 10 x +집사 10 x +집세 10 x +집안 10 x +집안일 10 x +집약 10 x +집어넣 10 x +집어넣 10 x +집요 10 x +집중 10 x +집중 10 x +집중력 10 x +집중력 10 x +집착 10 x +집착 10 x +집필 10 x +집필진 10 x +집행 10 x +집행부 10 x +집행유예 10 x +집현전 10 x +집회 10 x +짓 10 x +짓 10 x +짓누르 10 x +징 10 x +징계 10 x +징벌 10 x +징수 10 x +징수금 10 x +징수액 10 x +징역 10 x +징역 10 x +징역형 10 x +징용 10 x +징후 10 x +짖 10 x +짖 10 x +짙 10 x +짙 10 x +짚 10 x +짚 10 x +짜 10 x +짜깁 10 x +짜깁 10 x +짜내 10 x +짜리 10 x +짜인 10 x +짜임새 10 x +짜장 10 x +짜장면 10 x +짜증 10 x +짝 10 x +짝 10 x +짝짝 10 x +짠 10 x +짠하 10 x +짠하 10 x +짤 10 x +짧 10 x +짬 10 x +짬뽕 10 x +짰 10 x +짰 10 x +짱 10 x +짱구 10 x +째 10 x +쩌 10 x +쩍 10 x +쩐 10 x +쩔 10 x +쩜 10 x +쪄서 10 x +쪘 10 x +쪘 10 x +쪼 10 x +쪼개 10 x +쪼그려 10 x +쪽 10 x +쪽 10 x +쪽지 10 x +쫄 10 x +쫌 10 x +쫓 10 x +쫓 10 x +쫓겨났 10 x +쫓겨났 10 x +쫓기 10 x +쫓기 10 x +쫓아가 10 x +쫓아다니 10 x +쫙 10 x +쫙 10 x +쭉 10 x +쭉 10 x +쯤 10 x +찌 10 x +찌개 10 x +찌그러지 10 x +찌든 10 x +찌르 10 x +찌른 10 x +찌른다 10 x +찌푸려 10 x +찌푸리 10 x +찍 10 x +찍 10 x +찍혔 10 x +찍힌 10 x +찐 10 x +찔러 10 x +찔렀 10 x +찔렸 10 x +찔렸 10 x +찔린 10 x +찢 10 x +찢 10 x +찢긴 10 x +찢긴 10 x +찢어져 10 x +찢어지 10 x +찧 10 x +찧 10 x +차 10 x +차가운 10 x +차가워 10 x +차가워서 10 x +차갑 10 x +차관 10 x +차근차근 10 x +차기 10 x +차단 10 x +차등 10 x +차라리 10 x +차량 10 x +차령 10 x +차례 10 x +차리 10 x +차릴 10 x +차림 10 x +차림새 10 x +차마 10 x +차명 10 x +차범근 10 x +차별 10 x +차분 10 x +차분히 10 x +차사 10 x +차선 10 x +차세대 10 x +차아 10 x +차원 10 x +차은택 10 x +차이 10 x +차이나 10 x +차이점 10 x +차익 10 x +차입 10 x +차장 10 x +차제 10 x +차종 10 x +차지 10 x +차질 10 x +차차 10 x +차체 10 x +차축 10 x +차츰 10 x +차치 10 x +착 10 x +착각 10 x +착공 10 x +착륙 10 x +착륙 10 x +착수 10 x +착신 10 x +착실 10 x +착실히 10 x +착오 10 x +착용 10 x +착잡 10 x +착점 10 x +착착 10 x +착하 10 x +착한 10 x +찬 10 x +찬가 10 x +찬란 10 x +찬물 10 x +찬미 10 x +찬바람 10 x +찬반 10 x +찬사 10 x +찬성 10 x +찬장 10 x +찮은 10 x +찰 10 x +찰스 10 x +찰찰 10 x +참 10 x +참가 10 x +참가자 10 x +참견 10 x +참고 10 x +참고서 10 x +참고인 10 x +참관 10 x +참기름 10 x +참깨 10 x +참담 10 x +참된 10 x +참모 10 x +참모총장 10 x +참배 10 x +참사 10 x +참새 10 x +참석 10 x +참석 10 x +참석자 10 x +참석자 10 x +참신 10 x +참여 10 x +참여연대 10 x +참으 10 x +참으로 10 x +참을성 10 x +참작 10 x +참작 10 x +참정 10 x +참정권 10 x +참조 10 x +참치 10 x +참패 10 x +찻값 10 x +찻값 10 x +찻잔 10 x +찼 10 x +창 10 x +창가 10 x +창간 10 x +창건 10 x +창고 10 x +창구 10 x +창궐 10 x +창당 10 x +창립 10 x +창립 10 x +창문 10 x +창밖 10 x +창백 10 x +창사 10 x +창설 10 x +창시자 10 x +창업 10 x +창업 10 x +창업자 10 x +창업주 10 x +창원 10 x +창원시 10 x +창의 10 x +창의력 10 x +창의력 10 x +창자 10 x +창작 10 x +창조 10 x +창조성 10 x +창조주 10 x +창준 10 x +창출 10 x +창피 10 x +창하 10 x +찾 10 x +찾 10 x +찾아가 10 x +찾아갈 10 x +찾아갔 10 x +찾아내 10 x +찾아낼 10 x +찾아냈 10 x +찾아다녔 10 x +찾아다녔 10 x +찾아든 10 x +찾아보 10 x +찾아볼 10 x +찾아오 10 x +찾아온 10 x +찾아와 10 x +찾아와서 10 x +찾아왔 10 x +찾아왔 10 x +채 10 x +채굴 10 x +채권 10 x +채널 10 x +채무 10 x +채소 10 x +채용 10 x +채우 10 x +채워 10 x +채워진 10 x +채웠 10 x +채웠 10 x +채점 10 x +채집 10 x +채취 10 x +채택 10 x +채택 10 x +채팅 10 x +채팅 10 x +채혈 10 x +책 10 x +책 10 x +책가방 10 x +책꽂이 10 x +책무 10 x +책상 10 x +책임 10 x +책임감 10 x +책임자 10 x +책임지 10 x +책자 10 x +책장 10 x +책정 10 x +챌린지 10 x +챔피언 10 x +챔피언십 10 x +챙겨 10 x +챙겨볼 10 x +챙겨서 10 x +챙겨요 10 x +챙겼 10 x +챙기 10 x +챠 10 x +처 10 x +처녀 10 x +처단 10 x +처럼 10 x +처리 10 x +처리장 10 x +처방 10 x +처벌 10 x +처분 10 x +처사 10 x +처신 10 x +처우 10 x +처음 10 x +처장 10 x +처절 10 x +처져 10 x +처지 10 x +처참 10 x +처한 10 x +처할 10 x +처해 10 x +처형 10 x +척 10 x +척 10 x +척결 10 x +척수 10 x +척추 10 x +척하 10 x +천 10 x +천 10 x +천국 10 x +천국 10 x +천둥 10 x +천만 10 x +천문학 10 x +천문학 10 x +천문학 10 x +천민 10 x +천사 10 x +천성 10 x +천안 10 x +천안문 10 x +천억 10 x +천억 10 x +천연 10 x +천연가스 10 x +천연자원 10 x +천운 10 x +천장 10 x +천재 10 x +천정배 10 x +천정부지 10 x +천조 10 x +천지 10 x +천천히 10 x +천천히 10 x +천하 10 x +천하 10 x +천하제일 10 x +천하제일 10 x +천황 10 x +철 10 x +철강 10 x +철거 10 x +철거민 10 x +철교 10 x +철도 10 x +철문 10 x +철수 10 x +철원 10 x +철재 10 x +철저 10 x +철저히 10 x +철제 10 x +철조망 10 x +철책 10 x +철통같 10 x +철폐 10 x +철학 10 x +철학 10 x +철학자 10 x +철회 10 x +첨 10 x +첨가 10 x +첨단 10 x +첨병 10 x +첨부 10 x +첨예 10 x +첨지 10 x +첩보 10 x +첫 10 x +첫 10 x +첫날 10 x +첫눈 10 x +첫인상 10 x +첫째 10 x +첫출발 10 x +첫해 10 x +청 10 x +청각 10 x +청결 10 x +청계 10 x +청구 10 x +청나라 10 x +청남대 10 x +청년 10 x +청년단 10 x +청도군 10 x +청량리 10 x +청력 10 x +청렴 10 x +청문 10 x +청문회 10 x +청바지 10 x +청법 10 x +청사 10 x +청사진 10 x +청산 10 x +청소 10 x +청소기 10 x +청소년 10 x +청소부 10 x +청순 10 x +청신호 10 x +청아 10 x +청약 10 x +청약 10 x +청와대 10 x +청운동 10 x +청원 10 x +청원경찰 10 x +청원군 10 x +청장 10 x +청주 10 x +청주시 10 x +청중 10 x +청춘 10 x +청취 10 x +청취자 10 x +청탁 10 x +청하 10 x +청학 10 x +청해진 10 x +청혼 10 x +체 10 x +체감 10 x +체격 10 x +체결 10 x +체계 10 x +체력 10 x +체류 10 x +체면 10 x +체벌 10 x +체불 10 x +체액 10 x +체온 10 x +체육 10 x +체육 10 x +체육관 10 x +체육인 10 x +체인 10 x +체인점 10 x +체자 10 x +체장 10 x +체제 10 x +체조 10 x +체중 10 x +체증 10 x +체질 10 x +체첸 10 x +체첸인 10 x +체코 10 x +체크 10 x +체포 10 x +체포영장 10 x +체하 10 x +체해 10 x +체험 10 x +체형 10 x +첸시 10 x +첼시 10 x +쳇바퀴 10 x +쳇바퀴 10 x +쳐 10 x +쳐낸 10 x +쳐다보 10 x +쳐다본다 10 x +쳐들 10 x +쳐라 10 x +쳐서 10 x +쳐야 10 x +쳤 10 x +쳤 10 x +초 10 x +초강대국 10 x +초고 10 x +초과 10 x +초기 10 x +초긴장 10 x +초대 10 x +초등 10 x +초등학생 10 x +초딩 10 x +초딩 10 x +초라 10 x +초래 10 x +초려 10 x +초롱 10 x +초반 10 x +초밥 10 x +초밥 10 x +초보 10 x +초봉 10 x +초상 10 x +초선 10 x +초소 10 x +초순 10 x +초안 10 x +초월 10 x +초유 10 x +초임 10 x +초입 10 x +초저 10 x +초저녁 10 x +초점 10 x +초조 10 x +초청 10 x +초코 10 x +초코파이 10 x +초콜릿 10 x +초토화 10 x +초하루 10 x +초호 10 x +촉각 10 x +촉각 10 x +촉감 10 x +촉구 10 x +촉망 10 x +촉매 10 x +촉박 10 x +촉발 10 x +촉진 10 x +촉촉 10 x +촌 10 x +촌 10 x +촌스러운 10 x +촌스럽 10 x +촌스럽 10 x +촘촘 10 x +촛불 10 x +촛불 10 x +총 10 x +총각 10 x +총격 10 x +총격사건 10 x +총격전 10 x +총경 10 x +총괄 10 x +총국 10 x +총궐기 10 x +총기 10 x +총대 10 x +총리 10 x +총무 10 x +총무원장 10 x +총무처 10 x +총부리 10 x +총상 10 x +총생산 10 x +총선 10 x +총선거 10 x +총수 10 x +총알 10 x +총액 10 x +총액 10 x +총연장 10 x +총장 10 x +총재 10 x +총체 10 x +총총 10 x +총탄 10 x +총통 10 x +총통화 10 x +총파업 10 x +총학생회 10 x +총회 10 x +촤하 10 x +촬영 10 x +최 10 x +최강 10 x +최고 10 x +최고 10 x +최고위 10 x +최고위층 10 x +최고점 10 x +최근 10 x +최근호 10 x +최대 10 x +최대한 10 x +최동원 10 x +최룡해 10 x +최루탄 10 x +최문순 10 x +최병국 10 x +최빈국 10 x +최상 10 x +최선 10 x +최소 10 x +최소한 10 x +최순 10 x +최신 10 x +최신예 10 x +최신형 10 x +최신형 10 x +최악 10 x +최악 10 x +최영광 10 x +최우선 10 x +최장 10 x +최재성 10 x +최저 10 x +최저임금 10 x +최저치 10 x +최적 10 x +최전방 10 x +최종 10 x +최준용 10 x +최지용 10 x +최진녕 10 x +최초 10 x +최태민 10 x +최태원 10 x +최현열 10 x +최형우 10 x +최후 10 x +쵝오 10 x +추 10 x +추가 10 x +추격 10 x +추격 10 x +추계 10 x +추구 10 x +추궁 10 x +추기경 10 x +추도식 10 x +추돌 10 x +추락 10 x +추락 10 x +추론 10 x +추모 10 x +추모객 10 x +추모제 10 x +추미애 10 x +추방 10 x +추산 10 x +추상 10 x +추석 10 x +추석 10 x +추세 10 x +추수 10 x +추신수 10 x +추악 10 x +추억 10 x +추우 10 x +추운 10 x +추운데 10 x +추울 10 x +추워 10 x +추워서 10 x +추월 10 x +추웠 10 x +추웠 10 x +추위 10 x +추이 10 x +추자 10 x +추잡 10 x +추잡 10 x +추적 10 x +추적 10 x +추정 10 x +추종 10 x +추진 10 x +추진력 10 x +추징 10 x +추천 10 x +추첨 10 x +추출 10 x +추측 10 x +추카 10 x +추후 10 x +축 10 x +축구 10 x +축구장 10 x +축복 10 x +축복 10 x +축복 10 x +축사 10 x +축산 10 x +축산물 10 x +축소 10 x +축열기 10 x +축의금 10 x +축재 10 x +축재 10 x +축적 10 x +축전 10 x +축제 10 x +축제 10 x +축축 10 x +축출 10 x +축하 10 x +춘 10 x +춘시 10 x +춘우 10 x +춘천 10 x +춘천시 10 x +출 10 x +출간 10 x +출격 10 x +출고가 10 x +출구 10 x +출국 10 x +출근 10 x +출근길 10 x +출동 10 x +출두 10 x +출마 10 x +출발 10 x +출발지 10 x +출범 10 x +출사 10 x +출사표 10 x +출산 10 x +출생 10 x +출석 10 x +출석 10 x +출세 10 x +출시 10 x +출신 10 x +출신자 10 x +출연 10 x +출입 10 x +출입 10 x +출입구 10 x +출입구 10 x +출입문 10 x +출입자 10 x +출자 10 x +출장 10 x +출전 10 x +출점 10 x +출책 10 x +출처 10 x +출토 10 x +출퇴근 10 x +출판 10 x +출판물 10 x +출판사 10 x +출품 10 x +출한 10 x +출현 10 x +춤 10 x +춤추 10 x +춥 10 x +춥 10 x +충 10 x +충격 10 x +충격 10 x +충고 10 x +충남 10 x +충당 10 x +충돌 10 x +충동 10 x +충만 10 x +충무 10 x +충북 10 x +충북 10 x +충분 10 x +충분히 10 x +충분히 10 x +충성 10 x +충실 10 x +충전 10 x +충전기 10 x +충정 10 x +충족 10 x +충족 10 x +충주 10 x +충청 10 x +충치 10 x +충칭 10 x +췄 10 x +취 10 x +취급 10 x +취득 10 x +취득 10 x +취득세 10 x +취득자 10 x +취록 10 x +취미 10 x +취소 10 x +취약 10 x +취약점 10 x +취업 10 x +취업 10 x +취업자 10 x +취업자 10 x +취임 10 x +취임식 10 x +취재 10 x +취재진 10 x +취지 10 x +취직 10 x +취직 10 x +취하 10 x +취한 10 x +취할 10 x +취합니다 10 x +취해 10 x +취해야 10 x +취했 10 x +취했 10 x +취향 10 x +츄 10 x +츠 10 x +측 10 x +측 10 x +측근 10 x +측면 10 x +측정 10 x +측정 10 x +층 10 x +층수 10 x +층층 10 x +치 10 x +치가 10 x +치과 10 x +치는 10 x +치닫 10 x +치닫 10 x +치달 10 x +치러 10 x +치러야 10 x +치러진 10 x +치렀 10 x +치렀 10 x +치료 10 x +치료법 10 x +치료제 10 x +치루 10 x +치뤄 10 x +치르 10 x +치른다 10 x +치를 10 x +치마 10 x +치매 10 x +치명 10 x +치명상 10 x +치밀 10 x +치법 10 x +치부 10 x +치사 10 x +치사죄 10 x +치솟 10 x +치솟 10 x +치아 10 x +치안 10 x +치안정감 10 x +치약 10 x +치열 10 x +치욕 10 x +치우 10 x +치운 10 x +치워 10 x +치웠 10 x +치유 10 x +치이 10 x +치장 10 x +치적 10 x +치제 10 x +치중 10 x +치즈 10 x +치카오 10 x +치킨 10 x +치통 10 x +치프 10 x +치하 10 x +치히 10 x +칙령 10 x +칙칙 10 x +칙칙 10 x +칙칙 10 x +친 10 x +친구 10 x +친권 10 x +친근 10 x +친다 10 x +친부 10 x +친분 10 x +친서방 10 x +친절 10 x +친정 10 x +친족 10 x +친족 10 x +친지 10 x +친척 10 x +친하 10 x +친하 10 x +친한 10 x +친한 10 x +친할머니 10 x +친할머니 10 x +친해 10 x +친해 10 x +친환경 10 x +칠 10 x +칠레 10 x +칠월 10 x +칠판 10 x +침 10 x +침공 10 x +침대 10 x +침략 10 x +침례 10 x +침몰 10 x +침묵 10 x +침묵 10 x +침범 10 x +침수 10 x +침식 10 x +침식 10 x +침실 10 x +침입 10 x +침전물 10 x +침착 10 x +침체 10 x +침출수 10 x +침투 10 x +침해 10 x +칩거 10 x +칫솔 10 x +칭 10 x +칭찬 10 x +칭호 10 x +ㅋ 10 x +ㅋㅋ 10 x +카 10 x +카날 10 x +카노 10 x +카도 10 x +카드 10 x +카레 10 x +카로 10 x +카르도소 10 x +카르멘 10 x +카르타고 10 x +카르텔 10 x +카를로스 10 x +카리스마 10 x +카메라 10 x +카메룬 10 x +카빈 10 x +카세트테이프 10 x +카스 10 x +카스트 10 x +카약 10 x +카운터 10 x +카이 10 x +카이라 10 x +카자흐 10 x +카자흐스탄 10 x +카츠미 10 x +카카오 10 x +카타니아 10 x +카타르 10 x +카톡 10 x +카톡 10 x +카톨릭 10 x +카트 10 x +카트만두 10 x +카페 10 x +카페베네 10 x +카페인 10 x +카펫 10 x +카펫 10 x +카프카 10 x +칸 10 x +칼 10 x +칼국수 10 x +칼날 10 x +칼데라 10 x +칼라 10 x +칼럼 10 x +칼로리 10 x +칼리 10 x +캄보디아 10 x +캄캄 10 x +캉 10 x +캐 10 x +캐나다 10 x +캐나다인 10 x +캐런 10 x +캐롤 10 x +캐롤리 10 x +캐릭터 10 x +캐릭터 10 x +캐머런 10 x +캐물 10 x +캐미 10 x +캐비닛 10 x +캐스팅 10 x +캐주얼 10 x +캐피탈 10 x +캔 10 x +캘러 10 x +캘리포니아 10 x +캠 10 x +캠퍼스 10 x +캠페인 10 x +캠프 10 x +캠프파이어 10 x +캠핑카 10 x +커 10 x +커녕 10 x +커닝엄 10 x +커다란 10 x +커다랗 10 x +커뮤니케이션 10 x +커뮤니티 10 x +커뮤니티 10 x +커미션 10 x +커밍스 10 x +커버 10 x +커서 10 x +커요 10 x +커져 10 x +커졌 10 x +커졌 10 x +커지 10 x +커진다는 10 x +커질 10 x +커트 10 x +커튼 10 x +커플 10 x +커피 10 x +커피숍 10 x +커피숍 10 x +컨 10 x +컨디션 10 x +컨버스 10 x +컨버터블 10 x +컨베이어 10 x +컨벤션 10 x +컨설팅 10 x +컨설팅 10 x +컨셉 10 x +컨소시엄 10 x +컨테이너 10 x +컨트롤 10 x +컬 10 x +컬러 10 x +컬렉션 10 x +컴 10 x +컴백 10 x +컴퓨터 10 x +컵 10 x +컵 10 x +컷 10 x +컸 10 x +컸 10 x +케 10 x +케냐 10 x +케미 10 x +케빈 10 x +케슬린 10 x +케어 10 x +케이 10 x +케이블 10 x +케이블카 10 x +케이스 10 x +케이크 10 x +케이트 10 x +케이프 10 x +켄 10 x +켄타 10 x +켈리 10 x +켈리스 10 x +켐 10 x +켐프 10 x +켑의 10 x +켓 10 x +켓 10 x +켜 10 x +켜졌 10 x +켠 10 x +켤레 10 x +켰 10 x +코 10 x +코끼리 10 x +코너 10 x +코니 10 x +코드 10 x +코란 10 x +코레라 10 x +코렌 10 x +코로나 10 x +코르벳함 10 x +코르크 10 x +코리아 10 x +코리안 10 x +코메디 10 x +코미디 10 x +코미디언 10 x +코믹 10 x +코믹 10 x +코비 10 x +코스 10 x +코스닥 10 x +코스모스 10 x +코스피 10 x +코시 10 x +코앞 10 x +코엑스 10 x +코치 10 x +코카인 10 x +코코 10 x +코코넛 10 x +코코넛 10 x +코트 10 x +코페르니쿠스 10 x +코펜하겐 10 x +코펜하겐 10 x +코프 10 x +코피 10 x +코피노 10 x +콕 10 x +콘 10 x +콘돔 10 x +콘서트 10 x +콘크리트 10 x +콘텐츠 10 x +콜 10 x +콜라 10 x +콜로 10 x +콜롬비아 10 x +콜린 10 x +콤팩트 10 x +콤플렉스 10 x +콧등 10 x +콧물 10 x +콩 10 x +콩나물 10 x +콴 10 x +쾌감 10 x +쾌유 10 x +쾌적 10 x +쾌활 10 x +쿠 10 x +쿠다 10 x +쿠데 10 x +쿠데타 10 x +쿠마라퉁가 10 x +쿠바 10 x +쿠스 10 x +쿠어스 10 x +쿠웨이트 10 x +쿠키 10 x +쿠팡 10 x +쿠폰 10 x +쿡 10 x +쿨 10 x +쿨럭 10 x +쿨리발리 10 x +쿵 10 x +쿼터 10 x +쿼터백 10 x +퀄리티 10 x +퀘스트 10 x +퀴즈 10 x +큐 10 x +큐레이터 10 x +크 10 x +크기 10 x +크라잉넛 10 x +크래시 10 x +크래프트 10 x +크레도스 10 x +크레인 10 x +크로마뇽인 10 x +크로아티아 10 x +크롤 10 x +크루 10 x +크루즈 10 x +크리 10 x +크리스 10 x +크리스마스 10 x +크리스마스트리 10 x +크리스탈 10 x +크리스토퍼 10 x +크리스티 10 x +크리스티 10 x +크릭 10 x +크릭 10 x +크림 10 x +크림 10 x +크림빵 10 x +크크 10 x +큰 10 x +큰돈 10 x +큰손 10 x +큰일 10 x +큰절 10 x +클 10 x +클라 10 x +클라우디우스 10 x +클라이밍 10 x +클라인 10 x +클래런스 10 x +클래식 10 x +클러스터 10 x +클럽 10 x +클럽 10 x +클로 10 x +클로징 10 x +클리닉 10 x +클리닉 10 x +클릭 10 x +클릭 10 x +클린턴 10 x +클수록 10 x +클수록 10 x +키 10 x +키갈리 10 x +키는 10 x +키로 10 x +키루스 10 x +키보드 10 x +키스 10 x +키오스 10 x +키우 10 x +키운 10 x +키울 10 x +키워 10 x +키워진 10 x +키웠 10 x +키웠 10 x +키키 10 x +킥 10 x +킨텍스 10 x +킬 10 x +킬러 10 x +킬로 10 x +킬로그램 10 x +킬로미터 10 x +킬로와트 10 x +킴스 10 x +킹 10 x +킹마 10 x +킹메이커 10 x +킹콩 10 x +타 10 x +타개 10 x +타격 10 x +타격 10 x +타결 10 x +타고난 10 x +타고났 10 x +타고났 10 x +타깃 10 x +타당 10 x +타도 10 x +타락 10 x +타마 10 x +타사 10 x +타스 10 x +타오르 10 x +타운 10 x +타워 10 x +타원 10 x +타율 10 x +타이 10 x +타이머 10 x +타이밍 10 x +타이어 10 x +타이즈 10 x +타이트한 10 x +타이틀 10 x +타인 10 x +타임 10 x +타임스 10 x +타입 10 x +타자 10 x +타진 10 x +타카 10 x +타파 10 x +타협 10 x +타협안 10 x +타히티 10 x +타히티 10 x +탁 10 x +탁 10 x +탁상 10 x +탁아 10 x +탁월 10 x +탁자 10 x +탄 10 x +탄강 10 x +탄광 10 x +탄다 10 x +탄도 10 x +탄력 10 x +탄력 10 x +탄산 10 x +탄생 10 x +탄소 10 x +탄수화물 10 x +탄압 10 x +탄야 10 x +탄자니아 10 x +탄주 10 x +탄탄 10 x +탄핵 10 x +탄핵 10 x +탄핵 10 x +탄핵 10 x +탄환 10 x +탈 10 x +탈당 10 x +탈락 10 x +탈락 10 x +탈리아 10 x +탈바꿈 10 x +탈북자 10 x +탈선 10 x +탈세 10 x +탈주 10 x +탈진 10 x +탈출 10 x +탈취 10 x +탈퇴 10 x +탈피 10 x +탐 10 x +탐구 10 x +탐사 10 x +탐색 10 x +탐욕 10 x +탐욕 10 x +탐정 10 x +탐지 10 x +탐지기 10 x +탐탁 10 x +탐험가 10 x +탑 10 x +탑승 10 x +탑승권 10 x +탑시다 10 x +탑재 10 x +탓 10 x +탔 10 x +탔 10 x +탕 10 x +탕감 10 x +탕면 10 x +태 10 x +태국 10 x +태권 10 x +태권도 10 x +태그 10 x +태극기 10 x +태도 10 x +태동 10 x +태미 10 x +태블릿 10 x +태세 10 x +태아 10 x +태양 10 x +태양계 10 x +태양광 10 x +태양력 10 x +태양력 10 x +태양절 10 x +태양풍 10 x +태어나 10 x +태어난 10 x +태어날 10 x +태어났 10 x +태어났 10 x +태우 10 x +태운 10 x +태울 10 x +태워 10 x +태조 10 x +태즈메이니아 10 x +태평양 10 x +태평양전쟁 10 x +태풍 10 x +태화관 10 x +택배 10 x +택시 10 x +택지 10 x +택하 10 x +택한 10 x +택해 10 x +택했 10 x +탤런트 10 x +탬버린 10 x +탱고 10 x +탱크 10 x +터 10 x +터널 10 x +터다 10 x +터뜨렸 10 x +터뜨리 10 x +터미널 10 x +터져 10 x +터졌 10 x +터주 10 x +터지 10 x +터진 10 x +터질 10 x +터질까 10 x +터짐 10 x +터키 10 x +턱 10 x +턱 10 x +턴 10 x +털 10 x +털기 10 x +털린 10 x +털어놓 10 x +털어놓 10 x +털어놨 10 x +털어놨 10 x +텀블러 10 x +텃밭 10 x +텃밭 10 x +텅 10 x +테 10 x +테니스 10 x +테러 10 x +테러단 10 x +테러리스트 10 x +테레자 10 x +테마 10 x +테마파크 10 x +테살로니키 10 x +테스트 10 x +테이블 10 x +테이크 10 x +테이프 10 x +테크놀로지 10 x +테크닉 10 x +테타 10 x +테헤란 10 x +텍 10 x +텍 10 x +텐 10 x +텐데 10 x +텐데요 10 x +텐센트 10 x +텐트 10 x +텔레비전 10 x +텔레콤 10 x +템 10 x +템스강 10 x +토 10 x +토끼 10 x +토대 10 x +토레이 10 x +토로 10 x +토록 10 x +토록 10 x +토론 10 x +토론토 10 x +토론회 10 x +토르 10 x +토마토 10 x +토막 10 x +토막 10 x +토목 10 x +토목 10 x +토미 10 x +토사 10 x +토실 10 x +토양 10 x +토요 10 x +토요일 10 x +토의 10 x +토익 10 x +토지 10 x +토지세 10 x +토착 10 x +토크 10 x +토토 10 x +토트넘 10 x +토할 10 x +톡 10 x +톡 10 x +톡톡히 10 x +톡톡히 10 x +톤 10 x +톰 10 x +통 10 x +통계 10 x +통계청 10 x +통과 10 x +통관 10 x +통로 10 x +통명 10 x +통보 10 x +통사 10 x +통산 10 x +통상 10 x +통수 10 x +통신 10 x +통신망 10 x +통신문 10 x +통신부 10 x +통신비 10 x +통신사 10 x +통신원 10 x +통역 10 x +통영 10 x +통용 10 x +통유리 10 x +통일 10 x +통일민주당 10 x +통일세 10 x +통일원 10 x +통장 10 x +통제 10 x +통제력 10 x +통증 10 x +통째 10 x +통찰 10 x +통찰력 10 x +통첩 10 x +통치 10 x +통치권자 10 x +통치자 10 x +통통 10 x +통폐합 10 x +통풍 10 x +통하 10 x +통한 10 x +통한다 10 x +통합 10 x +통합 10 x +통해 10 x +통해서 10 x +통해요 10 x +통했 10 x +통행 10 x +통행증 10 x +통화 10 x +퇴 10 x +퇴거 10 x +퇴근 10 x +퇴근길 10 x +퇴로 10 x +퇴색 10 x +퇴역 10 x +퇴원 10 x +퇴임 10 x +퇴장 10 x +퇴정 10 x +퇴직 10 x +퇴직 10 x +퇴직금 10 x +퇴진 10 x +퇴짜 10 x +퇴출 10 x +퇴치 10 x +투 10 x +투고 10 x +투기 10 x +투기꾼 10 x +투기성 10 x +투데이 10 x +투명 10 x +투사 10 x +투성이 10 x +투수 10 x +투숙객 10 x +투스 10 x +투신 10 x +투신사 10 x +투어 10 x +투옥 10 x +투입 10 x +투자 10 x +투자액 10 x +투자액 10 x +투자은행 10 x +투자은행 10 x +투자자 10 x +투쟁 10 x +투정 10 x +투족 10 x +투족 10 x +투치 10 x +투치족 10 x +투표 10 x +투표함 10 x +툭 10 x +툭툭 10 x +툭툭 10 x +툭하면 10 x +툴 10 x +툴툴 10 x +퉁 10 x +튀 10 x +튀기 10 x +튀어나온 10 x +튕겨 10 x +튕기 10 x +튜브 10 x +튜브 10 x +트 10 x +트라 10 x +트래블 10 x +트랙 10 x +트럭 10 x +트럭 10 x +트럼프 10 x +트렌드 10 x +트리 10 x +트리트 10 x +트립 10 x +트빌리시 10 x +트위터 10 x +트윗 10 x +트윗 10 x +트이 10 x +트친 10 x +특가 10 x +특감 10 x +특강 10 x +특검 10 x +특공 10 x +특공대 10 x +특권 10 x +특권층 10 x +특급 10 x +특단 10 x +특례 10 x +특명 10 x +특목고 10 x +특별 10 x +특별 10 x +특별기 10 x +특별법 10 x +특별복 10 x +특별시 10 x +특별히 10 x +특사 10 x +특색 10 x +특선 10 x +특성 10 x +특수 10 x +특수전 10 x +특위 10 x +특유 10 x +특이 10 x +특전 10 x +특정 10 x +특정 10 x +특정인 10 x +특조 10 x +특종 10 x +특집 10 x +특집 10 x +특징 10 x +특징 10 x +특파원 10 x +특파원 10 x +특판 10 x +특판 10 x +특허 10 x +특혜 10 x +특혜관세 10 x +특화 10 x +특히 10 x +튼튼 10 x +틀 10 x +틀렸 10 x +틀리 10 x +틀린 10 x +틀릴 10 x +틀림 10 x +틀림없 10 x +틀림없이 10 x +틀어쥐 10 x +틀어진 10 x +틈 10 x +틈나 10 x +틈바구니 10 x +틈새 10 x +티 10 x +티 10 x +티거 10 x +티나 10 x +티나 10 x +티브이 10 x +티브이 10 x +티비 10 x +티비 10 x +티셔츠 10 x +티스 10 x +티스 10 x +티에리 10 x +티저 10 x +티케이 10 x +티켓 10 x +티켓 10 x +티켓 10 x +티키 10 x +티키 10 x +틱 10 x +틴 10 x +틴 10 x +틸로 10 x +틸로 10 x +팀 10 x +팀 10 x +팀장 10 x +팀장 10 x +팀제 10 x +팀쿡 10 x +팀쿡 10 x +팁 10 x +팁 10 x +팅 10 x +팅 10 x +파 10 x +파가 10 x +파격 10 x +파견 10 x +파고 10 x +파고들 10 x +파괴 10 x +파괴력 10 x +파국 10 x +파급 10 x +파도 10 x +파동 10 x +파라 10 x +파란 10 x +파란만장 10 x +파란색 10 x +파랗 10 x +파랗 10 x +파리 10 x +파마 10 x +파면 10 x +파멸 10 x +파문 10 x +파묻혀 10 x +파묻혀 10 x +파바로티 10 x +파바로티 10 x +파벌 10 x +파병 10 x +파뿌리 10 x +파산 10 x +파생 10 x +파스트 10 x +파시즘 10 x +파악 10 x +파악 10 x +파업 10 x +파업 10 x +파운드 10 x +파운드화 10 x +파울리나 10 x +파워 10 x +파월 10 x +파이 10 x +파이낸셜 10 x +파이팅 10 x +파이프 10 x +파인 10 x +파일 10 x +파장 10 x +파출소 10 x +파쿠 10 x +파크 10 x +파키스탄 10 x +파탄 10 x +파트너 10 x +파티 10 x +파티 10 x +파편 10 x +파행 10 x +팍 10 x +판 10 x +판검사 10 x +판결 10 x +판다고 10 x +판단 10 x +판단력 10 x +판매 10 x +판매량 10 x +판매원 10 x +판매자 10 x +판명 10 x +판문점 10 x +판사 10 x +판세 10 x +판소리 10 x +판정 10 x +판첸 10 x +판촉 10 x +판촉 10 x +판타스틱 10 x +판타스틱 10 x +팔 10 x +팔꿈치 10 x +팔다리 10 x +팔레비 10 x +팔레스타인 10 x +팔려 10 x +팔려서 10 x +팔렸 10 x +팔렸 10 x +팔로우 10 x +팔리 10 x +팔린 10 x +팔릴 10 x +팔목 10 x +팔아넘겨 10 x +팔아먹 10 x +팔월 10 x +팔찌 10 x +팜 10 x +팜플렛 10 x +팝 10 x +팝송 10 x +팡팡 10 x +패 10 x +패권 10 x +패기 10 x +패널 10 x +패러글라이딩 10 x +패러다임 10 x +패러디 10 x +패러디 10 x +패밀리 10 x +패배 10 x +패션 10 x +패스 10 x +패스트푸드 10 x +패였 10 x +패였 10 x +패자 10 x +패전 10 x +패키지 10 x +패터슨 10 x +패턴 10 x +패해 10 x +패혈증 10 x +팩 10 x +팩 10 x +팩스 10 x +팬 10 x +팬덤 10 x +팬티 10 x +팰리스 10 x +팻말 10 x +팽 10 x +팽배 10 x +팽창 10 x +팽팽 10 x +퍼 10 x +퍼거슨 10 x +퍼뜨렸 10 x +퍼뜨리 10 x +퍼부 10 x +퍼붓 10 x +퍼붓 10 x +퍼블리셔 10 x +퍼센트 10 x +퍼센트포인트 10 x +퍼스널 10 x +퍼스트 10 x +퍼시픽 10 x +퍼져 10 x +퍼져나갔 10 x +퍼져나갔 10 x +퍼졌 10 x +퍼졌 10 x +퍼졌으면 10 x +퍼지 10 x +퍼펙트 10 x +퍼포먼스 10 x +퍽 10 x +펀 10 x +펀드 10 x +펄 10 x +펄럭였 10 x +펌프 10 x +펑펑 10 x +페니키아 10 x +페달 10 x +페레스 10 x +페레이라 10 x +페르 10 x +페리 10 x +페미니즘 10 x +페소 10 x +페스 10 x +페스티벌 10 x +페스티벌 10 x +페어차일드 10 x +페이 10 x +페이스 10 x +페이스북 10 x +페이스북 10 x +페이지 10 x +페이커 10 x +페인트 10 x +페테 10 x +페트로 10 x +펙 10 x +펙 10 x +펜 10 x +펠리페 10 x +펭귄 10 x +펴 10 x +펴낸 10 x +펴냈 10 x +펴냈 10 x +편 10 x +편견 10 x +편도선 10 x +편리 10 x +편법 10 x +편법 10 x +편성 10 x +편승 10 x +편안 10 x +편안히 10 x +편안히 10 x +편의 10 x +편의점 10 x +편입 10 x +편입 10 x +편제 10 x +편중 10 x +편지 10 x +편집 10 x +편집장 10 x +편집장 10 x +편차 10 x +편파 10 x +편하 10 x +편하 10 x +편한 10 x +편한 10 x +편할 10 x +편할 10 x +편해요 10 x +편했 10 x +편향 10 x +편향 10 x +편협 10 x +편협 10 x +편히 10 x +편히 10 x +펼 10 x +펼쳐 10 x +펼쳐져 10 x +펼쳐지 10 x +펼쳐진 10 x +펼쳐진다 10 x +펼쳐질 10 x +펼쳤 10 x +펼쳤 10 x +펼치 10 x +펼칠 10 x +폄 10 x +폈 10 x +폈으나 10 x +평 10 x +평가 10 x +평균 10 x +평균수 10 x +평등 10 x +평론가 10 x +평민 10 x +평범 10 x +평상시 10 x +평생 10 x +평소 10 x +평수 10 x +평안 10 x +평야 10 x +평양 10 x +평양냉면 10 x +평온 10 x +평의회 10 x +평일 10 x +평점 10 x +평준 10 x +평창 10 x +평택 10 x +평평 10 x +평행 10 x +평행봉 10 x +평화 10 x +평화선 10 x +평화주의 10 x +평화통일 10 x +폐 10 x +폐기 10 x +폐기물 10 x +폐렴 10 x +폐로 10 x +폐막 10 x +폐쇄 10 x +폐쇄성 10 x +폐수 10 x +폐암 10 x +폐업 10 x +폐업 10 x +폐지 10 x +폐해 10 x +폐허 10 x +포 10 x +포괄 10 x +포근 10 x +포기 10 x +포기 10 x +포대 10 x +포도 10 x +포도주 10 x +포럼 10 x +포로 10 x +포르노 10 x +포마 10 x +포말 10 x +포상 10 x +포석 10 x +포석 10 x +포섭 10 x +포수 10 x +포스 10 x +포스코 10 x +포스터 10 x +포스트 10 x +포스트잇 10 x +포스팅 10 x +포시 10 x +포옹 10 x +포용 10 x +포우 10 x +포위 10 x +포인트 10 x +포장 10 x +포장마차 10 x +포장재 10 x +포즈 10 x +포지션 10 x +포진 10 x +포착 10 x +포착 10 x +포천 10 x +포철 10 x +포커 10 x +포켓 10 x +포켓 10 x +포켓몬 10 x +포크 10 x +포탄 10 x +포탈 10 x +포토 10 x +포트사이드 10 x +포폴 10 x +포퓰리즘 10 x +포함 10 x +포항 10 x +포항시 10 x +포화 10 x +폭 10 x +폭 10 x +폭격 10 x +폭격기 10 x +폭넓 10 x +폭동 10 x +폭락 10 x +폭력 10 x +폭력 10 x +폭로 10 x +폭리 10 x +폭발 10 x +폭발 10 x +폭발물 10 x +폭발물 10 x +폭설 10 x +폭스 10 x +폭스바겐 10 x +폭스콘 10 x +폭언 10 x +폭염 10 x +폭우 10 x +폭탄 10 x +폭탄 10 x +폭파 10 x +폭포 10 x +폭포 10 x +폭풍 10 x +폭풍 10 x +폭행 10 x +폰 10 x +폰페이 10 x +폴 10 x +폴라 10 x +폴란드 10 x +폴리 10 x +폴리스 10 x +폼 10 x +퐁 10 x +표 10 x +표결 10 x +표기 10 x +표류 10 x +표면 10 x +표명 10 x +표방 10 x +표본 10 x +표시 10 x +표시판 10 x +표적 10 x +표적 10 x +표정 10 x +표제 10 x +표준 10 x +표준어 10 x +표지 10 x +표지판 10 x +표차 10 x +표창 10 x +표출 10 x +표해야 10 x +표현 10 x +푸 10 x +푸념 10 x +푸드 10 x +푸르 10 x +푸른 10 x +푸야 10 x +푸에르토리코 10 x +푸짐 10 x +푹 10 x +푼 10 x +풀 10 x +풀려 10 x +풀려나 10 x +풀려났 10 x +풀려났 10 x +풀렸 10 x +풀리 10 x +풀린 10 x +풀릴 10 x +풀밭 10 x +풀뿌리 10 x +풀이 10 x +품 10 x +품귀 10 x +품명 10 x +품목 10 x +품목 10 x +품위 10 x +품절 10 x +품종 10 x +품질 10 x +풋볼 10 x +풋볼 10 x +풍 10 x +풍경 10 x +풍계리 10 x +풍기 10 x +풍긴다 10 x +풍뎅이 10 x +풍력 10 x +풍문 10 x +풍부 10 x +풍비박산 10 x +풍선 10 x +풍성 10 x +풍속 10 x +풍습 10 x +풍요 10 x +풍자 10 x +풍조 10 x +풍토 10 x +풍화 10 x +퓨전 10 x +프 10 x +프라 10 x +프라이드 10 x +프라임 10 x +프란츠 10 x +프랑 10 x +프랑스 10 x +프랑스어 10 x +프랑스인 10 x +프랜신 10 x +프랜차이즈 10 x +프랭클린 10 x +프레임 10 x +프로 10 x +프로그램 10 x +프로듀서 10 x +프로젝트 10 x +프로젝트 10 x +프로테스탄트 10 x +프로필 10 x +프리 10 x +프리다이빙 10 x +프리랜서 10 x +프리미엄 10 x +프리브 10 x +프리티 10 x +프린스 10 x +프린터 10 x +프톨레마이오스 10 x +픈 10 x +플 10 x +플라스틱 10 x +플라스틱 10 x +플라스틱 10 x +플라이트 10 x +플라자 10 x +플란트 10 x +플래닛 10 x +플래닛 10 x +플래카드 10 x +플랜 10 x +플랜트 10 x +플랫 10 x +플랫 10 x +플랫폼 10 x +플랫폼 10 x +플러스 10 x +플레이 10 x +플레이스 10 x +플레인 10 x +플로라 10 x +플로트 10 x +플롯 10 x +플롯 10 x +플리 10 x +피 10 x +피격 10 x +피고 10 x +피고인 10 x +피곤 10 x +피그 10 x +피난민 10 x +피드 10 x +피드백 10 x +피디 10 x +피디 10 x +피디아 10 x +피라미드 10 x +피력 10 x +피로 10 x +피로연 10 x +피마 10 x +피부 10 x +피부병 10 x +피부암 10 x +피살 10 x +피서객 10 x +피시방 10 x +피아노 10 x +피어난 10 x +피에르 10 x +피우 10 x +피워도 10 x +피워서 10 x +피웠 10 x +피웠 10 x +피의자 10 x +피임 10 x +피자 10 x +피조물 10 x +피진 10 x +피케 10 x +피케이 10 x +피켓 10 x +피크 10 x +피터 10 x +피플 10 x +피하 10 x +피한다는 10 x +피할 10 x +피해 10 x +피해액 10 x +피해야 10 x +피해자 10 x +피했 10 x +픽 10 x +핀 10 x +핀란드 10 x +핀란드어 10 x +필 10 x +필기 10 x +필두 10 x +필라델피아 10 x +필러 10 x +필름 10 x +필리핀 10 x +필립 10 x +필립 10 x +필사 10 x +필사본 10 x +필수 10 x +필수품 10 x +필연 10 x +필요 10 x +필요시 10 x +필자 10 x +필터링 10 x +핏 10 x +핏 10 x +핑 10 x +핑계 10 x +핑크 10 x +하 10 x +하강 10 x +하객 10 x +하경 10 x +하계 10 x +하고 10 x +하구 10 x +하군 10 x +하굿둑 10 x +하나 10 x +하나님 10 x +하나하나 10 x +하노이 10 x +하느님 10 x +하늘 10 x +하도 10 x +하도급 10 x +하드 10 x +하락 10 x +하락 10 x +하락세 10 x +하례 10 x +하루 10 x +하룻밤 10 x +하룻밤 10 x +하류 10 x +하르 10 x +하르트 10 x +하리 10 x +하마스 10 x +하바 10 x +하반기 10 x +하반신 10 x +하버드 10 x +하베스트 10 x +하변 10 x +하부 10 x +하비 10 x +하사 10 x +하성 10 x +하소연 10 x +하수 10 x +하수관거 10 x +하순 10 x +하시모토 10 x +하야 10 x +하얀 10 x +하얗 10 x +하여금 10 x +하여튼 10 x +하오 10 x +하와 10 x +하와이 10 x +하우사족 10 x +하우스 10 x +하원 10 x +하위 10 x +하의 10 x +하이 10 x +하이라이트 10 x +하이스 10 x +하이틴 10 x +하이퍼 10 x +하이퍼마켓 10 x +하이퍼마켓 10 x +하이힐 10 x +하재 10 x +하정우 10 x +하주 10 x +하중 10 x +하지만 10 x +하차 10 x +하찮 10 x +하천 10 x +하청 10 x +하체 10 x +하크 10 x +하타 10 x +하토야마 10 x +하트 10 x +하품 10 x +하필 10 x +하하 10 x +하행선 10 x +하향 10 x +학계 10 x +학과 10 x +학교 10 x +학교장 10 x +학급 10 x +학기 10 x +학년 10 x +학년제 10 x +학대 10 x +학대 10 x +학도 10 x +학력 10 x +학문 10 x +학번 10 x +학부 10 x +학부 10 x +학부모 10 x +학부모 10 x +학비 10 x +학살 10 x +학살자 10 x +학생 10 x +학생증 10 x +학생회 10 x +학술 10 x +학습 10 x +학습 10 x +학실 10 x +학용품 10 x +학원 10 x +학위 10 x +학자 10 x +학점 10 x +학창 10 x +학창 10 x +학파 10 x +학회 10 x +한 10 x +한가 10 x +한가요 10 x +한가위 10 x +한강 10 x +한겨레 10 x +한결 10 x +한결같이 10 x +한계 10 x +한국 10 x +한국 10 x +한국감정원 10 x +한국개발연구원 10 x +한국갤럽 10 x +한국건설 10 x +한국노총 10 x +한국방송공사 10 x +한국사 10 x +한국어 10 x +한국은행 10 x +한국은행 10 x +한국인 10 x +한국전력 10 x +한국전력 10 x +한국통신 10 x +한글 10 x +한꺼번에 10 x +한껏 10 x +한나라당 10 x +한낮 10 x +한낱 10 x +한눈 10 x +한다 10 x +한다거나 10 x +한다고 10 x +한다는 10 x +한다니 10 x +한다든가 10 x +한다든지 10 x +한다며 10 x +한다면 10 x +한다면서 10 x +한단 10 x +한단다 10 x +한대 10 x +한대요 10 x +한데 10 x +한데요 10 x +한도 10 x +한도액 10 x +한동안 10 x +한두 10 x +한때 10 x +한라그룹 10 x +한라산 10 x +한류 10 x +한마디 10 x +한마디 10 x +한명숙 10 x +한몫 10 x +한미 10 x +한바탕 10 x +한반도 10 x +한방 10 x +한번 10 x +한보 10 x +한복 10 x +한상희 10 x +한성 10 x +한센병 10 x +한센인 10 x +한술 10 x +한숨 10 x +한승수 10 x +한시 10 x +한식 10 x +한식 10 x +한신 10 x +한심 10 x +한약 10 x +한없이 10 x +한옥 10 x +한우 10 x +한은 10 x +한인 10 x +한자 10 x +한잔 10 x +한전 10 x +한정 10 x +한정판 10 x +한족 10 x +한지 10 x +한진 10 x +한쪽 10 x +한쪽 10 x +한참 10 x +한창 10 x +한창때 10 x +한총련 10 x +한층 10 x +한탄 10 x +한테 10 x +한테서 10 x +한파 10 x +한판 10 x +한편 10 x +한풀 10 x +한화 10 x +한화갑 10 x +한화그룹 10 x +한화그룹 10 x +할 10 x +할게 10 x +할게요 10 x +할까 10 x +할까요 10 x +할께요 10 x +할당제 10 x +할라고 10 x +할래 10 x +할래요 10 x +할려고 10 x +할려면은 10 x +할리우드 10 x +할매 10 x +할머니 10 x +할멈 10 x +할부 10 x +할수록 10 x +할수록 10 x +할아버지 10 x +할인 10 x +할인점 10 x +할지 10 x +할지라도 10 x +함 10 x +함구 10 x +함께 10 x +함량 10 x +함부로 10 x +함수 10 x +함안 10 x +함영진 10 x +함유 10 x +함정 10 x +함지박 10 x +함축 10 x +합 10 x +합 10 x +합격 10 x +합격 10 x +합격자 10 x +합격자 10 x +합계 10 x +합니까 10 x +합니다 10 x +합니다만 10 x +합당 10 x +합동 10 x +합동 10 x +합류 10 x +합리 10 x +합방 10 x +합법 10 x +합법 10 x +합법화 10 x +합병 10 x +합병증 10 x +합산 10 x +합성 10 x +합수부 10 x +합시다 10 x +합영법 10 x +합의 10 x +합의서 10 x +합작 10 x +합작 10 x +합작사 10 x +합참 10 x +합창곡 10 x +합쳐 10 x +합쳐도 10 x +합치 10 x +합칠 10 x +합칠 10 x +합하 10 x +합한 10 x +합해 10 x +합해서 10 x +합헌 10 x +핫 10 x +핫 10 x +핫머니 10 x +항 10 x +항간 10 x +항고 10 x +항공 10 x +항공권 10 x +항공기 10 x +항공모함 10 x +항공사 10 x +항구 10 x +항로 10 x +항만 10 x +항모 10 x +항목 10 x +항목 10 x +항법 10 x +항법사 10 x +항상 10 x +항생제 10 x +항소 10 x +항소심 10 x +항아리 10 x +항의 10 x +항이 10 x +항쟁 10 x +항해 10 x +항해사 10 x +핳 10 x +해 10 x +해결 10 x +해결책 10 x +해경 10 x +해고 10 x +해관 10 x +해군 10 x +해남 10 x +해내 10 x +해낸다 10 x +해낼 10 x +해냈 10 x +해냈 10 x +해다 10 x +해답 10 x +해당 10 x +해당자 10 x +해도 10 x +해둔 10 x +해라 10 x +해럴드 10 x +해로운 10 x +해로워 10 x +해롭 10 x +해리슨포드 10 x +해맑 10 x +해명 10 x +해발고 10 x +해방 10 x +해버림 10 x +해법 10 x +해법 10 x +해변 10 x +해병대 10 x +해부 10 x +해산 10 x +해상 10 x +해서 10 x +해서라도 10 x +해석 10 x +해석 10 x +해선 10 x +해설 10 x +해소 10 x +해수 10 x +해수면 10 x +해수부 10 x +해수욕장 10 x +해수욕장 10 x +해시 10 x +해안 10 x +해안가 10 x +해야 10 x +해야겠어 10 x +해야지 10 x +해양 10 x +해양수산부 10 x +해역 10 x +해역 10 x +해오 10 x +해온 10 x +해왔으나 10 x +해외 10 x +해외여행 10 x +해요 10 x +해용 10 x +해운 10 x +해운대 10 x +해운대구 10 x +해운업 10 x +해운정 10 x +해인 10 x +해임 10 x +해저 10 x +해적선 10 x +해제 10 x +해져 10 x +해져서 10 x +해져요 10 x +해졌 10 x +해졌 10 x +해졌으며 10 x +해준다 10 x +해지 10 x +해직 10 x +해진 10 x +해진다 10 x +해질 10 x +해짐 10 x +해집니다 10 x +해체 10 x +해치 10 x +해친다는 10 x +해칠 10 x +해커 10 x +해킹 10 x +해프닝 10 x +해해 10 x +해했 10 x +해협 10 x +핵 10 x +핵 10 x +핵무기 10 x +핵실험 10 x +핵심 10 x +핵에너지 10 x +핵연료 10 x +핵융합 10 x +핵융합 10 x +핸 10 x +핸드백 10 x +핸드폰 10 x +핸들 10 x +햄 10 x +햄버거 10 x +햇 10 x +햇 10 x +햇볕 10 x +햇볕 10 x +햇볕 10 x +햇빛 10 x +햇빛 10 x +햇빛 10 x +햇빛 10 x +햇살 10 x +했 10 x +했 10 x +했어도 10 x +했어야 10 x +했어요 10 x +했었 10 x +했었 10 x +했었어요 10 x +했으나 10 x +했으니 10 x +했으며 10 x +했으면서도 10 x +했잖아 10 x +행 10 x +행각 10 x +행군 10 x +행글라이더 10 x +행도 10 x +행동 10 x +행렬 10 x +행로 10 x +행보 10 x +행복 10 x +행복 10 x +행복감 10 x +행사 10 x +행사장 10 x +행성 10 x +행세 10 x +행실 10 x +행운 10 x +행위 10 x +행자부 10 x +행장 10 x +행적 10 x +행정 10 x +행정관 10 x +행정병 10 x +행정부 10 x +행주 10 x +행지 10 x +행진 10 x +행체 10 x +행태 10 x +행하 10 x +행할 10 x +행해 10 x +향 10 x +향기 10 x +향대 10 x +향방 10 x +향배 10 x +향상 10 x +향수 10 x +향하 10 x +향한 10 x +향해 10 x +향해야 10 x +향후 10 x +허 10 x +허가 10 x +허가증 10 x +허겁지겁 10 x +허겁지겁 10 x +허니문 10 x +허다 10 x +허덕이 10 x +허락 10 x +허름 10 x +허리 10 x +허리띠 10 x +허리뼈 10 x +허리케인 10 x +허망 10 x +허무 10 x +허물 10 x +허벅 10 x +허벅지 10 x +허세 10 x +허술 10 x +허씨 10 x +허용 10 x +허우적대 10 x +허위 10 x +허전 10 x +허점 10 x +허접 10 x +허진 10 x +허탈 10 x +허탈감 10 x +허풍 10 x +허허 10 x +헉 10 x +헌 10 x +헌 10 x +헌법 10 x +헌법 10 x +헌신 10 x +헌신짝 10 x +헌재 10 x +헌정 10 x +헌팅턴병 10 x +헌팅턴병 10 x +헐 10 x +헐떡이 10 x +헐리 10 x +험담 10 x +험지 10 x +헛 10 x +헛 10 x +헛소리 10 x +헝가리인 10 x +헤 10 x +헤드 10 x +헤드라인 10 x +헤럴드 10 x +헤매 10 x +헤맸 10 x +헤아려 10 x +헤아릴 10 x +헤어 10 x +헤어나 10 x +헤어졌 10 x +헤어지 10 x +헤어진 10 x +헤엄치 10 x +헤이그 10 x +헤즈 10 x +헤쳐 10 x +헤헤 10 x +헥 10 x +헥 10 x +헬기 10 x +헬름스 10 x +헬리콥터 10 x +헬리콥터 10 x +헬스 10 x +헬스클럽 10 x +헬스클럽 10 x +헷갈려 10 x +헷갈려요 10 x +헷갈리 10 x +헷갈리 10 x +헹궈 10 x +혀 10 x +혁명 10 x +혁신 10 x +혁신안 10 x +현 10 x +현관 10 x +현금 10 x +현기증 10 x +현대 10 x +현대그룹 10 x +현대사 10 x +현대산업개발 10 x +현대인 10 x +현대차 10 x +현대판 10 x +현명 10 x +현모 10 x +현물 10 x +현미경 10 x +현빈 10 x +현상 10 x +현석 10 x +현실 10 x +현아 10 x +현안 10 x +현역 10 x +현역 10 x +현장 10 x +현재 10 x +현저히 10 x +현존 10 x +현주소 10 x +현준 10 x +현지 10 x +현직 10 x +현직 10 x +현충일 10 x +현행 10 x +현행 10 x +현행법 10 x +현행법 10 x +현혹 10 x +현황 10 x +혈관 10 x +혈류 10 x +혈세 10 x +혈압 10 x +혈액 10 x +혈육 10 x +혈투 10 x +혐오 10 x +혐의 10 x +협 10 x +협 10 x +협동 10 x +협동조합 10 x +협력 10 x +협력 10 x +협력자 10 x +협박 10 x +협박 10 x +협상 10 x +협약 10 x +협약 10 x +협의 10 x +협의회 10 x +협정 10 x +협조 10 x +협조 10 x +협회 10 x +형 10 x +형량 10 x +형법 10 x +형사 10 x +형상 10 x +형성 10 x +형수 10 x +형식 10 x +형식 10 x +형아 10 x +형용 10 x +형용사 10 x +형제 10 x +형제자매 10 x +형태 10 x +형편 10 x +형편없 10 x +형평 10 x +혜원 10 x +혜택 10 x +혜택 10 x +호 10 x +호가 10 x +호가호위 10 x +호감 10 x +호기 10 x +호기심 10 x +호남 10 x +호대 10 x +호도 10 x +호된 10 x +호두과자 10 x +호랑이 10 x +호루라기 10 x +호르몬 10 x +호메이니 10 x +호박 10 x +호박 10 x +호박색 10 x +호박색 10 x +호반 10 x +호빵 10 x +호서 10 x +호선 10 x +호소 10 x +호소카와 10 x +호수 10 x +호수 10 x +호스 10 x +호실 10 x +호암 10 x +호우 10 x +호원 10 x +호위 10 x +호응 10 x +호의 10 x +호전 10 x +호주 10 x +호주머니 10 x +호칭 10 x +호텔 10 x +호통 10 x +호프 10 x +호황 10 x +호흡 10 x +호흡기 10 x +혹 10 x +혹독 10 x +혹독 10 x +혹사 10 x +혹시 10 x +혹시나 10 x +혹은 10 x +혼 10 x +혼났 10 x +혼다 10 x +혼동 10 x +혼란 10 x +혼미 10 x +혼신 10 x +혼외자 10 x +혼인 10 x +혼자 10 x +혼잡 10 x +혼잣말 10 x +혼합 10 x +혼합 10 x +혼혈 10 x +혼혈 10 x +홀 10 x +홀가분 10 x +홀대 10 x +홀딩스 10 x +홀로 10 x +홀로그램 10 x +홀로서기 10 x +홀어버이 10 x +홀즈 10 x +홈 10 x +홈런 10 x +홈페이지 10 x +홉 10 x +홉 10 x +홋카이도 10 x +홍 10 x +홍대 10 x +홍대앞 10 x +홍문종 10 x +홍보 10 x +홍보물 10 x +홍성우 10 x +홍수 10 x +홍승희 10 x +홍어 10 x +홍역 10 x +홍역 10 x +홍우준 10 x +홍익 10 x +홍익 10 x +홍제 10 x +홍준표 10 x +홍천 10 x +홍천군 10 x +홍콩 10 x +홍콩인 10 x +홍학 10 x +화 10 x +화가 10 x +화고 10 x +화교 10 x +화구 10 x +화끈 10 x +화나 10 x +화났 10 x +화내 10 x +화답 10 x +화답 10 x +화두 10 x +화려 10 x +화력 10 x +화면 10 x +화물 10 x +화물차 10 x +화법 10 x +화보 10 x +화북면 10 x +화분 10 x +화산 10 x +화살 10 x +화살표 10 x +화상 10 x +화석 10 x +화성 10 x +화쇄류 10 x +화승 10 x +화약 10 x +화약고 10 x +화염병 10 x +화요일 10 x +화이팅 10 x +화장 10 x +화장실 10 x +화장지 10 x +화장품 10 x +화재 10 x +화제 10 x +화질 10 x +화창 10 x +화초 10 x +화폐 10 x +화학 10 x +화학 10 x +화합 10 x +화합물 10 x +화해 10 x +화현 10 x +화환 10 x +확 10 x +확 10 x +확고 10 x +확고히 10 x +확대 10 x +확대 10 x +확률 10 x +확립 10 x +확보 10 x +확보 10 x +확산 10 x +확성기 10 x +확신 10 x +확실 10 x +확실히 10 x +확언 10 x +확연히 10 x +확연히 10 x +확인 10 x +확인서 10 x +확장 10 x +확장 10 x +확정 10 x +확진 10 x +확충 10 x +환 10 x +환갑 10 x +환경 10 x +환경관리공단 10 x +환경부 10 x +환경운동연합 10 x +환경운동연합 10 x +환경운동연합 10 x +환경청 10 x +환경친화 10 x +환구 10 x +환급 10 x +환급 10 x +환급금 10 x +환급금 10 x +환기 10 x +환담 10 x +환부 10 x +환불 10 x +환산 10 x +환상 10 x +환수 10 x +환영 10 x +환원 10 x +환율 10 x +환자 10 x +환차손 10 x +환풍기 10 x +환하 10 x +환하 10 x +환한 10 x +환한 10 x +환호 10 x +활 10 x +활기 10 x +활기차 10 x +활동 10 x +활동가 10 x +활동비 10 x +활력 10 x +활로 10 x +활발 10 x +활발히 10 x +활보 10 x +활성 10 x +활약 10 x +활용 10 x +활인 10 x +활주로 10 x +활짝 10 x +활활 10 x +황 10 x +황교안 10 x +황금 10 x +황금색 10 x +황금시간대 10 x +황당 10 x +황도 10 x +황량 10 x +황망 10 x +황모 10 x +황선 10 x +황정화 10 x +황제 10 x +황주군 10 x +황진영 10 x +황폐 10 x +황해북도 10 x +황홀감 10 x +황화수소 10 x +회 10 x +회견 10 x +회계 10 x +회계감사 10 x +회계사 10 x +회관 10 x +회교 10 x +회교도 10 x +회귀 10 x +회담 10 x +회대 10 x +회동 10 x +회복 10 x +회복 10 x +회복세 10 x +회부 10 x +회사 10 x +회사원 10 x +회사채 10 x +회상 10 x +회색 10 x +회생 10 x +회수 10 x +회식 10 x +회심 10 x +회오리 10 x +회원 10 x +회원국 10 x +회원국 10 x +회유 10 x +회의 10 x +회의 10 x +회의록 10 x +회의록 10 x +회의소 10 x +회의실 10 x +회의장 10 x +회인 10 x +회자 10 x +회장 10 x +회장단 10 x +회전 10 x +회전익 10 x +회중 10 x +회피 10 x +회항 10 x +회화 10 x +획관 10 x +획기 10 x +획기적으로 10 x +획단 10 x +획단 10 x +획득 10 x +획득 10 x +획득 10 x +획일 10 x +획정 10 x +획책 10 x +획책 10 x +횟수 10 x +횡단 10 x +횡단보도 10 x +횡령 10 x +횡포 10 x +횡행 10 x +효과 10 x +효도 10 x +효력 10 x +효모 10 x +효율 10 x +효은 10 x +효자 10 x +효험 10 x +횽 10 x +후 10 x +후각 10 x +후계 10 x +후계자 10 x +후광 10 x +후군 10 x +후기 10 x +후끈 10 x +후난 10 x +후대 10 x +후려칠 10 x +후련 10 x +후문 10 x +후미 10 x +후반 10 x +후반기 10 x +후발 10 x +후방 10 x +후배 10 x +후베이 10 x +후보 10 x +후비 10 x +후속 10 x +후속 10 x +후손 10 x +후예 10 x +후원 10 x +후원금 10 x +후원자 10 x +후원회 10 x +후유 10 x +후유증 10 x +후인 10 x +후임 10 x +후임자 10 x +후자 10 x +후진 10 x +후진국 10 x +후쿠시 10 x +후쿠시마 10 x +후쿠이현 10 x +후퇴 10 x +후폭풍 10 x +후폭풍 10 x +후회 10 x +훈 10 x +훈계 10 x +훈련 10 x +훈련장 10 x +훈춘 10 x +훈훈 10 x +훌륭 10 x +훌쩍 10 x +훌쩍 10 x +훌쩍훌쩍 10 x +훌쩍훌쩍 10 x +훑 10 x +훑어보 10 x +훔쳐 10 x +훔쳤 10 x +훔치 10 x +훗날 10 x +훤 10 x +훤하 10 x +훤히 10 x +훤히 10 x +훨씬 10 x +훼손 10 x +휘 10 x +휘날리 10 x +휘두르 10 x +휘두른 10 x +휘말려 10 x +휘말리 10 x +휘말린 10 x +휘발 10 x +휘발유 10 x +휘저 10 x +휘젓 10 x +휘청거리 10 x +휙 10 x +휙 10 x +휠체어 10 x +휩싸 10 x +휩싸였 10 x +휩싸이 10 x +휩쓸 10 x +휩쓸려 10 x +휴 10 x +휴가 10 x +휴가철 10 x +휴게소 10 x +휴교 10 x +휴대 10 x +휴대폰 10 x +휴먼 10 x +휴식 10 x +휴양지 10 x +휴업 10 x +휴일 10 x +휴전 10 x +휴정 10 x +휴지 10 x +휴지기 10 x +휴지통 10 x +휴직 10 x +휴직 10 x +휴학 10 x +흉기 10 x +흉내 10 x +흉터 10 x +흐 10 x +흐려 10 x +흐려서 10 x +흐렸 10 x +흐르 10 x +흐른 10 x +흐른다 10 x +흐를수록 10 x +흐를수록 10 x +흐름 10 x +흐리 10 x +흐린 10 x +흐뭇 10 x +흐흐 10 x +흑 10 x +흑백 10 x +흑수 10 x +흑인 10 x +흑자 10 x +흑점 10 x +흑흑 10 x +흑흑 10 x +흔드 10 x +흔들 10 x +흔들렸 10 x +흔들리 10 x +흔들림 10 x +흔적 10 x +흔적 10 x +흔쾌 10 x +흔하 10 x +흔하 10 x +흔한 10 x +흔한 10 x +흔히 10 x +흔히 10 x +흗드 10 x +흗드라는 10 x +흘겨보 10 x +흘금 10 x +흘금흘금 10 x +흘러 10 x +흘러가 10 x +흘러갔 10 x +흘러갔 10 x +흘러나오 10 x +흘러나온 10 x +흘러나와 10 x +흘러나왔 10 x +흘러내리 10 x +흘러들 10 x +흘러요 10 x +흘렀 10 x +흘렀 10 x +흘렸 10 x +흘렸 10 x +흘리 10 x +흘린 10 x +흘릴 10 x +흙 10 x +흙 10 x +흠 10 x +흠뻑 10 x +흠집 10 x +흠집 10 x +흡기 10 x +흡사 10 x +흡수 10 x +흡연 10 x +흡입구 10 x +흡입구 10 x +흥 10 x +흥덕구 10 x +흥망 10 x +흥미 10 x +흥밋거리 10 x +흥분 10 x +흥원 10 x +흥정 10 x +흥했 10 x +흥했 10 x +흥행 10 x +흩 10 x +흩 10 x +흩어져 10 x +흩어졌 10 x +흩어지 10 x +희 10 x +희곡 10 x +희대 10 x +희망 10 x +희망퇴직 10 x +희망퇴직 10 x +희미 10 x +희박 10 x +희사 10 x +희생 10 x +희생양 10 x +희생자 10 x +희소 10 x +희소식 10 x +희화 10 x +흰 10 x +흰머리 10 x +흰색 10 x +흰쌀밥 10 x +히 10 x +히가시노 10 x +히노 10 x +히다 10 x +히든카드 10 x +히로시마 10 x +히말라야 10 x +히브리 10 x +히스패닉 10 x +히스패닉 10 x +히트 10 x +히틀러 10 x +히히 10 x +힌 10 x +힌두 10 x +힌두교 10 x +힐러리 10 x +힐리스 10 x +힐링 10 x +힐튼 10 x +힘 10 x +힘겨운 10 x +힘겹 10 x +힘껏 10 x +힘껏 10 x +힘내 10 x +힘드 10 x +힘든 10 x +힘들 10 x +힘듭니다 10 x +힘쓰 10 x +힘없이 10 x +힘입 10 x +힘입 10 x +힘차 10 x +힘찬 10 x +힙 10 x +힙합 10 x +힛 10 x +힛 10 x +ㅡㅡ해튼 10 x diff --git a/vendor/qwen-asr/qwen_asr/inference/qwen3_asr.py b/vendor/qwen-asr/qwen_asr/inference/qwen3_asr.py new file mode 100644 index 00000000..d99b9151 --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/inference/qwen3_asr.py @@ -0,0 +1,830 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Union + +import numpy as np +import torch +from qwen_asr.core.transformers_backend import ( + Qwen3ASRConfig, + Qwen3ASRForConditionalGeneration, + Qwen3ASRProcessor, +) +from transformers import AutoConfig, AutoModel, AutoProcessor + +AutoConfig.register("qwen3_asr", Qwen3ASRConfig) +AutoModel.register(Qwen3ASRConfig, Qwen3ASRForConditionalGeneration) +AutoProcessor.register(Qwen3ASRConfig, Qwen3ASRProcessor) + +from .qwen3_forced_aligner import Qwen3ForcedAligner +from .utils import ( + MAX_ASR_INPUT_SECONDS, + MAX_FORCE_ALIGN_INPUT_SECONDS, + SAMPLE_RATE, + SUPPORTED_LANGUAGES, + AudioChunk, + AudioLike, + chunk_list, + merge_languages, + normalize_audios, + normalize_language_name, + parse_asr_output, + split_audio_into_chunks, + validate_language, +) + +try: + from qwen_asr.core.vllm_backend import Qwen3ASRForConditionalGeneration + from vllm import ModelRegistry + ModelRegistry.register_model("Qwen3ASRForConditionalGeneration", Qwen3ASRForConditionalGeneration) +except: + pass + + +@dataclass +class ASRTranscription: + """ + One transcription result. + + Attributes: + language (str): + Merged language string for the sample, e.g. "Chinese" or "Chinese,English". + Empty string if unknown or silent audio. + text (str): + Transcribed text. + time_stamps (Optional[Any]): + Forced aligner output (ForcedAlignResult). + Present only when return_time_stamps=True. + """ + language: str + text: str + time_stamps: Optional[Any] = None + + +@dataclass +class ASRStreamingState: + """ + Streaming ASR state for one audio stream (single utterance). + + Attributes: + unfixed_chunk_num (int): + For the first N chunks, do not use previous ASR result as prefix prompt (reset prefix to ""). + unfixed_token_num (int): + When chunk_id >= unfixed_chunk_num, rollback the last K tokens from the accumulated text + before using it as prefix prompt, to reduce boundary jitter. + chunk_size_sec (float): + Chunk size in seconds. Audio will be fed to the model in increments of this length. + chunk_size_samples (int): + Chunk size in samples at 16kHz (derived from chunk_size_sec). + chunk_id (int): + Current chunk index (0-based). + buffer (np.ndarray): + Buffered PCM samples that are not yet consumed into a full chunk. + audio_accum (np.ndarray): + Accumulated audio from the beginning of the stream up to current time (no padding). + prompt_raw (str): + Base prompt generated by chat template (with generation prompt), without appended prefix text. + context (str): + Context string. + force_language (Optional[str]): + If provided, force output to be text-only by appending "language X" in prompt_raw, + consistent with non-streaming transcribe(). + language (str): + Latest parsed language (updated after each chunk decode). Empty if unknown/silent. + text (str): + Latest parsed transcription text (updated after each chunk decode). + _raw_decoded (str): + Internal accumulated decoded raw text (before parse_asr_output normalization). + Used for rollback/token trimming and as prefix for prompting. + """ + unfixed_chunk_num: int + unfixed_token_num: int + chunk_size_sec: float + chunk_size_samples: int + + chunk_id: int + buffer: np.ndarray + audio_accum: np.ndarray + + prompt_raw: str + context: str + force_language: Optional[str] + + language: str + text: str + _raw_decoded: str + + +class Qwen3ASRModel: + """ + Unified inference wrapper for Qwen3-ASR with two backends: + - Transformers backend + - vLLM backend + + It optionally supports time stamp output via Qwen3-ForcedAligner. + + Notes: + - Each request uses a context text and exactly one audio. + - If language is provided, the prompt will force the output to be text-only by appending + "language {Language}" to the assistant prompt. + """ + + def __init__( + self, + backend: str, + model: Any, + processor: Any, + sampling_params: Optional[Any] = None, + forced_aligner: Optional[Qwen3ForcedAligner] = None, + max_inference_batch_size: int = -1, + max_new_tokens: int = 512, + ): + self.backend = backend # "transformers" | "vllm" + self.model = model + self.processor = processor + self.sampling_params = sampling_params + self.forced_aligner = forced_aligner + self.max_inference_batch_size = int(max_inference_batch_size) + self.max_new_tokens = max_new_tokens + + if backend == "transformers": + self.device = getattr(model, "device", None) + if self.device is None: + try: + self.device = next(model.parameters()).device + except StopIteration: + self.device = torch.device("cpu") + self.dtype = getattr(model, "dtype", torch.float32) + else: + self.device = None + self.dtype = None + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + forced_aligner: Optional[str] = None, + forced_aligner_kwargs: Optional[Dict[str, Any]] = None, + max_inference_batch_size: int = 32, + max_new_tokens: Optional[int] = 512, + **kwargs, + ) -> "Qwen3ASRModel": + """ + Initialize using Transformers backend. + + Args: + pretrained_model_name_or_path: + HuggingFace repo id or local directory. + forced_aligner: + Optional forced aligner model path/repo id. + forced_aligner_kwargs: + Optional kwargs forwarded to Qwen3ForcedAligner.from_pretrained(...). + max_inference_batch_size: + Batch size limit for inference. -1 means no chunking. Small values can avoid OOM. + max_new_tokens: + Maximum number of tokens to generate. + **kwargs: + Forwarded to AutoModel.from_pretrained(...). + + Returns: + Qwen3ASRModel + """ + + model = AutoModel.from_pretrained(pretrained_model_name_or_path, **kwargs) + + processor = AutoProcessor.from_pretrained(pretrained_model_name_or_path, fix_mistral_regex=True) + + forced_aligner_model = None + if forced_aligner is not None: + forced_aligner_model = Qwen3ForcedAligner.from_pretrained( + forced_aligner, **(forced_aligner_kwargs or {}) + ) + + return cls( + backend="transformers", + model=model, + processor=processor, + sampling_params=None, + forced_aligner=forced_aligner_model, + max_inference_batch_size=max_inference_batch_size, + max_new_tokens=max_new_tokens, + ) + + @classmethod + def LLM( + cls, + model: str, + forced_aligner: Optional[str] = None, + forced_aligner_kwargs: Optional[Dict[str, Any]] = None, + max_inference_batch_size: int = -1, + max_new_tokens: Optional[int] = 4096, + **kwargs, + ) -> "Qwen3ASRModel": + """ + Initialize using vLLM backend. + + Import is isolated to keep vLLM optional. + + Args: + model: + Model path/repo for vLLM. + forced_aligner: + Optional forced aligner model path/repo id. + forced_aligner_kwargs: + Optional kwargs forwarded to Qwen3ForcedAligner.from_pretrained(...). + max_inference_batch_size: + Batch size limit for inference. -1 means no chunking. Small values can avoid OOM. + max_new_tokens: + Maximum number of tokens to generate. + **kwargs: + Forwarded to vllm.LLM(...). + + Returns: + Qwen3ASRModel + + Raises: + ImportError: If vLLM is not installed. + """ + try: + from vllm import LLM as vLLM + from vllm import SamplingParams + except Exception as e: + raise ImportError( + "vLLM is not available. Install with: pip install qwen-asr[vllm]" + ) from e + + llm = vLLM(model=model, **kwargs) + + processor = Qwen3ASRProcessor.from_pretrained(model, fix_mistral_regex=True) + sampling_params = SamplingParams(**({"temperature": 0.0, "max_tokens": max_new_tokens})) + + forced_aligner_model = None + if forced_aligner is not None: + forced_aligner_model = Qwen3ForcedAligner.from_pretrained( + forced_aligner, **(forced_aligner_kwargs or {}) + ) + + return cls( + backend="vllm", + model=llm, + processor=processor, + sampling_params=sampling_params, + forced_aligner=forced_aligner_model, + max_inference_batch_size=max_inference_batch_size, + max_new_tokens=None, + ) + + def get_supported_languages(self) -> List[str]: + """ + Returns the supported language list. + + Returns: + List[str]: Canonical language names. + """ + return list(SUPPORTED_LANGUAGES) + + @torch.no_grad() + def transcribe( + self, + audio: Union[AudioLike, List[AudioLike]], + context: Union[str, List[str]] = "", + language: Optional[Union[str, List[Optional[str]]]] = None, + return_time_stamps: bool = False, + ) -> List[ASRTranscription]: + """ + Transcribe audio with optional context and optional forced alignment timestamps. + + Args: + audio: + Audio input(s). Supported: + - str: local path / URL / base64 data url + - (np.ndarray, sr) + - list of above + context: + Context string(s). If scalar, it will be broadcast to batch size. + language: + Optional language(s). If provided, it must be in supported languages. + If scalar, it will be broadcast to batch size. + If provided, the prompt will force output to be transcription text only. + return_time_stamps: + If True, timestamps are produced via forced aligner and merged across chunks. + This requires forced_aligner initialized. + + Returns: + List[ASRTranscription]: One result per input audio. + + Raises: + ValueError: + - If return_time_stamps=True but forced_aligner is not provided. + - If language is unsupported. + - If batch sizes mismatch for context/language. + """ + if return_time_stamps and self.forced_aligner is None: + raise ValueError("return_time_stamps=True requires `forced_aligner` to be provided at initialization.") + + wavs = normalize_audios(audio) + n = len(wavs) + + ctxs = context if isinstance(context, list) else [context] + if len(ctxs) == 1 and n > 1: + ctxs = ctxs * n + if len(ctxs) != n: + raise ValueError(f"Batch size mismatch: audio={n}, context={len(ctxs)}") + + langs_in: List[Optional[str]] + if language is None: + langs_in = [None] * n + else: + langs_in = language if isinstance(language, list) else [language] + if len(langs_in) == 1 and n > 1: + langs_in = langs_in * n + if len(langs_in) != n: + raise ValueError(f"Batch size mismatch: audio={n}, language={len(langs_in)}") + + langs_norm: List[Optional[str]] = [] + for l in langs_in: + if l is None or str(l).strip() == "": + langs_norm.append(None) + else: + ln = normalize_language_name(str(l)) + validate_language(ln) + langs_norm.append(ln) + + max_chunk_sec = MAX_FORCE_ALIGN_INPUT_SECONDS if return_time_stamps else MAX_ASR_INPUT_SECONDS + + # chunk audios and record mapping + chunks: List[AudioChunk] = [] + for i, wav in enumerate(wavs): + parts = split_audio_into_chunks( + wav=wav, + sr=SAMPLE_RATE, + max_chunk_sec=max_chunk_sec, + ) + for j, (cwav, offset_sec) in enumerate(parts): + chunks.append(AudioChunk(orig_index=i, chunk_index=j, wav=cwav, sr=SAMPLE_RATE, offset_sec=offset_sec)) + + # run ASR on chunks + chunk_ctx: List[str] = [ctxs[c.orig_index] for c in chunks] + chunk_lang: List[Optional[str]] = [langs_norm[c.orig_index] for c in chunks] + chunk_wavs: List[np.ndarray] = [c.wav for c in chunks] + raw_outputs = self._infer_asr(chunk_ctx, chunk_wavs, chunk_lang) + + # parse outputs, prepare for optional alignment + per_chunk_lang: List[str] = [] + per_chunk_text: List[str] = [] + for out, forced_lang in zip(raw_outputs, chunk_lang): + lang, txt = parse_asr_output(out, user_language=forced_lang) + per_chunk_lang.append(lang) + per_chunk_text.append(txt) + + # forced alignment (optional) + per_chunk_align: List[Optional[Any]] = [None] * len(chunks) + if return_time_stamps: + to_align_audio = [] + to_align_text = [] + to_align_lang = [] + to_align_idx = [] + + for idx, (c, txt, lang_pred) in enumerate(zip(chunks, per_chunk_text, per_chunk_lang)): + if txt.strip() == "": + continue + to_align_audio.append((c.wav, c.sr)) + to_align_text.append(txt) + to_align_lang.append(lang_pred) + to_align_idx.append(idx) + + # batch align with max_inference_batch_size + aligned_results: List[Any] = [] + for a_chunk, t_chunk, l_chunk in zip( + chunk_list(to_align_audio, self.max_inference_batch_size), + chunk_list(to_align_text, self.max_inference_batch_size), + chunk_list(to_align_lang, self.max_inference_batch_size), + ): + aligned_results.extend( + self.forced_aligner.align(audio=a_chunk, text=t_chunk, language=l_chunk) + ) + + # offset fix + for k, idx in enumerate(to_align_idx): + c = chunks[idx] + r = aligned_results[k] + per_chunk_align[idx] = self._offset_align_result(r, c.offset_sec) + + # merge chunks back to original samples + out_langs: List[List[str]] = [[] for _ in range(n)] + out_texts: List[List[str]] = [[] for _ in range(n)] + out_aligns: List[List[Any]] = [[] for _ in range(n)] + + for c, lang, txt, al in zip(chunks, per_chunk_lang, per_chunk_text, per_chunk_align): + out_langs[c.orig_index].append(lang) + out_texts[c.orig_index].append(txt) + if return_time_stamps and al is not None: + out_aligns[c.orig_index].append(al) + + results: List[ASRTranscription] = [] + for i in range(n): + merged_text = "".join([t for t in out_texts[i] if t is not None]) + merged_language = merge_languages(out_langs[i]) + merged_align = None + if return_time_stamps: + merged_align = self._merge_align_results(out_aligns[i]) + results.append(ASRTranscription(language=merged_language, text=merged_text, time_stamps=merged_align)) + + return results + + def _build_messages(self, context: str, audio_payload: Any) -> List[Dict[str, Any]]: + return [ + {"role": "system", "content": context or ""}, + {"role": "user", "content": [{"type": "audio", "audio": audio_payload}]}, + ] + + def _build_text_prompt(self, context: str, force_language: Optional[str]) -> str: + """ + Build the string prompt for one request. + + If force_language is provided, "language X" is appended after the generation prompt + to request text-only output. + """ + msgs = self._build_messages(context=context, audio_payload="") + base = self.processor.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False) + if force_language: + base = base + f"language {force_language}{''}" + return base + + def _infer_asr( + self, + contexts: List[str], + wavs: List[np.ndarray], + languages: List[Optional[str]], + ) -> List[str]: + """ + Run backend inference for chunk-level items. + + Args: + contexts: List of context strings. + wavs: List of mono waveforms (np.ndarray). + languages: List of forced languages or None. + + Returns: + List[str]: Raw decoded strings (one per chunk). + """ + if self.backend == "transformers": + return self._infer_asr_transformers(contexts, wavs, languages) + if self.backend == "vllm": + return self._infer_asr_vllm(contexts, wavs, languages) + raise RuntimeError(f"Unknown backend: {self.backend}") + + def _infer_asr_transformers( + self, + contexts: List[str], + wavs: List[np.ndarray], + languages: List[Optional[str]], + ) -> List[str]: + outs: List[str] = [] + + texts = [self._build_text_prompt(context=c, force_language=fl) for c, fl in zip(contexts, languages)] + + batch_size = self.max_inference_batch_size + if batch_size is None or batch_size < 0: + batch_size = len(texts) + + for i in range(0, len(texts), batch_size): + sub_text = texts[i : i + batch_size] + sub_wavs = wavs[i : i + batch_size] + inputs = self.processor(text=sub_text, audio=sub_wavs, return_tensors="pt", padding=True) + inputs = inputs.to(self.model.device).to(self.model.dtype) + + text_ids = self.model.generate(**inputs, max_new_tokens=self.max_new_tokens) + + decoded = self.processor.batch_decode( + text_ids.sequences[:, inputs["input_ids"].shape[1]:], + skip_special_tokens=True, + clean_up_tokenization_spaces=False, + ) + outs.extend(list(decoded)) + + return outs + + def _infer_asr_vllm( + self, + contexts: List[str], + wavs: List[np.ndarray], + languages: List[Optional[str]], + ) -> List[str]: + inputs: List[Dict[str, Any]] = [] + for c, w, fl in zip(contexts, wavs, languages): + prompt = self._build_text_prompt(context=c, force_language=fl) + inputs.append({"prompt": prompt, "multi_modal_data": {"audio": [w]}}) + + outs: List[str] = [] + for batch in chunk_list(inputs, self.max_inference_batch_size): + outputs = self.model.generate(batch, sampling_params=self.sampling_params, use_tqdm=False) + for o in outputs: + outs.append(o.outputs[0].text) + return outs + + def _offset_align_result(self, result: Any, offset_sec: float) -> Any: + """ + Apply time offset to a ForcedAlignResult-like object. + + This function assumes: + - result has attribute `.items` which is a list of items with start_time/end_time in seconds. + - dataclasses are frozen in upstream implementation, so we reconstruct by type. + + Args: + result: ForcedAlignResult + offset_sec: Offset in seconds + + Returns: + ForcedAlignResult: New object with shifted timestamps. + """ + if result is None: + return None + items = [] + for it in result.items: + items.append(type(it)(text=it.text, + start_time=round(it.start_time + offset_sec, 3), + end_time=round(it.end_time + offset_sec, 3))) + return type(result)(items=items) + + def _merge_align_results(self, results: List[Any]) -> Optional[Any]: + """ + Merge multiple ForcedAlignResult objects into a single one by concatenating items. + + Args: + results: List of ForcedAlignResult + + Returns: + ForcedAlignResult or None + """ + if not results: + return None + all_items = [] + for r in results: + if r is None: + continue + all_items.extend(list(r.items)) + if not all_items: + return None + return type(results[0])(items=all_items) + + def init_streaming_state( + self, + context: str = "", + language: Optional[str] = None, + unfixed_chunk_num: int = 2, + unfixed_token_num: int = 5, + chunk_size_sec: float = 2.0, + ) -> ASRStreamingState: + """ + Initialize streaming ASR state for a single stream. + + Notes: + - Streaming ASR is supported ONLY for vLLM backend. + - Streaming ASR does NOT support timestamps (forced aligner is not used). + - Batch inference is NOT supported. + + Args: + context: + Context string. + language: + Optional forced language. If provided, it must be in supported languages. + Same behavior as transcribe(): forces text-only output via prompt suffix. + unfixed_chunk_num: + For the first N chunks, do not use previous output as prefix prompt (reset prefix to ""). + unfixed_token_num: + Roll back the last K tokens from accumulated output when using it as prefix prompt + after unfixed_chunk_num. + chunk_size_sec: + Chunk size in seconds (audio is 16k PCM). The function will internally convert it + to sample count at 16kHz. + + Returns: + ASRStreamingState: Mutable state object to be passed to streaming_transcribe() and + finish_streaming_transcribe(). + + Raises: + ValueError: + - If backend is not "vllm". + - If chunk_size_sec <= 0. + - If forced language is invalid (same validation rules as transcribe()). + """ + if self.backend != "vllm": + raise ValueError("Streaming ASR is supported only for vLLM backend (backend='vllm').") + if chunk_size_sec is None or float(chunk_size_sec) <= 0: + raise ValueError(f"chunk_size_sec must be > 0, got: {chunk_size_sec}") + + force_language = None + if language is not None and str(language).strip() != "": + ln = normalize_language_name(str(language)) + validate_language(ln) + force_language = ln + + chunk_size_samples = int(round(float(chunk_size_sec) * SAMPLE_RATE)) + chunk_size_samples = max(1, chunk_size_samples) + + prompt_raw = self._build_text_prompt(context=context, force_language=force_language) + + return ASRStreamingState( + unfixed_chunk_num=int(unfixed_chunk_num), + unfixed_token_num=int(unfixed_token_num), + chunk_size_sec=float(chunk_size_sec), + chunk_size_samples=int(chunk_size_samples), + chunk_id=0, + buffer=np.zeros((0,), dtype=np.float32), + audio_accum=np.zeros((0,), dtype=np.float32), + prompt_raw=prompt_raw, + context=context or "", + force_language=force_language, + language="", + text="", + _raw_decoded="", + ) + + def streaming_transcribe(self, pcm16k: np.ndarray, state: ASRStreamingState) -> ASRStreamingState: + """ + Streaming ASR decode step. + + This function accepts an arbitrary-length 16k PCM float numpy array (mono). + It buffers incoming samples, and whenever enough samples are accumulated to form one + full chunk (chunk_size_sec), it runs one incremental decode step and updates: + + - state.language + - state.text + + The caller only needs to keep passing audio to this function and read state.language/state.text. + + Implementation details: + - Each time a new chunk is ready, we append it to audio_accum and re-feed *all* audio seen + so far to the model (no padding). + - We update the prompt as: state.prompt_raw + prefix_text + - Prefix rollback strategy: + * If chunk_id < unfixed_chunk_num: prefix_text = "" + * Else: rollback last unfixed_token_num tokens from previously accumulated decoded text. + + Notes: + - vLLM backend only. + - No timestamps. + - Single stream only (no batching). + + Args: + pcm16k: + 16kHz mono PCM waveform (np.ndarray). Length can be any non-negative integer. + dtype can be float32/float64/int16; it will be converted to float32. + state: + Streaming state returned by init_streaming_state(). + + Returns: + ASRStreamingState: The same state object (mutated) for convenience. + + Raises: + ValueError: + If backend is not "vllm" or state is invalid. + """ + if self.backend != "vllm": + raise ValueError("streaming_transcribe() is supported only for vLLM backend (backend='vllm').") + if state is None: + raise ValueError("state must not be None. Call init_streaming_state() first.") + if pcm16k is None: + raise ValueError("pcm16k must not be None.") + + # Ensure 1D mono + x = np.asarray(pcm16k) + if x.ndim != 1: + x = x.reshape(-1) + + # Convert to float32 PCM in [-1, 1] if int16 provided + if x.dtype == np.int16: + x = (x.astype(np.float32) / 32768.0) + else: + x = x.astype(np.float32, copy=False) + + # Append to buffer + if x.shape[0] > 0: + state.buffer = np.concatenate([state.buffer, x], axis=0) + + # Consume full chunks + while state.buffer.shape[0] >= state.chunk_size_samples: + chunk = state.buffer[: state.chunk_size_samples] + state.buffer = state.buffer[state.chunk_size_samples :] + + # Accumulate audio (re-feed from start, no padding) + if state.audio_accum.shape[0] == 0: + state.audio_accum = chunk + else: + state.audio_accum = np.concatenate([state.audio_accum, chunk], axis=0) + + # Build prefix with rollback strategy + prefix = "" + if state.chunk_id < state.unfixed_chunk_num: + prefix = "" + else: + cur_ids = self.processor.tokenizer.encode(state._raw_decoded) + k = int(state.unfixed_token_num) + while True: + end_idx = max(0, len(cur_ids) - k) + prefix = self.processor.tokenizer.decode(cur_ids[:end_idx]) if end_idx > 0 else "" + if '\ufffd' not in prefix: + break + else: + if end_idx == 0: + prefix = "" + break + k += 1 + + prompt = state.prompt_raw + prefix + + # vLLM input: single item + inp = {"prompt": prompt, "multi_modal_data": {"audio": [state.audio_accum]}} + + outputs = self.model.generate([inp], sampling_params=self.sampling_params, use_tqdm=False) + gen_text = outputs[0].outputs[0].text + + # Accumulate raw decoded (then parse to lang/text) + state._raw_decoded = (prefix + gen_text) if prefix is not None else gen_text + + lang, txt = parse_asr_output(state._raw_decoded, user_language=state.force_language) + state.language = lang + state.text = txt + + state.chunk_id += 1 + + return state + + def finish_streaming_transcribe(self, state: ASRStreamingState) -> ASRStreamingState: + """ + Finish streaming ASR. + + This function flushes the remaining buffered audio in state.buffer (tail audio). + It sends the remaining samples to the model even if shorter than chunk_size_sec, + without padding. Then it updates state.language/state.text one last time. + + Notes: + - vLLM backend only. + - No timestamps. + - Single stream only. + + Args: + state: + Streaming state. + + Returns: + ASRStreamingState: Updated state (mutated). + + Raises: + ValueError: + If backend is not "vllm" or state is invalid. + """ + if self.backend != "vllm": + raise ValueError("finish_streaming_transcribe() is supported only for vLLM backend (backend='vllm').") + if state is None: + raise ValueError("state must not be None.") + + # If no remaining buffer, still return state as-is. + if state.buffer is None or state.buffer.shape[0] == 0: + return state + + tail = state.buffer + state.buffer = np.zeros((0,), dtype=np.float32) + + # Append tail to accumulated audio + if state.audio_accum.shape[0] == 0: + state.audio_accum = tail + else: + state.audio_accum = np.concatenate([state.audio_accum, tail], axis=0) + + # Prefix rollback strategy (same as per-chunk) + prefix = "" + if state.chunk_id < state.unfixed_chunk_num: + prefix = "" + else: + cur_ids = self.processor.tokenizer.encode(state._raw_decoded) + end_idx = max(1, len(cur_ids) - int(state.unfixed_token_num)) + prefix = self.processor.tokenizer.decode(cur_ids[:end_idx]) + + prompt = state.prompt_raw + prefix + inp = {"prompt": prompt, "multi_modal_data": {"audio": [state.audio_accum]}} + + outputs = self.model.generate([inp], sampling_params=self.sampling_params, use_tqdm=False) + gen_text = outputs[0].outputs[0].text + + state._raw_decoded = (prefix + gen_text) if prefix is not None else gen_text + lang, txt = parse_asr_output(state._raw_decoded, user_language=state.force_language) + state.language = lang + state.text = txt + + state.chunk_id += 1 + return state \ No newline at end of file diff --git a/vendor/qwen-asr/qwen_asr/inference/qwen3_forced_aligner.py b/vendor/qwen-asr/qwen_asr/inference/qwen3_forced_aligner.py new file mode 100644 index 00000000..76fe0433 --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/inference/qwen3_forced_aligner.py @@ -0,0 +1,483 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +import os +import unicodedata +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Union + +import nagisa +import torch +from qwen_asr.core.transformers_backend import ( + Qwen3ASRConfig, + Qwen3ASRForConditionalGeneration, + Qwen3ASRProcessor, +) +from transformers import AutoConfig, AutoModel, AutoProcessor + +from .utils import ( + AudioLike, + ensure_list, + normalize_audios, +) + + +class Qwen3ForceAlignProcessor(): + def __init__(self): + ko_dict_path = os.path.join(os.path.dirname(__file__), "assets", "korean_dict_jieba.dict") + ko_scores = {} + with open(ko_dict_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + word = line.split()[0] + ko_scores[word] = 1.0 + self.ko_score = ko_scores + self.ko_tokenizer = None + + def is_kept_char(self, ch: str) -> bool: + if ch == "'": + return True + cat = unicodedata.category(ch) + if cat.startswith("L") or cat.startswith("N"): + return True + return False + + def clean_token(self, token: str) -> str: + return "".join(ch for ch in token if self.is_kept_char(ch)) + + def is_cjk_char(self, ch: str) -> bool: + code = ord(ch) + return ( + 0x4E00 <= code <= 0x9FFF # CJK Unified Ideographs + or 0x3400 <= code <= 0x4DBF # Extension A + or 0x20000 <= code <= 0x2A6DF # Extension B + or 0x2A700 <= code <= 0x2B73F # Extension C + or 0x2B740 <= code <= 0x2B81F # Extension D + or 0x2B820 <= code <= 0x2CEAF # Extension E + or 0xF900 <= code <= 0xFAFF # Compatibility Ideographs + ) + + def tokenize_chinese_mixed(self, text: str) -> List[str]: + tokens: List[str] = [] + current_latin: List[str] = [] + + def flush_latin(): + nonlocal current_latin + if current_latin: + token = "".join(current_latin) + cleaned = self.clean_token(token) + if cleaned: + tokens.append(cleaned) + current_latin = [] + + for ch in text: + if self.is_cjk_char(ch): + flush_latin() + tokens.append(ch) + else: + if self.is_kept_char(ch): + current_latin.append(ch) + else: + flush_latin() + + flush_latin() + + return tokens + + def tokenize_japanese(self, text: str) -> List[str]: + words = nagisa.tagging(text).words + tokens: List[str] = [] + for w in words: + cleaned = self.clean_token(w) + if cleaned: + tokens.append(cleaned) + return tokens + + def tokenize_korean(self, ko_tokenizer, text: str) -> List[str]: + raw_tokens = ko_tokenizer.tokenize(text) + tokens: List[str] = [] + for w in raw_tokens: + w_clean = self.clean_token(w) + if w_clean: + tokens.append(w_clean) + return tokens + + def split_segment_with_chinese(self, seg: str) -> List[str]: + tokens: List[str] = [] + buf: List[str] = [] + + def flush_buf(): + nonlocal buf + if buf: + tokens.append("".join(buf)) + buf = [] + + for ch in seg: + if self.is_cjk_char(ch): + flush_buf() + tokens.append(ch) + else: + buf.append(ch) + + flush_buf() + return tokens + + def tokenize_space_lang(self, text: str) -> List[str]: + tokens: List[str] = [] + for seg in text.split(): + cleaned = self.clean_token(seg) + if cleaned: + tokens.extend(self.split_segment_with_chinese(cleaned)) + return tokens + + def fix_timestamp(self, data) -> List[int]: + data = data.tolist() + n = len(data) + + dp = [1] * n + parent = [-1] * n + + for i in range(1, n): + for j in range(i): + if data[j] <= data[i] and dp[j] + 1 > dp[i]: + dp[i] = dp[j] + 1 + parent[i] = j + + max_length = max(dp) + max_idx = dp.index(max_length) + + lis_indices = [] + idx = max_idx + while idx != -1: + lis_indices.append(idx) + idx = parent[idx] + lis_indices.reverse() + + is_normal = [False] * n + for idx in lis_indices: + is_normal[idx] = True + + result = data.copy() + i = 0 + + while i < n: + if not is_normal[i]: + j = i + while j < n and not is_normal[j]: + j += 1 + + anomaly_count = j - i + + if anomaly_count <= 2: + left_val = None + for k in range(i - 1, -1, -1): + if is_normal[k]: + left_val = result[k] + break + + right_val = None + for k in range(j, n): + if is_normal[k]: + right_val = result[k] + break + + for k in range(i, j): + if left_val is None: + result[k] = right_val + elif right_val is None: + result[k] = left_val + else: + result[k] = left_val if (k - (i - 1)) <= ((j) - k) else right_val + + else: + left_val = None + for k in range(i - 1, -1, -1): + if is_normal[k]: + left_val = result[k] + break + + right_val = None + for k in range(j, n): + if is_normal[k]: + right_val = result[k] + break + + if left_val is not None and right_val is not None: + step = (right_val - left_val) / (anomaly_count + 1) + for k in range(i, j): + result[k] = left_val + step * (k - i + 1) + elif left_val is not None: + for k in range(i, j): + result[k] = left_val + elif right_val is not None: + for k in range(i, j): + result[k] = right_val + + i = j + else: + i += 1 + + return [int(res) for res in result] + + def encode_timestamp(self, text: str, language: str) -> List[str]: + language = language.lower() + + if language.lower() == "japanese": + word_list = self.tokenize_japanese(text) + elif language.lower() == "korean": + if self.ko_tokenizer is None: + from soynlp.tokenizer import LTokenizer + self.ko_tokenizer = LTokenizer(scores=self.ko_score) + word_list = self.tokenize_korean(self.ko_tokenizer, text) + else: + word_list = self.tokenize_space_lang(text) + + input_text = "".join(word_list) + "" + input_text = "<|audio_start|><|audio_pad|><|audio_end|>" + input_text + + return word_list, input_text + + def parse_timestamp(self, word_list, timestamp): + timestamp_output = [] + + timestamp_fixed = self.fix_timestamp(timestamp) + for i, word in enumerate(word_list): + start_time = timestamp_fixed[i * 2] + end_time = timestamp_fixed[i * 2 + 1] + timestamp_output.append({ + "text": word, + "start_time": start_time, + "end_time": end_time + }) + + return timestamp_output + + +@dataclass(frozen=True) +class ForcedAlignItem: + """ + One aligned item span. + + Attributes: + text (str): + The aligned unit (cjk character or word) produced by the forced aligner processor. + start_time (float): + Start time in seconds. + end_time (float): + End time in seconds. + """ + text: str + start_time: int + end_time: int + + +@dataclass(frozen=True) +class ForcedAlignResult: + """ + Forced alignment output for one sample. + + Attributes: + items (List[ForcedAlignItem]): + Aligned token spans. + """ + items: List[ForcedAlignItem] + + def __iter__(self): + return iter(self.items) + + def __len__(self): + return len(self.items) + + def __getitem__(self, idx: int) -> ForcedAlignItem: + return self.items[idx] + + +class Qwen3ForcedAligner: + """ + A HuggingFace-style wrapper for Qwen3-ForcedAligner model inference. + + This wrapper provides: + - `from_pretrained()` initialization via HuggingFace AutoModel/AutoProcessor + - audio input normalization (path/URL/base64/(np.ndarray, sr)) + - batch and single-sample forced alignment + - structured output with attribute access (`.text`, `.start_time`, `.end_time`) + """ + + def __init__( + self, + model: Qwen3ASRForConditionalGeneration, + processor: Qwen3ASRProcessor, + aligner_processor: Qwen3ForceAlignProcessor, + ): + self.model = model + self.processor = processor + self.aligner_processor = aligner_processor + + self.device = getattr(model, "device", None) + if self.device is None: + try: + self.device = next(model.parameters()).device + except StopIteration: + self.device = torch.device("cpu") + + self.timestamp_token_id = int(model.config.timestamp_token_id) + self.timestamp_segment_time = float(model.config.timestamp_segment_time) + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + **kwargs, + ) -> "Qwen3ForcedAligner": + """ + Load Qwen3-ForcedAligner model and initialize processors. + + This method: + 1) Registers config/model/processor for HF auto classes. + 2) Loads the model using `AutoModel.from_pretrained(...)`. + 3) Initializes: + - HF processor (`AutoProcessor.from_pretrained(...)`) + - forced alignment text processor (`Qwen3ForceAlignProcessor()`) + + Args: + pretrained_model_name_or_path (str): + HuggingFace repo id or local directory. + **kwargs: + Forwarded to `AutoModel.from_pretrained(...)`. + Typical examples: device_map="cuda:0", dtype=torch.bfloat16. + + Returns: + Qwen3ForcedAligner: + Initialized wrapper instance. + """ + AutoConfig.register("qwen3_asr", Qwen3ASRConfig) + AutoModel.register(Qwen3ASRConfig, Qwen3ASRForConditionalGeneration) + AutoProcessor.register(Qwen3ASRConfig, Qwen3ASRProcessor) + + model = AutoModel.from_pretrained(pretrained_model_name_or_path, **kwargs) + if not isinstance(model, Qwen3ASRForConditionalGeneration): + raise TypeError( + f"AutoModel returned {type(model)}, expected Qwen3ASRForConditionalGeneration." + ) + + processor = AutoProcessor.from_pretrained(pretrained_model_name_or_path, fix_mistral_regex=True) + aligner_processor = Qwen3ForceAlignProcessor() + + return cls(model=model, processor=processor, aligner_processor=aligner_processor) + + def _to_structured_items(self, timestamp_output: List[Dict[str, Any]]) -> ForcedAlignResult: + items: List[ForcedAlignItem] = [] + for it in timestamp_output: + items.append( + ForcedAlignItem( + text=str(it.get("text", "")), + start_time=float(it.get("start_time", 0)), + end_time=float(it.get("end_time", 0)), + ) + ) + return ForcedAlignResult(items=items) + + @torch.inference_mode() + def align( + self, + audio: Union[AudioLike, List[AudioLike]], + text: Union[str, List[str]], + language: Union[str, List[str]], + ) -> List[ForcedAlignResult]: + """ + Run forced alignment for batch or single sample. + + Args: + audio: + Audio input(s). Each item supports: + - local path / https URL / base64 string + - (np.ndarray, sr) + All audios will be converted into mono 16k float32 arrays in [-1, 1]. + text: + Transcript(s) for alignment. + language: + Language(s) for each sample (e.g., "Chinese", "English"). + + Returns: + List[ForcedAlignResult]: + One result per sample. Each result contains `items`, and each token can be accessed via + `.text`, `.start_time`, `.end_time`. + """ + texts = ensure_list(text) + languages = ensure_list(language) + audios = normalize_audios(audio) + + if len(languages) == 1 and len(audios) > 1: + languages = languages * len(audios) + + if not (len(audios) == len(texts) == len(languages)): + raise ValueError( + f"Batch size mismatch: audio={len(audios)}, text={len(texts)}, language={len(languages)}" + ) + + word_lists = [] + aligner_input_texts = [] + for t, lang in zip(texts, languages): + word_list, aligner_input_text = self.aligner_processor.encode_timestamp(t, lang) + word_lists.append(word_list) + aligner_input_texts.append(aligner_input_text) + + inputs = self.processor( + text=aligner_input_texts, + audio=audios, + return_tensors="pt", + padding=True, + ) + inputs = inputs.to(self.model.device).to(self.model.dtype) + + logits = self.model.thinker(**inputs).logits + output_ids = logits.argmax(dim=-1) + + results: List[ForcedAlignResult] = [] + for input_id, output_id, word_list in zip(inputs["input_ids"], output_ids, word_lists): + masked_output_id = output_id[input_id == self.timestamp_token_id] + timestamp_ms = (masked_output_id * self.timestamp_segment_time).to("cpu").numpy() + timestamp_output = self.aligner_processor.parse_timestamp(word_list, timestamp_ms) + for it in timestamp_output: + it['start_time'] = round(it['start_time'] / 1000.0, 3) + it['end_time'] = round(it['end_time'] / 1000.0, 3) + results.append(self._to_structured_items(timestamp_output)) + + return results + + def get_supported_languages(self) -> Optional[List[str]]: + """ + List supported language names for the current model. + + This is a thin wrapper around `self.model.get_support_languages()`. + If the underlying model does not expose language constraints (returns None), + this method also returns None. + + Returns: + Optional[List[str]]: + - A sorted list of supported language names (lowercased), if available. + - None if the model does not provide supported languages. + """ + fn = getattr(self.model, "get_support_languages", None) + if not callable(fn): + return None + + langs = fn() + if langs is None: + return None + + return sorted({str(x).lower() for x in langs}) diff --git a/vendor/qwen-asr/qwen_asr/inference/utils.py b/vendor/qwen-asr/qwen_asr/inference/utils.py new file mode 100644 index 00000000..405b3d9c --- /dev/null +++ b/vendor/qwen-asr/qwen_asr/inference/utils.py @@ -0,0 +1,497 @@ +# coding=utf-8 +# Copyright 2026 The Alibaba Qwen team. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +import base64 +import io +import urllib.request +from dataclasses import dataclass +from typing import Any, Iterable, List, Optional, Tuple, Union +from urllib.parse import urlparse + +import librosa +import numpy as np +import soundfile as sf + +AudioLike = Union[ + str, # wav path / URL / base64 + Tuple[np.ndarray, int], # (waveform, sr) +] +MaybeList = Union[Any, List[Any]] + +SAMPLE_RATE = 16000 +MAX_ASR_INPUT_SECONDS = 1200 +MAX_FORCE_ALIGN_INPUT_SECONDS = 180 +MIN_ASR_INPUT_SECONDS = 0.5 +SUPPORTED_LANGUAGES: List[str] = [ + "Chinese", + "English", + "Cantonese", + "Arabic", + "German", + "French", + "Spanish", + "Portuguese", + "Indonesian", + "Italian", + "Korean", + "Russian", + "Thai", + "Vietnamese", + "Japanese", + "Turkish", + "Hindi", + "Malay", + "Dutch", + "Swedish", + "Danish", + "Finnish", + "Polish", + "Czech", + "Filipino", + "Persian", + "Greek", + "Romanian", + "Hungarian", + "Macedonian" +] +_ASR_TEXT_TAG = "" +_LANG_PREFIX = "language " + + +def normalize_language_name(language: str) -> str: + """ + Normalize language name to the canonical format used by Qwen3-ASR: + first letter uppercase, the rest lowercase (e.g., 'cHINese' -> 'Chinese'). + + Args: + language (str): Input language name. + + Returns: + str: Normalized language name. + + Raises: + ValueError: If language is empty. + """ + if language is None: + raise ValueError("language is None") + s = str(language).strip() + if not s: + raise ValueError("language is empty") + return s[:1].upper() + s[1:].lower() + + +def validate_language(language: str) -> None: + """ + Validate the language is supported. + + Args: + language (str): Canonical language name. + + Raises: + ValueError: If unsupported. + """ + if language not in SUPPORTED_LANGUAGES: + raise ValueError(f"Unsupported language: {language}. Supported: {SUPPORTED_LANGUAGES}") + + +def ensure_list(x: MaybeList) -> List[Any]: + return x if isinstance(x, list) else [x] + + +def is_url(s: str) -> bool: + try: + u = urlparse(s) + return u.scheme in ("http", "https") and bool(u.netloc) + except Exception: + return False + + +def is_probably_base64(s: str) -> bool: + if s.startswith("data:audio"): + return True + if ("/" not in s and "\\" not in s) and len(s) > 256: + return True + return False + + +def decode_base64_bytes(b64: str) -> bytes: + if "," in b64 and b64.strip().startswith("data:"): + b64 = b64.split(",", 1)[1] + return base64.b64decode(b64) + + +def load_audio_any(x: str) -> Tuple[np.ndarray, int]: + if is_url(x): + with urllib.request.urlopen(x) as resp: + audio_bytes = resp.read() + with io.BytesIO(audio_bytes) as f: + audio, sr = sf.read(f, dtype="float32", always_2d=False) + elif is_probably_base64(x): + audio_bytes = decode_base64_bytes(x) + with io.BytesIO(audio_bytes) as f: + audio, sr = sf.read(f, dtype="float32", always_2d=False) + else: + audio, sr = librosa.load(x, sr=None, mono=False) + + audio = np.asarray(audio, dtype=np.float32) + sr = int(sr) + return audio, sr + + +def to_mono(audio: np.ndarray) -> np.ndarray: + if audio.ndim == 1: + return audio + # soundfile can return shape (T, C); some pipelines use (C, T) + if audio.ndim == 2: + if audio.shape[0] <= 8 and audio.shape[1] > audio.shape[0]: + audio = audio.T + return np.mean(audio, axis=-1).astype(np.float32) + raise ValueError(f"Unsupported audio ndim={audio.ndim}") + + +def float_range_normalize(audio: np.ndarray) -> np.ndarray: + audio = audio.astype(np.float32) + if audio.size == 0: + return audio + peak = float(np.max(np.abs(audio))) + if peak == 0.0: + return audio + # If decoded audio is int-like scaled or out-of-range, normalize conservatively. + if peak > 1.0: + audio = audio / peak + audio = np.clip(audio, -1.0, 1.0) + return audio + + +def normalize_audio_input(a: AudioLike) -> np.ndarray: + """ + Normalize one audio input to mono 16k float32 waveform in [-1, 1]. + + Supported inputs: + - str: local file path / https URL / base64 audio string + - (np.ndarray, sr): waveform and sampling rate + + Returns: + np.ndarray: + Mono 16k float32 waveform in [-1, 1]. + """ + if isinstance(a, str): + audio, sr = load_audio_any(a) + elif isinstance(a, tuple) and len(a) == 2 and isinstance(a[0], np.ndarray): + audio, sr = a[0], int(a[1]) + else: + raise TypeError(f"Unsupported audio input type: {type(a)}") + + audio = to_mono(np.asarray(audio)) + if sr != SAMPLE_RATE: + audio = librosa.resample(audio, orig_sr=sr, target_sr=SAMPLE_RATE).astype(np.float32) + audio = float_range_normalize(audio) + return audio + + +def normalize_audios(audios: Union[AudioLike, List[AudioLike]]) -> List[np.ndarray]: + items = ensure_list(audios) + return [normalize_audio_input(a) for a in items] + + +def chunk_list(xs: List[Any], chunk_size: int) -> Iterable[List[Any]]: + """ + Yield chunks of a list. + + Args: + xs (List[Any]): Input list. + chunk_size (int): Chunk size. + + Yields: + List[Any]: Slices of xs. + """ + if chunk_size <= 0: + yield xs + return + for i in range(0, len(xs), chunk_size): + yield xs[i : i + chunk_size] + + +@dataclass(frozen=True) +class AudioChunk: + """ + One chunk cut from an original audio. + + Attributes: + orig_index: Index of the original sample in the input batch. + chunk_index: Index of this chunk within the original sample. + wav: Mono float32 waveform. + sr: Sampling rate. + offset_sec: Start offset of this chunk in the original audio, in seconds. + """ + orig_index: int + chunk_index: int + wav: np.ndarray + sr: int + offset_sec: float + + +def split_audio_into_chunks( + wav: np.ndarray, + sr: int, + max_chunk_sec: float, + search_expand_sec: float = 5.0, + min_window_ms: float = 100.0, +) -> List[Tuple[np.ndarray, float]]: + """ + Split a long audio into chunks close to max_chunk_sec, using a low-energy boundary. + + This implementation guarantees: + - Concatenating all returned chunks reproduces the original audio exactly + (total number of samples is identical, no overlaps, no gaps). + + Args: + wav: Mono waveform float32. + sr: Sampling rate. + max_chunk_sec: Target max chunk duration in seconds. + search_expand_sec: Boundary search half-window in seconds. + min_window_ms: Sliding window in milliseconds for energy estimation. + + Returns: + List[Tuple[np.ndarray, float]]: List of (chunk_wav, offset_sec). + """ + wav = np.asarray(wav, dtype=np.float32) + if wav.ndim > 1: + wav = np.mean(wav, axis=-1).astype(np.float32) + + total_len = int(wav.shape[0]) + total_sec = total_len / float(sr) + if total_sec <= max_chunk_sec: + return [(wav, 0.0)] + + max_len = int(max_chunk_sec * sr) + expand = int(search_expand_sec * sr) + win = max(4, int((min_window_ms / 1000.0) * sr)) + + chunks: List[Tuple[np.ndarray, float]] = [] + + start = 0 + offset_sec = 0.0 + + while (total_len - start) > max_len: + cut = start + max_len + + left = max(start, cut - expand) + right = min(total_len, cut + expand) + + if right - left <= win: + boundary = cut + else: + seg = wav[left:right] + seg_abs = np.abs(seg) + + window_sums = np.convolve(seg_abs, np.ones(win, dtype=np.float32), mode="valid") + + min_pos = int(np.argmin(window_sums)) + + wstart = min_pos + wend = min_pos + win + local = seg_abs[wstart:wend] + inner = int(np.argmin(local)) + boundary = left + wstart + inner + + boundary = int(max(boundary, start + 1)) + boundary = int(min(boundary, total_len)) + + chunk = wav[start:boundary] + chunks.append((chunk, offset_sec)) + + offset_sec += (boundary - start) / float(sr) + start = boundary + + tail = wav[start:total_len] + chunks.append((tail, offset_sec)) + + # Pad too-short chunks to at least MIN_ASR_INPUT_SECONDS (zero-padding at tail) + min_len = int(MIN_ASR_INPUT_SECONDS * sr) + padded: List[Tuple[np.ndarray, float]] = [] + for c, off in chunks: + if c.shape[0] < min_len: + pad = min_len - int(c.shape[0]) + c = np.pad(c, (0, pad), mode="constant", constant_values=0.0).astype(np.float32) + padded.append((c, off)) + chunks = padded + + return chunks + + +def detect_and_fix_repetitions(text, threshold=20): + def fix_char_repeats(s, thresh): + res = [] + i = 0 + n = len(s) + while i < n: + count = 1 + while i + count < n and s[i + count] == s[i]: + count += 1 + + if count > thresh: + res.append(s[i]) + i += count + else: + res.append(s[i:i+count]) + i += count + return ''.join(res) + + def fix_pattern_repeats(s, thresh, max_len=20): + n = len(s) + min_repeat_chars = thresh * 2 + if n < min_repeat_chars: + return s + + i = 0 + result = [] + while i <= n - min_repeat_chars: + found = False + for k in range(1, max_len + 1): + if i + k * thresh > n: + break + + pattern = s[i:i+k] + valid = True + for rep in range(1, thresh): + start_idx = i + rep * k + if s[start_idx:start_idx+k] != pattern: + valid = False + break + + if valid: + total_rep = thresh + end_index = i + thresh * k + while end_index + k <= n and s[end_index:end_index+k] == pattern: + total_rep += 1 + end_index += k + result.append(pattern) + result.append(fix_pattern_repeats(s[end_index:], thresh, max_len)) + i = n + found = True + break + + if found: + break + else: + result.append(s[i]) + i += 1 + + if not found: + result.append(s[i:]) + return ''.join(result) + + text_raw = text + text = fix_char_repeats(text_raw, threshold) + text = fix_pattern_repeats(text, threshold) + return text + + +def parse_asr_output( + raw: str, + user_language: Optional[str] = None, +) -> Tuple[str, str]: + """ + Parse Qwen3-ASR raw output into (language, text). + + Cases: + - With tag: "language Chinese...." + - With newlines: "language Chinese\\n...\\n...." + - No tag: treat whole string as text. + - "language None": treat as empty audio -> ("", "") + + If user_language is provided, language is forced to user_language and raw is treated as text-only + (the model is expected to output plain transcription without metadata). + + Args: + raw: Raw decoded string. + user_language: Canonical language name if user forced language. + + Returns: + Tuple[str, str]: (language, text) + """ + if raw is None: + return "", "" + s = str(raw).strip() + if not s: + return "", "" + + s = detect_and_fix_repetitions(s) + + if user_language: + # user explicitly forced language => model output is treated as pure text + return user_language, s + + meta_part = s + text_part = "" + has_tag = _ASR_TEXT_TAG in s + if has_tag: + meta_part, text_part = s.split(_ASR_TEXT_TAG, 1) + else: + # no tag => pure text + return "", s.strip() + + meta_lower = meta_part.lower() + + # empty audio heuristic + if "language none" in meta_lower: + t = text_part.strip() + if not t: + return "", "" + # if model still returned something, keep it but language unknown + return "", t + + # extract "language xxx" from meta + lang = "" + for line in meta_part.splitlines(): + line = line.strip() + if not line: + continue + low = line.lower() + if low.startswith(_LANG_PREFIX): + val = line[len(_LANG_PREFIX):].strip() + if val: + lang = normalize_language_name(val) + break + + return lang, text_part.strip() + + +def merge_languages(langs: List[str]) -> str: + """ + Merge per-chunk languages into a compact comma-separated string, + keeping order and removing consecutive duplicates and empty entries. + + Example: + ["Chinese", "English", "English"] -> "Chinese,English" + + Args: + langs: List of canonical language names. + + Returns: + str: Merged language string. + """ + out: List[str] = [] + prev = None + for x in langs: + x = (x or "").strip() + if not x: + continue + if x == prev: + continue + out.append(x) + prev = x + return ",".join(out) diff --git a/vendor/qwen-asr/setup.cfg b/vendor/qwen-asr/setup.cfg new file mode 100644 index 00000000..8bfd5a12 --- /dev/null +++ b/vendor/qwen-asr/setup.cfg @@ -0,0 +1,4 @@ +[egg_info] +tag_build = +tag_date = 0 + diff --git a/vendor/qwen-asr/upstream-files.json b/vendor/qwen-asr/upstream-files.json new file mode 100644 index 00000000..ff2978a1 --- /dev/null +++ b/vendor/qwen-asr/upstream-files.json @@ -0,0 +1,22 @@ +{ + "LICENSE": "a44a6081c73ad75f0255bb2bb5cab74ef1829565a895a24e53a4f11290ab7655", + "MANIFEST.in": "b9b04249cc847e06fd05907c3b4faa3a1165c858ad86ac9221d61afa000af236", + "pyproject.toml": "073b776779667e112aa809df94f0dcc5b1caa3cdd031f0f9f01af29749b5df14", + "qwen_asr/__init__.py": "26897071129fd175b1851cb3407be711f79b912efaefb9dc4a19fe3e88427e94", + "qwen_asr/__main__.py": "4a340eacab567a4c5ce672d38b9b8c695088947d7e2759299266076db8791c8d", + "qwen_asr/cli/demo.py": "e129ab41de33554091786b9bf3e6c18f8a65a17ff9eb738149b5f54354d881b8", + "qwen_asr/cli/demo_streaming.py": "5c92e769c8b4b5dd5b2a95ab2c218c1cf476ecff86ac48863d28d0a4c9f02fa3", + "qwen_asr/cli/serve.py": "d599a02e977b05d9bd625d539e39889060822c64cfd1c12b614ece62c3c42c80", + "qwen_asr/core/transformers_backend/__init__.py": "6de5cac018e0c994e04e855ef7fe9ed142383e88ffdcdf80463cbc7d5028b21d", + "qwen_asr/core/transformers_backend/configuration_qwen3_asr.py": "acf6c3f1cb3dc1ea0df621a11f7df6ccca109be73b6bf51508b5052942f5bec1", + "qwen_asr/core/transformers_backend/modeling_qwen3_asr.py": "2fb5d98da1933748f5117ee05ce4e7150c9ead8154fb8e25f7af3968b853adc7", + "qwen_asr/core/transformers_backend/processing_qwen3_asr.py": "a61f1fbfc06e3dfc63a2b8d15e3b29e98b032792c3fb7d0e01c04ca9c2181ffb", + "qwen_asr/core/vllm_backend/__init__.py": "45401be955b08e1135df6ec40f5c1dc05951b17868366f8d0bf0df06cf9e7720", + "qwen_asr/core/vllm_backend/qwen3_asr.py": "dad51cde09bef35fae5304ba1e5d4cc02a199845128d9bbb7717c4206584aa60", + "qwen_asr/inference/assets/korean_dict_jieba.dict": "cc157a9ab78f92bf11ed98a69b285768d6627cf5cd57a581e6c06693effce37d", + "qwen_asr/inference/qwen3_asr.py": "0b1770f8e907b6c5a0a1e9ebce037cb63f48555f3cd15eaf6ea2078e9df41a7b", + "qwen_asr/inference/qwen3_forced_aligner.py": "fb586127507c5980d11c6d848b530e652afad3ae0707cba6eb0c90377b2a2111", + "qwen_asr/inference/utils.py": "b37fb4140e6d9be85f2ecb1552ed16136eb98a57f44b402f8b0b213cb6df2a82", + "README.md": "48c012c0551a0e46c5fd3f45f8dfc3b27f20d4f8bc8caaa50c29056bf16790b3", + "setup.cfg": "1c473cbaee8da5fc46e7f0158794af5cea4414c34a3cf3f180c2001f5e38bd3e" +} From 51c8363e7c2bc2d9198b93ade047bd10d5af7d79 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Wed, 21 Oct 2026 23:47:29 +1300 Subject: [PATCH 23/39] test(pi): keep MCP authority fixtures independent of SDK cold imports --- tests/fixtures/pi_mcp_server.py | 88 +++++++++++++++++++++---------- tests/test_pi_mcp.py | 8 +-- tests/test_pi_scoped_approvals.py | 3 +- 3 files changed, 67 insertions(+), 32 deletions(-) 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_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( From 59c379a6e9615b1cc17ba90cbfc9a8f55508ea39 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 22 Oct 2026 00:05:39 +1300 Subject: [PATCH 24/39] fix(deps): declare upstream re-exports without lint exclusions --- LICENSES/provenance.json | 2 +- tests/test_headless_inference_dependencies.py | 14 ++++++++++++-- vendor/qwen-asr.PROVENANCE.md | 12 ++++++++---- vendor/qwen-asr/qwen_asr/__init__.py | 6 +++--- .../qwen_asr/core/transformers_backend/__init__.py | 6 +++--- .../qwen_asr/core/vllm_backend/__init__.py | 2 +- 6 files changed, 28 insertions(+), 14 deletions(-) diff --git a/LICENSES/provenance.json b/LICENSES/provenance.json index fe584cb7..01bd4f0a 100644 --- a/LICENSES/provenance.json +++ b/LICENSES/provenance.json @@ -527,7 +527,7 @@ "modified": true, "release_action": "include", "gate_status": "ready", - "notes": "Inference/CLI Python sources are byte-identical to the pinned upstream sdist. Only pyproject.toml moves gradio from mandatory dependencies to demo extra; source inventory and removal condition are documented adjacent to the package." + "notes": "Inference/model/CLI implementation files are byte-identical to the pinned upstream sdist. pyproject.toml moves gradio to a demo extra; three package initializers add explicit re-export aliases with identical compiled bytecode. Reversible patch/hash checks and removal conditions are documented adjacent to the package." } ] } diff --git a/tests/test_headless_inference_dependencies.py b/tests/test_headless_inference_dependencies.py index cda53941..4d332921 100644 --- a/tests/test_headless_inference_dependencies.py +++ b/tests/test_headless_inference_dependencies.py @@ -4,6 +4,7 @@ import hashlib import importlib.util import json +import re from pathlib import Path import subprocess import sys @@ -55,8 +56,17 @@ def test_qwen_inference_sources_match_upstream_and_demo_is_optional(tmp_path): source = ROOT / 'vendor/qwen-asr' expected = json.loads((source / 'upstream-files.json').read_text()) for name, digest in expected.items(): - if name != 'pyproject.toml': - assert hashlib.sha256((source / name).read_bytes()).hexdigest() == digest, name + if name == 'pyproject.toml': + continue # Its distribution dependency boundary is asserted below. + payload = (source / name).read_bytes() + if name in {'qwen_asr/__init__.py', 'qwen_asr/core/transformers_backend/__init__.py', + 'qwen_asr/core/vllm_backend/__init__.py'}: + original, count = re.subn(rb'(?m)^(from [\w.]+ import ([A-Za-z_]\w*)) as \2$', rb'\1', payload) + assert count == (1 if '/vllm_backend/' in name else 3) + before, after = compile(original, name, 'exec'), compile(payload, name, 'exec') + assert (before.co_code, before.co_names, before.co_consts) == (after.co_code, after.co_names, after.co_consts) + payload = original + assert hashlib.sha256(payload).hexdigest() == digest, name project = tomllib.loads((source / 'pyproject.toml').read_text())['project'] assert 'gradio' not in {Requirement(item).name for item in project['dependencies']} assert {Requirement(item).name for item in project['optional-dependencies']['demo']} == {'gradio'} diff --git a/vendor/qwen-asr.PROVENANCE.md b/vendor/qwen-asr.PROVENANCE.md index 240f491e..eefd63c2 100644 --- a/vendor/qwen-asr.PROVENANCE.md +++ b/vendor/qwen-asr.PROVENANCE.md @@ -5,10 +5,14 @@ Original sdist: `https://files.pythonhosted.org/packages/7f/5b/56c5175d1a4d6ed86 SHA-256: `294893f2340dc2d58d1f1d1cb8ce26ac0a79f254805ab432dda2892bd5c8d13c`. License: Apache-2.0; original LICENSE, copyright headers, README and manifest retained. -Amadeus modification (2026-10-21): `pyproject.toml` moves `gradio` from mandatory -requirements to the `demo` extra (`gradio>=6.16.0,<7`). All `qwen_asr/**/*.py` files -and other shipped upstream inputs are byte-identical to the sdist, recorded in -`qwen-asr/upstream-files.json`. Generated upstream egg-info/PKG-INFO are omitted so +Amadeus modifications (2026-10-21–22): `pyproject.toml` moves `gradio` from mandatory +requirements to the `demo` extra (`gradio>=6.16.0,<7`). All inference/model/CLI implementation files and other shipped upstream inputs +are byte-identical to the sdist, recorded in `qwen-asr/upstream-files.json`. Three +package `__init__.py` files additionally make seven existing public re-exports +explicit (`from module import Name as Name`) to satisfy the repository lint rules +without adding an exclusion. Reversing only these aliases restores the upstream +file hashes; tests also require identical compiled bytecode, names and constants. +No public names or `__all__` values are changed. Generated upstream egg-info/PKG-INFO are omitted so build metadata comes from the patched pyproject. The upstream release version remains 0.0.6 for advisory lookup; `uv.lock` records the local directory source. The Host uses its locked uv source selection, not a metadata override or `--no-deps` diff --git a/vendor/qwen-asr/qwen_asr/__init__.py b/vendor/qwen-asr/qwen_asr/__init__.py index b8f0320e..32a9cbb8 100644 --- a/vendor/qwen-asr/qwen_asr/__init__.py +++ b/vendor/qwen-asr/qwen_asr/__init__.py @@ -17,9 +17,9 @@ qwen_asr: Qwen3-ASR package. """ -from .inference.qwen3_asr import Qwen3ASRModel -from .inference.qwen3_forced_aligner import Qwen3ForcedAligner +from .inference.qwen3_asr import Qwen3ASRModel as Qwen3ASRModel +from .inference.qwen3_forced_aligner import Qwen3ForcedAligner as Qwen3ForcedAligner -from .inference.utils import parse_asr_output +from .inference.utils import parse_asr_output as parse_asr_output __all__ = ["__version__"] diff --git a/vendor/qwen-asr/qwen_asr/core/transformers_backend/__init__.py b/vendor/qwen-asr/qwen_asr/core/transformers_backend/__init__.py index 375ae1de..1654ea8c 100644 --- a/vendor/qwen-asr/qwen_asr/core/transformers_backend/__init__.py +++ b/vendor/qwen-asr/qwen_asr/core/transformers_backend/__init__.py @@ -13,6 +13,6 @@ # 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. -from .configuration_qwen3_asr import Qwen3ASRConfig -from .modeling_qwen3_asr import Qwen3ASRForConditionalGeneration -from .processing_qwen3_asr import Qwen3ASRProcessor \ No newline at end of file +from .configuration_qwen3_asr import Qwen3ASRConfig as Qwen3ASRConfig +from .modeling_qwen3_asr import Qwen3ASRForConditionalGeneration as Qwen3ASRForConditionalGeneration +from .processing_qwen3_asr import Qwen3ASRProcessor as Qwen3ASRProcessor \ No newline at end of file diff --git a/vendor/qwen-asr/qwen_asr/core/vllm_backend/__init__.py b/vendor/qwen-asr/qwen_asr/core/vllm_backend/__init__.py index 0eef629a..55b59246 100644 --- a/vendor/qwen-asr/qwen_asr/core/vllm_backend/__init__.py +++ b/vendor/qwen-asr/qwen_asr/core/vllm_backend/__init__.py @@ -13,4 +13,4 @@ # 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. -from .qwen3_asr import Qwen3ASRForConditionalGeneration \ No newline at end of file +from .qwen3_asr import Qwen3ASRForConditionalGeneration as Qwen3ASRForConditionalGeneration \ No newline at end of file From c5aa032feb7a3a98e18442b2bc4738861238cc26 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 22 Oct 2026 00:07:34 +1300 Subject: [PATCH 25/39] docs(extensions): close local Pi and dependency blockers with r2 evidence --- .../community-preview-batch7-2026-10-21.md | 77 +++++++++++++++++++ .../external-review-handoff-2026-10-21.md | 19 +++-- docs/verification/extensions/store-v1.md | 5 +- 3 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch7-2026-10-21.md 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..38f0a959 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch7-2026-10-21.md @@ -0,0 +1,77 @@ +# 社区插件施工记录:第七批 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) 没有因此关闭。 diff --git a/docs/verification/extensions/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md index 56a500b5..9171f23c 100644 --- a/docs/verification/extensions/external-review-handoff-2026-10-21.md +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -1,13 +1,13 @@ # 插件基础设施外审交接:2026-10-21 -本轮可交付审查,**不建议据此批准合并或发布**。商店的本机源码/打包流程验收通过,Host 的 Pi 集成测试与 Python 依赖审计仍有明确阻断项。没有推送、合并主线或发布索引;用户自行决定将本地材料交给谁。 +当前 r2 可交付审查。第六批 Pi/依赖阻断项已在第七批本地修复验证;远端 CI、独立审核和公开 S3 发布门槛仍未关闭,**不是合并或发布批准**。没有推送、合并主线或发布索引;用户自行决定将本地材料交给谁。 ## 范围和入口 -请先读 [第六批施工记录](community-preview-batch6-2026-10-21.md) 与 [发布门槛](store-v1.md),再看 [获认可的计划](../../extension_community_preview_and_store_plan_2026-10-21.md)。第一至第五批记录是历史证据,不应替代最新的结果/限制。 +请先读 [第七批修复记录](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 应用/验收工具代码端点:`457b1fe70a8707e9e8db3527437ae7ea96204f4f`。后续提交仅更新记录;bundle 的精确末端由包内 `review-manifest.json` 指明。 +- Host 应用/验收工具代码端点:`59c379a6e9615b1cc17ba90cbfc9a8f55508ea39`;桌面报告受测提交为 `51c8363e`,最后的显式导出补丁另有字节码等价/真实导入检查。后续提交仅更新记录;bundle 的精确末端由包内 `review-manifest.json` 指明。 - 协议仓范围:`bf1a848448a19ed20eca029e8cc3991563e42e95..153153598a9f94e3b44a477e99c3017f978d13e5`。 - 固定目录 checker:Host `1840c8e4f646992ddaebea3139c8895f29743a8f`;Host 内目录副本 pin `58d3892d55c6a7ec6f8626153f503291bb10ab9a`。SDK/protocol pins 未变。 @@ -15,7 +15,7 @@ ## 建议优先核查 -1. **现有阻断项**:`evidence/host-tests.log` 与 `host-final-tests.log` 的不同 Pi 集成失败;`python-audit-summary.json` 的 certifi/Pillow 已知漏洞。没有放宽断言或增加忽略项。最后独立串行 Pi/MCP 与 scoped approvals 共 30 项通过,仍不能推断整组失败根因已消失或只由负载引起。Python 基线修复应另行评估 Pillow 13 API/图像处理影响、更新锁文件、重跑原生 Pi 与图像链路,并重新收口发布资格。 +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 工具验证提案。 @@ -23,11 +23,12 @@ ## 本轮可复核证据 -- Electron 576 项、Windows 固定 catalog 29 项、Docker 113 项(11 skip)通过;四类真实参考 wheel 作者旅程 12 阶段通过。 +- 当前修复: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,也不是独立人工验收。操作回执单列。 -- 全部测试首次失败与限制保留。`host-isolated-tests.log` 唯一失败来自在无 Git 源码 ZIP 中运行必须 `git ls-files` 的开发测试,不能被归类为产品启动失败;其余 194 通过。原工作树整组 Pi 失败仍独立存在。 -- Linux 容器断网构建 SDK 摘要与 Windows/现有 SDK pin 一致。Dockerfile 使用已记录镜像 digest 和协议哈希依赖清单;无特权、无个人目录/凭据、无 Docker socket 挂载。 +- 第六批历史 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 挂载。 ## 复现 @@ -39,6 +40,8 @@ Docker 只补协议/SDK 隔离:把 `protocol-source.zip` 解压后将包内 `d ## 包内容与禁止误读 +当前包名带 `-r2`,旧包原样保留。三份 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 index 4983f4b0..ad2b1cae 100644 --- a/docs/verification/extensions/store-v1.md +++ b/docs/verification/extensions/store-v1.md @@ -15,6 +15,9 @@ No public index, release, repository permission or main branch was changed. 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. @@ -37,7 +40,7 @@ not restoration of arbitrary old user credentials or profiles. | Gate | Current state | Concrete completion evidence | | --- | --- | --- | -| Host regression and dependency audit | Failed local qualification gates | Resolve the two observed Pi/MCP integration failures without weakening authority assertions; fix or explicitly assess current certifi/Pillow advisories and re-run required checks. See batch 6; store-flow passes do not override these failures. | +| 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. | From 7a6389cd85935c1fb7f6472e358eefe2bea9e27e Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 22 Oct 2026 00:19:08 +1300 Subject: [PATCH 26/39] docs(extensions): link independent dependency draft PR 252 --- .../extensions/community-preview-batch7-2026-10-21.md | 11 +++++++++++ .../extensions/external-review-handoff-2026-10-21.md | 6 ++++++ docs/verification/extensions/store-v1.md | 2 ++ 3 files changed, 19 insertions(+) diff --git a/docs/verification/extensions/community-preview-batch7-2026-10-21.md b/docs/verification/extensions/community-preview-batch7-2026-10-21.md index 38f0a959..0ee71eb1 100644 --- a/docs/verification/extensions/community-preview-batch7-2026-10-21.md +++ b/docs/verification/extensions/community-preview-batch7-2026-10-21.md @@ -75,3 +75,14 @@ Qwen 上游原始文件保留既有行尾空白,完整 `git diff --check` 会 桌面两轮使用干净 `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/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md index 9171f23c..700f5a03 100644 --- a/docs/verification/extensions/external-review-handoff-2026-10-21.md +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -13,6 +13,12 @@ 外审 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 核查) + +[草稿 PR #252](https://github.com/Code-Amadeus/Amadeus/pull/252) 已从 `main` 独立建立,head `80be1ebfc1dc03cdd466f7650baedbe6774de43a`,承接依赖补丁 `0c8150c9` + `59c379a6`。已核对 vendor/导入边界/来源测试相同及锁中的修复版本;它不包含商店或词典功能。核查时 CI 仍在运行,不能据此关闭商店远端 CI 门槛。 + +r2 包不改写,随附 `external-review-2026-10-22-pr252-addendum.zip` 提供本次 PR 快照与更新文档。独立 PR 的测试区分父审自行执行与协作提供的 CPU 模型证据,均没有被描述成 GPU 实机验收。 + ## 建议优先核查 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/`。 diff --git a/docs/verification/extensions/store-v1.md b/docs/verification/extensions/store-v1.md index ad2b1cae..4640e326 100644 --- a/docs/verification/extensions/store-v1.md +++ b/docs/verification/extensions/store-v1.md @@ -36,6 +36,8 @@ 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. Its CI was still running at that snapshot; it 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 | From c44799eaa12c983245c1551cfd6f901007997b05 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 22 Oct 2026 01:02:46 +1300 Subject: [PATCH 27/39] test(extensions): connect catalog publication to the desktop lifecycle --- docs/extension_catalog.md | 11 + docs/extension_store_demo.md | 118 ++++++ .../tests/fixtures/extensionPublication.cjs | 31 ++ release/source_release_policy.json | 9 +- tests/test_electron_model_less_smoke.py | 12 + tools/probes/extension_catalog_journey.py | 33 +- tools/probes/extension_store_demo.py | 304 +++++++++++++++ tools/probes/extension_store_roundtrip.py | 352 ++++++++++++++++++ tools/smoke_electron_model_less.py | 12 +- 9 files changed, 863 insertions(+), 19 deletions(-) create mode 100644 docs/extension_store_demo.md create mode 100644 electron/tests/fixtures/extensionPublication.cjs create mode 100644 tools/probes/extension_store_demo.py create mode 100644 tools/probes/extension_store_roundtrip.py diff --git a/docs/extension_catalog.md b/docs/extension_catalog.md index fc693e3b..a04d89fc 100644 --- a/docs/extension_catalog.md +++ b/docs/extension_catalog.md @@ -232,3 +232,14 @@ A source ZIP may run the same smoke without Git. The launcher verifies every lis 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. diff --git a/docs/extension_store_demo.md b/docs/extension_store_demo.md new file mode 100644 index 00000000..f43a9e94 --- /dev/null +++ b/docs/extension_store_demo.md @@ -0,0 +1,118 @@ +# 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 experimental Host checkout 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 the protocol experiment +at 153153598a9f94e3b44a477e99c3017f978d13e5 (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/store-demo +~~~ + +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\store-demo' +~~~ + +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. diff --git a/electron/tests/fixtures/extensionPublication.cjs b/electron/tests/fixtures/extensionPublication.cjs new file mode 100644 index 00000000..b69decb3 --- /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/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/release/source_release_policy.json b/release/source_release_policy.json index 1a152e35..2d876427 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -136,7 +136,11 @@ "electron/src/renderer/components/extensionCatalogModel.ts", "electron/src/shared/extensionCatalog.ts", "docs/extension_catalog.md", - "docs/verification/extensions/store-v1.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" ], "include_roots": [ "characters", @@ -307,7 +311,8 @@ "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/verification/extensions/community-preview-batch5-2026-10-21.md", + "docs/extension_store_demo.md" ], "exclude_globs": [ "docs/reports/**", diff --git a/tests/test_electron_model_less_smoke.py b/tests/test_electron_model_less_smoke.py index 2da4c8a3..f890a746 100644 --- a/tests/test_electron_model_less_smoke.py +++ b/tests/test_electron_model_less_smoke.py @@ -116,3 +116,15 @@ def test_source_archive_identity_checks_actual_files_without_parent_git(tmp_path (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)) diff --git a/tools/probes/extension_catalog_journey.py b/tools/probes/extension_catalog_journey.py index 340ddef5..39d7895a 100644 --- a/tools/probes/extension_catalog_journey.py +++ b/tools/probes/extension_catalog_journey.py @@ -24,25 +24,13 @@ from config.subprocess_environment import isolated_child_environment -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() +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 - base = ROOT / "build/extension-catalog-journey" / uuid.uuid4().hex[:12] - base.mkdir(parents=True) 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) @@ -70,6 +58,23 @@ def main(): ) ) (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 [ diff --git a/tools/probes/extension_store_demo.py b/tools/probes/extension_store_demo.py new file mode 100644 index 00000000..47e87674 --- /dev/null +++ b/tools/probes/extension_store_demo.py @@ -0,0 +1,304 @@ +"""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/index.json").read_text())["entries"]: + raise ValueError("Demo expects the experimental empty bootstrap") + self.current = self.repo / "catalog/index.json" + 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.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/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") + 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") + input_commit = self.commit(f"DEMO ONLY: {label} input and simulated review") + common = [ + "--host", self.config["checker_host"], "--python", self.config["checker_python"], + "--artifacts", self.artifacts, + ] + 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/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/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) + snapshot = phase / "index.json" + shutil.copyfile(self.repo / "catalog/index.json", snapshot) + self.current = snapshot + self.sequence = number + 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, + } + 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/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("--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") + 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..ecdbbea8 --- /dev/null +++ b/tools/probes/extension_store_roundtrip.py @@ -0,0 +1,352 @@ +"""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("Settings") + await button("General") + await button("Manage 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.get_by_text(package_id + " · " + version, exact=True)).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.get_by_text(package_id + " · " + old, exact=True) + 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.get_by_text(package_id + " · " + version, exact=True)).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): + # Installed cards may contain multiple packages; select its own edit button. + await product.page.get_by_role("tab", name="Installed", exact=True).click() + text = product.page.get_by_text(package_id + " · " + settings()["extensions"][package_id]["version"], exact=True) + card = product.page.locator("#extension-panel-installed .setting-card").filter(has=text) + await card.get_by_role("button", name="Configure extension", exact=True).click() + await product.page.locator("#extension-enabled").set_checked(True) + if package_id == notes: + await (product.page.locator(".settings-form-provider-choices label") + .filter(has_text="Pi").locator("input").check()) + await button("Save extension") + await expect(product.page.locator("form.settings-connection-editor")).to_have_count(0, 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") + 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/smoke_electron_model_less.py b/tools/smoke_electron_model_less.py index eb9e979a..709a250e 100644 --- a/tools/smoke_electron_model_less.py +++ b/tools/smoke_electron_model_less.py @@ -377,6 +377,8 @@ 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: @@ -396,7 +398,7 @@ def _environment(self) -> dict[str, str]: environment = model_less_backend_environment( super()._environment(), python_executable=sys.executable ) - if args.extension_fixture: + 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: @@ -436,7 +438,7 @@ def _environment(self) -> dict[str, str]: "checks": {}, } - if args.extension_fixture: + 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) @@ -490,7 +492,10 @@ 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.native_confirmations: + 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: @@ -542,6 +547,7 @@ def _parser() -> argparse.ArgumentParser: 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) From cff27e2b73acb245f377250d9225b087e026740d Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 22 Oct 2026 01:10:10 +1300 Subject: [PATCH 28/39] docs(extensions): record complete local store publication roundtrip --- .../community-preview-batch8-2026-10-22.md | 76 +++++++++++++++++++ .../external-review-handoff-2026-10-21.md | 19 +++-- docs/verification/extensions/store-v1.md | 15 +++- release/source_release_policy.json | 6 +- 4 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch8-2026-10-22.md 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..2242b591 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch8-2026-10-22.md @@ -0,0 +1,76 @@ +# 社区插件施工记录:第八批完整本地商店闭环 + +日期: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 仍为空,未发布任何插件。 diff --git a/docs/verification/extensions/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md index 700f5a03..8706e782 100644 --- a/docs/verification/extensions/external-review-handoff-2026-10-21.md +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -1,13 +1,22 @@ # 插件基础设施外审交接:2026-10-21 -当前 r2 可交付审查。第六批 Pi/依赖阻断项已在第七批本地修复验证;远端 CI、独立审核和公开 S3 发布门槛仍未关闭,**不是合并或发布批准**。没有推送、合并主线或发布索引;用户自行决定将本地材料交给谁。 +当前 r3 可交付审查,新增完整本地维护端到客户端闭环;r2 的依赖/Pi 修复及历史失败作为明确历史保留。远端 CI、独立真实产物审核和公开 S3 门槛仍未关闭,**不是合并或发布批准**。没有推送、合并主线或发布索引;用户自行决定将本地材料交给谁。 + +## 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)。第一至第六批记录是历史证据,不应替代最新的结果/限制。 +前批修复背景见 [第七批修复记录](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 应用/验收工具代码端点:`59c379a6e9615b1cc17ba90cbfc9a8f55508ea39`;桌面报告受测提交为 `51c8363e`,最后的显式导出补丁另有字节码等价/真实导入检查。后续提交仅更新记录;bundle 的精确末端由包内 `review-manifest.json` 指明。 +- Host 生产应用代码仍为第七批范围;新增闭环工具端点为 c44799eaa12c983245c1551cfd6f901007997b05。两轮实际受测身份见第八批记录和原始报告。bundle 的精确记录末端由 review-manifest.json 指明。 - 协议仓范围:`bf1a848448a19ed20eca029e8cc3991563e42e95..153153598a9f94e3b44a477e99c3017f978d13e5`。 - 固定目录 checker:Host `1840c8e4f646992ddaebea3139c8895f29743a8f`;Host 内目录副本 pin `58d3892d55c6a7ec6f8626153f503291bb10ab9a`。SDK/protocol pins 未变。 @@ -27,7 +36,7 @@ r2 包不改写,随附 `external-review-2026-10-22-pr252-addendum.zip` 提供 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)作为明确历史证据保留。 @@ -46,7 +55,7 @@ Docker 只补协议/SDK 隔离:把 `protocol-source.zip` 解压后将包内 `d ## 包内容与禁止误读 -当前包名带 `-r2`,旧包原样保留。三份 patch(两份依赖、一份 Pi)可独立提取;依赖补丁交另一个会话独立审查并保持与词典 PR 分离。 +当前包名带 -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 不混入参考目录。 diff --git a/docs/verification/extensions/store-v1.md b/docs/verification/extensions/store-v1.md index 4640e326..b8ac37ab 100644 --- a/docs/verification/extensions/store-v1.md +++ b/docs/verification/extensions/store-v1.md @@ -1,12 +1,23 @@ # Plugin store technical preview: release checklist -Status: local S3 preparation, **not released and not approved for publication**. +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. @@ -52,7 +63,7 @@ Dependency maintenance is now tracked separately in [draft PR #252](https://gith | 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 | 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. | +| 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. | diff --git a/release/source_release_policy.json b/release/source_release_policy.json index 2d876427..5380c8d9 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -312,7 +312,11 @@ "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/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" ], "exclude_globs": [ "docs/reports/**", From 394dca6949c50f81cb5562e44af271d7d1c1d4f0 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 22 Oct 2026 01:14:13 +1300 Subject: [PATCH 29/39] docs(extensions): record successful dependency PR CI snapshot --- .../extensions/community-preview-batch8-2026-10-22.md | 7 +++++++ .../extensions/external-review-handoff-2026-10-21.md | 4 +++- docs/verification/extensions/store-v1.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/verification/extensions/community-preview-batch8-2026-10-22.md b/docs/verification/extensions/community-preview-batch8-2026-10-22.md index 2242b591..ce8c7f08 100644 --- a/docs/verification/extensions/community-preview-batch8-2026-10-22.md +++ b/docs/verification/extensions/community-preview-batch8-2026-10-22.md @@ -74,3 +74,10 @@ 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/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md index 8706e782..5090f4d8 100644 --- a/docs/verification/extensions/external-review-handoff-2026-10-21.md +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -24,10 +24,12 @@ ## 独立依赖 PR(2026-10-22 核查) -[草稿 PR #252](https://github.com/Code-Amadeus/Amadeus/pull/252) 已从 `main` 独立建立,head `80be1ebfc1dc03cdd466f7650baedbe6774de43a`,承接依赖补丁 `0c8150c9` + `59c379a6`。已核对 vendor/导入边界/来源测试相同及锁中的修复版本;它不包含商店或词典功能。核查时 CI 仍在运行,不能据此关闭商店远端 CI 门槛。 +[草稿 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/`。 diff --git a/docs/verification/extensions/store-v1.md b/docs/verification/extensions/store-v1.md index b8ac37ab..1c4f6f0e 100644 --- a/docs/verification/extensions/store-v1.md +++ b/docs/verification/extensions/store-v1.md @@ -47,7 +47,7 @@ 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. Its CI was still running at that snapshot; it contains no store features and does not close the experimental store branch CI or publication gates. +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 From 341df15f77f37fec652de683f5650a5ef8215503 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 03:01:35 +1300 Subject: [PATCH 30/39] test(extensions): connect submission revision and live delivery checks --- tools/probes/extension_store_demo.py | 33 ++++++++++++++++++++--- tools/probes/extension_store_roundtrip.py | 5 ++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/tools/probes/extension_store_demo.py b/tools/probes/extension_store_demo.py index 47e87674..45579642 100644 --- a/tools/probes/extension_store_demo.py +++ b/tools/probes/extension_store_demo.py @@ -71,7 +71,8 @@ def __init__(self, root, config): self.generator = self.git(self.repo, "rev-parse", "HEAD") if json.loads((self.repo / "catalog/index.json").read_text())["entries"]: raise ValueError("Demo expects the experimental empty bootstrap") - self.current = self.repo / "catalog/index.json" + self.current = root / "bootstrap.json" + shutil.copyfile(self.repo / "catalog/index.json", self.current) publisher = self class Handler(BaseHTTPRequestHandler): @@ -80,6 +81,8 @@ def do_GET(self): file = None if route == "/index.json": file = publisher.current + elif route == "/candidate-index.json": + file = publisher.repo / "catalog/index.json" elif route.startswith(("/artifacts/", "/mirror/")): candidate = publisher.wheels.get(route.rsplit("/", 1)[-1]) if candidate and route in ( @@ -174,6 +177,10 @@ def publish(self, label, *, version=None, withdrawal=None, decision=None): 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": @@ -197,11 +204,24 @@ def publish(self, label, *, version=None, withdrawal=None, decision=None): for name in PACKAGES ] (self.repo / "catalog/security.json").write_text(json.dumps(safety, indent=2), encoding="utf-8") - input_commit = self.commit(f"DEMO ONLY: {label} input and simulated review") 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. @@ -223,17 +243,24 @@ def publish(self, label, *, version=None, withdrawal=None, decision=None): 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/index.json", "--previous", previous, + "--output", phase / "candidate-delivery.json") snapshot = phase / "index.json" shutil.copyfile(self.repo / "catalog/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, + "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") diff --git a/tools/probes/extension_store_roundtrip.py b/tools/probes/extension_store_roundtrip.py index ecdbbea8..f60bb111 100644 --- a/tools/probes/extension_store_roundtrip.py +++ b/tools/probes/extension_store_roundtrip.py @@ -340,6 +340,11 @@ async def rejects_install(package_id): 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 From 6dcec45c67ed6800e0433791ead39ca4b4a87a52 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 03:03:41 +1300 Subject: [PATCH 31/39] test(extensions): support standalone publisher qualification --- tools/probes/extension_store_demo.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tools/probes/extension_store_demo.py b/tools/probes/extension_store_demo.py index 45579642..17accef7 100644 --- a/tools/probes/extension_store_demo.py +++ b/tools/probes/extension_store_demo.py @@ -311,6 +311,7 @@ def main(): 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() @@ -320,6 +321,26 @@ def main(): 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: From 9e3938c1ddc79190703c4945e572fd3b1f17255f Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 03:10:45 +1300 Subject: [PATCH 32/39] docs(extensions): record lightweight submission workflow qualification --- ...unity_preview_and_store_plan_2026-10-21.md | 11 ++++ docs/extension_store_demo.md | 25 +++++++++- .../community-preview-batch9-2026-10-23.md | 50 +++++++++++++++++++ .../external-review-handoff-2026-10-21.md | 6 ++- release/source_release_policy.json | 3 +- 5 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch9-2026-10-23.md 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 index 0e4a40a8..6004222f 100644 --- a/docs/extension_community_preview_and_store_plan_2026-10-21.md +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -577,3 +577,14 @@ S0 的目录 schema 必须把稳定的身份/展示/兼容性摘要与具体贡 | 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_store_demo.md b/docs/extension_store_demo.md index f43a9e94..98a6e8b9 100644 --- a/docs/extension_store_demo.md +++ b/docs/extension_store_demo.md @@ -17,7 +17,7 @@ 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 the protocol experiment -at 153153598a9f94e3b44a477e99c3017f978d13e5 (or a separately reviewed compatible +at 8fb39ba (submission/delivery tools) (or a separately reviewed compatible revision); its real bootstrap remains empty. Build reference wheels with python -X utf8 tools/probes/extension_author_journey.py, @@ -116,3 +116,26 @@ 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/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 index 5090f4d8..11196f47 100644 --- a/docs/verification/extensions/external-review-handoff-2026-10-21.md +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -2,7 +2,11 @@ 当前 r3 可交付审查,新增完整本地维护端到客户端闭环;r2 的依赖/Pi 修复及历史失败作为明确历史保留。远端 CI、独立真实产物审核和公开 S3 门槛仍未关闭,**不是合并或发布批准**。没有推送、合并主线或发布索引;用户自行决定将本地材料交给谁。 -## r3 最新入口 +## 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,新增验收/演示工具,没有改变生产下载、安装、安全或权限实现。 diff --git a/release/source_release_policy.json b/release/source_release_policy.json index 5380c8d9..6946965a 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -316,7 +316,8 @@ "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/external-review-handoff-2026-10-21.md", + "docs/verification/extensions/community-preview-batch9-2026-10-23.md" ], "exclude_globs": [ "docs/reports/**", From ed61b8687420fdd636313ff22eedec89d6a03f2c Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 03:39:28 +1300 Subject: [PATCH 33/39] test(extensions): qualify main integration without occupying developer ports --- .../community-preview-batch10-2026-10-23.md | 44 +++++++++++++++++++ .../external-review-handoff-2026-10-21.md | 12 ++++- electron/tests/developmentServer.test.mjs | 20 ++++++--- release/source_release_policy.json | 3 +- 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch10-2026-10-23.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/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md index 11196f47..059118bd 100644 --- a/docs/verification/extensions/external-review-handoff-2026-10-21.md +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -1,6 +1,12 @@ # 插件基础设施外审交接:2026-10-21 -当前 r3 可交付审查,新增完整本地维护端到客户端闭环;r2 的依赖/Pi 修复及历史失败作为明确历史保留。远端 CI、独立真实产物审核和公开 S3 门槛仍未关闭,**不是合并或发布批准**。没有推送、合并主线或发布索引;用户自行决定将本地材料交给谁。 +r3 保存完整本地维护端到客户端闭环;r2 的依赖/Pi 修复及历史失败继续作为历史保留。最新集成范围见下方第十批。独立真实产物审核和公开 S3 门槛仍未关闭,**不是合并或发布批准**。 + +## 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 投稿流程补充 @@ -26,7 +32,9 @@ 外审 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 核查) +## 独立依赖 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 门槛。 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/release/source_release_policy.json b/release/source_release_policy.json index 6946965a..3b5c629f 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -317,7 +317,8 @@ "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-batch9-2026-10-23.md", + "docs/verification/extensions/community-preview-batch10-2026-10-23.md" ], "exclude_globs": [ "docs/reports/**", From 2c442865ff2f717bfe6ef203400e133bab5676de Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 04:28:21 +1300 Subject: [PATCH 34/39] fix(extensions): address store audit and preserve locked source inputs --- .github/workflows/source-release.yml | 2 + docs/extension_catalog.md | 14 ++++- ...unity_preview_and_store_plan_2026-10-21.md | 6 +- .../community-preview-batch11-2026-10-23.md | 36 ++++++++++++ .../external-review-handoff-2026-10-21.md | 4 ++ electron/package.json | 41 +++----------- electron/src/main/extensionCatalog.ts | 2 +- electron/src/main/extensionDownloads.ts | 55 +++++++++++++------ .../renderer/components/ExtensionCatalog.tsx | 2 +- .../renderer/components/ExtensionSettings.tsx | 2 +- electron/src/renderer/locales/zh-CN.json | 2 +- electron/src/renderer/styles/index.css | 3 - electron/tests/extensionCatalogUI.test.mjs | 8 +++ electron/tests/extensionDownloads.test.mjs | 51 +++++++++++++++-- .../tests/fixtures/extensionPublication.cjs | 2 +- electron/tests/fixtures/extensionRelease.cjs | 2 +- release/source_release_policy.json | 3 +- tools/probes/extension_store_demo.py | 18 +++--- .../Amadeus.Wallpaper.csproj | 2 + .../Amadeus.Wallpaper/packages.lock.json | 3 +- 20 files changed, 178 insertions(+), 80 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch11-2026-10-23.md diff --git a/.github/workflows/source-release.yml b/.github/workflows/source-release.yml index 05f324d0..0313a16f 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/check_third_party_provenance.py - tests/test_release_tooling.py diff --git a/docs/extension_catalog.md b/docs/extension_catalog.md index a04d89fc..fcf4af90 100644 --- a/docs/extension_catalog.md +++ b/docs/extension_catalog.md @@ -8,7 +8,7 @@ can provide a public store. The current S0 reference index contains no reviewed ## Authority and compatibility -The only index is `https://raw.githubusercontent.com/Code-Amadeus/Amadeus-Extensions/main/catalog/index.json`. +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 @@ -243,3 +243,15 @@ exact-artifact blocking, explicit unblocking and separate distribution restorati 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 index 6004222f..65fde74a 100644 --- a/docs/extension_community_preview_and_store_plan_2026-10-21.md +++ b/docs/extension_community_preview_and_store_plan_2026-10-21.md @@ -221,7 +221,7 @@ S0 至 S2 使用维护者参考包和 fixture 独立验收。S3 可以收录维 **依赖与发布位置** -依赖 D1,不依赖 D3。先使用已有参考包和测试 fixture 验收。目录源放在 Amadeus-Extensions 的 `catalog/`,首版选择受保护 main 上的 `catalog/index.json`,通过官方 raw HTTPS 地址分发,不另外部署 Pages 或 Release 索引资产。 +依赖 D1,不依赖 D3。先使用已有参考包和测试 fixture 验收。目录源放在 Amadeus-Extensions 的 `catalog/`,首版选择受保护 main 上的 `catalog/v1/index.json`,通过官方 raw HTTPS 地址分发,不另外部署 Pages 或 Release 索引资产。 目录输入先经审核合入 main。发布 PR 选定一个已合入的输入 commit,固定生成器和 Host 校验器版本,生成索引并提交审核。生成时间取该输入 commit 的时间,不读取构建机器当前时间;相同输入和工具版本生成相同字节。索引记录输入 commit,避免把包含生成文件自身的 commit 当作输入造成循环。 @@ -272,7 +272,7 @@ S0 至 S2 使用维护者参考包和 fixture 独立验收。S3 可以收录维 **改哪里** -- 协议仓拟新增 `catalog/packages/`、`catalog/schema.json`、`catalog/README.md`、`catalog/REVIEW_CHECKLIST.md`、`catalog/index.json`、`tools/check_catalog.py`、`tools/build_catalog.py` 及对应测试。 +- 协议仓拟新增 `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 的审核流程。 @@ -470,7 +470,7 @@ Pi/MCP 资格在包含 #234 的目标主线上至少覆盖 `tests/test_pi_mcp.py | --- | --- | --- | | 开发依赖 | D1 后分作者线和商店线;S0 至 S2 不等 D3 | D3 未完成只能交付相应技术预览,不能声称外部作者验证完成 | | 首批目录来源 | 允许维护者参考包;同清单、非作者本人审核 | 不强制外部作者公开发布,不以参考包替代 D3 | -| 索引承载与序号 | 官方仓受保护 main 的 raw `catalog/index.json`,发布 PR 递增序号 | 固定输入 commit/工具,提交时间用于重建;回退内容不回退序号 | +| 索引承载与序号 | 官方仓受保护 main 的 raw `catalog/v1/index.json`,发布 PR 递增序号 | 固定输入 commit/工具,提交时间用于重建;回退内容不回退序号 | | 无签名的信任根 | 官方 GitHub 仓库与发布权限 | S3 核实保护、评审、两步验证和最小权限;不承诺仓库失陷后的独立真实性 | | 产物镜像 | Host 设置可配置 HTTPS 镜像,仅下载字节 | 索引仍只认官方;逐跳检查、无请求凭据、安装器验证官方摘要 | | 首批包规则 | `py3-none-any`、可审查源码/资源、`X.Y.Z`、无第三方依赖或仅 SDK | 当前仅为目录收录限制;不顺带收紧一般本地包协议 | 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/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md index 059118bd..1cc1ad03 100644 --- a/docs/verification/extensions/external-review-handoff-2026-10-21.md +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -2,6 +2,10 @@ r3 保存完整本地维护端到客户端闭环;r2 的依赖/Pi 修复及历史失败继续作为历史保留。最新集成范围见下方第十批。独立真实产物审核和公开 S3 门槛仍未关闭,**不是合并或发布批准**。 +## 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 仅表示当时证据。 diff --git a/electron/package.json b/electron/package.json index f30e27c6..c8dbd188 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,7 +1,7 @@ { "name": "amadeus-desktop", "version": "0.17.1-alpha.0", - "description": "Amadeus AI Companion \u2014 Electron frontend", + "description": "Amadeus AI Companion — Electron frontend", "type": "module", "main": "dist/main/index.js", "scripts": { @@ -17,24 +17,10 @@ "electron:build": "npm run build && electron-builder" }, "build": { - "directories": { - "output": "build" - }, - "files": [ - "dist/main/**/*", - "dist/shared/**/*", - "dist/preload/**/*", - "dist/renderer/**/*", - "package.json" - ], + "directories": { "output": "build" }, + "files": ["dist/main/**/*", "dist/shared/**/*", "dist/preload/**/*", "dist/renderer/**/*", "package.json"], "extraResources": [ - { - "from": "../vendor/extension-sdk", - "to": "extension-sdk", - "filter": [ - "*.whl" - ] - }, + { "from": "../vendor/extension-sdk", "to": "extension-sdk", "filter": ["*.whl"] }, { "from": "..", "to": "extension-host", @@ -65,22 +51,9 @@ "win": { "icon": "../assets/icons/app/app_icon.ico", "extraResources": [ - { - "from": "../assets/icons/app/app_icon.ico", - "to": "assets/icons/app/app_icon.ico" - }, - { - "from": "../build/windows-wallpaper/host", - "to": "windows-wallpaper", - "filter": [ - "**/*", - "!*.pdb" - ] - }, - { - "from": "../scripts/setup_windows_wallpaper.ps1", - "to": "windows-wallpaper/setup_windows_wallpaper.ps1" - } + { "from": "../assets/icons/app/app_icon.ico", "to": "assets/icons/app/app_icon.ico" }, + { "from": "../build/windows-wallpaper/host", "to": "windows-wallpaper", "filter": ["**/*", "!*.pdb"] }, + { "from": "../scripts/setup_windows_wallpaper.ps1", "to": "windows-wallpaper/setup_windows_wallpaper.ps1" } ] } }, diff --git a/electron/src/main/extensionCatalog.ts b/electron/src/main/extensionCatalog.ts index 4e2a5721..3286f28d 100644 --- a/electron/src/main/extensionCatalog.ts +++ b/electron/src/main/extensionCatalog.ts @@ -7,7 +7,7 @@ import { ExtensionInstaller, ExtensionPackageHelper, ExtensionChangeCancelled } 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/index.json' +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 diff --git a/electron/src/main/extensionDownloads.ts b/electron/src/main/extensionDownloads.ts index 7e934bdf..2b1e25a4 100644 --- a/electron/src/main/extensionDownloads.ts +++ b/electron/src/main/extensionDownloads.ts @@ -1,6 +1,7 @@ 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 @@ -25,27 +26,35 @@ export type Download = (url: string, destination: string, limit: number, signal: 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 - const timer = setTimeout(() => finish(new Error('Extension download timed out')), 120_000) + 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(timer) + clearTimeout(stalled); clearTimeout(total) signal.removeEventListener('abort', cancel) - try { if (!error) fs.fsyncSync(handle) } catch { error = new Error('Extension download could not be saved') } - try { fs.closeSync(handle) } catch { error ||= new Error('Extension download could not be saved') } - if (error) { - request?.abort() - try { fs.unlinkSync(destination) } catch { /* The owned staging scope retries cleanup. */ } - reject(error) - } else resolve() + 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 }) @@ -56,24 +65,36 @@ export const downloadExtension: Download = (url, destination, limit, signal, pro 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 } - // Chromium emits decoded bytes; Content-Length describes wire bytes when compressed. 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 > limit) { finish(new Error('Extension download exceeds size limit')); return } + 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('data', chunk => { - if (finished) return - received += chunk.length - if (received > limit) { finish(new Error('Extension download exceeds size limit')); return } - try { fs.writeFileSync(handle, chunk); progress?.(received) } catch { finish(new Error('Extension download could not be saved')) } + 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) + }, }) - response.on('end', () => finish(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' diff --git a/electron/src/renderer/components/ExtensionCatalog.tsx b/electron/src/renderer/components/ExtensionCatalog.tsx index 673263d5..e74c944b 100644 --- a/electron/src/renderer/components/ExtensionCatalog.tsx +++ b/electron/src/renderer/components/ExtensionCatalog.tsx @@ -30,7 +30,7 @@ export default function ExtensionCatalog({ catalog, installed, busy, selection, {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.' : 'No packages are listed in this catalog yet.')}

    : !groups.length ?

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

    : 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')}
    diff --git a/electron/src/renderer/components/ExtensionSettings.tsx b/electron/src/renderer/components/ExtensionSettings.tsx index d7d226c7..034fea5f 100644 --- a/electron/src/renderer/components/ExtensionSettings.tsx +++ b/electron/src/renderer/components/ExtensionSettings.tsx @@ -197,7 +197,7 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn const missingProviders = draft?.providers.filter(id => !compatibleProviders.some(provider => provider.provider_id === id)) || [] return
    -
    +
    {(['installed', 'browse'] as const).map(value => )} diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index aeee3f96..bc04b700 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -652,7 +652,6 @@ "No installed Work Provider currently accepts MCP connections.": "当前没有已安装的工作服务提供方支持 MCP 连接。", "No log output yet...": "暂无日志输出…", "No matching chats": "没有匹配的对话", - "No packages are listed in this catalog yet.": "此目录尚未收录插件。", "No platform restriction declared": "未声明平台限制", "No plugin process": "无插件进程", "No plugins match this search.": "没有符合搜索条件的插件。", @@ -755,6 +754,7 @@ "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 时,小窗显示简洁头像和字幕。", diff --git a/electron/src/renderer/styles/index.css b/electron/src/renderer/styles/index.css index e9babfa7..deefed6c 100644 --- a/electron/src/renderer/styles/index.css +++ b/electron/src/renderer/styles/index.css @@ -6261,9 +6261,6 @@ html[data-theme-extension] .settings-inline-action[data-tone='danger']:is(:hover /* Catalog shares the existing plugin manager and theme tokens. Third-party copy is text only. */ .extension-settings [hidden] { display: none !important; } -.extension-manager-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--card-border); } -.extension-manager-tabs [role="tab"] { padding: 9px 16px; border: 0; background: transparent; color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; } -.extension-manager-tabs [aria-selected="true"] { color: var(--text); border-bottom-color: currentColor; font-weight: 700; } .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: 10px; } .extension-download-progress strong, .extension-download-progress > div { flex-basis: 100%; overflow-wrap: anywhere; } diff --git a/electron/tests/extensionCatalogUI.test.mjs b/electron/tests/extensionCatalogUI.test.mjs index 7bdfc33a..ee351c24 100644 --- a/electron/tests/extensionCatalogUI.test.mjs +++ b/electron/tests/extensionCatalogUI.test.mjs @@ -53,3 +53,11 @@ test('closed and future voice roles are not presented as TTS', () => { 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 index f3324abf..384dd4b1 100644 --- a/electron/tests/extensionDownloads.test.mjs +++ b/electron/tests/extensionDownloads.test.mjs @@ -4,6 +4,7 @@ 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 } } } @@ -17,10 +18,10 @@ const net = { request: opts => { } } 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 EventEmitter(); r.statusCode = status; r.headers = length === undefined ? {} : { 'content-length': [String(length)] } + 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.emit('data', Buffer.from(chunk)) - r.emit('end') + 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)) @@ -31,8 +32,8 @@ test('bounded transport, credentials isolation, redirects, cancellation and fail t.after(() => fs.rmSync(root, { recursive: true, force: true })) const cases = [ ['valid', r => response(r, ['wheel'], 5), true], - ['decoded response', request => { const r = new EventEmitter(); r.statusCode = 200; r.headers = { 'content-length': '99', 'content-encoding': 'gzip' }; request.emit('response', r); r.emit('data', Buffer.from('wheel')); r.emit('end') }, true], - ['string header', request => { const r = new EventEmitter(); r.statusCode = 200; r.headers = { 'content-length': '5' }; request.emit('response', r); r.emit('data', Buffer.from('wheel')); r.emit('end') }, 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], @@ -75,3 +76,43 @@ test('next process reclaims owned interrupted downloads while retaining unknown 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/fixtures/extensionPublication.cjs b/electron/tests/fixtures/extensionPublication.cjs index b69decb3..085b49bd 100644 --- a/electron/tests/fixtures/extensionPublication.cjs +++ b/electron/tests/fixtures/extensionPublication.cjs @@ -14,7 +14,7 @@ async function setupPublicationTransport(origin, certificate) { 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/index.json' + 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}) diff --git a/electron/tests/fixtures/extensionRelease.cjs b/electron/tests/fixtures/extensionRelease.cjs index f8aa51bd..e327097b 100644 --- a/electron/tests/fixtures/extensionRelease.cjs +++ b/electron/tests/fixtures/extensionRelease.cjs @@ -22,7 +22,7 @@ async function setupReleaseFixture(base, automaticConfirm = true) { 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/index.json'?origin+'/index.json' + 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) } diff --git a/release/source_release_policy.json b/release/source_release_policy.json index 3b5c629f..7f552ec8 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -318,7 +318,8 @@ "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-batch10-2026-10-23.md", + "docs/verification/extensions/community-preview-batch11-2026-10-23.md" ], "exclude_globs": [ "docs/reports/**", diff --git a/tools/probes/extension_store_demo.py b/tools/probes/extension_store_demo.py index 17accef7..b15fc851 100644 --- a/tools/probes/extension_store_demo.py +++ b/tools/probes/extension_store_demo.py @@ -69,10 +69,10 @@ def __init__(self, root, config): 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/index.json").read_text())["entries"]: + 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/index.json", self.current) + shutil.copyfile(self.repo / "catalog/v1/index.json", self.current) publisher = self class Handler(BaseHTTPRequestHandler): @@ -82,7 +82,7 @@ def do_GET(self): if route == "/index.json": file = publisher.current elif route == "/candidate-index.json": - file = publisher.repo / "catalog/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 ( @@ -171,7 +171,7 @@ def publish(self, label, *, version=None, withdrawal=None, decision=None): base = phase / "protected-base" self.clone(self.repo, base) previous_commit = self.git(base, "rev-parse", "HEAD") - previous = base / "catalog/index.json" + previous = base / "catalog/v1/index.json" if version: for name in PACKAGES: path = self.repo / "catalog/packages" / f"{name}-{version}.json" @@ -229,7 +229,7 @@ def publish(self, label, *, version=None, withdrawal=None, decision=None): build = [ "--source-repo", self.repo, "--source-commit", input_commit, "--approved-ref", "demo-approved", "--sequence", str(number), - "--previous", previous, "--output", self.repo / "catalog/index.json", + "--previous", previous, "--output", self.repo / "catalog/v1/index.json", "--fetch", *common, ] # Always run the original reviewed generator revision, not submitted input code. @@ -239,15 +239,15 @@ def publish(self, label, *, version=None, withdrawal=None, decision=None): 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/index.json", "--previous", previous) + 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/index.json", "--previous", previous, + "--expected", self.repo / "catalog/v1/index.json", "--previous", previous, "--output", phase / "candidate-delivery.json") snapshot = phase / "index.json" - shutil.copyfile(self.repo / "catalog/index.json", snapshot) + 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", @@ -275,7 +275,7 @@ def verify_rejections(self): index = json.loads(self.current.read_text()) index["sequence"] += 1 index["entries"][0]["display"]["description"] = "DEMO tampered output" - (proposal / "catalog/index.json").write_text(json.dumps(index), encoding="utf-8") + (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"], 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 From ae4a14626fb6ae80040770ca9feb7f3be6a1eb4d Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 05:28:04 +1300 Subject: [PATCH 35/39] fix(extensions): qualify combined UI and packaged manifest versions --- .github/workflows/python-windows.yml | 30 +++++++ electron/package.json | 2 +- .../renderer/components/ExtensionCatalog.tsx | 4 +- electron/tests/extensionSettings.test.mjs | 21 +++-- electron/tests/extensionStore.smoke.mjs | 19 ++-- tests/test_catalog_packaging.py | 26 ++++++ tools/probes/extension_release_shell.py | 89 +++++++++++++++---- tools/probes/extension_store_roundtrip.py | 28 +++--- 8 files changed, 166 insertions(+), 53 deletions(-) diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index c686bea8..4511023c 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -170,6 +170,31 @@ jobs: --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() uses: actions/upload-artifact@v6 @@ -185,6 +210,11 @@ jobs: 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/electron/package.json b/electron/package.json index c8dbd188..065a1ac9 100644 --- a/electron/package.json +++ b/electron/package.json @@ -39,7 +39,7 @@ "server/extensions/catalog.py", "server/extensions/catalog-source.json", "server/extensions/protocol-source.json", - "server/extensions/amadeus-extension.v0.schema.json", + "server/extensions/*.schema.json", "server/extensions/theme-tokens.v0.json", "agent_host/__init__.py", "agent_host/skills.py", diff --git a/electron/src/renderer/components/ExtensionCatalog.tsx b/electron/src/renderer/components/ExtensionCatalog.tsx index e74c944b..805ec806 100644 --- a/electron/src/renderer/components/ExtensionCatalog.tsx +++ b/electron/src/renderer/components/ExtensionCatalog.tsx @@ -44,11 +44,11 @@ export default function ExtensionCatalog({ catalog, installed, busy, selection,
    {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')} · SDK {value(selected.compatibility.sdk)}
    +
    {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}
    -
    SHA-256
    {selected.sha256}
    +
    {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.')}

    diff --git a/electron/tests/extensionSettings.test.mjs b/electron/tests/extensionSettings.test.mjs index df35cc6c..8f1baf55 100644 --- a/electron/tests/extensionSettings.test.mjs +++ b/electron/tests/extensionSettings.test.mjs @@ -289,19 +289,24 @@ test('pending installation reports a busy navigation guard; failed installation assert.equal(page.find(node => node.props?.role === 'alert').props.children, 'Synthetic invalid wheel') }) -test('catalog view switching retains a draft and policy-blocked packages can be disabled but not activated', async t => { - 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'}}})}) +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 field=()=>page.find(n=>n.props?.id==='extension-enabled'&&n.type==='input') - assert.equal(field().props.disabled,false) - field().props.onChange({target:{checked:false}});page.render() - assert.equal(field().props.disabled,true) + 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(field().props.checked,false) - assert.ok(page.find(n=>n.props?.role==='alert'&&n.props.children.includes('Security policy'))) + assert.equal(value().props.value,'1.75') + assert.equal(toggle().props.checked,false) }) test('opening Browse cannot implicitly recover damaged safety state', async t => { diff --git a/electron/tests/extensionStore.smoke.mjs b/electron/tests/extensionStore.smoke.mjs index ce508ca1..0346eb0f 100644 --- a/electron/tests/extensionStore.smoke.mjs +++ b/electron/tests/extensionStore.smoke.mjs @@ -54,8 +54,10 @@ async function until(source,message){const deadline=Date.now()+90000;while(Date. async function click(label){await until(`(()=>{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])')&&document.body.innerText.includes(${JSON.stringify(id+' · '+version)})&&!document.querySelector('.extension-download-progress')`,'Install did not finish: '+id)} -async function configure(enabled){await click('Configure extension');await ui(`(()=>{const n=document.querySelector('#extension-enabled');if(n.checked!==${enabled})n.click()})()`);await click('Save extension');await until("!document.querySelector('form.settings-connection-editor')",'Configuration did not save')} +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 @@ -123,17 +125,16 @@ try { 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') - // A refresh while editing cannot lose a draft or leave a stale success receipt. - await click('Configure extension');await ui("document.querySelector('#extension-enabled').click()") + // 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 draft and saved version',await ui("!document.querySelector('#extension-enabled').checked&&!document.body.innerText.includes('Package saved. Configure')")) - offline=false;await click('Save extension');await until("!document.querySelector('form.settings-connection-editor')",'Disable failed') + 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 click('Remove');await click('Confirm remove');await until("document.body.innerText.includes('No community extensions installed.')",'Uninstall failed') + 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 click('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') + 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} @@ -143,7 +144,7 @@ try { 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 click('Remove');await click('Confirm remove');await installer.cleanupStoppedBackend() + 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'}}) 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/tools/probes/extension_release_shell.py b/tools/probes/extension_release_shell.py index e7f514ec..563bfd82 100644 --- a/tools/probes/extension_release_shell.py +++ b/tools/probes/extension_release_shell.py @@ -6,11 +6,16 @@ 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] @@ -51,6 +56,50 @@ def address(): 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 @@ -81,19 +130,16 @@ async def button(name): ) async def configure(enabled, provider=None): - await button("Configure extension") - await page.locator("#extension-enabled").set_checked(enabled) if provider: - await ( - page.locator(".settings-form-provider-choices label") - .filter(has_text=provider) - .locator("input") - .check(timeout=30000) - ) - await button("Save extension") - await expect(page.locator("form.settings-connection-editor")).to_have_count( - 0, timeout=30000 - ) + await button("Configure extension") + await (page.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 = page.locator('#extension-panel-installed [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() @@ -104,7 +150,7 @@ async def install(package_id, version): await selection.select_option(version) await button("Install plugin…") await expect(page.locator("#extension-panel-installed")).to_be_visible(timeout=120000) - await expect(page.get_by_text(package_id + " · " + version, exact=True)).to_be_visible( + 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) @@ -125,6 +171,12 @@ async def install(package_id, version): (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("*")) @@ -158,17 +210,14 @@ async def install(package_id, version): ), ) try: - await button("Settings") - await button("General") - await button("Manage plugins") + 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(True) await button("Open appearance settings") await page.get_by_role("radio").filter(has_text="Quiet day").click() - await button("General") - await button("Manage plugins") + 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") @@ -182,7 +231,7 @@ async def install(package_id, version): ) await button("Check updates and security status") await button("Update plugin…") - await expect(page.get_by_text(theme + " · 0.2.0", exact=True)).to_be_visible(timeout=120000) + 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"))) @@ -199,6 +248,7 @@ async def install(package_id, version): ) await evaluate_main(product, "extensionReleaseFixture.offline=false") await configure(False) + await page.locator(".extension-details > summary").click() await button("Remove") await button("Confirm remove") await expect( @@ -299,6 +349,7 @@ def run_contract(): ) used.add((package_id, "0.1.0")) await configure(False) + await page.locator(".extension-details > summary").click() await button("Remove") await button("Confirm remove") report["used_artifacts"] = [ diff --git a/tools/probes/extension_store_roundtrip.py b/tools/probes/extension_store_roundtrip.py index f60bb111..a9ecfe7c 100644 --- a/tools/probes/extension_store_roundtrip.py +++ b/tools/probes/extension_store_roundtrip.py @@ -81,9 +81,7 @@ async def button(name): ) async def open_manager(): - await button("Settings") - await button("General") - await button("Manage plugins") + await button("Plugins") async def install_ui(package_id, version): await product.page.get_by_role("tab", name="Browse", exact=True).click() @@ -94,7 +92,7 @@ async def install_ui(package_id, version): 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.get_by_text(package_id + " · " + version, exact=True)).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"] @@ -105,27 +103,29 @@ async def install_ui(package_id, version): 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.get_by_text(package_id + " · " + old, exact=True) + 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.get_by_text(package_id + " · " + version, exact=True)).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) 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): - # Installed cards may contain multiple packages; select its own edit button. await product.page.get_by_role("tab", name="Installed", exact=True).click() - text = product.page.get_by_text(package_id + " · " + settings()["extensions"][package_id]["version"], exact=True) - card = product.page.locator("#extension-panel-installed .setting-card").filter(has=text) - await card.get_by_role("button", name="Configure extension", exact=True).click() - await product.page.locator("#extension-enabled").set_checked(True) + 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 (product.page.locator(".settings-form-provider-choices label") + 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(product.page.locator("form.settings-connection-editor")).to_have_count(0, timeout=30000) + 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( From e0f7e9b283b9a88eada169daf077a95e321812c7 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 05:45:49 +1300 Subject: [PATCH 36/39] test(extensions): scope shipping controls and record integration gates --- .../community-preview-batch12-2026-10-23.md | 37 +++++++++++++++++++ release/source_release_policy.json | 3 +- tools/probes/extension_release_shell.py | 33 ++++++++++------- 3 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 docs/verification/extensions/community-preview-batch12-2026-10-23.md 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..1784c9f3 --- /dev/null +++ b/docs/verification/extensions/community-preview-batch12-2026-10-23.md @@ -0,0 +1,37 @@ +# 复审整合与主线合并记录(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 悬停定位超时,后续同断言通过;整合探针的多卡片选择歧义及一次参数名错误已定位修正。最终完整源码/打包/发布闭环及新 head 的 CI 结果以各自回执为准,未完成前不将 #256 记为已合并。 + +后续独立资格:把目录 checker 重新认证到含 v1 的 Host main、将 manifest 版本纳入目录兼容信息并覆盖旧客户端、测试真实图标 wheel。当前历史 checker 仍拒绝它,空目录未发布任何包。历史计划和 Pi 测试保留,不重写已被 pin 引用的提交。 diff --git a/release/source_release_policy.json b/release/source_release_policy.json index a6964182..6c4e5987 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -324,7 +324,8 @@ "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-batch11-2026-10-23.md", + "docs/verification/extensions/community-preview-batch12-2026-10-23.md" ], "exclude_globs": [ "docs/reports/**", diff --git a/tools/probes/extension_release_shell.py b/tools/probes/extension_release_shell.py index 563bfd82..7af117d3 100644 --- a/tools/probes/extension_release_shell.py +++ b/tools/probes/extension_release_shell.py @@ -129,14 +129,19 @@ async def button(name): .click(timeout=30000) ) - async def configure(enabled, provider=None): + 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 button("Configure extension") - await (page.locator(".settings-form-provider-choices label") + 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 = page.locator('#extension-panel-installed [role="switch"][id^="extension-enabled-"]') + 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) @@ -214,7 +219,7 @@ async def install(package_id, version): theme = "com.example.desktop-theme" await install(theme, "0.1.0") check("resource installed through shipping UI and IPC", True) - await configure(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") @@ -247,10 +252,10 @@ async def install(package_id, version): not failure["ok"] and any(e["id"] == theme for e in failure["catalog"]["installed"]), ) await evaluate_main(product, "extensionReleaseFixture.offline=false") - await configure(False) - await page.locator(".extension-details > summary").click() - await button("Remove") - await button("Confirm remove") + 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) @@ -275,7 +280,7 @@ async def install(package_id, version): ), ): await install(package_id, "0.1.0") - await configure(True, "Pi" if kind == "mcp" else None) + 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( @@ -348,10 +353,10 @@ def run_contract(): and json.loads(runtime.read_text())["ok"], ) used.add((package_id, "0.1.0")) - await configure(False) - await page.locator(".extension-details > summary").click() - await button("Remove") - await button("Confirm remove") + 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"] From 1e564d38c727305f7a4e792955f3a5ff9b684ac4 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 05:49:59 +1300 Subject: [PATCH 37/39] test(smoke): retain artifact drawer focus across mode changes --- .../extensions/community-preview-batch12-2026-10-23.md | 2 +- tools/smoke_electron_model_less.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/verification/extensions/community-preview-batch12-2026-10-23.md b/docs/verification/extensions/community-preview-batch12-2026-10-23.md index 1784c9f3..46805283 100644 --- a/docs/verification/extensions/community-preview-batch12-2026-10-23.md +++ b/docs/verification/extensions/community-preview-batch12-2026-10-23.md @@ -32,6 +32,6 @@ Windows CI 增加正式生成器、固定 Host checker 和真实打包客户端 - 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 悬停定位超时,后续同断言通过;整合探针的多卡片选择歧义及一次参数名错误已定位修正。最终完整源码/打包/发布闭环及新 head 的 CI 结果以各自回执为准,未完成前不将 #256 记为已合并。 +- 完整验收探索轮保留失败:聊天侧栏 Artifacts 悬停定位两次超时;探针改用既有焦点维持展开契约,额外断言页签 aria-pressed=true,并保留内容可见性和原超时;整合探针的多卡片选择歧义及一次参数名错误已定位修正。最终完整源码/打包/发布闭环及新 head 的 CI 结果以各自回执为准,未完成前不将 #256 记为已合并。 后续独立资格:把目录 checker 重新认证到含 v1 的 Host main、将 manifest 版本纳入目录兼容信息并覆盖旧客户端、测试真实图标 wheel。当前历史 checker 仍拒绝它,空目录未发布任何包。历史计划和 Pi 测试保留,不重写已被 pin 引用的提交。 diff --git a/tools/smoke_electron_model_less.py b/tools/smoke_electron_model_less.py index 7ea6c321..b74412ba 100644 --- a/tools/smoke_electron_model_less.py +++ b/tools/smoke_electron_model_less.py @@ -317,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 ) From cd3f36fed333cbd089f330b7177b3a158bd557e0 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 06:02:21 +1300 Subject: [PATCH 38/39] fix(deps): update OpenTelemetry API and retain lifecycle failure evidence --- .../extensions/community-preview-batch12-2026-10-23.md | 6 ++++++ tools/probes/extension_release_failures.py | 4 ++++ uv.lock | 6 +++--- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/verification/extensions/community-preview-batch12-2026-10-23.md b/docs/verification/extensions/community-preview-batch12-2026-10-23.md index 46805283..08066ede 100644 --- a/docs/verification/extensions/community-preview-batch12-2026-10-23.md +++ b/docs/verification/extensions/community-preview-batch12-2026-10-23.md @@ -35,3 +35,9 @@ Windows CI 增加正式生成器、固定 Host checker 和真实打包客户端 - 完整验收探索轮保留失败:聊天侧栏 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 路径不在本次支持声明内。 diff --git a/tools/probes/extension_release_failures.py b/tools/probes/extension_release_failures.py index 478c5634..4496ab0b 100644 --- a/tools/probes/extension_release_failures.py +++ b/tools/probes/extension_release_failures.py @@ -138,6 +138,10 @@ async def runtime_state(version, expected): 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: diff --git a/uv.lock b/uv.lock index 9065f3e7..fc21d2c5 100644 --- a/uv.lock +++ b/uv.lock @@ -2885,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]] From 04f9115238f14d18bf7b775eebe500ccf5c2b1d1 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 06:15:31 +1300 Subject: [PATCH 39/39] docs(extensions): record integrated desktop and publication qualification --- docs/extension_store_demo.md | 15 ++++++++++----- .../community-preview-batch12-2026-10-23.md | 10 ++++++++++ .../external-review-handoff-2026-10-21.md | 6 +++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/extension_store_demo.md b/docs/extension_store_demo.md index 98a6e8b9..c90b5170 100644 --- a/docs/extension_store_demo.md +++ b/docs/extension_store_demo.md @@ -7,7 +7,7 @@ website is part of v1. ## Prerequisites and entry point -Use the experimental Host checkout without a personal .env, with its Python +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. @@ -16,8 +16,8 @@ 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 the protocol experiment -at 8fb39ba (submission/delivery tools) (or a separately reviewed compatible +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, @@ -27,7 +27,7 @@ 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/store-demo +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: @@ -42,9 +42,14 @@ 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\store-demo' +& '..\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 diff --git a/docs/verification/extensions/community-preview-batch12-2026-10-23.md b/docs/verification/extensions/community-preview-batch12-2026-10-23.md index 08066ede..7279531a 100644 --- a/docs/verification/extensions/community-preview-batch12-2026-10-23.md +++ b/docs/verification/extensions/community-preview-batch12-2026-10-23.md @@ -41,3 +41,13 @@ Windows CI 增加正式生成器、固定 Host checker 和真实打包客户端 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/external-review-handoff-2026-10-21.md b/docs/verification/extensions/external-review-handoff-2026-10-21.md index 1cc1ad03..25d839cf 100644 --- a/docs/verification/extensions/external-review-handoff-2026-10-21.md +++ b/docs/verification/extensions/external-review-handoff-2026-10-21.md @@ -1,6 +1,10 @@ # 插件基础设施外审交接:2026-10-21 -r3 保存完整本地维护端到客户端闭环;r2 的依赖/Pi 修复及历史失败继续作为历史保留。最新集成范围见下方第十批。独立真实产物审核和公开 S3 门槛仍未关闭,**不是合并或发布批准**。 +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 外审修复