From 194223917cfcc3296d2af1aebf8da8315ae1b1fa Mon Sep 17 00:00:00 2001 From: Desolate-Jix Date: Sat, 24 Oct 2026 17:40:02 +1300 Subject: [PATCH 01/13] feat(extensions): queue confirmed updates with recovery --- agent_host/adapters/pi.py | 11 +- agent_host/provider_runtime.py | 19 +- electron/src/main/backendProcessTree.ts | 186 ++++++++++- electron/src/main/desktopSettings.ts | 21 ++ electron/src/main/extensionCatalog.ts | 166 +++++++++- electron/src/main/extensionUpdates.ts | 76 +++++ electron/src/main/extensions.ts | 145 +++++++- electron/src/main/index.ts | 61 +++- .../renderer/components/ExtensionCatalog.tsx | 2 +- .../renderer/components/ExtensionSettings.tsx | 43 ++- electron/src/renderer/locales/zh-CN.json | 16 +- electron/src/shared/extensionCatalog.ts | 10 +- electron/src/shared/extensions.ts | 1 + electron/tests/backendProcessTree.test.mjs | 220 ++++++++++++- electron/tests/extensionCatalog.test.mjs | 145 +++++++- electron/tests/extensionSettings.test.mjs | 46 +++ electron/tests/extensionUpdates.test.mjs | 33 ++ electron/tests/extensions.test.mjs | 222 ++++++++++++- server/app.py | 25 +- server/backend_cleanup.py | 310 ++++++++++++++++++ tests/test_backend_cleanup.py | 177 ++++++++++ 21 files changed, 1880 insertions(+), 55 deletions(-) create mode 100644 electron/src/main/extensionUpdates.ts create mode 100644 electron/tests/extensionUpdates.test.mjs create mode 100644 server/backend_cleanup.py create mode 100644 tests/test_backend_cleanup.py 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/electron/src/main/backendProcessTree.ts b/electron/src/main/backendProcessTree.ts index 8bc8152c..e52a95e1 100644 --- a/electron/src/main/backendProcessTree.ts +++ b/electron/src/main/backendProcessTree.ts @@ -1,6 +1,171 @@ -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' +const CLEANUP_SCHEMA = 'amadeus.backend-cleanup.v1' +type CleanupOwner = { id: string; nonce: string; project_root: string; python_command: string } +type CleanupIndex = { schema: string; unknown: boolean; owners: CleanupOwner[] } +type ProcessIdentity = { pid: number; created: string } + +// 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): void { + if (!path.isAbsolute(file) || path.normalize(file) !== file) throw Error('Cleanup path must be canonical and absolute') + 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 (path.dirname(item) === item) break + } +} + +function readJson(file: string): unknown { + ordinaryPath(file) + 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 { + const value = readJson(file) as CleanupIndex + if (!value || value.schema !== CLEANUP_SCHEMA || typeof value.unknown !== 'boolean' + || !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())) + }) +} + +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("'", "''") + "'" + await powershell(`$ErrorActionPreference='Stop'; Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class BackendCleanupMove { [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool MoveFileEx(string a,string b,int flags); }'; if(-not [BackendCleanupMove]::MoveFileEx(${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 }): Promise<{ environment: Record }> { + return serializeCleanup(statePath, async () => { + const index = readIndex(statePath) ?? { schema: CLEANUP_SCHEMA, unknown: false, owners: [] } + 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): Promise { + return serializeCleanup(statePath, async () => { + const index = readIndex(statePath) ?? { schema: CLEANUP_SCHEMA, unknown: false, owners: [] } + 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 }: { + isProcessIdentityGone?: (identity: ProcessIdentity) => Promise; + } = {}): Promise { + try { + return await serializeCleanup(statePath, async () => { + const index = readIndex(statePath) + if (index === null) return true + if (index.unknown) return false + for (const owner of index.owners) { + const receipt = readJson(path.join(`${statePath}.receipts`, `${owner.id}.json`)) 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 || receipt.state !== 'settled' + || !validOwner(receipt.owner) || Object.keys(owner).some(key => owner[key as keyof CleanupOwner] !== receipt.owner[key as keyof CleanupOwner]) + || receipt.proof !== 'windows-job-drained' || 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 { @@ -39,11 +204,23 @@ 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 }: { platform?: string; forceStop?: typeof forceStopBackendTree; waitForExit?: typeof waitForProcessExit; + verifyCleanup?: () => Promise; } = {}): Promise { const requested = await requestShutdown() - if (requested && await waitForExit(proc, 2500)) return true + if (requested && await waitForExit(proc, 15000)) { + 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 @@ -55,5 +232,6 @@ 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' + // Forced exit cannot publish a successful provider/Work cleanup receipt. + return false } diff --git a/electron/src/main/desktopSettings.ts b/electron/src/main/desktopSettings.ts index 3a645d37..5e191945 100644 --- a/electron/src/main/desktopSettings.ts +++ b/electron/src/main/desktopSettings.ts @@ -661,6 +661,27 @@ export class DesktopSettingsStore { return result } + /** Called only by the installation owner after canonical validation. */ + validateExtensionRollbackRecord(raw: unknown): 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 + const clean = cleanStoredExtensions({ [id]: raw }, this.validateExtensionRecord) + 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..dc413563 100644 --- a/electron/src/main/extensionCatalog.ts +++ b/electron/src/main/extensionCatalog.ts @@ -1,19 +1,24 @@ 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 { 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' 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 +30,40 @@ 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 = () => {}) {} async dispatch(raw: unknown, environment: NodeJS.ProcessEnv, openExternal: (url: string) => Promise): Promise { try { const request = catalogRequest(raw) if (request.action === 'status') return { ok: true, progress: this.status() } if (request.action === 'install') return { ok: true, settings: await this.install(request.id, request.version), catalog: await this.view() } + if (request.action === '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 +73,133 @@ 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 (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 { const preferences = this.preferences(); return Boolean(preferences.pending || preferences.local || preferences.rollback) } + async applyScheduled(): Promise { + const preferences = this.preferences() + 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) + } + } 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 +207,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 +248,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 +258,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..b9ff0263 --- /dev/null +++ b/electron/src/main/extensionUpdates.ts @@ -0,0 +1,76 @@ +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 = false + constructor(private readonly stopped: () => Promise) {} + assertHeld = (): void => { + if (!this.held) throw new Error('Plugin installation requires the stopped-backend update 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 { + if (this.held) throw new Error('Another plugin maintenance transaction is in progress.') + this.held = true + try { + if (!await this.stopped()) throw new Error('The backend or its MCP/Work processes are still active or unconfirmed; plugin installation is blocked.') + return await operation() + } finally { this.held = false } + } +} + +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(): 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 = this.validate(raw.previous), target = this.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..53aca6ac 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -14,8 +14,9 @@ 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 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 +193,99 @@ 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 = () => {}) { + this.updates = new ExtensionUpdateJournal(root, raw => store.validateExtensionRollbackRecord(raw)) + } + + 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) 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)) + this.updates.write({ ...update, phase: healthy ? 'startup_confirmed' : 'recovery_required' }) + } + 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 () => { + this.assertInstallIdle() + 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.assertInstallIdle() + 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 + await this.applyFrontendChanges() + this.changed() + return this.store.snapshot(this.environment) + }) + } private get dataRoot(): string { return path.join(path.dirname(this.root), 'extension-data') } @@ -240,8 +327,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 +344,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 +438,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) { + 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 +451,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 +486,20 @@ 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() + if (pending?.target.id === raw?.id) { + 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 (raw.enabled !== false || !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 (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') @@ -414,6 +538,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 +584,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 +609,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 +638,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..e133cb16 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, stopOwnedBackend, verifyPreviousBackendCleanup } from './backendProcessTree.js' import { applicationMenuTemplate } from './applicationMenu.js' import { defaultMpsFallbackEnvironment } from './mpsFallbackPolicy.js' import { auipStoragePartition } from './auipStorage.js' @@ -42,6 +42,7 @@ 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 { ExtensionThemeResources } from './extensionThemes.js' import { developmentServer, isDevelopmentRendererUrl } from './developmentServer.js' @@ -52,6 +53,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 +67,8 @@ const desktopSettings = new DesktopSettingsStore( process.platform, extensionPackageHelper.validateRecord, ) +const extensionUpdateBoundary = new ExtensionUpdateBoundary(async () => !pythonProcess && !backendOwned && !backendStarting && !backendStopping + && extensionCleanupConfirmed && await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE) && await localPortIsClosed(BACKEND_PORT)) 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 +78,20 @@ 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(), extensionUpdateBoundary.assertHeld) 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') @@ -407,6 +419,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 +430,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 @@ -427,11 +442,15 @@ async function launchBackend(): Promise { const health = await backendHealthStatus() if (applicationLifecycle.isQuitting) return if (health === 'ready') { + await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) + extensionCleanupConfirmed = false backendOwned = true console.log(`[electron] backend already available on ${BACKEND_WS}; reusing it`) return } if (health === 'starting') { + await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) + extensionCleanupConfirmed = false backendOwned = true console.log(`[electron] backend is assembling on ${BACKEND_WS}; waiting for readiness`) await waitForBackendReady() @@ -444,8 +463,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. + // A closed port does not prove old provider descendants have stopped. + // Only the persisted launch/child receipt authorizes removal or installation. + extensionCleanupConfirmed = await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE) if (extensionCleanupConfirmed) await extensionInstaller.cleanupStoppedBackend() console.log(`[electron] starting backend: ${python} -m server.app --port ${BACKEND_PORT}`) @@ -461,12 +481,17 @@ async function launchBackend(): Promise { // An inherited string cannot replace Electron's installation authority. delete backendProcessEnvironment[EXTENSIONS_ENV] backendProcessEnvironment[EXTENSIONS_ENV] = launchSettings.extensions + const cleanupLaunch = await prepareBackendCleanup(BACKEND_CLEANUP_STATE, { + nonce: BACKEND_INSTANCE_NONCE, projectRoot: PROJECT_ROOT, pythonCommand: python, + }) + 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', @@ -506,7 +531,9 @@ async function stopBackend(): Promise { backendStopping = (async () => { extensionCleanupConfirmed = false - extensionCleanupConfirmed = await stopOwnedBackend(proc, () => requestBackendAction('/shutdown', 900)) + extensionCleanupConfirmed = await stopOwnedBackend(proc, () => requestBackendAction('/shutdown', 900), { + verifyCleanup: () => verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE), + }) if (pythonProcess === proc) pythonProcess = null backendOwned = false })().finally(() => { @@ -2228,7 +2255,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' ? 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 +2272,7 @@ 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 { 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) => { @@ -2821,6 +2854,8 @@ app.whenReady().then(async () => { ensureWindowsWallpaperTray() } let backendStartFailed = false + try { if (extensionCatalog.hasScheduled()) await extensionUpdateBoundary.run(() => extensionCatalog.applyScheduled()) } + catch (error) { desktopSettings.reportExtensionWarning(error instanceof Error ? error.message : 'Scheduled plugin installation failed; review Manage plugins.') } try { await startBackend() } catch (error) { @@ -2828,7 +2863,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 +2893,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..b014c584 100644 --- a/electron/src/renderer/components/ExtensionCatalog.tsx +++ b/electron/src/renderer/components/ExtensionCatalog.tsx @@ -60,7 +60,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('Schedule this version for next app start…')}
: 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..10953015 100644 --- a/electron/src/renderer/components/ExtensionSettings.tsx +++ b/electron/src/renderer/components/ExtensionSettings.tsx @@ -1,4 +1,4 @@ -import type { CatalogEntry, CatalogReply, CatalogView, CatalogProgress } from '../../shared/extensionCatalog.js' +import type { CatalogEntry, CatalogReply, CatalogView, CatalogProgress, CatalogRequest } from '../../shared/extensionCatalog.js' import ExtensionCatalog from './ExtensionCatalog' import { catalogBytes, recommendedUpdate } from './extensionCatalogModel' import { useEffect, useRef, useState } from 'react' @@ -62,7 +62,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 +117,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 +157,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: 'schedule', 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) @@ -268,9 +287,23 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn onClick={() => switchTab(value)}>{t(value === 'installed' ? 'Installed' : 'Browse')})}
+ + void updateControl({ action: 'auto-check', enabled })} /> 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('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.')}

+ {catalog.scheduledInstall || catalog.rollbackScheduled ?
+ {catalog.scheduledInstall ? `${catalog.scheduledInstall.id} → ${catalog.scheduledInstall.version} · ${catalog.scheduledInstall.digest}` : t('Rollback scheduled for the next app start.')} + void updateControl({ action: 'cancel-scheduled' })}>{t('Cancel scheduled change')} +
: null} + {catalog.pendingUpdate ?
+ {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.' : '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('I checked this version; keep it')} + void updateControl({ action: 'health' })}>{t('Check loaded plugin status')} + void updateControl({ action: 'rollback' })}>{t('Roll back on next app start…')} +
: null} {error || catalog.error ?
{t(error || catalog.error!)}
: null} {notice ?
{t(notice)}
: null} {progress ?
@@ -285,7 +318,7 @@ 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('Select a local wheel from an author you trust. The selected bytes are saved for installation at the next app start, with native confirmation.')}
{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 +373,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('Schedule update for next app start…')} showCatalog(update.id, update.version)}>{t('Review update details')}
: null} {pending ?
{t('Saved state')}: {t(savedState)} diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index 3e1cee17..53ea4687 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,6 +216,8 @@ "Chat window": "聊天窗口", "Chats": "对话", "Check a few lines": "核对几句对白", + "Check loaded plugin status": "检查已加载插件状态", + "Check the official catalog daily and notify me; never install automatically.": "每天检查官方目录并提示;不自动安装。", "Check updates and security status": "检查更新与安全状态", "Checking the official catalog…": "正在检查官方目录…", "Checking video support; static playback remains active.": "正在检测视频支持,当前继续静态播放。", @@ -533,6 +538,7 @@ "Hybrid local + DeepSeek": "混合 · 本地 + DeepSeek", "Hybrid local + OpenAI": "混合 · 本地 + OpenAI", "Hybrid · Local + OpenAI-compatible": "混合 · 本地 + OpenAI 兼容服务", + "I checked this version; keep it": "我已检查此版本,保留它", "I understand; remove the saved setting": "我已理解,删除已保存的设置", "I will start the game": "我自行启动游戏", "Identity & persona": "身份与人格", @@ -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.": "安装插件并管理设置。", @@ -835,6 +840,8 @@ "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.": "无法保存插件更新设置。", "Plugins": "插件", "Portrait art is optional. Without the Companion Lite pack, the window shows a simple avatar and captions.": "头像资源为可选项。未安装 Companion Lite 时,小窗显示简洁头像和字幕。", "Portrait overlay": "头像浮层", @@ -969,6 +976,8 @@ "Role identity": "角色身份", "Role name": "角色名称", "Role saved for the next backend start. The active role remains unchanged.": "角色已保存,将在下次后端启动时生效。当前角色未改变。", + "Roll back on next app start…": "下次应用启动时回滚…", + "Rollback scheduled for the next app start.": "已安排在下次应用启动时回滚。", "Routing & observation": "路由与观察", "Runner is ready": "扩展进程已就绪", "Runner is recovering; failed utterances are not retried": "扩展进程正在恢复,失败的语音请求不会重试", @@ -1016,6 +1025,9 @@ "Scale": "缩放", "Scale is relative to the fitted model. Position is relative to each viewport: −1 to 1.": "缩放相对于适配视口后的模型。位置相对于各自视口,范围为 −1 到 1。", "Scene capability status": "场景能力状态", + "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.": "已安排到下次应用启动。尚未更改已安装包;安装前仍需查看并批准原生确认。", "Scope": "范围", "Screen & image context": "屏幕与图像上下文", "Screenshot with my questions": "随提问附加截图", @@ -1028,6 +1040,7 @@ "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. 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 +1194,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.": "启动环境中已退役的关闭值会被忽略。此来源在桌面设置中为只读。", diff --git a/electron/src/shared/extensionCatalog.ts b/electron/src/shared/extensionCatalog.ts index de94d46e..21b90fa7 100644 --- a/electron/src/shared/extensionCatalog.ts +++ b/electron/src/shared/extensionCatalog.ts @@ -10,8 +10,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/backendProcessTree.test.mjs b/electron/tests/backendProcessTree.test.mjs index 57d2e39c..dd8a5a4a 100644 --- a/electron/tests/backendProcessTree.test.mjs +++ b/electron/tests/backendProcessTree.test.mjs @@ -1,10 +1,14 @@ import assert from 'node:assert/strict' import { EventEmitter } from 'node:events' +import fs from 'node:fs' +import os from 'node:os' +import { 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, + verifyPreviousBackendCleanup, markBackendCleanupUnconfirmed } = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) test('forced Windows backend stop targets only its owned PID and whole descendant tree without Host secrets', async () => { const invocations = [] @@ -40,20 +44,26 @@ function processFixture() { return proc } -test('graceful owned shutdown confirms lifetime without forced termination', async () => { +test('graceful owned shutdown requires the provider and process-tree receipt', async () => { const proc = processFixture() assert.equal(await stopOwnedBackend(proc, async () => true, { waitForExit: async () => { 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, { + 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 +87,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 +106,203 @@ 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) { + const directory = 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('registration survives crash and a later clean launch cannot erase it', 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('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', 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)) +}) + +test('real Windows tracking Job detects a child surviving its parent without killing it', { skip: process.platform !== 'win32' }, async t => { + const file = cleanupFixture(t) + const root = path.resolve('..') + const python = path.join(root, '.venv', 'Scripts', 'python.exe') + assert.equal(fs.existsSync(python), true, 'use the existing project Python, never install dependencies') + 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`) +}) + +test('a real backend exit without cleanup approval leaves diagnostic pending evidence', { skip: process.platform !== 'win32' }, async t => { + const file = cleanupFixture(t) + const root = path.resolve('..') + const python = path.join(root, '.venv', 'Scripts', 'python.exe') + 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# Exit without record_runtime_cleanup/finish, as a failed teardown would.\n`) + const result = spawnSync(python, ['-B', helper], { cwd: root, env: { ...process.env, ...environment }, encoding: 'utf8', timeout: 10000, windowsHide: true }) + assert.equal(result.status, 0, result.stdout + result.stderr) + const failed = path.join(`${file}.receipts`, `${launch.owner.id}.failed.json`) + for (let attempt = 0; attempt < 40 && !fs.existsSync(failed); attempt++) await new Promise(resolve => setTimeout(resolve, 25)) + assert.equal(JSON.parse(fs.readFileSync(failed, 'utf8')).reason, 'backend_cleanup_approval_missing') + assert.equal(await verifyPreviousBackendCleanup(file), false) + assert.equal(fs.existsSync(path.join(`${file}.receipts`, `${launch.owner.id}.json`)), false) +}) diff --git a/electron/tests/extensionCatalog.test.mjs b/electron/tests/extensionCatalog.test.mjs index acb27126..66f87f7d 100644 --- a/electron/tests/extensionCatalog.test.mjs +++ b/electron/tests/extensionCatalog.test.mjs @@ -3,6 +3,7 @@ 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) @@ -14,15 +15,18 @@ function fixture(t) { 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)) + return { root, store, helper, catalog, state, reopen: () => new ExtensionCatalog(path.join(root, 'catalog'), store, helper, installer, () => state.mirror, () => {}, download) } } test('renderer may select identity/action only, never supply URL/digest/review/source', () => { for (const field of ['url', 'sha256', 'digest', 'review', 'source', 'mirror']) assert.throws(() => catalogRequest({ action: 'install', id: 'p', version: '1.0.0', [field]: 'forged' })) @@ -119,3 +123,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() + 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()) + assert.deepEqual(f.state.installed,[]) + assert.equal((await f.catalog.view()).scheduledInstall,undefined) + const requests=f.state.requests.length + await f.reopen().applyScheduled() + 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() + 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() + 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(), failure === 'tamper' ? /bytes changed/ : /cancelled/) + } + assert.deepEqual(f.state.installed, []) + assert.equal((await f.catalog.view()).scheduledInstall, undefined) + await f.reopen().applyScheduled() + 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/extensionSettings.test.mjs b/electron/tests/extensionSettings.test.mjs index 8f1baf55..f1db1b19 100644 --- a/electron/tests/extensionSettings.test.mjs +++ b/electron/tests/extensionSettings.test.mjs @@ -319,3 +319,49 @@ test('opening Browse cannot implicitly recover damaged safety state', async t => page.button('Recover official extension 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('I checked this version; keep it').props.disabled,true) + page.button('Check loaded plugin status').props.onClick();await flush();page.render() + page.button('Roll back on next app start…').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'))) +}) diff --git a/electron/tests/extensionUpdates.test.mjs b/electron/tests/extensionUpdates.test.mjs new file mode 100644 index 00000000..e10267f2 --- /dev/null +++ b/electron/tests/extensionUpdates.test.mjs @@ -0,0 +1,33 @@ +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() +}) diff --git a/electron/tests/extensions.test.mjs b/electron/tests/extensions.test.mjs index 6c4f55b0..9a925c4e 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -108,7 +108,7 @@ function fixture(t, options = {}) { } 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 } + 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 +1172,223 @@ 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) + return f +} + +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.throws(()=>f.installer.assertUpdateCanLaunch(),/rollback/) + 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) + 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 }) + 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) +}) diff --git a/server/app.py b/server/app.py index 1a03740c..8410e58e 100644 --- a/server/app.py +++ b/server/app.py @@ -24,6 +24,11 @@ from queue import Queue from typing import Any, TYPE_CHECKING +# 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 + # Intel OpenMP, which torch uses for CPU work, keeps each worker thread spinning # for 200 ms after a parallel region. Silero VAD runs every 32 ms on the wake # and listening threads, so one worker never slept and the idle backend used a @@ -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("backend cleanup remains unconfirmed; extension updates are blocked") diff --git a/server/backend_cleanup.py b/server/backend_cleanup.py new file mode 100644 index 00000000..e49f14e8 --- /dev/null +++ b/server/backend_cleanup.py @@ -0,0 +1,310 @@ +"""Desktop-owned cleanup evidence; this module never terminates a process. + +Install the tracking Job before eager runtime imports. A clean receipt requires +successful Host teardown and a drained non-breakaway Job. A crash leaves the +desktop's pre-launch registration unresolved, even after every PID has exited. +""" +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) -> None: + if not path.is_absolute() or ".." in path.parts: + raise ValueError("cleanup path must be absolute") + for item in (*reversed(path.parents), path): + 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) -> None: + _ordinary_path(path) + 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) + 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"]) + _ordinary_path(state_path) + self.receipt = Path(str(state_path) + ".receipts") / f"{owner['id']}.json" + self.approval = self.receipt.with_suffix(".approval.json") + _ordinary_path(self.receipt) + 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() + 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: + 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, + }) + # 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) + 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" + _ordinary_path(receipt) + 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"], + }) + if kernel.WaitForSingleObject(root, 0xFFFFFFFF) != 0: + raise OSError("cleanup observer could not observe backend exit") + approval_path = receipt.with_suffix(".approval.json") + if not approval_path.exists(): + _atomic_json(receipt.with_suffix(".failed.json"), { + "schema": SCHEMA, "owner": owner, "reason": "backend_cleanup_approval_missing", + }) + return # Crash/failed teardown: registration remains unresolved. + _ordinary_path(approval_path) + approval = json.loads(approval_path.read_text(encoding="utf-8")) + if (approval.get("schema") != SCHEMA or approval.get("state") != "settled" + or approval.get("owner") != owner or approval.get("process") != process + or approval.get("job_name") != request["job_name"] + or approval.get("proof") != "windows-job-drained" + or approval.get("providers_settled") is not True + or approval.get("work_settled") is not True): + raise ValueError("cleanup observer approval mismatch") + deadline = time.monotonic() + 30 + while job.active_processes() != 0: + if time.monotonic() >= deadline: + raise TimeoutError("owned descendants survived backend cleanup") + time.sleep(.05) + _atomic_json(receipt, {**approval, "observer": { + "pid": os.getpid(), "created": _creation_identity(), + }}) + except Exception as exc: + _atomic_json(receipt.with_suffix(".failed.json"), {"schema": SCHEMA, "owner": owner, + "reason": type(exc).__name__}) + raise + finally: + job.close() + if root: + kernel.CloseHandle(root) + + +if __name__ == "__main__": + if len(sys.argv) != 3 or sys.argv[1] != "--observe" or os.name != "nt": + raise SystemExit(2) + _observe(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..ec751dda --- /dev/null +++ b/tests/test_backend_cleanup.py @@ -0,0 +1,177 @@ +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 + 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() + + +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"]} From 67b18304771d090e294065f060b82ebdb4bb9677 Mon Sep 17 00:00:00 2001 From: Desolate-Jix Date: Sat, 24 Oct 2026 18:05:19 +1300 Subject: [PATCH 02/13] fix(extensions): repair cross-platform cleanup CI contracts --- config/README.md | 1 + config/catalog_legacy.json | 3 ++ electron/src/main/backendProcessTree.ts | 14 ++++++- electron/tests/backendProcessTree.test.mjs | 47 ++++++++++++++++++++-- server/app.py | 10 ++--- tests/test_chat_ingress_lifecycle.py | 11 ++++- tests/test_vts_worker_lifecycle.py | 17 ++++++-- 7 files changed, 87 insertions(+), 16 deletions(-) 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/electron/src/main/backendProcessTree.ts b/electron/src/main/backendProcessTree.ts index e52a95e1..fc88331d 100644 --- a/electron/src/main/backendProcessTree.ts +++ b/electron/src/main/backendProcessTree.ts @@ -89,7 +89,19 @@ async function atomicIndex(file: string, value: CleanupIndex): Promise { ordinaryPath(file) if (process.platform === 'win32') { const quote = (value: string) => "'" + value.replaceAll("'", "''") + "'" - await powershell(`$ErrorActionPreference='Stop'; Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class BackendCleanupMove { [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool MoveFileEx(string a,string b,int flags); }'; if(-not [BackendCleanupMove]::MoveFileEx(${quote(temporary)},${quote(file)},9)){exit 3}`) + // 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') diff --git a/electron/tests/backendProcessTree.test.mjs b/electron/tests/backendProcessTree.test.mjs index dd8a5a4a..2834915d 100644 --- a/electron/tests/backendProcessTree.test.mjs +++ b/electron/tests/backendProcessTree.test.mjs @@ -108,7 +108,9 @@ test('exit observation releases its listener on success and timeout', async () = }) function cleanupFixture(t) { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-cleanup-')) + // 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') } @@ -134,6 +136,32 @@ test('new-protocol fresh state differs from a persisted unknown reused backend', 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)) + 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) +}) + +test('a failed durable Windows replacement preserves the original index', { skip: process.platform !== 'win32' }, async t => { + const file = cleanupFixture(t) + await markBackendCleanupUnconfirmed(file) + 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) } +}) + test('registration survives crash and a later clean launch cannot erase it', async t => { const file = cleanupFixture(t) await prepareBackendCleanup(file, launchIdentity) @@ -265,11 +293,22 @@ test('corrupt and missing-index receipt state never become fresh', async t => { 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() +} + test('real Windows tracking Job detects a child surviving its parent without killing it', { skip: process.platform !== 'win32' }, async t => { const file = cleanupFixture(t) const root = path.resolve('..') - const python = path.join(root, '.venv', 'Scripts', 'python.exe') - assert.equal(fs.existsSync(python), true, 'use the existing project Python, never install dependencies') + 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') @@ -293,7 +332,7 @@ test('real Windows tracking Job detects a child surviving its parent without kil test('a real backend exit without cleanup approval leaves diagnostic pending evidence', { skip: process.platform !== 'win32' }, async t => { const file = cleanupFixture(t) const root = path.resolve('..') - const python = path.join(root, '.venv', 'Scripts', 'python.exe') + 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') diff --git a/server/app.py b/server/app.py index 8410e58e..e4ae6357 100644 --- a/server/app.py +++ b/server/app.py @@ -24,11 +24,6 @@ from queue import Queue from typing import Any, TYPE_CHECKING -# 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 - # Intel OpenMP, which torch uses for CPU work, keeps each worker thread spinning # for 200 ms after a parallel region. Silero VAD runs every 32 ms on the wake # and listening threads, so one worker never slept and the idle backend used a @@ -36,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 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_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() From a277d628948a58d1697bb775d84cd11522d9437c Mon Sep 17 00:00:00 2001 From: Desolate-Jix Date: Sat, 24 Oct 2026 18:45:04 +1300 Subject: [PATCH 03/13] fix(deps): update filelock and setuptools security pins Upgrade filelock to 4.2.4 and setuptools to 84.2.1, including the isolated Qwen-ASR build. Constrain optional filelock consumers without adding model dependencies to the core profile. Validated 137 tests (2 skipped), dependency audit with the existing pytest waiver, environment compatibility, lock consistency, Ruff, architecture views, seven extension wheels, and cross-process FileLock/SoftFileLock/Hugging Face WeakFileLock behavior. --- pyproject.toml | 7 +++++-- tests/test_headless_inference_dependencies.py | 4 ++-- uv.lock | 17 ++++++++++------- vendor/qwen-asr.PROVENANCE.md | 5 ++++- vendor/qwen-asr/pyproject.toml | 4 ++-- 5 files changed, 23 insertions(+), 14 deletions(-) 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/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/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"] From 1124b6e7e6774570fdc3368c3df1dcfb3c552e48 Mon Sep 17 00:00:00 2001 From: Desolate-Jix Date: Sat, 24 Oct 2026 19:17:49 +1300 Subject: [PATCH 04/13] fix(extensions): qualify queued updates across real app restarts Adapt store, shipping-shell, failure-recovery and publication journeys to schedule changes before stopped startup and explicitly keep healthy updates. Install loopback test transport before app readiness through the owned inspector; preserve native confirmation and installation boundaries. Warm both retained versions through the canonical validator before reading cold recovery state. Bind packaged cleanup receipts to the code root resolved by the supplied Python runtime instead of the frontend resources directory. Validated 649 Electron tests, 38 cleanup/launcher Python tests, 30 store checks, the shipping failure journey with 11 restarts, and 27 packaged publication checks across six publications and 11 restarts. Windows executable build, Ruff and architecture checks passed. --- electron/src/main/backendProcessTree.ts | 14 +++ electron/src/main/desktopSettings.ts | 4 +- electron/src/main/extensionUpdates.ts | 4 +- electron/src/main/extensions.ts | 10 ++ electron/src/main/index.ts | 10 +- electron/tests/backendProcessTree.test.mjs | 24 ++++- electron/tests/extensionStore.smoke.mjs | 37 ++++--- electron/tests/extensions.test.mjs | 32 ++++++ tests/test_electron_model_less_smoke.py | 13 +++ tools/e2e_live_product_journey.py | 11 +- tools/probes/extension_release_failures.py | 73 +++++++------ tools/probes/extension_release_shell.py | 21 +++- tools/probes/extension_startup.py | 120 +++++++++++++++++++++ tools/probes/extension_store_roundtrip.py | 55 +++++++--- 14 files changed, 358 insertions(+), 70 deletions(-) create mode 100644 tools/probes/extension_startup.py diff --git a/electron/src/main/backendProcessTree.ts b/electron/src/main/backendProcessTree.ts index fc88331d..4a1180e1 100644 --- a/electron/src/main/backendProcessTree.ts +++ b/electron/src/main/backendProcessTree.ts @@ -4,6 +4,20 @@ import fs from 'node:fs' import path from 'node:path' 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[] } type ProcessIdentity = { pid: number; created: string } diff --git a/electron/src/main/desktopSettings.ts b/electron/src/main/desktopSettings.ts index 5e191945..15ebf143 100644 --- a/electron/src/main/desktopSettings.ts +++ b/electron/src/main/desktopSettings.ts @@ -662,10 +662,10 @@ export class DesktopSettingsStore { } /** Called only by the installation owner after canonical validation. */ - validateExtensionRollbackRecord(raw: unknown): StoredExtension { + 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 - const clean = cleanStoredExtensions({ [id]: raw }, this.validateExtensionRecord) + const clean = cleanStoredExtensions({ [id]: raw }, validate) if (clean.errors.length || !clean.records[id]) throw new Error('Invalid plugin recovery package record') return clean.records[id] } diff --git a/electron/src/main/extensionUpdates.ts b/electron/src/main/extensionUpdates.ts index b9ff0263..0ca6a322 100644 --- a/electron/src/main/extensionUpdates.ts +++ b/electron/src/main/extensionUpdates.ts @@ -47,14 +47,14 @@ 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(): ExtensionUpdateRecord | undefined { + 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 = this.validate(raw.previous), target = this.validate(raw.target) + 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 } } diff --git a/electron/src/main/extensions.ts b/electron/src/main/extensions.ts index 53aca6ac..4bfe4a0b 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -206,6 +206,16 @@ export class ExtensionInstaller { 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 diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index e133cb16..3296dfed 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -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 { markBackendCleanupUnconfirmed, prepareBackendCleanup, stopOwnedBackend, verifyPreviousBackendCleanup } 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' @@ -482,7 +482,8 @@ async function launchBackend(): Promise { delete backendProcessEnvironment[EXTENSIONS_ENV] backendProcessEnvironment[EXTENSIONS_ENV] = launchSettings.extensions const cleanupLaunch = await prepareBackendCleanup(BACKEND_CLEANUP_STATE, { - nonce: BACKEND_INSTANCE_NONCE, projectRoot: PROJECT_ROOT, pythonCommand: python, + nonce: BACKEND_INSTANCE_NONCE, + projectRoot: await resolveBackendProjectRoot(python, PROJECT_ROOT, backendProcessEnvironment), pythonCommand: python, }) extensionCleanupConfirmed = false pythonProcess = spawn(python, ['-m', 'server.app', '--port', String(BACKEND_PORT)], { @@ -2854,7 +2855,10 @@ app.whenReady().then(async () => { ensureWindowsWallpaperTray() } let backendStartFailed = false - try { if (extensionCatalog.hasScheduled()) await extensionUpdateBoundary.run(() => extensionCatalog.applyScheduled()) } + try { + await extensionInstaller.prepareStartup() + if (extensionCatalog.hasScheduled()) await extensionUpdateBoundary.run(() => extensionCatalog.applyScheduled()) + } catch (error) { desktopSettings.reportExtensionWarning(error instanceof Error ? error.message : 'Scheduled plugin installation failed; review Manage plugins.') } try { await startBackend() diff --git a/electron/tests/backendProcessTree.test.mjs b/electron/tests/backendProcessTree.test.mjs index 2834915d..5d824345 100644 --- a/electron/tests/backendProcessTree.test.mjs +++ b/electron/tests/backendProcessTree.test.mjs @@ -8,7 +8,29 @@ import test from 'node:test' import { loadTypeScript } from './helpers/loadTypeScript.mjs' const { forceStopBackendTree, stopOwnedBackend, waitForProcessExit, prepareBackendCleanup, - verifyPreviousBackendCleanup, markBackendCleanupUnconfirmed } = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) + verifyPreviousBackendCleanup, markBackendCleanupUnconfirmed, resolveBackendProjectRoot } = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) + +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 = [] diff --git a/electron/tests/extensionStore.smoke.mjs b/electron/tests/extensionStore.smoke.mjs index 0346eb0f..b6838ae5 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) 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 boundary.run(()=>catalog.applyScheduled());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(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.querySelector('.extension-catalog-toolbar button').disabled",'Refresh not finished') const before=confirms - await ui("(()=>{const b=Array.from(document.querySelectorAll('button')).find(n=>n.getClientRects().length&&n.textContent==='Install plugin…');b.click();b.click()})()") - await installed(themeId,'0.1.0');check('double click commits one install',confirms===before+1) + 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') @@ -112,7 +118,7 @@ try { 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,10 +126,13 @@ 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(boundary.run(()=>catalog.applyScheduled()),/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('I checked this version; keep it');await until("!document.body.innerText.includes('I checked this version; keep it')",'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') @@ -133,12 +142,12 @@ try { 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/extensions.test.mjs b/electron/tests/extensions.test.mjs index 9a925c4e..99a8f3ea 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -1186,6 +1186,38 @@ async function catalogUpdateFixture(t) { 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) + assert.throws(() => reopened.pendingUpdate(), /Invalid plugin recovery package record/) + 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 never treats missing or rejected semantic validation as startup authority', async t => { + const f = await catalogUpdateFixture(t) + await f.installer.install(f.selectedWheel, f.delivery) + 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) + await assert.rejects(reopened.prepareStartup(), /Invalid plugin recovery package record/) + assert.throws(() => reopened.assertUpdateCanLaunch(), /Invalid plugin recovery package record/) + assert.ok(fs.existsSync(path.join(f.root, f.previous.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 diff --git a/tests/test_electron_model_less_smoke.py b/tests/test_electron_model_less_smoke.py index aabcf703..75c53e11 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 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/extension_release_failures.py b/tools/probes/extension_release_failures.py index 4496ab0b..5cef82f9 100644 --- a/tools/probes/extension_release_failures.py +++ b/tools/probes/extension_release_failures.py @@ -53,6 +53,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 +99,22 @@ 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']: + 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()') + return {'ok': bool(current and current['version'] == version), 'error': '; '.join(snapshot['extensionErrors'])} async def restart(damage=None, offline=False): old_pid = product.process.pid + behavior = await main('(()=>{const s=extensionReleaseFixture;return {badWheel:s.badWheel,confirmations:s.confirmations}})()') + 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 +124,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 +153,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( @@ -217,12 +215,20 @@ 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", ) + health = await catalog({'action':'health'}) + check('failed update cannot be kept', health['catalog']['pendingUpdate']['recoveryRequired'] + and not (await catalog({'action':'confirm-update'}))['ok']) + 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')) await update(tone, False) assert (await product.page.evaluate("id=>window.amadeus.removeExtension(id)", tone))["ok"] await backend_restart() @@ -269,9 +275,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..cdd88a10 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 ) @@ -235,11 +250,13 @@ async def install(package_id, version): "(()=>{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("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..b3f6774e --- /dev/null +++ b/tools/probes/extension_startup.py @@ -0,0 +1,120 @@ +"""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='I checked this version; keep it', exact=True).click() + from playwright.async_api import expect + await expect(product.page.get_by_role('button', name='I checked this version; keep it', 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') diff --git a/tools/probes/extension_store_roundtrip.py b/tools/probes/extension_store_roundtrip.py index a9ecfe7c..99ec5366 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 @@ -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) From d17af14f853036cbce9c95bc025d0dd67c66a743 Mon Sep 17 00:00:00 2001 From: Desolate-Jix Date: Sat, 24 Oct 2026 19:49:08 +1300 Subject: [PATCH 05/13] fix(e2e): stabilize packaged startup fixture lifecycle --- electron/tests/fixtures/extensionRelease.cjs | 51 +++++++++++++++----- tools/probes/extension_release_failures.py | 7 ++- tools/probes/extension_startup.py | 8 +++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/electron/tests/fixtures/extensionRelease.cjs b/electron/tests/fixtures/extensionRelease.cjs index e327097b..5556188f 100644 --- a/electron/tests/fixtures/extensionRelease.cjs +++ b/electron/tests/fixtures/extensionRelease.cjs @@ -1,22 +1,51 @@ // Loaded only through the owned smoke process inspector; never shipped. async function setupReleaseFixture(base, automaticConfirm = true) { const require = process.getBuiltinModule('module').createRequire(process.execPath) - const {net, session, dialog} = require('electron') - const fs = require('node:fs'), path = require('node:path'), https = require('node:https') + const {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) + // 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/tools/probes/extension_release_failures.py b/tools/probes/extension_release_failures.py index 5cef82f9..4b0b2694 100644 --- a/tools/probes/extension_release_failures.py +++ b/tools/probes/extension_release_failures.py @@ -102,13 +102,17 @@ async def install(package_id, 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()') - return {'ok': bool(current and current['version'] == version), 'error': '; '.join(snapshot['extensionErrors'])} + 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): old_pid = product.process.pid @@ -174,6 +178,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" diff --git a/tools/probes/extension_startup.py b/tools/probes/extension_startup.py index b3f6774e..816ba6fb 100644 --- a/tools/probes/extension_startup.py +++ b/tools/probes/extension_startup.py @@ -118,3 +118,11 @@ async def command(method, params=None): 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)) From 31d3aa3f0911ef28ddf4d11a3fca7a839fffa9fe Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Sun, 25 Oct 2026 18:04:39 +1300 Subject: [PATCH 06/13] fix(extensions): allow configuring pending updates before confirmation --- electron/src/main/extensions.ts | 29 ++++++-- electron/tests/extensions.test.mjs | 102 ++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/electron/src/main/extensions.ts b/electron/src/main/extensions.ts index 4bfe4a0b..60ce3bda 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -228,7 +228,7 @@ export class ExtensionInstaller { } private observeUpdateHealth(statuses: unknown): void { const update = this.updates.read() - if (!update || update.owner !== this.updates.owner || this.updateRecoveryRequired) return + 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) : [] @@ -246,7 +246,10 @@ export class ExtensionInstaller { && 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)) - this.updates.write({ ...update, phase: healthy ? 'startup_confirmed' : 'recovery_required' }) + // 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() }) @@ -497,7 +500,8 @@ export class ExtensionInstaller { 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() - if (pending?.target.id === raw?.id) { + 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 (raw.enabled !== false || !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.') @@ -510,6 +514,9 @@ export class ExtensionInstaller { 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') @@ -538,8 +545,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) diff --git a/electron/tests/extensions.test.mjs b/electron/tests/extensions.test.mjs index 99a8f3ea..6d1a6693 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)) } } @@ -1261,7 +1263,7 @@ test('health receipt retains rollback until explicit user keep; missing or faile await f.installer.confirmPendingUpdate() assert.equal(f.installer.pendingUpdate(),undefined) } else { - assert.throws(()=>f.installer.assertUpdateCanLaunch(),/rollback/) + assert.doesNotThrow(()=>f.installer.assertUpdateCanLaunch()) await assert.rejects(f.installer.confirmPendingUpdate(),/startup has not/) } } @@ -1424,3 +1426,99 @@ test('partial or duplicate backend contribution health cannot discard the recove 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'}]} +} + +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) + }) +} From 895a7ce8f6efbd7471d58f0024a00b7d134b21ca Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Sun, 25 Oct 2026 18:16:50 +1300 Subject: [PATCH 07/13] fix(extensions): preserve immediate installation outside Windows --- docs/evidence/pr261-update-repairs/README.md | 47 ++++++++++++ .../pr261-update-repairs/linux-after.png | Bin 0 -> 28792 bytes .../pr261-update-repairs/linux-before.png | Bin 0 -> 28418 bytes .../pr261-update-repairs/windows-after.png | Bin 0 -> 28274 bytes docs/extension_authoring.md | 20 ++++++ electron/src/main/backendProcessTree.ts | 8 ++- electron/src/main/extensionCatalog.ts | 15 +++- electron/src/main/extensions.ts | 9 ++- electron/src/main/index.ts | 25 ++++--- .../renderer/components/ExtensionCatalog.tsx | 6 +- .../renderer/components/ExtensionSettings.tsx | 19 +++-- .../src/renderer/components/SettingsPage.tsx | 2 +- electron/src/renderer/locales/zh-CN.json | 4 ++ electron/src/shared/extensionCatalog.ts | 6 ++ electron/tests/backendProcessTree.test.mjs | 27 ++++++- electron/tests/extensionCatalog.test.mjs | 26 ++++++- electron/tests/extensionCatalogUI.test.mjs | 10 +++ .../tests/extensionInstallRouting.test.mjs | 67 ++++++++++++++++++ electron/tests/extensionSettings.test.mjs | 21 +++++- electron/tests/extensions.test.mjs | 49 +++++++++++-- 20 files changed, 324 insertions(+), 37 deletions(-) create mode 100644 docs/evidence/pr261-update-repairs/README.md create mode 100644 docs/evidence/pr261-update-repairs/linux-after.png create mode 100644 docs/evidence/pr261-update-repairs/linux-before.png create mode 100644 docs/evidence/pr261-update-repairs/windows-after.png create mode 100644 electron/tests/extensionInstallRouting.test.mjs 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 0000000000000000000000000000000000000000..896ef248a41d810789a31f4ed695073ae739a2be GIT binary patch literal 28792 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

@<0Uvr_(VUFfxw*>JJ{+xPq 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 literal 0 HcmV?d00001 diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md index b0ad5942..0c86dab7 100644 --- a/docs/extension_authoring.md +++ b/docs/extension_authoring.md @@ -20,6 +20,26 @@ 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 the owned backend cleanup receipt. +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. + +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 is retained. + ## Minimal static checker environment From this Host checkout on Windows with CPython 3.12.10: diff --git a/electron/src/main/backendProcessTree.ts b/electron/src/main/backendProcessTree.ts index 4a1180e1..7b50345e 100644 --- a/electron/src/main/backendProcessTree.ts +++ b/electron/src/main/backendProcessTree.ts @@ -2,6 +2,7 @@ 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' @@ -125,7 +126,9 @@ if(-not $native::MoveFileExW(${quote(temporary)},${quote(file)},9)){exit 3}`) /** 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 }): Promise<{ environment: Record }> { + { nonce, projectRoot, pythonCommand }: { nonce: string; projectRoot: string; pythonCommand: string }, + platform: NodeJS.Platform = process.platform): Promise<{ environment: Record }> { + if (!usesScheduledExtensionUpdates(platform)) return { environment: {} } return serializeCleanup(statePath, async () => { const index = readIndex(statePath) ?? { schema: CLEANUP_SCHEMA, unknown: false, owners: [] } const owner: CleanupOwner = { id: randomBytes(16).toString('hex'), nonce, @@ -237,6 +240,9 @@ export async function stopOwnedBackend(proc: ChildProcess, requestShutdown: () = } = {}): Promise { const requested = await requestShutdown() if (requested && await waitForExit(proc, 15000)) { + // 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. diff --git a/electron/src/main/extensionCatalog.ts b/electron/src/main/extensionCatalog.ts index dc413563..7eb64a0c 100644 --- a/electron/src/main/extensionCatalog.ts +++ b/electron/src/main/extensionCatalog.ts @@ -4,6 +4,7 @@ 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, removeExtensionDirectory } from './extensions.js' import { artifactUrl, downloadExtension, httpsUrl, MAX_INDEX_BYTES, MAX_WHEEL_BYTES, stagingDirectory, type Download } from './extensionDownloads.js' @@ -34,10 +35,14 @@ export class ExtensionCatalog { constructor(private readonly root: string, private readonly store: DesktopSettingsStore, private readonly helper: ExtensionPackageHelper, private readonly installer: ExtensionInstaller, private readonly mirror: () => string, private readonly changed: () => void = () => {}, private readonly download: Download = downloadExtension, - private readonly notifyUpdates: (updates: Array<{ id: string; version: string }>) => void = () => {}) {} + 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') { @@ -104,6 +109,7 @@ export class ExtensionCatalog { } /** 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) @@ -166,8 +172,13 @@ export class ExtensionCatalog { } } /** Only the app's stopped-backend boundary calls this before launching Host. */ - hasScheduled(): boolean { const preferences = this.preferences(); return Boolean(preferences.pending || preferences.local || preferences.rollback) } + hasScheduled(): boolean { + if (!usesScheduledExtensionUpdates(this.platform)) return false + const preferences = this.preferences() + return Boolean(preferences.pending || preferences.local || preferences.rollback) + } async applyScheduled(): Promise { + if (!usesScheduledExtensionUpdates(this.platform)) throw new Error('Scheduled plugin updates are only available on Windows.') const preferences = this.preferences() if (preferences.rollback) { this.savePreferences({ ...preferences, rollback: false }) diff --git a/electron/src/main/extensions.ts b/electron/src/main/extensions.ts index 60ce3bda..3948511a 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -15,6 +15,7 @@ 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 { contributionProfile, extensionNeedsAgent, extensionRequiresRestart } from '../shared/extensionProfile.js' @@ -202,7 +203,8 @@ export class ExtensionInstaller { private readonly sdkDirectory = path.join(projectRoot, 'vendor', 'extension-sdk'), private readonly themes = new ExtensionThemeResources(), private readonly changed: () => void = () => {}, - private readonly assertInstallIdle: () => void = () => {}) { + private readonly assertInstallIdle: () => void = () => {}, + private readonly platform: NodeJS.Platform = process.platform) { this.updates = new ExtensionUpdateJournal(root, raw => store.validateExtensionRollbackRecord(raw)) } @@ -272,6 +274,7 @@ export class ExtensionInstaller { } rollbackPendingUpdate(): Promise> { return this.serialize(async () => { + if (!usesScheduledExtensionUpdates(this.platform)) throw new Error('Scheduled plugin updates are only available on Windows.') this.assertInstallIdle() const update = this.updates.read() if (!update) throw new Error('No plugin update is waiting for rollback.') @@ -451,7 +454,7 @@ 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) { + 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 }) @@ -504,7 +507,7 @@ export class ExtensionInstaller { 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 (raw.enabled !== false || !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.') + 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 }, diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 3296dfed..09caf1cf 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -43,6 +43,7 @@ import type { ExtensionRemovalOptions, ExtensionUpdate } from '../shared/extensi 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' @@ -67,6 +68,7 @@ const desktopSettings = new DesktopSettingsStore( process.platform, extensionPackageHelper.validateRecord, ) +const scheduledExtensionUpdates = usesScheduledExtensionUpdates(process.platform) const extensionUpdateBoundary = new ExtensionUpdateBoundary(async () => !pythonProcess && !backendOwned && !backendStarting && !backendStopping && extensionCleanupConfirmed && await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE) && await localPortIsClosed(BACKEND_PORT)) const extensionInstaller = new ExtensionInstaller(path.join(USER_DATA_DIR, 'extensions'), EXTENSION_HOST_ROOT, @@ -84,7 +86,7 @@ const extensionInstaller = new ExtensionInstaller(path.join(USER_DATA_DIR, 'exte 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(), extensionUpdateBoundary.assertHeld) + }), () => notifyDesktopThemeChanged(), scheduledExtensionUpdates ? extensionUpdateBoundary.assertHeld : 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(), undefined, @@ -442,14 +444,14 @@ async function launchBackend(): Promise { const health = await backendHealthStatus() if (applicationLifecycle.isQuitting) return if (health === 'ready') { - await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) + if (scheduledExtensionUpdates) await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) extensionCleanupConfirmed = false backendOwned = true console.log(`[electron] backend already available on ${BACKEND_WS}; reusing it`) return } if (health === 'starting') { - await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) + if (scheduledExtensionUpdates) await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) extensionCleanupConfirmed = false backendOwned = true console.log(`[electron] backend is assembling on ${BACKEND_WS}; waiting for readiness`) @@ -463,9 +465,9 @@ async function launchBackend(): Promise { const python = getPythonCommand() - // A closed port does not prove old provider descendants have stopped. - // Only the persisted launch/child receipt authorizes removal or installation. - extensionCleanupConfirmed = await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE) + // Windows maintenance requires the persisted descendant receipt, not merely + // a closed port. Other platforms retain their existing cleanup contract. + if (scheduledExtensionUpdates) extensionCleanupConfirmed = await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE) if (extensionCleanupConfirmed) await extensionInstaller.cleanupStoppedBackend() console.log(`[electron] starting backend: ${python} -m server.app --port ${BACKEND_PORT}`) @@ -480,10 +482,11 @@ 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 const cleanupLaunch = await prepareBackendCleanup(BACKEND_CLEANUP_STATE, { nonce: BACKEND_INSTANCE_NONCE, - projectRoot: await resolveBackendProjectRoot(python, PROJECT_ROOT, backendProcessEnvironment), pythonCommand: python, + projectRoot: scheduledExtensionUpdates ? await resolveBackendProjectRoot(python, PROJECT_ROOT, backendProcessEnvironment) : PROJECT_ROOT, pythonCommand: python, }) extensionCleanupConfirmed = false pythonProcess = spawn(python, ['-m', 'server.app', '--port', String(BACKEND_PORT)], { @@ -2260,7 +2263,7 @@ ipcMain.handle('extensions.catalog', async (event, raw: unknown) => { const dispatch = () => extensionCatalog.dispatch(raw, process.env, url => shell.openExternal(url)) try { if (action === 'health' && backendOwned) await extensionInstaller.refreshUpdateHealth(await extensionStartupStatus()) - return action === 'install' ? await extensionUpdateBoundary.run(dispatch) : await dispatch() + 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' } } }) @@ -2273,7 +2276,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 { await extensionCatalog.scheduleLocal(selection.filePaths[0]); return { ok: true, scheduled: true } } + 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) => { diff --git a/electron/src/renderer/components/ExtensionCatalog.tsx b/electron/src/renderer/components/ExtensionCatalog.tsx index b014c584..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('Schedule this version for next app start…')} + 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 10953015..f0800390 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, 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('') @@ -157,7 +160,7 @@ 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: 'schedule', 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') @@ -292,7 +295,9 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn 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('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.')}

+

{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.')}

{catalog.scheduledInstall || catalog.rollbackScheduled ?
{catalog.scheduledInstall ? `${catalog.scheduledInstall.id} → ${catalog.scheduledInstall.version} · ${catalog.scheduledInstall.digest}` : t('Rollback scheduled for the next app start.')} void updateControl({ action: 'cancel-scheduled' })}>{t('Cancel scheduled change')} @@ -318,7 +323,9 @@ export default function ExtensionSettings({ extensions, runtime, providers, conn
{t('About plugin installation')} -
{t('Select a local wheel from an author you trust. The selected bytes are saved for installation at the next app start, with native confirmation.')}
+
{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('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} @@ -373,7 +380,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('Schedule update for next app start…')} 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)} @@ -457,7 +464,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 53ea4687..dbfebb81 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -963,6 +963,9 @@ "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.": "管理参考资料,了解会话历史的保存范围。", @@ -1040,6 +1043,7 @@ "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.": "请从左侧选择一个预设。", diff --git a/electron/src/shared/extensionCatalog.ts b/electron/src/shared/extensionCatalog.ts index 21b90fa7..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 diff --git a/electron/tests/backendProcessTree.test.mjs b/electron/tests/backendProcessTree.test.mjs index 5d824345..cdc7887f 100644 --- a/electron/tests/backendProcessTree.test.mjs +++ b/electron/tests/backendProcessTree.test.mjs @@ -7,9 +7,13 @@ import path from 'node:path' import test from 'node:test' import { loadTypeScript } from './helpers/loadTypeScript.mjs' -const { forceStopBackendTree, stopOwnedBackend, waitForProcessExit, prepareBackendCleanup, +const { forceStopBackendTree, stopOwnedBackend, waitForProcessExit, prepareBackendCleanup: preparePlatformBackendCleanup, verifyPreviousBackendCleanup, markBackendCleanupUnconfirmed, resolveBackendProjectRoot } = 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 prepareBackendCleanup = (file, launch, platform = 'win32') => preparePlatformBackendCleanup(file, launch, platform) + 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 } @@ -66,15 +70,32 @@ function processFixture() { return proc } +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.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 the provider and process-tree receipt', async () => { const proc = processFixture() - assert.equal(await stopOwnedBackend(proc, async () => true, { + assert.equal(await stopOwnedBackend(proc, async () => true, { platform: 'win32', waitForExit: async () => { 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, { + assert.equal(await stopOwnedBackend(noReceipt, async () => true, { platform: 'win32', waitForExit: async () => { noReceipt.exit(); return true }, }), false) }) diff --git a/electron/tests/extensionCatalog.test.mjs b/electron/tests/extensionCatalog.test.mjs index 66f87f7d..8ffed0e2 100644 --- a/electron/tests/extensionCatalog.test.mjs +++ b/electron/tests/extensionCatalog.test.mjs @@ -9,7 +9,7 @@ const mocks = { electron: { safeStorage: { isEncryptionAvailable: () => true } } const { ExtensionCatalog, catalogRequest, OFFICIAL_EXTENSION_INDEX } = loadTypeScript(new URL('../src/main/extensionCatalog.ts', import.meta.url), mocks) const { DesktopSettingsStore } = loadTypeScript(new URL('../src/main/desktopSettings.ts', import.meta.url), mocks) const entry = () => ({ id: 'org.example.demo', version: '0.1.0', sha256: 'a'.repeat(64), url: 'https://source.example/demo-0.1.0-py3-none-any.whl', size: 5, display: { homepage: 'https://example.org/' }, compatibility: {}, publisher: {}, review: {}, withdrawal: null, incompatible: [] }) -function fixture(t) { +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')) @@ -25,14 +25,34 @@ function fixture(t) { fs.writeFileSync(target, url === OFFICIAL_EXTENSION_INDEX ? JSON.stringify(state.index) : 'wheel') } state.notifications = [] - const catalog = new ExtensionCatalog(path.join(root, 'catalog'), store, helper, installer, () => state.mirror, () => {}, download, updates => state.notifications.push(updates)) - return { root, store, helper, catalog, state, reopen: () => new ExtensionCatalog(path.join(root, 'catalog'), store, helper, installer, () => state.mirror, () => {}, download) } + 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, 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(), /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') 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/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.test.mjs b/electron/tests/extensionSettings.test.mjs index f1db1b19..9f8ff28b 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', () => { @@ -365,3 +365,22 @@ test('native local import reports a queued change instead of claiming an install assert.equal(writes, 0) assert.ok(page.find(node => node.props?.role === 'status' && String(node.props.children).includes('Scheduled for the next app start'))) }) + +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/extensions.test.mjs b/electron/tests/extensions.test.mjs index 6d1a6693..1438f6a9 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -109,7 +109,8 @@ 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) + 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 } } @@ -1184,7 +1185,8 @@ async function catalogUpdateFixture(t) { 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) + 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 } @@ -1198,7 +1200,8 @@ test('cold startup validates both retained versions before reading recovery auth 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) + 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(), /Invalid plugin recovery package record/) await reopened.prepareStartup() assert.deepEqual([...new Set(seen)].sort(), ['0.1.0', '0.2.0']) @@ -1213,7 +1216,8 @@ test('cold recovery never treats missing or rejected semantic validation as star await f.installer.install(f.selectedWheel, f.delivery) 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) + const reopened = new ExtensionInstaller(f.root, f.directory, store, f.helper, () => 'trusted-python', {}, f.run, async () => true, + undefined, undefined, undefined, undefined, 'win32') await assert.rejects(reopened.prepareStartup(), /Invalid plugin recovery package record/) assert.throws(() => reopened.assertUpdateCanLaunch(), /Invalid plugin recovery package record/) assert.ok(fs.existsSync(path.join(f.root, f.previous.installId))) @@ -1352,7 +1356,8 @@ test('late health may permit explicit keep in the same process; another process 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) + 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()) @@ -1386,7 +1391,8 @@ test('rollback rechecks retained wheel bytes after the native confirmation', asy 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 }) + 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()) @@ -1432,6 +1438,37 @@ function healthyUpdateRow(record) { startup: 'accepted', state: 'active', contributions: [{kind: 'mcp_server', id: 'tools', health: 'ready'}]} } +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) From d509fcc0d0ec0ad06baafa4ed2692fe9a2ac234f Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Sun, 25 Oct 2026 18:33:24 +1300 Subject: [PATCH 08/13] fix(ui): align plugin updates with existing settings layout --- docs/evidence/pr261-update-ui/README.md | 46 ++++++++++++ docs/evidence/pr261-update-ui/capture.py | 69 ++++++++++++++++++ .../pr261-update-ui/live-default-zh.png | Bin 0 -> 51909 bytes .../pr261-update-ui/live-ready-dark-zh.png | Bin 0 -> 102285 bytes .../pr261-update-ui/live-ready-zh.png | Bin 0 -> 68831 bytes .../live-recovery-narrow-zh.png | Bin 0 -> 60393 bytes .../pr261-update-ui/live-scheduled-zh.png | Bin 0 -> 64723 bytes .../renderer/components/ExtensionSettings.tsx | 67 ++++++++++++----- electron/src/renderer/locales/zh-CN.json | 23 ++++-- electron/src/renderer/styles/index.css | 6 +- electron/tests/extensionSettings.smoke.mjs | 6 +- electron/tests/extensionSettings.test.mjs | 59 ++++++++++++++- electron/tests/extensionStore.smoke.mjs | 8 +- tools/probes/extension_release_shell.py | 2 +- tools/probes/extension_startup.py | 4 +- tools/probes/extension_store_roundtrip.py | 2 +- 16 files changed, 251 insertions(+), 41 deletions(-) create mode 100644 docs/evidence/pr261-update-ui/README.md create mode 100644 docs/evidence/pr261-update-ui/capture.py create mode 100644 docs/evidence/pr261-update-ui/live-default-zh.png create mode 100644 docs/evidence/pr261-update-ui/live-ready-dark-zh.png create mode 100644 docs/evidence/pr261-update-ui/live-ready-zh.png create mode 100644 docs/evidence/pr261-update-ui/live-recovery-narrow-zh.png create mode 100644 docs/evidence/pr261-update-ui/live-scheduled-zh.png 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 0000000000000000000000000000000000000000..31753a5dccda6517bec66b16c614299c315c7585 GIT binary patch literal 51909 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�sbh(Jqrz8nUAjQ%Pka*z>_#|Hp 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

t$T+7-irYhcMlQgc7^?h+c@%BFvDZmNax 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..99477da1e83c23948e3f747c62f19f65824b0e07 GIT binary patch literal 68831 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 U1``o<=v7g%x{-CO)c4RO+N^J-2Ty_6gI5YdiX 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 z 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%0D`P6;69-4 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 literal 0 HcmV?d00001 diff --git a/electron/src/renderer/components/ExtensionSettings.tsx b/electron/src/renderer/components/ExtensionSettings.tsx index f0800390..1fd9f55a 100644 --- a/electron/src/renderer/components/ExtensionSettings.tsx +++ b/electron/src/renderer/components/ExtensionSettings.tsx @@ -289,26 +289,50 @@ export default function ExtensionSettings({ platform, extensions, runtime, provi 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 updateControl({ action: 'auto-check', enabled })} /> - 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(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.')}

- {catalog.scheduledInstall || catalog.rollbackScheduled ?
- {catalog.scheduledInstall ? `${catalog.scheduledInstall.id} → ${catalog.scheduledInstall.version} · ${catalog.scheduledInstall.digest}` : t('Rollback scheduled for the next app start.')} - void updateControl({ action: 'cancel-scheduled' })}>{t('Cancel scheduled change')} -
: null} - {catalog.pendingUpdate ?
- {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.' : '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('I checked this version; keep it')} - void updateControl({ action: 'health' })}>{t('Check loaded plugin status')} - void updateControl({ action: 'rollback' })}>{t('Roll back on next app start…')} -
: null} + + +
+ +
{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 ?
@@ -326,6 +350,9 @@ export default function ExtensionSettings({ platform, extensions, runtime, provi
{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} diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index dbfebb81..aa1bd3d4 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -216,9 +216,9 @@ "Chat window": "聊天窗口", "Chats": "对话", "Check a few lines": "核对几句对白", - "Check loaded plugin status": "检查已加载插件状态", + "Check now": "立即检查", + "Check status": "检查状态", "Check the official catalog daily and notify me; never install automatically.": "每天检查官方目录并提示;不自动安装。", - "Check updates and security status": "检查更新与安全状态", "Checking the official catalog…": "正在检查官方目录…", "Checking video support; static playback remains active.": "正在检测视频支持,当前继续静态播放。", "Choice": "选项", @@ -280,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": "压缩旧历史,保持近期帧完整", @@ -538,7 +539,6 @@ "Hybrid local + DeepSeek": "混合 · 本地 + DeepSeek", "Hybrid local + OpenAI": "混合 · 本地 + OpenAI", "Hybrid · Local + OpenAI-compatible": "混合 · 本地 + OpenAI 兼容服务", - "I checked this version; keep it": "我已检查此版本,保留它", "I understand; remove the saved setting": "我已理解,删除已保存的设置", "I will start the game": "我自行启动游戏", "Identity & persona": "身份与人格", @@ -586,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": "资料与记忆", @@ -704,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": "下次启动", @@ -811,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": "授权操作失败", @@ -842,6 +846,7 @@ "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": "头像浮层", @@ -891,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.": "实时回声消除已配置,只在麦克风采集与共享播放重叠时启用。", @@ -901,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.": "回顾先前的剧情细节。", @@ -970,6 +977,7 @@ "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": "缓存目录中的已审核产物", @@ -979,7 +987,7 @@ "Role identity": "角色身份", "Role name": "角色名称", "Role saved for the next backend start. The active role remains unchanged.": "角色已保存,将在下次后端启动时生效。当前角色未改变。", - "Roll back on next app start…": "下次应用启动时回滚…", + "Rollback scheduled": "已预约回滚", "Rollback scheduled for the next app start.": "已安排在下次应用启动时回滚。", "Routing & observation": "路由与观察", "Runner is ready": "扩展进程已就绪", @@ -1028,9 +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": "随提问附加截图", @@ -1271,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/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 9f8ff28b..e0436115 100644 --- a/electron/tests/extensionSettings.test.mjs +++ b/electron/tests/extensionSettings.test.mjs @@ -316,7 +316,7 @@ 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) }) @@ -347,9 +347,9 @@ test('unconfirmed update exposes rollback and status refresh while keep remains 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('I checked this version; keep it').props.disabled,true) - page.button('Check loaded plugin status').props.onClick();await flush();page.render() - page.button('Roll back on next app start…').props.onClick();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) @@ -366,6 +366,57 @@ test('native local import reports a queued change instead of claiming an install 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 = [] diff --git a/electron/tests/extensionStore.smoke.mjs b/electron/tests/extensionStore.smoke.mjs index b6838ae5..27ddbc93 100644 --- a/electron/tests/extensionStore.smoke.mjs +++ b/electron/tests/extensionStore.smoke.mjs @@ -99,7 +99,7 @@ try { 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.querySelector('.extension-catalog-toolbar button').disabled",'Refresh not finished') + 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===${JSON.stringify(scheduleLabel)});b.click();b.click()})()`) await scheduled();check('double click schedules without native confirmation',confirms===before) @@ -113,7 +113,7 @@ 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}))})()") @@ -132,10 +132,10 @@ try { 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('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('I checked this version; keep it');await until("!document.body.innerText.includes('I checked this version; keep it')",'Update was not confirmed');check('explicit keep clears rollback receipt',!installer.pendingUpdate()) + 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) diff --git a/tools/probes/extension_release_shell.py b/tools/probes/extension_release_shell.py index cdd88a10..8da7c8ac 100644 --- a/tools/probes/extension_release_shell.py +++ b/tools/probes/extension_release_shell.py @@ -249,7 +249,7 @@ async def apply_scheduled(): 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("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) diff --git a/tools/probes/extension_startup.py b/tools/probes/extension_startup.py index 816ba6fb..89af42d8 100644 --- a/tools/probes/extension_startup.py +++ b/tools/probes/extension_startup.py @@ -51,9 +51,9 @@ async def confirm_update(product, package_id: str, version: str): 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='I checked this version; keep it', 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='I checked this version; keep it', exact=True)).to_have_count(0, timeout=30000) + 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') diff --git a/tools/probes/extension_store_roundtrip.py b/tools/probes/extension_store_roundtrip.py index 99ec5366..8727cf4f 100644 --- a/tools/probes/extension_store_roundtrip.py +++ b/tools/probes/extension_store_roundtrip.py @@ -235,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") From 019c79bfdab664cea3a8555a42877dc362ed8abe Mon Sep 17 00:00:00 2001 From: Desolate-Jix Date: Sun, 25 Oct 2026 19:25:11 +1300 Subject: [PATCH 09/13] fix(extensions): distinguish unavailable recovery and verify rejected keep --- electron/src/main/desktopSettings.ts | 8 +++- electron/tests/extensions.test.mjs | 16 +++++-- tests/test_electron_model_less_smoke.py | 50 ++++++++++++++++++++++ tools/probes/extension_release_failures.py | 23 ++++++++-- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/electron/src/main/desktopSettings.ts b/electron/src/main/desktopSettings.ts index 15ebf143..66480fa7 100644 --- a/electron/src/main/desktopSettings.ts +++ b/electron/src/main/desktopSettings.ts @@ -665,7 +665,13 @@ export class DesktopSettingsStore { 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 - const clean = cleanStoredExtensions({ [id]: raw }, validate) + 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] } diff --git a/electron/tests/extensions.test.mjs b/electron/tests/extensions.test.mjs index 1438f6a9..1d60e4b7 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -1202,7 +1202,7 @@ test('cold startup validates both retained versions before reading recovery auth } } 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(), /Invalid plugin recovery package record/) + 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) @@ -1211,16 +1211,24 @@ test('cold startup validates both retained versions before reading recovery auth assert.equal(store.storedExtensionRecords()[0].version, '0.1.0') }) -test('cold recovery never treats missing or rejected semantic validation as startup authority', async t => { +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') - await assert.rejects(reopened.prepareStartup(), /Invalid plugin recovery package record/) - assert.throws(() => reopened.assertUpdateCanLaunch(), /Invalid plugin recovery package record/) + 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))) } }) diff --git a/tests/test_electron_model_less_smoke.py b/tests/test_electron_model_less_smoke.py index 75c53e11..2cd2520f 100644 --- a/tests/test_electron_model_less_smoke.py +++ b/tests/test_electron_model_less_smoke.py @@ -291,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/tools/probes/extension_release_failures.py b/tools/probes/extension_release_failures.py index 4b0b2694..4a7ab648 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 @@ -225,9 +243,8 @@ async def runtime_state(version, expected): bool(await runtime_state("0.3.0", {"error"})) and json.loads(state_path.read_text())["extensions"][tone]["version"] == "0.3.0", ) - health = await catalog({'action':'health'}) - check('failed update cannot be kept', health['catalog']['pendingUpdate']['recoveryRequired'] - and not (await catalog({'action':'confirm-update'}))['ok']) + 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', From 15bc3995d2409d3db2aa18b98ae799a752c9c393 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Sun, 25 Oct 2026 23:20:36 +1300 Subject: [PATCH 10/13] fix(extensions): recover after unclean backend exits --- docs/evidence/pr261-crash-recovery/README.md | 108 ++++++++++++++++++ docs/extension_authoring.md | 23 +++- electron/src/main/appLifecycle.ts | 9 ++ electron/src/main/backendProcessTree.ts | 65 +++++++++-- electron/src/main/extensionCatalog.ts | 41 ++++--- electron/src/main/extensionUpdates.ts | 25 +++- electron/src/main/extensions.ts | 10 +- electron/src/main/index.ts | 27 ++++- electron/tests/appLifecycle.test.mjs | 26 +++++ electron/tests/backendProcessTree.test.mjs | 88 +++++++++++--- electron/tests/extensionCatalog.test.mjs | 20 ++-- electron/tests/extensionCrashFixture.test.mjs | 33 ++++++ electron/tests/extensionStore.smoke.mjs | 6 +- electron/tests/extensionUpdates.test.mjs | 15 +++ electron/tests/extensions.test.mjs | 81 +++++++++++++ electron/tests/fixtures/extensionRelease.cjs | 27 +++++ server/app.py | 2 +- server/backend_cleanup.py | 47 ++++---- tests/test_backend_cleanup.py | 14 +++ tools/probes/extension_release_failures.py | 17 ++- 20 files changed, 584 insertions(+), 100 deletions(-) create mode 100644 docs/evidence/pr261-crash-recovery/README.md create mode 100644 electron/tests/extensionCrashFixture.test.mjs diff --git a/docs/evidence/pr261-crash-recovery/README.md b/docs/evidence/pr261-crash-recovery/README.md new file mode 100644 index 00000000..36774df2 --- /dev/null +++ b/docs/evidence/pr261-crash-recovery/README.md @@ -0,0 +1,108 @@ +# 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. diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md index 0c86dab7..b4ec3547 100644 --- a/docs/extension_authoring.md +++ b/docs/extension_authoring.md @@ -23,7 +23,8 @@ 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 the owned backend cleanup receipt. +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 @@ -32,13 +33,31 @@ 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. The Windows Job +observer can supply that evidence after a crash or forced exit once every owned +descendant has stopped; unresolved Work outcomes remain in the Work ledger and +are never marked successful or replayed by plugin recovery. + +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. +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 is retained. +POSIX descendant containment; the existing shutdown/cleanup behavior, including +the 2.5-second graceful-stop deadline, is retained. ## Minimal static checker environment 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 7b50345e..75006721 100644 --- a/electron/src/main/backendProcessTree.ts +++ b/electron/src/main/backendProcessTree.ts @@ -20,8 +20,9 @@ export async function resolveBackendProjectRoot(python: string, cwd: string, env return root } type CleanupOwner = { id: string; nonce: string; project_root: string; python_command: string } -type CleanupIndex = { schema: string; unknown: boolean; owners: CleanupOwner[] } +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. @@ -71,6 +72,7 @@ function readIndex(file: string): CleanupIndex | null { try { 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 @@ -93,6 +95,39 @@ function powershell(script: string): Promise { }) } +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) + return bootIdentity ??= powershell(`$ErrorActionPreference='Stop' +$key=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Memory Management\\PrefetchParameters') +try { if($null -eq $key -or $key.GetValueKind('BootId') -ne [Microsoft.Win32.RegistryValueKind]::DWord){exit 3}; $key.GetValue('BootId')|ConvertTo-Json -Compress } finally { if($key){$key.Dispose()} }`) + .then(output => { + const value: unknown = JSON.parse(output) + if (!validBootId(value)) throw Error('Windows boot identity is unavailable') + return 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 }) @@ -127,10 +162,10 @@ if(-not $native::MoveFileExW(${quote(temporary)},${quote(file)},9)){exit 3}`) /** 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): Promise<{ environment: Record }> { + platform: NodeJS.Platform = process.platform, getBootId: BootProbe = readWindowsBootId): Promise<{ environment: Record }> { if (!usesScheduledExtensionUpdates(platform)) return { environment: {} } return serializeCleanup(statePath, async () => { - const index = readIndex(statePath) ?? { schema: CLEANUP_SCHEMA, unknown: false, owners: [] } + 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') @@ -147,9 +182,9 @@ export async function prepareBackendCleanup(statePath: string, } /** Reusing a backend without its original lifetime handle cannot prove cleanup. */ -export async function markBackendCleanupUnconfirmed(statePath: string): Promise { +export async function markBackendCleanupUnconfirmed(statePath: string, { getBootId = readWindowsBootId }: { getBootId?: BootProbe } = {}): Promise { return serializeCleanup(statePath, async () => { - const index = readIndex(statePath) ?? { schema: CLEANUP_SCHEMA, unknown: false, owners: [] } + const index = reconcileBoot(readIndex(statePath) ?? { schema: CLEANUP_SCHEMA, unknown: false, owners: [] }, await getBootId()) await atomicIndex(statePath, { ...index, unknown: true }) }) } @@ -165,22 +200,27 @@ async function processIdentityGone(identity: ProcessIdentity): Promise * 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 }: { + { isProcessIdentityGone = processIdentityGone, getBootId = readWindowsBootId }: { isProcessIdentityGone?: (identity: ProcessIdentity) => Promise; + getBootId?: BootProbe; } = {}): Promise { try { return await serializeCleanup(statePath, async () => { - const index = readIndex(statePath) - if (index === null) return true + 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`)) 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 || receipt.state !== 'settled' + 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' || receipt.providers_settled !== true || receipt.work_settled !== true + || 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 @@ -239,7 +279,7 @@ export async function stopOwnedBackend(proc: ChildProcess, requestShutdown: () = verifyCleanup?: () => Promise; } = {}): Promise { const requested = await requestShutdown() - if (requested && await waitForExit(proc, 15000)) { + 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 @@ -264,6 +304,7 @@ export async function stopOwnedBackend(proc: ChildProcess, requestShutdown: () = if (!await waitForExit(proc, 1200) && !hasExited(proc)) { throw new Error('Owned backend shutdown did not complete') } - // Forced exit cannot publish a successful provider/Work cleanup receipt. + // This stop call does not infer child exit from taskkill. The independent Job + // observer may subsequently publish physical exit proof, even after a crash. return false } diff --git a/electron/src/main/extensionCatalog.ts b/electron/src/main/extensionCatalog.ts index 7eb64a0c..a2050c74 100644 --- a/electron/src/main/extensionCatalog.ts +++ b/electron/src/main/extensionCatalog.ts @@ -9,6 +9,7 @@ import { DesktopSettingsStore } from './desktopSettings.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 } @@ -177,28 +178,32 @@ export class ExtensionCatalog { const preferences = this.preferences() return Boolean(preferences.pending || preferences.local || preferences.rollback) } - async applyScheduled(): Promise { + async applyScheduled(boundary: ExtensionUpdateBoundary): Promise { if (!usesScheduledExtensionUpdates(this.platform)) throw new Error('Scheduled plugin updates are only available on Windows.') const preferences = this.preferences() - 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.') + 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) } - 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)) { diff --git a/electron/src/main/extensionUpdates.ts b/electron/src/main/extensionUpdates.ts index 0ca6a322..7e10ea96 100644 --- a/electron/src/main/extensionUpdates.ts +++ b/electron/src/main/extensionUpdates.ts @@ -18,21 +18,34 @@ export function localPortIsClosed(port: number): Promise { /** Hold the main-process launch boundary for the entire stopped-backend transaction. */ export class ExtensionUpdateBoundary { - private held = false - constructor(private readonly stopped: () => Promise) {} + private held: 'install' | 'recovery' | undefined + constructor(private readonly stopped: () => Promise, + private readonly recoveryIdle: () => Promise = stopped) {} assertHeld = (): void => { - if (!this.held) throw new Error('Plugin installation requires the stopped-backend update boundary; schedule it for the next app start.') + 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 = true + this.held = kind try { - if (!await this.stopped()) throw new Error('The backend or its MCP/Work processes are still active or unconfirmed; plugin installation is blocked.') + 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 = false } + } finally { this.held = undefined } } } diff --git a/electron/src/main/extensions.ts b/electron/src/main/extensions.ts index 3948511a..174ad826 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -204,7 +204,8 @@ export class ExtensionInstaller { private readonly themes = new ExtensionThemeResources(), private readonly changed: () => void = () => {}, private readonly assertInstallIdle: () => void = () => {}, - private readonly platform: NodeJS.Platform = process.platform) { + private readonly platform: NodeJS.Platform = process.platform, + private readonly assertRecoveryIdle: () => void = assertInstallIdle) { this.updates = new ExtensionUpdateJournal(root, raw => store.validateExtensionRollbackRecord(raw)) } @@ -275,7 +276,7 @@ export class ExtensionInstaller { rollbackPendingUpdate(): Promise> { return this.serialize(async () => { if (!usesScheduledExtensionUpdates(this.platform)) throw new Error('Scheduled plugin updates are only available on Windows.') - this.assertInstallIdle() + this.assertRecoveryIdle() const update = this.updates.read() if (!update) throw new Error('No plugin update is waiting for rollback.') const previous = update.previous @@ -290,14 +291,15 @@ export class ExtensionInstaller { 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.assertInstallIdle() + 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 - await this.applyFrontendChanges() + // 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) }) diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 09caf1cf..6b4ebe91 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -69,8 +69,10 @@ const desktopSettings = new DesktopSettingsStore( extensionPackageHelper.validateRecord, ) const scheduledExtensionUpdates = usesScheduledExtensionUpdates(process.platform) -const extensionUpdateBoundary = new ExtensionUpdateBoundary(async () => !pythonProcess && !backendOwned && !backendStarting && !backendStopping - && extensionCleanupConfirmed && await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE) && await localPortIsClosed(BACKEND_PORT)) +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', @@ -86,7 +88,8 @@ const extensionInstaller = new ExtensionInstaller(path.join(USER_DATA_DIR, 'exte 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) + }), () => 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(), undefined, @@ -2853,6 +2856,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 @@ -2864,7 +2883,7 @@ app.whenReady().then(async () => { let backendStartFailed = false try { await extensionInstaller.prepareStartup() - if (extensionCatalog.hasScheduled()) await extensionUpdateBoundary.run(() => extensionCatalog.applyScheduled()) + if (extensionCatalog.hasScheduled()) await extensionCatalog.applyScheduled(extensionUpdateBoundary) } catch (error) { desktopSettings.reportExtensionWarning(error instanceof Error ? error.message : 'Scheduled plugin installation failed; review Manage plugins.') } try { 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/backendProcessTree.test.mjs b/electron/tests/backendProcessTree.test.mjs index cdc7887f..b1542047 100644 --- a/electron/tests/backendProcessTree.test.mjs +++ b/electron/tests/backendProcessTree.test.mjs @@ -8,11 +8,15 @@ import test from 'node:test' import { loadTypeScript } from './helpers/loadTypeScript.mjs' const { forceStopBackendTree, stopOwnedBackend, waitForProcessExit, prepareBackendCleanup: preparePlatformBackendCleanup, - verifyPreviousBackendCleanup, markBackendCleanupUnconfirmed, resolveBackendProjectRoot } = loadTypeScript(new URL('../src/main/backendProcessTree.ts', import.meta.url)) + 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 prepareBackendCleanup = (file, launch, platform = 'win32') => preparePlatformBackendCleanup(file, launch, 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') @@ -77,7 +81,7 @@ for (const platform of ['linux', 'darwin']) { assert.equal(fs.existsSync(file), false) const proc = processFixture() assert.equal(await stopOwnedBackend(proc, async () => true, {platform, - waitForExit: async () => {proc.exit(); return true}, + 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() @@ -87,10 +91,10 @@ for (const platform of ['linux', 'darwin']) { }) } -test('graceful owned shutdown requires the provider and process-tree receipt', async () => { +test('graceful owned shutdown requires independent process-tree exit evidence', async () => { const proc = processFixture() assert.equal(await stopOwnedBackend(proc, async () => true, { platform: 'win32', - waitForExit: async () => { proc.exit(); return true }, + 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) @@ -205,7 +209,7 @@ test('a failed durable Windows replacement preserves the original index', { skip } finally { fs.chmodSync(file, 0o600) } }) -test('registration survives crash and a later clean launch cannot erase it', async t => { +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) @@ -214,6 +218,52 @@ test('registration survives crash and a later clean launch cannot erase it', asy 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 identity is a stable OS counter, not an elapsed-time estimate', {skip: process.platform !== 'win32'}, async () => { + const first = await readWindowsBootId() + assert.ok(Number.isSafeInteger(first) && first >= 0) + assert.equal(await readWindowsBootId(), first) +}) + 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) @@ -290,7 +340,7 @@ test('compaction cannot erase an unknown fence queued during identity verificati assert.equal(await checking, true) await marking assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { - schema: 'amadeus.backend-cleanup.v1', unknown: true, owners: [], + schema: 'amadeus.backend-cleanup.v1', bootId: 10, unknown: true, owners: [], }) assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), false) }) @@ -348,7 +398,7 @@ function existingPython(root) { return result.stdout.trim() } -test('real Windows tracking Job detects a child surviving its parent without killing it', { skip: process.platform !== 'win32' }, async t => { +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) @@ -357,7 +407,7 @@ test('real Windows tracking Job detects a child surviving its parent without kil 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() { +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# Exit without record_runtime_cleanup/finish, as a failed teardown would.\n`) + 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, 0, result.stdout + result.stderr) - const failed = path.join(`${file}.receipts`, `${launch.owner.id}.failed.json`) - for (let attempt = 0; attempt < 40 && !fs.existsSync(failed); attempt++) await new Promise(resolve => setTimeout(resolve, 25)) - assert.equal(JSON.parse(fs.readFileSync(failed, 'utf8')).reason, 'backend_cleanup_approval_missing') - assert.equal(await verifyPreviousBackendCleanup(file), false) - assert.equal(fs.existsSync(path.join(`${file}.receipts`, `${launch.owner.id}.json`)), false) + 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) }) diff --git a/electron/tests/extensionCatalog.test.mjs b/electron/tests/extensionCatalog.test.mjs index 8ffed0e2..34cc0c19 100644 --- a/electron/tests/extensionCatalog.test.mjs +++ b/electron/tests/extensionCatalog.test.mjs @@ -8,6 +8,7 @@ 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, platform = 'win32') { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amadeus-catalog-')) @@ -26,7 +27,8 @@ function fixture(t, platform = 'win32') { } 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, reopen: () => new ExtensionCatalog(path.join(root, 'catalog'), store, helper, installer, () => state.mirror, () => {}, download, undefined, 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' })) @@ -44,7 +46,7 @@ for (const platform of ['linux', 'darwin']) { 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(), /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++ @@ -172,7 +174,7 @@ test('scheduling pins the selected artifact without downloading or installing un 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() + 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) @@ -185,11 +187,11 @@ test('offline or changed scheduled bytes cannot install, and failed intent is ne 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()) + 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() + await f.reopen().applyScheduled(f.boundary) assert.equal(f.state.requests.length,requests) } }) @@ -199,7 +201,7 @@ test('rollback selection only queues recovery; no package changes occur in the r 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() + await f.reopen().applyScheduled(f.boundary) assert.equal(f.state.rolledBack,true) assert.equal((await f.catalog.view()).rollbackScheduled,false) }) @@ -235,7 +237,7 @@ test('native local selection freezes exact prerelease bytes and installs only on 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() + 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') @@ -258,11 +260,11 @@ test('changed local snapshot or native cancellation is not installed or replayed } 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(), failure === 'tamper' ? /bytes changed/ : /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() + await f.reopen().applyScheduled(f.boundary) assert.deepEqual(f.state.installed, []) assert.deepEqual(fs.readdirSync(path.join(f.root, 'catalog/pending-local')), []) } 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/extensionStore.smoke.mjs b/electron/tests/extensionStore.smoke.mjs index 27ddbc93..a918e8d8 100644 --- a/electron/tests/extensionStore.smoke.mjs +++ b/electron/tests/extensionStore.smoke.mjs @@ -32,7 +32,7 @@ const check = (name, value=true)=>{assert.ok(value,name);report.checks.push(name const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)) const notify=()=>{if(window&&!window.isDestroyed())window.webContents.send('store.theme.changed')} 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) +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) @@ -69,7 +69,7 @@ try { 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 boundary.run(()=>catalog.applyScheduled());await installer.refreshUpdateHealth([]);notify();await click('Installed')} + 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)})) @@ -127,7 +127,7 @@ try { store.update({}, {values:{AMADEUS_EXTENSION_ARTIFACT_MIRROR:origin+'/mirror'}}) badWheel=true;slow=false await click('Schedule update for next app start…');await scheduled() - await assert.rejects(boundary.run(()=>catalog.applyScheduled()),/digest|SHA-256|package validation failed/i) + 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('Schedule update for next app start…');await scheduled();await applyScheduled();await installed(themeId,'0.2.0') diff --git a/electron/tests/extensionUpdates.test.mjs b/electron/tests/extensionUpdates.test.mjs index e10267f2..119fcd9a 100644 --- a/electron/tests/extensionUpdates.test.mjs +++ b/electron/tests/extensionUpdates.test.mjs @@ -31,3 +31,18 @@ test('active or unknown backend never executes an install, and failures release 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 1d60e4b7..db7028c3 100644 --- a/electron/tests/extensions.test.mjs +++ b/electron/tests/extensions.test.mjs @@ -1446,6 +1446,87 @@ function healthyUpdateRow(record) { 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}) diff --git a/electron/tests/fixtures/extensionRelease.cjs b/electron/tests/fixtures/extensionRelease.cjs index 5556188f..ecf451c8 100644 --- a/electron/tests/fixtures/extensionRelease.cjs +++ b/electron/tests/fixtures/extensionRelease.cjs @@ -1,4 +1,29 @@ // 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 {app, net, session, dialog} = require('electron') @@ -8,6 +33,8 @@ async function setupReleaseFixture(base, automaticConfirm = true) { 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')}} + 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. diff --git a/server/app.py b/server/app.py index e4ae6357..c4c2f1c8 100644 --- a/server/app.py +++ b/server/app.py @@ -3267,4 +3267,4 @@ def _noop_warmup() -> str: # 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("backend cleanup remains unconfirmed; extension updates are blocked") + 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 index e49f14e8..4b01963c 100644 --- a/server/backend_cleanup.py +++ b/server/backend_cleanup.py @@ -1,8 +1,8 @@ """Desktop-owned cleanup evidence; this module never terminates a process. -Install the tracking Job before eager runtime imports. A clean receipt requires -successful Host teardown and a drained non-breakaway Job. A crash leaves the -desktop's pre-launch registration unresolved, even after every PID has exited. +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 @@ -176,6 +176,13 @@ def __init__(self, launch: dict, *, job_factory=None, # 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, + }) except Exception as exc: # Ordinary backend startup may continue; unknown containment never # authorizes package replacement and is visible in its pending owner. @@ -187,6 +194,8 @@ def record_runtime_cleanup(self, *, providers_settled: bool, work_settled: bool, 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 @@ -271,27 +280,19 @@ def _observe(request: dict) -> None: }) if kernel.WaitForSingleObject(root, 0xFFFFFFFF) != 0: raise OSError("cleanup observer could not observe backend exit") - approval_path = receipt.with_suffix(".approval.json") - if not approval_path.exists(): - _atomic_json(receipt.with_suffix(".failed.json"), { - "schema": SCHEMA, "owner": owner, "reason": "backend_cleanup_approval_missing", - }) - return # Crash/failed teardown: registration remains unresolved. - _ordinary_path(approval_path) - approval = json.loads(approval_path.read_text(encoding="utf-8")) - if (approval.get("schema") != SCHEMA or approval.get("state") != "settled" - or approval.get("owner") != owner or approval.get("process") != process - or approval.get("job_name") != request["job_name"] - or approval.get("proof") != "windows-job-drained" - or approval.get("providers_settled") is not True - or approval.get("work_settled") is not True): - raise ValueError("cleanup observer approval mismatch") - deadline = time.monotonic() + 30 + tracking_path = receipt.with_suffix(".tracking.json") + _ordinary_path(tracking_path) + 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: - if time.monotonic() >= deadline: - raise TimeoutError("owned descendants survived backend cleanup") - time.sleep(.05) - _atomic_json(receipt, {**approval, "observer": { + 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(), }}) except Exception as exc: diff --git a/tests/test_backend_cleanup.py b/tests/test_backend_cleanup.py index ec751dda..b4ec1dd7 100644 --- a/tests/test_backend_cleanup.py +++ b/tests/test_backend_cleanup.py @@ -100,6 +100,9 @@ def test_success_receipt_binds_launch_and_root_creation_identity(tmp_path): 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. @@ -114,6 +117,17 @@ def unavailable(name): 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): diff --git a/tools/probes/extension_release_failures.py b/tools/probes/extension_release_failures.py index 4a7ab648..4166ce0d 100644 --- a/tools/probes/extension_release_failures.py +++ b/tools/probes/extension_release_failures.py @@ -132,9 +132,15 @@ async def install(package_id, version): 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() @@ -251,6 +257,15 @@ async def runtime_state(version, expected): 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() From c7a0c1a0a166e473629f0159fb19d66e75216ff4 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 04:20:22 +1300 Subject: [PATCH 11/13] fix(ci): handle unavailable Windows boot evidence --- docs/evidence/pr261-crash-recovery/README.md | 18 +++++++ electron/src/main/backendProcessTree.ts | 24 ++++++--- electron/tests/backendProcessTree.test.mjs | 55 ++++++++++++++++++-- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/docs/evidence/pr261-crash-recovery/README.md b/docs/evidence/pr261-crash-recovery/README.md index 36774df2..4ed88cae 100644 --- a/docs/evidence/pr261-crash-recovery/README.md +++ b/docs/evidence/pr261-crash-recovery/README.md @@ -106,3 +106,21 @@ 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/electron/src/main/backendProcessTree.ts b/electron/src/main/backendProcessTree.ts index 75006721..16ea1dbe 100644 --- a/electron/src/main/backendProcessTree.ts +++ b/electron/src/main/backendProcessTree.ts @@ -105,14 +105,22 @@ let bootIdentity: Promise | undefined * Missing evidence never authorizes retirement; ordinary Job receipts still work. */ export function readWindowsBootId(): Promise { if (process.platform !== 'win32') return Promise.resolve(undefined) - return bootIdentity ??= powershell(`$ErrorActionPreference='Stop' -$key=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Memory Management\\PrefetchParameters') -try { if($null -eq $key -or $key.GetValueKind('BootId') -ne [Microsoft.Win32.RegistryValueKind]::DWord){exit 3}; $key.GetValue('BootId')|ConvertTo-Json -Compress } finally { if($key){$key.Dispose()} }`) - .then(output => { - const value: unknown = JSON.parse(output) - if (!validBootId(value)) throw Error('Windows boot identity is unavailable') - return value - }).catch(() => { bootIdentity = undefined; return 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 { diff --git a/electron/tests/backendProcessTree.test.mjs b/electron/tests/backendProcessTree.test.mjs index b1542047..a1818271 100644 --- a/electron/tests/backendProcessTree.test.mjs +++ b/electron/tests/backendProcessTree.test.mjs @@ -258,10 +258,59 @@ test('physical exit evidence is independent of unsettled Work outcomes but still assert.equal(await verifyPreviousBackendCleanup(file, goneAndDrained), true) }) -test('Windows boot identity is a stable OS counter, not an elapsed-time estimate', {skip: process.platform !== 'win32'}, async () => { +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() - assert.ok(Number.isSafeInteger(first) && first >= 0) - assert.equal(await readWindowsBootId(), first) + 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 => { From 3413e88342f8ab520f0b83a4cc87a85744ff8610 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 08:40:34 +1300 Subject: [PATCH 12/13] fix(extensions): isolate cleanup failures from backend startup --- docs/evidence/pr261-startup-cleanup/README.md | 90 ++++++++++++ docs/extension_authoring.md | 23 ++- electron/src/main/backendProcessTree.ts | 53 +++++-- electron/src/main/index.ts | 43 ++++-- electron/tests/backendLaunch.test.mjs | 132 ++++++++++++++++++ electron/tests/backendProcessTree.test.mjs | 123 +++++++++++++++- server/backend_cleanup.py | 95 ++++++++++--- tools/probes/backend_cleanup_startup.py | 68 +++++++++ 8 files changed, 580 insertions(+), 47 deletions(-) create mode 100644 docs/evidence/pr261-startup-cleanup/README.md create mode 100644 electron/tests/backendLaunch.test.mjs create mode 100644 tools/probes/backend_cleanup_startup.py diff --git a/docs/evidence/pr261-startup-cleanup/README.md b/docs/evidence/pr261-startup-cleanup/README.md new file mode 100644 index 00000000..fad7caa3 --- /dev/null +++ b/docs/evidence/pr261-startup-cleanup/README.md @@ -0,0 +1,90 @@ +# 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. diff --git a/docs/extension_authoring.md b/docs/extension_authoring.md index b4ec3547..8241789a 100644 --- a/docs/extension_authoring.md +++ b/docs/extension_authoring.md @@ -36,18 +36,33 @@ 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. The Windows Job -observer can supply that evidence after a crash or forced exit once every owned -descendant has stopped; unresolved Work outcomes remain in the Work ledger and +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. -Windows shutdown/session-end cleanup is best effort; recovery does not depend +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 diff --git a/electron/src/main/backendProcessTree.ts b/electron/src/main/backendProcessTree.ts index 16ea1dbe..7034370f 100644 --- a/electron/src/main/backendProcessTree.ts +++ b/electron/src/main/backendProcessTree.ts @@ -42,17 +42,21 @@ async function serializeCleanup(statePath: string, action: () => Promise): } } -function ordinaryPath(file: string): void { +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 (path.dirname(item) === item) break + if (item === root) break } } -function readJson(file: string): unknown { - ordinaryPath(file) +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')) @@ -70,6 +74,13 @@ function validOwner(value: unknown): value is CleanupOwner { 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) @@ -220,7 +231,7 @@ export async function verifyPreviousBackendCleanup(statePath: string, 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`)) as { + 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; } @@ -245,12 +256,33 @@ export async function verifyPreviousBackendCleanup(statePath: string, /** 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'], { @@ -282,9 +314,10 @@ 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, - verifyCleanup }: { + verifyCleanup, cleanupLaunch }: { platform?: string; forceStop?: typeof forceStopBackendTree; waitForExit?: typeof waitForProcessExit; verifyCleanup?: () => Promise; + cleanupLaunch?: string; } = {}): Promise { const requested = await requestShutdown() if (requested && await waitForExit(proc, usesScheduledExtensionUpdates(platform) ? 15000 : 2500)) { @@ -304,7 +337,7 @@ export async function stopOwnedBackend(proc: ChildProcess, requestShutdown: () = // 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 @@ -312,7 +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') } - // This stop call does not infer child exit from taskkill. The independent Job - // observer may subsequently publish physical exit proof, even after a crash. + // 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/index.ts b/electron/src/main/index.ts index 6b4ebe91..d1a6afd3 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -259,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() @@ -443,18 +445,35 @@ 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) await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) + 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) await markBackendCleanupUnconfirmed(BACKEND_CLEANUP_STATE) + 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`) @@ -470,7 +489,7 @@ async function launchBackend(): Promise { // Windows maintenance requires the persisted descendant receipt, not merely // a closed port. Other platforms retain their existing cleanup contract. - if (scheduledExtensionUpdates) extensionCleanupConfirmed = await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE) + 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}`) @@ -487,10 +506,15 @@ async function launchBackend(): Promise { delete backendProcessEnvironment[EXTENSIONS_ENV] delete backendProcessEnvironment.AMADEUS_BACKEND_CLEANUP_LAUNCH backendProcessEnvironment[EXTENSIONS_ENV] = launchSettings.extensions - const cleanupLaunch = await prepareBackendCleanup(BACKEND_CLEANUP_STATE, { - nonce: BACKEND_INSTANCE_NONCE, - projectRoot: scheduledExtensionUpdates ? await resolveBackendProjectRoot(python, PROJECT_ROOT, backendProcessEnvironment) : PROJECT_ROOT, pythonCommand: python, - }) + 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, @@ -511,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()}`) }) @@ -539,7 +565,8 @@ async function stopBackend(): Promise { backendStopping = (async () => { extensionCleanupConfirmed = false extensionCleanupConfirmed = await stopOwnedBackend(proc, () => requestBackendAction('/shutdown', 900), { - verifyCleanup: () => verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE), + verifyCleanup: async () => !backendCleanupUnavailable && await verifyPreviousBackendCleanup(BACKEND_CLEANUP_STATE), + cleanupLaunch: backendCleanupLaunches.get(proc), }) if (pythonProcess === proc) pythonProcess = null backendOwned = false diff --git a/electron/tests/backendLaunch.test.mjs b/electron/tests/backendLaunch.test.mjs new file mode 100644 index 00000000..fc568ef3 --- /dev/null +++ b/electron/tests/backendLaunch.test.mjs @@ -0,0 +1,132 @@ +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, + 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 a1818271..41220b25 100644 --- a/electron/tests/backendProcessTree.test.mjs +++ b/electron/tests/backendProcessTree.test.mjs @@ -1,8 +1,8 @@ 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 { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import path from 'node:path' import test from 'node:test' import { loadTypeScript } from './helpers/loadTypeScript.mjs' @@ -67,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 }) @@ -190,15 +214,30 @@ test('cleanup refuses linked state paths without changing the target index', asy 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)) - 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) + 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) - await markBackendCleanupUnconfirmed(file) + 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) @@ -207,6 +246,9 @@ test('a failed durable Windows replacement preserves the original index', { skip 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 => { @@ -491,3 +533,70 @@ for (const outcome of ['crash', 'unsettled-work']) test(`real ${outcome} is reco 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/server/backend_cleanup.py b/server/backend_cleanup.py index 4b01963c..746abd56 100644 --- a/server/backend_cleanup.py +++ b/server/backend_cleanup.py @@ -1,4 +1,4 @@ -"""Desktop-owned cleanup evidence; this module never terminates a process. +"""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. @@ -22,10 +22,13 @@ SCHEMA = "amadeus.backend-cleanup.v1" -def _ordinary_path(path: Path) -> None: +def _ordinary_path(path: Path, root: Path) -> None: if not path.is_absolute() or ".." in path.parts: raise ValueError("cleanup path must be absolute") - for item in (*reversed(path.parents), path): + 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: @@ -34,8 +37,8 @@ def _ordinary_path(path: Path) -> None: raise ValueError("cleanup path cannot traverse a reparse point") -def _atomic_json(path: Path, value: dict) -> None: - _ordinary_path(path) +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: @@ -43,7 +46,7 @@ def _atomic_json(path: Path, value: dict) -> None: stream.write("\n") stream.flush() os.fsync(stream.fileno()) - _ordinary_path(path) + _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] @@ -161,10 +164,11 @@ def __init__(self, launch: dict, *, job_factory=None, if Path(command).resolve() != Path(sys.executable).resolve(): raise ValueError("cleanup launch Python executable does not match") state_path = Path(launch["state_path"]) - _ordinary_path(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) + _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") @@ -182,7 +186,7 @@ def __init__(self, launch: dict, *, job_factory=None, "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. @@ -205,7 +209,7 @@ def finish(self) -> bool: "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 @@ -234,7 +238,7 @@ def _start_observer(proof: BackendCleanup) -> None: deadline = time.monotonic() + 5 while time.monotonic() < deadline: if ready.exists(): - _ordinary_path(ready) + _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") @@ -261,7 +265,8 @@ def _observe(request: dict) -> None: process = request["process"] owner = request["launch"]["owner"] receipt = Path(str(request["launch"]["state_path"]) + ".receipts") / f"{owner['id']}.json" - _ordinary_path(receipt) + 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 @@ -277,11 +282,11 @@ def _observe(request: dict) -> None: 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) + _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"]}: @@ -294,10 +299,10 @@ def _observe(request: dict) -> None: "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__}) + "reason": type(exc).__name__}, data_root) raise finally: job.close() @@ -305,7 +310,61 @@ def _observe(request: dict) -> None: 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] != "--observe" or os.name != "nt": + if len(sys.argv) != 3 or sys.argv[1] not in {"--observe", "--terminate"} or os.name != "nt": raise SystemExit(2) - _observe(json.loads(sys.argv[2])) + (_observe if sys.argv[1] == "--observe" else _terminate)(json.loads(sys.argv[2])) 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())) From 65626b3fcc2d1c4e3ec98e185ff562ec21b0fe02 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 08:47:59 +1300 Subject: [PATCH 13/13] fix(ci): bind Windows fixtures and pin available dotnet SDK --- .github/workflows/electron-windows.yml | 3 ++- .github/workflows/python-windows.yml | 3 ++- .github/workflows/source-release.yml | 3 ++- docs/evidence/pr261-startup-cleanup/README.md | 26 +++++++++++++++++++ electron/tests/backendLaunch.test.mjs | 3 +++ 5 files changed, 35 insertions(+), 3 deletions(-) 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/docs/evidence/pr261-startup-cleanup/README.md b/docs/evidence/pr261-startup-cleanup/README.md index fad7caa3..acbd30cf 100644 --- a/docs/evidence/pr261-startup-cleanup/README.md +++ b/docs/evidence/pr261-startup-cleanup/README.md @@ -88,3 +88,29 @@ publication journey were not rerun locally. The cancelled-shutdown query behavio 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/electron/tests/backendLaunch.test.mjs b/electron/tests/backendLaunch.test.mjs index fc568ef3..c1915a03 100644 --- a/electron/tests/backendLaunch.test.mjs +++ b/electron/tests/backendLaunch.test.mjs @@ -21,6 +21,9 @@ function fixture(t, overrides = {}) { 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',