diff --git a/.github/workflows/electron-windows.yml b/.github/workflows/electron-windows.yml index 4fe6fea8..89ced27f 100644 --- a/.github/workflows/electron-windows.yml +++ b/.github/workflows/electron-windows.yml @@ -27,7 +27,8 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: '8.0.x' + # Pin the same SDK resolved by 8.0.x; avoid its broken aka.ms blob redirect. + dotnet-version: '8.0.427' - name: Install locked dependencies run: npm ci diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index 6294e86f..b6f38f17 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -155,7 +155,8 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: '8.0.x' + # Pin the same SDK resolved by 8.0.x; avoid its broken aka.ms blob redirect. + dotnet-version: '8.0.427' - name: Build actual Windows executable working-directory: electron diff --git a/.github/workflows/source-release.yml b/.github/workflows/source-release.yml index 10621291..f1fa931e 100644 --- a/.github/workflows/source-release.yml +++ b/.github/workflows/source-release.yml @@ -104,7 +104,8 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + # Pin the same SDK resolved by 8.0.x; avoid its broken aka.ms blob redirect. + dotnet-version: '8.0.427' - name: Install pinned uv run: python -m pip install uv==0.12.8 diff --git a/agent_host/adapters/pi.py b/agent_host/adapters/pi.py index 202d50de..3bf10726 100644 --- a/agent_host/adapters/pi.py +++ b/agent_host/adapters/pi.py @@ -477,5 +477,12 @@ async def cancel(self, run_id): async def close(self): runs = tuple(self._runs.items()) - await asyncio.gather(*(self.cancel(key) for key, _ in runs), return_exceptions=True) - await asyncio.gather(*(run.client.close() for _, run in runs), return_exceptions=True) + cancelled = await asyncio.gather(*(self.cancel(key) for key, _ in runs), return_exceptions=True) + closed = await asyncio.gather(*(run.client.close() for _, run in runs), return_exceptions=True) + errors = [] + if any(isinstance(result, BaseException) or not isinstance(result, dict) + or result.get("confirmed") is not True for result in cancelled): + errors.append("pi_cancel_unconfirmed") + if any(isinstance(result, BaseException) for result in closed): + errors.append("pi_close_failed") + return {"settled": not errors, "errors": errors} diff --git a/agent_host/provider_runtime.py b/agent_host/provider_runtime.py index 192154a0..4fa209a7 100644 --- a/agent_host/provider_runtime.py +++ b/agent_host/provider_runtime.py @@ -628,7 +628,7 @@ def project_reconciled_terminal_result( metadata=_public_provider_metadata(record.metadata), ) - async def close(self) -> None: + async def close(self) -> dict[str, Any]: """Release Provider-owned runtime resources during Host shutdown. Provider execution lifecycles stay behind their adapters. The Host @@ -638,11 +638,14 @@ async def close(self) -> None: deliberately not fabricated here. """ + errors: list[str] = [] if self._admission_cleanup_tasks: - await asyncio.gather( + outcomes = await asyncio.gather( *tuple(self._admission_cleanup_tasks), return_exceptions=True, ) + if any(isinstance(outcome, BaseException) for outcome in outcomes): + errors.append("admission_cleanup_failed") retained_control_tasks = { task for task in ( @@ -660,6 +663,7 @@ async def close(self) -> None: await self._wait_for_retained_task(task) except BaseException: logger.exception("provider retained control transaction failed during close") + errors.append("retained_control_failed") for provider_id, adapter in tuple(self._adapters.items()): close = getattr(adapter, "close", None) if not callable(close): @@ -667,9 +671,18 @@ async def close(self) -> None: try: outcome = close() if inspect.isawaitable(outcome): - await outcome + outcome = await outcome + if isinstance(outcome, dict) and outcome.get("settled") is not True: + errors.append(f"adapter_unsettled:{provider_id}") except Exception: logger.exception("provider adapter close failed: %s", provider_id) + errors.append(f"adapter_close_failed:{provider_id}") + pending = [record.run_id for record in self._runs.values() + if record.status not in {"done", "error", "cancelled"} + or (record.task_handle is not None and not record.task_handle.done()) + or (record.terminal_publication_task is not None + and not record.terminal_publication_task.done())] + return {"settled": not errors and not pending, "errors": errors, "pending_runs": pending} def add_orphaned_run(self, *, provider: str, run: dict[str, Any]) -> None: """Restore one adapter-owned resumable checkpoint without interpreting it.""" diff --git a/config/README.md b/config/README.md index c84365a0..12bc91dd 100644 --- a/config/README.md +++ b/config/README.md @@ -346,6 +346,7 @@ These are not pending mechanical migrations: | wallpaper scenario helpers | wallpaper/scenario overrides | Both wallpaper hosts resolve component-local media overrides without importing the heavyweight application settings facade. | | `vn_player/runtime.py` | `VN_*` values | These describe one VN session, not the application startup snapshot. | | `server/runtime_status.py` | build/workspace metadata | The launcher supplies diagnostic facts for the current process. | +| `server/backend_cleanup.py` | `AMADEUS_BACKEND_INSTANCE_NONCE` | The desktop supplies this in-memory process identity; cleanup must bind its launch registration to the authenticated desktop before runtime imports. It is not a user setting or persisted credential. | | `asr/qwen3_asr_sidecar.py` | `QWEN3_ASR_DEVICE`, `QWEN3_ASR_REQUIRE_CUDA` | The isolated interpreter reads the environment supplied by its parent. | | `tts/gpt_sovits_sidecar.py` | `TTS_REQUIRE_CUDA` | The isolated interpreter enforces the device requirement supplied by its parent. | | `server/presentation_runtime.py` | `AMADEUS_PRESENTATION_LOCALE`, `AMADEUS_WALLPAPER_CAPTION_MODE` | Presentation resolves its current, component-owned preferences. | diff --git a/config/catalog_legacy.json b/config/catalog_legacy.json index 142b0664..bca70fa1 100644 --- a/config/catalog_legacy.json +++ b/config/catalog_legacy.json @@ -241,6 +241,9 @@ "AMADEUS_VISUAL_PROFILES_PATH", "AMADEUS_WORK_LEDGER_PATH" ], + "server/backend_cleanup.py": [ + "AMADEUS_BACKEND_INSTANCE_NONCE" + ], "server/browser_branch_planner.py": [ "AMADEUS_BROWSER_BRANCH_LLM_ENABLED", "BROWSER_BRANCH_DOM_CHARS", diff --git a/docs/evidence/pr261-crash-recovery/README.md b/docs/evidence/pr261-crash-recovery/README.md new file mode 100644 index 00000000..4ed88cae --- /dev/null +++ b/docs/evidence/pr261-crash-recovery/README.md @@ -0,0 +1,126 @@ +# PR #261: recovery after an unclean backend exit + +Baseline: `019c79bfdab664cea3a8555a42877dc362ed8abe`. + +The external review's composition probe was reproduced against that revision: +an unconfirmed update plus an owner without a graceful-exit receipt blocked +both backend launch and scheduled rollback on every subsequent launch. The +normal-exit control restored the previous version. Direct installer rollback +tests had missed the production update-boundary predicate. + +## Repaired contracts + +- Recovery holds the desktop's stopped-root/startup mutex, verifies the retained + wheel and native confirmation, and restores the previous package record and + encrypted configuration. It does not require historical child-exit evidence, + apply frontend changes, or collect either installation. The recovery scope + cannot authorize a new installation. A currently running backend still blocks + recovery. +- Installation and deferred file removal continue to require physical process + exit evidence. The outside Windows Job observer now waits for the root and all + descendants even after a crash or unsettled teardown. A durable containment + marker, written only after successful root assignment, prevents an empty but + never-assigned Job from becoming evidence. Missing marker, invalid identities, + unreadable records and live descendants remain blocked. +- Graceful Provider/Work completion is diagnostic information, independent of + local process lifetime. No Work outcome, recovery decision, lease or replay + authority is changed by cleanup-index retirement. Existing `settled` receipts + retain their original validation; new `exited` receipts certify only physical + exit. +- A later Windows boot can retire otherwise unrecoverable process owners and + unknown state. The index stores the OS-maintained BootId counter documented + by [Microsoft](https://learn.microsoft.com/windows-hardware/design/device-experiences/oem-hvci-enablement). + Neither wall-clock estimates nor a closed listener count as a reboot. A + missing/backward boot identity grants no retirement. Evidence files remain + archived. An older index without BootId first acquires the current binding + without clearing owners; if old exit evidence is missing, Windows must be + restarted after that adoption. +- Windows query/session-end events request best-effort cleanup without vetoing + shutdown. A cancelled query does not leave the app in permanent quit intent. + Crash recovery does not rely on these callbacks completing. Linux/macOS keep + the original 2.5-second graceful-stop timeout. + +## Regression coverage + +The installer composition test executes the actual boundary declarations from +`index.ts`, with real store, journal, catalog and cleanup-index implementations. +It installs an update, registers an unresolved owner, reopens, schedules recovery, +and verifies repeated later launches can use the old version while maintenance +and removal remain blocked. A later injected boot permits deferred removal. +It also checks live-root rejection and retained credentials. A separate theme +test prevents record restoration from incidentally deleting either version. + +Native Windows tests exercise root termination without graceful approval, +unsettled Work diagnostics, and a descendant surviving its parent both with and +without graceful approval. The descendant is left running for the negative +check and released normally for the positive check. Boot-transition tests cover +same-boot refusal, unavailable/backward identity, legacy adoption, unknown-state +retirement and fresh owners after retirement. + +The shipping-app failure journey includes both normal rollback and a second +pending update whose owned backend is forcibly terminated before rollback. +The test captures the exact ChildProcess created with its isolated backend +nonce; it never searches for or terminates another process by name. The first +qualification attempt exposed a test-instrument error (the main module's +private variable was unavailable to inspector evaluation). The fixture now +captures spawn and refreshes the ESM binding; production exports no test hook. + +## Validation and limits + +- `cd electron; npm test`: **685 passed, 0 skipped**. +- `cd electron; npm run build`: passed configuration/SDK/Pi/TypeScript/Vite checks; + the existing large-chunk warning remains. +- Focused Python cleanup, lifecycle and smoke-probe suite: **96 passed**. +- Python lint on changed files, JavaScript syntax checks and `git diff --check`: + passed. +- Real offline author journey: **12 checks passed**, including native Provider + regressions and retained/default versus explicit package-data removal. +- Full shipping-app qualification: **14 normal extension checks + 23 + failure/recovery checks passed**, including **13 app restarts**. Both the + forced pending-update backend termination and subsequent rollback/old-runtime + startup assertions passed. Final Electron and backend exit was confirmed. + +Commands used (generated fixture paths are local to this qualification): + +```powershell +cd electron +npm test +npm run build +cd .. +.\.venv\Scripts\python.exe -m pytest tests/test_backend_cleanup.py tests/test_electron_model_less_smoke.py tests/test_chat_ingress_lifecycle.py tests/test_vts_worker_lifecycle.py -q --basetemp=build/pr261-recovery-python-all +.\.venv\Scripts\python.exe -X utf8 tools/probes/extension_author_journey.py +.\.venv\Scripts\python.exe -X utf8 tools/probes/extension_catalog_journey.py --execute --store-ui --prepare-only --failure-fixture --wheels build/extension-author-journey/86129dff75f6/wheels +.\.venv\Scripts\python.exe -X utf8 tools/smoke_electron_model_less.py --report-dir build/pr261-recovery-app-final --extension-fixture build/extension-catalog-journey/220348df14c1 --extension-failures +``` + +The final application report is under +`build/pr261-recovery-app-final/electron_model_less_20261025T101246Z_99d7e4/`. +Raw logs are not committed because they contain local paths. The full packaged +executable publication journey was not rerun for this patch. + +This is Windows qualification. Boot changes are injected into the OS-read +boundary; the real OS counter is read and checked, but no physical reboot or +Windows logoff was performed. Linux/macOS routing remains fixture-tested, not a +new native OS installation qualification. Catalog transport and native dialogs +use the existing isolated smoke fixture; plugin packages, application startup, +backend processes, process ownership and persisted state are real. No shared +dependency environment was synchronized and the worktree's junctions remain +untouched. + +## CI follow-up — 2026-10-26 + +The Electron Windows job at `15bc3995` failed one of 685 tests: the native +BootId observation returned unavailable after approximately the 10-second query +budget, but the test required an integer unconditionally. The log did not expose +whether the registry value was missing or the query timed out. Production +already treats unavailable evidence conservatively. + +BootId now uses a direct, bounded `reg.exe` query, avoiding the PowerShell host. +Only one `REG_DWORD` value in the unsigned 32-bit range is accepted and cached. +The reader tests cover missing/query-timeout, empty, wrong-type, overflow and +duplicate evidence, including unchanged unknown ownership and no unavailable +result caching. The native test verifies either stable evidence or continued +ownership fencing. No workflow, timeout increase, skip or cleanup exemption was +added. Full Electron tests: **686 passed, 0 skipped**; build passed. The earlier +Python and real-app results above belong to the recovery implementation, not a +new rerun for this registry-reader follow-up. diff --git a/docs/evidence/pr261-startup-cleanup/README.md b/docs/evidence/pr261-startup-cleanup/README.md new file mode 100644 index 00000000..acbd30cf --- /dev/null +++ b/docs/evidence/pr261-startup-cleanup/README.md @@ -0,0 +1,116 @@ +# PR #261: isolate maintenance failures and preserve the exit observer + +Follow-up to external review of `c7a0c1a0`, validated on Windows on 2026-10-26. + +## Problems and owning layers + +1. Desktop startup awaited cleanup registration, reused-backend marking and the + Python code-root probe without isolating errors. A zero-filled index, receipts + without their index, or a failed probe could prevent the ordinary backend from + starting. Both cleanup implementations also rejected junctions above the data + root, although extension storage supports relocated parent directories. +2. Electron's `taskkill /T /F` escalation killed the backend's outside-Job observer + along with the backend. The earlier `child.kill()` crash fixture did not cover + that path. Missing exit proof then required Windows restart before maintenance. + +## Changes and preserved boundaries + +- `launchBackend` isolates only cleanup setup failures. It reports the warning, + omits cleanup launch credentials, fences maintenance for the current desktop + session and attempts to persist unknown ownership. Damaged state is preserved; + foreign-backend, authentication, extension-validation and readiness failures + retain their existing behavior. Ready/assembling authenticated backend reuse + uses the same error isolation. POSIX does not run the Windows setup/probe. +- An interrupted write-through index replacement cannot make a previously empty + index look clean to a later desktop: uncommitted index transactions also fence + maintenance. No damaged or missing-index state is reset to fresh. +- Desktop and Python checks reject links at or inside the data root, and accept + parent-directory junctions. Receipt paths are checked against the same root. +- Each owned ChildProcess retains its own registered launch identity. Escalation + calls the existing cleanup module's explicit Job-termination entry point. It + checks registration, containment marker, PID creation identity and Job + membership before terminating that Job. The observer stays outside it. +- An unavailable/invalid Job falls back to the existing owned process-tree stop, + with a warning. That fallback may kill the observer. Neither termination method + itself authorizes package deletion: independent exit evidence is still required. + Work outcomes, rollback boundaries and Linux/macOS shutdown are unchanged. + +## Reproducible validation + +From `electron/`: + +```powershell +npm test +npm run build +``` + +- **701 tests passed, 0 skipped.** New startup tests execute the actual + `index.ts` launch/stop functions with the real cleanup store, covering zeroed + and missing indexes, registration/probe failure, retry fencing, reused and + foreign backends, launch-identity binding and POSIX routing. +- Native Windows tests exercise both a rejected shutdown request and the actual + 15-second graceful deadline, with a live child in the Job and an unrelated + process outside it. The observer publishes `exited`, the ledger verifies, and + the unrelated process remains alive. Wrong process creation evidence is + rejected before termination. The parent-junction case also runs the real + Python tracking module, observer, Job terminator and desktop verifier. +- Build passed: config ownership, SDK provenance, Pi type-checking, TypeScript + and Vite. The existing Vite bundle-size warning remains. + +From the repository root: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests/test_backend_cleanup.py tests/test_electron_model_less_smoke.py tests/test_chat_ingress_lifecycle.py tests/test_vts_worker_lifecycle.py -q +.\.venv\Scripts\python.exe -X utf8 -m tools.probes.backend_cleanup_startup +.\.venv\Scripts\python.exe -m ruff check server/backend_cleanup.py tools/probes/backend_cleanup_startup.py +git diff --check +``` + +- **96 Python tests passed**, changed Python lint and diff checks passed. +- The startup probe runs the shipping built Electron app and normal authenticated + Python backend in four isolated profiles: ordinary, zero-filled cleanup index, + missing index with retained receipts, and a junction above the user-data root. + Each runs the existing 15-check model-less journey (startup, authenticated + runtime status, GUI navigation, settings and final Electron/backend exit), plus + preservation/verification of cleanup evidence. The fixture changes only + isolated state before launch; it does not mock the product's startup logic. +- Synthetic fixture logs/reports remain in ignored `build/pr261-r3-*`; no user + credentials or raw process logs are included in this evidence note. The probe + removes only its own junction after verifying the exact target. The checkout's + existing dependency junctions are unchanged. + +## Limits + +No physical shutdown, logoff, fast startup, hibernation, or OS reboot was performed. +Use Windows Restart for lost-evidence recovery; fast-startup/hibernate transitions +remain unqualified. Native Linux/macOS application journeys and the full packaged +publication journey were not rerun locally. The cancelled-shutdown query behavior, +startup catalog networking, performance observations and pre-existing dependency +changes are outside this focused follow-up. No dependency, CI workflow, timeout, +test skip, review approval or merge policy was changed. + +## CI follow-up on 2026-10-26 + +The first hosted run of `3413e883` exposed a test-fixture integration mistake: +`backendLaunch.test.mjs` simulated Windows in the extracted desktop functions, +but omitted the platform argument when calling the real cleanup module. On a +Linux runner that module correctly chose its POSIX no-op route, so the zeroed +index, missing index and successful-registration tests failed. The fixture now +explicitly supplies `win32`, as the existing cleanup protocol tests do. The +assertions and separate Linux/macOS routing cases are retained. The focused +startup suite passes locally (10 tests, 0 skipped); native Linux validation is +performed by fresh hosted CI. + +Two Windows jobs also failed before project tests at `actions/setup-dotnet@v5`. +The `8.0.x` lookup resolved SDK **8.0.427**, but its `aka.ms` redirect targeted +`dotnetorigin.blob.core.windows.net`, which returned **409: public access is not +permitted**. Retrying reproduced the failure. Microsoft's release metadata and +the public `builds.dotnet.microsoft.com` URL confirm the same SDK is available. +The three Windows setup steps now pin **8.0.427**, rather than changing SDK +generation or using another distributor. The official action installer's +`-Version 8.0.427 -DryRun` resolves the public URL, whose HEAD request returns +200. No project test, assertion, timeout or failure gate is removed. + +Sources checked: [setup-dotnet installer](https://github.com/actions/setup-dotnet/blob/v5/src/installer.ts), +[bundled installation script](https://github.com/actions/setup-dotnet/blob/v5/externals/install-dotnet.ps1), +[Microsoft .NET 8 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/8.0/releases.json). diff --git a/docs/evidence/pr261-update-repairs/README.md b/docs/evidence/pr261-update-repairs/README.md new file mode 100644 index 00000000..85e33b49 --- /dev/null +++ b/docs/evidence/pr261-update-repairs/README.md @@ -0,0 +1,47 @@ +# PR #261 update repair verification + +Base: `d17af14f853036cbce9c95bc025d0dd67c66a743` (2026-10-25 review follow-up). + +The pending-update repair reuses the existing configuration validator and +settings writer. It invalidates the health receipt before changing settings, +then records the persisted, encrypted target at its new revision. Failed writes +retain the original recovery record and block activation. Missing or unhealthy +projection status remains pending within the same desktop session so configuration +can be repaired; process changes and failed backend starts still require recovery. + +The platform repair keeps scheduled installation and retained-version recovery +on Windows. Linux/macOS use the existing immediate, version-isolated installation +path and do not register Windows cleanup owners. The prior POSIX shutdown +contract is preserved; this change does not claim descendant containment there. + +## Validation + +- `cd electron; npm test`: 670 passed, 0 skipped. An initial translation-key + ordering failure was corrected before the passing full run. +- `cd electron; npm run build`: passed config/catalog ownership checks, SDK + verification, Pi type-checking, TypeScript and Vite. Vite retains its existing + large-chunk warning. +- `.venv/Scripts/python.exe -m pytest tests/test_backend_cleanup.py tests/test_chat_ingress_lifecycle.py tests/test_vts_worker_lifecycle.py tests/test_electron_model_less_smoke.py -q`: 86 passed. +- `.venv/Scripts/python.exe -X utf8 tools/smoke_electron_model_less.py --report-dir build/pr261-smoke`: passed the isolated Windows model-less startup, + authenticated runtime, plugin/settings navigation and clean-shutdown journey. +- `git diff --check`: passed. + +Regression coverage includes required secret/non-secret configuration, stale +health revisions, settings/journal write failures, rollback preserving encrypted +credentials and disable intent, cold recovery, native/catalog IPC routing, +version retention before restart, unsupported scheduling, and platform-specific +renderer actions. Linux/macOS routing is exercised with explicit platform +fixtures on Windows; native Linux/macOS execution was not performed. + +## Visual check + +These are synthetic static renders of the real React components using the built +stylesheet in headless Chromium on Windows. They verify the guidance layout, +not native Linux/macOS execution or a packaged installer. + +- [Linux/macOS before](linux-before.png): incorrectly promises scheduled installation. +- [Linux/macOS after](linux-after.png): explains immediate installation and restart-to-apply. +- [Windows after](windows-after.png): keeps scheduled-installation guidance. + +All screenshots use an empty synthetic plugin profile. No user settings, +credentials, conversations, or runtime logs are included. diff --git a/docs/evidence/pr261-update-repairs/linux-after.png b/docs/evidence/pr261-update-repairs/linux-after.png new file mode 100644 index 00000000..896ef248 Binary files /dev/null and b/docs/evidence/pr261-update-repairs/linux-after.png differ diff --git a/docs/evidence/pr261-update-repairs/linux-before.png b/docs/evidence/pr261-update-repairs/linux-before.png new file mode 100644 index 00000000..e4c7079c Binary files /dev/null and b/docs/evidence/pr261-update-repairs/linux-before.png differ diff --git a/docs/evidence/pr261-update-repairs/windows-after.png b/docs/evidence/pr261-update-repairs/windows-after.png new file mode 100644 index 00000000..8408af55 Binary files /dev/null and b/docs/evidence/pr261-update-repairs/windows-after.png differ diff --git a/docs/evidence/pr261-update-ui/README.md b/docs/evidence/pr261-update-ui/README.md new file mode 100644 index 00000000..550806c9 --- /dev/null +++ b/docs/evidence/pr261-update-ui/README.md @@ -0,0 +1,46 @@ +# PR #261 update-area UI verification + +The update section now uses the existing `SettingsGroup`, `CardShell`, `Switch`, +`SettingsButton` and `StatusPill` presentation. Automatic checking and manual +security checks occupy separate settings rows. Scheduled installation and +pending/recovery state share one status card. Package digests, retained-state +details and installation policy remain available in disclosures rather than +dominating the page. Actions and host-side update authority are unchanged. + +## Checks + +- `cd electron; npm test`: 673 passed, 0 skipped. +- `cd electron; npm run build`: passed; the existing Vite large-chunk warning remains. +- `cd electron; node -e "require('node:child_process').execFileSync(require('electron'), ['--disable-gpu','tests/extensionSettings.smoke.mjs'], {stdio:'inherit',windowsHide:true,timeout:120000})"`: passed, 12 interaction checks. +- `python docs/evidence/pr261-update-ui/capture.py`: passed the isolated model-less + app smoke and real-window captures. The final capture also asserts no error + banners and no horizontal overflow in the settings/status surfaces. +- `git diff --check`, the store smoke's JavaScript syntax check and the changed + Python probe syntax checks passed. The full store/package-install journey was + not qualified in this UI follow-up. + +The first direct PowerShell GUI-test launch lost its output pipes and displayed +an `EPIPE` dialog. Those disposable test processes were closed, and the settings +smoke was rerun using the CI Node wrapper above. That failed launch is not counted +as a passing test. No production exception suppression was added. A first capture +attempt also used an ambiguous locator for combined rollback/recovery state; +the final capture selects the recovery section explicitly. + +## Real application screenshots + +These images come from the built Electron application, with its normal app shell, +theme provider and model-less backend, using an isolated synthetic user profile. +The default view uses the actual empty profile. Scheduled, ready and recovery +states use synthetic catalog replies injected only into the owned disposable +test process. They demonstrate UI states, not completed plugin installations. + +- [Default Chinese view](live-default-zh.png) +- [Scheduled installation](live-scheduled-zh.png) +- [Ready to confirm, light theme](live-ready-zh.png) +- [Ready to confirm, dark theme](live-ready-dark-zh.png) +- [Combined rollback/recovery at narrow width](live-recovery-narrow-zh.png) + +Default client viewport: 1099 x 801 CSS pixels. Narrow client viewport: 599 x 701. +Screenshots are captured at CSS scale. Both views retain vertical scrolling as +needed; no horizontal overflow was observed. User settings, credentials and +conversations are not used or included. diff --git a/docs/evidence/pr261-update-ui/capture.py b/docs/evidence/pr261-update-ui/capture.py new file mode 100644 index 00000000..b54a89e7 --- /dev/null +++ b/docs/evidence/pr261-update-ui/capture.py @@ -0,0 +1,69 @@ +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +root = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(root)) +from tools import smoke_electron_model_less as smoke +from tools.probes.extension_release_shell import evaluate_main + +exercise = smoke._exercise_renderer +output = root / 'docs/evidence/pr261-update-ui' +output.mkdir(parents=True, exist_ok=True) + +async def capture(page, **options): + checks = await exercise(page, **options) + product = SimpleNamespace(process=SimpleNamespace(pid=options['process_id']), main_debug_port=options['inspector_port']) + result = await page.evaluate("window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_LOCALE:'zh-CN'}})") + assert result['ok'], result + await page.reload() + await page.wait_for_function("document.documentElement.lang === 'zh-CN'") + await page.get_by_role('button', name='插件', exact=True).click() + await page.get_by_role('heading', name='插件更新', exact=True).wait_for(state='visible') + await page.evaluate('document.fonts.ready') + await page.screenshot(path=str(output / 'live-default-zh.png'), full_page=False, scale='css') + snapshot = await page.evaluate('window.amadeus.getDesktopSettings()') + window_url = page.url + guard = "const electron=process.getBuiltinModule('module').createRequire(process.execPath)('electron');" + await evaluate_main(product, "(()=>{" + guard + "globalThis.__pr261Window=electron.BrowserWindow.getAllWindows().find(w=>w.webContents.getURL()===" + json.dumps(window_url) + ");if(!globalThis.__pr261Window)throw Error('Owned window missing');electron.ipcMain.removeHandler('extensions.catalog');electron.ipcMain.handle('extensions.catalog',()=>globalThis.__pr261Catalog);return true})()") + package = 'org.example.voice' + ready = dict(id=package, version='1.1.0', previousVersion='1.0.0', canConfirm=True, recoveryRequired=False) + records = [] + + async def show(name, extra, heading, *, narrow=False): + # Only catalog data is synthetic. The app, renderer, theme provider, + # window and backend are the normal isolated smoke application. + state = dict(entries=[], cached=False, autoCheck=True, **extra) + settings = dict(snapshot, extensionSecurity=dict(status='ready', checkedAt='2026-10-25T04:00:00Z')) + reply = dict(ok=True, catalog=state, settings=settings) + await evaluate_main(product, "(()=>{globalThis.__pr261Catalog=" + json.dumps(reply) + ";globalThis.__pr261Window.setSize(" + ('600,700' if narrow else '1100,800') + ");globalThis.__pr261Window.webContents.send('desktop-theme.changed');return true})()") + await page.wait_for_function('innerWidth < 650' if narrow else 'innerWidth > 1000') + await page.get_by_role('heading', name=heading, exact=True).wait_for(state='visible') + await page.evaluate('document.fonts.ready') + if narrow: + await page.locator('.extension-update-status').last.scroll_into_view_if_needed() + dimensions = await page.evaluate("({width:innerWidth,height:innerHeight,overflow:[...document.querySelectorAll('.settings-main,.settings-scroll-area,.extension-update-status')].filter(e=>e.scrollWidth>e.clientWidth+1).map(e=>e.className)})") + assert not dimensions['overflow'], dimensions + assert await page.locator('[role=alert]').count() == 0, await page.locator('[role=alert]').all_text_contents() + await page.screenshot(path=str(output / f'{name}.png'), full_page=False, scale='css') + records.append(dict(name=name, **dimensions)) + + await show('live-scheduled-zh', dict(scheduledInstall=dict(id=package, version='1.1.0', digest='b'*64)), '已预约安装') + await show('live-ready-zh', dict(pendingUpdate=ready), '更新待确认') + result = await page.evaluate("window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_THEME:'wallpaper-slice'}})") + assert result['ok'], result + await page.wait_for_function("document.documentElement.dataset.theme === 'wallpaper-slice'") + await show('live-ready-dark-zh', dict(pendingUpdate=ready), '更新待确认') + result = await page.evaluate("window.amadeus.updateDesktopSettings({values:{AMADEUS_UI_THEME:'classic'}})") + assert result['ok'], result + await page.wait_for_function("document.documentElement.dataset.theme === 'classic'") + recovery = dict(ready, id='org.example.long-package-identity-for-layout-checks', canConfirm=False, recoveryRequired=True) + await show('live-recovery-narrow-zh', dict(pendingUpdate=recovery, rollbackScheduled=True), '需要恢复', narrow=True) + (root / 'build/pr261-update-ui-viewports.json').write_text(json.dumps(records, indent=2), encoding='utf-8') + checks['update_ui_live_captures'] = True + return checks + +smoke._exercise_renderer = capture +sys.argv = ['smoke_electron_model_less.py', '--report-dir', str(root / 'build/pr261-update-ui-smoke')] +raise SystemExit(smoke.main()) diff --git a/docs/evidence/pr261-update-ui/live-default-zh.png b/docs/evidence/pr261-update-ui/live-default-zh.png new file mode 100644 index 00000000..31753a5d Binary files /dev/null and b/docs/evidence/pr261-update-ui/live-default-zh.png differ diff --git a/docs/evidence/pr261-update-ui/live-ready-dark-zh.png b/docs/evidence/pr261-update-ui/live-ready-dark-zh.png new file mode 100644 index 00000000..8f361476 Binary files /dev/null and b/docs/evidence/pr261-update-ui/live-ready-dark-zh.png differ diff --git a/docs/evidence/pr261-update-ui/live-ready-zh.png b/docs/evidence/pr261-update-ui/live-ready-zh.png new file mode 100644 index 00000000..99477da1 Binary files /dev/null and b/docs/evidence/pr261-update-ui/live-ready-zh.png differ diff --git a/docs/evidence/pr261-update-ui/live-recovery-narrow-zh.png b/docs/evidence/pr261-update-ui/live-recovery-narrow-zh.png new file mode 100644 index 00000000..efb66563 Binary files /dev/null and b/docs/evidence/pr261-update-ui/live-recovery-narrow-zh.png differ diff --git a/docs/evidence/pr261-update-ui/live-scheduled-zh.png b/docs/evidence/pr261-update-ui/live-scheduled-zh.png new file mode 100644 index 00000000..3c3afc73 Binary files /dev/null and b/docs/evidence/pr261-update-ui/live-scheduled-zh.png differ diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md index b0ad5942..8241789a 100644 --- a/docs/extension_authoring.md +++ b/docs/extension_authoring.md @@ -20,6 +20,60 @@ 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. +## Desktop installation and updates + +Windows queues catalog and local-wheel installations for the next app start. +Installation requires native confirmation and proof that the owned backend +process tree has exited. +Updates retain the previous package, configuration and OS-encrypted credentials +until the user keeps the verified update or explicitly rolls it back. In the +same app session, a pending update can be configured and retried: saving its +configuration invalidates prior health results, and confirmation requires health +for the exact new revision. Failed persistence, a failed backend startup, or an +unconfirmed update carried into another app session still requires recovery. +Explicitly disabling a package is preserved by rollback. + +Retained-version recovery restores only package records, configuration and +encrypted credentials while the current backend is stopped. It does not delete +either installation or require a graceful-exit receipt. File deletion and new +installation still require physical process-exit evidence. Electron escalates a +failed or timed-out shutdown by terminating the registered Windows Job, after +checking the backend's creation identity and Job membership. Its outside +observer survives and can supply exit evidence once every owned descendant has +stopped. If the Job cannot be verified/opened, the fallback stops the owned +process tree and may also kill the observer; that case still needs later-boot +recovery. Unresolved Work outcomes remain in the Work ledger and +are never marked successful or replayed by plugin recovery. + +Cleanup registration and its Python-root probe are maintenance prerequisites, +not prerequisites for core backend startup. If they fail, Electron reports a +warning, starts without cleanup credentials, and fences installation/deletion +for that app session. It also attempts to persist unknown ownership; corrupt or +missing-index evidence is preserved, not reset. Authenticated backend reuse has +the same isolation. The data root and its contents cannot be links/junctions; +links above that root (for example, a relocated user profile) are supported by +both desktop and backend checks. + +If containment or observer evidence was lost, a positively observed later +Windows boot retires the old process owners. This uses the OS boot counter, +not the clock, a closed port or an absent root PID. Sleep, an unavailable boot +identity, and a later clean app launch do not clear unknown evidence. Existing +PR profiles without a boot binding first acquire one conservatively; if their +old exit evidence is missing, restart Windows after opening the updated app. +Use Windows **Restart**, rather than relying on shutdown/fast startup or resume, +when recovering lost evidence. Fast-startup and hibernation transitions have +not been qualified by this change. Windows shutdown/session-end cleanup is best effort; recovery does not depend +on shutdown callbacks running to completion. + +Linux and macOS retain the existing version-isolated installation path, with +native confirmation at installation time. Backend contributions apply after a +backend restart; standalone themes apply immediately. These platforms do not +create the Windows cleanup ledger or a pending-update rollback journal, and do +not offer scheduled installation or retained-version recovery. Opt-in daily +catalog checks remain notification-only on every platform. This does not claim +POSIX descendant containment; the existing shutdown/cleanup behavior, including +the 2.5-second graceful-stop deadline, is retained. + ## Minimal static checker environment From this Host checkout on Windows with CPython 3.12.10: diff --git a/electron/src/main/appLifecycle.ts b/electron/src/main/appLifecycle.ts index 5b484a23..0ab0bd01 100644 --- a/electron/src/main/appLifecycle.ts +++ b/electron/src/main/appLifecycle.ts @@ -2,6 +2,7 @@ export class ApplicationLifecycle { private quitting = false private startupComplete = false private restoreAfterStartup = false + private sessionCleanup?: Promise get isQuitting(): boolean { return this.quitting } @@ -35,4 +36,12 @@ export class ApplicationLifecycle { this.quitting = true return hasOwnedBackend } + + /** Windows may cancel a query-session-end. Only the confirmed event commits + * quit intent; neither event delays/vetoes the operating system shutdown. */ + handleSessionEnd(confirmed: boolean, cleanup: () => Promise, reportFailure: (error: unknown) => void): Promise { + if (confirmed) this.quitting = true + return this.sessionCleanup ??= Promise.resolve().then(cleanup).catch(reportFailure) + .finally(() => { this.sessionCleanup = undefined }) + } } diff --git a/electron/src/main/backendProcessTree.ts b/electron/src/main/backendProcessTree.ts index 8bc8152c..7034370f 100644 --- a/electron/src/main/backendProcessTree.ts +++ b/electron/src/main/backendProcessTree.ts @@ -1,14 +1,288 @@ -import { spawn, type ChildProcess } from 'node:child_process' +import { execFile, spawn, type ChildProcess } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import fs from 'node:fs' import path from 'node:path' +import { usesScheduledExtensionUpdates } from '../shared/extensionCatalog.js' + +const CLEANUP_SCHEMA = 'amadeus.backend-cleanup.v1' + +/** The packaged frontend and an explicitly supplied Python backend may live + * in different directories. Bind cleanup to the code that Python will load. */ +export async function resolveBackendProjectRoot(python: string, cwd: string, environment: NodeJS.ProcessEnv, + execute: typeof execFile = execFile): Promise { + const source = "import importlib.util,json,pathlib; s=importlib.util.find_spec('server'); assert s and s.origin; print(json.dumps(str(pathlib.Path(s.origin).resolve().parent.parent)))" + const output = await new Promise((resolve, reject) => { + execute(python, ['-c', source], { cwd, env: environment, windowsHide: true, timeout: 10000, maxBuffer: 32 * 1024 }, + (error, stdout) => error ? reject(error) : resolve(String(stdout).trim())) + }) + const root: unknown = JSON.parse(output) + if (typeof root !== 'string' || !path.isAbsolute(root) || root.includes('\0')) throw Error('Python backend code root is unavailable') + return root +} +type CleanupOwner = { id: string; nonce: string; project_root: string; python_command: string } +type CleanupIndex = { schema: string; unknown: boolean; owners: CleanupOwner[]; bootId?: number } +type ProcessIdentity = { pid: number; created: string } +type BootProbe = () => Promise + +// Desktop is the sole index writer. Hold this boundary across asynchronous +// identity probes and durable writes so compaction cannot erase a new owner. +const cleanupOperations = new Map>() +async function serializeCleanup(statePath: string, action: () => Promise): Promise { + ordinaryPath(statePath) + const key = process.platform === 'win32' ? statePath.toLowerCase() : statePath + const previous = cleanupOperations.get(key) ?? Promise.resolve() + let release!: () => void + const current = new Promise(resolve => { release = resolve }) + cleanupOperations.set(key, current) + await previous + try { return await action() } + finally { + release() + if (cleanupOperations.get(key) === current) cleanupOperations.delete(key) + } +} + +function ordinaryPath(file: string, root = path.dirname(file)): void { + if (!path.isAbsolute(file) || path.normalize(file) !== file) throw Error('Cleanup path must be canonical and absolute') + const relative = path.relative(root, file) + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw Error('Cleanup path is outside its data root') + // Match extension storage: the data root and its contents must be ordinary, + // but a relocated user profile may have a junction above that root. + for (let item = file; ; item = path.dirname(item)) { + try { if (fs.lstatSync(item).isSymbolicLink()) throw Error('Cleanup path cannot traverse a link') } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } + if (item === root) break + } +} + +function readJson(file: string, root = path.dirname(file)): unknown { + ordinaryPath(file, root) + const info = fs.lstatSync(file) + if (!info.isFile() || info.size > 2 * 1024 * 1024) throw Error('Invalid cleanup record') + return JSON.parse(fs.readFileSync(file, 'utf8')) +} + +function validOwner(value: unknown): value is CleanupOwner { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const item = value as CleanupOwner + return Object.keys(item).sort().join(',') === 'id,nonce,project_root,python_command' + && typeof item.id === 'string' && /^[a-f0-9]{32}$/.test(item.id) + && typeof item.nonce === 'string' && item.nonce.length >= 16 && item.nonce.length <= 256 + && typeof item.project_root === 'string' && path.isAbsolute(item.project_root) + && typeof item.python_command === 'string' && item.python_command.length > 0 +} + +function readIndex(file: string): CleanupIndex | null { + try { + // A failed write-through replacement can leave the previous (even empty) + // index readable. Its uncommitted transaction is missing ownership evidence, + // not permission to install on the next desktop launch. + ordinaryPath(file) + const prefix = `${path.basename(file)}.` + if (fs.readdirSync(path.dirname(file)).some(name => name.startsWith(prefix) && name.endsWith('.tmp') + && /^[a-f0-9]{32}$/.test(name.slice(prefix.length, -4)))) throw Error('Cleanup index has an interrupted update') + const value = readJson(file) as CleanupIndex + if (!value || value.schema !== CLEANUP_SCHEMA || typeof value.unknown !== 'boolean' + || value.bootId !== undefined && !validBootId(value.bootId) + || !Array.isArray(value.owners) || value.owners.length > 4096 || !value.owners.every(validOwner) + || new Set(value.owners.map(owner => owner.id)).size !== value.owners.length) throw Error('Invalid cleanup index') + return value + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + // An index lost independently from its receipts is not a fresh installation. + if (fs.existsSync(`${file}.receipts`)) throw Error('Cleanup index is missing') + return null + } +} + +function powershell(script: string): Promise { + const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows' + return new Promise((resolve, reject) => { + execFile(path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + { windowsHide: true, timeout: 10000, maxBuffer: 32 * 1024, + env: { SystemRoot: systemRoot, WINDIR: systemRoot } }, + (error, stdout) => error ? reject(error) : resolve(String(stdout).trim())) + }) +} + +function validBootId(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 0xffffffff +} + +let bootIdentity: Promise | undefined +/** Windows' boot counter is independent of wall-clock changes and PID reuse. + * https://learn.microsoft.com/windows-hardware/design/device-experiences/oem-hvci-enablement + * Missing evidence never authorizes retirement; ordinary Job receipts still work. */ +export function readWindowsBootId(): Promise { + if (process.platform !== 'win32') return Promise.resolve(undefined) + const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows' + // Query the registry directly; boot observation does not need a PowerShell + // host (whose startup can exhaust the deadline on a loaded hosted runner). + return bootIdentity ??= new Promise((resolve, reject) => { + execFile(path.join(systemRoot, 'System32', 'reg.exe'), + ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Memory Management\\PrefetchParameters', '/v', 'BootId', '/reg:64'], + { windowsHide: true, timeout: 10000, maxBuffer: 32 * 1024, + env: { SystemRoot: systemRoot, WINDIR: systemRoot } }, + (error, stdout) => { + if (error) { reject(error); return } + const rows = [...String(stdout).matchAll(/^\s*BootId[ \t]+REG_DWORD[ \t]+(0x[0-9a-f]+)[ \t]*\r?$/gim)] + const value = rows.length === 1 ? Number(rows[0][1]) : NaN + if (!validBootId(value)) { reject(Error('Windows boot identity is unavailable')); return } + resolve(value) + }) + }).catch(() => { bootIdentity = undefined; return undefined }) +} + +function reconcileBoot(index: CleanupIndex, bootId: number | undefined): CleanupIndex { + if (bootId === undefined) return index + if (!validBootId(bootId)) throw Error('Invalid Windows boot identity') + // Existing PR profiles lack a boot binding. Adopt this boot without granting + // cleanup authority: their first positively observed later boot can retire it. + if (index.bootId === undefined) return { ...index, bootId } + if (bootId < index.bootId) throw Error('Windows boot identity moved backwards; cleanup remains unconfirmed') + if (bootId === index.bootId) return index + // No process from the earlier boot can still hold package files. This says + // nothing about Work outcomes; their durable recovery records are untouched. + return { schema: CLEANUP_SCHEMA, bootId, unknown: false, owners: [] } +} + +async function atomicIndex(file: string, value: CleanupIndex): Promise { + ordinaryPath(file) + fs.mkdirSync(path.dirname(file), { recursive: true }) + ordinaryPath(file) + const temporary = `${file}.${randomBytes(16).toString('hex')}.tmp` + const handle = fs.openSync(temporary, 'wx', 0o600) + try { fs.writeFileSync(handle, JSON.stringify(value) + '\n', 'utf8'); fs.fsyncSync(handle) } + finally { fs.closeSync(handle) } + ordinaryPath(file) + if (process.platform === 'win32') { + const quote = (value: string) => "'" + value.replaceAll("'", "''") + "'" + // Declare the native call in memory. Add-Type starts a C# compiler for each + // write, adding an avoidable process and cold-start cost to this deadline. + await powershell(`$ErrorActionPreference='Stop' +$assembly=[AppDomain]::CurrentDomain.DefineDynamicAssembly([Reflection.AssemblyName]::new('BackendCleanupMove'),[Reflection.Emit.AssemblyBuilderAccess]::Run) +$module=$assembly.DefineDynamicModule('Native') +$type=$module.DefineType('BackendCleanupMove',[Reflection.TypeAttributes]'Public,Abstract,Sealed') +$method=$type.DefinePInvokeMethod( + 'MoveFileExW','kernel32.dll',[Reflection.MethodAttributes]'Public,Static,PinvokeImpl', + [Reflection.CallingConventions]::Standard,[bool],[Type[]]@([string],[string],[int]), + [Runtime.InteropServices.CallingConvention]::Winapi,[Runtime.InteropServices.CharSet]::Unicode) +$method.SetImplementationFlags($method.GetMethodImplementationFlags() -bor [Reflection.MethodImplAttributes]::PreserveSig) +$native=$type.CreateType() +if(-not $native::MoveFileExW(${quote(temporary)},${quote(file)},9)){exit 3}`) + } else { + fs.renameSync(temporary, file) + const directory = fs.openSync(path.dirname(file), 'r') + try { fs.fsyncSync(directory) } finally { fs.closeSync(directory) } + } +} + +/** Register before spawn. Never replace an earlier unresolved launch with a new one. */ +export async function prepareBackendCleanup(statePath: string, + { nonce, projectRoot, pythonCommand }: { nonce: string; projectRoot: string; pythonCommand: string }, + platform: NodeJS.Platform = process.platform, getBootId: BootProbe = readWindowsBootId): Promise<{ environment: Record }> { + if (!usesScheduledExtensionUpdates(platform)) return { environment: {} } + return serializeCleanup(statePath, async () => { + const index = reconcileBoot(readIndex(statePath) ?? { schema: CLEANUP_SCHEMA, unknown: false, owners: [] }, await getBootId()) + const owner: CleanupOwner = { id: randomBytes(16).toString('hex'), nonce, + project_root: path.resolve(projectRoot), python_command: pythonCommand } + if (!validOwner(owner) || index.owners.length >= 4096) throw Error('Invalid backend cleanup launch') + // First persist the owner. If directory creation/spawn later fails, retain it. + await atomicIndex(statePath, { ...index, owners: [...index.owners, owner] }) + const directory = `${statePath}.receipts` + ordinaryPath(directory) + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }) + ordinaryPath(directory) + return { environment: { AMADEUS_BACKEND_CLEANUP_LAUNCH: JSON.stringify({ + schema: CLEANUP_SCHEMA, state_path: statePath, owner, + }) } } + }) +} + +/** Reusing a backend without its original lifetime handle cannot prove cleanup. */ +export async function markBackendCleanupUnconfirmed(statePath: string, { getBootId = readWindowsBootId }: { getBootId?: BootProbe } = {}): Promise { + return serializeCleanup(statePath, async () => { + const index = reconcileBoot(readIndex(statePath) ?? { schema: CLEANUP_SCHEMA, unknown: false, owners: [] }, await getBootId()) + await atomicIndex(statePath, { ...index, unknown: true }) + }) +} + +async function processIdentityGone(identity: ProcessIdentity): Promise { + if (process.platform !== 'win32') return false + const result = JSON.parse(await powershell(`$ErrorActionPreference='Stop'; try { $p=[System.Diagnostics.Process]::GetProcessById(${identity.pid}); $created=$p.StartTime.ToUniversalTime().ToFileTimeUtc().ToString(); @{state='running';created=$created}|ConvertTo-Json -Compress } catch [System.ArgumentException] { '{"state":"absent"}' } catch { exit 3 }`)) + return result.state === 'absent' || (result.state === 'running' + && typeof result.created === 'string' && /^\d+$/.test(result.created) && result.created !== identity.created) +} + +/** Fresh means only that this protocol has never registered an owner. Legacy + * orphan processes require an operator migration check; absence cannot prove them. + * Fully verified owners are discharged from the live index; receipts stay archived. */ +export async function verifyPreviousBackendCleanup(statePath: string, + { isProcessIdentityGone = processIdentityGone, getBootId = readWindowsBootId }: { + isProcessIdentityGone?: (identity: ProcessIdentity) => Promise; + getBootId?: BootProbe; + } = {}): Promise { + try { + return await serializeCleanup(statePath, async () => { + const saved = readIndex(statePath) + if (saved === null) return true + const index = reconcileBoot(saved, await getBootId()) + if (index !== saved) await atomicIndex(statePath, index) + if (index.unknown) return false + for (const owner of index.owners) { + const receipt = readJson(path.join(`${statePath}.receipts`, `${owner.id}.json`), path.dirname(statePath)) as { + schema: string; state: string; owner: CleanupOwner; process: ProcessIdentity; observer: ProcessIdentity; + proof: string; job_name: string; providers_settled: boolean; work_settled: boolean; + } + if (!receipt || receipt.schema !== CLEANUP_SCHEMA || !['exited', 'settled'].includes(receipt.state) + || !validOwner(receipt.owner) || Object.keys(owner).some(key => owner[key as keyof CleanupOwner] !== receipt.owner[key as keyof CleanupOwner]) + || receipt.proof !== 'windows-job-drained' + // Old receipts remain valid only with their original complete shape. + || receipt.state === 'settled' && (receipt.providers_settled !== true || receipt.work_settled !== true) + || receipt.job_name !== `Global\\AmadeusBackendCleanup-${owner.id}` + || ![receipt.process, receipt.observer].every(identity => identity + && Number.isSafeInteger(identity.pid) && identity.pid > 0 + && typeof identity.created === 'string' && /^[1-9]\d{10,20}$/.test(identity.created)) + || !await isProcessIdentityGone(receipt.process) || !await isProcessIdentityGone(receipt.observer)) return false + } + // Every registered root and observer was positively verified. Receipts stay + // archived; future checks only inspect owners not yet discharged here. + if (index.owners.length) await atomicIndex(statePath, { ...index, owners: [] }) + return true + }) + } catch { return false } +} /** Force-stop only the backend process created by this Electron instance. */ export async function forceStopBackendTree(proc: ChildProcess, platform: string = process.platform, - createProcess: typeof spawn = spawn): Promise { + createProcess: typeof spawn = spawn, cleanupLaunch?: string): Promise { if (!Number.isSafeInteger(proc.pid) || Number(proc.pid) <= 0) throw new Error('Owned backend process identity is unavailable') if (platform !== 'win32') { proc.kill() return } + if (cleanupLaunch) { + try { + const launch = JSON.parse(cleanupLaunch) + if (launch.schema !== CLEANUP_SCHEMA || !validOwner(launch.owner)) throw Error('Invalid cleanup launch') + // Terminate only the Job registered for this launch. The independent + // observer is outside it and can still certify the resulting exit. + await new Promise((resolve, reject) => { + execFile(launch.owner.python_command, + ['-I', '-B', path.join(launch.owner.project_root, 'server', 'backend_cleanup.py'), '--terminate', cleanupLaunch], + { windowsHide: true, timeout: 10000, maxBuffer: 32 * 1024, + env: { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR } }, + error => error ? reject(error) : resolve()) + }) + return + } catch { + // Missing/invalid Job evidence cannot authorize another Job. Preserve the + // old owned-tree stop as a fallback; it may also kill the observer. + if (hasExited(proc)) return + console.warn('[electron] registered backend Job could not be terminated; falling back to owned process-tree shutdown') + } + } const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows' await new Promise((resolve, reject) => { const child = createProcess(path.join(systemRoot, 'System32', 'taskkill.exe'), ['/PID', String(proc.pid), '/T', '/F'], { @@ -39,15 +313,31 @@ export function waitForProcessExit(proc: ChildProcess, timeoutMs: number): Promi /** Returns whether child lifetime was confirmed, after the owned backend exits. */ export async function stopOwnedBackend(proc: ChildProcess, requestShutdown: () => Promise, - { platform = process.platform, forceStop = forceStopBackendTree, waitForExit = waitForProcessExit }: { + { platform = process.platform, forceStop = forceStopBackendTree, waitForExit = waitForProcessExit, + verifyCleanup, cleanupLaunch }: { platform?: string; forceStop?: typeof forceStopBackendTree; waitForExit?: typeof waitForProcessExit; + verifyCleanup?: () => Promise; + cleanupLaunch?: string; } = {}): Promise { const requested = await requestShutdown() - if (requested && await waitForExit(proc, 2500)) return true + if (requested && await waitForExit(proc, usesScheduledExtensionUpdates(platform) ? 15000 : 2500)) { + // Preserve the pre-existing POSIX graceful-root shutdown contract. This is + // not descendant proof and is never used for the Windows update boundary. + if (!usesScheduledExtensionUpdates(platform)) return true + if (!verifyCleanup) return false + // The out-of-Job observer publishes only after root exit. Give that final + // durable write a bounded verification grace period; never infer success. + const deadline = Date.now() + 2000 + do { + try { if (await verifyCleanup()) return true } catch { return false } + if (Date.now() >= deadline) return false + await new Promise(resolve => setTimeout(resolve, 50)) + } while (true) + } // The process may have exited between the request/timeout and escalation. // That proves the backend stopped, but not that a forced tree cleanup ran. if (hasExited(proc)) return false - try { await forceStop(proc, platform) } + try { await forceStop(proc, platform, spawn, cleanupLaunch) } catch (error) { if (hasExited(proc)) return false throw error @@ -55,5 +345,7 @@ export async function stopOwnedBackend(proc: ChildProcess, requestShutdown: () = if (!await waitForExit(proc, 1200) && !hasExited(proc)) { throw new Error('Owned backend shutdown did not complete') } - return platform === 'win32' + // Neither Job termination nor taskkill is itself cleanup proof. A surviving + // observer can publish a receipt; the taskkill fallback may kill it too. + return false } diff --git a/electron/src/main/desktopSettings.ts b/electron/src/main/desktopSettings.ts index 3a645d37..66480fa7 100644 --- a/electron/src/main/desktopSettings.ts +++ b/electron/src/main/desktopSettings.ts @@ -661,6 +661,33 @@ export class DesktopSettingsStore { return result } + /** Called only by the installation owner after canonical validation. */ + validateExtensionRollbackRecord(raw: unknown, validate: ExtensionRecordValidator = this.validateExtensionRecord): StoredExtension { + if (!raw || typeof raw !== 'object' || typeof (raw as StoredExtension).id !== 'string') throw new Error('Invalid plugin recovery record') + const id = (raw as StoredExtension).id + let validationUnavailable = false + const clean = cleanStoredExtensions({ [id]: raw }, (manifest, values) => { + const result = validate(manifest, values) + validationUnavailable = result === 'unavailable' + return result + }) + if (validationUnavailable) throw new Error('Plugin recovery validation is temporarily unavailable; saved package records were retained. Retry when the trusted validator is available.') + if (clean.errors.length || !clean.records[id]) throw new Error('Invalid plugin recovery package record') + return clean.records[id] + } + + /** Restore one installation only; never rewind global settings or security facts. */ + restoreExtension(previous: StoredExtension, failedInstallId: string): void { + const restored = this.validateExtensionRollbackRecord(previous) + const stored = this.read() + const current = stored.extensions[restored.id] + if (current && ![failedInstallId, restored.installId].includes(current.installId)) throw new Error('Plugin state changed; recovery will not overwrite another installation') + stored.extensions[restored.id] = structuredClone(restored) + if (this.extensionChangeNeedsRestart(restored, current)) markPending(stored, [EXTENSIONS_ENV]) + this.write(stored) + this.appliedExtensions.delete(restored.id) + } + /** Called only by the installation owner after canonical validation. */ saveExtension(environment: NodeJS.ProcessEnv, record: StoredExtension, secrets: Record = {}): Record { const stored = this.read() diff --git a/electron/src/main/extensionCatalog.ts b/electron/src/main/extensionCatalog.ts index 3286f28d..a2050c74 100644 --- a/electron/src/main/extensionCatalog.ts +++ b/electron/src/main/extensionCatalog.ts @@ -1,19 +1,26 @@ import fs from 'node:fs' import path from 'node:path' +import { createHash, randomBytes } from 'node:crypto' +import { assertExtensionPath } from './extensionPaths.js' import { isDeepStrictEqual } from 'node:util' import type { CatalogEntry, CatalogIndex, CatalogRequest, CatalogView, CatalogReply } from '../shared/extensionCatalog.js' +import { usesScheduledExtensionUpdates } from '../shared/extensionCatalog.js' import { DesktopSettingsStore } from './desktopSettings.js' -import { ExtensionInstaller, ExtensionPackageHelper, ExtensionChangeCancelled } from './extensions.js' +import { ExtensionInstaller, ExtensionPackageHelper, ExtensionChangeCancelled, removeExtensionDirectory } 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' +import type { ExtensionUpdateBoundary } from './extensionUpdates.js' export const OFFICIAL_EXTENSION_INDEX = 'https://raw.githubusercontent.com/Code-Amadeus/Amadeus-Extensions/main/catalog/v1/index.json' +type LocalSelection = { directory: string; file: string; id: string; version: string; digest: string } +type UpdatePreferences = { enabled: boolean; attemptedAt: number; notified: string; pending?: { id: string; version: string; digest: string }; local?: LocalSelection; rollback?: boolean } export function catalogRequest(raw: unknown): CatalogRequest { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid catalog request') const r = raw as CatalogRequest - const selected = r.action === 'install' || r.action === 'homepage' || r.action === 'repository' - if (!['get', 'status', 'refresh', 'recover', 'cancel', 'install', 'homepage', 'repository'].includes(r.action) - || Object.keys(r).some(k => !(selected ? ['action', 'id', 'version'] : ['action']).includes(k))) throw new Error('Invalid catalog request fields') + const selected = r.action === 'install' || r.action === 'schedule' || r.action === 'homepage' || r.action === 'repository' + if (!['get', 'status', 'refresh', 'recover', 'cancel', 'install', 'schedule', 'auto-check', 'cancel-scheduled', 'rollback', 'confirm-update', 'health', 'homepage', 'repository'].includes(r.action) + || Object.keys(r).some(k => !(selected ? ['action', 'id', 'version'] : r.action === 'auto-check' ? ['action', 'enabled'] : ['action']).includes(k))) throw new Error('Invalid catalog request fields') + if (r.action === 'auto-check' && typeof r.enabled !== 'boolean') throw new Error('Invalid automatic-check preference') 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 } @@ -25,14 +32,44 @@ export class ExtensionCatalog { private refreshController?: AbortController private active?: AbortController private progress?: CatalogView['downloading'] + private timer?: ReturnType 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 readonly download: Download = downloadExtension, + private readonly notifyUpdates: (updates: Array<{ id: string; version: string }>) => void = () => {}, + private readonly platform: NodeJS.Platform = process.platform) {} async dispatch(raw: unknown, environment: NodeJS.ProcessEnv, openExternal: (url: string) => Promise): Promise { try { const request = catalogRequest(raw) + if (['schedule', 'rollback', 'confirm-update', 'health'].includes(request.action) && !usesScheduledExtensionUpdates(this.platform)) { + throw new Error('Scheduled plugin updates are only available on Windows.') + } 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 === 'schedule') { + if (this.installer.pendingUpdate()) throw new Error('Confirm or roll back the previous plugin update first.') + await this.refresh() + const selected = this.entry(request.id, request.version) + this.admitted(selected) + const preferences = this.preferences() + this.savePreferences({ ...preferences, pending: { id: selected.id, version: selected.version, digest: selected.sha256 }, local: undefined, rollback: false }) + if (preferences.local) this.removeLocal(preferences.local) + return { ok: true, scheduled: true, catalog: await this.view() } + } + if (request.action === 'auto-check') this.savePreferences({ ...this.preferences(), enabled: request.enabled }) + if (request.action === 'cancel-scheduled') { + const preferences = this.preferences() + this.savePreferences({ ...preferences, pending: undefined, local: undefined, rollback: false }) + if (preferences.local) this.removeLocal(preferences.local) + } + if (request.action === 'rollback') { + if (!this.installer.pendingUpdate()) throw new Error('No plugin update is waiting for rollback.') + const preferences = this.preferences() + this.savePreferences({ ...preferences, pending: undefined, local: undefined, rollback: true }) + if (preferences.local) this.removeLocal(preferences.local) + return { ok: true, scheduled: true, catalog: await this.view() } + } + if (request.action === 'confirm-update') return { ok: true, settings: await this.installer.confirmPendingUpdate(), 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() @@ -42,12 +79,143 @@ export class ExtensionCatalog { } } private get cacheFile(): string { return path.join(this.root, 'index.json') } + private preferences(): UpdatePreferences { + const file = path.join(this.root, 'update-preferences.json') + assertExtensionPath(this.root, file) + if (!fs.existsSync(file)) return { enabled: false, attemptedAt: 0, notified: '' } + if (fs.statSync(file).size > 64 * 1024) throw new Error('Plugin update preferences are too large; automatic checking is blocked.') + const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as UpdatePreferences + if (typeof raw.enabled !== 'boolean' || !Number.isFinite(raw.attemptedAt) || raw.attemptedAt < 0 || typeof raw.notified !== 'string' + || raw.rollback !== undefined && typeof raw.rollback !== 'boolean') throw new Error('Plugin update preferences are invalid; automatic checking and scheduled installation are blocked.') + if (raw.pending) { + catalogRequest({ action: 'install', id: raw.pending.id, version: raw.pending.version }) + if (!/^[a-f0-9]{64}$/.test(raw.pending.digest)) throw new Error('Scheduled plugin digest is invalid.') + } + if (raw.local) { + if (typeof raw.local.id !== 'string' || !raw.local.id || raw.local.id.length > 128 || typeof raw.local.version !== 'string' || !raw.local.version || raw.local.version.length > 128 + || !/^[a-f0-9]{32}$/.test(raw.local.directory) || !/^[A-Za-z0-9_][A-Za-z0-9_.+-]*\.whl$/.test(raw.local.file) + || !/^[a-f0-9]{64}$/.test(raw.local.digest)) throw new Error('Scheduled local wheel identity is invalid.') + } + if ([raw.pending, raw.local, raw.rollback].filter(Boolean).length > 1) throw new Error('Conflicting scheduled plugin changes are blocked.') + return raw + } + private localFile(selection: LocalSelection): string { + const file = path.join(this.root, 'pending-local', selection.directory, selection.file) + assertExtensionPath(this.root, file) + return file + } + private removeLocal(selection: LocalSelection): void { + const root = path.join(this.root, 'pending-local') + removeExtensionDirectory(root, path.join(root, selection.directory), selection.directory) + } + /** Native file picker only; renderer and Runtime never supply a source path. */ + async scheduleLocal(wheel: string): Promise { + if (!usesScheduledExtensionUpdates(this.platform)) throw new Error('Scheduled plugin updates are only available on Windows.') + if (this.installer.pendingUpdate()) throw new Error('Confirm or roll back the previous plugin update first.') + if (!path.isAbsolute(wheel) || path.extname(wheel).toLowerCase() !== '.whl' || !fs.statSync(wheel).isFile() || fs.statSync(wheel).size > MAX_WHEEL_BYTES) throw new Error('Select a bounded local Python wheel (.whl).') + const directory = randomBytes(16).toString('hex'), file = path.basename(wheel) + const target = this.localFile({ directory, file, id: '', version: '', digest: '' }) + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(path.join(path.dirname(target), '.amadeus-extension-install.json'), JSON.stringify({ installId: directory }), { flag: 'wx', mode: 0o600 }) + let persisted = false + try { + fs.copyFileSync(wheel, target, fs.constants.COPYFILE_EXCL) + if (fs.statSync(target).size > MAX_WHEEL_BYTES) throw new Error('The selected local wheel exceeds the size limit.') + const inspected = await this.helper.call('inspect-wheel', [target]) + const digest = createHash('sha256').update(fs.readFileSync(target)).digest('hex') + if (!inspected.manifest || inspected.digest !== digest) throw new Error('The selected local wheel identity could not be verified.') + const selection = { directory, file, id: inspected.manifest.id, version: inspected.manifest.version, digest } + const preferences = this.preferences() + this.savePreferences({ ...preferences, pending: undefined, local: selection, rollback: false }) + persisted = true + if (preferences.local) this.removeLocal(preferences.local) + } finally { if (!persisted) this.removeLocal({ directory, file, id: '', version: '', digest: '' }) } + } + private savePreferences(value: UpdatePreferences): void { + assertExtensionPath(this.root, path.join(this.root, 'update-preferences.json')) + fs.mkdirSync(this.root, { recursive: true }) + const temporary = path.join(this.root, `update-${randomBytes(16).toString('hex')}.tmp`) + const handle = fs.openSync(temporary, 'wx', 0o600) + try { fs.writeFileSync(handle, JSON.stringify(value) + '\n'); fs.fsyncSync(handle) } + finally { fs.closeSync(handle) } + try { fs.renameSync(temporary, path.join(this.root, 'update-preferences.json')) } + finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary) } + this.changed() + } + startAutomaticChecking(): void { + if (this.timer) return + void this.checkAutomatic().catch(() => {}) + this.timer = setInterval(() => { void this.checkAutomatic().catch(() => {}) }, 60_000) + this.timer.unref?.() + } + stopAutomaticChecking(): void { if (this.timer) clearInterval(this.timer); this.timer = undefined } + async checkAutomatic(now = Date.now()): Promise { + const preferences = this.preferences() + if (!preferences.enabled || this.active || this.refreshTask || now - preferences.attemptedAt < 86_400_000) return + this.savePreferences({ ...preferences, attemptedAt: now }) + const view = await this.refresh() + const updates = this.store.storedExtensionRecords().flatMap(installed => { + if (installed.pendingRemoval || !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(installed.version)) return [] + const compare = (left: string, right: string) => { + const a = left.split('.').map(BigInt), b = right.split('.').map(BigInt) + for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1 + return 0 + } + const next = view.entries.filter(entry => entry.id === installed.id && !entry.incompatible.length && !entry.withdrawal && !entry.blockedReason + && compare(entry.version, installed.version) > 0).sort((a, b) => compare(b.version, a.version))[0] + return next ? [{ id: next.id, version: next.version }] : [] + }) + const signature = JSON.stringify(updates.sort((a, b) => a.id.localeCompare(b.id))) + const current = this.preferences() + if (current.enabled && updates.length && signature !== current.notified) { + this.notifyUpdates(updates) + this.savePreferences({ ...this.preferences(), notified: signature }) + } + } + /** Only the app's stopped-backend boundary calls this before launching Host. */ + hasScheduled(): boolean { + if (!usesScheduledExtensionUpdates(this.platform)) return false + const preferences = this.preferences() + return Boolean(preferences.pending || preferences.local || preferences.rollback) + } + async applyScheduled(boundary: ExtensionUpdateBoundary): Promise { + if (!usesScheduledExtensionUpdates(this.platform)) throw new Error('Scheduled plugin updates are only available on Windows.') + const preferences = this.preferences() + const apply = async () => { + if (preferences.rollback) { + this.savePreferences({ ...preferences, rollback: false }) + await this.installer.rollbackPendingUpdate() + } else if (preferences.local) { + const selection = preferences.local + this.savePreferences({ ...preferences, local: undefined }) + const file = this.localFile(selection) + const assertCurrent = () => { + assertExtensionPath(this.root, file) + if (fs.statSync(file).size > MAX_WHEEL_BYTES || createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== selection.digest) throw new Error('Scheduled local wheel bytes changed; select the wheel again.') + } + try { assertCurrent(); await this.installer.install(file, { catalog: false, identity: selection, assertCurrent }) } + finally { this.removeLocal(selection) } + } else if (preferences.pending) { + // Consume intent before attempting: never retry a failed/cancelled install + // on another boot without a new explicit user selection. + this.savePreferences({ ...preferences, pending: undefined }) + await this.install(preferences.pending.id, preferences.pending.version, preferences.pending.digest) + } + } + if (preferences.rollback) await boundary.runRecovery(apply) + else await boundary.run(apply) + } 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 + let preferences: UpdatePreferences | undefined + let pendingUpdate: CatalogView['pendingUpdate'] + try { preferences = this.preferences(); pendingUpdate = this.installer.pendingUpdate?.() } + catch (error) { this.error ||= error instanceof Error ? error.message : 'Plugin recovery state is unavailable' } + const scheduled = preferences?.pending || preferences?.local 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 => { @@ -55,6 +223,7 @@ export class ExtensionCatalog { 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)) } }), + autoCheck: preferences?.enabled ?? false, scheduledInstall: scheduled ? { id: scheduled.id, version: scheduled.version, digest: scheduled.digest } : undefined, rollbackScheduled: preferences?.rollback, pendingUpdate, error: this.error || this.store.extensionSecurityBlock() || undefined, downloading: this.progress } } refresh(recovery = false): Promise { @@ -95,7 +264,7 @@ export class ExtensionCatalog { } 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> { + async install(id: string, version: string, expectedDigest?: string): Promise> { if (this.active) throw new Error('Another catalog installation is in progress') const controller = new AbortController() this.active = controller @@ -105,6 +274,7 @@ export class ExtensionCatalog { try { await this.refresh().catch(cancelledDownload) const selected = structuredClone(this.entry(id, version)) + if (expectedDigest && expectedDigest !== selected.sha256) throw new Error('Scheduled plugin bytes changed; select and review the version again.') const assertCurrent = () => { if (controller.signal.aborted) throw new ExtensionChangeCancelled() const current = this.entry(id, version) diff --git a/electron/src/main/extensionUpdates.ts b/electron/src/main/extensionUpdates.ts new file mode 100644 index 00000000..7e10ea96 --- /dev/null +++ b/electron/src/main/extensionUpdates.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs' +import path from 'node:path' +import { randomBytes } from 'node:crypto' +import { createConnection } from 'node:net' +import { assertExtensionPath } from './extensionPaths.js' +import type { StoredExtension } from './desktopSettings.js' + +/** A timeout/unreachable listener is unknown, never proof of stopped ownership. */ +export function localPortIsClosed(port: number): Promise { + return new Promise(resolve => { + const socket = createConnection({ host: '127.0.0.1', port }) + const finish = (closed: boolean) => { socket.destroy(); resolve(closed) } + socket.setTimeout(1000, () => finish(false)) + socket.once('connect', () => finish(false)) + socket.once('error', (error: NodeJS.ErrnoException) => finish(error.code === 'ECONNREFUSED')) + }) +} + +/** Hold the main-process launch boundary for the entire stopped-backend transaction. */ +export class ExtensionUpdateBoundary { + private held: 'install' | 'recovery' | undefined + constructor(private readonly stopped: () => Promise, + private readonly recoveryIdle: () => Promise = stopped) {} + assertHeld = (): void => { + if (this.held !== 'install') throw new Error('Plugin installation requires the stopped-backend update boundary; schedule it for the next app start.') + } + assertRecoveryHeld = (): void => { + if (!this.held) throw new Error('Plugin recovery requires the stopped-backend recovery boundary; schedule it for the next app start.') + } + assertBackendMayStart(): void { + if (this.held) throw new Error('Plugin maintenance is in progress; backend start is blocked.') + } + async run(operation: () => Promise): Promise { + return this.exclusive('install', this.stopped, operation) + } + /** Restore retained records without deleting files or claiming child cleanup. */ + async runRecovery(operation: () => Promise): Promise { + return this.exclusive('recovery', this.recoveryIdle, operation) + } + private async exclusive(kind: 'install' | 'recovery', idle: () => Promise, operation: () => Promise): Promise { + if (this.held) throw new Error('Another plugin maintenance transaction is in progress.') + this.held = kind + try { + if (!await idle()) throw new Error(kind === 'install' + ? 'The backend or its MCP/Work processes are still active or unconfirmed; plugin installation is blocked. After an unexpected exit, restart Windows if cleanup cannot be verified. Retained-version recovery remains available.' + : 'The backend is still active; stop it before restoring the retained plugin version.') + return await operation() + } finally { this.held = undefined } + } +} + +export type ExtensionUpdateRecord = { + format: 'amadeus.extension-update/v1'; owner: string + phase: 'pending' | 'startup_confirmed' | 'recovery_required' + previous: StoredExtension; target: StoredExtension +} + +/** Records only Host-owned package records; secret values remain OS-encrypted. */ +export class ExtensionUpdateJournal { + readonly owner = randomBytes(16).toString('hex') + constructor(private readonly root: string, private readonly validate: (raw: unknown) => StoredExtension) {} + private get file(): string { return path.join(this.root, 'update-rollback.json') } + read(validate = this.validate): ExtensionUpdateRecord | undefined { + assertExtensionPath(this.root, this.file) + if (!fs.existsSync(this.file)) return + if (fs.statSync(this.file).size > 2 * 1024 * 1024) throw new Error('Plugin recovery record is too large; installation is blocked.') + const raw = JSON.parse(fs.readFileSync(this.file, 'utf8')) as ExtensionUpdateRecord + if (raw?.format !== 'amadeus.extension-update/v1' || !/^[a-f0-9]{32}$/.test(raw.owner) + || !['pending', 'startup_confirmed', 'recovery_required'].includes(raw.phase)) throw new Error('Plugin recovery record is invalid; installation is blocked.') + const previous = validate(raw.previous), target = validate(raw.target) + if (previous.id !== target.id || previous.installId === target.installId) throw new Error('Plugin recovery identities do not match.') + return { format: raw.format, owner: raw.owner, phase: raw.phase, previous, target } + } + write(record: ExtensionUpdateRecord): void { + fs.mkdirSync(this.root, { recursive: true }) + assertExtensionPath(this.root, this.file) + const temporary = `${this.file}.${randomBytes(16).toString('hex')}.tmp` + assertExtensionPath(this.root, temporary) + const handle = fs.openSync(temporary, 'wx', 0o600) + try { fs.writeFileSync(handle, JSON.stringify(record) + '\n', 'utf8'); fs.fsyncSync(handle) } + finally { fs.closeSync(handle) } + try { fs.renameSync(temporary, this.file) } + finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary) } + } + clear(): void { + assertExtensionPath(this.root, this.file) + if (fs.existsSync(this.file)) fs.unlinkSync(this.file) + } +} diff --git a/electron/src/main/extensions.ts b/electron/src/main/extensions.ts index 90258ec3..174ad826 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -14,8 +14,10 @@ import type { ExtensionConfigValue, ExtensionManifest, ExtensionRemovalOptions, import { ExtensionThemeResources } from './extensionThemes.js' import { readExtensionImage } from './extensionImages.js' import { startupValue } from '../shared/startupSettings.js' +import { ExtensionUpdateJournal } from './extensionUpdates.js' +import { usesScheduledExtensionUpdates } from '../shared/extensionCatalog.js' import type { DesktopThemeState } from '../shared/extensionTheme.js' -import { extensionNeedsAgent, extensionRequiresRestart } from '../shared/extensionProfile.js' +import { contributionProfile, extensionNeedsAgent, extensionRequiresRestart } from '../shared/extensionProfile.js' 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[] } @@ -192,13 +194,116 @@ export class ExtensionInstaller { private backendReferences = new Set() private cleanupReady = false private uncertainPreparations = new Set() + private readonly updates: ExtensionUpdateJournal + private updateRecoveryRequired = false constructor(private readonly root: string, private readonly projectRoot: string, private readonly store: DesktopSettingsStore, private readonly helper: ExtensionPackageHelper, private readonly python: () => string, private readonly environment: NodeJS.ProcessEnv, private readonly run: ExtensionCommandRunner = runExtensionCommand, private readonly confirm: (review: ExtensionConfirmation) => Promise = async () => false, private readonly sdkDirectory = path.join(projectRoot, 'vendor', 'extension-sdk'), private readonly themes = new ExtensionThemeResources(), - private readonly changed: () => void = () => {}) {} + private readonly changed: () => void = () => {}, + private readonly assertInstallIdle: () => void = () => {}, + private readonly platform: NodeJS.Platform = process.platform, + private readonly assertRecoveryIdle: () => void = assertInstallIdle) { + this.updates = new ExtensionUpdateJournal(root, raw => store.validateExtensionRollbackRecord(raw)) + } + + async prepareStartup(): Promise { + // Structural validation permits loading records for the asynchronous Python + // validator. It grants no activation or rollback authority: re-read with the + // strict validator before the app can apply a queued change or launch Host. + const update = this.updates.read(raw => this.store.validateExtensionRollbackRecord(raw, () => true)) + await this.helper.refreshRecords([...this.store.storedExtensionRecords(), + ...(update ? [update.previous, update.target] : [])]) + if (update) this.updates.read() + } + + pendingUpdate(): { id: string; version: string; previousVersion: string; canConfirm: boolean; recoveryRequired: boolean } | undefined { + const update = this.updates.read() + if (!update) return + return { id: update.target.id, version: update.target.version, previousVersion: update.previous.version, + canConfirm: !this.updateRecoveryRequired && update.owner === this.updates.owner && update.phase === 'startup_confirmed', + recoveryRequired: this.updateRecoveryRequired || update.owner !== this.updates.owner || update.phase === 'recovery_required' } + } + assertUpdateCanLaunch(): void { + if (this.updateRecoveryRequired || this.pendingUpdate()?.recoveryRequired) throw new Error('An unconfirmed plugin update requires rollback before the backend can start. Open Manage plugins.') + } + private observeUpdateHealth(statuses: unknown): void { + const update = this.updates.read() + if (!update || update.owner !== this.updates.owner || this.updateRecoveryRequired || update.phase === 'recovery_required') return + const target = this.store.storedExtensionRecords().find(record => record.id === update.target.id + && record.installId === update.target.installId && record.revision === update.target.revision) + const rows = Array.isArray(statuses) ? statuses.filter(row => row?.id === update.target.id) : [] + const row = rows.length === 1 ? rows[0] : undefined + // Projection readiness is not a successful remote MCP call. User keep is + // still separate, and an earlier process's receipt is never reusable. + const expected = target ? Object.entries(target.manifest.contributes).flatMap(([kind, items]) => items + .filter(item => contributionProfile(kind, item.role)?.apply !== 'frontend').map(item => `${kind}/${item.id}`)).sort() : [] + const reported = Array.isArray(row?.contributions) ? row.contributions : [] + const backendReady = row?.version === target?.version && row?.revision === target?.revision + && row?.startup === 'accepted' && row?.state === 'active' + && reported.every((item: { health?: string }) => item?.health === 'ready') + && JSON.stringify(reported.map((item: { kind?: string; id?: string }) => `${item?.kind}/${item?.id}`).sort()) === JSON.stringify(expected) + const frontend = this.store.appliedExtensionRecords().find(record => record.id === target?.id + && record.installId === target.installId && record.revision === target.revision) + const frontendReady = frontend?.enabled && target && !this.store.extensionFrontendClosed(target.id) + const healthy = target && (!target.enabled || (expected.length ? backendReady : frontendReady)) + // A missing/unhealthy projection is still a pending validation in this + // process. Configuration can be repaired and the new revision retried; + // failed transactions and a later desktop process still require recovery. + this.updates.write({ ...update, phase: healthy ? 'startup_confirmed' : 'pending' }) + } + refreshUpdateHealth(statuses: unknown): Promise { + return this.serialize(async () => { this.observeUpdateHealth(statuses); this.changed() }) + } + markUpdateFailed(): void { + const update = this.updates.read() + if (update?.owner === this.updates.owner) { this.updateRecoveryRequired = true; this.updates.write({ ...update, phase: 'recovery_required' }) } + } + confirmPendingUpdate(): Promise> { + return this.serialize(async () => { + const update = this.updates.read() + if (!update || !this.pendingUpdate()?.canConfirm) throw new Error('Plugin startup has not been confirmed; the previous version is retained.') + const current = this.store.storedExtensionRecords().find(record => record.id === update.target.id) + if (current?.installId !== update.target.installId || current.revision !== update.target.revision) throw new Error('Plugin state changed; confirmation was refused.') + this.store.assertExtensionAllowed(update.target) + this.updates.clear() + this.changed() + return this.store.snapshot(this.environment) + }) + } + rollbackPendingUpdate(): Promise> { + return this.serialize(async () => { + if (!usesScheduledExtensionUpdates(this.platform)) throw new Error('Scheduled plugin updates are only available on Windows.') + this.assertRecoveryIdle() + const update = this.updates.read() + if (!update) throw new Error('No plugin update is waiting for rollback.') + const previous = update.previous + const directory = path.join(this.root, previous.installId) + assertExtensionPath(this.root, directory) + const originalWheel = fs.readdirSync(directory).find(name => { + const file = path.join(directory, name) + assertExtensionPath(this.root, file) + return name.endsWith('.whl') && fs.lstatSync(file).isFile() && createHash('sha256').update(fs.readFileSync(file)).digest('hex') === previous.digest + }) + if (!originalWheel) throw new Error('The previous verified wheel is unavailable; rollback is blocked without deleting either version.') + await this.helper.call('validate-installed', [this.packagePath(previous)], { manifest: previous.manifest }) + if (!await this.confirm({ operation: 'update', manifest: previous.manifest, previousVersion: update.target.version, + previousManifest: update.target.manifest, digest: previous.digest, sourceFile: originalWheel })) throw new ExtensionChangeCancelled() + this.assertRecoveryIdle() + const confirmedWheel = path.join(directory, originalWheel) + assertExtensionPath(this.root, confirmedWheel) + if (!fs.lstatSync(confirmedWheel).isFile() || createHash('sha256').update(fs.readFileSync(confirmedWheel)).digest('hex') !== previous.digest) throw new Error('The previous verified wheel changed during confirmation; rollback is blocked.') + this.store.restoreExtension(previous, update.target.installId) + this.updates.clear() + this.updateRecoveryRequired = false + // Recovery changes only retained records. Frontend application and file + // collection belong to the next launch, with their own lifetime checks. + this.changed() + return this.store.snapshot(this.environment) + }) + } private get dataRoot(): string { return path.join(path.dirname(this.root), 'extension-data') } @@ -240,8 +345,10 @@ export class ExtensionInstaller { return result } - install(wheel: string, delivery?: { identity: ArtifactIdentity; assertCurrent: () => void; onStage?: (stage: 'verifying' | 'confirming' | 'preparing') => void }): Promise> { + install(wheel: string, delivery?: { catalog?: boolean; identity: ArtifactIdentity; assertCurrent: () => void; onStage?: (stage: 'verifying' | 'confirming' | 'preparing') => void }): Promise> { return this.serialize(async () => { + this.assertInstallIdle() + if (this.updates.read()) throw new Error('Confirm or roll back the previous plugin update before installing another package.') this.store.assertExtensionAllowed() delivery?.assertCurrent() if (!path.isAbsolute(wheel) || path.extname(wheel).toLowerCase() !== '.whl' || !fs.lstatSync(wheel).isFile()) { @@ -255,26 +362,28 @@ export class ExtensionInstaller { this.preparing.add(installId) let committed = false let retainFiles = false + let rollback: { previous: StoredExtension; target: StoredExtension } | undefined try { // Copy before inspection: pip consumes the same immutable wheel bytes // 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 inspection = await this.helper.call(delivery && delivery.catalog !== false ? '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.assertInstallIdle() 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') + if (delivery && (identity.id !== delivery.identity.id || identity.version !== delivery.identity.version || identity.digest !== delivery.identity.digest)) throw new Error('Prepared artifact identity or digest differs from the selected bytes') 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') + if (delivery && delivery.catalog !== false && 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') { @@ -347,6 +456,11 @@ export class ExtensionInstaller { installId, packageRoot: inspection.package_root, sitePackages, manifest, enabled: existing?.enabled || false, pendingRemoval: false, deleteDataOnRemoval: false, values: checked.config || {}, encryptedSecrets: retainedSecrets, providers: existing?.providers || [] } + if (delivery && existing && usesScheduledExtensionUpdates(this.platform)) { + record.revision = (existing.revision ?? 1) + 1 + rollback = { previous: structuredClone(existing), target: record } + this.updates.write({ format: 'amadeus.extension-update/v1', owner: this.updates.owner, phase: 'pending', ...rollback }) + } if (existing && (extensionRequiresRestart(existing.manifest) || extensionRequiresRestart(manifest))) this.backendReferences.add(existing.installId) assertAllowed() this.store.saveExtension(this.environment, record) @@ -355,8 +469,22 @@ export class ExtensionInstaller { this.changed() return this.store.snapshot(this.environment) } catch (error) { - retainFiles = error instanceof ExtensionPreparationShutdownError - if (retainFiles) { + if (rollback) { + try { + this.store.restoreExtension(rollback.previous, installId) + this.updates.clear() + committed = false + } catch { + this.updateRecoveryRequired = true + retainFiles = true + try { this.updates.write({ format: 'amadeus.extension-update/v1', owner: this.updates.owner, phase: 'recovery_required', ...rollback }) } + catch { /* The previous durable journal still blocks a new process; this owner remains fenced in memory. */ } + this.store.reportExtensionWarning('Plugin update failed and rollback persistence needs recovery. Both package versions and encrypted credentials were retained.') + } + } + const shutdownUnconfirmed = error instanceof ExtensionPreparationShutdownError + retainFiles ||= shutdownUnconfirmed + if (shutdownUnconfirmed) { this.uncertainPreparations.add(installId) this.store.reportExtensionWarning('Extension preparation shutdown could not be confirmed; staging files were retained.') } @@ -376,6 +504,24 @@ export class ExtensionInstaller { return this.serialize(async () => { const record = this.store.extensionRecords().find(item => item.id === raw?.id) if (!record || record.pendingRemoval) throw new Error('Extension was not found or is pending removal') + const pending = this.updates.read() + const updatingPending = pending?.target.id === raw?.id + if (updatingPending && raw.enabled === false) { + const unchangedConfig = Object.keys(raw.values || {}).length === 0 && Object.keys(raw.secrets || {}).length === 0 + && (raw.providers === undefined || JSON.stringify(raw.providers) === JSON.stringify(record.providers)) + if (!unchangedConfig || Object.keys(raw).some(key => !['id', 'enabled', 'values', 'secrets', 'providers'].includes(key))) throw new Error('Confirm or roll back this plugin update before changing its configuration.') + // Revocation remains available even during recovery. Persist it in the + // rollback record first so recovery can never re-enable the package. + this.updates.write({ ...pending, phase: 'recovery_required', previous: { ...pending.previous, enabled: false }, + target: { ...record, enabled: false, revision: (record.revision ?? 1) + 1 } }) + this.store.saveExtension(this.environment, { ...record, enabled: false }) + await this.applyFrontendChanges() + this.changed() + return this.store.snapshot(this.environment) + } + if (updatingPending && this.pendingUpdate()?.recoveryRequired) { + throw new Error('Roll back this plugin update before changing its configuration.') + } if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') throw new Error('Invalid extension enabled value') 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') @@ -404,8 +550,20 @@ export class ExtensionInstaller { 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) + // Invalidate health durably before settings can advance. A failed write + // leaves recovery fenced, while the original rollback snapshot survives. + if (updatingPending) this.updates.write({ ...pending, phase: 'recovery_required' }) + try { + this.store.saveExtension(this.environment, { ...record, values: validated.config || {}, enabled, + providers: [...new Set(providers)] }, changedSecrets) + if (updatingPending) { + const target = this.store.storedExtensionRecords().find(item => item.id === record.id)! + this.updates.write({ ...pending, target, phase: 'pending' }) + } + } catch (error) { + if (updatingPending) this.updateRecoveryRequired = true + throw error + } await this.applyFrontendChanges() this.changed() return this.store.snapshot(this.environment) @@ -414,6 +572,7 @@ export class ExtensionInstaller { remove(id: string, options: ExtensionRemovalOptions = {}): Promise> { return this.serialize(async () => { + if (this.updates.read()?.target.id === id) throw new Error('Confirm or roll back this plugin update before removal.') if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => key !== 'deleteData') || (options.deleteData !== undefined && typeof options.deleteData !== 'boolean')) throw new Error('Invalid extension data removal choice') @@ -459,6 +618,8 @@ export class ExtensionInstaller { return } const retained = this.store.extensionInstallationReferences() + const update = this.updates.read() + if (update) { retained.add(update.previous.installId); retained.add(update.target.installId) } for (const applied of this.store.appliedExtensionRecords()) retained.add(applied.installId) for (const installId of this.backendReferences) retained.add(installId) for (const entry of fs.readdirSync(this.root)) { @@ -482,6 +643,7 @@ export class ExtensionInstaller { prepareLaunch(): Promise<{ extensions: string; environment: NodeJS.ProcessEnv; pendingRevisions: Record; records: StoredExtension[] }> { return this.serialize(async () => { + this.assertUpdateCanLaunch() // A fresh process cannot inherit a receipt from the previous process. // This retires logical state only; cleanup still requires confirmed stop. this.store.releaseBackendExtensionApplications() @@ -510,6 +672,7 @@ export class ExtensionInstaller { if (Number.isSafeInteger(row.revision) && row.revision > 0 && ['accepted', 'rejected'].includes(row.startup)) receipts.set(row.id, row) } const sent = JSON.parse(launch.extensions) as Array<{ id: string; revision: number; startup_error?: string }> + this.observeUpdateHealth(statuses) const blocked = new Map(sent.filter(item => item.startup_error).map(item => [item.id, item.revision])) for (const record of launch.records.filter(item => extensionRequiresRestart(item.manifest) && !item.pendingRemoval)) { // Host-preflight rejection is a handled revision, not a loaded receipt. diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 56ea316e..d1a6afd3 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -2,7 +2,7 @@ * Electron main process - spawns Python backend and creates the app window. */ -import { app, BrowserWindow, Menu, WebContentsView, dialog, ipcMain, screen, shell } from 'electron' +import { app, BrowserWindow, Menu, Notification, WebContentsView, dialog, ipcMain, screen, shell } from 'electron' import { spawn, ChildProcess } from 'child_process' import { randomBytes } from 'crypto' import http from 'http' @@ -29,7 +29,7 @@ import { isWallpaperStartup } from './startupMode.js' import { managesWindowsWallpaper, recoverWindowsWallpaperHostExit, WINDOWS_WALLPAPER_STOP_TIMEOUT_MS, stopWallpaperForRenderer, WindowsWallpaperSession, windowsWallpaperDependencies } from './windowsWallpaper.js' import { WindowsWallpaperTray } from './windowsWallpaperTray.js' import { ApplicationLifecycle } from './appLifecycle.js' -import { stopOwnedBackend } from './backendProcessTree.js' +import { markBackendCleanupUnconfirmed, prepareBackendCleanup, resolveBackendProjectRoot, stopOwnedBackend, verifyPreviousBackendCleanup } from './backendProcessTree.js' import { applicationMenuTemplate } from './applicationMenu.js' import { defaultMpsFallbackEnvironment } from './mpsFallbackPolicy.js' import { auipStoragePartition } from './auipStorage.js' @@ -42,6 +42,8 @@ import { EXTENSIONS_ENV } from './desktopSettings.js' import type { ExtensionRemovalOptions, ExtensionUpdate } from '../shared/extensions.js' import { extensionDisclosure } from '../shared/extensions.js' import { ExtensionCatalog } from './extensionCatalog.js' +import { ExtensionUpdateBoundary, localPortIsClosed } from './extensionUpdates.js' +import { usesScheduledExtensionUpdates } from '../shared/extensionCatalog.js' import { ExtensionThemeResources } from './extensionThemes.js' import { developmentServer, isDevelopmentRendererUrl } from './developmentServer.js' @@ -52,6 +54,7 @@ const ELECTRON_ROOT = path.join(__dirname, '..', '..') const USER_DATA_DIR = process.env.AMADEUS_ELECTRON_USER_DATA_DIR ? path.resolve(process.env.AMADEUS_ELECTRON_USER_DATA_DIR) : path.join(PROJECT_ROOT, '.electron-user-data') +const BACKEND_CLEANUP_STATE = path.join(USER_DATA_DIR, 'backend-cleanup.json') const CACHE_DIR = process.env.AMADEUS_ELECTRON_CACHE_DIR ? path.resolve(process.env.AMADEUS_ELECTRON_CACHE_DIR) : path.join(PROJECT_ROOT, '.electron-cache') @@ -65,6 +68,11 @@ const desktopSettings = new DesktopSettingsStore( process.platform, extensionPackageHelper.validateRecord, ) +const scheduledExtensionUpdates = usesScheduledExtensionUpdates(process.platform) +const extensionRecoveryIdle = async () => !pythonProcess && !backendOwned && !backendStarting && !backendStopping + && !applicationLifecycle.isQuitting && await localPortIsClosed(BACKEND_PORT) +const extensionUpdateBoundary = new ExtensionUpdateBoundary(async () => await extensionRecoveryIdle() + && extensionCleanupConfirmed && await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE), extensionRecoveryIdle) 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', @@ -74,12 +82,21 @@ const extensionInstaller = new ExtensionInstaller(path.join(USER_DATA_DIR, 'exte return result.response === 1 }, app.isPackaged ? path.join(process.resourcesPath, 'extension-sdk') : undefined, new ExtensionThemeResources(async () => { - if (!mainWindow || mainWindow.isDestroyed()) throw new Error('System fonts cannot be checked before the main window is available') - 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()) + // Scheduled font themes still use the trusted renderer for native font checks. + // The maintenance boundary keeps backend/Work startup blocked while it loads. + if (!mainWindow || mainWindow.isDestroyed()) createWindow() + const renderer = mainWindow + if (!renderer || renderer.isDestroyed()) throw new Error('System fonts cannot be checked because the trusted renderer is unavailable') + return renderer.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(), scheduledExtensionUpdates ? extensionUpdateBoundary.assertHeld : undefined, + process.platform, scheduledExtensionUpdates ? extensionUpdateBoundary.assertRecoveryHeld : undefined) 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()) + () => String(startupValue('AMADEUS_EXTENSION_ARTIFACT_MIRROR', desktopSettings.snapshot(process.env), '')), () => notifyDesktopThemeChanged(), undefined, + updates => { + if (Notification.isSupported()) new Notification({ title: 'Amadeus plugin updates available', + body: updates.map(update => `${update.id} → ${update.version}`).join('\n') + '\nReview in Manage plugins. Nothing has been installed.' }).show() + }) function notifyDesktopThemeChanged(): void { for (const window of BrowserWindow.getAllWindows()) if (!window.isDestroyed() && isTrustedBackendRenderer(window.webContents)) window.webContents.send('desktop-theme.changed') @@ -242,6 +259,8 @@ let workOverlayIgnoringMouse = false let workOverlayPanelBounds: Electron.Rectangle | null = null let workOverlayHitRegions: Electron.Rectangle[] = [] let pythonProcess: ChildProcess | null = null +const backendCleanupLaunches = new WeakMap() +let backendCleanupUnavailable = false let extensionCleanupConfirmed = true let backendStopping: Promise | null = null const applicationLifecycle = new ApplicationLifecycle() @@ -407,6 +426,8 @@ async function extensionStartupStatus(): Promise { } function startBackend(): Promise { + extensionUpdateBoundary.assertBackendMayStart() + extensionInstaller.assertUpdateCanLaunch() if (backendStarting) return backendStarting const snapshot = desktopSettings.snapshot(process.env) const selection = startupCharacterSelection({ @@ -416,6 +437,7 @@ function startBackend(): Promise { }) startupFailure = null backendStarting = launchBackend().catch(error => { + extensionInstaller.markUpdateFailed() startupFailure = backendStartupFailure(error instanceof BackendStartupExitError ? error.exitCode : null, error instanceof Error ? error.message : String(error), selection) throw error @@ -423,15 +445,36 @@ function startBackend(): Promise { return backendStarting } +async function retainUnknownBackendCleanup(error: unknown): Promise { + // Maintenance evidence must not take down chat/TTS. Keep this session fenced + // even if the damaged/unwritable index cannot persist the unknown marker. + backendCleanupUnavailable = true + extensionCleanupConfirmed = false + console.warn('[electron] backend cleanup evidence unavailable:', error) + desktopSettings.reportExtensionWarning('Backend cleanup tracking is unavailable; the backend can run, but extension installation and file deletion remain blocked.') + try { await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) } + catch (markError) { console.warn('[electron] could not persist unknown backend cleanup:', markError) } +} + async function launchBackend(): Promise { const health = await backendHealthStatus() if (applicationLifecycle.isQuitting) return if (health === 'ready') { + if (scheduledExtensionUpdates) { + try { await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) } + catch (error) { await retainUnknownBackendCleanup(error) } + } + extensionCleanupConfirmed = false backendOwned = true console.log(`[electron] backend already available on ${BACKEND_WS}; reusing it`) return } if (health === 'starting') { + if (scheduledExtensionUpdates) { + try { await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) } + catch (error) { await retainUnknownBackendCleanup(error) } + } + extensionCleanupConfirmed = false backendOwned = true console.log(`[electron] backend is assembling on ${BACKEND_WS}; waiting for readiness`) await waitForBackendReady() @@ -444,8 +487,9 @@ async function launchBackend(): Promise { const python = getPythonCommand() - // No process owns this instance/port. Pending uninstall and orphan cleanup - // happen before a new backend can create Runner children. + // Windows maintenance requires the persisted descendant receipt, not merely + // a closed port. Other platforms retain their existing cleanup contract. + if (scheduledExtensionUpdates) extensionCleanupConfirmed = !backendCleanupUnavailable && await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE) if (extensionCleanupConfirmed) await extensionInstaller.cleanupStoppedBackend() console.log(`[electron] starting backend: ${python} -m server.app --port ${BACKEND_PORT}`) @@ -460,13 +504,25 @@ async function launchBackend(): Promise { } // An inherited string cannot replace Electron's installation authority. delete backendProcessEnvironment[EXTENSIONS_ENV] + delete backendProcessEnvironment.AMADEUS_BACKEND_CLEANUP_LAUNCH backendProcessEnvironment[EXTENSIONS_ENV] = launchSettings.extensions + let cleanupLaunch: { environment: Record } = { environment: {} } + if (scheduledExtensionUpdates) { + try { + cleanupLaunch = await prepareBackendCleanup(BACKEND_CLEANUP_STATE, { + nonce: BACKEND_INSTANCE_NONCE, + projectRoot: await resolveBackendProjectRoot(python, PROJECT_ROOT, backendProcessEnvironment), pythonCommand: python, + }) + } catch (error) { await retainUnknownBackendCleanup(error) } + } + extensionCleanupConfirmed = false pythonProcess = spawn(python, ['-m', 'server.app', '--port', String(BACKEND_PORT)], { cwd: PROJECT_ROOT, env: { ...backendProcessEnvironment, ...bundledDictionaryEnvironment(PROJECT_ROOT, process.resourcesPath, app.isPackaged), ...defaultMpsFallbackEnvironment(process.platform, process.arch, backendProcessEnvironment), + ...cleanupLaunch.environment, PYTHONUNBUFFERED: '1', PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', @@ -479,6 +535,8 @@ async function launchBackend(): Promise { }) const proc = pythonProcess + const cleanupIdentity = cleanupLaunch.environment.AMADEUS_BACKEND_CLEANUP_LAUNCH + if (cleanupIdentity) backendCleanupLaunches.set(proc, cleanupIdentity) proc.stdout?.on('data', (data: Buffer) => { console.log(`[backend] ${data.toString('utf8').trim()}`) }) @@ -506,7 +564,10 @@ async function stopBackend(): Promise { backendStopping = (async () => { extensionCleanupConfirmed = false - extensionCleanupConfirmed = await stopOwnedBackend(proc, () => requestBackendAction('/shutdown', 900)) + extensionCleanupConfirmed = await stopOwnedBackend(proc, () => requestBackendAction('/shutdown', 900), { + verifyCleanup: async () => !backendCleanupUnavailable && await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE), + cleanupLaunch: backendCleanupLaunches.get(proc), + }) if (pythonProcess === proc) pythonProcess = null backendOwned = false })().finally(() => { @@ -2228,7 +2289,13 @@ ipcMain.handle('mcp-connections.remove', (event, connectionId: string) => { }) ipcMain.handle('extensions.catalog', async (event, raw: unknown) => { if (!isTrustedBackendRenderer(event.sender)) return { ok: false, error: 'Untrusted extension requester.' } - return extensionCatalog.dispatch(raw, process.env, url => shell.openExternal(url)) + const action = raw && typeof raw === 'object' && 'action' in raw ? raw.action : undefined + const dispatch = () => extensionCatalog.dispatch(raw, process.env, url => shell.openExternal(url)) + try { + if (action === 'health' && backendOwned) await extensionInstaller.refreshUpdateHealth(await extensionStartupStatus()) + return action === 'install' && scheduledExtensionUpdates ? await extensionUpdateBoundary.run(dispatch) : await dispatch() + } + catch (error) { return { ok: false, error: error instanceof Error ? error.message : 'Plugin update boundary is unavailable' } } }) ipcMain.handle('extensions.icon', (event, id: string) => { if (!isTrustedBackendRenderer(event.sender) || typeof id !== 'string') return null @@ -2239,7 +2306,11 @@ ipcMain.handle('extensions.install', async (event) => { const options: Electron.OpenDialogOptions = { title: 'Install Amadeus extension', properties: ['openFile'], filters: [{ name: 'Python wheel', extensions: ['whl'] }] } const selection = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options) if (selection.canceled || selection.filePaths.length !== 1) return { ok: false, cancelled: true } - try { return { ok: true, settings: await extensionInstaller.install(selection.filePaths[0]) } } + try { + if (!scheduledExtensionUpdates) return { ok: true, settings: await extensionInstaller.install(selection.filePaths[0]) } + await extensionCatalog.scheduleLocal(selection.filePaths[0]) + return { ok: true, scheduled: true } + } catch (error) { return error instanceof ExtensionChangeCancelled ? { ok: false, cancelled: true } : { ok: false, error: error instanceof Error ? error.message : 'Extension installation failed' } } }) ipcMain.handle('extensions.update', async (event, update: ExtensionUpdate) => { @@ -2812,6 +2883,22 @@ function ensureWindowsWallpaperTray(): void { windowsWallpaperTray = new WindowsWallpaperTray(APP_ICON_PATH, showMain, () => app.quit(), () => { void restoreBuiltinTheme() }) } +// before-quit is not emitted for Windows shutdown/logoff. Start best-effort +// cleanup at the query event, without treating a cancelled query as quit intent. +// Durable process evidence and boot retirement handle termination at any point. +app.on('browser-window-created', (_event, window) => { + if (process.platform !== 'win32') return + const endSession = (confirmed: boolean) => { + if (confirmed) extensionCatalog.stopAutomaticChecking() + void applicationLifecycle.handleSessionEnd(confirmed, async () => { + try { await windowsWallpaper?.stop() } + finally { await stopBackend() } + }, error => console.error('[electron] session-end cleanup failed:', error)) + } + window.on('query-session-end', () => endSession(false)) + window.on('session-end', () => endSession(true)) +}) + app.whenReady().then(async () => { if (applicationLifecycle.isQuitting) return // This change owns Windows startup only; retain the existing non-Windows @@ -2821,6 +2908,11 @@ app.whenReady().then(async () => { ensureWindowsWallpaperTray() } let backendStartFailed = false + try { + await extensionInstaller.prepareStartup() + if (extensionCatalog.hasScheduled()) await extensionCatalog.applyScheduled(extensionUpdateBoundary) + } + catch (error) { desktopSettings.reportExtensionWarning(error instanceof Error ? error.message : 'Scheduled plugin installation failed; review Manage plugins.') } try { await startBackend() } catch (error) { @@ -2828,7 +2920,8 @@ app.whenReady().then(async () => { console.error('[electron] backend failed to become ready', error) } if (applicationLifecycle.isQuitting) return - createWindow() + if (!mainWindow || mainWindow.isDestroyed()) createWindow() + extensionCatalog.startAutomaticChecking() if (process.platform === 'win32' && (backendStartFailed || windowsWallpaperTray?.hasIcon === false)) mainWindow?.show() if (applicationLifecycle.completeStartup()) { mainWindow?.show() @@ -2857,6 +2950,7 @@ app.on('window-all-closed', () => { }) app.on('before-quit', (event) => { + extensionCatalog.stopAutomaticChecking() closeElectronSliceWindow() closeWorkOverlayWindow() closeAllWorkPreviewSurfaces() diff --git a/electron/src/renderer/components/ExtensionCatalog.tsx b/electron/src/renderer/components/ExtensionCatalog.tsx index 805ec806..6b387f59 100644 --- a/electron/src/renderer/components/ExtensionCatalog.tsx +++ b/electron/src/renderer/components/ExtensionCatalog.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState } from 'react' import type { CatalogEntry, CatalogView } from '../../shared/extensionCatalog.js' +import { usesScheduledExtensionUpdates } 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 }: { +export default function ExtensionCatalog({ platform, catalog, installed, busy, selection, onSelect, onInstall, onLink }: { + platform?: string catalog: CatalogView; installed: ExtensionSummary[]; busy: boolean selection: { id: string; version: string } | null onSelect: (selection: { id: string; version: string } | null) => void @@ -60,7 +62,7 @@ export default function ExtensionCatalog({ catalog, installed, busy, selection,
{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…')} + 0} onClick={() => onInstall(selected)}>{t(usesScheduledExtensionUpdates(platform) ? 'Schedule this version for next app start…' : 'Review and install this version…')}
: selection ?

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

: null} diff --git a/electron/src/renderer/components/ExtensionSettings.tsx b/electron/src/renderer/components/ExtensionSettings.tsx index 2bf6d828..1fd9f55a 100644 --- a/electron/src/renderer/components/ExtensionSettings.tsx +++ b/electron/src/renderer/components/ExtensionSettings.tsx @@ -1,4 +1,5 @@ -import type { CatalogEntry, CatalogReply, CatalogView, CatalogProgress } from '../../shared/extensionCatalog.js' +import type { CatalogEntry, CatalogReply, CatalogView, CatalogProgress, CatalogRequest } from '../../shared/extensionCatalog.js' +import { usesScheduledExtensionUpdates } from '../../shared/extensionCatalog.js' import ExtensionCatalog from './ExtensionCatalog' import { catalogBytes, recommendedUpdate } from './extensionCatalogModel' import { useEffect, useRef, useState } from 'react' @@ -16,6 +17,7 @@ import { compatibleExtensionProviders, extensionDraft, extensionLanguageSupporte const ATTENTION_STATES = new Set(['error', 'degraded', 'needs_config', 'incompatible']) interface Props { + platform?: string extensions: ExtensionSummary[] runtime: ExtensionRuntimeStatus[] providers: ExtensionProvider[] @@ -32,9 +34,10 @@ interface Props { onEditorStateChange?: (state: SettingsEditorState) => void } -export default function ExtensionSettings({ extensions, runtime, providers, connected, available, encryptionAvailable, selectedVoiceBackend, selectedTheme, appliedThemeVersion, onOpenAppearance, +export default function ExtensionSettings({ platform, extensions, runtime, providers, connected, available, encryptionAvailable, selectedVoiceBackend, selectedTheme, appliedThemeVersion, onOpenAppearance, onSettingsChanged, onRestartRequired, onOpenVoice, onEditorStateChange }: Props) { const { locale, t } = useI18n() + const scheduledUpdates = usesScheduledExtensionUpdates(platform) const [selected, setSelected] = useState(null) const [draft, setDraft] = useState(null) const initial = useRef('') @@ -62,7 +65,10 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn 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 } + const unsubscribe = window.amadeus?.onDesktopThemeChanged?.(() => { + void window.amadeus?.extensionCatalog?.({ action: 'get' }).then(result => { if (!closed) receiveCatalog(result) }).catch(() => {}) + }) + return () => { closed = true; unsubscribe?.() } }, []) useEffect(() => { const api = window.amadeus @@ -114,6 +120,10 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn const complete = (result: ExtensionMutationResult, failure: string) => { if (result.cancelled) return false if (!result.ok) throw new Error(result.error || failure) + if (result.scheduled) { + setNotice('Scheduled for the next app start. Installed packages have not changed. Review the native confirmation before installation.') + return true + } if (result.settings) onSettingsChanged(result.settings) if (result.settings?.restartRequired) onRestartRequired() // This is an operation receipt. Package cards own the changing applied state. @@ -150,16 +160,28 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn 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 }) + const result = await window.amadeus.extensionCatalog({ action: scheduledUpdates ? 'schedule' : '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') + if (result.scheduled) { setNotice('Scheduled for the next app start. Installed packages have not changed. Review the native confirmation before installation.'); return } 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 updateControl = async (request: CatalogRequest) => { + if (!window.amadeus || busy || catalogBusy.current) return + setBusy(true); setError(''); setNotice('') + try { + const result = await window.amadeus.extensionCatalog(request) + receiveCatalog(result) + if (!result.ok) throw new Error(result.error || 'Plugin update settings could not be saved.') + if (result.scheduled) setNotice('Scheduled for the next app start. Installed packages have not changed. Review the native confirmation before installation.') + } catch (reason) { setError(reason instanceof Error ? reason.message : 'Plugin update settings could not be saved.') } + finally { setBusy(false) } + } const cancelCatalog = async () => { if (!window.amadeus) return setProgress(current => current ? { ...current, cancelRequested: true } : current) @@ -267,10 +289,50 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn onKeyDown={event => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { event.preventDefault(); const next = event.key === 'Home' ? 'installed' : event.key === 'End' ? 'browse' : tab === 'installed' ? 'browse' : 'installed'; switchTab(next); document.getElementById(`extension-tab-${next}`)?.focus() } }} onClick={() => switchTab(value)}>{t(value === 'installed' ? 'Installed' : 'Browse')})} -
- 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.')}
-
+ + +
+ +
{t('Check the official catalog daily and notify me; never install automatically.')}
+
+ void updateControl({ action: 'auto-check', enabled })} /> +
+ +
+
{t('Updates and security')}
+
{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.')}
+
+ void checkSecurity(security.status === 'unavailable')}>{t(security.status === 'unavailable' ? 'Recover security state' : 'Check now')} +
+ {catalog.scheduledInstall || catalog.rollbackScheduled || catalog.pendingUpdate ? + {catalog.scheduledInstall || catalog.rollbackScheduled ?
+
+

{t(catalog.rollbackScheduled ? 'Rollback scheduled' : 'Scheduled installation')}

+ {t('Next app start')} +
+ {catalog.scheduledInstall ? <> +
{catalog.scheduledInstall.id} · {catalog.scheduledInstall.version}
+
{t('Review the installation confirmation when you next open Amadeus.')}
+
{t('Package details')}
{t('SHA-256')}: {catalog.scheduledInstall.digest}
+ :
{t('Rollback scheduled for the next app start.')}
} +
void updateControl({ action: 'cancel-scheduled' })}>{t('Cancel scheduled change')}
+
: null} + {catalog.pendingUpdate ?
+
+

{t(catalog.pendingUpdate.recoveryRequired ? 'Recovery required' : 'Update awaiting confirmation')}

+ {!catalog.pendingUpdate.recoveryRequired ? {t(catalog.pendingUpdate.canConfirm ? 'Ready to confirm' : 'Pending verification')} : null} +
+
{catalog.pendingUpdate.id} · {catalog.pendingUpdate.previousVersion} → {catalog.pendingUpdate.version}
+
{t(catalog.pendingUpdate.recoveryRequired ? 'Plugin update is unconfirmed or failed. Roll back before starting the backend.' : 'Complete setup and test this update before keeping it.')}
+
{t('Update details')}

{t('The previous package and encrypted credentials are retained. Check this version before keeping it; Host startup health alone does not verify a remote MCP call.')}

+
+ void updateControl({ action: 'confirm-update' })}>{t('Keep this version')} + void updateControl({ action: 'health' })}>{t('Check status')} + void updateControl({ action: 'rollback' })}>{t('Schedule rollback…')} +
+
: null} +
: null} +
{error || catalog.error ?
{t(error || catalog.error!)}
: null} {notice ?
{t(notice)}
: null} {progress ?
@@ -285,7 +347,12 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn
{t('About plugin installation')} -
{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(scheduledUpdates + ? 'Select a local wheel from an author you trust. The selected bytes are saved for installation at the next app start, with native confirmation.' + : 'Select a local wheel from an author you trust. Review the native confirmation before installation.')}
+
{t(scheduledUpdates + ? 'Catalog and local-wheel installations wait for the next app start, before the backend or any Work/MCP session starts. Local wheels are copied now; installed packages change only after native confirmation.' + : 'Review and install packages now. Backend plugins apply after a backend restart; standalone themes apply immediately. Scheduled updates and retained-version recovery are currently available on Windows only.')}
{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.')}
{!available ?
{t('Extension management is available in the Electron app.')}
: null} @@ -340,7 +407,7 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn
{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} + {update ?
{t('Compatible update available')}: {extension.version} → {update.version} void installCatalog(update)}>{t(scheduledUpdates ? 'Schedule update for next app start…' : 'Review and update…')} showCatalog(update.id, update.version)}>{t('Review update details')}
: null} {pending ?
{t('Saved state')}: {t(savedState)} @@ -424,7 +491,7 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn
} diff --git a/electron/src/renderer/components/SettingsPage.tsx b/electron/src/renderer/components/SettingsPage.tsx index 6285267d..347b1822 100644 --- a/electron/src/renderer/components/SettingsPage.tsx +++ b/electron/src/renderer/components/SettingsPage.tsx @@ -1312,7 +1312,7 @@ export default function SettingsPage({ send, subscribe, connected, reconnectBack {section === 'characters' ? characterWorkspace : null} {section === 'extensions' ? <> {(desktop?.extensionErrors || []).map((detail, index) =>
{t(detail)}
)} - presentation === 'plugins' ? onOpenSettings?.('general') : setSection('general')} diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index 3e1cee17..aa1bd3d4 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -119,6 +119,7 @@ "Automatic": "自动", "Automatic (recommended)": "自动(推荐)", "Automatic approval is on for Work and Cooperative chats, including MCP and shell. Permission limits still apply. It stays on until turned off.": "Work 与协作聊天已开启自动审批,包括 MCP 和 Shell;权限限制仍然有效,将持续生效直到手动关闭。", + "Automatic plugin update checks": "自动检查插件更新", "Automatic selection": "自动选择", "Automatic · No configured model": "自动 · 没有已配置模型", "Automatically approve permitted requests in all Work and Cooperative chats, including MCP and shell. Saved across restarts until you turn it off; approvals remain recorded.": "自动批准所有 Work 与协作聊天中可授权的请求,包括 MCP 和 Shell。跨重启保存,直到手动关闭;审批仍会留痕。", @@ -162,6 +163,7 @@ "Cancel": "取消", "Cancel connection": "取消连接", "Cancel installation": "取消安装", + "Cancel scheduled change": "取消待执行的变更", "Cancellation could not be requested. The operation is still running.": "无法请求取消,操作仍在进行。", "Cancellation requested; waiting for safe cleanup.": "已请求取消,正在等待安全清理。", "Cancelled": "已取消", @@ -174,6 +176,7 @@ "Capture when a planned story beat arrives": "计划剧情节点到达时采集", "Captured text": "取文预览", "Capturing game view…": "正在截取游戏画面…", + "Catalog and local-wheel installations wait for the next app start, before the backend or any Work/MCP session starts. Local wheels are copied now; installed packages change only after native confirmation.": "商店及本地 wheel 安装均等待下次应用启动,在后端及 Work/MCP 会话开始前进行。本地 wheel 现在仅复制暂存;批准原生确认后才更改已安装包。", "Catalog check time is unavailable.": "目录检查时间不可用。", "Catalog checked at": "目录检查时间", "Catalog installation cancelled. No new package was saved.": "目录安装已取消,未保存新插件。", @@ -213,7 +216,9 @@ "Chat window": "聊天窗口", "Chats": "对话", "Check a few lines": "核对几句对白", - "Check updates and security status": "检查更新与安全状态", + "Check now": "立即检查", + "Check status": "检查状态", + "Check the official catalog daily and notify me; never install automatically.": "每天检查官方目录并提示;不自动安装。", "Checking the official catalog…": "正在检查官方目录…", "Checking video support; static playback remains active.": "正在检测视频支持,当前继续静态播放。", "Choice": "选项", @@ -275,6 +280,7 @@ "Compatibility unavailable": "兼容性状态不可用", "Compatible Work Providers": "兼容的 Work Provider", "Compatible update available": "有兼容更新", + "Complete setup and test this update before keeping it.": "请先完成配置并试用更新,再确认保留此版本。", "Completed": "已完成", "Completed Japanese assistant messages are translated for display only.": "完成的日语助手消息只翻译用于显示。", "Compress old history; keep recent frames complete": "压缩旧历史,保持近期帧完整", @@ -553,7 +559,6 @@ "Inspect and update": "检查并更新", "Inspecting model capabilities…": "正在检查模型能力…", "Install a local Python wheel from an author you trust. Extension code runs on this computer. Installation, configuration, enablement and removal apply after a backend restart.": "从可信任的作者安装本地 Python wheel 包。扩展代码会在此计算机上运行。安装、配置、启停和卸载均在后端重启后生效。", - "Install a local wheel from an author you trust. Frontend-only packages apply immediately. Packages with backend contributions apply together after backend restart.": "从可信作者处安装本地 wheel。纯前端包立即生效;包含后端贡献的包在后端重启后整包生效。", "Install local wheel…": "安装本地 wheel…", "Install once. Manage contributions and their application state here.": "安装一次,在这里管理所有贡献及其生效状态。", "Install plugins and manage their settings.": "安装插件并管理设置。", @@ -581,6 +586,7 @@ "Keep enabled": "保持启用", "Keep extension": "保留扩展", "Keep playing. Your companion follows the story as text arrives.": "继续游玩即可,陪玩会随收到的文本跟进剧情。", + "Keep this version": "保留此版本", "Keeping": "正在保留", "Keeps new frames complete; retains 50% of older visual rows at history rebuilds.": "保持新帧完整;重建历史时保留 50% 的较早视觉行。", "Knowledge & memory": "资料与记忆", @@ -699,6 +705,7 @@ "New chat in": "在此新建对话:", "New role": "新建角色", "Next": "下一步", + "Next app start": "下次启动", "Next backend start: resolved from {source} at startup.": "下次后端启动:启动时从 {source} 确定。", "Next backend start: {name}": "下次后端启动:{name}", "Next start": "下次启动", @@ -806,12 +813,14 @@ "PROJECT ARTIFACTS": "项目产物", "PROJECTS": "项目", "Package ID": "插件 ID", + "Package details": "安装包详情", "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.": "暂停发言,继续跟读和记录剧情。", + "Pending verification": "等待验证", "Perception": "感知", "Performance tuning for the embedded GPT-SoVITS engine.": "内置 GPT-SoVITS 引擎的性能调优。", "Permission decision failed": "授权操作失败", @@ -835,6 +844,9 @@ "Plugin runtime": "插件运行时", "Plugin store preview: no packages have been listed yet.": "插件商店预览:暂未收录插件。", "Plugin type": "插件类型", + "Plugin update is unconfirmed or failed. Roll back before starting the backend.": "插件更新尚未确认或已失败;启动后端前请先回滚。", + "Plugin update settings could not be saved.": "无法保存插件更新设置。", + "Plugin updates": "插件更新", "Plugins": "插件", "Portrait art is optional. Without the Companion Lite pack, the window shows a simple avatar and captions.": "头像资源为可选项。未安装 Companion Lite 时,小窗显示简洁头像和字幕。", "Portrait overlay": "头像浮层", @@ -884,6 +896,7 @@ "Read-only view of what the current runtime can provide and whether each configured implementation is ready.": "只读展示当前运行时可提供的能力,以及各实现是否已就绪。", "Reading the separately baked VN companion portrait assets.": "正在读取单独烘焙的 VN 伴侣头像资源。", "Ready": "就绪", + "Ready to confirm": "可以确认", "Ready to connect": "准备连接", "Ready to play": "可以开始游玩", "Realtime echo cancellation is configured and activates only while microphone capture overlaps shared playback.": "实时回声消除已配置,只在麦克风采集与共享播放重叠时启用。", @@ -894,7 +907,8 @@ "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": "恢复官方插件安全状态", + "Recover security state": "恢复安全状态", + "Recovery required": "需要恢复", "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.": "回顾先前的剧情细节。", @@ -956,10 +970,14 @@ "Retiring": "正在停用", "Reuse persistent native sessions": "复用持久化原生会话", "Reuse visual feature cache": "复用视觉特征缓存", + "Review and install packages now. Backend plugins apply after a backend restart; standalone themes apply immediately. Scheduled updates and retained-version recovery are currently available on Windows only.": "确认后即可安装插件。后端插件在重启后端后生效,独立主题立即生效。预约更新及旧版本恢复目前仅适用于 Windows。", + "Review and install this version…": "确认并安装此版本…", + "Review and update…": "确认并更新…", "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 installation confirmation when you next open Amadeus.": "下次打开 Amadeus 时,请确认安装内容。", "Review the native installation confirmation.": "请查看原生安装确认窗口。", "Review update details": "查看更新详情", "Reviewed artifact in the cached catalog": "缓存目录中的已审核产物", @@ -969,6 +987,8 @@ "Role identity": "角色身份", "Role name": "角色名称", "Role saved for the next backend start. The active role remains unchanged.": "角色已保存,将在下次后端启动时生效。当前角色未改变。", + "Rollback scheduled": "已预约回滚", + "Rollback scheduled for the next app start.": "已安排在下次应用启动时回滚。", "Routing & observation": "路由与观察", "Runner is ready": "扩展进程已就绪", "Runner is recovering; failed utterances are not retried": "扩展进程正在恢复,失败的语音请求不会重试", @@ -1016,6 +1036,11 @@ "Scale": "缩放", "Scale is relative to the fitted model. Position is relative to each viewport: −1 to 1.": "缩放相对于适配视口后的模型。位置相对于各自视口,范围为 −1 到 1。", "Scene capability status": "场景能力状态", + "Schedule rollback…": "预约回滚…", + "Schedule this version for next app start…": "下次应用启动时安装此版本…", + "Schedule update for next app start…": "下次应用启动时更新…", + "Scheduled for the next app start. Installed packages have not changed. Review the native confirmation before installation.": "已安排到下次应用启动。尚未更改已安装包;安装前仍需查看并批准原生确认。", + "Scheduled installation": "已预约安装", "Scope": "范围", "Screen & image context": "屏幕与图像上下文", "Screenshot with my questions": "随提问附加截图", @@ -1028,6 +1053,8 @@ "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 local wheel from an author you trust. Review the native confirmation before installation.": "选择可信作者提供的本地 wheel 文件,安装前请查看原生确认窗口。", + "Select a local wheel from an author you trust. The selected bytes are saved for installation at the next app start, with native confirmation.": "选择可信作者的本地 wheel。所选字节将保存到下次应用启动时安装,安装前仍需批准原生确认。", "Select a model and open the preview to test its expressions and mouth movement.": "选择模型并打开预览,即可测试表情和嘴型。", "Select a preset from the left panel.": "请从左侧选择一个预设。", "Select a window…": "请选择窗口…", @@ -1181,6 +1208,7 @@ "The old Off value selected the original Chat runtime. It never prohibited Work execution. Cooperative is now the sole Chat runtime; existing Work permissions still apply.": "旧的关闭值选择了原 Chat 运行时,从未禁止 Work 执行。Cooperative 现在是唯一的 Chat 运行时,现有 Work 权限仍然适用。", "The original operation was not started again. You can repeat the request in Chat.": "原操作没有重新启动。你可以在聊天中再次提出请求。", "The preview uses the selected layout in this viewport; wallpaper cropping is verified in the wallpaper surface.": "预览在当前视口中使用所选布局;壁纸裁剪需在壁纸画面中验证。", + "The previous package and encrypted credentials are retained. Check this version before keeping it; Host startup health alone does not verify a remote MCP call.": "已保留上一版本及加密凭证。请实际检查本版后再保留;Host 启动健康不代表远端 MCP 调用已验证。", "The reflection model is unavailable.": "回顾思考模型当前不可用。", "The retired Off value in .env is ignored. This source is read-only in desktop Settings.": ".env 中已退役的关闭值会被忽略。此来源在桌面设置中为只读。", "The retired Off value in the startup environment is ignored. This source is read-only in desktop Settings.": "启动环境中已退役的关闭值会被忽略。此来源在桌面设置中为只读。", @@ -1253,7 +1281,10 @@ "Untitled chat": "未命名对话", "Untitled project": "未命名项目", "Unverified": "未验证", + "Update awaiting confirmation": "更新待确认", + "Update details": "更新详情", "Update plugin…": "更新插件…", + "Updates and security": "更新与安全", "Updates every 3s": "每 3 秒更新", "Updates sentence splitting, cache keys, and embedded reference selection": "更新分句、缓存键和内置参考音频选择", "Updating": "正在更新", diff --git a/electron/src/renderer/styles/index.css b/electron/src/renderer/styles/index.css index a39daa97..fa23f9da 100644 --- a/electron/src/renderer/styles/index.css +++ b/electron/src/renderer/styles/index.css @@ -2611,7 +2611,11 @@ 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-catalog-toolbar, .extension-download-progress { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.extension-update-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; } +.extension-update-status { display: flex; flex-direction: column; gap: 8px; min-width: 0; } +.extension-update-status + .extension-update-status { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--divider); } +.extension-update-status .settings-form-actions-end { justify-content: flex-start; } +.extension-download-progress { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } .extension-download-progress { padding: 12px; border: 1px solid var(--card-border); border-radius: var(--radius-container); } .extension-download-progress strong, .extension-download-progress > div { flex-basis: 100%; overflow-wrap: anywhere; } .extension-download-progress progress { flex: 1; min-width: 100px; accent-color: var(--text); } diff --git a/electron/src/shared/extensionCatalog.ts b/electron/src/shared/extensionCatalog.ts index de94d46e..9f4356cd 100644 --- a/electron/src/shared/extensionCatalog.ts +++ b/electron/src/shared/extensionCatalog.ts @@ -1,3 +1,9 @@ +// Persistent descendant cleanup proof currently exists only on Windows. +// Other platforms retain version-isolated installation and restart-to-apply. +export function usesScheduledExtensionUpdates(platform: string | undefined): boolean { + return platform === 'win32' +} + export type CatalogEntry = { id: string; version: string; sha256: string; url: string; size: number display: Record; compatibility: Record @@ -10,8 +16,12 @@ export type CatalogIndex = { security: Array<{ id: string; version: string; sha256: string; status: 'blocked' | 'allowed'; sequence: number; reason: string; evidence: string }> } export type CatalogRequest = { action: 'get' | 'status' | 'refresh' | 'recover' | 'cancel' } - | { action: 'install'; id: string; version: string } + | { action: 'install' | 'schedule'; id: string; version: string } + | { action: 'auto-check'; enabled: boolean } + | { action: 'cancel-scheduled' | 'rollback' | 'confirm-update' | 'health' } | { 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 CatalogView = { autoCheck?: boolean; scheduledInstall?: { id: string; version: string; digest: string }; rollbackScheduled?: boolean; + pendingUpdate?: { id: string; version: string; previousVersion: string; canConfirm: boolean; recoveryRequired: boolean }; + 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 } +export type CatalogReply = { ok: boolean; error?: string; cancelled?: boolean; scheduled?: boolean; catalog?: CatalogView; settings?: Record; progress?: CatalogProgress } diff --git a/electron/src/shared/extensions.ts b/electron/src/shared/extensions.ts index 0c875def..f9c7bff5 100644 --- a/electron/src/shared/extensions.ts +++ b/electron/src/shared/extensions.ts @@ -121,6 +121,7 @@ export function extensionDisclosure(manifest: ExtensionManifest, previous?: Exte } export type ExtensionMutationResult = { + scheduled?: boolean ok: boolean cancelled?: boolean error?: string diff --git a/electron/tests/appLifecycle.test.mjs b/electron/tests/appLifecycle.test.mjs index 78c812d3..f69cb68b 100644 --- a/electron/tests/appLifecycle.test.mjs +++ b/electron/tests/appLifecycle.test.mjs @@ -54,3 +54,29 @@ for (const fail of [false, true]) { assert.deepEqual(calls, fail ? ['cleanup', 'reported', 'quit'] : ['cleanup', 'quit']) }) } + +test('a cancelled Windows shutdown query leaves the app usable; confirmed session end commits quit intent', async () => { + const lifecycle = new ApplicationLifecycle() + let release, calls = 0 + const cleanup = () => { calls++; return new Promise(resolve => { release = resolve }) } + const query = lifecycle.handleSessionEnd(false, cleanup, assert.fail) + await Promise.resolve() + assert.equal(lifecycle.isQuitting, false) + assert.equal(lifecycle.shouldHideWallpaperWindow(true), true) + const ended = lifecycle.handleSessionEnd(true, cleanup, assert.fail) + assert.equal(lifecycle.isQuitting, true) + assert.equal(lifecycle.shouldHideWallpaperWindow(true), false) + assert.equal(calls, 1) + release() + await Promise.all([query, ended]) +}) + +test('session-end cleanup failure is observable without creating permanent quit intent for a cancelled query', async () => { + const lifecycle = new ApplicationLifecycle(), errors = [] + await lifecycle.handleSessionEnd(false, async () => { throw Error('shutdown failed') }, error => errors.push(error.message)) + assert.deepEqual(errors, ['shutdown failed']) + assert.equal(lifecycle.isQuitting, false) + let retried = false + await lifecycle.handleSessionEnd(false, async () => { retried = true }, assert.fail) + assert.equal(retried, true) +}) diff --git a/electron/tests/backendLaunch.test.mjs b/electron/tests/backendLaunch.test.mjs new file mode 100644 index 00000000..c1915a03 --- /dev/null +++ b/electron/tests/backendLaunch.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import {EventEmitter} from 'node:events' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import ts from 'typescript' +import {loadTypeScript} from './helpers/loadTypeScript.mjs' + +const cleanup = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) +const source = fs.readFileSync(new URL('../src/main/index.ts', import.meta.url), 'utf8') +const ast = ts.createSourceFile('index.ts', source, ts.ScriptTarget.ES2022, true) +const functions = ['retainUnknownBackendCleanup', 'launchBackend', 'stopBackend'].map(name => + ast.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === name).getText(ast)).join('\n') +const compiled = ts.transpileModule(functions, {compilerOptions: {target: ts.ScriptTarget.ES2022}}).outputText + +function fixture(t, overrides = {}) { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-backend-launch-'))) + t.after(() => fs.rmSync(root, {recursive: true, force: true})) + const file = path.join(root, 'backend-cleanup.json') + const calls = {spawn: [], warnings: [], applied: 0, removed: 0, ready: 0} + const deps = { + ...cleanup, + // These fixtures exercise Windows maintenance on every CI host. The mocked + // index.ts process does not change the real cleanup module's host platform. + prepareBackendCleanup: (statePath, launch) => cleanup.prepareBackendCleanup(statePath, launch, 'win32'), + BACKEND_CLEANUP_STATE: file, BACKEND_INSTANCE_NONCE: 'synthetic-launch-nonce', + PROJECT_ROOT: root, BACKEND_WS: 'ws://fixture', BACKEND_PORT: 12345, BACKEND_TOKEN: 'synthetic-token', + scheduledExtensionUpdates: true, EXTENSIONS_ENV: 'AMADEUS_EXTENSION_LAUNCH', + applicationLifecycle: {isQuitting: false}, app: {isPackaged: false}, devServer: null, + backendHealthStatus: async () => 'absent', getPythonCommand: () => 'python', + resolveBackendProjectRoot: async () => root, + bundledDictionaryEnvironment: () => ({}), defaultMpsFallbackEnvironment: () => ({}), + extensionStartupStatus: async () => ({}), requestBackendAction: async () => false, + extensionInstaller: { + cleanupStoppedBackend: async () => {calls.removed++}, + prepareLaunch: async () => ({pendingRevisions: {}, environment: {}, extensions: '{}'}), + acceptLaunch: async () => true, + }, + desktopSettings: {markApplied: () => {calls.applied++}, reportExtensionWarning: message => calls.warnings.push(message)}, + console: {log() {}, warn() {}, error() {}}, + process: {platform: 'win32', env: {AMADEUS_BACKEND_CLEANUP_LAUNCH: 'inherited-untrusted-value'}}, + spawn: (command, args, options) => { + calls.spawn.push({command, args, options}) + return Object.assign(new EventEmitter(), {pid: 12345, exitCode: null, signalCode: null}) + }, + waitForBackendReady: async () => {calls.ready++}, + ...overrides, + } + const api = new Function(...Object.keys(deps), ` + let pythonProcess = null, backendOwned = false, backendStopping = null, windowsWallpaper = null; + let extensionCleanupConfirmed = true, backendCleanupUnavailable = false; + const backendCleanupLaunches = new WeakMap(); + ${compiled} + return {launchBackend, stopBackend, state: () => ({extensionCleanupConfirmed, backendCleanupUnavailable, backendOwned})}; + `)(...Object.values(deps)) + return {root, file, calls, deps, ...api} +} + +for (const damage of ['zero-filled', 'missing-index', 'registration-error', 'root-probe-error']) { + test(`${damage} does not block core startup or grant cleanup authority`, async t => { + const f = fixture(t, { + ...(damage === 'registration-error' ? {prepareBackendCleanup: async () => {throw Error('write failed')}} : {}), + ...(damage === 'root-probe-error' ? {resolveBackendProjectRoot: async () => {throw Error('probe timed out')}} : {}), + stopOwnedBackend: async (_proc, _request, options) => { + assert.equal(await options.verifyCleanup(), false) + assert.equal(options.cleanupLaunch, undefined) + return false + }, + }) + if (damage === 'zero-filled') fs.writeFileSync(f.file, Buffer.alloc(128)) + if (damage === 'missing-index') fs.mkdirSync(`${f.file}.receipts`) + await f.launchBackend() + assert.equal(f.calls.spawn.length, 1) + assert.equal(f.calls.applied, 1) + assert.equal(f.calls.ready, 1) + assert.equal(f.calls.spawn[0].options.env.AMADEUS_BACKEND_CLEANUP_LAUNCH, undefined) + assert.equal(f.calls.spawn[0].options.env.AMADEUS_BACKEND_AUTH_MODE, 'required') + assert.ok(f.calls.warnings.some(message => /tracking is unavailable/.test(message))) + assert.equal(f.state().extensionCleanupConfirmed, false) + assert.equal(f.state().backendCleanupUnavailable, true) + assert.equal(await cleanup.verifyPreviousBackendCleanup(f.file), false, 'a fresh verifier must still fence maintenance') + if (damage === 'zero-filled') assert.deepEqual(fs.readFileSync(f.file), Buffer.alloc(128), 'do not erase damaged evidence') + await f.stopBackend() + const removed = f.calls.removed + await f.launchBackend() + assert.equal(f.calls.removed, removed, 'a retry must not collect files after untracked launch') + }) +} + +for (const health of ['ready', 'starting']) test(`ledger failure cannot reject a ${health} authenticated backend`, async t => { + const f = fixture(t, {backendHealthStatus: async () => health}) + fs.writeFileSync(f.file, Buffer.alloc(128)) + await f.launchBackend() + assert.equal(f.calls.spawn.length, 0) + assert.equal(f.calls.ready, health === 'starting' ? 1 : 0) + assert.equal(f.state().backendOwned, true) + assert.equal(f.state().extensionCleanupConfirmed, false) + assert.equal(await cleanup.verifyPreviousBackendCleanup(f.file), false) +}) + +test('a foreign backend is still rejected before cleanup fallback', async t => { + const f = fixture(t, {backendHealthStatus: async () => 'foreign'}) + await assert.rejects(f.launchBackend(), /another backend/) + assert.equal(f.calls.spawn.length, 0) + assert.equal(f.calls.warnings.length, 0) +}) + +test('successful registration is bound to the same process used for forced shutdown', async t => { + let stopped = false + const f = fixture(t, {stopOwnedBackend: async (proc, _request, options) => { + assert.equal(proc.pid, 12345) + assert.equal(options.cleanupLaunch, f.calls.spawn[0].options.env.AMADEUS_BACKEND_CLEANUP_LAUNCH) + assert.equal(await options.verifyCleanup(), false) + stopped = true + return false + }}) + await f.launchBackend() + const launch = JSON.parse(f.calls.spawn[0].options.env.AMADEUS_BACKEND_CLEANUP_LAUNCH) + assert.equal(launch.owner.nonce, 'synthetic-launch-nonce') + assert.equal(f.state().backendCleanupUnavailable, false) + await f.stopBackend() + assert.equal(stopped, true) +}) + +for (const platform of ['linux', 'darwin']) test(`${platform} startup does not probe or register Windows cleanup`, async t => { + const f = fixture(t, {scheduledExtensionUpdates: false, + resolveBackendProjectRoot: async () => assert.fail('Windows root probe'), + prepareBackendCleanup: async () => assert.fail('Windows registration'), + }) + await f.launchBackend() + assert.equal(f.calls.applied, 1) + assert.equal(f.calls.warnings.length, 0) + assert.equal(fs.existsSync(f.file), false) +}) diff --git a/electron/tests/backendProcessTree.test.mjs b/electron/tests/backendProcessTree.test.mjs index 57d2e39c..41220b25 100644 --- a/electron/tests/backendProcessTree.test.mjs +++ b/electron/tests/backendProcessTree.test.mjs @@ -1,10 +1,44 @@ import assert from 'node:assert/strict' -import { EventEmitter } from 'node:events' +import { EventEmitter, once } from 'node:events' +import fs from 'node:fs' +import os from 'node:os' +import { spawn, spawnSync } from 'node:child_process' import path from 'node:path' import test from 'node:test' import { loadTypeScript } from './helpers/loadTypeScript.mjs' -const { forceStopBackendTree, stopOwnedBackend, waitForProcessExit } = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) +const { forceStopBackendTree, stopOwnedBackend, waitForProcessExit, prepareBackendCleanup: preparePlatformBackendCleanup, + verifyPreviousBackendCleanup: verifyCleanup, markBackendCleanupUnconfirmed: markUnconfirmed, resolveBackendProjectRoot, + readWindowsBootId } = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) + +// Receipt protocol fixtures exercise Windows policy even when Node is running +// elsewhere; the POSIX routing cases explicitly supply their own platform. +const fixtureBoot = async () => 10 +const prepareBackendCleanup = (file, launch, platform = 'win32', boot = fixtureBoot) => preparePlatformBackendCleanup(file, launch, platform, boot) +const verifyPreviousBackendCleanup = (file, options = {}) => verifyCleanup(file, {getBootId: fixtureBoot, ...options}) +const markBackendCleanupUnconfirmed = (file, options = {}) => markUnconfirmed(file, {getBootId: fixtureBoot, ...options}) + +test('packaged cleanup resolves the supplied Python module root with the actual launch environment', async () => { + const resources = path.resolve('packaged/resources'), backend = path.resolve('external/backend') + const environment = { PYTHONPATH: backend } + const root = await resolveBackendProjectRoot('supplied-python', resources, environment, (python, args, options, done) => { + assert.equal(python, 'supplied-python') + assert.equal(args[0], '-c') + assert.ok(args[1].includes("find_spec('server')")) + assert.equal(options.cwd, resources) + assert.equal(options.env, environment) + assert.equal(options.windowsHide, true) + done(null, JSON.stringify(backend), '') + }) + assert.equal(root, backend) +}) + +test('an unresolved backend root never falls back to the frontend resource directory', async () => { + for (const output of ['null', '"relative"', 'not JSON']) { + await assert.rejects(resolveBackendProjectRoot('python', process.cwd(), {}, (_python, _args, _options, done) => done(null, output, ''))) + } + await assert.rejects(resolveBackendProjectRoot('python', process.cwd(), {}, (_python, _args, _options, done) => done(Error('missing backend'))), /missing backend/) +}) test('forced Windows backend stop targets only its owned PID and whole descendant tree without Host secrets', async () => { const invocations = [] @@ -33,6 +67,30 @@ test('failed process-tree shutdown is observable and cannot authorize uninstall await assert.rejects(forceStopBackendTree({ pid: 0 }, 'win32', spawn), /identity/) }) +test('an unavailable registered Job falls back only to the owned process tree', async () => { + const {forceStopBackendTree: force} = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url), { + 'node:child_process': {execFile: (_python, args, options, done) => { + assert.equal(args[3], '--terminate') + assert.deepEqual(Object.keys(options.env).sort(), ['SystemRoot', 'WINDIR']) + queueMicrotask(() => done(Error('Job unavailable'))) + }}, + }) + let fallback = 0 + const create = (command, args) => { + fallback++ + assert.equal(path.basename(command), 'taskkill.exe') + assert.deepEqual(args, ['/PID', '12345', '/T', '/F']) + const child = Object.assign(new EventEmitter(), {kill() {}}) + queueMicrotask(() => child.emit('close', 0)) + return child + } + await force({pid: 12345, exitCode: null, signalCode: null}, 'win32', create, JSON.stringify({ + schema: 'amadeus.backend-cleanup.v1', owner: {id: 'a'.repeat(32), nonce: 'synthetic-launch-nonce', + project_root: path.resolve('..'), python_command: 'python'}, + })) + assert.equal(fallback, 1) +}) + function processFixture() { const proc = new EventEmitter() Object.assign(proc, { pid: 12345, exitCode: null, signalCode: null }) @@ -40,20 +98,43 @@ function processFixture() { return proc } -test('graceful owned shutdown confirms lifetime without forced termination', async () => { +for (const platform of ['linux', 'darwin']) { + test(`${platform} preserves graceful root shutdown without claiming Windows cleanup proof`, async t => { + const file = cleanupFixture(t) + assert.deepEqual(await prepareBackendCleanup(file, {nonce: 'synthetic-owner-nonce', projectRoot: path.dirname(file), pythonCommand: 'python'}, platform), {environment: {}}) + assert.equal(fs.existsSync(file), false) + const proc = processFixture() + assert.equal(await stopOwnedBackend(proc, async () => true, {platform, + waitForExit: async (_proc, timeout) => {assert.equal(timeout, 2500); proc.exit(); return true}, + verifyCleanup: async () => {assert.fail('POSIX must not claim a Windows receipt')}, + }), true) + const forced = processFixture() + assert.equal(await stopOwnedBackend(forced, async () => false, {platform, + forceStop: async () => {forced.exit()}, waitForExit: async () => true, + }), false) + }) +} + +test('graceful owned shutdown requires independent process-tree exit evidence', async () => { const proc = processFixture() - assert.equal(await stopOwnedBackend(proc, async () => true, { - waitForExit: async () => { proc.exit(); return true }, + assert.equal(await stopOwnedBackend(proc, async () => true, { platform: 'win32', + waitForExit: async (_proc, timeout) => { assert.equal(timeout, 15000); proc.exit(); return true }, forceStop: async () => { assert.fail('graceful stop must not force the tree') }, + verifyCleanup: async () => true, }), true) + const noReceipt = processFixture() + assert.equal(await stopOwnedBackend(noReceipt, async () => true, { platform: 'win32', + waitForExit: async () => { noReceipt.exit(); return true }, + }), false) }) -test('forced Windows shutdown confirms children only after backend exit', async () => { +test('forced Windows shutdown stops the root without authorizing package replacement', async () => { const proc = processFixture() assert.equal(await stopOwnedBackend(proc, async () => false, { platform: 'win32', forceStop: async owned => { assert.equal(owned, proc); proc.exit() }, waitForExit: async () => true, - }), true) + verifyCleanup: async () => { assert.fail('forced exit cannot use a cleanup receipt') }, + }), false) }) test('an exit racing failed force-stop reports uncertain children instead of a false running backend', async () => { @@ -77,7 +158,7 @@ test('a still-running backend fails explicitly and can be stopped on retry', asy }), /shutdown did not complete/) assert.equal(await stopOwnedBackend(proc, async () => false, { platform: 'win32', forceStop: async () => proc.exit(), waitForExit: async () => true, - }), true) + }), false) }) test('non-Windows forced root exit does not claim descendant lifetime', async () => { @@ -96,3 +177,426 @@ test('exit observation releases its listener on success and timeout', async () = assert.equal(await result, true) assert.equal(proc.listenerCount('exit'), 0) }) + +function cleanupFixture(t) { + // macOS exposes its temporary directory through /var -> /private/var. + // Exercise cleanup's canonical-path contract without weakening its link guard. + const directory = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-cleanup-'))) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + return path.join(directory, 'backend-cleanup.json') +} + +function writeReceipt(file, environment, changes = {}) { + const launch = JSON.parse(environment.AMADEUS_BACKEND_CLEANUP_LAUNCH) + const receipt = { schema: launch.schema, owner: launch.owner, state: 'settled', + process: { pid: 12345, created: '133000000000000000' }, + observer: { pid: 23456, created: '133000000000000001' }, + job_name: `Global\\AmadeusBackendCleanup-${launch.owner.id}`, + proof: 'windows-job-drained', providers_settled: true, work_settled: true, ...changes } + fs.writeFileSync(path.join(`${file}.receipts`, `${launch.owner.id}.json`), JSON.stringify(receipt)) + return receipt +} + +const launchIdentity = { nonce: 'synthetic-launch-nonce', projectRoot: path.resolve('..'), pythonCommand: 'python' } +const goneAndDrained = { isProcessIdentityGone: async () => true } + +test('new-protocol fresh state differs from a persisted unknown reused backend', async t => { + const file = cleanupFixture(t) + assert.equal(await verifyPreviousBackendCleanup(file), true) + await markBackendCleanupUnconfirmed(file) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) +}) + +test('cleanup refuses linked state paths without changing the target index', async t => { + const file = cleanupFixture(t) + await markBackendCleanupUnconfirmed(file) + const before = fs.readFileSync(file, 'utf8') + const link = path.join(path.dirname(file), 'linked') + fs.symlinkSync(path.dirname(file), link, process.platform === 'win32' ? 'junction' : 'dir') + const linkedFile = path.join(link, path.basename(file)) + try { + await assert.rejects(prepareBackendCleanup(linkedFile, launchIdentity), /cannot traverse a link/) + await assert.rejects(markBackendCleanupUnconfirmed(linkedFile), /cannot traverse a link/) + assert.equal(await verifyPreviousBackendCleanup(linkedFile, goneAndDrained), false) + assert.equal(fs.readFileSync(file, 'utf8'), before) + } finally { fs.unlinkSync(link) } +}) + +test('cleanup refuses a linked receipt directory within an ordinary data root', async t => { + const file = cleanupFixture(t) + const target = path.join(path.dirname(file), 'outside-receipts') + fs.mkdirSync(target) + const link = `${file}.receipts` + fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + try { + await assert.rejects(prepareBackendCleanup(file, launchIdentity)) + assert.equal(await verifyPreviousBackendCleanup(file), false) + assert.deepEqual(fs.readdirSync(target), []) + } finally { fs.unlinkSync(link) } +}) + +test('a failed durable Windows replacement preserves the original index', { skip: process.platform !== 'win32' }, async t => { + const file = cleanupFixture(t) + fs.writeFileSync(file, JSON.stringify({schema: 'amadeus.backend-cleanup.v1', bootId: 10, unknown: false, owners: []})) + const before = fs.readFileSync(file, 'utf8') + // A read-only destination permits the index read but denies MoveFileExW. + fs.chmodSync(file, 0o444) + try { + await assert.rejects(prepareBackendCleanup(file, launchIdentity), error => error.code === 3) + assert.equal(fs.readFileSync(file, 'utf8'), before) + assert.equal(fs.existsSync(`${file}.receipts`), false) + } finally { fs.chmodSync(file, 0o600) } + const fresh = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) + assert.equal(await fresh.verifyPreviousBackendCleanup(file, {getBootId: fixtureBoot}), false, + 'an interrupted registration must fence a later desktop even when the old empty index is readable') +}) + +test('a later clean launch cannot erase unresolved containment from the same boot', async t => { + const file = cleanupFixture(t) + await prepareBackendCleanup(file, launchIdentity) + const second = await prepareBackendCleanup(file, launchIdentity) + writeReceipt(file, second.environment) + assert.equal(JSON.parse(fs.readFileSync(file)).owners.length, 2) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) +}) + +test('a proven later Windows boot retires unresolved owners and unknown state without modifying archived evidence', async t => { + const file = cleanupFixture(t) + const launch = await prepareBackendCleanup(file, launchIdentity) + const old = JSON.parse(launch.environment.AMADEUS_BACKEND_CLEANUP_LAUNCH).owner + const diagnostic = path.join(`${file}.receipts`, `${old.id}.failed.json`) + fs.writeFileSync(diagnostic, 'retained failed-teardown evidence') + await markBackendCleanupUnconfirmed(file) + const bytes = fs.readFileSync(file, 'utf8') + for (const getBootId of [fixtureBoot, async () => undefined, async () => 9, async () => { throw Error('unavailable') }]) { + assert.equal(await verifyPreviousBackendCleanup(file, {getBootId}), false) + assert.equal(fs.readFileSync(file, 'utf8'), bytes) + } + assert.equal(await verifyPreviousBackendCleanup(file, {getBootId: async () => 11, + isProcessIdentityGone: async () => assert.fail('previous-boot PIDs are not current owners'), + }), true) + assert.deepEqual(JSON.parse(fs.readFileSync(file)), {schema: 'amadeus.backend-cleanup.v1', bootId: 11, unknown: false, owners: []}) + assert.equal(fs.readFileSync(diagnostic, 'utf8'), 'retained failed-teardown evidence') + await prepareBackendCleanup(file, launchIdentity, 'win32', async () => 11) + assert.equal(await verifyPreviousBackendCleanup(file, {getBootId: async () => 11}), false, 'new-boot registration still needs its own exit proof') +}) + +test('legacy unresolved owners acquire a boot binding without being silently cleared', async t => { + const file = cleanupFixture(t) + await prepareBackendCleanup(file, launchIdentity, 'win32', async () => undefined) + assert.equal(JSON.parse(fs.readFileSync(file)).bootId, undefined) + assert.equal(await verifyPreviousBackendCleanup(file), false) + assert.equal(JSON.parse(fs.readFileSync(file)).bootId, 10) + assert.equal(JSON.parse(fs.readFileSync(file)).owners.length, 1) + assert.equal(await verifyPreviousBackendCleanup(file), false) + assert.equal(await verifyPreviousBackendCleanup(file, {getBootId: async () => 11}), true) +}) + +test('physical exit evidence is independent of unsettled Work outcomes but still requires exact process identities', async t => { + const file = cleanupFixture(t) + const {environment} = await prepareBackendCleanup(file, launchIdentity) + writeReceipt(file, environment, {state: 'exited', providers_settled: false, work_settled: false}) + assert.equal(await verifyPreviousBackendCleanup(file, {isProcessIdentityGone: async () => false}), false) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), true) +}) + +test('Windows boot reader rejects unavailable or malformed registry evidence and caches only a valid DWORD', {skip: process.platform !== 'win32'}, async t => { + const module = new URL('../src/main/backendProcessTree.ts', import.meta.url) + const replies = [ + {error: Error('registry key unavailable')}, + {error: Object.assign(Error('query timed out'), {killed: true})}, + {output: ''}, + {output: ' BootId REG_SZ 0x20\r\n'}, + {output: ' BootId REG_DWORD 0x100000000\r\n'}, + {output: ' BootId REG_DWORD 0x20\r\n BootId REG_DWORD 0x21\r\n'}, + ] + for (const reply of replies) { + let calls = 0 + const {readWindowsBootId: read} = loadTypeScript(module, {'node:child_process': {execFile: (command, args, options, done) => { + calls++ + assert.equal(path.basename(command).toLowerCase(), 'reg.exe') + assert.deepEqual(args.slice(-3), ['/v', 'BootId', '/reg:64']) + assert.equal(options.windowsHide, true) + queueMicrotask(() => done(reply.error || null, reply.output || '')) + }}}) + assert.equal(await read(), undefined) + const file = cleanupFixture(t) + await markBackendCleanupUnconfirmed(file) + const before = fs.readFileSync(file) + assert.equal(await verifyPreviousBackendCleanup(file, {getBootId: read}), false) + assert.deepEqual(fs.readFileSync(file), before) + assert.equal(calls, 2, 'unavailable evidence must not be cached or grant retirement') + } + for (const [hex, expected] of [['0x0', 0], ['0x2a', 42], ['0xffffffff', 0xffffffff]]) { + let calls = 0 + const {readWindowsBootId: read} = loadTypeScript(module, {'node:child_process': {execFile: (_command, _args, _options, done) => { + calls++ + queueMicrotask(() => done(null, `HKEY_LOCAL_MACHINE\\fixture\r\n BootId REG_DWORD ${hex}\r\n`)) + }}}) + assert.equal(await read(), expected) + assert.equal(await read(), expected) + assert.equal(calls, 1) + } +}) + +test('native Windows boot observation supplies stable evidence or leaves unknown ownership fenced', {skip: process.platform !== 'win32'}, async t => { + const first = await readWindowsBootId() + if (first !== undefined) { + assert.ok(Number.isSafeInteger(first) && first >= 0 && first <= 0xffffffff) + assert.equal(await readWindowsBootId(), first) + } else { + t.diagnostic('OS boot counter is unavailable; verifying conservative ownership retention') + } + const file = cleanupFixture(t) + await markUnconfirmed(file, {getBootId: readWindowsBootId}) + assert.equal(await verifyCleanup(file, {getBootId: readWindowsBootId}), false) + const retained = JSON.parse(fs.readFileSync(file)) + assert.equal(retained.unknown, true) + assert.deepEqual(retained.owners, []) +}) + +test('clean receipt must bind nonce, launch and PID creation, and a fully drained Job', async t => { + const file = cleanupFixture(t) + const { environment } = await prepareBackendCleanup(file, launchIdentity) + const good = writeReceipt(file, environment) + assert.equal(await verifyPreviousBackendCleanup(file, { ...goneAndDrained, isProcessIdentityGone: async () => false }), false) + assert.equal(await verifyPreviousBackendCleanup(file, { isProcessIdentityGone: async identity => identity.pid !== 23456 }), false) + assert.equal(await verifyPreviousBackendCleanup(file, { isProcessIdentityGone: async () => { throw Error('access denied') } }), false) + for (const changes of [ + { owner: { ...good.owner, nonce: 'other-instance-nonce' } }, + { owner: { ...good.owner, python_command: 'other-python' } }, + { process: { pid: 12345 } }, { observer: { pid: 23456 } }, { work_settled: false }, { providers_settled: false }, + { job_name: 'other-job' }, { state: 'unknown' }, + ]) { + writeReceipt(file, environment, changes) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) + } + writeReceipt(file, environment) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), true) +}) + +test('verified history is compacted once while receipt archives stay unchanged', async t => { + const file = cleanupFixture(t) + const archived = [] + for (let attempt = 0; attempt < 3; attempt++) { + const { environment } = await prepareBackendCleanup(file, launchIdentity) + const receipt = writeReceipt(file, environment) + const receiptPath = path.join(`${file}.receipts`, `${receipt.owner.id}.json`) + archived.push([receiptPath, fs.readFileSync(receiptPath, 'utf8')]) + } + let probes = 0 + const options = { isProcessIdentityGone: async () => { probes++; return true } } + assert.equal(await verifyPreviousBackendCleanup(file, options), true) + assert.equal(probes, 6) + assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')).owners, []) + assert.equal(await verifyPreviousBackendCleanup(file, options), true) + assert.equal(probes, 6, 'a repeated shutdown scan must not relaunch historical PID probes') + for (const [receiptPath, bytes] of archived) assert.equal(fs.readFileSync(receiptPath, 'utf8'), bytes) +}) + +function deferred() { + let resolve + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +test('compaction cannot overwrite a new owner queued during identity verification', async t => { + const file = cleanupFixture(t) + const first = await prepareBackendCleanup(file, launchIdentity) + writeReceipt(file, first.environment) + const entered = deferred(), release = deferred() + const checking = verifyPreviousBackendCleanup(file, { isProcessIdentityGone: async () => { + entered.resolve(); await release.promise; return true + } }) + await entered.promise + const adding = prepareBackendCleanup(file, launchIdentity) + release.resolve() + assert.equal(await checking, true) + const second = JSON.parse((await adding).environment.AMADEUS_BACKEND_CLEANUP_LAUNCH) + assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')).owners, [second.owner]) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false, 'the new owner has no cleanup receipt') +}) + +test('compaction cannot erase an unknown fence queued during identity verification', async t => { + const file = cleanupFixture(t) + const { environment } = await prepareBackendCleanup(file, launchIdentity) + writeReceipt(file, environment) + const entered = deferred(), release = deferred() + const checking = verifyPreviousBackendCleanup(file, { isProcessIdentityGone: async () => { + entered.resolve(); await release.promise; return true + } }) + await entered.promise + const marking = markBackendCleanupUnconfirmed(file) + release.resolve() + assert.equal(await checking, true) + await marking + assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { + schema: 'amadeus.backend-cleanup.v1', bootId: 10, unknown: true, owners: [], + }) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) +}) + +test('concurrent registrations and unknown marking retain every new owner and fence', async t => { + const file = cleanupFixture(t) + const [first, , second] = await Promise.all([ + prepareBackendCleanup(file, launchIdentity), markBackendCleanupUnconfirmed(file), + prepareBackendCleanup(file, launchIdentity), + ]) + const owners = [first, second].map(result => JSON.parse(result.environment.AMADEUS_BACKEND_CLEANUP_LAUNCH).owner) + const index = JSON.parse(fs.readFileSync(file, 'utf8')) + assert.equal(index.unknown, true) + assert.deepEqual(index.owners, owners) + for (const owner of owners) assert.equal(fs.existsSync(path.join(`${file}.receipts`, `${owner.id}.json`)), false) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) +}) + +test('missing or corrupt proof leaves all live index bytes unchanged', async t => { + const file = cleanupFixture(t) + const first = await prepareBackendCleanup(file, launchIdentity) + const second = await prepareBackendCleanup(file, launchIdentity) + writeReceipt(file, first.environment) + const before = fs.readFileSync(file, 'utf8') + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) + assert.equal(fs.readFileSync(file, 'utf8'), before) + const bad = writeReceipt(file, second.environment, { work_settled: false }) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) + assert.equal(fs.readFileSync(file, 'utf8'), before) + writeReceipt(file, second.environment, { process: { ...bad.process, created: '' } }) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) + assert.equal(fs.readFileSync(file, 'utf8'), before) +}) + +test('corrupt and missing-index receipt state never become fresh', async t => { + const file = cleanupFixture(t) + const { environment } = await prepareBackendCleanup(file, launchIdentity) + writeReceipt(file, environment) + fs.unlinkSync(file) + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) + fs.writeFileSync(file, '{broken') + assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) + await assert.rejects(prepareBackendCleanup(file, launchIdentity)) +}) + +function existingPython(root) { + const local = path.join(root, '.venv', 'Scripts', 'python.exe') + const command = process.env.AMADEUS_TEST_PYTHON || (fs.existsSync(local) ? local : 'python') + // These probes import only the standard-library cleanup module. CI can use + // its installed Python without creating the application's virtualenv. + const result = spawnSync(command, ['-I', '-c', 'import sys; print(sys.executable)'], { + encoding: 'utf8', timeout: 10000, windowsHide: true, + }) + assert.equal(result.status, 0, result.error?.message || result.stderr) + return result.stdout.trim() +} + +for (const graceful of [true, false]) test(`real Windows Job retains a surviving child with graceful approval=${graceful}`, { skip: process.platform !== 'win32' }, async t => { + const file = cleanupFixture(t) + const root = path.resolve('..') + const python = existingPython(root) + const { environment } = await prepareBackendCleanup(file, { ...launchIdentity, projectRoot: root, pythonCommand: python }) + const release = path.join(path.dirname(file), 'release-child') + const started = path.join(path.dirname(file), 'child-started') + const helper = path.join(path.dirname(file), 'job-helper.py') + const childSource = `import pathlib,time; p=pathlib.Path(${JSON.stringify(release)}); pathlib.Path(${JSON.stringify(started)}).write_text('started'); end=time.monotonic()+20\nwhile not p.exists() and time.monotonic() setTimeout(resolve, 50)) + } + assert.equal(clean, true, `owned child ${identities.child} must drain normally after release`) +}) + +for (const outcome of ['crash', 'unsettled-work']) test(`real ${outcome} is recoverable after the Job drains without granting Work success`, { skip: process.platform !== 'win32' }, async t => { + const file = cleanupFixture(t) + const root = path.resolve('..') + const python = existingPython(root) + const { environment } = await prepareBackendCleanup(file, { ...launchIdentity, projectRoot: root, pythonCommand: python }) + const launch = JSON.parse(environment.AMADEUS_BACKEND_CLEANUP_LAUNCH) + const helper = path.join(path.dirname(file), 'unsettled-helper.py') + fs.writeFileSync(helper, `import json,os,sys\nsys.path.insert(0,${JSON.stringify(root)})\nfrom server.backend_cleanup import BackendCleanup\nproof=BackendCleanup(json.loads(os.environ['AMADEUS_BACKEND_CLEANUP_LAUNCH']))\nassert proof.failure is None, proof.failure\n${outcome === 'crash' ? 'os._exit(17)' : 'proof.record_runtime_cleanup(providers_settled=True,work_settled=False,teardown_succeeded=True)\nassert not proof.finish()'}\n`) + const result = spawnSync(python, ['-B', helper], { cwd: root, env: { ...process.env, ...environment }, encoding: 'utf8', timeout: 10000, windowsHide: true }) + assert.equal(result.status, outcome === 'crash' ? 17 : 0, result.stdout + result.stderr) + const receipt = path.join(`${file}.receipts`, `${launch.owner.id}.json`) + let clean = false + for (let attempt = 0; attempt < 40 && !clean; attempt++) { + clean = await verifyPreviousBackendCleanup(file) + if (!clean) await new Promise(resolve => setTimeout(resolve, 25)) + } + assert.equal(clean, true) + assert.equal(JSON.parse(fs.readFileSync(receipt, 'utf8')).state, 'exited') + assert.equal(fs.existsSync(path.join(`${file}.receipts`, `${launch.owner.id}.approval.json`)), false) +}) + +for (const scenario of ['failed-request', 'graceful-timeout', 'ancestor-junction']) { + test(`native owned Job stop preserves its observer: ${scenario}`, {skip: process.platform !== 'win32', timeout: 60000}, async t => { + let file = cleanupFixture(t) + let link + if (scenario === 'ancestor-junction') { + const base = path.dirname(file) + const target = path.join(base, 'relocated-profile') + fs.mkdirSync(path.join(target, 'user-data'), {recursive: true}) + link = path.join(base, 'profile-link') + fs.symlinkSync(target, link, 'junction') + file = path.join(link, 'user-data', 'backend-cleanup.json') + } + const root = path.resolve('..'), python = existingPython(root) + let proc, unrelated + try { + const {environment} = await prepareBackendCleanup(file, {...launchIdentity, projectRoot: root, pythonCommand: python}) + const rawLaunch = environment.AMADEUS_BACKEND_CLEANUP_LAUNCH + const launch = JSON.parse(rawLaunch) + const helper = path.join(path.dirname(file), 'hung-backend.py') + fs.writeFileSync(helper, `import json,os,subprocess,sys,time\nsys.path.insert(0,${JSON.stringify(root)})\nfrom server.backend_cleanup import begin_from_environment\nproof=begin_from_environment()\nassert proof and proof.failure is None, proof.failure if proof else 'no launch'\nchild=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)\nassert not proof.job.only_backend_remains()\nprint(json.dumps({'pid':os.getpid(),'child':child.pid}),flush=True)\ntime.sleep(60)\n`) + const env = {...process.env, ...environment} + delete env.AMADEUS_BACKEND_INSTANCE_NONCE + proc = spawn(python, ['-B', helper], {cwd: root, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']}) + let errors = '' + proc.stderr.on('data', data => {errors += data}) + const output = await Promise.race([ + once(proc.stdout, 'data'), + once(proc, 'exit').then(() => {throw Error(`backend exited before readiness: ${errors}`)}), + ]) + assert.ok(JSON.parse(String(output[0])).child > 0) + assert.equal(await verifyPreviousBackendCleanup(file), false, 'live Job must fence maintenance') + unrelated = spawn(python, ['-c', 'import time; time.sleep(60)'], {windowsHide: true, stdio: 'ignore'}) + await once(unrelated, 'spawn') + // A different creation identity must never authorize Job termination. + const trackingFile = path.join(`${file}.receipts`, `${launch.owner.id}.tracking.json`) + const bytes = fs.readFileSync(trackingFile) + const tracking = JSON.parse(bytes) + tracking.process.created = String(BigInt(tracking.process.created) + 1n) + fs.writeFileSync(trackingFile, JSON.stringify(tracking)) + const denied = spawnSync(python, ['-I', '-B', path.join(root, 'server/backend_cleanup.py'), '--terminate', rawLaunch], {windowsHide: true, encoding: 'utf8', timeout: 10000}) + assert.notEqual(denied.status, 0) + assert.match(denied.stderr, /does not match/) + assert.equal(proc.exitCode, null) + fs.writeFileSync(trackingFile, bytes) + assert.equal(await stopOwnedBackend(proc, async () => scenario === 'graceful-timeout', { + cleanupLaunch: rawLaunch, verifyCleanup: () => verifyPreviousBackendCleanup(file), + }), false, 'termination itself must not grant maintenance authority') + assert.notEqual(proc.exitCode ?? proc.signalCode, null) + let clean = false + for (let i = 0; i < 40 && !clean; i++) { + clean = await verifyPreviousBackendCleanup(file) + if (!clean) await new Promise(resolve => setTimeout(resolve, 50)) + } + assert.equal(clean, true, 'observer must survive Electron escalation and certify the drained Job') + assert.equal(JSON.parse(fs.readFileSync(path.join(`${file}.receipts`, `${launch.owner.id}.json`))).state, 'exited') + assert.equal(unrelated.exitCode, null, 'unrelated process must remain alive') + } finally { + if (proc && proc.exitCode === null && proc.signalCode === null) { + await forceStopBackendTree(proc) + await waitForProcessExit(proc, 5000) + } + if (unrelated) {unrelated.kill(); await waitForProcessExit(unrelated, 5000)} + if (link) fs.unlinkSync(link) + } + }) +} diff --git a/electron/tests/extensionCatalog.test.mjs b/electron/tests/extensionCatalog.test.mjs index acb27126..34cc0c19 100644 --- a/electron/tests/extensionCatalog.test.mjs +++ b/electron/tests/extensionCatalog.test.mjs @@ -3,32 +3,58 @@ import test from 'node:test' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { createHash } from 'node:crypto' 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 { ExtensionUpdateBoundary } = loadTypeScript(new URL('../src/main/extensionUpdates.ts', import.meta.url)) 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) { +function fixture(t, platform = 'win32') { 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 installer = { pendingUpdate: () => state.pendingUpdate, + rollbackPendingUpdate: async () => { state.rolledBack = true }, + 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) } + state.notifications = [] + const catalog = new ExtensionCatalog(path.join(root, 'catalog'), store, helper, installer, () => state.mirror, () => {}, download, updates => state.notifications.push(updates), platform) + return { root, store, helper, catalog, state, boundary: new ExtensionUpdateBoundary(async () => true), + reopen: () => new ExtensionCatalog(path.join(root, 'catalog'), store, helper, installer, () => state.mirror, () => {}, download, undefined, platform) } } 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' }) }) + +for (const platform of ['linux', 'darwin']) { + test(`${platform} catalog installs immediately and cannot queue an unsupported update`, async t => { + const f = fixture(t, platform) + const choice = {id: entry().id, version: entry().version} + const reply = await f.catalog.dispatch({action: 'install', ...choice}, {}, async () => {}) + assert.equal(reply.ok, true) + assert.equal(reply.scheduled, undefined) + assert.equal(f.state.installed.length, 1) + assert.equal((await f.catalog.dispatch({action: 'schedule', ...choice}, {}, async () => {})).ok, false) + await assert.rejects(f.catalog.scheduleLocal(path.join(f.root, 'not-opened.whl')), /only available on Windows/) + await assert.rejects(f.catalog.applyScheduled(f.boundary), /only available on Windows/) + assert.equal(f.catalog.hasScheduled(), false) + assert.equal(fs.existsSync(path.join(f.root, 'catalog/update-preferences.json')), false) + f.state.index.sequence++ + f.state.index.entries[0].withdrawal = {reason: 'maintainer', evidence: 'https://example.org/withdrawn'} + assert.equal((await f.catalog.dispatch({action: 'install', ...choice}, {}, async () => {})).ok, false) + assert.equal(f.state.installed.length, 1, 'immediate delivery must retain catalog admission checks') + }) +} 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') @@ -119,3 +145,138 @@ test('a preparation or persistence error is not misreported as cancellation', as assert.equal(result.cancelled, undefined) assert.match(result.error, /persistence failure/) }) + + +test('automatic checks default off, persist opt-in, notify only compatible updates, and never install', async t => { + const f = fixture(t) + const now = Date.now() + await f.catalog.checkAutomatic(now) + assert.deepEqual(f.state.requests, []) + t.mock.method(f.store, 'storedExtensionRecords', () => [{id:entry().id,version:'0.0.1'}]) + assert.equal((await f.catalog.dispatch({action:'auto-check',enabled:true},{},async()=>{})).ok,true) + f.state.index.entries.push({...entry(),version:'9.0.0',incompatible:['future']}) + await f.catalog.checkAutomatic(now) + assert.deepEqual(f.state.notifications, [[{id:entry().id,version:'0.1.0'}]]) + assert.deepEqual(f.state.installed, []) + await f.catalog.checkAutomatic(now+1000) + assert.equal(f.state.requests.length,1) + await f.catalog.checkAutomatic(now+86_400_001) + assert.equal(f.state.notifications.length,1,'unchanged candidate does not repeat a notification') + assert.equal((await f.reopen().view()).autoCheck,true) + assert.throws(()=>catalogRequest({action:'auto-check',enabled:'true'})) + assert.throws(()=>catalogRequest({action:'auto-check',enabled:true,url:'https://forged/'})) +}) + +test('scheduling pins the selected artifact without downloading or installing until a fresh startup check', async t => { + const f = fixture(t) + const scheduled = await f.catalog.dispatch({action:'schedule',id:entry().id,version:entry().version},{},async()=>{}) + assert.equal(scheduled.scheduled,true) + assert.equal(scheduled.catalog.scheduledInstall.digest,entry().sha256) + assert.deepEqual(f.state.installed,[]) + assert.deepEqual(f.state.requests,[OFFICIAL_EXTENSION_INDEX]) + await f.reopen().applyScheduled(f.boundary) + assert.equal(f.state.installed.length,1) + assert.deepEqual(f.state.requests,[OFFICIAL_EXTENSION_INDEX,OFFICIAL_EXTENSION_INDEX,entry().url]) + assert.equal((await f.catalog.view()).scheduledInstall,undefined) +}) + +test('offline or changed scheduled bytes cannot install, and failed intent is never automatically replayed', async t => { + for(const failure of ['offline','digest','withdrawal']) { + const f=fixture(t) + await f.catalog.dispatch({action:'schedule',id:entry().id,version:entry().version},{},async()=>{}) + if(failure==='offline') f.state.networkError=true + if(failure==='digest') f.state.index.entries[0].sha256='b'.repeat(64) + if(failure==='withdrawal') f.state.index.entries[0].withdrawal={reason:'maintainer',evidence:'https://example.org/'} + await assert.rejects(f.reopen().applyScheduled(f.boundary)) + assert.deepEqual(f.state.installed,[]) + assert.equal((await f.catalog.view()).scheduledInstall,undefined) + const requests=f.state.requests.length + await f.reopen().applyScheduled(f.boundary) + assert.equal(f.state.requests.length,requests) + } +}) + +test('rollback selection only queues recovery; no package changes occur in the running app', async t => { + const f=fixture(t) + f.state.pendingUpdate={id:entry().id,version:'0.2.0',previousVersion:'0.1.0',canConfirm:false,recoveryRequired:true} + assert.equal((await f.catalog.dispatch({action:'rollback'},{},async()=>{})).scheduled,true) + assert.equal(f.state.rolledBack,undefined) + await f.reopen().applyScheduled(f.boundary) + assert.equal(f.state.rolledBack,true) + assert.equal((await f.catalog.view()).rollbackScheduled,false) +}) + + +test('turning checks off during a response suppresses its eventual notification',async t=>{ + const f=fixture(t) + t.mock.method(f.store,'storedExtensionRecords',()=>[{id:entry().id,version:'0.0.1'}]) + await f.catalog.dispatch({action:'auto-check',enabled:true},{},async()=>{}) + let release + const gate=new Promise(resolve=>{release=resolve}) + t.mock.method(f.catalog,'refresh',async()=>{await gate;return {entries:[entry()]}}) + const pending=f.catalog.checkAutomatic(Date.now()) + await f.catalog.dispatch({action:'auto-check',enabled:false},{},async()=>{}) + release();await pending + assert.deepEqual(f.state.notifications,[]) + assert.deepEqual(f.state.installed,[]) +}) + + +test('native local selection freezes exact prerelease bytes and installs only on the next start', async t => { + const f = fixture(t) + const wheel = path.join(f.root, 'demo-1.0.0rc1-py3-none-any.whl') + fs.writeFileSync(wheel, 'selected-local-wheel') + const digest = createHash('sha256').update(fs.readFileSync(wheel)).digest('hex') + t.mock.method(f.helper, 'call', async (command, [file]) => { + assert.equal(command, 'inspect-wheel') + return { ok: true, manifest: { id: 'org.example.local', version: '1.0.0rc1' }, digest: createHash('sha256').update(fs.readFileSync(file)).digest('hex') } + }) + await f.catalog.scheduleLocal(wheel) + assert.deepEqual((await f.catalog.view()).scheduledInstall, {id:'org.example.local', version:'1.0.0rc1', digest}) + assert.deepEqual(f.state.installed, []) + assert.deepEqual(f.state.requests, [], 'local import has no catalog lookup or runtime download') + fs.writeFileSync(wheel, 'the original may change after selection') + f.state.confirm = async () => { assert.equal(f.state.installed.length, 0) } + await f.reopen().applyScheduled(f.boundary) + assert.equal(f.state.installed.length, 1) + assert.equal(f.state.installed[0].digest, digest) + assert.equal(f.state.installed[0].version, '1.0.0rc1') + assert.equal((await f.catalog.view()).scheduledInstall, undefined) + assert.deepEqual(fs.readdirSync(path.join(f.root, 'catalog/pending-local')), []) +}) + +test('changed local snapshot or native cancellation is not installed or replayed; queued local import can be cancelled', async t => { + for (const failure of ['tamper', 'confirmation', 'cancel']) { + const f = fixture(t) + const wheel = path.join(f.root, 'demo-1.0.0-py3-none-any.whl') + fs.writeFileSync(wheel, 'selected-local-wheel') + t.mock.method(f.helper, 'call', async (_command, [file]) => ({ok:true,manifest:{id:'org.example.local',version:'1.0.0'},digest:createHash('sha256').update(fs.readFileSync(file)).digest('hex')})) + await f.catalog.scheduleLocal(wheel) + const preferences = JSON.parse(fs.readFileSync(path.join(f.root, 'catalog/update-preferences.json'))) + const snapshot = path.join(f.root, 'catalog/pending-local', preferences.local.directory, preferences.local.file) + if (failure === 'cancel') { + assert.equal((await f.catalog.dispatch({action:'cancel-scheduled'},{},async()=>{})).ok, true) + assert.equal(fs.existsSync(snapshot), false) + } else { + if (failure === 'tamper') fs.writeFileSync(snapshot, 'different bytes') + else f.state.confirm = async () => { throw new Error('Native confirmation cancelled') } + await assert.rejects(f.reopen().applyScheduled(f.boundary), failure === 'tamper' ? /bytes changed/ : /cancelled/) + } + assert.deepEqual(f.state.installed, []) + assert.equal((await f.catalog.view()).scheduledInstall, undefined) + await f.reopen().applyScheduled(f.boundary) + assert.deepEqual(f.state.installed, []) + assert.deepEqual(fs.readdirSync(path.join(f.root, 'catalog/pending-local')), []) + } +}) + +test('rejected local inspection never replaces the prior user selection', async t => { + const f = fixture(t) + await f.catalog.dispatch({action:'schedule',id:entry().id,version:entry().version},{},async()=>{}) + const wheel = path.join(f.root, 'demo-1.0.0-py3-none-any.whl') + fs.writeFileSync(wheel, 'invalid-wheel') + t.mock.method(f.helper, 'call', async () => { throw new Error('Invalid wheel') }) + await assert.rejects(f.catalog.scheduleLocal(wheel), /Invalid wheel/) + assert.deepEqual((await f.catalog.view()).scheduledInstall, {id:entry().id,version:entry().version,digest:entry().sha256}) + assert.deepEqual(fs.readdirSync(path.join(f.root, 'catalog/pending-local')), []) +}) diff --git a/electron/tests/extensionCatalogUI.test.mjs b/electron/tests/extensionCatalogUI.test.mjs index ee351c24..73380aa6 100644 --- a/electron/tests/extensionCatalogUI.test.mjs +++ b/electron/tests/extensionCatalogUI.test.mjs @@ -61,3 +61,13 @@ test('empty catalog keeps browsing available and explains preview status', () => assert.match(html, /Plugin store preview: no packages have been listed yet/) assert.match(html, /Catalog packages/) }) + +test('catalog confirmation labels match the host installation mode on every platform', () => { + 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') + for (const platform of ['win32', 'linux', 'darwin']) { + const html = renderToStaticMarkup(React.createElement(Component, {platform, catalog:{entries:[item],cached:false}, installed:[], busy:false, selection:item, onSelect(){}, onInstall(){}, onLink(){}})) + assert.equal(html.includes('Schedule this version for next app start…'), platform === 'win32') + assert.equal(html.includes('Review and install this version…'), platform !== 'win32') + } +}) diff --git a/electron/tests/extensionCrashFixture.test.mjs b/electron/tests/extensionCrashFixture.test.mjs new file mode 100644 index 00000000..d7d57b1a --- /dev/null +++ b/electron/tests/extensionCrashFixture.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import {EventEmitter} from 'node:events' +import test from 'node:test' + +const source = fs.readFileSync(new URL('./fixtures/extensionRelease.cjs', import.meta.url), 'utf8') +const capture = new Function(source + '\nreturn captureFixtureBackend')() + +test('crash injection targets only the fixture backend ChildProcess and refreshes its ESM binding', async () => { + const children = [], nonce = 'synthetic-fixture-nonce' + const processes = {spawn: () => { + const child = Object.assign(new EventEmitter(), {exitCode: null, signalCode: null, killed: false}) + child.kill = () => {child.killed = true; queueMicrotask(() => {child.exitCode = 1; child.emit('exit', 1)}); return true} + children.push(child) + return child + }} + let synced = 0 + const crash = capture(processes, () => synced++, nonce) + assert.equal(synced, 1) + processes.spawn('python', ['-m', 'server.app'], {env: {AMADEUS_BACKEND_INSTANCE_NONCE: 'other-nonce'}}) + processes.spawn('python', ['helper.py'], {env: {AMADEUS_BACKEND_INSTANCE_NONCE: nonce}}) + await assert.rejects(crash(), /No live owned/) + assert.ok(children.every(child => !child.killed)) + const first = processes.spawn('python', ['-m', 'server.app', '--port', '17777'], {env: {AMADEUS_BACKEND_INSTANCE_NONCE: nonce}}) + assert.equal(await crash(), true) + assert.equal(first.killed, true) + assert.equal(first.listenerCount('exit') + first.listenerCount('error'), 0) + await assert.rejects(crash(), /No live owned/) + const next = processes.spawn('python', ['-m', 'server.app'], {env: {AMADEUS_BACKEND_INSTANCE_NONCE: nonce}}) + assert.equal(await crash(), true) + assert.equal(next.killed, true) + assert.ok(children.slice(0, 2).every(child => !child.killed)) +}) diff --git a/electron/tests/extensionInstallRouting.test.mjs b/electron/tests/extensionInstallRouting.test.mjs new file mode 100644 index 00000000..2dbecbe8 --- /dev/null +++ b/electron/tests/extensionInstallRouting.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' +import ts from 'typescript' +import {loadTypeScript} from './helpers/loadTypeScript.mjs' + +const {usesScheduledExtensionUpdates} = loadTypeScript(new URL('../src/shared/extensionCatalog.ts', import.meta.url)) +const {ExtensionUpdateBoundary} = loadTypeScript(new URL('../src/main/extensionUpdates.ts', import.meta.url)) +const source = ts.createSourceFile('index.ts', fs.readFileSync(new URL('../src/main/index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true) + +// Execute the production IPC entry points without launching Electron or a +// backend. Their delivery boundary must agree with the renderer's platform. +function handlersFor(platform) { + const handlers = new Map(), calls = [] + const statements = source.statements.filter(statement => { + if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return false + const call = statement.expression + return call.expression.getText(source) === 'ipcMain.handle' + && ['extensions.install', 'extensions.catalog'].includes(call.arguments[0]?.text) + }) + assert.equal(statements.length, 2) + const context = { + ipcMain: {handle: (name, callback) => handlers.set(name, callback)}, + scheduledExtensionUpdates: usesScheduledExtensionUpdates(platform), + extensionUpdateBoundary: new ExtensionUpdateBoundary(async () => false), + extensionInstaller: {install: async file => {calls.push(['install-local', file]); return {installed: true}}}, + extensionCatalog: { + scheduleLocal: async file => {calls.push(['schedule-local', file])}, + dispatch: async request => {calls.push(['catalog', request.action]); return {ok: true, settings: {installed: true}}}, + }, + isTrustedBackendRenderer: sender => sender === 'trusted', + dialog: {showOpenDialog: async () => ({canceled: false, filePaths: ['selected-wheel.whl']})}, + mainWindow: undefined, backendOwned: false, process: {env: {}}, + shell: {openExternal: async () => {}}, ExtensionChangeCancelled: class extends Error {}, + } + const code = ts.transpileModule(statements.map(statement => statement.getText(source)).join('\n'), {compilerOptions: {target: ts.ScriptTarget.ES2022}}).outputText + new Function(...Object.keys(context), code)(...Object.values(context)) + return {handlers, calls} +} + +for (const platform of ['linux', 'darwin']) { + test(`${platform} native and catalog IPC use immediate delivery even while backend maintenance is unavailable`, async () => { + const {handlers, calls} = handlersFor(platform) + const local = await handlers.get('extensions.install')({sender: 'trusted'}) + assert.deepEqual(local, {ok: true, settings: {installed: true}}) + assert.equal((await handlers.get('extensions.catalog')({sender: 'trusted'}, {action: 'install', id: 'org.example.demo', version: '1.0.0'})).ok, true) + assert.deepEqual(calls, [['install-local', 'selected-wheel.whl'], ['catalog', 'install']]) + }) +} + +test('Windows native IPC queues installation and direct catalog installation still requires cleanup proof', async () => { + const {handlers, calls} = handlersFor('win32') + assert.deepEqual(await handlers.get('extensions.install')({sender: 'trusted'}), {ok: true, scheduled: true}) + const blocked = await handlers.get('extensions.catalog')({sender: 'trusted'}, {action: 'install', id: 'org.example.demo', version: '1.0.0'}) + assert.equal(blocked.ok, false) + assert.match(blocked.error, /active or unconfirmed/) + assert.deepEqual(calls, [['schedule-local', 'selected-wheel.whl']]) +}) + +test('platform routing does not bypass trusted renderer admission', async () => { + for (const platform of ['win32', 'linux', 'darwin']) { + const {handlers, calls} = handlersFor(platform) + assert.equal((await handlers.get('extensions.install')({sender: 'foreign'})).ok, false) + assert.equal((await handlers.get('extensions.catalog')({sender: 'foreign'}, {action: 'install'})).ok, false) + assert.deepEqual(calls, []) + } +}) diff --git a/electron/tests/extensionSettings.smoke.mjs b/electron/tests/extensionSettings.smoke.mjs index 4231f6db..4d90ddb3 100644 --- a/electron/tests/extensionSettings.smoke.mjs +++ b/electron/tests/extensionSettings.smoke.mjs @@ -66,11 +66,11 @@ try { 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 click('Check now') 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 click('Check now') 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') @@ -177,7 +177,7 @@ try { 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 click('Recover security state') await until("document.body.textContent.includes('Last successful security check')", 'Recovery success not shown') assert.equal(await ui("window.extensionFixture.calls.filter(c=>c.method==='extensions.catalog'&&c.params.action!=='status').at(-1).params.action"), 'recover') assert.ok(await ui("document.body.textContent.includes('Artifact mirror base URL')")) diff --git a/electron/tests/extensionSettings.test.mjs b/electron/tests/extensionSettings.test.mjs index 8f1baf55..e0436115 100644 --- a/electron/tests/extensionSettings.test.mjs +++ b/electron/tests/extensionSettings.test.mjs @@ -39,7 +39,7 @@ const providers = [ { provider_id: 'pi', display_name: 'Pi', capabilities: { capability_projections: ['mcp_connection'] } }, { provider_id: 'unrelated', display_name: 'Incompatible agent', capabilities: { capability_projections: ['skill'] } }, ] -const props = overrides => ({ extensions: [extension()], runtime: [], providers, connected: false, available: true, encryptionAvailable: true, selectedVoiceBackend: '', +const props = overrides => ({ platform: 'win32', extensions: [extension()], runtime: [], providers, connected: false, available: true, encryptionAvailable: true, selectedVoiceBackend: '', onSettingsChanged() {}, onRestartRequired() {}, onOpenVoice() {}, ...overrides }) test('typed extension edits preserve defaults and never reconstruct saved secrets', () => { @@ -316,6 +316,122 @@ test('opening Browse cannot implicitly recover damaged safety state', async t => 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() + page.button('Recover security state').props.onClick();await flush();page.render() assert.equal(requests.filter(r=>r.action==='recover').length,1) }) + + +test('update switch changes only checking preference and exact version scheduling never requests hot installation',async t=>{ + const requests=[] + const candidate={id:'org.example.voice',version:'1.1.0',sha256:'b'.repeat(64),size:1,url:'https://example.org/plugin.whl',display:{},compatibility:{contributions:[]},review:{},withdrawal:null,incompatible:[]} + const view={entries:[candidate],cached:false,autoCheck:false} + const page=harness({extensionCatalog:async request=>{ + requests.push(request) + if(request.action==='auto-check')view.autoCheck=request.enabled + return {ok:true,scheduled:request.action==='schedule',catalog:{...view}} + }}) + t.after(()=>page.restore()) + await flush();page.render() + page.find(node=>node.props?.label==='Automatic plugin update checks'&&node.props.onChange).props.onChange(true) + await flush();page.render() + assert.deepEqual(requests.find(request=>request.action==='auto-check'),{action:'auto-check',enabled:true}) + page.button('Schedule update for next app start…').props.onClick() + await flush();page.render() + assert.deepEqual(requests.find(request=>request.action==='schedule'),{action:'schedule',id:'org.example.voice',version:'1.1.0'}) + assert.equal(requests.some(request=>request.action==='install'),false) + assert.ok(page.find(node=>node.props?.role==='status'&&String(node.props.children).includes('Installed packages have not changed'))) +}) + +test('unconfirmed update exposes rollback and status refresh while keep remains blocked',async t=>{ + const requests=[] + const page=harness({extensionCatalog:async request=>{requests.push(request);return {ok:true,scheduled:request.action==='rollback',catalog:{entries:[],cached:true,pendingUpdate:{id:'org.example.voice',version:'1.1.0',previousVersion:'1.0.0',canConfirm:false,recoveryRequired:true}}}}}) + t.after(()=>page.restore()) + await flush();page.render() + assert.equal(page.button('Keep this version').props.disabled,true) + page.button('Check status').props.onClick();await flush();page.render() + page.button('Schedule rollback…').props.onClick();await flush();page.render() + assert.ok(requests.some(request=>request.action==='health')) + assert.ok(requests.some(request=>request.action==='rollback')) + assert.equal(requests.some(request=>request.action==='install'||request.action==='confirm-update'),false) +}) + + +test('native local import reports a queued change instead of claiming an installed package', async t => { + let writes = 0 + const page = harness({installExtension: async () => ({ok:true,scheduled:true})}, {onSettingsChanged: () => { writes++ }}) + t.after(() => page.restore()) + page.button('Install plugin…').props.onClick() + await flush();page.render() + assert.equal(writes, 0) + assert.ok(page.find(node => node.props?.role === 'status' && String(node.props.children).includes('Scheduled for the next app start'))) +}) + +test('update checking has an associated label and description, with manual checking kept independent', async t => { + const requests = [] + const page = harness({extensionCatalog: async request => { + requests.push(request) + return {ok:true,catalog:{entries:[],cached:false,autoCheck:false},settings:{extensionSecurity:{status:'ready'}}} + }}) + t.after(() => page.restore()) + await flush(); page.render() + const toggle = page.find(node => node.props?.label === 'Automatic plugin update checks' && node.props.onChange) + assert.ok(page.find(node => node.type === 'label' && node.props.htmlFor === toggle.props.id)) + assert.ok(page.find(node => node.props?.id === toggle.props.describedBy)) + page.button('Check now').props.onClick() + await flush(); page.render() + assert.equal(requests.filter(request => request.action === 'refresh').length, 1) + assert.equal(requests.some(request => request.action === 'auto-check'), false) +}) + +test('scheduled installation keeps the full digest in closed details and cancellation remains available', async t => { + const requests = [] + const digest = 'b'.repeat(64) + const page = harness({extensionCatalog: async request => { + requests.push(request) + return {ok:true,catalog:{entries:[],cached:false,scheduledInstall:{id:'org.example.voice',version:'1.1.0',digest}}} + }}) + t.after(() => page.restore()) + await flush(); page.render() + const details = page.find(node => node.type === 'details' && node.props.children?.[0]?.props.children === 'Package details') + assert.ok(details) + assert.ok(!details.props.open) + assert.ok(JSON.stringify(details.props.children).includes(digest)) + page.button('Cancel scheduled change').props.onClick() + await flush(); page.render() + assert.equal(requests.filter(request => request.action === 'cancel-scheduled').length, 1) + assert.equal(requests.some(request => request.action === 'install' || request.action === 'confirm-update'), false) +}) + +test('a verified update still requires the explicit keep action', async t => { + const requests = [] + const page = harness({extensionCatalog: async request => { + requests.push(request) + return {ok:true,catalog:{entries:[],cached:false,pendingUpdate:{id:'org.example.voice',version:'1.1.0',previousVersion:'1.0.0',canConfirm:true,recoveryRequired:false}}} + }}) + t.after(() => page.restore()) + await flush(); page.render() + assert.equal(requests.some(request => request.action === 'confirm-update'), false) + assert.equal(page.button('Keep this version').props.disabled, false) + page.button('Keep this version').props.onClick() + await flush(); page.render() + assert.equal(requests.filter(request => request.action === 'confirm-update').length, 1) +}) + +for (const platform of ['linux', 'darwin']) { + test(`${platform} update UI requests immediate installation and reports its actual settings`, async t => { + const requests = [], saved = [] + const candidate = {id:'org.example.voice',version:'1.1.0',sha256:'b'.repeat(64),size:1,url:'https://example.org/plugin.whl',display:{},compatibility:{contributions:[]},review:{},withdrawal:null,incompatible:[]} + const page = harness({extensionCatalog: async request => { + requests.push(request) + return {ok:true,catalog:{entries:[candidate],cached:false}, ...(request.action === 'install' ? {settings:{extensions:['installed']}} : {})} + }}, {platform, onSettingsChanged: value => saved.push(value)}) + t.after(() => page.restore()) + await flush(); page.render() + assert.equal(page.button('Schedule update for next app start…'), undefined) + page.button('Review and update…').props.onClick() + await flush(); page.render() + assert.deepEqual(requests.find(request => request.action === 'install'), {action:'install',id:candidate.id,version:candidate.version}) + assert.equal(requests.some(request => request.action === 'schedule'), false) + assert.ok(saved.some(value => value.extensions?.[0] === 'installed')) + }) +} diff --git a/electron/tests/extensionStore.smoke.mjs b/electron/tests/extensionStore.smoke.mjs index 0346eb0f..a918e8d8 100644 --- a/electron/tests/extensionStore.smoke.mjs +++ b/electron/tests/extensionStore.smoke.mjs @@ -11,6 +11,7 @@ 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' +import { ExtensionUpdateBoundary } from '../dist/main/extensionUpdates.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('..')) @@ -30,7 +31,8 @@ const report={ok:false, checks:[], artifacts:fixture.index.entries.map(e=>({id:e 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 boundary = new ExtensionUpdateBoundary(async()=>true) // This fixture has no backend. +const installer = new ExtensionInstaller(path.join(base,'extensions'),root,store,helper,()=>python,environment,undefined,async()=>{confirms++;await pause(450);return true},undefined,undefined,notify,boundary.assertHeld,process.platform,boundary.assertRecoveryHeld) 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) @@ -65,6 +67,9 @@ try { 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 scheduleLabel='Schedule this version for next app start…' + async function scheduled(){await until("document.body.innerText.includes('Scheduled for the next app start. Installed packages have not changed.')&&!document.querySelector('.extension-download-progress')",'Selection was not scheduled');assert.ok(catalog.hasScheduled())} + async function applyScheduled(){await catalog.applyScheduled(boundary);await installer.refreshUpdateHealth([]);notify();await click('Installed')} 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)})) @@ -89,15 +94,16 @@ try { 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') + await click(scheduleLabel);await scheduled() + check('scheduling leaves installed records and wheel downloads untouched',store.storedExtensionRecords().length===0&&!network.some(url=>url.startsWith('/wheels/'))) + await click('Installed');await capture('store-scheduled-en') + await click('Cancel scheduled change');await until("!document.body.innerText.includes('Cancel scheduled change')",'Cancellation not acknowledged') + check('cancellation leaves no saved install or queued intent',store.storedExtensionRecords().length===0&&!catalog.hasScheduled()) + await click('Browse');await until("[...document.querySelectorAll('button')].some(node=>node.textContent.trim()==='Check now'&&!node.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) + await ui(`(()=>{const b=Array.from(document.querySelectorAll('button')).find(n=>n.getClientRects().length&&n.textContent===${JSON.stringify(scheduleLabel)});b.click();b.click()})()`) + await scheduled();check('double click schedules without native confirmation',confirms===before) + await applyScheduled();await installed(themeId,'0.1.0');check('stopped startup commits one confirmed 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') @@ -107,12 +113,12 @@ try { 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') + await click('Check now');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)")) + check('future mixed package details remain visible but installation is disabled',await ui(`Array.from(document.querySelectorAll('button')).some(n=>n.textContent===${JSON.stringify(scheduleLabel)}&&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') @@ -120,25 +126,28 @@ try { 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') + await click('Schedule update for next app start…');await scheduled() + await assert.rejects(catalog.applyScheduled(boundary),/digest|SHA-256|package validation failed/i) + check('failed startup consumes the scheduled intent',!catalog.hasScheduled()) 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') + badWheel=false;await click('Schedule update for next app start…');await scheduled();await applyScheduled();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 click('Keep this version');await until("!document.body.innerText.includes('Keep this version')",'Update was not confirmed');check('explicit keep clears rollback receipt',!installer.pendingUpdate()) await capture('store-updated-en') // Offline refresh preserves the installed version and enabled state. - offline=true;await click('Check updates and security status');await until("document.querySelector('[role=alert]')",'Offline refresh failure absent') + offline=true;await click('Check now');await until("document.querySelector('[role=alert]')",'Offline refresh failure absent') check('offline refresh preserves saved version and enable intent',store.storedExtensionRecords()[0].version==='0.2.0'&&store.storedExtensionRecords()[0].enabled) offline=false;await configure(false) check('disable restores builtin appearance',!(await installer.themeState()).theme) const data=path.join(base,'extension-data',createHash('sha256').update(themeId).digest('hex'));fs.writeFileSync(path.join(data,'retained.txt'),'fixture') await remove();await click('Confirm remove');await until("document.body.innerText.includes('No community extensions installed.')",'Uninstall failed') check('uninstall retains data by default',fs.existsSync(path.join(data,'retained.txt'))) - await click('Browse');await input('input[type=search]',themeId);await click('View plugin details');await click('Install plugin…');await installed(themeId,'0.2.0') + await click('Browse');await input('input[type=search]',themeId);await click('View plugin details');await click(scheduleLabel);await scheduled();await applyScheduled();await installed(themeId,'0.2.0') await remove();await ui("Array.from(document.querySelectorAll('input[type=checkbox]')).find(n=>n.closest('.extension-removal-data')).click()");await click('Confirm remove');await until("document.body.innerText.includes('No community extensions installed.')",'Explicit deletion failed') check('explicit uninstall data deletion',!fs.existsSync(data)) // A process package uses the same UI and real offline pip transaction, then its actual Runner. index={...index,sequence:3} - await click('Browse');await input('input[type=search]',toneId);await click('View plugin details');await ui("(()=>{const n=document.querySelector('.extension-catalog-detail select');n.value='0.1.0';n.dispatchEvent(new Event('change',{bubbles:true}))})()");await click('Install plugin…');await installed(toneId,'0.1.0');await configure(true) + 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(scheduleLabel);await scheduled();await applyScheduled();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') @@ -151,7 +160,7 @@ try { 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)")) + check('blocked install control is disabled',await ui("document.querySelector('.extension-catalog-detail .settings-form-actions-end button:last-child').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) diff --git a/electron/tests/extensionUpdates.test.mjs b/electron/tests/extensionUpdates.test.mjs new file mode 100644 index 00000000..119fcd9a --- /dev/null +++ b/electron/tests/extensionUpdates.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { loadTypeScript } from './helpers/loadTypeScript.mjs' +const { ExtensionUpdateBoundary } = loadTypeScript(new URL('../src/main/extensionUpdates.ts', import.meta.url)) + +test('maintenance blocks new backend starts during both idle proof and the whole install', async () => { + let prove, finish + const boundary = new ExtensionUpdateBoundary(() => new Promise(resolve => { prove = resolve })) + const run = boundary.run(async () => { + boundary.assertHeld() + assert.throws(() => boundary.assertBackendMayStart(), /blocked/) + await new Promise(resolve => { finish = resolve }) + }) + assert.throws(() => boundary.assertBackendMayStart(), /blocked/) + await assert.rejects(boundary.run(async () => {}), /Another/) + prove(true) + await Promise.resolve() + assert.throws(() => boundary.assertBackendMayStart(), /blocked/) + finish(); await run + boundary.assertBackendMayStart() + assert.throws(boundary.assertHeld, /stopped-backend/) +}) + +test('active or unknown backend never executes an install, and failures release the start fence', async () => { + let executed = false + const blocked = new ExtensionUpdateBoundary(async () => false) + await assert.rejects(blocked.run(async () => { executed = true }), /active or unconfirmed/) + assert.equal(executed, false) + blocked.assertBackendMayStart() + const idle = new ExtensionUpdateBoundary(async () => true) + await assert.rejects(idle.run(async () => { throw new Error('prepare failed') }), /prepare failed/) + idle.assertBackendMayStart() +}) + +test('record-only recovery does not require file-cleanup authority and cannot grant it', async () => { + const boundary = new ExtensionUpdateBoundary(async () => false, async () => true) + await boundary.runRecovery(async () => { + boundary.assertRecoveryHeld() + assert.throws(boundary.assertHeld, /stopped-backend/) + assert.throws(() => boundary.assertBackendMayStart(), /blocked/) + await assert.rejects(boundary.run(async () => assert.fail('install')), /Another/) + }) + await assert.rejects(boundary.run(async () => assert.fail('install')), /active or unconfirmed/) + boundary.assertBackendMayStart() + assert.throws(boundary.assertRecoveryHeld, /recovery boundary/) + const running = new ExtensionUpdateBoundary(async () => false, async () => false) + await assert.rejects(running.runRecovery(async () => assert.fail('live recovery')), /backend is still active/) +}) diff --git a/electron/tests/extensions.test.mjs b/electron/tests/extensions.test.mjs index 6c4f55b0..db7028c3 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -71,7 +71,9 @@ function fixture(t, options = {}) { } const { config, allow_incomplete } = input if (Object.keys(config).some(key => !metadata.config?.[key])) throw new Error('configuration contains undeclared fields') - if (!allow_incomplete && metadata.config?.token?.required && !config.token) throw new Error('required configuration is missing: token') + for (const [key, field] of Object.entries(metadata.config || {})) { + if (!allow_incomplete && field.required && (config[key] === undefined || config[key] === '')) throw new Error(`required configuration is missing: ${key}`) + } if (config.streaming !== undefined && typeof config.streaming !== 'boolean') throw new Error('invalid configuration type for streaming') return { ok: true, config: { ...Object.fromEntries(Object.entries(metadata.config || {}).filter(([, field]) => !field.secret && field.default !== undefined).map(([key, field]) => [key, field.default])), ...Object.fromEntries(Object.entries(config).filter(([key]) => !metadata.config[key].secret)) } } @@ -107,8 +109,9 @@ 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 typeof options.confirm === 'function' ? options.confirm(review, store) : options.confirm !== false }, sdkDirectory) - return { directory, root, file, store, installer, selectedWheel, calls, commandCalls, metadata } + async review => { calls.push({ command: 'confirm', review }); return typeof options.confirm === 'function' ? options.confirm(review, store) : options.confirm !== false }, sdkDirectory, + undefined, undefined, undefined, options.platform || 'win32') + return { directory, root, file, store, installer, selectedWheel, calls, commandCalls, metadata, helper, run } } test('packaged installer uses the shipped SDK without a source build directory', async t => { @@ -1172,3 +1175,476 @@ test('catalog progress reports inspection, native confirmation and preparation w assert.deepEqual(phases, ['verifying', 'confirming', 'preparing']) assert.equal(f.store.storedExtensionRecords()[0].digest, identity.digest) }) + + +async function catalogUpdateFixture(t) { + const f=fixture(t,{manifest:manifest('mcp_server')}) + await f.installer.install(f.selectedWheel) + await f.installer.update({id:f.metadata.id,enabled:true,providers:['pi'],values:{streaming:false},secrets:{token:'synthetic-rollback-secret'}}) + f.previous=structuredClone(f.store.storedExtensionRecords()[0]) + f.metadata.version='0.2.0' + fs.writeFileSync(f.selectedWheel,'synthetic updated wheel bytes') + f.delivery={identity:{id:f.metadata.id,version:f.metadata.version,digest:createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex')},assertCurrent(){}} + f.reopen=()=>new ExtensionInstaller(f.root,f.directory,f.store,f.helper,()=> 'trusted-python',{},f.run,async()=>true, + undefined,undefined,undefined,undefined,'win32') + return f +} + +test('cold startup validates both retained versions before reading recovery authority', async t => { + const f = await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel, f.delivery) + const validated = new Set() + const store = new DesktopSettingsStore(f.file, path.join(f.directory, '.env'), process.platform, + metadata => validated.has(metadata.version) ? true : 'unavailable') + const seen = [] + const helper = { ...f.helper, refreshRecords: async records => { + for (const record of records) { seen.push(record.version); validated.add(record.version) } + } } + const reopened = new ExtensionInstaller(f.root, f.directory, store, helper, () => 'trusted-python', {}, f.run, async () => true, + undefined, undefined, undefined, undefined, 'win32') + assert.throws(() => reopened.pendingUpdate(), /validation is temporarily unavailable/) + await reopened.prepareStartup() + assert.deepEqual([...new Set(seen)].sort(), ['0.1.0', '0.2.0']) + assert.equal(reopened.pendingUpdate().recoveryRequired, true) + assert.throws(() => reopened.assertUpdateCanLaunch(), /rollback/) + await reopened.rollbackPendingUpdate() + assert.equal(store.storedExtensionRecords()[0].version, '0.1.0') +}) + +test('cold recovery distinguishes unavailable from rejected validation and preserves all recovery data', async t => { + const f = await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel, f.delivery) + const files = [f.file, f.file + '.bak', path.join(f.root, 'update-rollback.json')] + const before = files.map(file => fs.readFileSync(file)) + for (const result of ['unavailable', false]) { + const store = new DesktopSettingsStore(f.file, path.join(f.directory, '.env'), process.platform, () => result) + const reopened = new ExtensionInstaller(f.root, f.directory, store, f.helper, () => 'trusted-python', {}, f.run, async () => true, + undefined, undefined, undefined, undefined, 'win32') + const error = result === 'unavailable' ? /validation is temporarily unavailable/ : /Invalid plugin recovery package record/ + await assert.rejects(reopened.prepareStartup(), error) + assert.throws(() => reopened.pendingUpdate(), error) + assert.throws(() => reopened.assertUpdateCanLaunch(), error) + await assert.rejects(reopened.prepareLaunch(), error) + await assert.rejects(reopened.rollbackPendingUpdate(), error) + files.forEach((file, index) => assert.deepEqual(fs.readFileSync(file), before[index])) + assert.ok(fs.existsSync(path.join(f.root, f.previous.installId))) + assert.ok(fs.existsSync(path.join(f.root, JSON.parse(before[2]).target.installId))) + } +}) + +test('catalog update retains last-good wheel and encrypted config across process restart and explicit rollback',async t=>{ + const f=await catalogUpdateFixture(t) + delete f.metadata.config.token + delete f.metadata.config.streaming + await f.installer.install(f.selectedWheel,f.delivery) + const target=f.store.storedExtensionRecords()[0] + assert.equal(target.encryptedSecrets.token,undefined) + const journal=fs.readFileSync(path.join(f.root,'update-rollback.json'),'utf8') + assert.ok(!journal.includes('synthetic-rollback-secret')) + assert.equal(f.installer.pendingUpdate().canConfirm,false) + await assert.rejects(f.installer.confirmPendingUpdate(),/startup has not/) + const reopened=f.reopen() + assert.equal(reopened.pendingUpdate().recoveryRequired,true) + assert.throws(()=>reopened.assertUpdateCanLaunch(),/rollback/) + await assert.rejects(reopened.prepareLaunch(),/rollback/) + await reopened.cleanupStoppedBackend() + assert.ok(fs.existsSync(path.join(f.root,f.previous.installId)),'last-good environment must survive cleanup') + await reopened.rollbackPendingUpdate() + const restored=f.store.storedExtensionRecords()[0] + assert.equal(restored.installId,f.previous.installId) + assert.deepEqual(restored.values,f.previous.values) + assert.deepEqual(restored.encryptedSecrets,f.previous.encryptedSecrets) + assert.deepEqual(restored.providers,f.previous.providers) + assert.equal(f.store.extensionSecrets(restored).token,'synthetic-rollback-secret') + assert.equal(reopened.pendingUpdate(),undefined) + assert.equal(JSON.parse(fs.readFileSync(f.file+'.bak')).extensions[restored.id].installId,restored.installId) +}) + +test('health receipt retains rollback until explicit user keep; missing or failed startup cannot be kept',async t=>{ + for(const health of ['ready','missing','error']) { + const f=await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel,f.delivery) + const launch=await f.installer.prepareLaunch() + const target=f.store.storedExtensionRecords()[0] + const rows=health==='missing'?[]:[{id:target.id,version:target.version,revision:target.revision,startup:'accepted',state:health==='error'?'error':'active',contributions:[{kind:'mcp_server',id:'tools',health:health==='error'?'error':'ready'}]}] + await f.installer.acceptLaunch(launch,rows) + assert.equal(f.installer.pendingUpdate().canConfirm,health==='ready') + assert.ok(fs.existsSync(path.join(f.root,f.previous.installId))) + assert.ok(fs.existsSync(path.join(f.root,'update-rollback.json'))) + if(health==='ready') { + await f.installer.confirmPendingUpdate() + assert.equal(f.installer.pendingUpdate(),undefined) + } else { + assert.doesNotThrow(()=>f.installer.assertUpdateCanLaunch()) + await assert.rejects(f.installer.confirmPendingUpdate(),/startup has not/) + } + } +}) + +test('a committed-then-failed catalog update restores prior package, config and encrypted secrets',async t=>{ + const f=await catalogUpdateFixture(t) + const save=f.store.saveExtension.bind(f.store) + t.mock.method(f.store,'saveExtension',(...args)=>{save(...args);throw new Error('post-save failure')}) + await assert.rejects(f.installer.install(f.selectedWheel,f.delivery),/post-save failure/) + assert.equal(f.store.storedExtensionRecords()[0].installId,f.previous.installId) + assert.deepEqual(f.store.storedExtensionRecords()[0].encryptedSecrets,f.previous.encryptedSecrets) + assert.equal(f.installer.pendingUpdate(),undefined) + for(const file of [f.file,f.file+'.bak']) assert.equal(JSON.parse(fs.readFileSync(file)).extensions[f.previous.id].installId,f.previous.installId) +}) + +test('persistent primary-write failure keeps durable last-good recovery and both environments for a later explicit rollback',async t=>{ + const f=await catalogUpdateFixture(t) + const rename=fs.renameSync + t.mock.method(fs,'renameSync',(source,target)=>{ + if(target===f.file&&source===f.file+'.tmp')throw Object.assign(new Error('primary unavailable'),{code:'EACCES'}) + return rename(source,target) + }) + await assert.rejects(f.installer.install(f.selectedWheel,f.delivery),/primary unavailable/) + assert.equal(f.installer.pendingUpdate().recoveryRequired,true) + const journal=JSON.parse(fs.readFileSync(path.join(f.root,'update-rollback.json'))) + assert.equal(journal.previous.installId,f.previous.installId) + assert.ok(fs.existsSync(path.join(f.root,journal.target.installId))) + fs.renameSync=rename + const reopened=f.reopen() + await reopened.rollbackPendingUpdate() + assert.equal(f.store.storedExtensionRecords()[0].installId,f.previous.installId) + assert.deepEqual(f.store.storedExtensionRecords()[0].encryptedSecrets,f.previous.encryptedSecrets) +}) + +test('rollback rejects a damaged original wheel without discarding either version',async t=>{ + const f=await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel,f.delivery) + const oldDirectory=path.join(f.root,f.previous.installId) + const wheel=fs.readdirSync(oldDirectory).find(name=>name.startsWith('demo-')&&name.endsWith('.whl')) + fs.writeFileSync(path.join(oldDirectory,wheel),'tampered old wheel') + await assert.rejects(f.reopen().rollbackPendingUpdate(),/previous verified wheel/) + assert.equal(f.store.storedExtensionRecords()[0].version,'0.2.0') + assert.ok(f.installer.pendingUpdate()) +}) + + +test('rollback preserves newer safety bans and explicit disable cannot be undone by recovery',async t=>{ + const f=await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel,f.delivery) + f.store.commitExtensionSecurity(securityFor(f.previous,1)) + await f.installer.update({id:f.metadata.id,enabled:false,values:{},secrets:{},providers:['pi']}) + await f.reopen().rollbackPendingUpdate() + const restored=f.store.storedExtensionRecords()[0] + assert.equal(restored.enabled,false) + assert.match(f.store.extensionSecurityBlock(restored),/blocked/) + assert.deepEqual(f.store.extensionRecords()[0].encryptedSecrets,f.previous.encryptedSecrets) + assert.equal(f.store.extensionSecurityState().sequence,1) +}) + +test('a new safety ban cannot erase last-good recovery by accepting an earlier healthy receipt',async t=>{ + const f=await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel,f.delivery) + const launch=await f.installer.prepareLaunch(),target=f.store.storedExtensionRecords()[0] + await f.installer.acceptLaunch(launch,[{id:target.id,version:target.version,revision:target.revision,startup:'accepted',state:'active',contributions:[{kind:'mcp_server',id:'tools',health:'ready'}]}]) + f.store.commitExtensionSecurity(securityFor(target,1)) + await assert.rejects(f.installer.confirmPendingUpdate(),/blocked/) + assert.ok(f.installer.pendingUpdate()) + assert.ok(fs.existsSync(path.join(f.root,f.previous.installId))) +}) + +test('late health may permit explicit keep in the same process; another process cannot reuse that receipt',async t=>{ + const f=await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel,f.delivery) + const launch=await f.installer.prepareLaunch(),target=f.store.storedExtensionRecords()[0] + await f.installer.acceptLaunch(launch,[]) + const rows=[{id:target.id,version:target.version,revision:target.revision,startup:'accepted',state:'active',contributions:[{kind:'mcp_server',id:'tools',health:'ready'}]}] + await f.installer.refreshUpdateHealth(rows) + assert.equal(f.installer.pendingUpdate().canConfirm,true) + const reopened=f.reopen() + await reopened.refreshUpdateHealth(rows) + assert.equal(reopened.pendingUpdate().canConfirm,false) + assert.equal(reopened.pendingUpdate().recoveryRequired,true) +}) + +test('native rollback cancellation leaves the new state and both versions intact',async t=>{ + const f=await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel,f.delivery) + const rejecting=new ExtensionInstaller(f.root,f.directory,f.store,f.helper,()=> 'trusted-python',{},f.run,async()=>false, + undefined,undefined,undefined,undefined,'win32') + await assert.rejects(rejecting.rollbackPendingUpdate(),/cancelled/) + assert.equal(f.store.storedExtensionRecords()[0].version,'0.2.0') + assert.ok(rejecting.pendingUpdate()) + assert.ok(fs.existsSync(path.join(f.root,f.previous.installId))) +}) + + +test('failed rollback journal persistence fences the same process and retains recovery across restart',async t=>{ + const f=await catalogUpdateFixture(t) + const save=f.store.saveExtension.bind(f.store) + t.mock.method(f.store,'saveExtension',(...args)=>{save(...args);throw new Error('after-save fault')}) + t.mock.method(f.store,'restoreExtension',()=>{throw new Error('rollback write unavailable')}) + const rename=fs.renameSync + let journalWrites=0 + t.mock.method(fs,'renameSync',(source,target)=>{ + if(String(target).endsWith('update-rollback.json')&&++journalWrites>1)throw new Error('journal write unavailable') + return rename(source,target) + }) + await assert.rejects(f.installer.install(f.selectedWheel,f.delivery),/after-save fault/) + assert.equal(f.installer.pendingUpdate().recoveryRequired,true) + assert.throws(()=>f.installer.assertUpdateCanLaunch(),/rollback/) + assert.throws(()=>f.reopen().assertUpdateCanLaunch(),/rollback/) + assert.ok(fs.existsSync(path.join(f.root,f.previous.installId))) + assert.ok(fs.existsSync(path.join(f.root,f.store.storedExtensionRecords()[0].installId))) +}) + + +test('rollback rechecks retained wheel bytes after the native confirmation', async t => { + const f = await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel, f.delivery) + const oldDirectory = path.join(f.root, f.previous.installId) + const wheel = fs.readdirSync(oldDirectory).find(name => name.startsWith('demo-') && name.endsWith('.whl')) + const racing = new ExtensionInstaller(f.root, f.directory, f.store, f.helper, () => 'trusted-python', {}, f.run, + async () => { fs.writeFileSync(path.join(oldDirectory, wheel), 'changed during native confirmation'); return true }, + undefined, undefined, undefined, undefined, 'win32') + await assert.rejects(racing.rollbackPendingUpdate(), /changed during confirmation/) + assert.equal(f.store.storedExtensionRecords()[0].version, '0.2.0') + assert.ok(racing.pendingUpdate()) + assert.ok(fs.existsSync(oldDirectory)) +}) + + +test('frontend-only queued update uses its native applied revision without inventing a backend contribution', async t => { + const f = fixture(t, {manifest:themeManifest()}) + await f.installer.install(f.selectedWheel) + await f.installer.update({id:f.metadata.id,enabled:true}) + const previous = f.store.storedExtensionRecords()[0] + f.metadata.version = '0.2.0' + fs.writeFileSync(f.selectedWheel, 'updated frontend wheel') + const delivery = {catalog:false,identity:{id:f.metadata.id,version:f.metadata.version,digest:createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex')},assertCurrent(){}} + await f.installer.install(f.selectedWheel, delivery) + const launch = await f.installer.prepareLaunch() + assert.deepEqual(JSON.parse(launch.extensions), []) + await f.installer.acceptLaunch(launch, []) + assert.equal(f.installer.pendingUpdate().canConfirm, true) + assert.ok(fs.existsSync(path.join(f.root, previous.installId))) + await f.installer.confirmPendingUpdate() + assert.equal(f.installer.pendingUpdate(), undefined) +}) + +test('partial or duplicate backend contribution health cannot discard the recovery version', async t => { + const f = await catalogUpdateFixture(t) + f.metadata.contributes.mcp_server.push({...f.metadata.contributes.mcp_server[0],id:'more-tools'}) + await f.installer.install(f.selectedWheel, f.delivery) + const launch = await f.installer.prepareLaunch(), target = f.store.storedExtensionRecords()[0] + const row = {id:target.id,version:target.version,revision:target.revision,startup:'accepted',state:'active'} + const ready = {kind:'mcp_server',id:'tools',health:'ready'} + for (const contributions of [[ready], [ready, ready]]) { + await f.installer.acceptLaunch(launch, [{...row,contributions}]) + assert.equal(f.installer.pendingUpdate().canConfirm, false) + } + await f.installer.refreshUpdateHealth([{...row,contributions:[ready,{...ready,id:'more-tools'}]}]) + assert.equal(f.installer.pendingUpdate().canConfirm, true) +}) + +function healthyUpdateRow(record) { + return {id: record.id, version: record.version, revision: record.revision, + startup: 'accepted', state: 'active', contributions: [{kind: 'mcp_server', id: 'tools', health: 'ready'}]} +} + +test('unclean update exit can restore the retained version across launches without granting file cleanup', async t => { + const f = await catalogUpdateFixture(t) + const {ExtensionCatalog} = loadTypeScript(new URL('../src/main/extensionCatalog.ts', import.meta.url), mocks) + const {ExtensionUpdateBoundary} = loadTypeScript(new URL('../src/main/extensionUpdates.ts', import.meta.url)) + const {prepareBackendCleanup, verifyPreviousBackendCleanup} = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) + const cleanupFile = path.join(f.directory, 'backend-cleanup.json') + let boot = 10 + const getBootId = async () => boot + const verify = () => verifyPreviousBackendCleanup(cleanupFile, {getBootId}) + // Execute index.ts's real boundary wiring: the original deadlock was at this + // composition boundary, invisible to a direct installer rollback unit test. + const source = fs.readFileSync(new URL('../src/main/index.ts', import.meta.url), 'utf8') + const wiring = source.slice(source.indexOf('const extensionRecoveryIdle ='), source.indexOf('const extensionInstaller =')) + assert.ok(wiring.includes('new ExtensionUpdateBoundary')) + function startApp(liveBackend = false) { + const context = {ExtensionUpdateBoundary, pythonProcess: liveBackend ? {pid: 12345} : null, + backendOwned: liveBackend, backendStarting: null, backendStopping: null, + applicationLifecycle: {isQuitting: false}, extensionCleanupConfirmed: true, + BACKEND_CLEANUP_STATE: cleanupFile, BACKEND_PORT: 17777, + verifyPreviousBackendCleanup: verify, localPortIsClosed: async () => !liveBackend} + const boundary = new Function(...Object.keys(context), wiring + '\nreturn extensionUpdateBoundary')(...Object.values(context)) + const store = new DesktopSettingsStore(f.file, path.join(f.directory, '.env'), 'win32', () => true) + const installer = new ExtensionInstaller(f.root, f.directory, store, f.helper, () => 'trusted-python', {}, f.run, async () => true, + undefined, undefined, undefined, boundary.assertHeld, 'win32', boundary.assertRecoveryHeld) + const catalog = new ExtensionCatalog(path.join(f.directory, 'catalog'), store, f.helper, installer, () => '', () => {}, + async () => assert.fail('local recovery must not access the network'), () => {}, 'win32') + return {boundary, store, installer, catalog} + } + const installing = startApp() + await installing.installer.prepareStartup() + await installing.catalog.scheduleLocal(f.selectedWheel) + await installing.catalog.applyScheduled(installing.boundary) + const target = installing.store.storedExtensionRecords()[0] + await prepareBackendCleanup(cleanupFile, {nonce: 'synthetic-crashed-backend', projectRoot: f.directory, pythonCommand: 'python'}, 'win32', getBootId) + assert.equal(await verify(), false) + const recovery = startApp() + await recovery.installer.prepareStartup() + await assert.rejects(recovery.installer.prepareLaunch(), /rollback/) + assert.equal((await recovery.catalog.dispatch({action: 'rollback'}, {}, async () => {})).scheduled, true) + const live = startApp(true) + await assert.rejects(live.catalog.applyScheduled(live.boundary), /backend is still active/) + assert.equal(live.catalog.hasScheduled(), true) + const next = startApp() + await next.installer.prepareStartup() + const ledgerBefore = fs.readFileSync(cleanupFile) + await next.catalog.applyScheduled(next.boundary) + assert.deepEqual(fs.readFileSync(cleanupFile), ledgerBefore) + assert.equal(next.installer.pendingUpdate(), undefined) + assert.deepEqual(next.store.storedExtensionRecords()[0], f.previous) + for (let launch = 0; launch < 3; launch++) { + const app = startApp() + await app.installer.prepareStartup() + assert.equal(JSON.parse((await app.installer.prepareLaunch()).extensions)[0].version, '0.1.0') + await assert.rejects(app.boundary.run(() => app.installer.install(f.selectedWheel)), /active or unconfirmed/) + assert.ok(fs.existsSync(path.join(f.root, target.installId))) + assert.ok(fs.existsSync(path.join(f.root, f.previous.installId))) + } + await next.installer.remove(f.previous.id) + assert.equal(await verify(), false) + assert.ok(fs.existsSync(path.join(f.root, f.previous.installId)), 'pending removal waits for physical cleanup') + boot++ + assert.equal(await verify(), true) + await next.installer.cleanupStoppedBackend() + assert.equal(fs.existsSync(path.join(f.root, f.previous.installId)), false) +}) + +test('restoring a retained theme does not incidentally collect either version', async t => { + const f = fixture(t, {manifest: themeManifest()}) + await f.installer.install(f.selectedWheel) + await f.installer.update({id: f.metadata.id, enabled: true}) + const previous = f.store.storedExtensionRecords()[0] + f.metadata.version = '0.2.0' + fs.writeFileSync(f.selectedWheel, 'synthetic new theme') + await f.installer.install(f.selectedWheel, {identity: {id: f.metadata.id, version: '0.2.0', digest: createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex')}, assertCurrent() {}}) + const target = f.store.storedExtensionRecords()[0] + await f.installer.cleanupStoppedBackend() + await f.installer.rollbackPendingUpdate() + assert.equal(f.store.storedExtensionRecords()[0].installId, previous.installId) + for (const record of [previous, target]) assert.ok(fs.existsSync(path.join(f.root, record.installId))) +}) + +for (const platform of ['linux', 'darwin']) for (const source of ['local', 'catalog']) { + test(`${platform} ${source} install retains the running version and applies updates without Windows recovery state`, async t => { + const f = fixture(t, {manifest: manifest('mcp_server'), platform}) + const {prepareBackendCleanup} = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) + const stateFile = path.join(f.directory, 'backend-cleanup.json') + const registration = await prepareBackendCleanup(stateFile, {nonce: 'synthetic-nonce', projectRoot: f.directory, pythonCommand: 'python'}, platform) + assert.deepEqual(registration.environment, {}) + const delivery = () => source === 'catalog' ? {identity: {id: f.metadata.id, version: f.metadata.version, + digest: createHash('sha256').update(fs.readFileSync(f.selectedWheel)).digest('hex')}, assertCurrent() {}} : undefined + await f.installer.install(f.selectedWheel, delivery()) + await f.installer.update({id: f.metadata.id, enabled: true, providers: ['pi'], secrets: {token: 'synthetic-token'}}) + const previous = f.store.storedExtensionRecords()[0] + await f.installer.acceptLaunch(await f.installer.prepareLaunch(), [healthyUpdateRow(previous)]) + f.metadata.version = '0.2.0' + fs.writeFileSync(f.selectedWheel, 'synthetic next wheel') + await f.installer.install(f.selectedWheel, delivery()) + const current = f.store.storedExtensionRecords()[0] + assert.equal(current.version, '0.2.0') + assert.equal(f.store.appliedExtensionRecords()[0].installId, previous.installId) + assert.ok(fs.existsSync(path.join(f.root, previous.installId))) + assert.equal(f.installer.pendingUpdate(), undefined) + assert.equal(fs.existsSync(path.join(f.root, 'update-rollback.json')), false) + assert.equal(fs.existsSync(stateFile), false) + assert.equal(f.calls.filter(call => call.command === 'confirm' && call.review.operation === 'update').length, 1) + await f.installer.cleanupStoppedBackend() + await f.installer.acceptLaunch(await f.installer.prepareLaunch(), [healthyUpdateRow(current)]) + assert.equal(f.store.appliedExtensionRecords()[0].installId, current.installId) + await assert.rejects(f.installer.rollbackPendingUpdate(), /only available on Windows/) + }) +} + +for (const secret of [true, false]) { + test(`pending update can complete a new required ${secret ? 'secret' : 'value'} and validate only the resulting revision`, async t => { + const f = await catalogUpdateFixture(t) + f.metadata.config.added = {type: 'string', title: 'New required configuration', required: true, secret} + await f.installer.install(f.selectedWheel, f.delivery) + const target = f.store.storedExtensionRecords()[0] + const initial = await f.installer.prepareLaunch() + assert.ok(JSON.parse(initial.extensions)[0].startup_error) + await f.installer.acceptLaunch(initial, [{...healthyUpdateRow(target), startup: 'rejected', state: 'needs_config'}]) + assert.equal(f.installer.pendingUpdate().canConfirm, false) + await assert.rejects(f.installer.update({id: target.id, values: {}, secrets: {}}), /required configuration/) + await assert.rejects(f.installer.confirmPendingUpdate(), /startup has not/) + await f.installer.update({id: target.id, [secret ? 'secrets' : 'values']: {added: 'synthetic-new-value'}}) + const configured = f.store.storedExtensionRecords()[0] + assert.ok(configured.revision > target.revision) + const journal = JSON.parse(fs.readFileSync(path.join(f.root, 'update-rollback.json'), 'utf8')) + assert.deepEqual(journal.previous, f.previous) + assert.deepEqual(journal.target, configured) + if (secret) assert.ok(!JSON.stringify(journal).includes('synthetic-new-value')) + await f.installer.refreshUpdateHealth([healthyUpdateRow(target)]) + assert.equal(f.installer.pendingUpdate().canConfirm, false, 'previous revision cannot validate changed configuration') + const launch = await f.installer.prepareLaunch() + const sent = JSON.parse(launch.extensions)[0] + assert.equal(sent.startup_error, undefined) + assert.equal((secret ? sent.secrets : sent.config).added, 'synthetic-new-value') + await f.installer.acceptLaunch(launch, [healthyUpdateRow(configured)]) + assert.equal(f.installer.pendingUpdate().canConfirm, true) + await f.installer.confirmPendingUpdate() + assert.equal(f.installer.pendingUpdate(), undefined) + }) +} + +test('editing a healthy pending update invalidates its receipt and rollback preserves the original configuration and disable', async t => { + const f = await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel, f.delivery) + const launch = await f.installer.prepareLaunch(), before = f.store.storedExtensionRecords()[0] + await f.installer.acceptLaunch(launch, [healthyUpdateRow(before)]) + assert.equal(f.installer.pendingUpdate().canConfirm, true) + await f.installer.update({id: before.id, values: {streaming: true}, secrets: {token: 'replacement-token'}}) + assert.equal(f.installer.pendingUpdate().canConfirm, false) + await f.installer.refreshUpdateHealth([healthyUpdateRow(before)]) + await assert.rejects(f.installer.confirmPendingUpdate(), /startup has not/) + await f.installer.update({id: before.id, enabled: false}) + await f.installer.refreshUpdateHealth([]) + assert.equal(f.installer.pendingUpdate().recoveryRequired, true) + await f.reopen().rollbackPendingUpdate() + const restored = f.store.storedExtensionRecords()[0] + assert.equal(restored.enabled, false) + assert.deepEqual(restored.values, f.previous.values) + assert.deepEqual(restored.encryptedSecrets, f.previous.encryptedSecrets) +}) + +test('configuration edits cannot resume an update from another desktop or a failed backend startup', async t => { + const f = await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel, f.delivery) + const edits = {id: f.metadata.id, values: {streaming: true}} + await assert.rejects(f.reopen().update(edits), /Roll back/) + f.installer.markUpdateFailed() + await assert.rejects(f.installer.update(edits), /Roll back/) + assert.deepEqual(f.store.storedExtensionRecords()[0].values, f.previous.values) +}) + +for (const fault of ['settings', 'journal']) { + test(`pending configuration ${fault} persistence failure retains rollback and cannot reuse health`, async t => { + const f = await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel, f.delivery) + const launch = await f.installer.prepareLaunch(), before = f.store.storedExtensionRecords()[0] + await f.installer.acceptLaunch(launch, [healthyUpdateRow(before)]) + if (fault === 'settings') { + const save = f.store.saveExtension.bind(f.store) + t.mock.method(f.store, 'saveExtension', (...args) => { save(...args); throw Error('settings failure') }) + } else { + const rename = fs.renameSync + let writes = 0 + t.mock.method(fs, 'renameSync', (source, target) => { + if (String(target).endsWith('update-rollback.json') && ++writes === 2) throw Error('journal failure') + return rename(source, target) + }) + } + await assert.rejects(f.installer.update({id: before.id, values: {streaming: true}}), /failure/) + assert.throws(() => f.installer.assertUpdateCanLaunch(), /rollback/) + await f.installer.refreshUpdateHealth([healthyUpdateRow(before)]) + await assert.rejects(f.installer.confirmPendingUpdate(), /startup has not/) + const reopened = f.reopen() + assert.throws(() => reopened.assertUpdateCanLaunch(), /rollback/) + await reopened.rollbackPendingUpdate() + assert.deepEqual(f.store.storedExtensionRecords()[0].values, f.previous.values) + assert.deepEqual(f.store.storedExtensionRecords()[0].encryptedSecrets, f.previous.encryptedSecrets) + }) +} diff --git a/electron/tests/fixtures/extensionRelease.cjs b/electron/tests/fixtures/extensionRelease.cjs index e327097b..ecf451c8 100644 --- a/electron/tests/fixtures/extensionRelease.cjs +++ b/electron/tests/fixtures/extensionRelease.cjs @@ -1,22 +1,78 @@ // Loaded only through the owned smoke process inspector; never shipped. +function captureFixtureBackend(childProcess, syncExports, nonce) { + if (typeof nonce !== 'string' || nonce.length < 16) throw Error('Missing fixture backend identity') + const spawn = childProcess.spawn + let backend + childProcess.spawn = (command, args, options) => { + const child = spawn(command, args, options) + if (args?.[0] === '-m' && args?.[1] === 'server.app' + && options?.env?.AMADEUS_BACKEND_INSTANCE_NONCE === nonce) backend = child + return child + } + // The shipping main module imports spawn as an ESM named binding. + syncExports() + return async () => { + const child = backend + if (!child || child.exitCode !== null || child.signalCode !== null) throw Error('No live owned fixture backend') + return await new Promise((resolve, reject) => { + const exited = () => { child.off('error', failed); resolve(true) } + const failed = error => { child.off('exit', exited); child.off('error', failed); reject(error) } + child.once('exit', exited) + child.once('error', failed) + if (!child.kill()) failed(Error('Owned backend termination failed')) + }) + } +} + 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 {app, net, session, dialog} = require('electron') + const fs = require('node:fs'), path = require('node:path') + const {spawn} = require('node:child_process') const {X509Certificate} = require('node:crypto') const fixture = JSON.parse(fs.readFileSync(path.join(base, 'fixture.json'))) const state = globalThis.extensionReleaseFixture = {fixture, requests:[], confirmations:0, offline:false, badWheel:false, holdWheel:false, index:{...fixture.index, entries:fixture.index.entries.filter(e=>e.version==='0.1.0')}} - const server = https.createServer({key:fs.readFileSync(path.join(base,'key.pem')), cert:fs.readFileSync(path.join(base,'cert.pem'))}, (req,res)=>{ - state.requests.push(req.url) - if(state.offline){res.writeHead(503);res.end();return} - if(req.url==='/index.json'){res.end(JSON.stringify(state.index));return} - const wheel=fixture.wheels[decodeURIComponent(req.url.split('/').at(-1))] - if(!wheel){res.writeHead(404);res.end();return} - const bytes=fs.readFileSync(wheel);if(state.badWheel)bytes[0]^=255;res.writeHead(200,{'Content-Length':bytes.length});if(state.holdWheel){res.write(bytes.subarray(0,Math.max(1,Math.floor(bytes.length/2))));return}res.end(bytes) + state.crashBackend = captureFixtureBackend(require('node:child_process'), + require('node:module').syncBuiltinESMExports, process.env.AMADEUS_BACKEND_INSTANCE_NONCE) + // Keep the TLS server out of the inspected packaged main process, where the + // in-process fixture exhibited native crashes and intermittent TLS failures. + // The actual Electron net client and its certificate verification still run. + const server = spawn(process.execPath, ['-e', ` + const fs=require('node:fs'),https=require('node:https'); + const base=${JSON.stringify(base)},path=require('node:path'); + let next=0;const pending=new Map(); + const server=https.createServer({key:fs.readFileSync(path.join(base,'key.pem')),cert:fs.readFileSync(path.join(base,'cert.pem'))},(req,res)=>{ + const id=++next;pending.set(id,res);process.send({id,url:req.url}); + res.on('close',()=>pending.delete(id)); + }); + process.on('message',s=>{ + const res=pending.get(s.id);if(!res)return; + if(s.offline){res.writeHead(503);res.end();return} + if(s.index){res.end(JSON.stringify(s.index));return} + if(!s.wheel){res.writeHead(404);res.end();return} + const bytes=fs.readFileSync(s.wheel);if(s.badWheel)bytes[0]^=255; + res.writeHead(200,{'Content-Length':bytes.length}); + if(s.holdWheel){res.write(bytes.subarray(0,Math.max(1,Math.floor(bytes.length/2))));return} + res.end(bytes); + }); + process.on('disconnect',()=>{server.closeAllConnections();server.close(()=>process.exit(0))}); + server.listen(0,'127.0.0.1',()=>process.send({origin:'https://localhost:'+server.address().port})); + `], {env:{SystemRoot:process.env.SystemRoot,WINDIR:process.env.WINDIR,ELECTRON_RUN_AS_NODE:'1'},windowsHide:true,stdio:['ignore','ignore','pipe','ipc']}) + server.stderr.on('data',data=>console.error('release fixture:',String(data))) + const origin=await new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>reject(Error('Release fixture server did not start')),10000) + server.once('error',error=>{clearTimeout(timer);reject(error)}) + server.once('exit',code=>{clearTimeout(timer);reject(Error('Release fixture server exited: '+code))}) + server.on('message',request=>{ + if(request.origin){clearTimeout(timer);resolve(request.origin);return} + state.requests.push(request.url) + server.send({id:request.id,offline:state.offline,badWheel:state.badWheel,holdWheel:state.holdWheel, + index:request.url==='/index.json'?state.index:undefined, + wheel:fixture.wheels[decodeURIComponent(request.url.split('/').at(-1))]}) + }) }) - await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)) - const origin='https://localhost:'+server.address().port + app.once('will-quit',()=>{if(server.connected)server.disconnect()}) 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) diff --git a/pyproject.toml b/pyproject.toml index 04af3486..afff7d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=83,<84"] +requires = ["setuptools>=84.2.1,<85"] build-backend = "setuptools.build_meta" [project] @@ -154,7 +154,7 @@ dev = [ "pytest==8.4.2", "pytest-asyncio==1.4.0", "ruff==0.16.3", - "setuptools==83.0.0", + "setuptools==84.2.1", "wheel==0.47.0", ] @@ -166,6 +166,9 @@ extension-authoring = ["jsonschema", "packaging", "PyYAML"] # configurable; this only selects the packages in the current project environment. [tool.uv] required-version = ">=0.12.8" +# Keep model/cache consumers on the security-fixed filelock line without +# adding optional model dependencies to the T1 core environment. +constraint-dependencies = ["filelock>=4.2.4,<5"] conflicts = [ [ { extra = "torch-cpu" }, diff --git a/server/app.py b/server/app.py index 1a03740c..c4c2f1c8 100644 --- a/server/app.py +++ b/server/app.py @@ -31,6 +31,11 @@ # inference. This must run before anything imports torch. os.environ.setdefault("KMP_BLOCKTIME", "1") +# Contain descendants before any eager runtime import can create a child. This +# Job only tracks lifetime; it has no kill-on-close or breakaway policy flags. +from server.backend_cleanup import begin_from_environment +_BACKEND_CLEANUP = begin_from_environment() if __name__ == "__main__" else None + # The launch snapshot contains package-scoped credentials. Remove it before # eager model/runtime imports can create any child process. from server.extensions.snapshot import pop_snapshot_environment @@ -2459,16 +2464,19 @@ async def _send_speculative_pending(text: str, **kw) -> dict: work_status_narrator = None async def _close_runtime() -> None: + teardown_succeeded = True vts_worker_stop.set() # Stop external requests before shared playback and Provider teardown. await asyncio.to_thread(extension_runtime.close) try: await chat_h.close() except Exception: + teardown_succeeded = False logger.exception("Chat shutdown failed; continuing shared-resource cleanup") try: await cooperative_chat.begin_close() except Exception: + teardown_succeeded = False logger.exception( "cooperative Chat stop preparation failed; continuing shared-resource cleanup" ) @@ -2493,11 +2501,12 @@ async def _close_runtime() -> None: provider_runtime.set_automatic_permission_authorizer(None) provider_runtime.set_start_admission_validator(None) try: - await provider_runtime.close() + provider_cleanup = await provider_runtime.close() finally: try: await cooperative_chat.finish_close() except Exception: + teardown_succeeded = False logger.exception( "cooperative Chat observation drain failed after Provider close" ) @@ -2515,6 +2524,8 @@ async def _close_runtime() -> None: await work_ledger.drain_provider_facts() await work_observer.close() await work_preview.close_all() + work_settled = (not work_ledger.store.list_unresolved_provider_attempts(limit=1) + and not work_ledger.store.list_writer_leases(active_only=True)) work_ledger.close() _stop_gui_render_runtime() close_voice_input_services(wake_service, asr_manager) @@ -2523,7 +2534,14 @@ async def _close_runtime() -> None: await asyncio.to_thread(stop_llama_server) except Exception: + teardown_succeeded = False logger.exception("failed to stop managed llama-server") + if _BACKEND_CLEANUP is not None: + _BACKEND_CLEANUP.record_runtime_cleanup( + providers_settled=provider_cleanup.get("settled") is True, + work_settled=work_settled, + teardown_succeeded=teardown_succeeded, + ) # Retain the whole dependency-ordered teardown, not only its first close. # Repeated cancellation of the bootstrap caller cannot skip later owners. @@ -3245,3 +3263,8 @@ def _noop_warmup() -> str: parser.add_argument("--port", type=int, default=17777) args = parser.parse_args() asyncio.run(bootstrap(port=args.port)) + if _BACKEND_CLEANUP is not None: + # asyncio.run has also drained its executor and async generators. No + # further child launches are allowed after the final Job observation. + if not _BACKEND_CLEANUP.finish(): + logger.warning("graceful backend teardown was not settled; Work recovery remains pending and process exit is verified independently") diff --git a/server/backend_cleanup.py b/server/backend_cleanup.py new file mode 100644 index 00000000..746abd56 --- /dev/null +++ b/server/backend_cleanup.py @@ -0,0 +1,370 @@ +"""Desktop-owned cleanup evidence and explicit owned-Job termination. + +Install the tracking Job before eager runtime imports. The outside observer +certifies physical exit independently of graceful teardown or Work outcomes. +Missing containment/observer evidence remains blocked until a proven later boot. +""" +from __future__ import annotations + +import ctypes +from ctypes import wintypes +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time +import uuid + +LAUNCH_ENV = "AMADEUS_BACKEND_CLEANUP_LAUNCH" +SCHEMA = "amadeus.backend-cleanup.v1" + + +def _ordinary_path(path: Path, root: Path) -> None: + if not path.is_absolute() or ".." in path.parts: + raise ValueError("cleanup path must be absolute") + relative = path.relative_to(root) + # The data root itself and its descendants cannot be reparse points. A + # junction above the root is a supported relocated user profile. + for item in (root, *(root.joinpath(*relative.parts[:i]) for i in range(1, len(relative.parts) + 1))): + try: + info = item.lstat() + except FileNotFoundError: + continue + if item.is_symlink() or getattr(info, "st_file_attributes", 0) & 0x400: + raise ValueError("cleanup path cannot traverse a reparse point") + + +def _atomic_json(path: Path, value: dict, root: Path) -> None: + _ordinary_path(path, root) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + # The desktop creates the receipt directory before registering the launch. + with temporary.open("x", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, ensure_ascii=True, separators=(",", ":")) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + _ordinary_path(path, root) + if os.name == "nt": + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD] + kernel.MoveFileExW.restype = wintypes.BOOL + if not kernel.MoveFileExW(str(temporary), str(path), 0x8): # WRITE_THROUGH, never replace + raise ctypes.WinError(ctypes.get_last_error()) + else: + os.link(temporary, path) + temporary.unlink() + descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +class TrackingJob: + """A Windows containment counter, with no termination or breakaway flags. + + Nested-job accounting includes MCP/Provider child jobs. Closing this handle + cannot kill their processes. External HTTP daemons are never assigned. + """ + + def __init__(self, name: str, *, kernel=None, assign=True): + if kernel is None: + if os.name != "nt": + raise OSError("cleanup proof requires Windows Job accounting") + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + kernel.CreateJobObjectW.restype = wintypes.HANDLE + kernel.GetCurrentProcess.restype = wintypes.HANDLE + kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + kernel.AssignProcessToJobObject.restype = wintypes.BOOL + kernel.QueryInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, + ctypes.c_void_p, wintypes.DWORD, + ctypes.c_void_p] + kernel.QueryInformationJobObject.restype = wintypes.BOOL + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + self.kernel = kernel + self.name = name + if os.name == "nt": + ctypes.set_last_error(0) + self.handle = kernel.CreateJobObjectW(None, name) + if not self.handle: + raise OSError("cannot create backend tracking Job") + if kernel is not None and os.name == "nt" and ctypes.get_last_error() == 183: + self.close() + raise OSError("backend tracking Job identity already exists") + # A new Job has zero limits: no KILL_ON_JOB_CLOSE, BREAKAWAY_OK or + # SILENT_BREAKAWAY_OK. Never call SetInformationJobObject here. + if assign: + self.assign_current() + + def assign_current(self) -> None: + if not self.kernel.AssignProcessToJobObject(self.handle, self.kernel.GetCurrentProcess()): + self.close() + raise OSError("cannot assign backend tracking Job") + + def only_backend_remains(self) -> bool: + try: + return self.active_processes() == 1 + except OSError: + return False + + def active_processes(self) -> int: + class Accounting(ctypes.Structure): + _fields_ = [(name, ctypes.c_int64) for name in ( + "TotalUserTime", "TotalKernelTime", "ThisPeriodTotalUserTime", + "ThisPeriodTotalKernelTime")] + [(name, wintypes.DWORD) for name in ( + "TotalPageFaultCount", "TotalProcesses", "ActiveProcesses", + "TotalTerminatedProcesses")] + information = Accounting() + if not self.handle or not self.kernel.QueryInformationJobObject( + self.handle, 1, ctypes.byref(information), ctypes.sizeof(information), None): + raise OSError("backend tracking Job accounting is unavailable") + return information.ActiveProcesses + + def close(self) -> None: + if self.handle: + self.kernel.CloseHandle(self.handle) + self.handle = None + + +def _creation_identity() -> str: + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.GetCurrentProcess.restype = wintypes.HANDLE + kernel.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + times = [wintypes.FILETIME() for _ in range(4)] + if not kernel.GetProcessTimes(kernel.GetCurrentProcess(), *(ctypes.byref(item) for item in times)): + raise OSError("backend creation identity is unavailable") + return str(times[0].dwLowDateTime | times[0].dwHighDateTime << 32) + + +class BackendCleanup: + def __init__(self, launch: dict, *, job_factory=None, + identity_factory=_creation_identity, observer_factory=None): + self.launch = launch + self.job = None + self.creation = None + self.runtime_settled = False + self.failure: str | None = None + try: + owner = launch["owner"] + if (launch.get("schema") != SCHEMA or not isinstance(owner, dict) + or set(owner) != {"id", "nonce", "project_root", "python_command"} + or not re.fullmatch(r"[0-9a-f]{32}", owner["id"]) + or not isinstance(owner["nonce"], str) or len(owner["nonce"]) < 16 + or Path(owner["project_root"]).resolve() != Path(__file__).resolve().parents[1] + or not isinstance(owner["python_command"], str) or not owner["python_command"]): + raise ValueError("invalid desktop cleanup launch identity") + nonce = os.environ.get("AMADEUS_BACKEND_INSTANCE_NONCE") + if nonce is not None and nonce != owner["nonce"]: + raise ValueError("cleanup launch does not match authenticated desktop") + command = shutil.which(owner["python_command"]) or owner["python_command"] + if Path(command).resolve() != Path(sys.executable).resolve(): + raise ValueError("cleanup launch Python executable does not match") + state_path = Path(launch["state_path"]) + self.data_root = state_path.parent + _ordinary_path(state_path, self.data_root) + self.receipt = Path(str(state_path) + ".receipts") / f"{owner['id']}.json" + self.approval = self.receipt.with_suffix(".approval.json") + _ordinary_path(self.receipt, self.data_root) + index = json.loads(state_path.read_text(encoding="utf-8")) + if index.get("schema") != SCHEMA or owner not in index.get("owners", []): + raise ValueError("cleanup launch is not registered by desktop") + self.job_name = f"Global\\AmadeusBackendCleanup-{owner['id']}" + self.creation = identity_factory() + self.job = (job_factory or (lambda name: TrackingJob(name, assign=False)))(self.job_name) + # The observer starts outside the Job. It keeps a query handle after + # the backend exits: named Jobs cannot be reopened reliably once + # their last handle closes, even when descendants are still alive. + (observer_factory or _start_observer)(self) + self.job.assign_current() + # The observer starts before assignment so it is outside the Job. + # An empty Job is evidence only after this root actually joined it. + _atomic_json(self.receipt.with_suffix(".tracking.json"), { + "schema": SCHEMA, "owner": owner, + "process": {"pid": os.getpid(), "created": self.creation}, + "job_name": self.job_name, + }, self.data_root) + except Exception as exc: + # Ordinary backend startup may continue; unknown containment never + # authorizes package replacement and is visible in its pending owner. + self.failure = type(exc).__name__ + + def record_runtime_cleanup(self, *, providers_settled: bool, work_settled: bool, + teardown_succeeded: bool) -> None: + self.runtime_settled = (providers_settled is True and work_settled is True + and teardown_succeeded is True) + + def finish(self) -> bool: + # Optional graceful-teardown diagnostics, not file-cleanup authority. + # Orphaned Work remains unresolved in its own ledger after process exit. + if (self.failure or not self.runtime_settled or self.job is None + or not self.creation or not self.job.only_backend_remains()): + return False + _atomic_json(self.approval, { + "schema": SCHEMA, "owner": self.launch["owner"], "state": "settled", + "process": {"pid": os.getpid(), "created": self.creation}, + "job_name": self.job_name, + "proof": "windows-job-drained", "providers_settled": True, + "work_settled": True, + }, self.data_root) + # Keep the non-inheritable tracking handle open until process exit. + # Nothing after this call may launch another child. + return True + + +def begin_from_environment() -> BackendCleanup | None: + raw = os.environ.pop(LAUNCH_ENV, "") + if not raw: + return None + if len(raw) > 64 * 1024: + raise ValueError("cleanup launch exceeds limit") + return BackendCleanup(json.loads(raw)) + + +def _start_observer(proof: BackendCleanup) -> None: + request = {"launch": proof.launch, "process": {"pid": os.getpid(), "created": proof.creation}, + "job_name": proof.job_name} + environment = {key: value for key, value in os.environ.items() + if key.upper() in {"SYSTEMROOT", "WINDIR"}} + child = subprocess.Popen([sys.executable, "-B", str(Path(__file__).resolve()), "--observe", + json.dumps(request, separators=(",", ":"))], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=environment, **({"creationflags": subprocess.CREATE_NO_WINDOW} + if os.name == "nt" else {})) + ready = proof.receipt.with_suffix(".observer-ready.json") + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if ready.exists(): + _ordinary_path(ready, proof.data_root) + identity = json.loads(ready.read_text(encoding="utf-8")) + if identity.get("job_name") != proof.job_name or identity.get("owner") != proof.launch["owner"]: + raise ValueError("cleanup observer identity mismatch") + return + if child.poll() is not None: + raise OSError("cleanup observer exited before its query handle was ready") + time.sleep(.01) + raise TimeoutError("cleanup observer did not become ready") + + +def _observe(request: dict) -> None: + """No application imports, credentials, provider work or process termination.""" + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.OpenJobObjectW.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR] + kernel.OpenJobObjectW.restype = wintypes.HANDLE + kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel.OpenProcess.restype = wintypes.HANDLE + kernel.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel.WaitForSingleObject.restype = wintypes.DWORD + kernel.QueryInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, + wintypes.DWORD, ctypes.c_void_p] + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + process = request["process"] + owner = request["launch"]["owner"] + receipt = Path(str(request["launch"]["state_path"]) + ".receipts") / f"{owner['id']}.json" + data_root = Path(request["launch"]["state_path"]).parent + _ordinary_path(receipt, data_root) + job = TrackingJob.__new__(TrackingJob) + job.kernel = kernel + job.handle = kernel.OpenJobObjectW(4, False, request["job_name"]) # QUERY only + root = kernel.OpenProcess(0x100000 | 0x1000, False, process["pid"]) # wait + identity + try: + if not job.handle or not root: + raise OSError("cleanup observer cannot acquire lifetime handles") + times = [wintypes.FILETIME() for _ in range(4)] + if not kernel.GetProcessTimes(root, *(ctypes.byref(item) for item in times)): + raise OSError("cleanup observer cannot identify backend") + created = str(times[0].dwLowDateTime | times[0].dwHighDateTime << 32) + if created != process["created"]: + raise ValueError("cleanup observer backend creation identity mismatch") + _atomic_json(receipt.with_suffix(".observer-ready.json"), { + "owner": owner, "job_name": request["job_name"], + }, data_root) + if kernel.WaitForSingleObject(root, 0xFFFFFFFF) != 0: + raise OSError("cleanup observer could not observe backend exit") + tracking_path = receipt.with_suffix(".tracking.json") + _ordinary_path(tracking_path, data_root) + tracking = json.loads(tracking_path.read_text(encoding="utf-8")) + if tracking != {"schema": SCHEMA, "owner": owner, "process": process, + "job_name": request["job_name"]}: + raise ValueError("cleanup observer containment identity mismatch") + # Keep the query handle until the last descendant exits. A fixed timeout + # would strand the owner forever if that descendant exited just later. + while job.active_processes() != 0: + time.sleep(.1) + _atomic_json(receipt, {"schema": SCHEMA, "owner": owner, "state": "exited", + "process": process, "job_name": request["job_name"], + "proof": "windows-job-drained", "observer": { + "pid": os.getpid(), "created": _creation_identity(), + }}, data_root) + except Exception as exc: + _atomic_json(receipt.with_suffix(".failed.json"), {"schema": SCHEMA, "owner": owner, + "reason": type(exc).__name__}, data_root) + raise + finally: + job.close() + if root: + kernel.CloseHandle(root) + + +def _terminate(launch: dict) -> None: + """Explicit desktop escalation; never called by the outside observer.""" + owner = launch["owner"] + if launch.get("schema") != SCHEMA or not re.fullmatch(r"[0-9a-f]{32}", owner["id"]): + raise ValueError("invalid cleanup launch") + state_path = Path(launch["state_path"]) + data_root = state_path.parent + _ordinary_path(state_path, data_root) + index = json.loads(state_path.read_text(encoding="utf-8")) + if index.get("schema") != SCHEMA or owner not in index.get("owners", []): + raise ValueError("cleanup launch is not registered") + tracking_path = Path(str(state_path) + ".receipts") / f"{owner['id']}.tracking.json" + _ordinary_path(tracking_path, data_root) + tracking = json.loads(tracking_path.read_text(encoding="utf-8")) + job_name = f"Global\\AmadeusBackendCleanup-{owner['id']}" + if (tracking.get("schema") != SCHEMA or tracking.get("owner") != owner + or tracking.get("job_name") != job_name): + raise ValueError("cleanup containment identity mismatch") + process = tracking["process"] + if (type(process.get("pid")) is not int or process["pid"] <= 0 + or not isinstance(process.get("created"), str) + or not re.fullmatch(r"[1-9]\d{10,20}", process["created"])): + raise ValueError("invalid backend process identity") + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.OpenJobObjectW.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR] + kernel.OpenJobObjectW.restype = wintypes.HANDLE + kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel.OpenProcess.restype = wintypes.HANDLE + kernel.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + kernel.IsProcessInJob.argtypes = [wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL)] + kernel.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + job = kernel.OpenJobObjectW(4 | 8, False, job_name) # QUERY | TERMINATE + root = kernel.OpenProcess(0x1000, False, process["pid"]) # identity only + try: + if not job or not root: + raise OSError("backend Job termination handles unavailable") + times = [wintypes.FILETIME() for _ in range(4)] + if not kernel.GetProcessTimes(root, *(ctypes.byref(item) for item in times)): + raise OSError("backend process identity unavailable") + created = str(times[0].dwLowDateTime | times[0].dwHighDateTime << 32) + member = wintypes.BOOL() + if (created != process["created"] or not kernel.IsProcessInJob(root, job, ctypes.byref(member)) + or not member.value): + raise ValueError("backend does not match the registered Job") + if not kernel.TerminateJobObject(job, 1): + raise ctypes.WinError(ctypes.get_last_error()) + finally: + if root: + kernel.CloseHandle(root) + if job: + kernel.CloseHandle(job) + + +if __name__ == "__main__": + if len(sys.argv) != 3 or sys.argv[1] not in {"--observe", "--terminate"} or os.name != "nt": + raise SystemExit(2) + (_observe if sys.argv[1] == "--observe" else _terminate)(json.loads(sys.argv[2])) diff --git a/tests/test_backend_cleanup.py b/tests/test_backend_cleanup.py new file mode 100644 index 00000000..b4ec1dd7 --- /dev/null +++ b/tests/test_backend_cleanup.py @@ -0,0 +1,191 @@ +import asyncio +import json +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from server.backend_cleanup import BackendCleanup, LAUNCH_ENV, SCHEMA, TrackingJob, begin_from_environment +from agent_host.provider_runtime import ProviderRuntime +from agent_host.adapters.pi import PiAdapter + + +class FakeKernel: + def __init__(self, *, assign=True, active=1, query=True): + self.assign, self.active, self.query = assign, active, query + self.closed = [] + + def CreateJobObjectW(self, security, name): + assert security is None + self.name = name + return 81 + + def GetCurrentProcess(self): + return 17 + + def AssignProcessToJobObject(self, handle, process): + assert (handle, process) == (81, 17) + return self.assign + + def QueryInformationJobObject(self, handle, kind, output, size, length): + assert (handle, kind, length) == (81, 1, None) + output._obj.ActiveProcesses = self.active + return self.query + + def CloseHandle(self, handle): + self.closed.append(handle) + + +def test_job_only_tracks_without_termination_or_breakaway_flags(): + kernel = FakeKernel() + job = TrackingJob("Global\\fixture", kernel=kernel) + assert job.only_backend_remains() + kernel.active = 2 # Includes grandchildren in nested Provider/MCP jobs. + assert not job.only_backend_remains() + kernel.query = False + assert not job.only_backend_remains() + job.close() + assert kernel.closed == [81] + assert not job.only_backend_remains() + + +def test_job_assignment_failure_is_not_accepted_as_empty(): + kernel = FakeKernel(assign=False) + with pytest.raises(OSError, match="assign"): + TrackingJob("Global\\fixture", kernel=kernel) + assert kernel.closed == [81] + + +def launch_fixture(tmp_path): + owner = {"id": "a" * 32, "nonce": "synthetic-owner-nonce", + "project_root": str(Path(__file__).resolve().parents[1]), "python_command": sys.executable} + index = tmp_path / "backend-cleanup.json" + index.write_text(json.dumps({"schema": SCHEMA, "unknown": False, "owners": [owner]})) + Path(str(index) + ".receipts").mkdir() + return {"schema": SCHEMA, "state_path": str(index), "owner": owner} + + +def proof_fixture(tmp_path, *, active=1, identity="133000000000000000"): + launch = launch_fixture(tmp_path) + job = TrackingJob("Global\\fixture", kernel=FakeKernel(active=active)) + proof = BackendCleanup(launch, job_factory=lambda name: job, identity_factory=lambda: identity, + observer_factory=lambda proof: None) + assert proof.failure is None + return proof + + +@pytest.mark.parametrize("providers,work,teardown", [(False, True, True), (True, False, True), (True, True, False)]) +def test_unknown_or_failed_cleanup_cannot_publish(tmp_path, providers, work, teardown): + proof = proof_fixture(tmp_path) + proof.record_runtime_cleanup(providers_settled=providers, work_settled=work, teardown_succeeded=teardown) + assert not proof.finish() + assert not proof.approval.exists() + + +def test_surviving_process_blocks_receipt_without_being_killed(tmp_path): + proof = proof_fixture(tmp_path, active=2) + proof.record_runtime_cleanup(providers_settled=True, work_settled=True, teardown_succeeded=True) + assert not proof.finish() + assert proof.job.kernel.closed == [] + assert not proof.approval.exists() + + +def test_success_receipt_binds_launch_and_root_creation_identity(tmp_path): + proof = proof_fixture(tmp_path) + proof.record_runtime_cleanup(providers_settled=True, work_settled=True, teardown_succeeded=True) + assert proof.finish() + receipt = json.loads(proof.approval.read_text()) + assert receipt["owner"] == proof.launch["owner"] + assert receipt["process"] == {"pid": os.getpid(), "created": "133000000000000000"} + assert receipt["job_name"] == "Global\\AmadeusBackendCleanup-" + "a" * 32 + tracking = json.loads(proof.receipt.with_suffix(".tracking.json").read_text()) + assert tracking == {"schema": SCHEMA, "owner": proof.launch["owner"], + "process": receipt["process"], "job_name": proof.job_name} + assert proof.job.handle is not None # Lifetime handle stays open until exit. + with pytest.raises(OSError): + proof.finish() # An existing receipt is never overwritten. + + +def test_failed_containment_preserves_registration_and_blocks_receipt(tmp_path): + launch = launch_fixture(tmp_path) + def unavailable(name): + raise OSError("nested Job assignment unavailable") + proof = BackendCleanup(launch, job_factory=unavailable) + assert proof.failure == "OSError" + proof.record_runtime_cleanup(providers_settled=True, work_settled=True, teardown_succeeded=True) + assert not proof.finish() + assert Path(launch["state_path"]).exists() + assert not Path(str(launch["state_path"]) + ".receipts", "a" * 32 + ".tracking.json").exists() + + +def test_failed_assignment_never_claims_that_an_empty_job_contained_the_backend(tmp_path): + launch = launch_fixture(tmp_path) + job = TrackingJob("Global\\fixture", kernel=FakeKernel(assign=False), assign=False) + proof = BackendCleanup(launch, job_factory=lambda name: job, + identity_factory=lambda: "133000000000000000", observer_factory=lambda proof: None) + assert proof.failure == "OSError" + assert not proof.receipt.with_suffix(".tracking.json").exists() + assert not proof.finish() + + +def test_environment_is_consumed_before_any_child_can_inherit_it(tmp_path, monkeypatch): + launch = launch_fixture(tmp_path) + monkeypatch.setenv(LAUNCH_ENV, json.dumps(launch)) + monkeypatch.setattr("server.backend_cleanup.BackendCleanup", lambda item: item) + assert begin_from_environment() == launch + assert LAUNCH_ENV not in os.environ + + +@pytest.mark.asyncio +async def test_provider_close_reports_adapter_failure_without_skipping_other_owners(): + runtime = ProviderRuntime() + closed = [] + async def broken(): + raise RuntimeError("synthetic close failure") + async def healthy(): + closed.append(True) + runtime._adapters = {"broken": SimpleNamespace(close=broken), "healthy": SimpleNamespace(close=healthy)} + result = await runtime.close() + assert not result["settled"] + assert result["errors"] == ["adapter_close_failed:broken"] + assert closed == [True] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["queued", "running", "orphaned"]) +async def test_provider_close_does_not_invent_terminal_work(status): + runtime = ProviderRuntime() + runtime._runs["run"] = SimpleNamespace(run_id="run", status=status, task_handle=None, terminal_publication_task=None) + result = await runtime.close() + assert not result["settled"] + assert result["pending_runs"] == ["run"] + + +@pytest.mark.asyncio +async def test_provider_terminal_status_is_insufficient_while_execution_finally_is_running(): + runtime = ProviderRuntime() + wait = asyncio.Event() + task = asyncio.create_task(wait.wait()) + runtime._runs["run"] = SimpleNamespace(run_id="run", status="cancelled", task_handle=task, terminal_publication_task=None) + try: + assert not (await runtime.close())["settled"] + wait.set() + await task + assert (await runtime.close())["settled"] + finally: + wait.set() + await task + + +@pytest.mark.asyncio +async def test_pi_close_does_not_swallow_cancel_or_transport_failure(): + adapter = PiAdapter.__new__(PiAdapter) + async def failed_close(): + raise OSError("synthetic transport close failure") + async def unconfirmed(key): + return {"confirmed": False} + adapter._runs = {"run": SimpleNamespace(client=SimpleNamespace(close=failed_close))} + adapter.cancel = unconfirmed + assert await adapter.close() == {"settled": False, "errors": ["pi_cancel_unconfirmed", "pi_close_failed"]} diff --git a/tests/test_chat_ingress_lifecycle.py b/tests/test_chat_ingress_lifecycle.py index f5b7cea0..2e0e4b2c 100644 --- a/tests/test_chat_ingress_lifecycle.py +++ b/tests/test_chat_ingress_lifecycle.py @@ -477,6 +477,7 @@ def close(self): vts_worker_stop = threading.Event() extension_close = Mock() + cleanup_record = Mock() loop = asyncio.get_running_loop() vts_workers = [loop.run_in_executor(None, vts_worker_stop.wait) for _ in range(2)] async def provider_close(): @@ -488,6 +489,7 @@ async def provider_close(): order.append("provider") release.set() await asyncio.sleep(0) + return {"settled": True} async def drain_inputs(): assert order == ["cooperative_begin", "provider", "cooperative_finish", "cooperative_ledger"] order.append("inputs") @@ -523,9 +525,12 @@ async def drain_inputs(): openclaw_gateway_start_task=None, provider_activity_h=SimpleNamespace(close=AsyncMock()), work_h=SimpleNamespace(drain_inputs=drain_inputs), - work_ledger=SimpleNamespace(drain_provider_facts=AsyncMock(), close=lambda: order.append("ledger")), + work_ledger=SimpleNamespace(drain_provider_facts=AsyncMock(), close=lambda: order.append("ledger"), + store=SimpleNamespace(list_unresolved_provider_attempts=Mock(return_value=[]), + list_writer_leases=Mock(return_value=[]))), work_observer=SimpleNamespace(close=AsyncMock()), work_preview=SimpleNamespace(close_all=AsyncMock()), - _stop_gui_render_runtime=Mock(), wake_service=None, asr_manager=None) + _stop_gui_render_runtime=Mock(), wake_service=None, asr_manager=None, + _BACKEND_CLEANUP=SimpleNamespace(record_runtime_cleanup=cleanup_record)) exec(compile(module, str(path), "exec"), namespace) caller = asyncio.create_task(namespace["run_cleanup"]()) if cancel_count: @@ -543,4 +548,6 @@ async def drain_inputs(): assert order == ["cooperative_begin", "provider", "cooperative_finish", "cooperative_ledger", "inputs", "ledger"] assert logger.exception.call_count == int(fence_fails) + cleanup_record.assert_called_once_with(providers_settled=True, work_settled=True, + teardown_succeeded=not fence_fails) asyncio.run(run()) diff --git a/tests/test_electron_model_less_smoke.py b/tests/test_electron_model_less_smoke.py index aabcf703..2cd2520f 100644 --- a/tests/test_electron_model_less_smoke.py +++ b/tests/test_electron_model_less_smoke.py @@ -170,6 +170,19 @@ def popen(args, **kwargs): assert "." in command +async def test_startup_fixture_refuses_an_existing_inspector_before_launch(tmp_path, monkeypatch): + from tools import e2e_live_product_journey as journey + + monkeypatch.setattr(journey, '_windows_launch_identity', lambda: None) + monkeypatch.setattr(journey, '_require_windows_electron_profile', lambda identity: None) + monkeypatch.setattr(journey, '_port_is_open', lambda port: port == 9224) + product = journey.ElectronProduct(run_root=tmp_path, debug_port=9223, main_debug_port=9224, + no_tts=True, identity={}) + with pytest.raises(RuntimeError, match='main inspector port 9224 is already in use'): + await product.start(startup_timeout=1, main_startup_script='throw Error("must not execute")') + assert product.process is None + + async def test_packaged_launcher_does_not_require_an_npm_install(tmp_path, monkeypatch): from tools import e2e_live_product_journey as journey @@ -278,3 +291,53 @@ async def test_windows_still_saves_both_modes_and_checks_restart_semantics(): await _exercise_startup_settings(StartupPage("win32", visible=True, pending=True), timeout=0) with pytest.raises(TimeoutError, match="did not persist"): await _exercise_startup_settings(StartupPage("win32", visible=True, persist=False), timeout=0) + + +@pytest.mark.parametrize("recovery_required", [False, True]) +async def test_failed_update_probe_checks_real_keep_rejection_and_retained_recovery(recovery_required): + from tools.probes.extension_release_failures import assert_failed_update_cannot_be_kept + + pending = {"id": "com.example.tone", "version": "0.3.0", "previousVersion": "0.1.0", + "canConfirm": False, "recoveryRequired": recovery_required} + calls = [] + + async def catalog(request): + calls.append(request["action"]) + if request["action"] == "confirm-update": + return {"ok": False, "error": "Plugin startup has not been confirmed"} + return {"ok": True, "catalog": {"pendingUpdate": dict(pending)}} + + await assert_failed_update_cannot_be_kept(catalog, "com.example.tone", "0.3.0", "0.1.0") + assert calls == ["health", "confirm-update", "get"] + + +@pytest.mark.parametrize("fault", ["health_failed", "missing", "wrong_target", "confirmable", "accepted_keep", "lost_recovery", "changed_recovery"]) +async def test_failed_update_probe_rejects_false_success_or_lost_recovery(fault): + from tools.probes.extension_release_failures import assert_failed_update_cannot_be_kept + + pending = {"id": "com.example.tone", "version": "0.3.0", "previousVersion": "0.1.0", + "canConfirm": False, "recoveryRequired": False} + + async def catalog(request): + action = request["action"] + if action == "confirm-update": + return {"ok": fault == "accepted_keep"} + current = dict(pending) + if action == "health": + if fault == "health_failed": + return {"ok": False} + if fault == "missing": + return {"ok": True, "catalog": {}} + if fault == "wrong_target": + current["version"] = "0.4.0" + if fault == "confirmable": + current["canConfirm"] = True + if action == "get": + if fault == "lost_recovery": + return {"ok": True, "catalog": {}} + if fault == "changed_recovery": + current["previousVersion"] = "0.0.9" + return {"ok": True, "catalog": {"pendingUpdate": current}} + + with pytest.raises(AssertionError): + await assert_failed_update_cannot_be_kept(catalog, "com.example.tone", "0.3.0", "0.1.0") diff --git a/tests/test_headless_inference_dependencies.py b/tests/test_headless_inference_dependencies.py index 05874f3f..9380061f 100644 --- a/tests/test_headless_inference_dependencies.py +++ b/tests/test_headless_inference_dependencies.py @@ -66,7 +66,7 @@ def test_qwen_sources_and_only_declared_packaging_changes_match_upstream(): original = tomllib.loads((ROOT / 'vendor/qwen-asr.upstream-pyproject.toml').read_text(encoding='utf-8')) patched = tomllib.loads((source / 'pyproject.toml').read_text(encoding='utf-8')) allowed = deepcopy(original) - allowed['build-system']['requires'] = ['setuptools==83.0.0', 'wheel==0.47.0'] + allowed['build-system']['requires'] = ['setuptools==84.2.1', 'wheel==0.47.0'] allowed['project']['dependencies'].remove('gradio') allowed['project']['optional-dependencies']['demo'] = ['gradio>=6.16.0,<7'] allowed['project']['license'] = 'Apache-2.0' @@ -108,7 +108,7 @@ def test_isolated_qwen_distributions_preserve_code_resources_and_metadata(tmp_pa info = 'qwen_asr-0.0.6.dist-info/' metadata = BytesParser().parsebytes(wheel.read(info + 'METADATA')) wheel_metadata = BytesParser().parsebytes(wheel.read(info + 'WHEEL')) - assert wheel_metadata['Generator'] == 'setuptools (83.0.0)' + assert wheel_metadata['Generator'] == 'setuptools (84.2.1)' assert metadata['Name'] == project['name'] and metadata['Version'] == project['version'] assert metadata['Requires-Python'] == project['requires-python'] assert metadata['License-Expression'] == 'Apache-2.0' diff --git a/tests/test_vts_worker_lifecycle.py b/tests/test_vts_worker_lifecycle.py index 7abd31e7..a855aeeb 100644 --- a/tests/test_vts_worker_lifecycle.py +++ b/tests/test_vts_worker_lifecycle.py @@ -195,7 +195,8 @@ def wait(self, timeout=None): @pytest.mark.parametrize("heartbeat_start_fails", [False, True]) -def test_actual_bootstrap_owns_workers_even_if_second_submit_fails(isolated_action, monkeypatch, heartbeat_start_fails): +@pytest.mark.parametrize("pending_work", ["none", "attempt", "lease"]) +def test_actual_bootstrap_owns_workers_even_if_second_submit_fails(isolated_action, monkeypatch, heartbeat_start_fails, pending_work): """Execute the real final startup/teardown scope without booting heavy devices.""" async def run(): path = Path(__file__).resolve().parents[1] / "server/app.py" @@ -210,6 +211,7 @@ async def run(): loop = asyncio.get_running_loop() futures, stops, callbacks = [], [], [] extension_close = Mock() + cleanup_record = Mock() def submit(executor, worker, stop): if heartbeat_start_fails and futures: @@ -224,6 +226,7 @@ async def provider_close(): assert stops and all(stop.is_set() for stop in stops) assert all(future.done() and not future.cancelled() for future in futures) callbacks.append("provider") + return {"settled": True} async def drain_inputs(): assert callbacks == ["provider"] @@ -244,7 +247,7 @@ def close_ledger(): loop=SimpleNamespace(run_in_executor=submit), _vts_action_mod=action, vts_manager=isolated_action, VTS_HEARTBEAT_ENABLED=True, port=0, server_task=server_task, chat_h=SimpleNamespace(close=AsyncMock()), logger=Mock(), - cooperative_chat=None, + cooperative_chat=SimpleNamespace(begin_close=AsyncMock(), finish_close=AsyncMock()), chat_role_delivery=None, cooperative_ledger=None, openclaw_gateway_start_task=None, bus=SimpleNamespace(off=Mock()), Method=Method, @@ -259,9 +262,13 @@ def close_ledger(): set_start_admission_validator=Mock(), close=provider_close), provider_activity_h=SimpleNamespace(close=AsyncMock()), work_h=SimpleNamespace(drain_inputs=drain_inputs), - work_ledger=SimpleNamespace(drain_provider_facts=AsyncMock(), close=close_ledger), + work_ledger=SimpleNamespace(drain_provider_facts=AsyncMock(), close=close_ledger, + store=SimpleNamespace(list_unresolved_provider_attempts=Mock( + return_value=[object()] if pending_work == "attempt" else []), + list_writer_leases=Mock(return_value=[object()] if pending_work == "lease" else []))), work_observer=SimpleNamespace(close=AsyncMock()), work_preview=SimpleNamespace(close_all=AsyncMock()), - _stop_gui_render_runtime=Mock(), wake_service=None, asr_manager=None) + _stop_gui_render_runtime=Mock(), wake_service=None, asr_manager=None, + _BACKEND_CLEANUP=SimpleNamespace(record_runtime_cleanup=cleanup_record)) exec(compile(module, str(path), "exec"), namespace) try: if heartbeat_start_fails: @@ -271,6 +278,8 @@ def close_ledger(): await namespace["run_tail"]() assert len(futures) == (1 if heartbeat_start_fails else 2) assert callbacks == ["provider", "inputs", "ledger"] + cleanup_record.assert_called_once_with(providers_settled=True, + work_settled=pending_work == "none", teardown_succeeded=True) finally: for stop in stops: stop.set() diff --git a/tools/e2e_live_product_journey.py b/tools/e2e_live_product_journey.py index d40aba41..65b10207 100644 --- a/tools/e2e_live_product_journey.py +++ b/tools/e2e_live_product_journey.py @@ -1162,14 +1162,18 @@ def _environment(self) -> dict[str, str]: env.pop("AMADEUS_E2E_NO_TTS", None) return env - async def start(self, *, startup_timeout: float) -> None: + async def start(self, *, startup_timeout: float, main_startup_script: str | None = None) -> None: _require_windows_electron_profile(_windows_launch_identity()) + if main_startup_script and not self.main_debug_port: + raise ValueError('A startup fixture requires the owned main inspector') if _port_is_open(BACKEND_PORT): raise RuntimeError( f"port {BACKEND_PORT} is already in use; refuse to replace a live runtime" ) if _port_is_open(self.debug_port): raise RuntimeError(f"debug port {self.debug_port} is already in use") + if self.main_debug_port and _port_is_open(self.main_debug_port): + raise RuntimeError(f"main inspector port {self.main_debug_port} is already in use") state = self.run_root / "state" for name in ("sessions", "desktop", "scratch", "worktrees"): (state / name).mkdir(parents=True, exist_ok=True) @@ -1190,7 +1194,7 @@ async def start(self, *, startup_timeout: float) -> None: self.process = subprocess.Popen( [ str(electron), - *([f"--inspect=127.0.0.1:{self.main_debug_port}"] if self.main_debug_port else []), + *([f"--{'inspect-brk' if main_startup_script else 'inspect'}=127.0.0.1:{self.main_debug_port}"] if self.main_debug_port else []), *([] if self.packaged_executable else ["."]), f"--remote-debugging-port={self.debug_port}", ], @@ -1202,6 +1206,9 @@ async def start(self, *, startup_timeout: float) -> None: creationflags=creationflags, ) deadline = time.monotonic() + max(1.0, startup_timeout) + if main_startup_script: + from tools.probes.extension_startup import prepare_startup_fixture + await prepare_startup_fixture(self, main_startup_script, timeout=startup_timeout) while time.monotonic() < deadline: if self.process.poll() is not None: raise RuntimeError( diff --git a/tools/probes/backend_cleanup_startup.py b/tools/probes/backend_cleanup_startup.py new file mode 100644 index 00000000..14d88926 --- /dev/null +++ b/tools/probes/backend_cleanup_startup.py @@ -0,0 +1,68 @@ +"""Qualify core Electron startup with isolated damaged/relocated cleanup state. + +Run after building Electron: python -m tools.probes.backend_cleanup_startup +Uses the existing model-less application journey; no chat turn or model is run. +""" + +import asyncio +import json +from pathlib import Path + +from tools import smoke_electron_model_less as smoke +from tools.smoke_electron_model_less import ROOT +from tools.e2e_live_product_journey import ElectronProduct + +original_start = ElectronProduct.start + +async def main(): + results = [] + for mode in ['ordinary', 'zero-filled', 'missing-index', 'ancestor-junction']: + links = [] + async def start(self, **kwargs): + state = self.run_root / 'state' + if mode == 'ancestor-junction': + import _winapi + target = self.run_root / 'relocated-state' + target.mkdir() + _winapi.CreateJunction(str(target), str(state)) + links.append((state, target)) + data = state / 'electron-user-data' + data.mkdir(parents=True, exist_ok=True) + index = data / 'backend-cleanup.json' + if mode == 'zero-filled': + index.write_bytes(bytes(128)) + elif mode == 'missing-index': + Path(str(index) + '.receipts').mkdir() + return await original_start(self, **kwargs) + ElectronProduct.start = start + try: + args = smoke._parser().parse_args(['--report-dir', str(ROOT / 'build' / f'pr261-r3-app-{mode}')]) + code, report = await smoke.run_smoke(args) + if code == 0: + run_root = Path(report['paths']['run_root']) + index = run_root / 'state/electron-user-data/backend-cleanup.json' + if mode == 'zero-filled': + assert index.read_bytes() == bytes(128), 'damaged evidence was overwritten' + elif mode == 'missing-index': + assert not index.exists() and Path(str(index) + '.receipts').is_dir() + else: + evidence = json.loads(index.read_text()) + assert evidence['owners'] == [] and evidence['unknown'] is False + if mode in {'zero-filled', 'missing-index'}: + assert 'backend cleanup evidence unavailable' in Path(report['paths']['process_log']).read_text(encoding='utf-8') + report['checks']['cleanup_evidence_preserved_or_verified'] = True + results.append({'scenario': mode, 'exit_code': code, 'status': report['status'], + 'checks': report['checks'], 'report': report['paths']['report'], + 'error': report.get('error')}) + print(json.dumps(results[-1]), flush=True) + finally: + ElectronProduct.start = original_start + for link, target in links: + assert link.is_junction() and link.resolve() == target.resolve() + assert target.resolve().is_relative_to(link.parent.resolve()) + link.rmdir() # Remove only this test's junction, never its target. + (ROOT / 'build' / 'pr261-r3-app-results.json').write_text(json.dumps(results, indent=2)) + return int(any(item['exit_code'] for item in results)) + +if __name__ == '__main__': + raise SystemExit(asyncio.run(main())) diff --git a/tools/probes/extension_release_failures.py b/tools/probes/extension_release_failures.py index 4496ab0b..4166ce0d 100644 --- a/tools/probes/extension_release_failures.py +++ b/tools/probes/extension_release_failures.py @@ -12,6 +12,24 @@ ROOT = Path(__file__).resolve().parents[2] +async def assert_failed_update_cannot_be_kept(catalog, package_id, version, previous_version): + """Reject a real keep request without requiring a forced-rollback phase. + + An unhealthy projection can stay pending in this desktop session so its + configuration can be repaired. It still grants no authority to keep it. + """ + health = await catalog({"action": "health"}) + assert health.get("ok"), "Plugin health query failed" + pending = health.get("catalog", {}).get("pendingUpdate") + expected = {"id": package_id, "version": version, "previousVersion": previous_version} + assert pending and all(pending.get(key) == value for key, value in expected.items()), "Failed update recovery identity changed" + assert pending.get("canConfirm") is False, "Failed update became confirmable" + refused = await catalog({"action": "confirm-update"}) + assert refused.get("ok") is False, "Failed update was kept" + after = await catalog({"action": "get"}) + assert after.get("ok") and after.get("catalog", {}).get("pendingUpdate") == pending, "Rejected keep changed the recovery record" + + def prepare_startup_failure(base, reference): from tools.probes.extension_theme_lifecycle import build_wheel from tools.extension_package import host_profile @@ -53,6 +71,7 @@ def prepare_startup_failure(base, reference): async def exercise_failures(product, fixture): from tools.probes.extension_release_shell import evaluate_main + from tools.probes.extension_startup import release_fixture_script from tools.smoke_electron_model_less import _wait_enabled checks = [] @@ -98,10 +117,32 @@ async def refresh(): return await catalog({"action": "refresh"}) async def install(package_id, version): - return await catalog({"action": "install", "id": package_id, "version": version}) + previous = json.loads(state_path.read_text())['extensions'].get(package_id) + response = await catalog({"action": "schedule", "id": package_id, "version": version}) + if not response['ok']: + report.setdefault('installation_attempts', []).append({ + 'id': package_id, 'version': version, 'ok': False, 'error': response.get('error')}) + return response + assert response.get('scheduled') + assert json.loads(state_path.read_text())['extensions'].get(package_id) == previous + await restart() + current = json.loads(state_path.read_text())['extensions'].get(package_id) + snapshot = await product.page.evaluate('window.amadeus.getDesktopSettings()') + result = {'ok': bool(current and current['version'] == version), 'error': '; '.join(snapshot['extensionErrors'])} + report.setdefault('installation_attempts', []).append({'id': package_id, 'version': version, **result}) + return result - async def restart(damage=None, offline=False): + async def restart(damage=None, offline=False, crash_backend=False): old_pid = product.process.pid + behavior = await main('(()=>{const s=extensionReleaseFixture;return {badWheel:s.badWheel,confirmations:s.confirmations}})()') + if crash_backend: + # Only the backend ChildProcess held by this disposable Electron + # main process is terminated. No PID lookup or external process is + # targeted, and no graceful cleanup approval can be written. + killed = await main("extensionReleaseFixture.crashBackend()") + check('pending-update backend terminated without graceful approval', killed is True) + await main("(()=>{setImmediate(()=>process.getBuiltinModule('module').createRequire(process.execPath)('electron').app.quit());return true})()") + await asyncio.to_thread(product.process.wait, 30) await product.stop() assert product.process.poll() is not None phase = report.get("app_restarts", 0) + 1 @@ -111,20 +152,10 @@ async def restart(damage=None, offline=False): 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 - ) + await product.start(startup_timeout=180, main_startup_script=release_fixture_script( + fixture, {**behavior, 'index': index, 'offline': offline})) + 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()") @@ -150,22 +181,17 @@ async def runtime_state(version, expected): "fresh app process completes prior pending removals", not json.loads(state_path.read_text())["extensions"], ) - await main("extensionReleaseFixture.holdWheel=true") - pending = asyncio.create_task(install(theme, "0.1.0")) - for _ in range(150): - status = await catalog({"action": "status"}) - if (status.get("progress") or {}).get("received", 0) > 0: - break - await asyncio.sleep(0.1) - else: - raise AssertionError("Download never yielded progress") - await catalog({"action": "cancel"}) - cancelled = await pending + selected = await catalog({'action':'schedule', 'id':theme, 'version':'0.1.0'}) + assert selected['ok'] and selected['scheduled'] + cancelled = await catalog({'action':'cancel-scheduled'}) check( - "cancelled streaming download does not install", - cancelled.get("cancelled") and not json.loads(state_path.read_text())["extensions"], + "cancelled scheduled install neither downloads nor installs", + cancelled['ok'] and not cancelled['catalog'].get('scheduledInstall') + and not json.loads(state_path.read_text())["extensions"] + and not any('.whl' in path for path in await main('extensionReleaseFixture.requests')), ) - await main("extensionReleaseFixture.holdWheel=false") + denied = await catalog({'action':'install', 'id':theme, 'version':'0.1.0'}) + check('live backend refuses immediate installation', not denied['ok'] and 'installation is blocked' in denied['error']) assert (await install(theme, "0.1.0"))["ok"] await update(theme, True) await product.page.evaluate( @@ -176,6 +202,7 @@ async def runtime_state(version, expected): check( "wrong digest update preserves previous installed and applied version", not rejected["ok"] + and 'package validation failed' in rejected['error'] and json.loads(state_path.read_text())["extensions"][theme]["version"] == "0.1.0" and (await product.page.evaluate("window.amadeus.getDesktopTheme()"))["theme"][ "version" @@ -217,12 +244,28 @@ async def runtime_state(version, expected): 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 assert_failed_update_cannot_be_kept(catalog, tone, "0.3.0", "0.1.0") + check('failed update cannot be kept') + assert (await catalog({'action':'rollback'}))['scheduled'] + await restart() + check('explicit rollback restores the previous verified runtime', + json.loads(state_path.read_text())['extensions'][tone]['version'] == '0.1.0' + and bool(await runtime_state('0.1.0', {'active'})) + and not (await catalog({'action':'get'}))['catalog'].get('pendingUpdate')) + assert (await install(tone, "0.3.0"))["ok"] + assert await runtime_state("0.3.0", {"error"}) + await assert_failed_update_cannot_be_kept(catalog, tone, "0.3.0", "0.1.0") + assert (await catalog({'action': 'rollback'}))['scheduled'] + await restart(crash_backend=True) + check('unclean pending update can roll back and start the previous runtime', + json.loads(state_path.read_text())['extensions'][tone]['version'] == '0.1.0' + and bool(await runtime_state('0.1.0', {'active'})) + and not (await catalog({'action': 'get'}))['catalog'].get('pendingUpdate')) await update(tone, False) assert (await product.page.evaluate("id=>window.amadeus.removeExtension(id)", tone))["ok"] await backend_restart() @@ -269,9 +312,14 @@ async def runtime_state(version, expected): + "]});return true})()" ) local = await product.page.evaluate("window.amadeus.installExtension()") + assert local['ok'] and local['scheduled'] + prior = json.loads(state_path.read_text())['extensions'][theme]['installId'] + await restart(offline=True) + observed = await product.page.evaluate('window.amadeus.getDesktopSettings()') check( - "offline known ban also blocks local wheel installation", - not local["ok"] and "blocked" in local["error"], + "offline known ban blocks scheduled local wheel at startup", + json.loads(state_path.read_text())['extensions'][theme]['installId'] == prior + and any('blocked' in error for error in observed['extensionErrors']), ) await main("extensionReleaseFixture.offline=false") rollback = copy.deepcopy(index) diff --git a/tools/probes/extension_release_shell.py b/tools/probes/extension_release_shell.py index 7af117d3..8da7c8ac 100644 --- a/tools/probes/extension_release_shell.py +++ b/tools/probes/extension_release_shell.py @@ -103,6 +103,7 @@ def verify_checker_schemas(checker_root: Path, wheel: Path, python: str = sys.ex async def exercise(product, fixture: Path): from playwright.async_api import expect from config.process_tree import ProcessTree + from tools.probes.extension_startup import release_fixture_script, restart_product, confirm_update page = product.page report = { @@ -153,13 +154,27 @@ async def install(package_id, version): selection = page.locator(".extension-catalog-detail select") if await selection.count(): await selection.select_option(version) - await button("Install plugin…") + await button("Schedule this version for next app start…") + await apply_scheduled() await expect(page.locator("#extension-panel-installed")).to_be_visible(timeout=120000) await expect(page.locator(f'[data-extension-id="{package_id}"][data-extension-version="{version}"]')).to_be_visible( timeout=120000 ) await expect(page.locator(".extension-download-progress")).to_have_count(0, timeout=120000) + async def apply_scheduled(): + nonlocal page + await expect(page.get_by_text('Scheduled for the next app start. Installed packages have not changed. Review the native confirmation before installation.', exact=True)).to_be_visible(timeout=30000) + queued = (await catalog({'action': 'get'}))['catalog']['scheduledInstall'] + before = json.loads(settings_path.read_text()).get('extensions', {}).get(queued['id']) + assert before is None or before['version'] != queued['version'] + state = await evaluate_main(product, '(()=>{const s=extensionReleaseFixture;return {index:s.index,confirmations:s.confirmations}})()') + await restart_product(product, release_fixture_script(fixture, state)) + page = product.page + await button('Plugins') + await page.get_by_role('tab', name='Installed', exact=True).click() + assert not (await catalog({'action': 'get'}))['catalog'].get('scheduledInstall') + resource_root = ( product.packaged_executable.parent / "resources" if product.packaged_executable else ROOT ) @@ -234,12 +249,14 @@ async def install(package_id, version): 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 button("Check now") + await button("Schedule update for next app start…") + await apply_scheduled() 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"))) + await confirm_update(product, theme, '0.2.0') requests = await evaluate_main(product, "extensionReleaseFixture.requests") check( "download uses configured artifact mirror", diff --git a/tools/probes/extension_startup.py b/tools/probes/extension_startup.py new file mode 100644 index 00000000..89af42d8 --- /dev/null +++ b/tools/probes/extension_startup.py @@ -0,0 +1,128 @@ +"""Install loopback transport/dialog fixtures before the owned app's startup. + +Fixtures replace transport and dialogs, while the shipping startup, update +boundary and installer still execute. The inspector belongs only to the +explicitly launched disposable smoke process. +""" +from __future__ import annotations + +import asyncio +import http.client +import json +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def release_fixture_script(fixture: Path, state=None): + code = (ROOT / 'electron/tests/fixtures/extensionRelease.cjs').read_text(encoding='utf-8') + return (code + f';await setupReleaseFixture({json.dumps(str(fixture))});' + + 'Object.assign(extensionReleaseFixture,' + json.dumps(state or {}) + ');') + + +async def restart_product(product, script: str): + from tools.probes.extension_release_shell import evaluate_main + from tools.smoke_electron_model_less import _wait_enabled + + previous = product.process.pid + await evaluate_main(product, "(()=>{setImmediate(()=>process.getBuiltinModule('module').createRequire(process.execPath)('electron').app.quit());return true})()") + await asyncio.to_thread(product.process.wait, 30) + await product.stop() + count = getattr(product, '_extension_restarts', 0) + 1 + product._extension_restarts = count + (product.run_root / f'extension-restart-{count}.log').write_bytes(product.log_path.read_bytes()) + await product.start(startup_timeout=180, main_startup_script=script) + assert product.process.pid != previous + await _wait_enabled(product.page.locator('textarea[placeholder*="Type a message"]'), timeout=60) + + +async def confirm_update(product, package_id: str, version: str): + """Keep only after the caller has checked the actual installed behavior.""" + for _ in range(60): + result = await product.page.evaluate("window.amadeus.extensionCatalog({action:'health'})") + assert result['ok'], result.get('error') + pending = result['catalog'].get('pendingUpdate') + assert pending and pending['id'] == package_id and pending['version'] == version + if pending['canConfirm']: + break + assert not pending['recoveryRequired'], pending + await asyncio.sleep(0.5) + else: + raise AssertionError('Updated plugin did not become ready for explicit confirmation') + await product.page.get_by_role('button', name='Plugins', exact=True).click() + await product.page.get_by_role('button', name='Keep this version', exact=True).click() + from playwright.async_api import expect + await expect(product.page.get_by_role('button', name='Keep this version', exact=True)).to_have_count(0, timeout=30000) + result = await product.page.evaluate("window.amadeus.extensionCatalog({action:'get'})") + assert not result['catalog'].get('pendingUpdate') + + +async def prepare_startup_fixture(product, script: str, *, timeout: float): + import websockets + + def address(): + connection = http.client.HTTPConnection('127.0.0.1', product.main_debug_port, timeout=2) + 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() + + deadline = time.monotonic() + timeout + while True: + if product.process.poll() is not None: + raise RuntimeError('Owned Electron exited before startup fixture setup') + try: + url = await asyncio.to_thread(address) + break + except (OSError, ValueError): + if time.monotonic() >= deadline: + raise TimeoutError('Owned main inspector did not open') + await asyncio.sleep(0.1) + + async with websockets.connect(url, open_timeout=3) as socket: + sequence = 0 + paused = False + + async def command(method, params=None): + nonlocal sequence, paused + sequence += 1 + await socket.send(json.dumps(dict(id=sequence, method=method, params=params or {}))) + while True: + response = json.loads(await asyncio.wait_for(socket.recv(), 30)) + if response.get('method') == 'Debugger.paused': + paused = True + if response.get('id') == sequence: + if 'error' in response: + raise RuntimeError(str(response['error'])) + return response.get('result', {}) + + await command('Runtime.enable') + await command('Debugger.enable') + await command('Runtime.runIfWaitingForDebugger') + while not paused: + response = json.loads(await asyncio.wait_for(socket.recv(), 30)) + paused = response.get('method') == 'Debugger.paused' + expression = ( + f"(()=>{{if(process.pid!=={product.process.pid})throw Error('Unexpected process');" + "const require=process.getBuiltinModule('module').createRequire(process.execPath);" + "const app=require('electron').app;const ready=app.whenReady.bind(app);let fixture;" + "app.whenReady=()=>fixture??=ready().then(async()=>{" + script + "});return true})()" + ) + result = await command('Runtime.evaluate', dict(expression=expression, returnByValue=True)) + if result.get('exceptionDetails') or result.get('result', {}).get('value') is not True: + raise RuntimeError('Could not install startup fixture: ' + str(result)) + await command('Debugger.resume') + # Detach debugger instrumentation explicitly after resuming. Closing the + # socket while startup callbacks are running can crash packaged Electron. + await command('Debugger.disable') + ready = await command('Runtime.evaluate', dict( + expression="process.getBuiltinModule('module').createRequire(process.execPath)('electron').app.whenReady().then(()=>true)", + awaitPromise=True, returnByValue=True)) + if ready.get('exceptionDetails') or ready.get('result', {}).get('value') is not True: + raise RuntimeError('Startup fixture did not become ready: ' + str(ready)) diff --git a/tools/probes/extension_store_roundtrip.py b/tools/probes/extension_store_roundtrip.py index a9ecfe7c..8727cf4f 100644 --- a/tools/probes/extension_store_roundtrip.py +++ b/tools/probes/extension_store_roundtrip.py @@ -16,6 +16,7 @@ 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.probes.extension_startup import confirm_update from tools.smoke_electron_model_less import _wait_enabled from config.process_tree import ProcessTree @@ -51,11 +52,12 @@ async def main(expression): return await evaluate_main(product, expression) async def transport(): + assert await main(f'(async()=>{{{transport_script()};return true}})()') + + def transport_script(): 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'))})}})()" - ) + return (code + f';await setupPublicationTransport({json.dumps(publisher.origin)},' + f'{json.dumps(str(publisher.root / "cert.pem"))});') async def catalog(request): return await asyncio.wait_for( @@ -90,7 +92,8 @@ async def install_ui(package_id, version): 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 button("Schedule this version for next app start…") + await apply_scheduled(package_id, version) await expect(product.page.locator("#extension-panel-installed")).to_be_visible(timeout=120000) await expect(product.page.locator(f'[data-extension-id="{package_id}"][data-extension-version="{version}"]')).to_be_visible(timeout=120000) await expect(product.page.locator(".extension-download-progress")).to_have_count(0, timeout=120000) @@ -105,12 +108,27 @@ async def update_ui(package_id, version): old = settings()["extensions"][package_id]["version"] text = product.page.locator(f'[data-extension-id="{package_id}"][data-extension-version="{old}"]') card = product.page.locator("#extension-panel-installed .setting-card").filter(has=text) - await card.get_by_role("button", name="Update plugin…", exact=True).click() + await card.get_by_role("button", name="Schedule update for next app start…", exact=True).click() + await apply_scheduled(package_id, version) 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"] + if package_id == notes: + await notes_contract('updated-before-keep') + else: + assert (await product.page.evaluate('window.amadeus.getDesktopTheme()'))['theme']['version'] == version + await confirm_update(product, package_id, version) + + async def apply_scheduled(package_id, version): + await expect(product.page.get_by_text('Scheduled for the next app start. Installed packages have not changed. Review the native confirmation before installation.', exact=True)).to_be_visible(timeout=30000) + previous = settings()['extensions'].get(package_id) + assert previous is None or previous['version'] != version + await restart() + await open_manager() + await product.page.get_by_role('tab', name='Installed', exact=True).click() + assert not (await catalog({'action':'get'}))['catalog'].get('scheduledInstall') async def configure(package_id): await product.page.get_by_role("tab", name="Installed", exact=True).click() @@ -136,6 +154,8 @@ async def select_theme(): async def restart(*, damage=(), offline=False): previous_pid = product.process.pid + await main("(()=>{setImmediate(()=>process.getBuiltinModule('module').createRequire(process.execPath)('electron').app.quit());return true})()") + await asyncio.to_thread(product.process.wait, 30) await product.stop() assert product.process.poll() is not None report["app_restarts"] += 1 @@ -145,10 +165,9 @@ async def restart(*, damage=(), offline=False): assert not path.is_symlink() path.write_bytes(b"\0" * 1536) publisher.offline = offline - await product.start(startup_timeout=120) + await product.start(startup_timeout=180, main_startup_script=transport_script()) 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] @@ -191,7 +210,7 @@ def run(): save() async def rejects_install(package_id): - response = await catalog({"action": "install", "id": package_id, "version": "0.2.0"}) + response = await catalog({"action": "schedule", "id": package_id, "version": "0.2.0"}) assert not response["ok"], "Rejected artifact unexpectedly installed" return response @@ -216,7 +235,7 @@ async def rejects_install(package_id): publisher.origin + "/mirror", ))["ok"] await refresh() - await button("Check updates and security status") + await button("Check now") # Exercise the actual Installed-view manual update controls. await update_ui(theme, "0.2.0") await update_ui(notes, "0.2.0") @@ -267,8 +286,14 @@ async def rejects_install(package_id): "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"]) + assert local['ok'] and local['scheduled'] + previous = settings()['extensions'][theme]['installId'] + await restart(offline=True) + local_state = await product.page.evaluate('window.amadeus.getDesktopSettings()') + check("known ban rejects scheduled local bytes at offline startup", + settings()['extensions'][theme]['installId'] == previous + and any('blocked' in error for error in local_state['extensionErrors']) + and not (await catalog({'action':'get'}))['catalog'].get('scheduledInstall')) await restart(damage=[state_path, state_path.with_suffix(".json.bak")], offline=True) check("double NUL damage persists safety unavailable after restart", @@ -348,8 +373,12 @@ async def rejects_install(package_id): report["blocked_identities"] = blocked["security"] report["ok"] = True return report - except Exception: - await product.screenshot("publication-failure") + except Exception as error: + report['error'] = f'{type(error).__name__}: {error}' + try: + await product.screenshot("publication-failure") + except Exception as capture_error: + report['capture_error'] = str(capture_error) raise finally: await asyncio.to_thread(publisher.close) diff --git a/uv.lock b/uv.lock index 0018e669..0511c965 100644 --- a/uv.lock +++ b/uv.lock @@ -53,6 +53,9 @@ conflicts = [[ { package = "amadeus", extra = "torch-cpu" }, ]] +[manifest] +constraints = [{ name = "filelock", specifier = ">=4.2.4,<5" }] + [[package]] name = "accelerate" version = "1.12.0" @@ -665,7 +668,7 @@ requires-dist = [ { name = "scipy", marker = "extra == 'voice'", specifier = "==1.17.1" }, { name = "sentence-transformers", marker = "extra == 'rag'", specifier = "==5.5.1" }, { name = "sentencepiece", marker = "extra == 'local-models'", specifier = "==0.2.1" }, - { name = "setuptools", marker = "extra == 'dev'", specifier = "==83.0.0" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = "==84.2.1" }, { name = "silero-vad", marker = "extra == 'vad'", specifier = "==6.2.1" }, { name = "soundfile", marker = "extra == 'voice'", specifier = "==0.13.1" }, { name = "split-lang", marker = "extra == 'local-models'", specifier = "==2.1.1" }, @@ -1282,11 +1285,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.32.5" +version = "4.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" }, ] [[package]] @@ -3834,11 +3837,11 @@ wheels = [ [[package]] name = "setuptools" -version = "83.0.0" +version = "84.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" }, ] [[package]] diff --git a/vendor/qwen-asr.PROVENANCE.md b/vendor/qwen-asr.PROVENANCE.md index 743b142c..fbc8f8b1 100644 --- a/vendor/qwen-asr.PROVENANCE.md +++ b/vendor/qwen-asr.PROVENANCE.md @@ -8,9 +8,12 @@ License: Apache-2.0; original LICENSE, copyright headers, README and manifest re Amadeus packaging modifications (2026-10-22), all in `pyproject.toml`: - Move mandatory `gradio` to the `demo` extra (`gradio>=6.16.0,<7`). -- Pin the package-local isolated build requirements to `setuptools==83.0.0` and +- Pin the package-local isolated build requirements to `setuptools==84.2.1` and `wheel==0.47.0`, retaining `setuptools.build_meta`. No global build constraint is imposed on AEC or other source dependencies. +- Update the isolated setuptools pin on 2026-10-24 to include the archive and + namespace-path security fixes from 84.1.0 and the wheel-tag fixes from 84.2.1. + The inference implementation and original-source hashes remain unchanged. - Replace the deprecated license table with SPDX `Apache-2.0` and explicitly retain the original notice using `license-files = ["LICENSE"]`. diff --git a/vendor/qwen-asr/pyproject.toml b/vendor/qwen-asr/pyproject.toml index ea51ab07..1b877473 100644 --- a/vendor/qwen-asr/pyproject.toml +++ b/vendor/qwen-asr/pyproject.toml @@ -1,7 +1,7 @@ # Amadeus packaging changes: pin this package's isolated build tools, use SPDX # license metadata with its original notice, and separate the optional demo UI. [build-system] -requires = ["setuptools==83.0.0", "wheel==0.47.0"] +requires = ["setuptools==84.2.1", "wheel==0.47.0"] build-backend = "setuptools.build_meta" [project] @@ -56,4 +56,4 @@ 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 +qwen_tts = ["py.typed", "**/*.dict"]