From d010dc9e915ee67836b179ea48df04e88e4bf960 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 08:10:59 +0000 Subject: [PATCH 1/2] build(desktop): exclude node-pty compiler intermediates from the package node-pty's build/**/obj trees and .exp/.iobj/.ipdb/.lib/.pdb/.tlog link outputs shipped inside app.asar and app.asar.unpacked (124 files in the 0.92.0 Windows installer). Only the .node/.dll/.exe outputs are runtime payload; the rest just widens the NSIS extraction surface. Co-authored-by: RainMona --- docs/managed-workspace-runtime.md | 7 +++++-- package.json | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/managed-workspace-runtime.md b/docs/managed-workspace-runtime.md index e80b5917c..35ea9a261 100644 --- a/docs/managed-workspace-runtime.md +++ b/docs/managed-workspace-runtime.md @@ -279,8 +279,11 @@ separate checks before the upgrade journey launches the candidate. `asarUnpack` explicitly retains node-pty and dugite's embedded Git under `app.asar.unpacked`, with other native dependencies handled by builder's -native-module detection. The package assertion verifies archive contents, -physical native files, runtime resources and matching product versions. +native-module detection. node-pty's compiler intermediates (`build/**/obj/`, +`.exp`, `.iobj`, `.ipdb`, `.lib`, `.pdb`, `.tlog`) are excluded from the +package; only its `.node`, `.dll` and `.exe` outputs are runtime payload. The +package assertion verifies archive contents, physical native files, runtime +resources, the absence of those intermediates and matching product versions. Contributors who run `pnpm vendor:runtime` also get the generated search-tool directory on `pnpm dev`'s managed PATH; dev startup never downloads or mutates that payload implicitly. diff --git a/package.json b/package.json index 38f2f39bf..38252aeef 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,9 @@ "!node_modules/**/*.d.ts", "!node_modules/**/*.d.ts.map", "!node_modules/**/*.map", - "!node_modules/**/_types/**" + "!node_modules/**/_types/**", + "!node_modules/node-pty/build/**/obj/**", + "!node_modules/node-pty/build/**/*.{exp,iobj,ipdb,lib,pdb,tlog}" ], "mac": { "target": [ From e187ae4fb732f5b3708fe7a3f7f717214e45ed72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 08:11:13 +0000 Subject: [PATCH 2/2] fix(desktop): verify the installed payload before launching on Windows Community reports of `Cannot find module 'fs-extra'` on Windows came from installs whose resources tree was only partially extracted: the 0.91.x/0.92.0 installers ship the file, and electron-builder's NSIS extraction falls back to a non-atomic 7z extract that ignores per-file errors after its atomic copy fails (locked handles, antivirus, MAX_PATH). Nothing checked the result, so the app started from a partial tree and failed on the first missing module or toolchain. - afterPack writes resources/openalice-integrity.json: version plus every file under app.asar, app.asar.unpacked/ and runtime/ with its size. - installer.nsh customInstall re-checks that inventory after extraction and aborts (exit 3) with a reinstall message instead of handing off to --force-run. customInit now waits up to 20 s for processes under $INSTDIR to exit instead of sleeping one second before RD /S. - The packaged desktop main verifies the inventory before resolving the data home; a damaged install shows a reinstall dialog linking to the latest release and quits. OPENALICE_DESKTOP_SKIP_INSTALL_INTEGRITY=1 bypasses it for diagnosis. - assert-desktop-package validates the inventory against the unpacked package and rejects node-pty compiler intermediates in the archive. The helper PowerShell is written to $PLUGINSDIR one line per FileWrite so NSIS string limits and $/${} expansion never touch the script body; the inventory is parsed with JavaScriptSerializer because Windows PowerShell's ConvertFrom-Json caps input near 2 MB and the inventory is ~1.7 MB. Co-authored-by: RainMona --- apps/desktop/build/installer.nsh | 92 +++++++++++++++- apps/desktop/src/install-integrity.spec.ts | 100 +++++++++++++++++ apps/desktop/src/install-integrity.ts | 120 +++++++++++++++++++++ apps/desktop/src/main.ts | 38 ++++++- docs/managed-workspace-runtime.md | 24 +++++ scripts/assert-desktop-package.mjs | 17 +++ scripts/assert-desktop-package.spec.ts | 67 +++++++++++- scripts/desktop-after-pack.mjs | 6 ++ scripts/desktop-after-pack.spec.ts | 43 +++++++- scripts/desktop-install-integrity.mjs | 56 ++++++++++ scripts/desktop-upgrade-smoke-lib.spec.ts | 17 ++- 11 files changed, 572 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/install-integrity.spec.ts create mode 100644 apps/desktop/src/install-integrity.ts create mode 100644 scripts/desktop-install-integrity.mjs diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh index 6a16ec728..01b008b90 100644 --- a/apps/desktop/build/installer.nsh +++ b/apps/desktop/build/installer.nsh @@ -1,12 +1,80 @@ +; PowerShell helpers are written to $PLUGINSDIR at run time. NSIS strings are +; length-limited and `$`, `"` and `${}` all need escaping, so keep each helper +; one FileWrite per line and use single-quoted PowerShell string literals. + +; Stops every process whose executable lives under the install directory and +; waits for the process list to drain instead of sleeping a fixed second. +; Exit code: number of processes still alive at the deadline. +!macro writeStopInstallProcessesScript PATH + FileOpen $R9 "${PATH}" w + FileWrite $R9 "param([string]$$Root, [int]$$TimeoutSeconds = 20)$\r$\n" + FileWrite $R9 "$$deadline = [DateTime]::UtcNow.AddSeconds($$TimeoutSeconds)$\r$\n" + FileWrite $R9 "$$running = @()$\r$\n" + FileWrite $R9 "do {$\r$\n" + FileWrite $R9 " $$running = @(Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$Root, [System.StringComparison]::OrdinalIgnoreCase) })$\r$\n" + FileWrite $R9 " foreach ($$p in $$running) { Stop-Process -Id $$p.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n" + FileWrite $R9 " if ($$running.Count -eq 0) { break }$\r$\n" + FileWrite $R9 " Start-Sleep -Milliseconds 250$\r$\n" + FileWrite $R9 "} while ([DateTime]::UtcNow -lt $$deadline)$\r$\n" + FileWrite $R9 "foreach ($$p in $$running) { Write-Output ('still running: ' + $$p.ProcessId + ' ' + $$p.ExecutablePath) }$\r$\n" + FileWrite $R9 "exit $$running.Count$\r$\n" + FileClose $R9 +!macroend + +; Compares the installed resources tree with the inventory that +; scripts/desktop-after-pack.mjs wrote beside app.asar. Windows PowerShell's +; ConvertFrom-Json caps input near 2 MB, so the inventory is parsed with the +; underlying serializer and an explicit limit. Exit code: 0 complete, +; 1 missing/truncated files, 2 inventory unreadable. +!macro writeVerifyInstallScript PATH + FileOpen $R9 "${PATH}" w + FileWrite $R9 "param([string]$$Root)$\r$\n" + FileWrite $R9 "$$manifest = Join-Path $$Root 'openalice-integrity.json'$\r$\n" + FileWrite $R9 "if (-not [System.IO.File]::Exists($$manifest)) { Write-Output 'inventory missing: openalice-integrity.json'; exit 2 }$\r$\n" + FileWrite $R9 "$$data = $$null$\r$\n" + FileWrite $R9 "try {$\r$\n" + FileWrite $R9 " Add-Type -AssemblyName System.Web.Extensions$\r$\n" + FileWrite $R9 " $$serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer$\r$\n" + FileWrite $R9 " $$serializer.MaxJsonLength = [int]::MaxValue$\r$\n" + FileWrite $R9 " $$data = $$serializer.DeserializeObject([System.IO.File]::ReadAllText($$manifest))$\r$\n" + FileWrite $R9 "} catch {$\r$\n" + FileWrite $R9 " try { $$data = [System.IO.File]::ReadAllText($$manifest) | ConvertFrom-Json } catch { Write-Output ('inventory unreadable: ' + $$_.Exception.Message); exit 2 }$\r$\n" + FileWrite $R9 "}$\r$\n" + FileWrite $R9 "$$files = @($$data.files)$\r$\n" + FileWrite $R9 "$$bad = 0$\r$\n" + FileWrite $R9 "foreach ($$entry in $$files) {$\r$\n" + FileWrite $R9 " $$ok = $$false$\r$\n" + FileWrite $R9 " try {$\r$\n" + FileWrite $R9 " $$info = New-Object System.IO.FileInfo (Join-Path $$Root ([string]$$entry[0]))$\r$\n" + FileWrite $R9 " $$ok = $$info.Exists -and ($$null -eq $$entry[1] -or $$info.Length -eq [int64]$$entry[1])$\r$\n" + FileWrite $R9 " } catch { $$ok = $$false }$\r$\n" + FileWrite $R9 " if (-not $$ok) {$\r$\n" + FileWrite $R9 " $$bad++$\r$\n" + FileWrite $R9 " if ($$bad -le 5) { Write-Output ([string]$$entry[0]) }$\r$\n" + FileWrite $R9 " }$\r$\n" + FileWrite $R9 "}$\r$\n" + FileWrite $R9 "Write-Output ('checked ' + $$files.Count + ' files, problems ' + $$bad)$\r$\n" + FileWrite $R9 "if ($$bad -gt 0) { exit 1 }$\r$\n" + FileWrite $R9 "exit 0$\r$\n" + FileClose $R9 +!macroend + !macro customInit ${if} ${isUpdated} + InitPluginsDir DetailPrint "Closing the legacy OpenAlice process tree before update." nsExec::ExecToLog '"$SYSDIR\taskkill.exe" /T /F /IM "${APP_EXECUTABLE_FILENAME}"' Pop $0 + ; Guardian children (UTA, Workspace CLIs, managed Node/Git) can outlive the + ; Electron tree briefly. Extracting while they hold handles open leaves a + ; partial install, so wait for the process list under $INSTDIR to drain. DetailPrint "Closing remaining processes launched from the OpenAlice install directory." - nsExec::ExecToLog `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "Get-CimInstance -ClassName Win32_Process | Where-Object {$$_.ExecutablePath -and $$_.ExecutablePath.StartsWith('$INSTDIR', [System.StringComparison]::OrdinalIgnoreCase)} | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"` + !insertmacro writeStopInstallProcessesScript "$PLUGINSDIR\openalice-stop-install-processes.ps1" + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\openalice-stop-install-processes.ps1" -Root "$INSTDIR"' Pop $0 - Sleep 1000 + ${if} $0 != 0 + DetailPrint "Processes from the OpenAlice install directory are still running (count $0)." + ${endif} ; Legacy non-ASAR releases and external runtime payloads can contain paths ; beyond the legacy MAX_PATH limit. Their NSIS uninstaller repeatedly @@ -30,3 +98,23 @@ DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" "QuietUninstallString" ${endif} !macroend + +; Runs after extraction and before electron-builder's force-run/finish launch. +; electron-builder's extraction falls back to a non-atomic 7z extract that +; ignores per-file errors, so a locked or long path can otherwise ship a +; partial tree that only fails at first launch. Refuse to hand off to the app. +!macro customInstall + DetailPrint "Verifying the installed OpenAlice files." + !insertmacro writeVerifyInstallScript "$PLUGINSDIR\openalice-verify-install.ps1" + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\openalice-verify-install.ps1" -Root "$INSTDIR\resources"' + Pop $0 + Pop $1 + ${if} $0 != 0 + DetailPrint "OpenAlice install verification failed (exit $0)." + DetailPrint "$1" + MessageBox MB_OK|MB_ICONSTOP "OpenAlice was not installed completely.$\r$\n$\r$\nFiles are missing or truncated under:$\r$\n$INSTDIR$\r$\n$\r$\nClose OpenAlice and any program scanning that folder, then run this installer again. Your OpenAlice data is not affected.$\r$\n$\r$\n$1" /SD IDOK + SetErrorLevel 3 + Abort "OpenAlice was not installed completely." + ${endif} + DetailPrint "OpenAlice install verified: $1" +!macroend diff --git a/apps/desktop/src/install-integrity.spec.ts b/apps/desktop/src/install-integrity.spec.ts new file mode 100644 index 000000000..225702207 --- /dev/null +++ b/apps/desktop/src/install-integrity.spec.ts @@ -0,0 +1,100 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + INSTALL_INTEGRITY_FILE, + describeInstallIntegrityFailure, + summarizeInstallIntegrity, + verifyInstallIntegrity, +} from './install-integrity.js' + +const roots: string[] = [] + +async function resourcesFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), 'openalice-install-integrity-')) + roots.push(root) + await mkdir(join(root, 'runtime/vendor/pi'), { recursive: true }) + await mkdir(join(root, 'app.asar.unpacked/node_modules/node-pty/build/Release'), { recursive: true }) + const contents: Array<[string, string]> = [ + ['app.asar', 'archive-bytes'], + ['app.asar.unpacked/node_modules/node-pty/build/Release/pty.node', 'native'], + ['runtime/package.json', '{"version":"0.92.1"}'], + ['runtime/vendor/pi/package.json', '{"name":"pi"}'], + ] + for (const [file, body] of contents) await writeFile(join(root, file), body) + await writeFile(join(root, INSTALL_INTEGRITY_FILE), JSON.stringify({ + version: '0.92.1', + files: contents.map(([file, body]) => [file, Buffer.byteLength(body)]), + })) + return root +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('packaged install integrity', () => { + it('verifies every inventoried file by presence and size', async () => { + const root = await resourcesFixture() + const result = await verifyInstallIntegrity(root, { concurrency: 2 }) + expect(result).toMatchObject({ status: 'verified', checked: 4 }) + expect(summarizeInstallIntegrity(result)).toMatch(/^verified 4 files in \d+ms$/) + }) + + it('reports missing and truncated files sorted for the reinstall dialog', async () => { + const root = await resourcesFixture() + await rm(join(root, 'runtime/vendor/pi/package.json')) + await writeFile(join(root, 'app.asar.unpacked/node_modules/node-pty/build/Release/pty.node'), 'nat') + await writeFile(join(root, 'app.asar'), 'archive-bytes-plus-trailing-garbage') + + const result = await verifyInstallIntegrity(root) + expect(result).toMatchObject({ + status: 'damaged', + checked: 4, + missing: ['runtime/vendor/pi/package.json'], + mismatched: ['app.asar', 'app.asar.unpacked/node_modules/node-pty/build/Release/pty.node'], + }) + if (result.status !== 'damaged') throw new Error('expected damaged result') + expect(summarizeInstallIntegrity(result)).toContain('damaged: 1 missing, 2 truncated of 4 files') + const message = describeInstallIntegrityFailure(result, { + version: '0.92.1', + installRoot: root, + diagnosticsPath: join(root, 'desktop.log'), + }) + expect(message).toContain('OpenAlice 0.92.1 is missing part of its installation') + expect(message).toContain('1 file(s) are missing and 2 are truncated under:') + expect(message).toContain(root) + expect(message).toContain(' runtime/vendor/pi/package.json') + expect(message).toContain('Download the installer again and reinstall OpenAlice.') + expect(message).toContain(join(root, 'desktop.log')) + }) + + it('treats a missing or malformed inventory as unverifiable', async () => { + const root = await resourcesFixture() + await writeFile(join(root, INSTALL_INTEGRITY_FILE), '{"files":"nope"}') + const malformed = await verifyInstallIntegrity(root) + expect(malformed).toMatchObject({ status: 'unverifiable' }) + if (malformed.status !== 'unverifiable') throw new Error('expected unverifiable result') + expect(malformed.reason).toContain(INSTALL_INTEGRITY_FILE) + expect(describeInstallIntegrityFailure(malformed, { version: '0.92.1', installRoot: root })) + .toContain('could not verify its installed files') + + await rm(join(root, INSTALL_INTEGRITY_FILE)) + await expect(verifyInstallIntegrity(root)).resolves.toMatchObject({ status: 'unverifiable' }) + }) + + it.skipIf(process.platform === 'win32')('checks symlinks by presence only', async () => { + const root = await resourcesFixture() + await symlink('../package.json', join(root, 'runtime/vendor/link')) + await writeFile(join(root, INSTALL_INTEGRITY_FILE), JSON.stringify({ + version: '0.92.1', + files: [['runtime/vendor/link', null], ['runtime/vendor/missing-link', null]], + })) + await expect(verifyInstallIntegrity(root)).resolves.toMatchObject({ + status: 'damaged', + missing: ['runtime/vendor/missing-link'], + mismatched: [], + }) + }) +}) diff --git a/apps/desktop/src/install-integrity.ts b/apps/desktop/src/install-integrity.ts new file mode 100644 index 000000000..ead1e22b4 --- /dev/null +++ b/apps/desktop/src/install-integrity.ts @@ -0,0 +1,120 @@ +import { lstat, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +// Written by scripts/desktop-after-pack.mjs beside app.asar. Keep the file +// name and entry shape in sync with scripts/desktop-install-integrity.mjs. +export const INSTALL_INTEGRITY_FILE = 'openalice-integrity.json' +export const INSTALL_INTEGRITY_SKIP_ENV = 'OPENALICE_DESKTOP_SKIP_INSTALL_INTEGRITY' +export const REINSTALL_URL = 'https://github.com/TraderAlice/OpenAlice/releases/latest' + +export interface InstallIntegrityManifest { + version: string + files: Array<[path: string, size: number | null]> +} + +export type InstallIntegrityResult = + | { status: 'verified'; checked: number; durationMs: number } + | { status: 'damaged'; checked: number; missing: string[]; mismatched: string[]; durationMs: number } + | { status: 'unverifiable'; reason: string } + +const MAX_REPORTED_PATHS = 8 + +export async function readInstallIntegrityManifest(resourcesPath: string): Promise { + const raw = await readFile(join(resourcesPath, INSTALL_INTEGRITY_FILE), 'utf8') + const parsed: unknown = JSON.parse(raw) + if ( + typeof parsed !== 'object' || parsed === null || + typeof (parsed as { version?: unknown }).version !== 'string' || + !Array.isArray((parsed as { files?: unknown }).files) + ) { + throw new Error(`${INSTALL_INTEGRITY_FILE} has no version/files inventory`) + } + return parsed as InstallIntegrityManifest +} + +export async function verifyInstallIntegrity( + resourcesPath: string, + options: { manifest?: InstallIntegrityManifest; concurrency?: number } = {}, +): Promise { + const startedAt = Date.now() + let manifest = options.manifest + if (!manifest) { + try { + manifest = await readInstallIntegrityManifest(resourcesPath) + } catch (error) { + return { status: 'unverifiable', reason: error instanceof Error ? error.message : String(error) } + } + } + const missing: string[] = [] + const mismatched: string[] = [] + const entries = manifest.files + const concurrency = Math.max(1, options.concurrency ?? 32) + let cursor = 0 + const worker = async () => { + while (cursor < entries.length) { + const entry = entries[cursor++] + if (!entry) continue + const [relativePath, size] = entry + try { + const stat = await lstat(join(resourcesPath, relativePath)) + if (size !== null && !stat.isSymbolicLink() && stat.size !== size) mismatched.push(relativePath) + } catch { + missing.push(relativePath) + } + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, entries.length || 1) }, worker)) + const durationMs = Date.now() - startedAt + if (missing.length === 0 && mismatched.length === 0) { + return { status: 'verified', checked: entries.length, durationMs } + } + missing.sort() + mismatched.sort() + return { status: 'damaged', checked: entries.length, missing, mismatched, durationMs } +} + +export function summarizeInstallIntegrity(result: InstallIntegrityResult): string { + switch (result.status) { + case 'verified': + return `verified ${result.checked} files in ${result.durationMs}ms` + case 'unverifiable': + return `inventory unavailable: ${result.reason}` + case 'damaged': { + const sample = [...result.missing, ...result.mismatched].slice(0, MAX_REPORTED_PATHS) + return ( + `damaged: ${result.missing.length} missing, ${result.mismatched.length} truncated ` + + `of ${result.checked} files in ${result.durationMs}ms; first: ${sample.join(', ')}` + ) + } + } +} + +export function describeInstallIntegrityFailure( + result: Exclude, + context: { version: string; installRoot: string; diagnosticsPath?: string }, +): string { + const lines: string[] = [] + if (result.status === 'unverifiable') { + lines.push(`OpenAlice ${context.version} could not verify its installed files.`, '', result.reason) + } else { + lines.push( + `OpenAlice ${context.version} is missing part of its installation, so it did not start.`, + '', + `${result.missing.length} file(s) are missing and ${result.mismatched.length} are truncated under:`, + context.installRoot, + ) + const sample = [...result.missing, ...result.mismatched].slice(0, MAX_REPORTED_PATHS) + if (sample.length > 0) { + lines.push('', ...sample.map((file) => ` ${file}`)) + const remaining = result.missing.length + result.mismatched.length - sample.length + if (remaining > 0) lines.push(` ... and ${remaining} more`) + } + } + lines.push( + '', + 'This usually means the installer or an update was interrupted, or another program removed files from the install directory.', + 'Download the installer again and reinstall OpenAlice. Your data directory is not affected.', + ) + if (context.diagnosticsPath) lines.push('', `Diagnostic log:\n${context.diagnosticsPath}`) + return lines.join('\n') +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 43f6c625a..52bcb4713 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -21,7 +21,7 @@ * Out of scope (future iterations): tray icon, multi-window, native menus. */ -import { app, BrowserWindow, dialog, Menu, Notification, protocol, session } from 'electron' +import { app, BrowserWindow, dialog, Menu, Notification, protocol, session, shell } from 'electron' import { runRendererTradingModeSmoke } from './trading-mode-smoke.js' import { runRendererDataHomeSmoke } from './data-home-smoke.js' import { runRendererWorkspaceAcceptanceSmoke } from './workspace-acceptance-smoke.js' @@ -59,6 +59,13 @@ import { existingOwnerSmokeMode, resolveExistingOwnerStartup } from './existing- import { inspectPreviousUpdateAttempt, recordUpdateAttempt } from './update-attempt.js' import { childIsRunning, stopChild } from './child-shutdown.js' import { exitDesktopProcess } from './app-exit.js' +import { + INSTALL_INTEGRITY_SKIP_ENV, + REINSTALL_URL, + describeInstallIntegrityFailure, + summarizeInstallIntegrity, + verifyInstallIntegrity, +} from './install-integrity.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -583,6 +590,35 @@ app.whenReady().then(async () => { ) } + // A partially extracted or partially deleted install otherwise fails later + // with an arbitrary missing module or toolchain error. Refuse to start and + // point at a reinstall before touching the selected data home. + if (app.isPackaged && !truthyEnv(process.env[INSTALL_INTEGRITY_SKIP_ENV])) { + const integrity = await verifyInstallIntegrity(process.resourcesPath) + desktopDiagnostics.write('install-integrity', summarizeInstallIntegrity(integrity)) + if (integrity.status !== 'verified') { + const choice = dialog.showMessageBoxSync({ + type: 'error', + title: 'OpenAlice — installation incomplete', + message: 'OpenAlice cannot start because its installation is incomplete.', + detail: describeInstallIntegrityFailure(integrity, { + version: app.getVersion(), + installRoot: process.platform === 'darwin' + ? dirname(dirname(process.resourcesPath)) + : dirname(process.resourcesPath), + diagnosticsPath: desktopDiagnostics.path, + }), + buttons: ['Download installer', 'Quit'], + defaultId: 0, + cancelId: 1, + noLink: true, + }) + if (choice === 0) await shell.openExternal(REINSTALL_URL) + app.quit() + return + } + } + // Build output lives at /dist/electron/main.js, /dist/main.js // (Alice), /services/uta/dist/uta.js (UTA), and the optional // /services/connector/dist/connector.js. The desktop package diff --git a/docs/managed-workspace-runtime.md b/docs/managed-workspace-runtime.md index 35ea9a261..98751a8dc 100644 --- a/docs/managed-workspace-runtime.md +++ b/docs/managed-workspace-runtime.md @@ -270,6 +270,30 @@ which would remove Electron's own application metadata. Packaging commands run through `pnpm -F @traderalice/desktop` (the configured hook is relative to that working directory). +The same hook then writes `openalice-integrity.json` beside `app.asar`: the +product version plus every file under `app.asar`, `app.asar.unpacked/` and +`runtime/` with its byte size (`scripts/desktop-install-integrity.mjs`). Two +consumers compare the installed tree against it: + +- The Windows NSIS `customInstall` macro (`apps/desktop/build/installer.nsh`) + runs after extraction and before electron-builder's force-run launch. A + missing or truncated file aborts the install with exit level 3 and a + reinstall message instead of handing off to a partial tree. The `customInit` + update path also waits for processes under `$INSTDIR` to exit (up to 20 s) + before removing the previous app directory. +- The packaged desktop main process (`apps/desktop/src/install-integrity.ts`) + verifies the inventory before resolving the data home. A damaged install + shows a reinstall dialog that links to the latest release and quits; the + result is recorded in the desktop diagnostic log as `install-integrity`. + `OPENALICE_DESKTOP_SKIP_INSTALL_INTEGRITY=1` bypasses the check for + diagnosis only. + +Both checks exist because electron-builder's extraction falls back to a +non-atomic 7z extract that ignores per-file errors when its atomic copy fails +(locked files, antivirus scans, `MAX_PATH`). `pnpm electron:assert-package` +verifies the inventory against the unpacked package, so a stale inventory or a +payload change without `afterPack` fails locally before release. + Windows installed-version polling reads the authoritative `app.asar/package.json` when an archive exists, and the loose `app/package.json` for older releases. Clear the ASAR header cache between polls because NSIS replaces the archive in diff --git a/scripts/assert-desktop-package.mjs b/scripts/assert-desktop-package.mjs index 4021c735a..621041a5d 100644 --- a/scripts/assert-desktop-package.mjs +++ b/scripts/assert-desktop-package.mjs @@ -4,6 +4,7 @@ import { dirname, join, normalize, relative, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { extractFile, listPackage, statFile } from '@electron/asar' import { DEFAULT_DESKTOP_PACKAGE_ROOT, resolveDesktopPackageRootArg } from './desktop-package-artifact.mjs' +import { INSTALL_INTEGRITY_FILE, readInstallIntegrity, verifyInstallIntegrity } from './desktop-install-integrity.mjs' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -117,10 +118,26 @@ export function assertDesktopPackage(options = {}) { throw new Error(`native payload must be unpacked: ${file}`) } } + // Compiler intermediates only add extraction surface to the installer. + const buildIntermediates = archiveEntries.filter((entry) => + /^node_modules\/node-pty\/build\/.*(\/obj\/|\.(exp|iobj|ipdb|lib|pdb|tlog)$)/.test(entry) && + !('files' in statFile(archivePath, normalize(entry)))) + if (buildIntermediates.length > 0) { + throw new Error(`node-pty build intermediates must not ship: ${buildIntermediates[0]}`) + } } catch (error) { errors.push(`[desktop-package] invalid app.asar: ${error instanceof Error ? error.message : String(error)}`) } + const resourcesRoot = dirname(appRoot) + try { + const integrity = verifyInstallIntegrity(resourcesRoot, readInstallIntegrity(resourcesRoot)) + for (const file of integrity.missing) errors.push(`[desktop-package] ${INSTALL_INTEGRITY_FILE} lists a missing file: ${file}`) + for (const file of integrity.mismatched) errors.push(`[desktop-package] ${INSTALL_INTEGRITY_FILE} size differs on disk: ${file}`) + } catch (error) { + errors.push(`[desktop-package] unreadable ${INSTALL_INTEGRITY_FILE}: ${error instanceof Error ? error.message : String(error)}`) + } + const nodeModules = join(unpackedRoot, 'node_modules') const virtualStore = join(nodeModules, '.pnpm') const virtualEntries = existsSync(virtualStore) ? readdirSync(virtualStore) : [] diff --git a/scripts/assert-desktop-package.spec.ts b/scripts/assert-desktop-package.spec.ts index bb78e2a49..9ab7f59a5 100644 --- a/scripts/assert-desktop-package.spec.ts +++ b/scripts/assert-desktop-package.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import { createPackageWithOptions, uncache } from '@electron/asar' import { ASAR_REQUIRED_FILES, BASE_REQUIRED_FILES, assertDesktopPackage } from './assert-desktop-package.mjs' +import { INSTALL_INTEGRITY_FILE, collectInstallIntegrity } from './desktop-install-integrity.mjs' const PI_CLI = 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js' @@ -17,7 +18,16 @@ function writePackageFile(appRoot: string, file: string, content = '') { writeFileSync(path, content) } -async function writeBasePackage(appRoot: string, manifest: unknown, duplicateResource = false) { +function writeIntegrityManifest(appRoot: string) { + const resources = dirname(appRoot) + writePackageFile(resources, INSTALL_INTEGRITY_FILE, JSON.stringify(collectInstallIntegrity(resources, { version: '0.91.1' }))) +} + +async function writeBasePackage( + appRoot: string, + manifest: unknown, + options: { duplicateResource?: boolean; buildIntermediate?: boolean } = {}, +) { for (const file of BASE_REQUIRED_FILES) { if (file === 'vendor/manifest.json') continue writePackageFile(appRoot, file) @@ -30,12 +40,14 @@ async function writeBasePackage(appRoot: string, manifest: unknown, duplicateRes for (const dir of ['default', 'vendor', 'ui/dist', 'src/workspaces/templates']) { mkdirSync(join(input, dir), { recursive: true }) } - if (duplicateResource) writePackageFile(input, 'default/duplicated.md', 'duplicate') + if (options.duplicateResource) writePackageFile(input, 'default/duplicated.md', 'duplicate') + if (options.buildIntermediate) writePackageFile(input, 'node_modules/node-pty/build/Release/obj/pty/pty.obj', 'obj') for (const file of ASAR_REQUIRED_FILES) writePackageFile(input, file) writePackageFile(input, 'package.json', JSON.stringify({ version: '0.91.1' })) writePackageFile(input, 'node_modules/node-pty/build/Release/pty.node') await createPackageWithOptions(input, join(dirname(appRoot), 'app.asar'), { unpack: '**/*.node' }) rmSync(input, { recursive: true, force: true }) + writeIntegrityManifest(appRoot) } function piManifest() { @@ -101,13 +113,26 @@ describe('assertDesktopPackage', () => { const dependencyFilter = getNodeModuleFileMatcher(root, destination, expand, config[platform], info).createFilter() expect(dependencyFilter(join(root, 'node_modules/dugite/build/lib/index.js'), fileStat)).toBe(true) expect(dependencyFilter(join(root, 'node_modules/dugite/git/bin/git'), fileStat)).toBe(platform === 'mac') + expect(dependencyFilter(join(root, 'node_modules/node-pty/build/Release/pty.node'), fileStat)).toBe(true) + expect(dependencyFilter(join(root, 'node_modules/node-pty/build/Release/winpty-agent.exe'), fileStat)).toBe(true) + for (const file of [ + 'node_modules/node-pty/build/Release/obj/pty/pty.obj', + 'node_modules/node-pty/build/Release/obj/conpty/conpty.tlog/CL.command.1.tlog', + 'node_modules/node-pty/build/deps/winpty/src/Release/obj/agent/Agent.obj', + 'node_modules/node-pty/build/Release/pty.lib', + 'node_modules/node-pty/build/Release/winpty.iobj', + 'node_modules/node-pty/build/Release/conpty.ipdb', + 'node_modules/node-pty/build/Release/conpty.exp', + ]) { + expect(dependencyFilter(join(root, file), fileStat), file).toBe(false) + } }) it('rejects duplicated resource files while permitting empty archive directories', async () => { const root = mkdtempSync(join(tmpdir(), 'openalice-package-duplicate-')) try { const appRoot = join(root, 'mac-arm64/OpenAlice.app/Contents/Resources/runtime') - await writeBasePackage(appRoot, { ...piManifest(), ...searchToolsManifest('darwin-arm64') }, true) + await writeBasePackage(appRoot, { ...piManifest(), ...searchToolsManifest('darwin-arm64') }, { duplicateResource: true }) expect(assertDesktopPackage({ packageRoot: root, arch: 'arm64' }).errors.join('\n')) .toContain('external runtime resource duplicated in ASAR: default/duplicated.md') } finally { @@ -115,6 +140,42 @@ describe('assertDesktopPackage', () => { } }) + it('rejects node-pty compiler intermediates inside the archive', async () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-package-intermediates-')) + try { + const appRoot = join(root, 'win-unpacked/resources/runtime') + await writeBasePackage(appRoot, piManifest(), { buildIntermediate: true }) + expect(assertDesktopPackage({ packageRoot: root, arch: 'x64' }).errors.join('\n')) + .toContain('node-pty build intermediates must not ship: node_modules/node-pty/build/Release/obj/pty/pty.obj') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('requires the install integrity inventory to match the packaged payload', async () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-package-integrity-')) + const appRoot = join(root, 'mac-arm64/OpenAlice.app/Contents/Resources/runtime') + try { + await writeBasePackage(appRoot, { ...piManifest(), ...searchToolsManifest('darwin-arm64') }) + writeSearchToolFiles(appRoot, 'darwin-arm64') + writePackageFile(join(dirname(appRoot), 'app.asar.unpacked'), 'node_modules/dugite/git/bin/git') + writeIntegrityManifest(appRoot) + expect(assertDesktopPackage({ packageRoot: root, arch: 'arm64' }).ok).toBe(true) + + rmSync(join(appRoot, 'src/workspaces/templates/_common.mjs')) + writePackageFile(appRoot, 'ui/dist/index.html', 'truncated differently') + const errors = assertDesktopPackage({ packageRoot: root, arch: 'arm64' }).errors.join('\n') + expect(errors).toContain(`${INSTALL_INTEGRITY_FILE} lists a missing file: runtime/src/workspaces/templates/_common.mjs`) + expect(errors).toContain(`${INSTALL_INTEGRITY_FILE} size differs on disk: runtime/ui/dist/index.html`) + + rmSync(join(dirname(appRoot), INSTALL_INTEGRITY_FILE)) + expect(assertDesktopPackage({ packageRoot: root, arch: 'arm64' }).errors.join('\n')) + .toContain(`unreadable ${INSTALL_INTEGRITY_FILE}`) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('rejects a legacy loose app tree with no ASAR runtime layout', () => { const root = mkdtempSync(join(tmpdir(), 'openalice-package-legacy-')) try { diff --git a/scripts/desktop-after-pack.mjs b/scripts/desktop-after-pack.mjs index d5c957768..3a62440da 100644 --- a/scripts/desktop-after-pack.mjs +++ b/scripts/desktop-after-pack.mjs @@ -1,6 +1,7 @@ import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { extractFile } from '@electron/asar' +import { INSTALL_INTEGRITY_FILE, collectInstallIntegrity } from './desktop-install-integrity.mjs' // extraResources removes matching inputs from app.asar, including package.json. // Project only product identity into the physical resource tree after packing; @@ -11,4 +12,9 @@ export default async function afterPack(context) { : join(context.appOutDir, 'resources') const { name, version, type } = JSON.parse(extractFile(join(resources, 'app.asar'), 'package.json').toString()) await writeFile(join(resources, 'runtime', 'package.json'), `${JSON.stringify({ name, version, type }, null, 2)}\n`) + // Inventory the final payload last so runtime/package.json is covered too. + await writeFile( + join(resources, INSTALL_INTEGRITY_FILE), + `${JSON.stringify(collectInstallIntegrity(resources, { version }))}\n`, + ) } diff --git a/scripts/desktop-after-pack.spec.ts b/scripts/desktop-after-pack.spec.ts index 5309b225e..9503683c0 100644 --- a/scripts/desktop-after-pack.spec.ts +++ b/scripts/desktop-after-pack.spec.ts @@ -1,9 +1,10 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createPackage } from '@electron/asar' import { describe, expect, it } from 'vitest' import afterPack from './desktop-after-pack.mjs' +import { INSTALL_INTEGRITY_FILE, readInstallIntegrity, verifyInstallIntegrity } from './desktop-install-integrity.mjs' describe('desktop product metadata projection', () => { it.each(['darwin', 'win32'])('takes %s runtime identity from the built archive', async (platform) => { @@ -31,4 +32,44 @@ describe('desktop product metadata projection', () => { rmSync(root, { recursive: true, force: true }) } }) + + it('inventories the archive, unpacked natives, and runtime payload after projecting metadata', async () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-asar-integrity-')) + try { + const input = join(root, 'input') + const resources = join(root, 'resources') + mkdirSync(input) + mkdirSync(join(resources, 'runtime/vendor/pi'), { recursive: true }) + mkdirSync(join(resources, 'app.asar.unpacked/node_modules/node-pty/build/Release'), { recursive: true }) + writeFileSync(join(input, 'package.json'), JSON.stringify({ name: 'open-alice', version: '0.92.1', type: 'module' })) + await createPackage(input, join(resources, 'app.asar')) + writeFileSync(join(resources, 'runtime/vendor/pi/package.json'), '{"name":"pi"}') + writeFileSync(join(resources, 'app.asar.unpacked/node_modules/node-pty/build/Release/pty.node'), 'native') + writeFileSync(join(resources, 'elevate.exe'), 'not inventoried') + + await afterPack({ electronPlatformName: 'win32', appOutDir: root, packager: { appInfo: { productFilename: 'OpenAlice' } } }) + + const manifest = readInstallIntegrity(resources) + expect(manifest.version).toBe('0.92.1') + expect(manifest.files).toEqual([ + ['app.asar', statSync(join(resources, 'app.asar')).size], + ['app.asar.unpacked/node_modules/node-pty/build/Release/pty.node', 6], + ['runtime/package.json', statSync(join(resources, 'runtime/package.json')).size], + ['runtime/vendor/pi/package.json', 13], + ]) + expect(verifyInstallIntegrity(resources, manifest)).toEqual({ checked: 4, missing: [], mismatched: [] }) + + rmSync(join(resources, 'runtime/vendor/pi/package.json')) + writeFileSync(join(resources, 'app.asar.unpacked/node_modules/node-pty/build/Release/pty.node'), 'nat') + expect(verifyInstallIntegrity(resources, manifest)).toEqual({ + checked: 4, + missing: ['runtime/vendor/pi/package.json'], + mismatched: ['app.asar.unpacked/node_modules/node-pty/build/Release/pty.node'], + }) + rmSync(join(resources, INSTALL_INTEGRITY_FILE)) + expect(() => readInstallIntegrity(resources)).toThrow() + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) }) diff --git a/scripts/desktop-install-integrity.mjs b/scripts/desktop-install-integrity.mjs new file mode 100644 index 000000000..0b6272838 --- /dev/null +++ b/scripts/desktop-install-integrity.mjs @@ -0,0 +1,56 @@ +import { lstatSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +// Payload inventory written beside app.asar after packing. The installer and +// the packaged desktop compare the installed tree against it so a partial +// extraction fails with a reinstall message instead of an arbitrary missing +// module or missing toolchain error at first launch. +export const INSTALL_INTEGRITY_FILE = 'openalice-integrity.json' +export const INSTALL_INTEGRITY_ROOTS = ['app.asar', 'app.asar.unpacked', 'runtime'] + +export function collectInstallIntegrity(resourcesDir, { version }) { + const files = [] + const visit = (absolute, relativePath) => { + const stat = lstatSync(absolute) + if (stat.isDirectory()) { + for (const entry of readdirSync(absolute)) visit(join(absolute, entry), `${relativePath}/${entry}`) + return + } + // Symlink sizes depend on the filesystem, so only their presence is tracked. + files.push([relativePath, stat.isSymbolicLink() ? null : stat.size]) + } + for (const root of INSTALL_INTEGRITY_ROOTS) { + try { + lstatSync(join(resourcesDir, root)) + } catch { + continue + } + visit(join(resourcesDir, root), root) + } + files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return { version, files } +} + +export function readInstallIntegrity(resourcesDir) { + const manifest = JSON.parse(readFileSync(join(resourcesDir, INSTALL_INTEGRITY_FILE), 'utf8')) + if (typeof manifest?.version !== 'string' || !Array.isArray(manifest.files)) { + throw new Error(`${INSTALL_INTEGRITY_FILE} has no version/files inventory`) + } + return manifest +} + +export function verifyInstallIntegrity(resourcesDir, manifest) { + const missing = [] + const mismatched = [] + for (const [relativePath, size] of manifest.files) { + let stat + try { + stat = lstatSync(join(resourcesDir, relativePath)) + } catch { + missing.push(relativePath) + continue + } + if (size !== null && !stat.isSymbolicLink() && stat.size !== size) mismatched.push(relativePath) + } + return { checked: manifest.files.length, missing, mismatched } +} diff --git a/scripts/desktop-upgrade-smoke-lib.spec.ts b/scripts/desktop-upgrade-smoke-lib.spec.ts index 2d89e18eb..6e4d47f65 100644 --- a/scripts/desktop-upgrade-smoke-lib.spec.ts +++ b/scripts/desktop-upgrade-smoke-lib.spec.ts @@ -63,11 +63,26 @@ describe('desktop upgrade smoke planning', () => { expect(packageJson.build.nsis.include).toBe('apps/desktop/build/installer.nsh') expect(installerInclude).toContain('${if} ${isUpdated}') expect(installerInclude).toContain('/T /F /IM "${APP_EXECUTABLE_FILENAME}"') - expect(installerInclude).toContain("ExecutablePath.StartsWith('$INSTDIR'") + expect(installerInclude).toContain('ExecutablePath.StartsWith($$Root') expect(installerInclude).toContain('Stop-Process -Id') + expect(installerInclude).toContain('openalice-stop-install-processes.ps1" -Root "$INSTDIR"') + expect(installerInclude).not.toContain('Sleep 1000') expect(installerInclude).toContain('SetOutPath "$TEMP"') expect(installerInclude).toContain('/D /C RD /S /Q "\\\\?\\$INSTDIR"') expect(installerInclude).toContain('DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" "UninstallString"') + // Post-extraction verification must run before electron-builder's + // force-run launch and abort the install instead of handing off. + expect(installerInclude).toContain('!macro customInstall') + expect(installerInclude).toContain("Join-Path $$Root 'openalice-integrity.json'") + expect(installerInclude).toContain('openalice-verify-install.ps1" -Root "$INSTDIR\\resources"') + expect(installerInclude).toContain('Abort "OpenAlice was not installed completely."') + for (const macro of ['writeStopInstallProcessesScript', 'writeVerifyInstallScript']) { + const body = installerInclude.split(`!macro ${macro} PATH`)[1]?.split('!macroend')[0] ?? '' + // NSIS expands `${name}` and `$(name)` inside strings; the helpers must + // only use `$$` escapes so PowerShell receives literal `$` variables. + expect(body, macro).not.toMatch(/\$\(|\$\{(?!PATH\})/) + expect(body, macro).toMatch(/FileWrite \$R9 "exit /) + } }) it('selects the newest published version different from the candidate', () => {