From 8f2582bec13e913e8cfae11afd4a3df63a99ac60 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 21:30:37 +0300 Subject: [PATCH 01/30] test(release): prove packaged desktop startup --- .github/workflows/release.yml | 10 + apps/desktop/src/main.ts | 10 + docs/release.md | 3 + scripts/release-smoke.ts | 44 ++ .../verify-packaged-desktop-startup.test.ts | 321 ++++++++++ scripts/verify-packaged-desktop-startup.ts | 579 ++++++++++++++++++ 6 files changed, 967 insertions(+) create mode 100644 scripts/verify-packaged-desktop-startup.test.ts create mode 100644 scripts/verify-packaged-desktop-startup.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 686b4c1a..f6d2f9c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -380,6 +380,16 @@ jobs: fi fi + - name: Smoke exact packaged desktop startup + if: ${{ matrix.platform != 'linux' }} + shell: bash + run: | + node scripts/verify-packaged-desktop-startup.ts \ + --assets-dir release-publish \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --version "${{ needs.preflight.outputs.version }}" + - name: Upload build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ff680d47..94278e1b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3514,9 +3514,19 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); }); window.webContents.on("did-finish-load", () => { + writeDesktopLogHeader("renderer main frame loaded"); window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, _validatedUrl, isMainFrame) => { + if (!isMainFrame) return; + writeDesktopLogHeader( + `renderer main frame load failed code=${errorCode} message=${errorDescription}`, + ); + }, + ); window.once("ready-to-show", () => { // Preserve the original first-launch behavior, then respect the state saved // by subsequent closes. Normal bounds are restored before maximizing so the diff --git a/docs/release.md b/docs/release.md index 3cfa04d9..f447b265 100644 --- a/docs/release.md +++ b/docs/release.md @@ -15,6 +15,9 @@ This document covers build-only native validation, promotion through the protect - macOS `x64` DMG - Linux `x64` AppImage - Windows `x64` NSIS installer +- Before upload, launches the exact collected macOS and Windows payloads from + isolated Scient state and requires stable app readiness, backend readiness, + and successful renderer main-frame loading. - Publishes one versioned GitHub Release with all produced files. - Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - Stable 0.5.x releases are GitHub Latest; the 0.4.x compatibility release remains historical. diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index af70e70b..ac6c2311 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -288,6 +288,50 @@ function verifyReleaseWorkflowSafety(): void { ) { throw new Error("Expected the release-note gate to verify the exact resolved version."); } + const collectAssetsIndex = buildSteps.findIndex((step) => step.name === "Collect release assets"); + const packagedStartupIndex = buildSteps.findIndex( + (step) => step.name === "Smoke exact packaged desktop startup", + ); + const uploadArtifactsIndex = buildSteps.findIndex( + (step) => step.name === "Upload build artifacts", + ); + const packagedStartupStep = buildSteps[packagedStartupIndex]; + if ( + collectAssetsIndex < 0 || + packagedStartupIndex <= collectAssetsIndex || + uploadArtifactsIndex <= packagedStartupIndex || + packagedStartupStep?.if !== "${{ matrix.platform != 'linux' }}" || + !packagedStartupStep.run?.includes("node scripts/verify-packaged-desktop-startup.ts") || + !packagedStartupStep.run.includes("--assets-dir release-publish") + ) { + throw new Error( + "Expected exact macOS and Windows packaged-startup proof after collection and before upload.", + ); + } + const packagedStartupVerifier = readFileSync( + resolve(repoRoot, "scripts/verify-packaged-desktop-startup.ts"), + "utf8", + ).replaceAll("\r\n", "\n"); + assertContains( + packagedStartupVerifier, + "SCIENT_HOME: scientHome", + "Expected packaged startup verification to isolate Scient state.", + ); + assertContains( + packagedStartupVerifier, + "PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST", + "Expected packaged startup verification to inherit only explicit host variables.", + ); + assertContains( + packagedStartupVerifier, + 'log.includes("renderer main frame loaded")', + "Expected packaged startup proof to require successful renderer loading.", + ); + assertNotContains( + packagedStartupVerifier, + '"linux" | "mac" | "win"', + "The extracted startup verifier must not absorb deferred Linux packaging policy.", + ); const appleSigningNames = [ "CSC_LINK", "CSC_KEY_PASSWORD", diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts new file mode 100644 index 00000000..e6aea2fd --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -0,0 +1,321 @@ +import type { ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + assertPackagedLaunchCommandSafety, + createPackagedDesktopSmokeEnvironment, + expectedPackagedDesktopStartupAssetName, + hasPackagedStartupProof, + isScientWindowsExecutable, + parsePackagedDesktopStartupArgs, + resolveExactPackagedDesktopStartupAsset, + resolveNativePackagedDesktopPlatform, + resolvePackagedDesktopLogPath, + sanitizePackagedDesktopInheritedEnvironment, + terminateProcessTree, + waitForPackagedStartupProof, +} from "./verify-packaged-desktop-startup.ts"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("packaged desktop startup verification", () => { + it("parses a bounded native payload request", () => { + expect( + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "mac", + "--arch", + "x64", + "--version", + "1.2.3", + ]), + ).toEqual({ + assetsDirectory: expect.stringMatching(/release-publish$/), + platform: "mac", + arch: "x64", + version: "1.2.3", + timeoutMs: 60_000, + }); + + expect(() => + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "mac", + "--arch", + "x64", + "--version", + "1.2.3", + "--timeout-ms", + "4999", + ]), + ).toThrow("--timeout-ms must be an integer between 5000 and 180000"); + }); + + it("isolates Scient state and removes inherited runtime authority", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-env-test-")); + temporaryRoots.push(root); + + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "mac", version: "1.2.3" }, + { + DISPLAY: ":99", + NODE_OPTIONS: "--require /tmp/untrusted.js", + OPENAI_API_KEY: "must-not-leak", + PATH: process.env.PATH, + SCIENT_DEV_ALLOW_NO_SANDBOX: "1", + SCIENT_HOME: "/must/not/leak", + LEGACY_PRODUCT_HOME: "/must/not/leak-either", + PROVIDER_AUTH_TOKEN: "must-not-leak", + ELECTRON_RUN_AS_NODE: "1", + }, + ); + + expect(env.LEGACY_PRODUCT_HOME).toBeUndefined(); + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.PROVIDER_AUTH_TOKEN).toBeUndefined(); + expect(env.SCIENT_DEV_ALLOW_NO_SANDBOX).toBeUndefined(); + expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(env.DISPLAY).toBe(":99"); + for (const name of [ + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "SCIENT_HOME", + ] as const) { + expect(env[name]?.startsWith(root)).toBe(true); + expect(existsSync(env[name]!)).toBe(true); + } + if (process.platform !== "win32") { + expect(statSync(env.XDG_RUNTIME_DIR!).mode & 0o777).toBe(0o700); + } + expect(resolvePackagedDesktopLogPath(env)).toBe( + join(env.SCIENT_HOME!, "userdata", "logs", "desktop-main.log"), + ); + }); + + it("allowlists only host variables needed to launch a native packaged app", () => { + expect( + sanitizePackagedDesktopInheritedEnvironment({ + DISPLAY: ":99", + ELECTRON_RUN_AS_NODE: "1", + NODE_OPTIONS: "--inspect", + OPENAI_API_KEY: "secret", + PATH: "/usr/bin", + SystemRoot: "C:\\Windows", + }), + ).toEqual({ DISPLAY: ":99", PATH: "/usr/bin", SystemRoot: "C:\\Windows" }); + }); + + it("requires the exact versioned and architecture-specific release asset", () => { + expect(expectedPackagedDesktopStartupAssetName("mac", "arm64", "1.2.3")).toBe( + "Scient-1.2.3-arm64.zip", + ); + expect(expectedPackagedDesktopStartupAssetName("win", "x64", "1.2.3")).toBe( + "Scient-1.2.3-x64.exe", + ); + + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-assets-test-")); + temporaryRoots.push(root); + const expected = join(root, "Scient-1.2.3-arm64.zip"); + writeFileSync(expected, "payload"); + expect(resolveExactPackagedDesktopStartupAsset(root, "Scient-1.2.3-arm64.zip")).toBe(expected); + + writeFileSync(join(root, "Scient-1.2.2-arm64.zip"), "stale payload"); + expect(() => resolveExactPackagedDesktopStartupAsset(root, "Scient-1.2.3-arm64.zip")).toThrow( + "found 2 .zip payloads", + ); + }); + + it("does not accept proof from a packaged process that exits immediately", async () => { + let now = 0; + let outcome = { exited: null, launchError: null } as { + exited: { code: number | null; signal: NodeJS.Signals | null } | null; + launchError: Error | null; + }; + + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => outcome, + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + outcome = { exited: { code: 1, signal: null }, launchError: null }; + }, + }), + ).rejects.toThrow("exited before stable startup proof"); + }); + + it("requires app, window, backend, and renderer readiness from the isolated log", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-proof-test-")); + temporaryRoots.push(root); + const logPath = join(root, "desktop-main.log"); + const requiredMarkers = [ + "app ready", + "bootstrap main window created", + "bootstrap backend ready source=packaged", + "renderer main frame loaded", + ]; + + for (const omittedMarker of requiredMarkers) { + writeFileSync( + logPath, + requiredMarkers.filter((marker) => marker !== omittedMarker).join("\n"), + ); + expect(hasPackagedStartupProof(logPath)).toBe(false); + } + + writeFileSync(logPath, requiredMarkers.join("\n")); + expect(hasPackagedStartupProof(logPath)).toBe(true); + }); + + it("accepts startup proof only after the process remains alive for the stability window", async () => { + let now = 0; + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => ({ exited: null, launchError: null }), + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + }, + }), + ).resolves.toBeUndefined(); + expect(now).toBeGreaterThanOrEqual(1_000); + }); + + it("fails when Windows process-tree cleanup cannot prove exit", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 1 })); + await expect( + terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived Windows cleanup"); + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42, 84]); + }); + + it("still cleans a detached Windows backend after the packaged root exits", async () => { + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + + await terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => true, + }, + [84], + ); + + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([84]); + }); + + it("fails when a POSIX process tree survives TERM and KILL", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + await expect( + terminateProcessTree( + child, + { + platform: "darwin", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived SIGTERM and SIGKILL"); + expect(signals).toEqual([ + { pid: 42, signal: "SIGTERM" }, + { pid: 84, signal: "SIGTERM" }, + { pid: 42, signal: "SIGKILL" }, + { pid: 84, signal: "SIGKILL" }, + ]); + }); + + it("prepares the isolated Scient macOS profile marker", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-mac-env-test-")); + temporaryRoots.push(root); + + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "mac", version: "1.2.3" }, + { PATH: process.env.PATH }, + ); + const markerPath = join( + env.HOME!, + "Library", + "Application Support", + "scient", + "last-launch-version.json", + ); + + expect(JSON.parse(readFileSync(markerPath, "utf8"))).toEqual({ version: "1.2.3" }); + }); + + it("recognizes only the Scient Windows executable identity", () => { + expect(isScientWindowsExecutable("C:\\payload\\Scient.exe")).toBe(true); + expect(isScientWindowsExecutable("C:\\payload\\scient.EXE")).toBe(true); + expect(isScientWindowsExecutable("C:\\payload\\Synara.exe")).toBe(false); + expect(isScientWindowsExecutable("C:\\payload\\Scient Helper.exe")).toBe(false); + }); + + it("rejects unsafe arguments from every native packaged launch", () => { + const launch = { command: "/tmp/Scient", args: [], cwd: "/tmp" }; + expect(() => assertPackagedLaunchCommandSafety(launch)).not.toThrow(); + expect(() => + assertPackagedLaunchCommandSafety({ + ...launch, + args: [...launch.args, "--no-sandbox"], + }), + ).toThrow("must exercise the real sandboxed command line"); + }); + + it("maps Node host platforms to release platform names", () => { + expect(resolveNativePackagedDesktopPlatform("darwin")).toBe("mac"); + expect(resolveNativePackagedDesktopPlatform("win32")).toBe("win"); + expect(resolveNativePackagedDesktopPlatform("linux")).toBeNull(); + }); +}); diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts new file mode 100644 index 00000000..b0bcf0b1 --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.ts @@ -0,0 +1,579 @@ +#!/usr/bin/env node +// FILE: verify-packaged-desktop-startup.ts +// Purpose: Launches an exact collected desktop release payload from isolated temporary state. +// Layer: Release verification script + +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type PackagedDesktopPlatform = "mac" | "win"; + +export interface PackagedDesktopStartupOptions { + readonly assetsDirectory: string; + readonly platform: PackagedDesktopPlatform; + readonly arch: string; + readonly version: string; + readonly timeoutMs: number; +} + +export function parsePackagedDesktopStartupArgs( + argv: ReadonlyArray, +): PackagedDesktopStartupOptions { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || values.has(name)) { + throw new Error(`Invalid packaged startup argument near ${name ?? ""}.`); + } + values.set(name, value); + } + + const known = new Set(["--assets-dir", "--platform", "--arch", "--version", "--timeout-ms"]); + for (const name of values.keys()) { + if (!known.has(name)) throw new Error(`Unknown packaged startup argument: ${name}.`); + } + + const required = (name: string): string => { + const value = values.get(name)?.trim(); + if (!value) throw new Error(`Missing packaged startup argument: ${name}.`); + return value; + }; + + const platform = required("--platform"); + if (platform !== "mac" && platform !== "win") { + throw new Error(`Unsupported packaged startup platform: ${platform}.`); + } + + const timeoutMs = Number(values.get("--timeout-ms") ?? "60000"); + if (!Number.isInteger(timeoutMs) || timeoutMs < 5_000 || timeoutMs > 180_000) { + throw new Error("--timeout-ms must be an integer between 5000 and 180000."); + } + + return { + assetsDirectory: resolve(required("--assets-dir")), + platform, + arch: required("--arch"), + version: required("--version"), + timeoutMs, + }; +} + +function runCommand(command: string, args: ReadonlyArray, cwd?: string): void { + const result = spawnSync(command, [...args], { + cwd, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + shell: false, + windowsHide: true, + }); + if (result.error) { + throw new Error(`${command} could not start: ${result.error.message}`); + } + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + const detail = output ? `\n${output}` : ""; + throw new Error( + `${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}.${detail}`, + ); + } +} + +function findFiles(root: string, predicate: (path: string) => boolean): string[] { + const matches: string[] = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.shift(); + if (!current) continue; + for (const entry of readdirSync(current, { withFileTypes: true })) { + const candidate = join(current, entry.name); + if (entry.isDirectory()) { + pending.push(candidate); + } else if (entry.isFile() && predicate(candidate)) { + matches.push(candidate); + } + } + } + return matches.toSorted((left, right) => left.localeCompare(right)); +} + +export function expectedPackagedDesktopStartupAssetName( + platform: PackagedDesktopPlatform, + arch: string, + version: string, +): string { + const extension = platform === "mac" ? ".zip" : ".exe"; + return `Scient-${version}-${arch}${extension}`; +} + +export function resolveExactPackagedDesktopStartupAsset( + directory: string, + expectedName: string, +): string { + const suffix = expectedName.slice(expectedName.lastIndexOf(".")); + const matches = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(suffix)) + .map((entry) => join(directory, entry.name)); + if (matches.length !== 1) { + throw new Error( + `Expected exactly ${expectedName}; found ${matches.length} ${suffix} payloads: ${matches.map((match) => basename(match)).join(", ") || "none"}.`, + ); + } + if (basename(matches[0]!) !== expectedName) { + throw new Error( + `Expected exact release asset ${expectedName}, found ${basename(matches[0]!)}.`, + ); + } + return matches[0]!; +} + +export interface PackagedDesktopLaunchCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; +} + +export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchCommand): void { + const forbiddenArgument = launch.args.find( + (argument) => argument === "--no-sandbox" || argument.startsWith("--no-sandbox="), + ); + if (forbiddenArgument) { + throw new Error( + `Packaged desktop verification must exercise the real sandboxed command line; refusing ${forbiddenArgument}.`, + ); + } +} + +function prepareMacLaunch( + assetsDirectory: string, + extractionRoot: string, + expectedAssetName: string, +): PackagedDesktopLaunchCommand { + const archive = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); + runCommand("ditto", ["-x", "-k", archive, extractionRoot]); + const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); + if (appBundles.length !== 1) { + throw new Error(`Expected one packaged macOS app in ${basename(archive)}.`); + } + const appBundle = join(extractionRoot, appBundles[0]!); + const executables = findFiles(join(appBundle, "Contents", "MacOS"), (candidate) => + statSync(candidate).isFile(), + ); + if (executables.length !== 1) { + throw new Error(`Expected one macOS main executable, found ${executables.length}.`); + } + return { command: executables[0]!, args: [], cwd: appBundle }; +} + +export function isScientWindowsExecutable(candidate: string): boolean { + return /[/\\]Scient\.exe$/i.test(candidate); +} + +function prepareWindowsLaunch( + assetsDirectory: string, + extractionRoot: string, + expectedAssetName: string, +): PackagedDesktopLaunchCommand { + const installer = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); + const installerRoot = join(extractionRoot, "installer"); + const applicationRoot = join(extractionRoot, "application"); + mkdirSync(installerRoot, { recursive: true }); + mkdirSync(applicationRoot, { recursive: true }); + runCommand("7z", ["x", "-y", `-o${installerRoot}`, installer]); + const applicationArchives = findFiles(installerRoot, (candidate) => + /[/\\]app-(?:32|64|arm64)\.7z$/i.test(candidate), + ); + if (applicationArchives.length !== 1) { + throw new Error( + `Expected one embedded NSIS application archive, found ${applicationArchives.length}.`, + ); + } + runCommand("7z", ["x", "-y", `-o${applicationRoot}`, applicationArchives[0]!]); + const executables = findFiles(applicationRoot, isScientWindowsExecutable); + if (executables.length !== 1) { + throw new Error(`Expected one extracted Scient.exe, found ${executables.length}.`); + } + return { command: executables[0]!, args: [], cwd: dirname(executables[0]!) }; +} + +function prepareLaunch( + options: PackagedDesktopStartupOptions, + extractionRoot: string, +): PackagedDesktopLaunchCommand { + const expectedAssetName = expectedPackagedDesktopStartupAssetName( + options.platform, + options.arch, + options.version, + ); + const launch = + options.platform === "mac" + ? prepareMacLaunch(options.assetsDirectory, extractionRoot, expectedAssetName) + : prepareWindowsLaunch(options.assetsDirectory, extractionRoot, expectedAssetName); + assertPackagedLaunchCommandSafety(launch); + return launch; +} + +export function createPackagedDesktopSmokeEnvironment( + root: string, + options: Pick, + inheritedEnvironment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const isolatedHome = join(root, "home"); + const scientHome = join(root, "scient-home"); + const env = sanitizePackagedDesktopInheritedEnvironment(inheritedEnvironment); + Object.assign(env, { + HOME: isolatedHome, + USERPROFILE: isolatedHome, + APPDATA: join(root, "appdata"), + LOCALAPPDATA: join(root, "localappdata"), + XDG_CONFIG_HOME: join(root, "xdg-config"), + XDG_CACHE_HOME: join(root, "xdg-cache"), + XDG_DATA_HOME: join(root, "xdg-data"), + XDG_RUNTIME_DIR: join(root, "xdg-runtime"), + SCIENT_HOME: scientHome, + SYNARA_DISABLE_AUTO_UPDATE: "1", + ELECTRON_ENABLE_LOGGING: "1", + }); + for (const path of [ + env.HOME, + env.APPDATA, + env.LOCALAPPDATA, + env.XDG_CONFIG_HOME, + env.XDG_CACHE_HOME, + env.XDG_DATA_HOME, + env.SCIENT_HOME, + ]) { + if (path) mkdirSync(path, { recursive: true }); + } + if (env.XDG_RUNTIME_DIR) { + mkdirSync(env.XDG_RUNTIME_DIR, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") chmodSync(env.XDG_RUNTIME_DIR, 0o700); + } + + if (options.platform === "mac") { + const userDataPath = join(isolatedHome, "Library", "Application Support", "scient"); + mkdirSync(userDataPath, { recursive: true }); + // Prevent the packaged app's update-only icon repair from registering this + // temporary bundle in the runner's normal Launch Services database. + const launchVersionPath = join(userDataPath, "last-launch-version.json"); + writeFileSync(launchVersionPath, `${JSON.stringify({ version: options.version }, null, 2)}\n`); + } + return env; +} + +const PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST = new Set([ + "COMSPEC", + "ComSpec", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "LANG", + "LC_ALL", + "LC_CTYPE", + "PATH", + "PATHEXT", + "Path", + "SYSTEMROOT", + "SystemRoot", + "TEMP", + "TMP", + "TMPDIR", + "WAYLAND_DISPLAY", + "WINDIR", + "XAUTHORITY", + "XDG_CURRENT_DESKTOP", + "XDG_DATA_DIRS", + "XDG_SESSION_TYPE", + "windir", +]); + +export function sanitizePackagedDesktopInheritedEnvironment( + inheritedEnvironment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(inheritedEnvironment).filter( + ([name, value]) => + value !== undefined && PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST.has(name), + ), + ); +} + +export function resolvePackagedDesktopLogPath(environment: NodeJS.ProcessEnv): string { + const scientHome = environment.SCIENT_HOME; + if (!scientHome) throw new Error("Packaged startup smoke requires an isolated SCIENT_HOME."); + return join(scientHome, "userdata", "logs", "desktop-main.log"); +} + +export interface ProcessTerminationTarget { + readonly pid: number; + readonly processGroup: boolean; +} + +export interface ProcessTerminationDependencies { + readonly platform?: NodeJS.Platform; + readonly runTaskkill?: (pid: number) => { + readonly error?: Error; + readonly status: number | null; + }; + readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; + readonly waitForTargetsExit?: ( + targets: ReadonlyArray, + timeoutMs: number, + ) => Promise; +} + +function processTerminationTargetIsAlive(target: ProcessTerminationTarget): boolean { + try { + process.kill(target.processGroup ? -target.pid : target.pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function waitForProcessTerminationTargets( + targets: ReadonlyArray, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolveExit) => { + const poll = () => { + if (targets.every((target) => !processTerminationTargetIsAlive(target))) { + resolveExit(true); + return; + } + if (Date.now() >= deadline) { + resolveExit(false); + return; + } + setTimeout(poll, 100); + }; + poll(); + }); +} + +function sendProcessTreeSignal(target: ProcessTerminationTarget, signal: NodeJS.Signals): void { + try { + process.kill(target.processGroup ? -target.pid : target.pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +export async function terminateProcessTree( + child: ChildProcess, + dependencies: ProcessTerminationDependencies = {}, + additionalProcessIds: ReadonlyArray = [], +): Promise { + const platform = dependencies.platform ?? process.platform; + const childCanStillOwnProcesses = + platform !== "win32" || (child.exitCode === null && child.signalCode === null); + const targets = [ + ...(child.pid && childCanStillOwnProcesses + ? [{ pid: child.pid, processGroup: platform !== "win32" }] + : []), + ...additionalProcessIds.map((pid) => ({ pid, processGroup: platform !== "win32" })), + ].filter( + (target, index, allTargets) => + target.pid > 0 && allTargets.findIndex((candidate) => candidate.pid === target.pid) === index, + ); + if (targets.length === 0) return; + const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; + if (platform === "win32") { + const taskkillResults = targets.map((target) => ({ + pid: target.pid, + result: + dependencies.runTaskkill?.(target.pid) ?? + spawnSync("taskkill", ["/pid", String(target.pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }), + })); + if (await awaitTargetsExit(targets, 5_000)) return; + const taskkillResult = taskkillResults + .map(({ pid, result }) => + result.error + ? `${pid}: could not start (${result.error.message})` + : `${pid}: status ${result.status ?? "unknown"}`, + ) + .join(", "); + throw new Error( + `Packaged process trees survived Windows cleanup; taskkill results: ${taskkillResult}.`, + ); + } + const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; + for (const target of targets) sendSignal(target, "SIGTERM"); + if (await awaitTargetsExit(targets, 5_000)) return; + for (const target of targets) sendSignal(target, "SIGKILL"); + if (await awaitTargetsExit(targets, 2_000)) return; + throw new Error( + `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived SIGTERM and SIGKILL.`, + ); +} + +export function hasPackagedStartupProof(logPath: string): boolean { + try { + const log = readFileSync(logPath, "utf8"); + return ( + log.includes("app ready") && + log.includes("bootstrap main window created") && + log.includes("renderer main frame loaded") && + log.includes("bootstrap backend ready source=") + ); + } catch { + return false; + } +} + +function readPackagedBackendProcessId(environment: NodeJS.ProcessEnv | null): number | null { + const scientHome = environment?.SCIENT_HOME; + if (!scientHome) return null; + try { + const state = JSON.parse( + readFileSync(join(scientHome, "userdata", "server-runtime.json"), "utf8"), + ) as { readonly pid?: unknown }; + return Number.isInteger(state.pid) && Number(state.pid) > 0 ? Number(state.pid) : null; + } catch { + return null; + } +} + +export interface PackagedDesktopChildOutcome { + readonly exited: { readonly code: number | null; readonly signal: NodeJS.Signals | null } | null; + readonly launchError: Error | null; +} + +interface PackagedStartupProofWaitOptions { + readonly timeoutMs: number; + readonly hasProof: () => boolean; + readonly readOutcome: () => PackagedDesktopChildOutcome; + readonly now?: () => number; + readonly delay?: (milliseconds: number) => Promise; + readonly stableForMs?: number; +} + +export async function waitForPackagedStartupProof({ + timeoutMs, + hasProof, + readOutcome, + now = Date.now, + delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)), + stableForMs = 1_000, +}: PackagedStartupProofWaitOptions): Promise { + const deadline = now() + timeoutMs; + let proofObservedAt: number | null = null; + while (now() < deadline) { + const outcome = readOutcome(); + if (outcome.launchError) { + throw new Error(`Packaged app could not start: ${outcome.launchError.message}`); + } + if (outcome.exited) { + throw new Error( + `Packaged app exited before stable startup proof (code=${outcome.exited.code ?? "null"}, signal=${outcome.exited.signal ?? "null"}).`, + ); + } + const currentTime = now(); + if (hasProof()) { + proofObservedAt ??= currentTime; + if (currentTime - proofObservedAt >= stableForMs) return; + } else { + proofObservedAt = null; + } + await delay(Math.min(200, Math.max(1, deadline - currentTime))); + } + throw new Error(`Packaged startup proof timed out after ${timeoutMs}ms.`); +} + +export function resolveNativePackagedDesktopPlatform( + platform: NodeJS.Platform, +): PackagedDesktopPlatform | null { + if (platform === "darwin") return "mac"; + if (platform === "win32") return "win"; + return null; +} + +export async function verifyPackagedDesktopStartup( + options: PackagedDesktopStartupOptions, +): Promise { + const nativePlatform = resolveNativePackagedDesktopPlatform(process.platform); + if (nativePlatform !== options.platform) { + throw new Error( + `Packaged ${options.platform} startup smoke must run on its native host, not ${process.platform}.`, + ); + } + + const temporaryRoot = mkdtempSync(join(tmpdir(), `scient-packaged-smoke-${options.platform}-`)); + const extractionRoot = join(temporaryRoot, "payload"); + mkdirSync(extractionRoot, { recursive: true }); + + let child: ChildProcess | null = null; + let environment: NodeJS.ProcessEnv | null = null; + let output = ""; + try { + const launch = prepareLaunch(options, extractionRoot); + environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); + const logPath = resolvePackagedDesktopLogPath(environment); + child = spawn(launch.command, [...launch.args], { + cwd: launch.cwd, + env: environment, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + const childOutcome: { + exited: PackagedDesktopChildOutcome["exited"]; + launchError: PackagedDesktopChildOutcome["launchError"]; + } = { exited: null, launchError: null }; + child.once("exit", (code, signal) => { + childOutcome.exited = { code, signal }; + }); + child.once("error", (error) => { + childOutcome.launchError = error; + }); + const recordOutput = (chunk: unknown) => { + output = `${output}${String(chunk)}`.slice(-200_000); + }; + child.stdout?.on("data", recordOutput); + child.stderr?.on("data", recordOutput); + + await waitForPackagedStartupProof({ + timeoutMs: options.timeoutMs, + hasProof: () => hasPackagedStartupProof(logPath), + readOutcome: () => childOutcome, + }); + console.log( + `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, + ); + } catch (error) { + const detail = output.trim() ? `\nPackaged process output:\n${output.trim()}` : ""; + throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`, { + cause: error, + }); + } finally { + try { + if (child) { + const backendProcessId = readPackagedBackendProcessId(environment); + await terminateProcessTree(child, {}, backendProcessId === null ? [] : [backendProcessId]); + } + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + await verifyPackagedDesktopStartup(parsePackagedDesktopStartupArgs(process.argv.slice(2))); +} From c7c51bc2901f1130556fa48f675b03d15172dc31 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sat, 25 Jul 2026 23:50:18 +0300 Subject: [PATCH 02/30] Close packaged startup proof gaps --- apps/desktop/src/main.ts | 17 +++++ docs/release.md | 5 +- scripts/release-smoke.ts | 15 ++++ .../verify-packaged-desktop-startup.test.ts | 68 ++++++++++++++++++- scripts/verify-packaged-desktop-startup.ts | 32 +++++++-- 5 files changed, 126 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 94278e1b..a9dde042 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -576,6 +576,9 @@ async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | }), onHttpReady: () => { if (generation !== null) backendSupervisor?.markReady(generation); + writeDesktopLogHeader( + `backend semantic ready generation=${generation === null ? "unknown" : generation}`, + ); }, onHttpFailure: (error) => { if (generation === null) return; @@ -2820,6 +2823,9 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { // uses taskkill /T after the same graceful IPC deadline. ...backendProcessContainmentOptions(captureBackendLogs), }); + writeDesktopLogHeader( + `backend process spawned generation=${generation} pid=${child.pid ?? "unknown"}`, + ); const listeningDetector = new ServerListeningDetector(); backendListeningDetector = listeningDetector; let backendSessionClosed = false; @@ -2881,6 +2887,9 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v } function handleBackendGenerationExited(exit: DesktopBackendExit): void { + writeDesktopLogHeader( + `backend process exited generation=${exit.generation} pid=${exit.pid ?? "unknown"} reason=${exit.reason}`, + ); cancelBackendReadinessWait(); const runtime = backendGenerationRuntimes.get(exit.generation); backendGenerationRuntimes.delete(exit.generation); @@ -3527,6 +3536,14 @@ function createWindow(): BrowserWindow { ); }, ); + window.webContents.on("render-process-gone", (_event, details) => { + writeDesktopLogHeader( + `renderer main process gone reason=${details.reason} exitCode=${details.exitCode}`, + ); + }); + window.on("unresponsive", () => { + writeDesktopLogHeader("renderer main window unresponsive"); + }); window.once("ready-to-show", () => { // Preserve the original first-launch behavior, then respect the state saved // by subsequent closes. Normal bounds are restored before maximizing so the diff --git a/docs/release.md b/docs/release.md index f447b265..0f743600 100644 --- a/docs/release.md +++ b/docs/release.md @@ -16,8 +16,9 @@ This document covers build-only native validation, promotion through the protect - Linux `x64` AppImage - Windows `x64` NSIS installer - Before upload, launches the exact collected macOS and Windows payloads from - isolated Scient state and requires stable app readiness, backend readiness, - and successful renderer main-frame loading. + isolated Scient state and requires stable app readiness, semantic backend + readiness, successful renderer main-frame loading, and no intervening native + renderer or backend process failure or renderer unresponsiveness. - Publishes one versioned GitHub Release with all produced files. - Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - Stable 0.5.x releases are GitHub Latest; the 0.4.x compatibility release remains historical. diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index ac6c2311..44c214ca 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -317,6 +317,11 @@ function verifyReleaseWorkflowSafety(): void { "SCIENT_HOME: scientHome", "Expected packaged startup verification to isolate Scient state.", ); + assertContains( + packagedStartupVerifier, + 'SYNARA_TELEMETRY_ENABLED: "false"', + "Expected packaged startup verification to disable production telemetry.", + ); assertContains( packagedStartupVerifier, "PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST", @@ -327,6 +332,16 @@ function verifyReleaseWorkflowSafety(): void { 'log.includes("renderer main frame loaded")', "Expected packaged startup proof to require successful renderer loading.", ); + assertContains( + packagedStartupVerifier, + 'log.includes("backend semantic ready generation=")', + "Expected packaged startup proof to require semantic backend readiness.", + ); + assertContains( + packagedStartupVerifier, + '!log.includes("renderer main window unresponsive")', + "Expected packaged startup proof to reject an unresponsive renderer process.", + ); assertNotContains( packagedStartupVerifier, '"linux" | "mac" | "win"', diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index e6aea2fd..e42b7da8 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -1,5 +1,13 @@ import type { ChildProcess } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,6 +20,7 @@ import { hasPackagedStartupProof, isScientWindowsExecutable, parsePackagedDesktopStartupArgs, + readPackagedBackendProcessIds, resolveExactPackagedDesktopStartupAsset, resolveNativePackagedDesktopPlatform, resolvePackagedDesktopLogPath, @@ -91,6 +100,7 @@ describe("packaged desktop startup verification", () => { expect(env.PROVIDER_AUTH_TOKEN).toBeUndefined(); expect(env.SCIENT_DEV_ALLOW_NO_SANDBOX).toBeUndefined(); expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(env.SYNARA_TELEMETRY_ENABLED).toBe("false"); expect(env.DISPLAY).toBe(":99"); for (const name of [ "HOME", @@ -175,7 +185,7 @@ describe("packaged desktop startup verification", () => { const requiredMarkers = [ "app ready", "bootstrap main window created", - "bootstrap backend ready source=packaged", + "backend semantic ready generation=1", "renderer main frame loaded", ]; @@ -189,6 +199,16 @@ describe("packaged desktop startup verification", () => { writeFileSync(logPath, requiredMarkers.join("\n")); expect(hasPackagedStartupProof(logPath)).toBe(true); + + for (const failureMarker of [ + "renderer main frame load failed code=-2 message=failed", + "renderer main process gone reason=crashed exitCode=1", + "renderer main window unresponsive", + "backend process exited generation=1 pid=42 reason=unexpected exit", + ]) { + writeFileSync(logPath, [...requiredMarkers, failureMarker].join("\n")); + expect(hasPackagedStartupProof(logPath)).toBe(false); + } }); it("accepts startup proof only after the process remains alive for the stability window", async () => { @@ -249,6 +269,50 @@ describe("packaged desktop startup verification", () => { expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([84]); }); + it("recovers every spawned backend PID before runtime state is durable", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-pids-test-")); + temporaryRoots.push(root); + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "mac", version: "1.2.3" }, + { PATH: process.env.PATH }, + ); + const logPath = resolvePackagedDesktopLogPath(env); + mkdirSync(join(env.SCIENT_HOME!, "userdata", "logs"), { recursive: true }); + writeFileSync( + logPath, + [ + "backend process spawned generation=1 pid=42", + "backend process spawned generation=2 pid=84", + "backend process spawned generation=2 pid=84", + ].join("\n"), + ); + + expect(readPackagedBackendProcessIds(env)).toEqual([42, 84]); + }); + + it("combines the durable runtime PID with every PID observed during startup", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-pids-test-")); + temporaryRoots.push(root); + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "mac", version: "1.2.3" }, + { PATH: process.env.PATH }, + ); + const userDataPath = join(env.SCIENT_HOME!, "userdata"); + mkdirSync(join(userDataPath, "logs"), { recursive: true }); + writeFileSync(join(userDataPath, "server-runtime.json"), JSON.stringify({ pid: 126 })); + writeFileSync( + resolvePackagedDesktopLogPath(env), + [ + "backend process spawned generation=1 pid=42", + "backend process spawned generation=2 pid=126", + ].join("\n"), + ); + + expect(readPackagedBackendProcessIds(env)).toEqual([126, 42]); + }); + it("fails when a POSIX process tree survives TERM and KILL", async () => { const child = { exitCode: null, diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index b0bcf0b1..15c6e5cd 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -244,6 +244,7 @@ export function createPackagedDesktopSmokeEnvironment( XDG_RUNTIME_DIR: join(root, "xdg-runtime"), SCIENT_HOME: scientHome, SYNARA_DISABLE_AUTO_UPDATE: "1", + SYNARA_TELEMETRY_ENABLED: "false", ELECTRON_ENABLE_LOGGING: "1", }); for (const path of [ @@ -429,24 +430,42 @@ export function hasPackagedStartupProof(logPath: string): boolean { log.includes("app ready") && log.includes("bootstrap main window created") && log.includes("renderer main frame loaded") && - log.includes("bootstrap backend ready source=") + log.includes("backend semantic ready generation=") && + !log.includes("renderer main frame load failed") && + !log.includes("renderer main process gone") && + !log.includes("renderer main window unresponsive") && + !log.includes("backend process exited generation=") ); } catch { return false; } } -function readPackagedBackendProcessId(environment: NodeJS.ProcessEnv | null): number | null { +export function readPackagedBackendProcessIds(environment: NodeJS.ProcessEnv | null): number[] { const scientHome = environment?.SCIENT_HOME; - if (!scientHome) return null; + if (!scientHome) return []; + const processIds = new Set(); try { const state = JSON.parse( readFileSync(join(scientHome, "userdata", "server-runtime.json"), "utf8"), ) as { readonly pid?: unknown }; - return Number.isInteger(state.pid) && Number(state.pid) > 0 ? Number(state.pid) : null; + if (Number.isInteger(state.pid) && Number(state.pid) > 0) { + processIds.add(Number(state.pid)); + } + } catch { + // Startup may fail before the runtime-state file is durable. The desktop + // main log records every backend PID immediately after spawn as a fallback. + } + try { + const log = readFileSync(resolvePackagedDesktopLogPath(environment), "utf8"); + for (const match of log.matchAll(/backend process spawned generation=\d+ pid=(\d+)/gu)) { + const processId = Number(match[1]); + if (Number.isInteger(processId) && processId > 0) processIds.add(processId); + } } catch { - return null; + // No backend was observed yet. } + return [...processIds]; } export interface PackagedDesktopChildOutcome { @@ -564,8 +583,7 @@ export async function verifyPackagedDesktopStartup( } finally { try { if (child) { - const backendProcessId = readPackagedBackendProcessId(environment); - await terminateProcessTree(child, {}, backendProcessId === null ? [] : [backendProcessId]); + await terminateProcessTree(child, {}, readPackagedBackendProcessIds(environment)); } } finally { rmSync(temporaryRoot, { recursive: true, force: true }); From 8bed41bb0e8861d42acc59b1207f442115898606 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 00:09:04 +0300 Subject: [PATCH 03/30] Preserve packaged smoke isolation --- apps/desktop/src/main.ts | 4 +-- apps/desktop/src/syncShellEnvironment.test.ts | 8 ++++- apps/desktop/src/syncShellEnvironment.ts | 4 +++ docs/release.md | 3 +- scripts/release-smoke.ts | 5 +++ .../verify-packaged-desktop-startup.test.ts | 24 +++++++++++++ scripts/verify-packaged-desktop-startup.ts | 35 ++++++++++++++++--- 7 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a9dde042..fa8de58f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -98,7 +98,7 @@ import { } from "./resumableUpdateDownload"; import { hardenElectronUpdater } from "./electronUpdaterSecurity"; import { ServerListeningDetector } from "./serverListeningDetector"; -import { syncShellEnvironment } from "./syncShellEnvironment"; +import { shouldSynchronizeShellEnvironment, syncShellEnvironment } from "./syncShellEnvironment"; import { type DownloadProgressSample, getAutoUpdateDisabledReason, @@ -201,7 +201,7 @@ import { // baseline, so a replacement during startup cannot silently become "normal." const startupBundleIdentity = captureStartupBundleIdentity(); -syncShellEnvironment(); +if (shouldSynchronizeShellEnvironment()) syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const SAVE_FILE_CHANNEL = "desktop:save-file"; diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index 4219dcf5..212346d1 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { syncShellEnvironment } from "./syncShellEnvironment"; +import { shouldSynchronizeShellEnvironment, syncShellEnvironment } from "./syncShellEnvironment"; describe("syncShellEnvironment", () => { it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { @@ -257,4 +257,10 @@ describe("syncShellEnvironment", () => { expect(env.PATH).toBe("/usr/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); + + it("lets an isolated packaged smoke prevent host environment rehydration", () => { + expect(shouldSynchronizeShellEnvironment({ SCIENT_DISABLE_SHELL_ENV_SYNC: "1" })).toBe(false); + expect(shouldSynchronizeShellEnvironment({ SCIENT_DISABLE_SHELL_ENV_SYNC: "0" })).toBe(true); + expect(shouldSynchronizeShellEnvironment({})).toBe(true); + }); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 5f92f8dc..32bcb466 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -122,3 +122,7 @@ export function syncShellEnvironment( logWarning("Failed to synchronize the desktop shell environment.", error); } } + +export function shouldSynchronizeShellEnvironment(env: NodeJS.ProcessEnv = process.env): boolean { + return env.SCIENT_DISABLE_SHELL_ENV_SYNC !== "1"; +} diff --git a/docs/release.md b/docs/release.md index 0f743600..59f08ca1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -18,7 +18,8 @@ This document covers build-only native validation, promotion through the protect - Before upload, launches the exact collected macOS and Windows payloads from isolated Scient state and requires stable app readiness, semantic backend readiness, successful renderer main-frame loading, and no intervening native - renderer or backend process failure or renderer unresponsiveness. + renderer or backend process failure or renderer unresponsiveness. The smoke + also prevents login-shell and Windows-registry environment rehydration. - Publishes one versioned GitHub Release with all produced files. - Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - Stable 0.5.x releases are GitHub Latest; the 0.4.x compatibility release remains historical. diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 44c214ca..bb306bfc 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -322,6 +322,11 @@ function verifyReleaseWorkflowSafety(): void { 'SYNARA_TELEMETRY_ENABLED: "false"', "Expected packaged startup verification to disable production telemetry.", ); + assertContains( + packagedStartupVerifier, + 'SCIENT_DISABLE_SHELL_ENV_SYNC: "1"', + "Expected packaged startup verification to prevent host environment rehydration.", + ); assertContains( packagedStartupVerifier, "PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST", diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index e42b7da8..b9abcf55 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -100,6 +100,7 @@ describe("packaged desktop startup verification", () => { expect(env.PROVIDER_AUTH_TOKEN).toBeUndefined(); expect(env.SCIENT_DEV_ALLOW_NO_SANDBOX).toBeUndefined(); expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(env.SCIENT_DISABLE_SHELL_ENV_SYNC).toBe("1"); expect(env.SYNARA_TELEMETRY_ENABLED).toBe("false"); expect(env.DISPLAY).toBe(":99"); for (const name of [ @@ -313,6 +314,29 @@ describe("packaged desktop startup verification", () => { expect(readPackagedBackendProcessIds(env)).toEqual([126, 42]); }); + it("does not signal a backend PID that the startup log proves already exited", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-pids-test-")); + temporaryRoots.push(root); + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "mac", version: "1.2.3" }, + { PATH: process.env.PATH }, + ); + const userDataPath = join(env.SCIENT_HOME!, "userdata"); + mkdirSync(join(userDataPath, "logs"), { recursive: true }); + writeFileSync(join(userDataPath, "server-runtime.json"), JSON.stringify({ pid: 42 })); + writeFileSync( + resolvePackagedDesktopLogPath(env), + [ + "backend process spawned generation=1 pid=42", + "backend process exited generation=1 pid=42 reason=code=1", + "backend process spawned generation=2 pid=84", + ].join("\n"), + ); + + expect(readPackagedBackendProcessIds(env)).toEqual([84]); + }); + it("fails when a POSIX process tree survives TERM and KILL", async () => { const child = { exitCode: null, diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 15c6e5cd..cf02886d 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -243,6 +243,7 @@ export function createPackagedDesktopSmokeEnvironment( XDG_DATA_HOME: join(root, "xdg-data"), XDG_RUNTIME_DIR: join(root, "xdg-runtime"), SCIENT_HOME: scientHome, + SCIENT_DISABLE_SHELL_ENV_SYNC: "1", SYNARA_DISABLE_AUTO_UPDATE: "1", SYNARA_TELEMETRY_ENABLED: "false", ELECTRON_ENABLE_LOGGING: "1", @@ -445,26 +446,52 @@ export function readPackagedBackendProcessIds(environment: NodeJS.ProcessEnv | n const scientHome = environment?.SCIENT_HOME; if (!scientHome) return []; const processIds = new Set(); + let runtimeProcessId: number | null = null; try { const state = JSON.parse( readFileSync(join(scientHome, "userdata", "server-runtime.json"), "utf8"), ) as { readonly pid?: unknown }; if (Number.isInteger(state.pid) && Number(state.pid) > 0) { - processIds.add(Number(state.pid)); + runtimeProcessId = Number(state.pid); } } catch { // Startup may fail before the runtime-state file is durable. The desktop // main log records every backend PID immediately after spawn as a fallback. } + const observedProcessIds = new Set(); + const activeSpawnedProcessIds = new Set(); try { const log = readFileSync(resolvePackagedDesktopLogPath(environment), "utf8"); - for (const match of log.matchAll(/backend process spawned generation=\d+ pid=(\d+)/gu)) { - const processId = Number(match[1]); - if (Number.isInteger(processId) && processId > 0) processIds.add(processId); + for (const line of log.split(/\r?\n/gu)) { + const spawned = line.match(/backend process spawned generation=\d+ pid=(\d+)/u)?.[1]; + const exited = line.match(/backend process exited generation=\d+ pid=(\d+)/u)?.[1]; + if (spawned !== undefined) { + const processId = Number(spawned); + if (Number.isInteger(processId) && processId > 0) { + observedProcessIds.add(processId); + activeSpawnedProcessIds.add(processId); + } + } + if (exited !== undefined) { + const processId = Number(exited); + if (Number.isInteger(processId) && processId > 0) { + observedProcessIds.add(processId); + activeSpawnedProcessIds.delete(processId); + } + } } } catch { // No backend was observed yet. } + // Never signal a PID that the same fresh-run log proves already exited: the + // OS may have reused it for an unrelated process by the time cleanup runs. + if ( + runtimeProcessId !== null && + (!observedProcessIds.has(runtimeProcessId) || activeSpawnedProcessIds.has(runtimeProcessId)) + ) { + processIds.add(runtimeProcessId); + } + for (const processId of activeSpawnedProcessIds) processIds.add(processId); return [...processIds]; } From fc5f257ec3b2a6515c643170c87360e3549d4935 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 00:21:58 +0300 Subject: [PATCH 04/30] Avoid duplicate Windows tree kills --- scripts/verify-packaged-desktop-startup.test.ts | 4 ++-- scripts/verify-packaged-desktop-startup.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index b9abcf55..3c11ba3a 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -228,7 +228,7 @@ describe("packaged desktop startup verification", () => { expect(now).toBeGreaterThanOrEqual(1_000); }); - it("fails when Windows process-tree cleanup cannot prove exit", async () => { + it("targets a live Windows root once and fails when its complete tree survives", async () => { const child = { exitCode: null, pid: 42, @@ -246,7 +246,7 @@ describe("packaged desktop startup verification", () => { [84], ), ).rejects.toThrow("survived Windows cleanup"); - expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42, 84]); + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42]); }); it("still cleans a detached Windows backend after the packaged root exits", async () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index cf02886d..87031e35 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -393,7 +393,15 @@ export async function terminateProcessTree( if (targets.length === 0) return; const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; if (platform === "win32") { - const taskkillResults = targets.map((target) => ({ + // taskkill /T already owns every descendant of a live packaged root. Do + // not target recorded backend PIDs again after killing that tree: Windows + // could reuse one between calls. When the root has already exited, the + // recorded active backend PIDs are the only remaining cleanup authority. + const liveRootProcessId = child.pid && childCanStillOwnProcesses ? child.pid : null; + const taskkillTargets = liveRootProcessId + ? targets.filter((target) => target.pid === liveRootProcessId) + : targets; + const taskkillResults = taskkillTargets.map((target) => ({ pid: target.pid, result: dependencies.runTaskkill?.(target.pid) ?? From 599abe7f5d82eec652f769f7d9f2305c4fc395d9 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 00:33:57 +0300 Subject: [PATCH 05/30] Harden packaged smoke process cleanup --- .../verify-packaged-desktop-startup.test.ts | 50 +++++++++++++++++++ scripts/verify-packaged-desktop-startup.ts | 27 ++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 3c11ba3a..4e213f28 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -240,6 +240,7 @@ describe("packaged desktop startup verification", () => { child, { platform: "win32", + childIsAlive: () => true, runTaskkill, waitForTargetsExit: async () => false, }, @@ -249,6 +250,28 @@ describe("packaged desktop startup verification", () => { expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42]); }); + it("uses recorded Windows backends when the root handle has exited before its event", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + + await terminateProcessTree( + child, + { + platform: "win32", + childIsAlive: () => false, + runTaskkill, + waitForTargetsExit: async () => true, + }, + [84], + ); + + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([84]); + }); + it("still cleans a detached Windows backend after the packaged root exits", async () => { const child = { exitCode: 0, @@ -350,6 +373,7 @@ describe("packaged desktop startup verification", () => { { platform: "darwin", sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + targetIsAlive: () => true, waitForTargetsExit: async () => false, }, [84], @@ -363,6 +387,32 @@ describe("packaged desktop startup verification", () => { ]); }); + it("does not escalate a POSIX process tree that exited during the TERM grace period", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + await terminateProcessTree( + child, + { + platform: "darwin", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + targetIsAlive: (target) => target.pid === 84, + waitForTargetsExit: async (targets) => targets.every((target) => target.pid === 84), + }, + [84], + ); + + expect(signals).toEqual([ + { pid: 42, signal: "SIGTERM" }, + { pid: 84, signal: "SIGTERM" }, + { pid: 84, signal: "SIGKILL" }, + ]); + }); + it("prepares the isolated Scient macOS profile marker", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-mac-env-test-")); temporaryRoots.push(root); diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 87031e35..6d9483db 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -324,17 +324,30 @@ export interface ProcessTerminationTarget { export interface ProcessTerminationDependencies { readonly platform?: NodeJS.Platform; + readonly childIsAlive?: (child: ChildProcess) => boolean; readonly runTaskkill?: (pid: number) => { readonly error?: Error; readonly status: number | null; }; readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; + readonly targetIsAlive?: (target: ProcessTerminationTarget) => boolean; readonly waitForTargetsExit?: ( targets: ReadonlyArray, timeoutMs: number, ) => Promise; } +function childProcessHandleIsAlive(child: ChildProcess): boolean { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false; + try { + // ChildProcess.kill uses the spawned process handle on Windows, avoiding a + // decision based solely on an asynchronously updated exitCode or reused PID. + return child.kill(0); + } catch { + return false; + } +} + function processTerminationTargetIsAlive(target: ProcessTerminationTarget): boolean { try { process.kill(target.processGroup ? -target.pid : target.pid, 0); @@ -380,7 +393,10 @@ export async function terminateProcessTree( ): Promise { const platform = dependencies.platform ?? process.platform; const childCanStillOwnProcesses = - platform !== "win32" || (child.exitCode === null && child.signalCode === null); + platform !== "win32" || + ((dependencies.childIsAlive ?? childProcessHandleIsAlive)(child) && + child.exitCode === null && + child.signalCode === null); const targets = [ ...(child.pid && childCanStillOwnProcesses ? [{ pid: child.pid, processGroup: platform !== "win32" }] @@ -425,10 +441,13 @@ export async function terminateProcessTree( const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; for (const target of targets) sendSignal(target, "SIGTERM"); if (await awaitTargetsExit(targets, 5_000)) return; - for (const target of targets) sendSignal(target, "SIGKILL"); - if (await awaitTargetsExit(targets, 2_000)) return; + const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; + const survivingTargets = targets.filter((target) => targetIsAlive(target)); + if (survivingTargets.length === 0) return; + for (const target of survivingTargets) sendSignal(target, "SIGKILL"); + if (await awaitTargetsExit(survivingTargets, 2_000)) return; throw new Error( - `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived SIGTERM and SIGKILL.`, + `Packaged process trees ${survivingTargets.map(({ pid }) => pid).join(", ")} survived SIGTERM and SIGKILL.`, ); } From 01a6e320ea9194033832a1179d390ccea53236d5 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 00:53:29 +0300 Subject: [PATCH 06/30] Harden packaged startup proof acceptance --- .../verify-packaged-desktop-startup.test.ts | 29 +++++++++++ scripts/verify-packaged-desktop-startup.ts | 48 ++++++++++++++++--- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 4e213f28..baaca111 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -20,6 +20,7 @@ import { hasPackagedStartupProof, isScientWindowsExecutable, parsePackagedDesktopStartupArgs, + readPackagedDesktopLogTail, readPackagedBackendProcessIds, resolveExactPackagedDesktopStartupAsset, resolveNativePackagedDesktopPlatform, @@ -228,6 +229,34 @@ describe("packaged desktop startup verification", () => { expect(now).toBeGreaterThanOrEqual(1_000); }); + it("rejects startup proof when the process handle closes before the exit event arrives", async () => { + let now = 0; + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => ({ exited: null, launchError: null }), + isProcessAlive: () => now < 1_000, + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + }, + }), + ).rejects.toThrow("process handle is closed"); + }); + + it("keeps a bounded diagnostic tail from a failed packaged startup log", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-log-tail-test-")); + temporaryRoots.push(root); + const logPath = join(root, "desktop-main.log"); + writeFileSync(logPath, "discarded-prefix\nrenderer main process gone reason=crashed"); + + const tail = readPackagedDesktopLogTail(logPath, 49); + expect(tail.length).toBeLessThanOrEqual(49); + expect(tail).toContain("renderer main process gone reason=crashed"); + expect(readPackagedDesktopLogTail(join(root, "missing.log"))).toBe(""); + }); + it("targets a live Windows root once and fails when its complete tree survives", async () => { const child = { exitCode: null, diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 6d9483db..6c7a6f02 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -527,10 +527,20 @@ export interface PackagedDesktopChildOutcome { readonly launchError: Error | null; } +export function readPackagedDesktopLogTail(logPath: string, maxCharacters = 200_000): string { + try { + return readFileSync(logPath, "utf8").slice(-maxCharacters).trim(); + } catch { + return ""; + } +} + interface PackagedStartupProofWaitOptions { readonly timeoutMs: number; readonly hasProof: () => boolean; readonly readOutcome: () => PackagedDesktopChildOutcome; + /** Authoritative process-handle probe used when exit events lag behind OS state. */ + readonly isProcessAlive?: () => boolean; readonly now?: () => number; readonly delay?: (milliseconds: number) => Promise; readonly stableForMs?: number; @@ -540,6 +550,7 @@ export async function waitForPackagedStartupProof({ timeoutMs, hasProof, readOutcome, + isProcessAlive = () => true, now = Date.now, delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)), stableForMs = 1_000, @@ -556,10 +567,24 @@ export async function waitForPackagedStartupProof({ `Packaged app exited before stable startup proof (code=${outcome.exited.code ?? "null"}, signal=${outcome.exited.signal ?? "null"}).`, ); } + if (!isProcessAlive()) { + throw new Error( + "Packaged app exited before stable startup proof (process handle is closed).", + ); + } const currentTime = now(); if (hasProof()) { proofObservedAt ??= currentTime; - if (currentTime - proofObservedAt >= stableForMs) return; + if (currentTime - proofObservedAt >= stableForMs) { + // Recheck at the acceptance boundary: Windows can close the process + // handle before Node delivers the asynchronous `exit` event. + if (!isProcessAlive()) { + throw new Error( + "Packaged app exited before stable startup proof (process handle is closed).", + ); + } + return; + } } else { proofObservedAt = null; } @@ -592,11 +617,13 @@ export async function verifyPackagedDesktopStartup( let child: ChildProcess | null = null; let environment: NodeJS.ProcessEnv | null = null; + let logPath: string | null = null; let output = ""; try { const launch = prepareLaunch(options, extractionRoot); environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); - const logPath = resolvePackagedDesktopLogPath(environment); + const launchLogPath = resolvePackagedDesktopLogPath(environment); + logPath = launchLogPath; child = spawn(launch.command, [...launch.args], { cwd: launch.cwd, env: environment, @@ -623,17 +650,26 @@ export async function verifyPackagedDesktopStartup( await waitForPackagedStartupProof({ timeoutMs: options.timeoutMs, - hasProof: () => hasPackagedStartupProof(logPath), + hasProof: () => hasPackagedStartupProof(launchLogPath), readOutcome: () => childOutcome, + isProcessAlive: () => childProcessHandleIsAlive(child!), }); console.log( `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, ); } catch (error) { const detail = output.trim() ? `\nPackaged process output:\n${output.trim()}` : ""; - throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`, { - cause: error, - }); + let logDetail = ""; + if (logPath) { + const boundedLog = readPackagedDesktopLogTail(logPath); + if (boundedLog) logDetail = `\nPackaged desktop log tail:\n${boundedLog}`; + } + throw new Error( + `${error instanceof Error ? error.message : String(error)}${detail}${logDetail}`, + { + cause: error, + }, + ); } finally { try { if (child) { From 756230cd35e6b4ec19ca84b22db6557d8286328d Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 01:13:20 +0300 Subject: [PATCH 07/30] Close packaged startup certification gaps --- .github/workflows/release.yml | 3 +- apps/desktop/src/main.ts | 64 +++- scripts/release-smoke.ts | 13 +- .../verify-packaged-desktop-startup.test.ts | 136 +++++++-- scripts/verify-packaged-desktop-startup.ts | 283 +++++++++++++----- 5 files changed, 403 insertions(+), 96 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6d2f9c1..216ba7d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -388,7 +388,8 @@ jobs: --assets-dir release-publish \ --platform "${{ matrix.platform }}" \ --arch "${{ matrix.arch }}" \ - --version "${{ needs.preflight.outputs.version }}" + --version "${{ needs.preflight.outputs.version }}" \ + --commit "${{ github.sha }}" - name: Upload build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fa8de58f..099df21f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -315,7 +315,11 @@ let desktopShutdownComplete = false; let desktopProtocolRegistered = false; let aboutCommitHashCache: string | null | undefined; let embeddedReleaseMetadataCache: - | { readonly commitHash: string | null; readonly signed: boolean | null } + | { + readonly commitHash: string | null; + readonly fullCommitHash: string | null; + readonly signed: boolean | null; + } | undefined; let appUpdateYmlCache: Record | null | undefined; let desktopLogSink: RotatingFileSink | null = null; @@ -936,6 +940,10 @@ function parseAppUpdateYml(): Record | null { } function normalizeCommitHash(value: unknown): string | null { + return normalizeFullCommitHash(value)?.slice(0, COMMIT_HASH_DISPLAY_LENGTH) ?? null; +} + +function normalizeFullCommitHash(value: unknown): string | null { if (typeof value !== "string") { return null; } @@ -943,11 +951,12 @@ function normalizeCommitHash(value: unknown): string | null { if (!COMMIT_HASH_PATTERN.test(trimmed)) { return null; } - return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase(); + return trimmed.toLowerCase(); } function resolveEmbeddedReleaseMetadata(): { readonly commitHash: string | null; + readonly fullCommitHash: string | null; readonly signed: boolean | null; } { if (embeddedReleaseMetadataCache !== undefined) { @@ -956,7 +965,7 @@ function resolveEmbeddedReleaseMetadata(): { const packageJsonPath = Path.join(resolveAppRoot(), "package.json"); if (!FS.existsSync(packageJsonPath)) { - embeddedReleaseMetadataCache = { commitHash: null, signed: null }; + embeddedReleaseMetadataCache = { commitHash: null, fullCommitHash: null, signed: null }; return embeddedReleaseMetadataCache; } @@ -968,10 +977,11 @@ function resolveEmbeddedReleaseMetadata(): { }; embeddedReleaseMetadataCache = { commitHash: normalizeCommitHash(parsed.scientCommitHash), + fullCommitHash: normalizeFullCommitHash(parsed.scientCommitHash), signed: typeof parsed.scientSigned === "boolean" ? parsed.scientSigned : null, }; } catch { - embeddedReleaseMetadataCache = { commitHash: null, signed: null }; + embeddedReleaseMetadataCache = { commitHash: null, fullCommitHash: null, signed: null }; } return embeddedReleaseMetadataCache; @@ -981,6 +991,14 @@ function resolveEmbeddedCommitHash(): string | null { return resolveEmbeddedReleaseMetadata().commitHash; } +function writePackagedStartupSmokeIdentity(): void { + if (process.env.SCIENT_PACKAGED_STARTUP_SMOKE !== "1") return; + const commit = resolveEmbeddedReleaseMetadata().fullCommitHash ?? "unknown"; + writeDesktopLogHeader( + `packaged identity name=${app.getName()} version=${app.getVersion()} commit=${commit}`, + ); +} + function resolveAboutCommitHash(): string | null { if (aboutCommitHashCache !== undefined) { return aboutCommitHashCache; @@ -3526,6 +3544,43 @@ function createWindow(): BrowserWindow { writeDesktopLogHeader("renderer main frame loaded"); window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); + if (process.env.SCIENT_PACKAGED_STARTUP_SMOKE === "1") { + setTimeout(() => { + const generation = getBackendSupervisor().currentGeneration; + void Promise.all([ + window.webContents.executeJavaScript( + "new Promise((resolve) => requestAnimationFrame(() => resolve(document.readyState === 'complete')))", + true, + ), + fetch(`${backendHttpUrl}/health`, { signal: AbortSignal.timeout(5_000) }).then( + async (response) => { + if (!response.ok) return false; + const payload = (await response.json()) as { startupReady?: unknown }; + return payload.startupReady === true; + }, + ), + ]) + .then(([rendererReady, backendReady]) => { + const currentGeneration = getBackendSupervisor().currentGeneration; + const sameLiveGeneration = + generation !== null && + currentGeneration?.number === generation.number && + currentGeneration.child.exitCode === null && + currentGeneration.child.signalCode === null; + if (rendererReady !== true || backendReady !== true || !sameLiveGeneration) { + throw new Error("renderer or backend failed the delayed responsiveness check"); + } + writeDesktopLogHeader( + `packaged responsiveness confirmed generation=${generation.number}`, + ); + }) + .catch((error: unknown) => { + writeDesktopLogHeader( + `packaged responsiveness failed message=${formatErrorMessage(error)}`, + ); + }); + }, 1_500); + } }); window.webContents.on( "did-fail-load", @@ -3705,6 +3760,7 @@ if (hasSingleInstanceLock) { .whenReady() .then(async () => { writeDesktopLogHeader("app ready"); + writePackagedStartupSmokeIdentity(); configureAppIdentity(); applyLegacyMacDockIcon(); refreshMacIconCacheOnVersionChange(); diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bb306bfc..eb89b928 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -302,7 +302,8 @@ function verifyReleaseWorkflowSafety(): void { uploadArtifactsIndex <= packagedStartupIndex || packagedStartupStep?.if !== "${{ matrix.platform != 'linux' }}" || !packagedStartupStep.run?.includes("node scripts/verify-packaged-desktop-startup.ts") || - !packagedStartupStep.run.includes("--assets-dir release-publish") + !packagedStartupStep.run.includes("--assets-dir release-publish") || + !packagedStartupStep.run.includes('--commit "${{ github.sha }}"') ) { throw new Error( "Expected exact macOS and Windows packaged-startup proof after collection and before upload.", @@ -342,6 +343,16 @@ function verifyReleaseWorkflowSafety(): void { 'log.includes("backend semantic ready generation=")', "Expected packaged startup proof to require semantic backend readiness.", ); + assertContains( + packagedStartupVerifier, + 'log.includes("packaged responsiveness confirmed generation=")', + "Expected packaged startup proof to require delayed active renderer and backend responsiveness.", + ); + assertContains( + packagedStartupVerifier, + "packaged identity name=Scient version=${expected.version} commit=${expected.commit}", + "Expected packaged startup proof to bind the running product, version, and commit.", + ); assertContains( packagedStartupVerifier, '!log.includes("renderer main window unresponsive")', diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index baaca111..63c965de 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -17,9 +17,11 @@ import { assertPackagedLaunchCommandSafety, createPackagedDesktopSmokeEnvironment, expectedPackagedDesktopStartupAssetName, + formatPackagedStartupFailures, hasPackagedStartupProof, isScientWindowsExecutable, parsePackagedDesktopStartupArgs, + readWindowsExecutableArchitecture, readPackagedDesktopLogTail, readPackagedBackendProcessIds, resolveExactPackagedDesktopStartupAsset, @@ -50,12 +52,15 @@ describe("packaged desktop startup verification", () => { "x64", "--version", "1.2.3", + "--commit", + "0123456789abcdef0123456789abcdef01234567", ]), ).toEqual({ assetsDirectory: expect.stringMatching(/release-publish$/), platform: "mac", arch: "x64", version: "1.2.3", + commit: "0123456789abcdef0123456789abcdef01234567", timeoutMs: 60_000, }); @@ -69,10 +74,42 @@ describe("packaged desktop startup verification", () => { "x64", "--version", "1.2.3", + "--commit", + "0123456789abcdef0123456789abcdef01234567", "--timeout-ms", "4999", ]), ).toThrow("--timeout-ms must be an integer between 5000 and 180000"); + + expect(() => + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "win", + "--arch", + "ia32", + "--version", + "1.2.3", + "--commit", + "0123456789abcdef0123456789abcdef01234567", + ]), + ).toThrow("Unsupported packaged startup architecture: ia32"); + + expect(() => + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "win", + "--arch", + "x64", + "--version", + "1.2.3", + "--commit", + "0123456", + ]), + ).toThrow("--commit must be a complete 40-character Git commit SHA"); }); it("isolates Scient state and removes inherited runtime authority", () => { @@ -102,6 +139,7 @@ describe("packaged desktop startup verification", () => { expect(env.SCIENT_DEV_ALLOW_NO_SANDBOX).toBeUndefined(); expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); expect(env.SCIENT_DISABLE_SHELL_ENV_SYNC).toBe("1"); + expect(env.SCIENT_PACKAGED_STARTUP_SMOKE).toBe("1"); expect(env.SYNARA_TELEMETRY_ENABLED).toBe("false"); expect(env.DISPLAY).toBe(":99"); for (const name of [ @@ -113,6 +151,9 @@ describe("packaged desktop startup verification", () => { "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", + "TEMP", + "TMP", + "TMPDIR", "SCIENT_HOME", ] as const) { expect(env[name]?.startsWith(root)).toBe(true); @@ -186,9 +227,11 @@ describe("packaged desktop startup verification", () => { const logPath = join(root, "desktop-main.log"); const requiredMarkers = [ "app ready", + "packaged identity name=Scient version=1.2.3 commit=0123456789abcdef0123456789abcdef01234567", "bootstrap main window created", "backend semantic ready generation=1", "renderer main frame loaded", + "packaged responsiveness confirmed generation=1", ]; for (const omittedMarker of requiredMarkers) { @@ -196,23 +239,53 @@ describe("packaged desktop startup verification", () => { logPath, requiredMarkers.filter((marker) => marker !== omittedMarker).join("\n"), ); - expect(hasPackagedStartupProof(logPath)).toBe(false); + expect( + hasPackagedStartupProof(logPath, { + version: "1.2.3", + commit: "0123456789abcdef0123456789abcdef01234567", + }), + ).toBe(false); } writeFileSync(logPath, requiredMarkers.join("\n")); - expect(hasPackagedStartupProof(logPath)).toBe(true); + expect( + hasPackagedStartupProof(logPath, { + version: "1.2.3", + commit: "0123456789abcdef0123456789abcdef01234567", + }), + ).toBe(true); for (const failureMarker of [ "renderer main frame load failed code=-2 message=failed", "renderer main process gone reason=crashed exitCode=1", "renderer main window unresponsive", + "packaged responsiveness failed message=frozen", "backend process exited generation=1 pid=42 reason=unexpected exit", ]) { writeFileSync(logPath, [...requiredMarkers, failureMarker].join("\n")); - expect(hasPackagedStartupProof(logPath)).toBe(false); + expect( + hasPackagedStartupProof(logPath, { + version: "1.2.3", + commit: "0123456789abcdef0123456789abcdef01234567", + }), + ).toBe(false); } }); + it("reads the native architecture from a Windows PE executable header", () => { + const executable = new Uint8Array(128); + executable[0] = 0x4d; + executable[1] = 0x5a; + new DataView(executable.buffer).setUint32(0x3c, 64, true); + executable.set([0x50, 0x45, 0, 0], 64); + new DataView(executable.buffer).setUint16(68, 0x8664, true); + expect(readWindowsExecutableArchitecture(executable)).toBe("x64"); + new DataView(executable.buffer).setUint16(68, 0xaa64, true); + expect(readWindowsExecutableArchitecture(executable)).toBe("arm64"); + executable[64] = 0; + expect(readWindowsExecutableArchitecture(executable)).toBeNull(); + }); + it("accepts startup proof only after the process remains alive for the stability window", async () => { let now = 0; await expect( @@ -257,6 +330,21 @@ describe("packaged desktop startup verification", () => { expect(readPackagedDesktopLogTail(join(root, "missing.log"))).toBe(""); }); + it("preserves startup, cleanup, process output, and log diagnostics together", () => { + expect( + formatPackagedStartupFailures( + [ + { phase: "startup verification failed", error: new Error("renderer froze") }, + { phase: "process cleanup failed", error: new Error("backend survived") }, + ], + "stderr detail", + "desktop log detail", + ), + ).toContain( + "startup verification failed: renderer froze\nprocess cleanup failed: backend survived\nPackaged process output:\nstderr detail\nPackaged desktop log tail:\ndesktop log detail", + ); + }); + it("targets a live Windows root once and fails when its complete tree survives", async () => { const child = { exitCode: null, @@ -279,7 +367,7 @@ describe("packaged desktop startup verification", () => { expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42]); }); - it("uses recorded Windows backends when the root handle has exited before its event", async () => { + it("waits for recorded Windows backends without signaling reused PIDs after root exit", async () => { const child = { exitCode: null, pid: 42, @@ -298,10 +386,10 @@ describe("packaged desktop startup verification", () => { [84], ); - expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([84]); + expect(runTaskkill).not.toHaveBeenCalled(); }); - it("still cleans a detached Windows backend after the packaged root exits", async () => { + it("observes detached Windows backend cleanup after the packaged root exits", async () => { const child = { exitCode: 0, pid: 42, @@ -319,7 +407,7 @@ describe("packaged desktop startup verification", () => { [84], ); - expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([84]); + expect(runTaskkill).not.toHaveBeenCalled(); }); it("recovers every spawned backend PID before runtime state is durable", () => { @@ -401,18 +489,17 @@ describe("packaged desktop startup verification", () => { child, { platform: "darwin", + childIsAlive: () => true, sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), targetIsAlive: () => true, waitForTargetsExit: async () => false, }, [84], ), - ).rejects.toThrow("survived SIGTERM and SIGKILL"); + ).rejects.toThrow("refusing to signal unverified backend PIDs"); expect(signals).toEqual([ { pid: 42, signal: "SIGTERM" }, - { pid: 84, signal: "SIGTERM" }, { pid: 42, signal: "SIGKILL" }, - { pid: 84, signal: "SIGKILL" }, ]); }); @@ -428,18 +515,33 @@ describe("packaged desktop startup verification", () => { child, { platform: "darwin", + childIsAlive: () => true, sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), - targetIsAlive: (target) => target.pid === 84, - waitForTargetsExit: async (targets) => targets.every((target) => target.pid === 84), + targetIsAlive: () => false, + waitForTargetsExit: async () => true, }, [84], ); - expect(signals).toEqual([ - { pid: 42, signal: "SIGTERM" }, - { pid: 84, signal: "SIGTERM" }, - { pid: 84, signal: "SIGKILL" }, - ]); + expect(signals).toEqual([{ pid: 42, signal: "SIGTERM" }]); + }); + + it("fails closed without signaling a recorded PID when the root is already gone", async () => { + const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + + await expect( + terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("refusing to signal unverified PIDs"); + expect(runTaskkill).not.toHaveBeenCalled(); }); it("prepares the isolated Scient macOS profile marker", () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 6c7a6f02..d59cb2f0 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -25,6 +25,7 @@ export interface PackagedDesktopStartupOptions { readonly platform: PackagedDesktopPlatform; readonly arch: string; readonly version: string; + readonly commit: string; readonly timeoutMs: number; } @@ -41,7 +42,14 @@ export function parsePackagedDesktopStartupArgs( values.set(name, value); } - const known = new Set(["--assets-dir", "--platform", "--arch", "--version", "--timeout-ms"]); + const known = new Set([ + "--assets-dir", + "--platform", + "--arch", + "--version", + "--commit", + "--timeout-ms", + ]); for (const name of values.keys()) { if (!known.has(name)) throw new Error(`Unknown packaged startup argument: ${name}.`); } @@ -56,17 +64,26 @@ export function parsePackagedDesktopStartupArgs( if (platform !== "mac" && platform !== "win") { throw new Error(`Unsupported packaged startup platform: ${platform}.`); } + const arch = required("--arch"); + if (arch !== "arm64" && arch !== "x64") { + throw new Error(`Unsupported packaged startup architecture: ${arch}.`); + } const timeoutMs = Number(values.get("--timeout-ms") ?? "60000"); if (!Number.isInteger(timeoutMs) || timeoutMs < 5_000 || timeoutMs > 180_000) { throw new Error("--timeout-ms must be an integer between 5000 and 180000."); } + const commit = required("--commit").toLowerCase(); + if (!/^[0-9a-f]{40}$/u.test(commit)) { + throw new Error("--commit must be a complete 40-character Git commit SHA."); + } return { assetsDirectory: resolve(required("--assets-dir")), platform, - arch: required("--arch"), + arch, version: required("--version"), + commit, timeoutMs, }; } @@ -91,6 +108,23 @@ function runCommand(command: string, args: ReadonlyArray, cwd?: string): } } +function runTextCommand(command: string, args: ReadonlyArray, cwd?: string): string { + const result = spawnSync(command, [...args], { + cwd, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + shell: false, + windowsHide: true, + }); + if (result.error || result.status !== 0) { + const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error( + `${command} ${args.join(" ")} failed${result.error ? `: ${result.error.message}` : ` with exit ${result.status ?? "unknown"}`}${detail ? `\n${detail}` : ""}`, + ); + } + return result.stdout.trim(); +} + function findFiles(root: string, predicate: (path: string) => boolean): string[] { const matches: string[] = []; const pending = [root]; @@ -160,12 +194,13 @@ function prepareMacLaunch( assetsDirectory: string, extractionRoot: string, expectedAssetName: string, + options: Pick, ): PackagedDesktopLaunchCommand { const archive = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); runCommand("ditto", ["-x", "-k", archive, extractionRoot]); const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); - if (appBundles.length !== 1) { - throw new Error(`Expected one packaged macOS app in ${basename(archive)}.`); + if (appBundles.length !== 1 || appBundles[0] !== "Scient.app") { + throw new Error(`Expected the exact Scient.app bundle in ${basename(archive)}.`); } const appBundle = join(extractionRoot, appBundles[0]!); const executables = findFiles(join(appBundle, "Contents", "MacOS"), (candidate) => @@ -174,6 +209,49 @@ function prepareMacLaunch( if (executables.length !== 1) { throw new Error(`Expected one macOS main executable, found ${executables.length}.`); } + const infoPlist = join(appBundle, "Contents", "Info.plist"); + const bundleIdentifier = runTextCommand("plutil", [ + "-extract", + "CFBundleIdentifier", + "raw", + "-o", + "-", + infoPlist, + ]); + const bundleVersion = runTextCommand("plutil", [ + "-extract", + "CFBundleShortVersionString", + "raw", + "-o", + "-", + infoPlist, + ]); + const bundleExecutable = runTextCommand("plutil", [ + "-extract", + "CFBundleExecutable", + "raw", + "-o", + "-", + infoPlist, + ]); + if ( + bundleIdentifier !== "com.scientfactory.scient" || + bundleVersion !== options.version || + bundleExecutable !== "Scient" + ) { + throw new Error( + `Unexpected macOS bundle identity id=${bundleIdentifier} version=${bundleVersion} executable=${bundleExecutable}.`, + ); + } + const expectedArchitecture = options.arch === "x64" ? "x86_64" : options.arch; + const executableArchitectures = runTextCommand("lipo", ["-archs", executables[0]!]) + .split(/\s+/u) + .filter(Boolean); + if (executableArchitectures.length !== 1 || executableArchitectures[0] !== expectedArchitecture) { + throw new Error( + `Expected exact macOS ${expectedArchitecture} executable, found ${executableArchitectures.join(", ") || "unknown"}.`, + ); + } return { command: executables[0]!, args: [], cwd: appBundle }; } @@ -181,10 +259,31 @@ export function isScientWindowsExecutable(candidate: string): boolean { return /[/\\]Scient\.exe$/i.test(candidate); } +export function readWindowsExecutableArchitecture(executable: Uint8Array): string | null { + if (executable.length < 64 || executable[0] !== 0x4d || executable[1] !== 0x5a) return null; + const view = new DataView(executable.buffer, executable.byteOffset, executable.byteLength); + const peOffset = view.getUint32(0x3c, true); + if ( + peOffset + 6 > executable.length || + executable[peOffset] !== 0x50 || + executable[peOffset + 1] !== 0x45 || + executable[peOffset + 2] !== 0 || + executable[peOffset + 3] !== 0 + ) { + return null; + } + const machine = view.getUint16(peOffset + 4, true); + if (machine === 0x8664) return "x64"; + if (machine === 0xaa64) return "arm64"; + if (machine === 0x014c) return "ia32"; + return null; +} + function prepareWindowsLaunch( assetsDirectory: string, extractionRoot: string, expectedAssetName: string, + options: Pick, ): PackagedDesktopLaunchCommand { const installer = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); const installerRoot = join(extractionRoot, "installer"); @@ -205,6 +304,12 @@ function prepareWindowsLaunch( if (executables.length !== 1) { throw new Error(`Expected one extracted Scient.exe, found ${executables.length}.`); } + const executableArchitecture = readWindowsExecutableArchitecture(readFileSync(executables[0]!)); + if (executableArchitecture !== options.arch) { + throw new Error( + `Expected exact Windows ${options.arch} executable, found ${executableArchitecture ?? "unknown"}.`, + ); + } return { command: executables[0]!, args: [], cwd: dirname(executables[0]!) }; } @@ -219,8 +324,8 @@ function prepareLaunch( ); const launch = options.platform === "mac" - ? prepareMacLaunch(options.assetsDirectory, extractionRoot, expectedAssetName) - : prepareWindowsLaunch(options.assetsDirectory, extractionRoot, expectedAssetName); + ? prepareMacLaunch(options.assetsDirectory, extractionRoot, expectedAssetName, options) + : prepareWindowsLaunch(options.assetsDirectory, extractionRoot, expectedAssetName, options); assertPackagedLaunchCommandSafety(launch); return launch; } @@ -242,8 +347,12 @@ export function createPackagedDesktopSmokeEnvironment( XDG_CACHE_HOME: join(root, "xdg-cache"), XDG_DATA_HOME: join(root, "xdg-data"), XDG_RUNTIME_DIR: join(root, "xdg-runtime"), + TEMP: join(root, "tmp"), + TMP: join(root, "tmp"), + TMPDIR: join(root, "tmp"), SCIENT_HOME: scientHome, SCIENT_DISABLE_SHELL_ENV_SYNC: "1", + SCIENT_PACKAGED_STARTUP_SMOKE: "1", SYNARA_DISABLE_AUTO_UPDATE: "1", SYNARA_TELEMETRY_ENABLED: "false", ELECTRON_ENABLE_LOGGING: "1", @@ -255,6 +364,7 @@ export function createPackagedDesktopSmokeEnvironment( env.XDG_CONFIG_HOME, env.XDG_CACHE_HOME, env.XDG_DATA_HOME, + env.TEMP, env.SCIENT_HOME, ]) { if (path) mkdirSync(path, { recursive: true }); @@ -393,75 +503,83 @@ export async function terminateProcessTree( ): Promise { const platform = dependencies.platform ?? process.platform; const childCanStillOwnProcesses = - platform !== "win32" || - ((dependencies.childIsAlive ?? childProcessHandleIsAlive)(child) && - child.exitCode === null && - child.signalCode === null); + (dependencies.childIsAlive ?? childProcessHandleIsAlive)(child) && + child.exitCode === null && + child.signalCode === null; + const rootTarget = + child.pid && childCanStillOwnProcesses + ? { pid: child.pid, processGroup: platform !== "win32" } + : null; + // Backend PIDs recovered from logs/runtime state are observation-only. They + // prove cleanup completion, but a bare PID is not durable signal authority: + // the OS may reuse it after an unlogged exit. + const observedTargets = additionalProcessIds + .map((pid) => ({ pid, processGroup: platform !== "win32" })) + .filter( + (target, index, allTargets) => + target.pid > 0 && + allTargets.findIndex((candidate) => candidate.pid === target.pid) === index, + ); const targets = [ - ...(child.pid && childCanStillOwnProcesses - ? [{ pid: child.pid, processGroup: platform !== "win32" }] - : []), - ...additionalProcessIds.map((pid) => ({ pid, processGroup: platform !== "win32" })), - ].filter( - (target, index, allTargets) => - target.pid > 0 && allTargets.findIndex((candidate) => candidate.pid === target.pid) === index, - ); + ...(rootTarget ? [rootTarget] : []), + ...observedTargets.filter((target) => target.pid !== rootTarget?.pid), + ]; if (targets.length === 0) return; const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; + if (!rootTarget) { + if (await awaitTargetsExit(observedTargets, 5_000)) return; + throw new Error( + `Recorded backend process candidates ${observedTargets.map(({ pid }) => pid).join(", ")} remained after their parent exited; refusing to signal unverified PIDs.`, + ); + } if (platform === "win32") { - // taskkill /T already owns every descendant of a live packaged root. Do - // not target recorded backend PIDs again after killing that tree: Windows - // could reuse one between calls. When the root has already exited, the - // recorded active backend PIDs are the only remaining cleanup authority. - const liveRootProcessId = child.pid && childCanStillOwnProcesses ? child.pid : null; - const taskkillTargets = liveRootProcessId - ? targets.filter((target) => target.pid === liveRootProcessId) - : targets; - const taskkillResults = taskkillTargets.map((target) => ({ - pid: target.pid, - result: - dependencies.runTaskkill?.(target.pid) ?? - spawnSync("taskkill", ["/pid", String(target.pid), "/t", "/f"], { - stdio: "ignore", - windowsHide: true, - }), - })); + // The live ChildProcess handle establishes root ownership; taskkill /T + // derives descendants from that root. Never signal observation-only PIDs. + const taskkillResult = + dependencies.runTaskkill?.(rootTarget.pid) ?? + spawnSync("taskkill", ["/pid", String(rootTarget.pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }); if (await awaitTargetsExit(targets, 5_000)) return; - const taskkillResult = taskkillResults - .map(({ pid, result }) => - result.error - ? `${pid}: could not start (${result.error.message})` - : `${pid}: status ${result.status ?? "unknown"}`, - ) - .join(", "); + const taskkillDetail = taskkillResult.error + ? `could not start (${taskkillResult.error.message})` + : `status ${taskkillResult.status ?? "unknown"}`; throw new Error( - `Packaged process trees survived Windows cleanup; taskkill results: ${taskkillResult}.`, + `Packaged process trees survived Windows cleanup; root ${rootTarget.pid} taskkill ${taskkillDetail}.`, ); } const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; - for (const target of targets) sendSignal(target, "SIGTERM"); + sendSignal(rootTarget, "SIGTERM"); if (await awaitTargetsExit(targets, 5_000)) return; const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; - const survivingTargets = targets.filter((target) => targetIsAlive(target)); - if (survivingTargets.length === 0) return; - for (const target of survivingTargets) sendSignal(target, "SIGKILL"); - if (await awaitTargetsExit(survivingTargets, 2_000)) return; + if (targetIsAlive(rootTarget)) sendSignal(rootTarget, "SIGKILL"); + if (await awaitTargetsExit(targets, 2_000)) return; throw new Error( - `Packaged process trees ${survivingTargets.map(({ pid }) => pid).join(", ")} survived SIGTERM and SIGKILL.`, + `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived root-only SIGTERM and SIGKILL; refusing to signal unverified backend PIDs.`, ); } -export function hasPackagedStartupProof(logPath: string): boolean { +export function hasPackagedStartupProof( + logPath: string, + expected?: Pick, +): boolean { try { const log = readFileSync(logPath, "utf8"); + const expectedIdentity = expected + ? `packaged identity name=Scient version=${expected.version} commit=${expected.commit}` + : "packaged identity name=Scient"; return ( log.includes("app ready") && + log.includes(expectedIdentity) && log.includes("bootstrap main window created") && log.includes("renderer main frame loaded") && log.includes("backend semantic ready generation=") && + log.includes("packaged responsiveness confirmed generation=") && !log.includes("renderer main frame load failed") && !log.includes("renderer main process gone") && !log.includes("renderer main window unresponsive") && + !log.includes("packaged responsiveness failed") && !log.includes("backend process exited generation=") ); } catch { @@ -535,6 +653,21 @@ export function readPackagedDesktopLogTail(logPath: string, maxCharacters = 200_ } } +export function formatPackagedStartupFailures( + failures: ReadonlyArray<{ readonly phase: string; readonly error: unknown }>, + output: string, + logTail: string, +): string { + const failureDetail = failures + .map( + ({ phase, error }) => `${phase}: ${error instanceof Error ? error.message : String(error)}`, + ) + .join("\n"); + const processDetail = output.trim() ? `\nPackaged process output:\n${output.trim()}` : ""; + const logDetail = logTail ? `\nPackaged desktop log tail:\n${logTail}` : ""; + return `${failureDetail}${processDetail}${logDetail}`; +} + interface PackagedStartupProofWaitOptions { readonly timeoutMs: number; readonly hasProof: () => boolean; @@ -619,6 +752,7 @@ export async function verifyPackagedDesktopStartup( let environment: NodeJS.ProcessEnv | null = null; let logPath: string | null = null; let output = ""; + const failures: Array<{ phase: string; error: unknown }> = []; try { const launch = prepareLaunch(options, extractionRoot); environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); @@ -650,35 +784,38 @@ export async function verifyPackagedDesktopStartup( await waitForPackagedStartupProof({ timeoutMs: options.timeoutMs, - hasProof: () => hasPackagedStartupProof(launchLogPath), + hasProof: () => hasPackagedStartupProof(launchLogPath, options), readOutcome: () => childOutcome, isProcessAlive: () => childProcessHandleIsAlive(child!), }); - console.log( - `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, - ); } catch (error) { - const detail = output.trim() ? `\nPackaged process output:\n${output.trim()}` : ""; - let logDetail = ""; - if (logPath) { - const boundedLog = readPackagedDesktopLogTail(logPath); - if (boundedLog) logDetail = `\nPackaged desktop log tail:\n${boundedLog}`; - } - throw new Error( - `${error instanceof Error ? error.message : String(error)}${detail}${logDetail}`, - { - cause: error, - }, - ); - } finally { - try { - if (child) { - await terminateProcessTree(child, {}, readPackagedBackendProcessIds(environment)); - } - } finally { - rmSync(temporaryRoot, { recursive: true, force: true }); + failures.push({ phase: "startup verification failed", error }); + } + + try { + if (child) { + await terminateProcessTree(child, {}, readPackagedBackendProcessIds(environment)); } + } catch (error) { + failures.push({ phase: "process cleanup failed", error }); + } + + // Capture diagnostics after cleanup attempts but before deleting isolated state. + const logTail = logPath ? readPackagedDesktopLogTail(logPath) : ""; + try { + rmSync(temporaryRoot, { recursive: true, force: true }); + } catch (error) { + failures.push({ phase: `temporary-state cleanup failed at ${temporaryRoot}`, error }); } + + if (failures.length > 0) { + throw new Error(formatPackagedStartupFailures(failures, output, logTail), { + cause: failures[0]?.error, + }); + } + console.log( + `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, + ); } const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; From e29e9c881efd0a9c2001459bd5c1828a64debb58 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 01:37:43 +0300 Subject: [PATCH 08/30] Close exact packaged startup proof gaps --- apps/desktop/src/main.ts | 12 ++- .../packagedStartupRendererReadiness.test.ts | 33 +++++++ .../src/packagedStartupRendererReadiness.ts | 5 + apps/web/src/main.tsx | 12 +++ scripts/build-desktop-artifact.ts | 4 +- scripts/release-smoke.ts | 47 ++++++++++ .../verify-packaged-desktop-startup.test.ts | 25 ++++- scripts/verify-packaged-desktop-startup.ts | 93 ++++++++++++++++--- 8 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/packagedStartupRendererReadiness.test.ts create mode 100644 apps/desktop/src/packagedStartupRendererReadiness.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 099df21f..cae1a93e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -92,6 +92,7 @@ import { import { collectMacUpdateDiagnostics } from "./macUpdateDiagnostics"; import { openInitialBackendWindow } from "./initialBackendWindowOpen"; import { shouldAllowMediaPermissionRequest } from "./mediaPermissions"; +import { PACKAGED_RENDERER_READINESS_EXPRESSION } from "./packagedStartupRendererReadiness"; import { installResumableUpdateDownloader, type ResumableDownloaderTarget, @@ -263,6 +264,7 @@ const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? `${SCIENT_APP_NAME} (Dev)` : SCIENT_APP_NAME; const APP_USER_MODEL_ID = scientBundleId(isDevelopment); const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; +const FULL_COMMIT_HASH_PATTERN = /^[0-9a-f]{40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; const LOG_DIR = SCIENT_DATA_DIRECTORIES.logsDir; const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; @@ -940,7 +942,11 @@ function parseAppUpdateYml(): Record | null { } function normalizeCommitHash(value: unknown): string | null { - return normalizeFullCommitHash(value)?.slice(0, COMMIT_HASH_DISPLAY_LENGTH) ?? null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return COMMIT_HASH_PATTERN.test(trimmed) + ? trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase() + : null; } function normalizeFullCommitHash(value: unknown): string | null { @@ -948,7 +954,7 @@ function normalizeFullCommitHash(value: unknown): string | null { return null; } const trimmed = value.trim(); - if (!COMMIT_HASH_PATTERN.test(trimmed)) { + if (!FULL_COMMIT_HASH_PATTERN.test(trimmed)) { return null; } return trimmed.toLowerCase(); @@ -3549,7 +3555,7 @@ function createWindow(): BrowserWindow { const generation = getBackendSupervisor().currentGeneration; void Promise.all([ window.webContents.executeJavaScript( - "new Promise((resolve) => requestAnimationFrame(() => resolve(document.readyState === 'complete')))", + `new Promise((resolve) => requestAnimationFrame(() => resolve(${PACKAGED_RENDERER_READINESS_EXPRESSION})))`, true, ), fetch(`${backendHttpUrl}/health`, { signal: AbortSignal.timeout(5_000) }).then( diff --git a/apps/desktop/src/packagedStartupRendererReadiness.test.ts b/apps/desktop/src/packagedStartupRendererReadiness.test.ts new file mode 100644 index 00000000..3891f5d8 --- /dev/null +++ b/apps/desktop/src/packagedStartupRendererReadiness.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { PACKAGED_RENDERER_READINESS_EXPRESSION } from "./packagedStartupRendererReadiness"; + +function evaluateRendererReadiness(input: { + readonly readyState: string; + readonly rendererReady?: string; +}): boolean { + const evaluate = new Function( + "document", + `return ${PACKAGED_RENDERER_READINESS_EXPRESSION};`, + ) as (document: unknown) => boolean; + + return evaluate({ + readyState: input.readyState, + documentElement: { + dataset: { + ...(input.rendererReady === undefined ? {} : { scientRendererReady: input.rendererReady }), + }, + }, + }); +} + +describe("packaged renderer readiness", () => { + it("requires both a complete document and the renderer-owned React commit marker", () => { + expect(evaluateRendererReadiness({ readyState: "loading", rendererReady: "true" })).toBe(false); + expect(evaluateRendererReadiness({ readyState: "complete" })).toBe(false); + expect(evaluateRendererReadiness({ readyState: "complete", rendererReady: "false" })).toBe( + false, + ); + expect(evaluateRendererReadiness({ readyState: "complete", rendererReady: "true" })).toBe(true); + }); +}); diff --git a/apps/desktop/src/packagedStartupRendererReadiness.ts b/apps/desktop/src/packagedStartupRendererReadiness.ts new file mode 100644 index 00000000..64abc2a1 --- /dev/null +++ b/apps/desktop/src/packagedStartupRendererReadiness.ts @@ -0,0 +1,5 @@ +// This expression runs inside the packaged renderer through Electron's +// executeJavaScript boundary. Keep it data-only so the release verifier can +// exercise the exact expression without importing the Electron main process. +export const PACKAGED_RENDERER_READINESS_EXPRESSION = + "document.readyState === 'complete' && document.documentElement.dataset.scientRendererReady === 'true'"; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 486a794a..8b35064f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -12,6 +12,17 @@ import { isElectron } from "./env"; const router = getRouter(appHistory); +function PackagedStartupReadyMarker() { + React.useEffect(() => { + document.documentElement.dataset.scientRendererReady = "true"; + return () => { + delete document.documentElement.dataset.scientRendererReady; + }; + }, []); + + return null; +} + document.title = APP_DISPLAY_NAME; if (isElectron) { @@ -20,6 +31,7 @@ if (isElectron) { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + , ); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5e33d9a0..a6f821a3 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -151,7 +151,7 @@ class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ }> {} function resolveGitCommitHash(repoRoot: string): string { - const result = spawnSync("git", ["rev-parse", "--short=12", "HEAD"], { + const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", }); @@ -159,7 +159,7 @@ function resolveGitCommitHash(repoRoot: string): string { return "unknown"; } const hash = result.stdout.trim(); - if (!/^[0-9a-f]{7,40}$/i.test(hash)) { + if (!/^[0-9a-f]{40}$/i.test(hash)) { return "unknown"; } return hash.toLowerCase(); diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index eb89b928..e92c04f1 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -313,6 +313,38 @@ function verifyReleaseWorkflowSafety(): void { resolve(repoRoot, "scripts/verify-packaged-desktop-startup.ts"), "utf8", ).replaceAll("\r\n", "\n"); + const desktopMain = readFileSync( + resolve(repoRoot, "apps/desktop/src/main.ts"), + "utf8", + ).replaceAll("\r\n", "\n"); + const webMain = readFileSync(resolve(repoRoot, "apps/web/src/main.tsx"), "utf8").replaceAll( + "\r\n", + "\n", + ); + const rendererReadiness = readFileSync( + resolve(repoRoot, "apps/desktop/src/packagedStartupRendererReadiness.ts"), + "utf8", + ).replaceAll("\r\n", "\n"); + assertContains( + desktopMain, + "const FULL_COMMIT_HASH_PATTERN = /^[0-9a-f]{40}$/i;", + "Expected packaged startup identity to accept only a complete embedded commit.", + ); + assertContains( + desktopMain, + "packaged identity name=${app.getName()} version=${app.getVersion()} commit=${commit}", + "Expected the running packaged app to expose its exact embedded identity to the verifier.", + ); + assertContains( + webMain, + 'document.documentElement.dataset.scientRendererReady = "true"', + "Expected packaged startup proof to depend on a renderer-owned React commit marker.", + ); + assertContains( + rendererReadiness, + "document.documentElement.dataset.scientRendererReady === 'true'", + "Expected packaged startup proof to reject a renderer that never committed React.", + ); assertContains( packagedStartupVerifier, "SCIENT_HOME: scientHome", @@ -604,6 +636,21 @@ function verifyDesktopStageLockAuthority(): void { resolve(repoRoot, "scripts/build-desktop-artifact.ts"), "utf8", ).replaceAll("\r\n", "\n"); + assertContains( + buildScript, + 'spawnSync("git", ["rev-parse", "HEAD"]', + "Expected packaged release metadata to embed the complete source commit.", + ); + assertContains( + buildScript, + "scientCommitHash: commitHash", + "Expected the complete source commit to cross the desktop staging boundary.", + ); + assertNotContains( + buildScript, + '"--short=12"', + "Packaged release metadata must not truncate the commit required by exact-payload proof.", + ); assertContains( buildScript, "bun install --omit dev --ignore-scripts --linker hoisted --filter @scientfactory/cli --filter @synara/desktop", diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 63c965de..a96fcb24 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; import { existsSync, mkdirSync, @@ -20,6 +21,7 @@ import { formatPackagedStartupFailures, hasPackagedStartupProof, isScientWindowsExecutable, + monitorPackagedStartupTermination, parsePackagedDesktopStartupArgs, readWindowsExecutableArchitecture, readPackagedDesktopLogTail, @@ -302,6 +304,22 @@ describe("packaged desktop startup verification", () => { expect(now).toBeGreaterThanOrEqual(1_000); }); + it("turns interrupt signals into an observable cleanup request and removes its listeners", async () => { + const source = new EventEmitter(); + const termination = monitorPackagedStartupTermination(source); + + source.emit("SIGTERM"); + + await expect(termination.signal).resolves.toBe("SIGTERM"); + expect(termination.readSignal()).toBe("SIGTERM"); + expect(source.listenerCount("SIGINT")).toBe(1); + expect(source.listenerCount("SIGTERM")).toBe(0); + + termination.dispose(); + expect(source.listenerCount("SIGINT")).toBe(0); + expect(source.listenerCount("SIGTERM")).toBe(0); + }); + it("rejects startup proof when the process handle closes before the exit event arrives", async () => { let now = 0; await expect( @@ -510,6 +528,7 @@ describe("packaged desktop startup verification", () => { signalCode: null, } as unknown as ChildProcess; const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const exitWaits: number[] = []; await terminateProcessTree( child, @@ -518,12 +537,16 @@ describe("packaged desktop startup verification", () => { childIsAlive: () => true, sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), targetIsAlive: () => false, - waitForTargetsExit: async () => true, + waitForTargetsExit: async (_targets, timeoutMs) => { + exitWaits.push(timeoutMs); + return true; + }, }, [84], ); expect(signals).toEqual([{ pid: 42, signal: "SIGTERM" }]); + expect(exitWaits).toEqual([12_000]); }); it("fails closed without signaling a recorded PID when the root is already gone", async () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index d59cb2f0..cac02963 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -29,6 +29,45 @@ export interface PackagedDesktopStartupOptions { readonly timeoutMs: number; } +interface TerminationSignalSource { + once(signal: NodeJS.Signals, listener: () => void): unknown; + removeListener(signal: NodeJS.Signals, listener: () => void): unknown; +} + +export function monitorPackagedStartupTermination(source: TerminationSignalSource = process): { + readonly signal: Promise; + readonly readSignal: () => NodeJS.Signals | null; + readonly dispose: () => void; +} { + let observedSignal: NodeJS.Signals | null = null; + let resolveSignal!: (signal: NodeJS.Signals) => void; + const signal = new Promise((resolve) => { + resolveSignal = resolve; + }); + const listeners = new Map void>(); + + for (const name of ["SIGINT", "SIGTERM"] as const) { + const listener = () => { + if (observedSignal !== null) return; + observedSignal = name; + resolveSignal(name); + }; + listeners.set(name, listener); + source.once(name, listener); + } + + return { + signal, + readSignal: () => observedSignal, + dispose: () => { + for (const [name, listener] of listeners) { + source.removeListener(name, listener); + } + listeners.clear(); + }, + }; +} + export function parsePackagedDesktopStartupArgs( argv: ReadonlyArray, ): PackagedDesktopStartupOptions { @@ -447,6 +486,8 @@ export interface ProcessTerminationDependencies { ) => Promise; } +const POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 12_000; + function childProcessHandleIsAlive(child: ChildProcess): boolean { if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false; try { @@ -551,7 +592,10 @@ export async function terminateProcessTree( } const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; sendSignal(rootTarget, "SIGTERM"); - if (await awaitTargetsExit(targets, 5_000)) return; + // The desktop owns a bounded backend shutdown that can legitimately take up + // to ten seconds. Preserve its supervisor until that bound has elapsed so it + // can terminate the backend's separate process group itself. + if (await awaitTargetsExit(targets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)) return; const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; if (targetIsAlive(rootTarget)) sendSignal(rootTarget, "SIGKILL"); if (await awaitTargetsExit(targets, 2_000)) return; @@ -753,6 +797,7 @@ export async function verifyPackagedDesktopStartup( let logPath: string | null = null; let output = ""; const failures: Array<{ phase: string; error: unknown }> = []; + const termination = monitorPackagedStartupTermination(); try { const launch = prepareLaunch(options, extractionRoot); environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); @@ -782,30 +827,56 @@ export async function verifyPackagedDesktopStartup( child.stdout?.on("data", recordOutput); child.stderr?.on("data", recordOutput); - await waitForPackagedStartupProof({ - timeoutMs: options.timeoutMs, - hasProof: () => hasPackagedStartupProof(launchLogPath, options), - readOutcome: () => childOutcome, - isProcessAlive: () => childProcessHandleIsAlive(child!), - }); + await Promise.race([ + waitForPackagedStartupProof({ + timeoutMs: options.timeoutMs, + hasProof: () => hasPackagedStartupProof(launchLogPath, options), + readOutcome: () => childOutcome, + isProcessAlive: () => childProcessHandleIsAlive(child!), + }), + termination.signal.then((signal) => { + throw new Error(`Packaged startup verification interrupted by ${signal}.`); + }), + ]); } catch (error) { failures.push({ phase: "startup verification failed", error }); } + let processCleanupFailed = false; try { if (child) { await terminateProcessTree(child, {}, readPackagedBackendProcessIds(environment)); } } catch (error) { + processCleanupFailed = true; failures.push({ phase: "process cleanup failed", error }); } + const interruptedBy = termination.readSignal(); + termination.dispose(); + if ( + interruptedBy !== null && + failures.every((failure) => failure.phase !== "startup verification failed") + ) { + failures.push({ + phase: "startup verification failed", + error: new Error(`Packaged startup verification interrupted by ${interruptedBy}.`), + }); + } + // Capture diagnostics after cleanup attempts but before deleting isolated state. const logTail = logPath ? readPackagedDesktopLogTail(logPath) : ""; - try { - rmSync(temporaryRoot, { recursive: true, force: true }); - } catch (error) { - failures.push({ phase: `temporary-state cleanup failed at ${temporaryRoot}`, error }); + if (processCleanupFailed) { + failures.push({ + phase: "temporary-state cleanup skipped", + error: new Error(`Preserved failed process evidence at ${temporaryRoot}.`), + }); + } else { + try { + rmSync(temporaryRoot, { recursive: true, force: true }); + } catch (error) { + failures.push({ phase: `temporary-state cleanup failed at ${temporaryRoot}`, error }); + } } if (failures.length > 0) { From e69eb019740cc47aa173125571f1887cb69a851d Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 01:58:10 +0300 Subject: [PATCH 09/30] Bound packaged smoke process cleanup --- .../verify-packaged-desktop-startup.test.ts | 57 +++++++++++++++++-- scripts/verify-packaged-desktop-startup.ts | 35 ++++++++++-- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index a96fcb24..d4e97c00 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -369,7 +369,7 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number) => ({ status: 1 })); + const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ status: 1 })); await expect( terminateProcessTree( child, @@ -383,6 +383,7 @@ describe("packaged desktop startup verification", () => { ), ).rejects.toThrow("survived Windows cleanup"); expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42]); + expect(runTaskkill.mock.calls.map(([, timeoutMs]) => timeoutMs)).toEqual([5_000]); }); it("waits for recorded Windows backends without signaling reused PIDs after root exit", async () => { @@ -391,7 +392,7 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ status: 0 })); await terminateProcessTree( child, @@ -413,7 +414,7 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ status: 0 })); await terminateProcessTree( child, @@ -549,9 +550,57 @@ describe("packaged desktop startup verification", () => { expect(exitWaits).toEqual([12_000]); }); + it("fails closed when the POSIX root exits before escalation", async () => { + const childState: { exitCode: number | null; pid: number; signalCode: NodeJS.Signals | null } = + { + exitCode: null, + pid: 42, + signalCode: null, + }; + const child = childState as unknown as ChildProcess; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + await expect( + terminateProcessTree( + child, + { + platform: "darwin", + childIsAlive: () => true, + sendSignal: (target, signal) => { + signals.push({ pid: target.pid, signal }); + if (signal === "SIGTERM") childState.exitCode = 0; + }, + targetIsAlive: () => true, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("refusing to signal a potentially reused process group"); + expect(signals).toEqual([{ pid: 42, signal: "SIGTERM" }]); + }); + + it("observes an orphaned POSIX process group without signaling it", async () => { + const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; + const observed: Array> = []; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + await expect( + terminateProcessTree(child, { + platform: "darwin", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + waitForTargetsExit: async (targets) => { + observed.push(targets); + return false; + }, + }), + ).rejects.toThrow("refusing to signal unverified PIDs or process groups"); + expect(observed).toEqual([[{ pid: 42, processGroup: true }]]); + expect(signals).toEqual([]); + }); + it("fails closed without signaling a recorded PID when the root is already gone", async () => { const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ status: 0 })); await expect( terminateProcessTree( diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index cac02963..7f18676f 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -474,7 +474,10 @@ export interface ProcessTerminationTarget { export interface ProcessTerminationDependencies { readonly platform?: NodeJS.Platform; readonly childIsAlive?: (child: ChildProcess) => boolean; - readonly runTaskkill?: (pid: number) => { + readonly runTaskkill?: ( + pid: number, + timeoutMs: number, + ) => { readonly error?: Error; readonly status: number | null; }; @@ -487,6 +490,7 @@ export interface ProcessTerminationDependencies { } const POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 12_000; +const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000; function childProcessHandleIsAlive(child: ChildProcess): boolean { if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false; @@ -551,6 +555,13 @@ export async function terminateProcessTree( child.pid && childCanStillOwnProcesses ? { pid: child.pid, processGroup: platform !== "win32" } : null; + // A POSIX process group can outlive its leader. Once the ChildProcess reports + // exit we no longer have durable signal authority, but we must still observe + // that original group before declaring cleanup complete and deleting evidence. + const exitedRootGroupTarget = + platform !== "win32" && child.pid && !rootTarget + ? { pid: child.pid, processGroup: true } + : null; // Backend PIDs recovered from logs/runtime state are observation-only. They // prove cleanup completion, but a bare PID is not durable signal authority: // the OS may reuse it after an unlogged exit. @@ -563,23 +574,27 @@ export async function terminateProcessTree( ); const targets = [ ...(rootTarget ? [rootTarget] : []), - ...observedTargets.filter((target) => target.pid !== rootTarget?.pid), + ...(exitedRootGroupTarget ? [exitedRootGroupTarget] : []), + ...observedTargets.filter( + (target) => target.pid !== (rootTarget?.pid ?? exitedRootGroupTarget?.pid), + ), ]; if (targets.length === 0) return; const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; if (!rootTarget) { - if (await awaitTargetsExit(observedTargets, 5_000)) return; + if (await awaitTargetsExit(targets, 5_000)) return; throw new Error( - `Recorded backend process candidates ${observedTargets.map(({ pid }) => pid).join(", ")} remained after their parent exited; refusing to signal unverified PIDs.`, + `Recorded process candidates ${targets.map(({ pid }) => pid).join(", ")} remained after their parent exited; refusing to signal unverified PIDs or process groups.`, ); } if (platform === "win32") { // The live ChildProcess handle establishes root ownership; taskkill /T // derives descendants from that root. Never signal observation-only PIDs. const taskkillResult = - dependencies.runTaskkill?.(rootTarget.pid) ?? + dependencies.runTaskkill?.(rootTarget.pid, WINDOWS_TASKKILL_TIMEOUT_MS) ?? spawnSync("taskkill", ["/pid", String(rootTarget.pid), "/t", "/f"], { stdio: "ignore", + timeout: WINDOWS_TASKKILL_TIMEOUT_MS, windowsHide: true, }); if (await awaitTargetsExit(targets, 5_000)) return; @@ -596,6 +611,16 @@ export async function terminateProcessTree( // to ten seconds. Preserve its supervisor until that bound has elapsed so it // can terminate the backend's separate process group itself. if (await awaitTargetsExit(targets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)) return; + const childStillOwnsRoot = + child.pid === rootTarget.pid && + child.exitCode === null && + child.signalCode === null && + (dependencies.childIsAlive ?? childProcessHandleIsAlive)(child); + if (!childStillOwnsRoot) { + throw new Error( + `Packaged root ${rootTarget.pid} exited before POSIX escalation; refusing to signal a potentially reused process group.`, + ); + } const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; if (targetIsAlive(rootTarget)) sendSignal(rootTarget, "SIGKILL"); if (await awaitTargetsExit(targets, 2_000)) return; From d1ea51e70509586eeff32c258e4c633429f36166 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 02:20:37 +0300 Subject: [PATCH 10/30] Harden packaged startup release proof --- .github/workflows/release.yml | 26 +- apps/desktop/src/main.ts | 77 ++- docs/release.md | 7 + scripts/release-smoke.ts | 68 ++- .../verify-packaged-desktop-startup.test.ts | 154 +++++- scripts/verify-packaged-desktop-startup.ts | 494 ++++++++++++++---- 6 files changed, 679 insertions(+), 147 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 216ba7d6..3fd011da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,8 +22,7 @@ on: type: boolean permissions: - contents: write - id-token: write + contents: read concurrency: group: scient-desktop-release @@ -202,6 +201,8 @@ jobs: build: name: Build ${{ matrix.label }} + permissions: + contents: read needs: preflight runs-on: ${{ matrix.runner }} timeout-minutes: ${{ matrix.timeout_minutes }} @@ -239,6 +240,7 @@ jobs: with: ref: ${{ needs.preflight.outputs.ref }} fetch-depth: 0 + persist-credentials: false - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -383,13 +385,26 @@ jobs: - name: Smoke exact packaged desktop startup if: ${{ matrix.platform != 'linux' }} shell: bash + env: + SCIENT_PACKAGED_STARTUP_DIAGNOSTICS_DIR: ${{ github.workspace }}/packaged-startup-diagnostics run: | node scripts/verify-packaged-desktop-startup.ts \ --assets-dir release-publish \ --platform "${{ matrix.platform }}" \ --arch "${{ matrix.arch }}" \ --version "${{ needs.preflight.outputs.version }}" \ - --commit "${{ github.sha }}" + --commit "${{ github.sha }}" \ + --allow-unsigned-windows "${{ needs.preflight.outputs.publish_release != 'true' || needs.preflight.outputs.unsigned_release == 'true' }}" \ + --windows-publisher-subject "${{ vars.SCIENT_WINDOWS_PUBLISHER_SUBJECT }}" + + - name: Upload packaged startup failure diagnostics + if: ${{ failure() && matrix.platform != 'linux' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: packaged-startup-diagnostics-${{ matrix.platform }}-${{ matrix.arch }} + path: packaged-startup-diagnostics/* + if-no-files-found: warn + retention-days: 7 - name: Upload build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -400,6 +415,9 @@ jobs: publish_cli: name: Publish CLI to npm + permissions: + contents: read + id-token: write if: ${{ needs.preflight.outputs.publish_release == 'true' && vars.SCIENT_PUBLISH_CLI == '1' }} needs: [preflight, build] runs-on: ubuntu-24.04 @@ -437,6 +455,8 @@ jobs: release: name: Publish GitHub Release + permissions: + contents: write if: ${{ needs.preflight.outputs.publish_release == 'true' }} needs: [preflight, build] runs-on: ubuntu-24.04 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cae1a93e..18cefd09 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -971,7 +971,11 @@ function resolveEmbeddedReleaseMetadata(): { const packageJsonPath = Path.join(resolveAppRoot(), "package.json"); if (!FS.existsSync(packageJsonPath)) { - embeddedReleaseMetadataCache = { commitHash: null, fullCommitHash: null, signed: null }; + embeddedReleaseMetadataCache = { + commitHash: null, + fullCommitHash: null, + signed: null, + }; return embeddedReleaseMetadataCache; } @@ -987,7 +991,11 @@ function resolveEmbeddedReleaseMetadata(): { signed: typeof parsed.scientSigned === "boolean" ? parsed.scientSigned : null, }; } catch { - embeddedReleaseMetadataCache = { commitHash: null, fullCommitHash: null, signed: null }; + embeddedReleaseMetadataCache = { + commitHash: null, + fullCommitHash: null, + signed: null, + }; } return embeddedReleaseMetadataCache; @@ -1473,7 +1481,11 @@ function configureApplicationMenu(): void { ? [ { role: "resetZoom" }, { role: "zoomIn", ...acceleratorProps("CmdOrCtrl+=") }, - { role: "zoomIn", ...acceleratorProps("CmdOrCtrl+Plus"), visible: false }, + { + role: "zoomIn", + ...acceleratorProps("CmdOrCtrl+Plus"), + visible: false, + }, { role: "zoomOut" }, ] : [ @@ -1785,8 +1797,14 @@ function showDesktopNotification(input: { function resolveUserDataPath(): string { const appDataBase = resolveDesktopAppDataBase(); const targetPath = resolveDesktopUserDataPath({ appDataBase, isDevelopment }); - const sourcePath = resolvePapiLabDesktopUserDataPath({ appDataBase, isDevelopment }); - const migration = seedDesktopUserDataProfileFromPapiLab({ sourcePath, targetPath }); + const sourcePath = resolvePapiLabDesktopUserDataPath({ + appDataBase, + isDevelopment, + }); + const migration = seedDesktopUserDataProfileFromPapiLab({ + sourcePath, + targetPath, + }); if (migration.status === "seed-failed") { console.warn("[desktop] Failed to seed Scient profile from PapiLab", migration.error); } @@ -1898,7 +1916,9 @@ function refreshMacIconCacheOnVersionChange(): void { // Read-only bundle: fall through to lsregister. } - const child = ChildProcess.spawn(LSREGISTER_PATH, ["-f", bundlePath], { stdio: "ignore" }); + const child = ChildProcess.spawn(LSREGISTER_PATH, ["-f", bundlePath], { + stdio: "ignore", + }); child.unref(); child.once("error", (error) => { console.warn("[desktop] Failed to refresh macOS icon cache after update", error); @@ -2624,7 +2644,9 @@ function configureAutoUpdater(): void { configuredGitHubUpdateSource = resolveGitHubUpdateSource(appUpdateYml); if (configuredGitHubUpdateSource !== null) { // The updater itself uses app-update.yml; this URL is only the human fallback. - setUpdateState({ releaseUrl: buildGitHubReleasesPageUrl(configuredGitHubUpdateSource) }); + setUpdateState({ + releaseUrl: buildGitHubReleasesPageUrl(configuredGitHubUpdateSource), + }); } autoUpdater.autoDownload = false; @@ -3427,7 +3449,9 @@ function getIconOption(): { icon: string } | Record { // (`show: false`), so this color is not expected to match a custom in-app theme exactly. function getWindowMaterialOptions(): BrowserWindowConstructorOptions { if (process.platform !== "darwin") { - return { backgroundColor: nativeTheme.shouldUseDarkColors ? "#181818" : "#ffffff" }; + return { + backgroundColor: nativeTheme.shouldUseDarkColors ? "#181818" : "#ffffff", + }; } return { vibrancy: "under-window", @@ -3495,6 +3519,10 @@ function createWindow(): BrowserWindow { backgroundThrottling: true, }, }); + let resolvePackagedWindowVisibility!: (visible: boolean) => void; + const packagedWindowVisibility = new Promise((resolveVisibility) => { + resolvePackagedWindowVisibility = resolveVisibility; + }); browserManager.setWindow(window); attachDesktopZoomFactorSync(window); @@ -3558,22 +3586,31 @@ function createWindow(): BrowserWindow { `new Promise((resolve) => requestAnimationFrame(() => resolve(${PACKAGED_RENDERER_READINESS_EXPRESSION})))`, true, ), - fetch(`${backendHttpUrl}/health`, { signal: AbortSignal.timeout(5_000) }).then( - async (response) => { - if (!response.ok) return false; - const payload = (await response.json()) as { startupReady?: unknown }; - return payload.startupReady === true; - }, - ), + fetch(`${backendHttpUrl}/health`, { + signal: AbortSignal.timeout(5_000), + }).then(async (response) => { + if (!response.ok) return false; + const payload = (await response.json()) as { + startupReady?: unknown; + }; + return payload.startupReady === true; + }), + packagedWindowVisibility, ]) - .then(([rendererReady, backendReady]) => { + .then(([rendererReady, backendReady, windowVisible]) => { const currentGeneration = getBackendSupervisor().currentGeneration; const sameLiveGeneration = generation !== null && currentGeneration?.number === generation.number && currentGeneration.child.exitCode === null && currentGeneration.child.signalCode === null; - if (rendererReady !== true || backendReady !== true || !sameLiveGeneration) { + if ( + rendererReady !== true || + backendReady !== true || + !sameLiveGeneration || + windowVisible !== true || + !window.isVisible() + ) { throw new Error("renderer or backend failed the delayed responsiveness check"); } writeDesktopLogHeader( @@ -3613,6 +3650,10 @@ function createWindow(): BrowserWindow { window.maximize(); } window.show(); + if (window.isVisible()) { + writeDesktopLogHeader("packaged main window visible"); + resolvePackagedWindowVisibility(true); + } emitDesktopWindowState(window); }); @@ -3765,9 +3806,9 @@ if (hasSingleInstanceLock) { app .whenReady() .then(async () => { + configureAppIdentity(); writeDesktopLogHeader("app ready"); writePackagedStartupSmokeIdentity(); - configureAppIdentity(); applyLegacyMacDockIcon(); refreshMacIconCacheOnVersionChange(); configureMediaPermissions(); diff --git a/docs/release.md b/docs/release.md index 59f08ca1..1ee49bd2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -264,6 +264,13 @@ Build-only validation may continue with an unsigned NSIS installer when neither provider is configured; a deliberate unsigned early-access dispatch may publish that installer with its operating-system warning. +Set the repository variable `SCIENT_WINDOWS_PUBLISHER_SUBJECT` to the exact +Authenticode certificate subject (for example, the complete `CN=..., O=..., C=...` +value reported by `Get-AuthenticodeSignature`). Before upload, native Windows +startup proof requires both the collected NSIS installer and its extracted +`Scient.exe` to have valid timestamped signatures from that exact subject. The +check is skipped only by the explicit unsigned early-access publication mode. + ### Option A: standard Authenticode certificate This path supports an OV/EV code-signing certificate accepted by electron-builder. diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index e92c04f1..535647b4 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -53,7 +53,10 @@ function copyWorkspaceManifestFixture(targetRoot: string): void { }); } -function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { +function writeMacManifestFixtures(targetRoot: string): { + arm64Path: string; + x64Path: string; +} { const assetDirectory = resolve(targetRoot, "release-assets"); mkdirSync(assetDirectory, { recursive: true }); @@ -140,6 +143,8 @@ interface ReleaseWorkflowStep { readonly if?: string; readonly name?: string; readonly run?: string; + readonly uses?: string; + readonly with?: Record; } function assertScopedSigningEnvironment( @@ -185,7 +190,10 @@ function verifyCanonicalIdentity(): void { throw new Error("Expected packaged Scient clients to use the approved update channel."); } - const linux = createDesktopPlatformBuildConfig({ platform: "linux", target: "AppImage" }).linux; + const linux = createDesktopPlatformBuildConfig({ + platform: "linux", + target: "AppImage", + }).linux; if (!linux || linux.executableName !== "scient") { throw new Error("Expected Linux desktop releases to install the scient executable."); } @@ -238,13 +246,31 @@ function verifyReleaseWorkflowSafety(): void { "utf8", ).replaceAll("\r\n", "\n"); const parsedWorkflow = parseYaml(workflow) as { + permissions?: Record; jobs?: { - build?: { steps?: Array }; + build?: { permissions?: Record; steps?: Array }; preflight?: { steps?: Array }; + publish_cli?: { permissions?: Record }; + release?: { permissions?: Record }; }; }; const buildSteps = parsedWorkflow.jobs?.build?.steps ?? []; const preflightSteps = parsedWorkflow.jobs?.preflight?.steps ?? []; + const buildPermissions = parsedWorkflow.jobs?.build?.permissions ?? {}; + const buildCheckout = buildSteps.find((step) => step.name === "Checkout"); + if ( + parsedWorkflow.permissions?.contents !== "read" || + Object.keys(parsedWorkflow.permissions ?? {}).length !== 1 || + buildPermissions.contents !== "read" || + "id-token" in buildPermissions || + buildCheckout?.with?.["persist-credentials"] !== false || + parsedWorkflow.jobs?.publish_cli?.permissions?.["id-token"] !== "write" || + parsedWorkflow.jobs?.release?.permissions?.contents !== "write" + ) { + throw new Error( + "Expected native payload execution to have read-only contents, no OIDC, and no persisted checkout credential.", + ); + } const requireBuildStep = (name: string) => { const step = buildSteps.find((candidate) => candidate.name === name); if (!step) { @@ -303,12 +329,29 @@ function verifyReleaseWorkflowSafety(): void { packagedStartupStep?.if !== "${{ matrix.platform != 'linux' }}" || !packagedStartupStep.run?.includes("node scripts/verify-packaged-desktop-startup.ts") || !packagedStartupStep.run.includes("--assets-dir release-publish") || - !packagedStartupStep.run.includes('--commit "${{ github.sha }}"') + !packagedStartupStep.run.includes('--commit "${{ github.sha }}"') || + !packagedStartupStep.run.includes("--allow-unsigned-windows") || + !packagedStartupStep.run.includes("--windows-publisher-subject") ) { throw new Error( "Expected exact macOS and Windows packaged-startup proof after collection and before upload.", ); } + const packagedDiagnosticsIndex = buildSteps.findIndex( + (step) => step.name === "Upload packaged startup failure diagnostics", + ); + const packagedDiagnosticsStep = buildSteps[packagedDiagnosticsIndex]; + if ( + packagedDiagnosticsIndex !== packagedStartupIndex + 1 || + packagedDiagnosticsStep?.if !== "${{ failure() && matrix.platform != 'linux' }}" || + packagedDiagnosticsStep.uses !== + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" || + packagedDiagnosticsStep.with?.path !== "packaged-startup-diagnostics/*" + ) { + throw new Error( + "Expected bounded packaged-startup failure diagnostics to upload from native runners.", + ); + } const packagedStartupVerifier = readFileSync( resolve(repoRoot, "scripts/verify-packaged-desktop-startup.ts"), "utf8", @@ -335,6 +378,18 @@ function verifyReleaseWorkflowSafety(): void { "packaged identity name=${app.getName()} version=${app.getVersion()} commit=${commit}", "Expected the running packaged app to expose its exact embedded identity to the verifier.", ); + if ( + desktopMain.indexOf("configureAppIdentity();", desktopMain.indexOf("app.whenReady()")) < 0 || + desktopMain.indexOf("configureAppIdentity();", desktopMain.indexOf("app.whenReady()")) > + desktopMain.indexOf( + "writePackagedStartupSmokeIdentity();", + desktopMain.indexOf("app.whenReady()"), + ) + ) { + throw new Error( + "Expected Scient runtime identity to be configured before smoke identity proof.", + ); + } assertContains( webMain, 'document.documentElement.dataset.scientRendererReady = "true"', @@ -365,6 +420,11 @@ function verifyReleaseWorkflowSafety(): void { "PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST", "Expected packaged startup verification to inherit only explicit host variables.", ); + assertContains( + packagedStartupVerifier, + 'log.includes("packaged main window visible")', + "Expected packaged startup proof to require a visible native window.", + ); assertContains( packagedStartupVerifier, 'log.includes("renderer main frame loaded")', diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index d4e97c00..c567b824 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -16,8 +16,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { assertPackagedLaunchCommandSafety, + assertWindowsReleaseSignatureDetails, createPackagedDesktopSmokeEnvironment, expectedPackagedDesktopStartupAssetName, + expectedPackagedDesktopStartupAssetNames, formatPackagedStartupFailures, hasPackagedStartupProof, isScientWindowsExecutable, @@ -29,9 +31,11 @@ import { resolveExactPackagedDesktopStartupAsset, resolveNativePackagedDesktopPlatform, resolvePackagedDesktopLogPath, + runPackagedPreparationCommand, sanitizePackagedDesktopInheritedEnvironment, terminateProcessTree, waitForPackagedStartupProof, + writePackagedStartupFailureDiagnostics, } from "./verify-packaged-desktop-startup.ts"; const temporaryRoots: string[] = []; @@ -202,6 +206,16 @@ describe("packaged desktop startup verification", () => { ); }); + it("requires both exact macOS distributable payloads but one Windows installer", () => { + expect(expectedPackagedDesktopStartupAssetNames("mac", "arm64", "1.2.3")).toEqual([ + "Scient-1.2.3-arm64.zip", + "Scient-1.2.3-arm64.dmg", + ]); + expect(expectedPackagedDesktopStartupAssetNames("win", "x64", "1.2.3")).toEqual([ + "Scient-1.2.3-x64.exe", + ]); + }); + it("does not accept proof from a packaged process that exits immediately", async () => { let now = 0; let outcome = { exited: null, launchError: null } as { @@ -231,6 +245,7 @@ describe("packaged desktop startup verification", () => { "app ready", "packaged identity name=Scient version=1.2.3 commit=0123456789abcdef0123456789abcdef01234567", "bootstrap main window created", + "packaged main window visible", "backend semantic ready generation=1", "renderer main frame loaded", "packaged responsiveness confirmed generation=1", @@ -288,6 +303,40 @@ describe("packaged desktop startup verification", () => { expect(readWindowsExecutableArchitecture(executable)).toBeNull(); }); + it("requires valid timestamped Windows signatures from the configured publisher", () => { + const valid = { + status: "Valid", + statusMessage: "Signature verified.", + signerSubject: "CN=Scient Factory Ltd, O=Scient Factory Ltd, C=IL", + signerThumbprint: "ABC123", + timestampSubject: "CN=Trusted Timestamp", + }; + expect(() => + assertWindowsReleaseSignatureDetails( + [valid, valid], + "CN=Scient Factory Ltd, O=Scient Factory Ltd, C=IL", + ), + ).not.toThrow(); + expect(() => + assertWindowsReleaseSignatureDetails( + [{ ...valid, status: "NotSigned" }, valid], + valid.signerSubject, + ), + ).toThrow("not valid"); + expect(() => + assertWindowsReleaseSignatureDetails( + [{ ...valid, signerSubject: "CN=Other Publisher" }, valid], + valid.signerSubject, + ), + ).toThrow("does not match"); + expect(() => + assertWindowsReleaseSignatureDetails( + [{ ...valid, timestampSubject: null }, valid], + valid.signerSubject, + ), + ).toThrow("timestamp signer is missing"); + }); + it("accepts startup proof only after the process remains alive for the stability window", async () => { let now = 0; await expect( @@ -320,6 +369,32 @@ describe("packaged desktop startup verification", () => { expect(source.listenerCount("SIGTERM")).toBe(0); }); + it("cancels a hung preparation command when the smoke receives SIGTERM", async () => { + const source = new EventEmitter(); + const termination = monitorPackagedStartupTermination(source); + const spawnProcess = vi.fn((_command, _args, options) => { + const child = new EventEmitter() as ChildProcess; + Object.assign(child, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + options.signal?.addEventListener("abort", () => child.emit("error", options.signal?.reason), { + once: true, + }); + return child; + }) as unknown as typeof import("node:child_process").spawn; + + const command = runPackagedPreparationCommand("ditto", ["hung.zip"], { + signal: termination.abortSignal, + spawnProcess, + }); + source.emit("SIGTERM"); + + await expect(command).rejects.toThrow("interrupted by SIGTERM"); + expect(termination.abortSignal.aborted).toBe(true); + termination.dispose(); + }); + it("rejects startup proof when the process handle closes before the exit event arrives", async () => { let now = 0; await expect( @@ -352,8 +427,14 @@ describe("packaged desktop startup verification", () => { expect( formatPackagedStartupFailures( [ - { phase: "startup verification failed", error: new Error("renderer froze") }, - { phase: "process cleanup failed", error: new Error("backend survived") }, + { + phase: "startup verification failed", + error: new Error("renderer froze"), + }, + { + phase: "process cleanup failed", + error: new Error("backend survived"), + }, ], "stderr detail", "desktop log detail", @@ -363,13 +444,29 @@ describe("packaged desktop startup verification", () => { ); }); + it("exports bounded redacted failure evidence for hosted runners", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-diagnostics-test-")); + temporaryRoots.push(root); + + const path = writePackagedStartupFailureDiagnostics( + root, + "failed https://localhost/?token=private Bearer very-secret", + ); + + expect(readFileSync(path, "utf8")).toContain("token=[REDACTED]"); + expect(readFileSync(path, "utf8")).toContain("Bearer [REDACTED]"); + expect(readFileSync(path, "utf8")).not.toContain("very-secret"); + }); + it("targets a live Windows root once and fails when its complete tree survives", async () => { const child = { exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ status: 1 })); + const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ + status: 1, + })); await expect( terminateProcessTree( child, @@ -392,7 +489,9 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ status: 0 })); + const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ + status: 0, + })); await terminateProcessTree( child, @@ -414,7 +513,9 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ status: 0 })); + const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ + status: 0, + })); await terminateProcessTree( child, @@ -551,12 +652,15 @@ describe("packaged desktop startup verification", () => { }); it("fails closed when the POSIX root exits before escalation", async () => { - const childState: { exitCode: number | null; pid: number; signalCode: NodeJS.Signals | null } = - { - exitCode: null, - pid: 42, - signalCode: null, - }; + const childState: { + exitCode: number | null; + pid: number; + signalCode: NodeJS.Signals | null; + } = { + exitCode: null, + pid: 42, + signalCode: null, + }; const child = childState as unknown as ChildProcess; const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; @@ -580,7 +684,11 @@ describe("packaged desktop startup verification", () => { }); it("observes an orphaned POSIX process group without signaling it", async () => { - const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; const observed: Array> = []; const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; @@ -599,8 +707,14 @@ describe("packaged desktop startup verification", () => { }); it("fails closed without signaling a recorded PID when the root is already gone", async () => { - const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ status: 0 })); + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ + status: 0, + })); await expect( terminateProcessTree( @@ -616,6 +730,14 @@ describe("packaged desktop startup verification", () => { expect(runTaskkill).not.toHaveBeenCalled(); }); + it("fails closed when a Windows root exits without any recorded descendants", async () => { + const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; + + await expect(terminateProcessTree(child, { platform: "win32" })).rejects.toThrow( + "exited before cleanup ownership was established", + ); + }); + it("prepares the isolated Scient macOS profile marker", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-mac-env-test-")); temporaryRoots.push(root); @@ -633,7 +755,9 @@ describe("packaged desktop startup verification", () => { "last-launch-version.json", ); - expect(JSON.parse(readFileSync(markerPath, "utf8"))).toEqual({ version: "1.2.3" }); + expect(JSON.parse(readFileSync(markerPath, "utf8"))).toEqual({ + version: "1.2.3", + }); }); it("recognizes only the Scient Windows executable identity", () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 7f18676f..fe06f846 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -15,7 +15,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; export type PackagedDesktopPlatform = "mac" | "win"; @@ -27,6 +27,8 @@ export interface PackagedDesktopStartupOptions { readonly version: string; readonly commit: string; readonly timeoutMs: number; + readonly allowUnsignedWindows?: boolean; + readonly windowsPublisherSubject?: string; } interface TerminationSignalSource { @@ -36,6 +38,7 @@ interface TerminationSignalSource { export function monitorPackagedStartupTermination(source: TerminationSignalSource = process): { readonly signal: Promise; + readonly abortSignal: AbortSignal; readonly readSignal: () => NodeJS.Signals | null; readonly dispose: () => void; } { @@ -45,11 +48,13 @@ export function monitorPackagedStartupTermination(source: TerminationSignalSourc resolveSignal = resolve; }); const listeners = new Map void>(); + const abortController = new AbortController(); for (const name of ["SIGINT", "SIGTERM"] as const) { const listener = () => { if (observedSignal !== null) return; observedSignal = name; + abortController.abort(new Error(`Packaged startup verification interrupted by ${name}.`)); resolveSignal(name); }; listeners.set(name, listener); @@ -58,6 +63,7 @@ export function monitorPackagedStartupTermination(source: TerminationSignalSourc return { signal, + abortSignal: abortController.signal, readSignal: () => observedSignal, dispose: () => { for (const [name, listener] of listeners) { @@ -88,6 +94,8 @@ export function parsePackagedDesktopStartupArgs( "--version", "--commit", "--timeout-ms", + "--allow-unsigned-windows", + "--windows-publisher-subject", ]); for (const name of values.keys()) { if (!known.has(name)) throw new Error(`Unknown packaged startup argument: ${name}.`); @@ -116,6 +124,14 @@ export function parsePackagedDesktopStartupArgs( if (!/^[0-9a-f]{40}$/u.test(commit)) { throw new Error("--commit must be a complete 40-character Git commit SHA."); } + const allowUnsignedWindowsValue = values.get("--allow-unsigned-windows") ?? "false"; + if (allowUnsignedWindowsValue !== "true" && allowUnsignedWindowsValue !== "false") { + throw new Error("--allow-unsigned-windows must be true or false."); + } + const windowsPublisherSubject = values.get("--windows-publisher-subject")?.trim(); + if (platform === "win" && allowUnsignedWindowsValue === "false" && !windowsPublisherSubject) { + throw new Error("Signed Windows startup proof requires --windows-publisher-subject."); + } return { assetsDirectory: resolve(required("--assets-dir")), @@ -124,44 +140,67 @@ export function parsePackagedDesktopStartupArgs( version: required("--version"), commit, timeoutMs, + ...(platform === "win" + ? { + allowUnsignedWindows: allowUnsignedWindowsValue === "true", + ...(windowsPublisherSubject ? { windowsPublisherSubject } : {}), + } + : {}), }; } -function runCommand(command: string, args: ReadonlyArray, cwd?: string): void { - const result = spawnSync(command, [...args], { - cwd, - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - shell: false, - windowsHide: true, - }); - if (result.error) { - throw new Error(`${command} could not start: ${result.error.message}`); - } - if (result.status !== 0) { - const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); - const detail = output ? `\n${output}` : ""; - throw new Error( - `${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}.${detail}`, - ); - } -} +const PREPARATION_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; -function runTextCommand(command: string, args: ReadonlyArray, cwd?: string): string { - const result = spawnSync(command, [...args], { - cwd, - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, +export async function runPackagedPreparationCommand( + command: string, + args: ReadonlyArray, + options: { + readonly cwd?: string; + readonly signal: AbortSignal; + readonly spawnProcess?: typeof spawn; + }, +): Promise { + if (options.signal.aborted) throw options.signal.reason; + const child = (options.spawnProcess ?? spawn)(command, [...args], { + cwd: options.cwd, shell: false, + signal: options.signal, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); - if (result.error || result.status !== 0) { - const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); - throw new Error( - `${command} ${args.join(" ")} failed${result.error ? `: ${result.error.message}` : ` with exit ${result.status ?? "unknown"}`}${detail ? `\n${detail}` : ""}`, - ); - } - return result.stdout.trim(); + let stdout = ""; + let stderr = ""; + const append = (current: string, chunk: unknown) => + `${current}${String(chunk)}`.slice(-PREPARATION_OUTPUT_LIMIT_BYTES); + child.stdout?.on("data", (chunk) => { + stdout = append(stdout, chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr = append(stderr, chunk); + }); + await new Promise((resolveCommand, rejectCommand) => { + let settled = false; + const settle = (error?: Error) => { + if (settled) return; + settled = true; + if (error) rejectCommand(error); + else resolveCommand(); + }; + child.once("error", (error) => settle(error)); + child.once("exit", (code, signal) => { + if (code === 0) { + settle(); + return; + } + const detail = [stdout, stderr].filter(Boolean).join("\n").trim(); + settle( + new Error( + `${command} ${args.join(" ")} failed with exit ${code ?? "unknown"}${signal ? ` signal ${signal}` : ""}.${detail ? `\n${detail}` : ""}`, + ), + ); + }); + }); + return stdout.trim(); } function findFiles(root: string, predicate: (path: string) => boolean): string[] { @@ -191,6 +230,15 @@ export function expectedPackagedDesktopStartupAssetName( return `Scient-${version}-${arch}${extension}`; } +export function expectedPackagedDesktopStartupAssetNames( + platform: PackagedDesktopPlatform, + arch: string, + version: string, +): ReadonlyArray { + const primary = expectedPackagedDesktopStartupAssetName(platform, arch, version); + return platform === "mac" ? [primary, `Scient-${version}-${arch}.dmg`] : [primary]; +} + export function resolveExactPackagedDesktopStartupAsset( directory: string, expectedName: string, @@ -216,6 +264,7 @@ export interface PackagedDesktopLaunchCommand { readonly command: string; readonly args: ReadonlyArray; readonly cwd: string; + readonly cleanup?: () => Promise; } export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchCommand): void { @@ -229,69 +278,103 @@ export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchC } } -function prepareMacLaunch( +async function prepareMacLaunch( assetsDirectory: string, extractionRoot: string, expectedAssetName: string, options: Pick, -): PackagedDesktopLaunchCommand { + signal: AbortSignal, +): Promise { const archive = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); - runCommand("ditto", ["-x", "-k", archive, extractionRoot]); - const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); - if (appBundles.length !== 1 || appBundles[0] !== "Scient.app") { - throw new Error(`Expected the exact Scient.app bundle in ${basename(archive)}.`); - } - const appBundle = join(extractionRoot, appBundles[0]!); - const executables = findFiles(join(appBundle, "Contents", "MacOS"), (candidate) => - statSync(candidate).isFile(), - ); - if (executables.length !== 1) { - throw new Error(`Expected one macOS main executable, found ${executables.length}.`); - } - const infoPlist = join(appBundle, "Contents", "Info.plist"); - const bundleIdentifier = runTextCommand("plutil", [ - "-extract", - "CFBundleIdentifier", - "raw", - "-o", - "-", - infoPlist, - ]); - const bundleVersion = runTextCommand("plutil", [ - "-extract", - "CFBundleShortVersionString", - "raw", - "-o", - "-", - infoPlist, - ]); - const bundleExecutable = runTextCommand("plutil", [ - "-extract", - "CFBundleExecutable", - "raw", - "-o", - "-", - infoPlist, - ]); - if ( - bundleIdentifier !== "com.scientfactory.scient" || - bundleVersion !== options.version || - bundleExecutable !== "Scient" - ) { - throw new Error( - `Unexpected macOS bundle identity id=${bundleIdentifier} version=${bundleVersion} executable=${bundleExecutable}.`, + const isDiskImage = expectedAssetName.endsWith(".dmg"); + if (isDiskImage) { + await runPackagedPreparationCommand( + "hdiutil", + ["attach", "-readonly", "-nobrowse", "-mountpoint", extractionRoot, archive], + { signal }, ); + } else { + await runPackagedPreparationCommand("ditto", ["-x", "-k", archive, extractionRoot], { signal }); } - const expectedArchitecture = options.arch === "x64" ? "x86_64" : options.arch; - const executableArchitectures = runTextCommand("lipo", ["-archs", executables[0]!]) - .split(/\s+/u) - .filter(Boolean); - if (executableArchitectures.length !== 1 || executableArchitectures[0] !== expectedArchitecture) { - throw new Error( - `Expected exact macOS ${expectedArchitecture} executable, found ${executableArchitectures.join(", ") || "unknown"}.`, + const cleanup = isDiskImage + ? async () => { + await runPackagedPreparationCommand("hdiutil", ["detach", extractionRoot], { + signal: AbortSignal.timeout(30_000), + }); + } + : undefined; + try { + const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); + if (appBundles.length !== 1 || appBundles[0] !== "Scient.app") { + throw new Error(`Expected the exact Scient.app bundle in ${basename(archive)}.`); + } + const appBundle = join(extractionRoot, appBundles[0]!); + const executables = findFiles(join(appBundle, "Contents", "MacOS"), (candidate) => + statSync(candidate).isFile(), + ); + if (executables.length !== 1) { + throw new Error(`Expected one macOS main executable, found ${executables.length}.`); + } + const infoPlist = join(appBundle, "Contents", "Info.plist"); + const bundleIdentifier = await runPackagedPreparationCommand( + "plutil", + ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlist], + { signal }, + ); + const bundleVersion = await runPackagedPreparationCommand( + "plutil", + ["-extract", "CFBundleShortVersionString", "raw", "-o", "-", infoPlist], + { signal }, + ); + const bundleExecutable = await runPackagedPreparationCommand( + "plutil", + ["-extract", "CFBundleExecutable", "raw", "-o", "-", infoPlist], + { signal }, ); + if ( + bundleIdentifier !== "com.scientfactory.scient" || + bundleVersion !== options.version || + bundleExecutable !== "Scient" + ) { + throw new Error( + `Unexpected macOS bundle identity id=${bundleIdentifier} version=${bundleVersion} executable=${bundleExecutable}.`, + ); + } + const expectedArchitecture = options.arch === "x64" ? "x86_64" : options.arch; + const executableArchitectures = ( + await runPackagedPreparationCommand("lipo", ["-archs", executables[0]!], { + signal, + }) + ) + .split(/\s+/u) + .filter(Boolean); + if ( + executableArchitectures.length !== 1 || + executableArchitectures[0] !== expectedArchitecture + ) { + throw new Error( + `Expected exact macOS ${expectedArchitecture} executable, found ${executableArchitectures.join(", ") || "unknown"}.`, + ); + } + return { + command: executables[0]!, + args: [], + cwd: appBundle, + ...(cleanup ? { cleanup } : {}), + }; + } catch (error) { + if (cleanup) { + try { + await cleanup(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Failed to inspect and detach ${basename(archive)}.`, + ); + } + } + throw error; } - return { command: executables[0]!, args: [], cwd: appBundle }; } export function isScientWindowsExecutable(candidate: string): boolean { @@ -318,18 +401,114 @@ export function readWindowsExecutableArchitecture(executable: Uint8Array): strin return null; } -function prepareWindowsLaunch( +export interface WindowsReleaseSignatureDetails { + readonly status: string; + readonly statusMessage: string; + readonly signerSubject: string | null; + readonly signerThumbprint: string | null; + readonly timestampSubject: string | null; +} + +export function assertWindowsReleaseSignatureDetails( + signatures: ReadonlyArray, + expectedPublisherSubject: string, +): void { + for (const [index, signature] of signatures.entries()) { + const label = index === 0 ? "Windows installer" : "Extracted Scient executable"; + if (signature.status !== "Valid") { + throw new Error( + `${label} Authenticode signature is not valid (${signature.status}: ${signature.statusMessage}).`, + ); + } + if (signature.signerSubject?.trim() !== expectedPublisherSubject) { + throw new Error( + `${label} publisher ${signature.signerSubject ?? "missing"} does not match ${expectedPublisherSubject}.`, + ); + } + if (!signature.signerThumbprint?.trim()) { + throw new Error(`${label} signer thumbprint is missing.`); + } + if (!signature.timestampSubject?.trim()) { + throw new Error(`${label} timestamp signer is missing.`); + } + } +} + +const WINDOWS_RELEASE_SIGNATURE_SCRIPT = [ + "param([string]$InstallerPath, [string]$ExecutablePath)", + "$ErrorActionPreference = 'Stop'", + "function Read-Signature([string]$Path) {", + " $Signature = Get-AuthenticodeSignature -LiteralPath $Path", + " [pscustomobject]@{", + " status = [string]$Signature.Status", + " statusMessage = [string]$Signature.StatusMessage", + " signerSubject = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Subject }", + " signerThumbprint = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Thumbprint }", + " timestampSubject = if ($null -eq $Signature.TimeStamperCertificate) { $null } else { [string]$Signature.TimeStamperCertificate.Subject }", + " }", + "}", + "@((Read-Signature $InstallerPath), (Read-Signature $ExecutablePath)) | ConvertTo-Json -Compress -Depth 4", +].join("\r\n"); + +async function verifyWindowsReleaseSignatures( + installer: string, + executable: string, + expectedPublisherSubject: string, + extractionRoot: string, + signal: AbortSignal, +): Promise { + const scriptPath = join(extractionRoot, "verify-release-signatures.ps1"); + writeFileSync(scriptPath, WINDOWS_RELEASE_SIGNATURE_SCRIPT, { encoding: "utf8", mode: 0o600 }); + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows"; + const powershell = win32.join( + systemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const output = await runPackagedPreparationCommand( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-InstallerPath", + installer, + "-ExecutablePath", + executable, + ], + { signal }, + ); + const parsed = JSON.parse(output) as ReadonlyArray; + if (!Array.isArray(parsed) || parsed.length !== 2) { + throw new Error("Windows Authenticode verifier returned invalid release signature details."); + } + assertWindowsReleaseSignatureDetails(parsed, expectedPublisherSubject); +} + +async function prepareWindowsLaunch( assetsDirectory: string, extractionRoot: string, expectedAssetName: string, - options: Pick, -): PackagedDesktopLaunchCommand { + options: Pick< + PackagedDesktopStartupOptions, + "arch" | "allowUnsignedWindows" | "windowsPublisherSubject" + >, + signal: AbortSignal, +): Promise { const installer = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); const installerRoot = join(extractionRoot, "installer"); const applicationRoot = join(extractionRoot, "application"); mkdirSync(installerRoot, { recursive: true }); mkdirSync(applicationRoot, { recursive: true }); - runCommand("7z", ["x", "-y", `-o${installerRoot}`, installer]); + await runPackagedPreparationCommand("7z", ["x", "-y", `-o${installerRoot}`, installer], { + signal, + }); const applicationArchives = findFiles(installerRoot, (candidate) => /[/\\]app-(?:32|64|arm64)\.7z$/i.test(candidate), ); @@ -338,7 +517,11 @@ function prepareWindowsLaunch( `Expected one embedded NSIS application archive, found ${applicationArchives.length}.`, ); } - runCommand("7z", ["x", "-y", `-o${applicationRoot}`, applicationArchives[0]!]); + await runPackagedPreparationCommand( + "7z", + ["x", "-y", `-o${applicationRoot}`, applicationArchives[0]!], + { signal }, + ); const executables = findFiles(applicationRoot, isScientWindowsExecutable); if (executables.length !== 1) { throw new Error(`Expected one extracted Scient.exe, found ${executables.length}.`); @@ -349,22 +532,44 @@ function prepareWindowsLaunch( `Expected exact Windows ${options.arch} executable, found ${executableArchitecture ?? "unknown"}.`, ); } + if (!options.allowUnsignedWindows) { + const expectedPublisherSubject = options.windowsPublisherSubject?.trim(); + if (!expectedPublisherSubject) { + throw new Error("Signed Windows startup proof requires an expected publisher subject."); + } + await verifyWindowsReleaseSignatures( + installer, + executables[0]!, + expectedPublisherSubject, + extractionRoot, + signal, + ); + } return { command: executables[0]!, args: [], cwd: dirname(executables[0]!) }; } -function prepareLaunch( +async function prepareLaunch( options: PackagedDesktopStartupOptions, extractionRoot: string, -): PackagedDesktopLaunchCommand { - const expectedAssetName = expectedPackagedDesktopStartupAssetName( - options.platform, - options.arch, - options.version, - ); + expectedAssetName: string, + signal: AbortSignal, +): Promise { const launch = options.platform === "mac" - ? prepareMacLaunch(options.assetsDirectory, extractionRoot, expectedAssetName, options) - : prepareWindowsLaunch(options.assetsDirectory, extractionRoot, expectedAssetName, options); + ? await prepareMacLaunch( + options.assetsDirectory, + extractionRoot, + expectedAssetName, + options, + signal, + ) + : await prepareWindowsLaunch( + options.assetsDirectory, + extractionRoot, + expectedAssetName, + options, + signal, + ); assertPackagedLaunchCommandSafety(launch); return launch; } @@ -579,7 +784,14 @@ export async function terminateProcessTree( (target) => target.pid !== (rootTarget?.pid ?? exitedRootGroupTarget?.pid), ), ]; - if (targets.length === 0) return; + if (targets.length === 0) { + if (platform === "win32" && child.pid) { + throw new Error( + `Packaged Windows root ${child.pid} exited before cleanup ownership was established; preserving evidence because unobserved descendants may remain.`, + ); + } + return; + } const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; if (!rootTarget) { if (await awaitTargetsExit(targets, 5_000)) return; @@ -642,6 +854,7 @@ export function hasPackagedStartupProof( log.includes("app ready") && log.includes(expectedIdentity) && log.includes("bootstrap main window created") && + log.includes("packaged main window visible") && log.includes("renderer main frame loaded") && log.includes("backend semantic ready generation=") && log.includes("packaged responsiveness confirmed generation=") && @@ -710,7 +923,10 @@ export function readPackagedBackendProcessIds(environment: NodeJS.ProcessEnv | n } export interface PackagedDesktopChildOutcome { - readonly exited: { readonly code: number | null; readonly signal: NodeJS.Signals | null } | null; + readonly exited: { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + } | null; readonly launchError: Error | null; } @@ -737,6 +953,25 @@ export function formatPackagedStartupFailures( return `${failureDetail}${processDetail}${logDetail}`; } +function redactPackagedStartupDiagnostic(value: string): string { + return value + .replace(/\bBearer\s+\S+/giu, "Bearer [REDACTED]") + .replace(/([?&](?:token|key|secret|code)=)[^&\s]+/giu, "$1[REDACTED]"); +} + +export function writePackagedStartupFailureDiagnostics( + diagnosticsDirectory: string, + details: string, +): string { + const resolvedDirectory = resolve(diagnosticsDirectory); + mkdirSync(resolvedDirectory, { recursive: true }); + const diagnosticPath = join(resolvedDirectory, "packaged-startup-failure.txt"); + writeFileSync(diagnosticPath, `${redactPackagedStartupDiagnostic(details).slice(-400_000)}\n`, { + mode: 0o600, + }); + return diagnosticPath; +} + interface PackagedStartupProofWaitOptions { readonly timeoutMs: number; readonly hasProof: () => boolean; @@ -803,8 +1038,9 @@ export function resolveNativePackagedDesktopPlatform( return null; } -export async function verifyPackagedDesktopStartup( +async function verifyPackagedDesktopPayload( options: PackagedDesktopStartupOptions, + expectedAssetName: string, ): Promise { const nativePlatform = resolveNativePackagedDesktopPlatform(process.platform); if (nativePlatform !== options.platform) { @@ -813,18 +1049,29 @@ export async function verifyPackagedDesktopStartup( ); } - const temporaryRoot = mkdtempSync(join(tmpdir(), `scient-packaged-smoke-${options.platform}-`)); + const temporaryRoot = mkdtempSync( + join( + tmpdir(), + `scient-packaged-smoke-${options.platform}-${expectedAssetName.endsWith(".dmg") ? "dmg" : "primary"}-`, + ), + ); const extractionRoot = join(temporaryRoot, "payload"); mkdirSync(extractionRoot, { recursive: true }); let child: ChildProcess | null = null; + let launch: PackagedDesktopLaunchCommand | null = null; let environment: NodeJS.ProcessEnv | null = null; let logPath: string | null = null; let output = ""; const failures: Array<{ phase: string; error: unknown }> = []; const termination = monitorPackagedStartupTermination(); try { - const launch = prepareLaunch(options, extractionRoot); + const preparationSignal = AbortSignal.any([ + AbortSignal.timeout(options.timeoutMs), + termination.abortSignal, + ]); + launch = await prepareLaunch(options, extractionRoot, expectedAssetName, preparationSignal); + if (preparationSignal.aborted) throw preparationSignal.reason; environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); const launchLogPath = resolvePackagedDesktopLogPath(environment); logPath = launchLogPath; @@ -877,6 +1124,15 @@ export async function verifyPackagedDesktopStartup( failures.push({ phase: "process cleanup failed", error }); } + if (launch?.cleanup) { + try { + await launch.cleanup(); + } catch (error) { + processCleanupFailed = true; + failures.push({ phase: "payload unmount failed", error }); + } + } + const interruptedBy = termination.readSignal(); termination.dispose(); if ( @@ -900,20 +1156,44 @@ export async function verifyPackagedDesktopStartup( try { rmSync(temporaryRoot, { recursive: true, force: true }); } catch (error) { - failures.push({ phase: `temporary-state cleanup failed at ${temporaryRoot}`, error }); + failures.push({ + phase: `temporary-state cleanup failed at ${temporaryRoot}`, + error, + }); } } if (failures.length > 0) { + const details = formatPackagedStartupFailures(failures, output, logTail); + const diagnosticsDirectory = process.env.SCIENT_PACKAGED_STARTUP_DIAGNOSTICS_DIR?.trim(); + if (diagnosticsDirectory) { + try { + writePackagedStartupFailureDiagnostics(diagnosticsDirectory, details); + } catch (error) { + failures.push({ phase: "failure-diagnostic export failed", error }); + } + } throw new Error(formatPackagedStartupFailures(failures, output, logTail), { cause: failures[0]?.error, }); } console.log( - `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, + `Packaged ${options.platform}/${options.arch} startup smoke passed for ${expectedAssetName} from isolated Scient state.`, ); } +export async function verifyPackagedDesktopStartup( + options: PackagedDesktopStartupOptions, +): Promise { + for (const expectedAssetName of expectedPackagedDesktopStartupAssetNames( + options.platform, + options.arch, + options.version, + )) { + await verifyPackagedDesktopPayload(options, expectedAssetName); + } +} + const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; if (invokedPath === fileURLToPath(import.meta.url)) { await verifyPackagedDesktopStartup(parsePackagedDesktopStartupArgs(process.argv.slice(2))); From 02486276e6ecec715ce5a8227382e0fead49c670 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 02:35:09 +0300 Subject: [PATCH 11/30] Close packaged startup review findings --- .github/workflows/release.yml | 3 +- scripts/release-smoke.ts | 7 +- .../verify-packaged-desktop-startup.test.ts | 65 +++++++- scripts/verify-packaged-desktop-startup.ts | 153 +++++++++++++----- 4 files changed, 183 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fd011da..c6b25e6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -387,6 +387,7 @@ jobs: shell: bash env: SCIENT_PACKAGED_STARTUP_DIAGNOSTICS_DIR: ${{ github.workspace }}/packaged-startup-diagnostics + SCIENT_WINDOWS_PUBLISHER_SUBJECT: ${{ vars.SCIENT_WINDOWS_PUBLISHER_SUBJECT }} run: | node scripts/verify-packaged-desktop-startup.ts \ --assets-dir release-publish \ @@ -395,7 +396,7 @@ jobs: --version "${{ needs.preflight.outputs.version }}" \ --commit "${{ github.sha }}" \ --allow-unsigned-windows "${{ needs.preflight.outputs.publish_release != 'true' || needs.preflight.outputs.unsigned_release == 'true' }}" \ - --windows-publisher-subject "${{ vars.SCIENT_WINDOWS_PUBLISHER_SUBJECT }}" + --windows-publisher-subject "$SCIENT_WINDOWS_PUBLISHER_SUBJECT" - name: Upload packaged startup failure diagnostics if: ${{ failure() && matrix.platform != 'linux' }} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 535647b4..730ce95d 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -331,7 +331,12 @@ function verifyReleaseWorkflowSafety(): void { !packagedStartupStep.run.includes("--assets-dir release-publish") || !packagedStartupStep.run.includes('--commit "${{ github.sha }}"') || !packagedStartupStep.run.includes("--allow-unsigned-windows") || - !packagedStartupStep.run.includes("--windows-publisher-subject") + !packagedStartupStep.run.includes( + '--windows-publisher-subject "$SCIENT_WINDOWS_PUBLISHER_SUBJECT"', + ) || + packagedStartupStep.env?.SCIENT_WINDOWS_PUBLISHER_SUBJECT !== + "${{ vars.SCIENT_WINDOWS_PUBLISHER_SUBJECT }}" || + packagedStartupStep.run.includes("${{ vars.SCIENT_WINDOWS_PUBLISHER_SUBJECT }}") ) { throw new Error( "Expected exact macOS and Windows packaged-startup proof after collection and before upload.", diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index c567b824..026dc8d3 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -15,6 +15,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + attachMacDiskImageForInspection, assertPackagedLaunchCommandSafety, assertWindowsReleaseSignatureDetails, createPackagedDesktopSmokeEnvironment, @@ -375,26 +376,66 @@ describe("packaged desktop startup verification", () => { const spawnProcess = vi.fn((_command, _args, options) => { const child = new EventEmitter() as ChildProcess; Object.assign(child, { + exitCode: null, + pid: 42, + signalCode: null, stdout: new EventEmitter(), stderr: new EventEmitter(), }); - options.signal?.addEventListener("abort", () => child.emit("error", options.signal?.reason), { - once: true, - }); return child; }) as unknown as typeof import("node:child_process").spawn; + const terminateProcess = vi.fn(async (child: ChildProcess) => { + Object.assign(child, { exitCode: null, signalCode: "SIGTERM" }); + child.emit("exit", null, "SIGTERM"); + child.emit("close", null, "SIGTERM"); + }); const command = runPackagedPreparationCommand("ditto", ["hung.zip"], { signal: termination.abortSignal, spawnProcess, + terminateProcess, }); source.emit("SIGTERM"); await expect(command).rejects.toThrow("interrupted by SIGTERM"); + expect(terminateProcess).toHaveBeenCalledOnce(); expect(termination.abortSignal.aborted).toBe(true); termination.dispose(); }); + it("attempts to detach a partially mounted DMG after attach fails", async () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + const attachError = new Error("attach interrupted"); + const runCommand = vi.fn(async (command: string, args: ReadonlyArray) => { + calls.push({ command, args }); + if (args[0] === "attach") throw attachError; + return ""; + }) as unknown as typeof runPackagedPreparationCommand; + + await expect( + attachMacDiskImageForInspection( + "/tmp/Scient.dmg", + "/tmp/scient-mount", + new AbortController().signal, + runCommand, + ), + ).rejects.toBe(attachError); + expect(calls).toEqual([ + { + command: "hdiutil", + args: [ + "attach", + "-readonly", + "-nobrowse", + "-mountpoint", + "/tmp/scient-mount", + "/tmp/Scient.dmg", + ], + }, + { command: "hdiutil", args: ["detach", "-force", "/tmp/scient-mount"] }, + ]); + }); + it("rejects startup proof when the process handle closes before the exit event arrives", async () => { let now = 0; await expect( @@ -465,7 +506,7 @@ describe("packaged desktop startup verification", () => { signalCode: null, } as unknown as ChildProcess; const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ - status: 1, + status: 0, })); await expect( terminateProcessTree( @@ -483,6 +524,22 @@ describe("packaged desktop startup verification", () => { expect(runTaskkill.mock.calls.map(([, timeoutMs]) => timeoutMs)).toEqual([5_000]); }); + it("fails Windows cleanup when taskkill fails even if observed targets disappear", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + await expect( + terminateProcessTree(child, { + platform: "win32", + childIsAlive: () => true, + runTaskkill: () => ({ status: 1 }), + waitForTargetsExit: async () => true, + }), + ).rejects.toThrow("lost authoritative tree termination"); + }); + it("waits for recorded Windows backends without signaling reused PIDs after root exit", async () => { const child = { exitCode: null, diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index fe06f846..8c5aca3d 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -150,6 +150,11 @@ export function parsePackagedDesktopStartupArgs( } const PREPARATION_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; +const PREPARATION_CLOSE_TIMEOUT_MS = 2_000; + +function delay(milliseconds: number): Promise { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} export async function runPackagedPreparationCommand( command: string, @@ -158,13 +163,14 @@ export async function runPackagedPreparationCommand( readonly cwd?: string; readonly signal: AbortSignal; readonly spawnProcess?: typeof spawn; + readonly terminateProcess?: (child: ChildProcess) => Promise; }, ): Promise { if (options.signal.aborted) throw options.signal.reason; const child = (options.spawnProcess ?? spawn)(command, [...args], { cwd: options.cwd, + detached: process.platform !== "win32", shell: false, - signal: options.signal, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); @@ -178,28 +184,67 @@ export async function runPackagedPreparationCommand( child.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); }); - await new Promise((resolveCommand, rejectCommand) => { - let settled = false; - const settle = (error?: Error) => { - if (settled) return; - settled = true; - if (error) rejectCommand(error); - else resolveCommand(); - }; - child.once("error", (error) => settle(error)); - child.once("exit", (code, signal) => { - if (code === 0) { - settle(); - return; - } - const detail = [stdout, stderr].filter(Boolean).join("\n").trim(); - settle( - new Error( - `${command} ${args.join(" ")} failed with exit ${code ?? "unknown"}${signal ? ` signal ${signal}` : ""}.${detail ? `\n${detail}` : ""}`, - ), - ); + let spawnError: Error | null = null; + const closed = new Promise<{ + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + }>((resolveClosed) => { + child.once("error", (error) => { + spawnError = error; }); + child.once("close", (code, signal) => { + resolveClosed({ code, signal }); + }); + }); + let abortReason: unknown = null; + let abortCleanup: Promise | null = null; + let resolveAbortStarted!: () => void; + const abortStarted = new Promise((resolveAbort) => { + resolveAbortStarted = resolveAbort; }); + const handleAbort = () => { + abortReason = options.signal.reason ?? new Error(`${command} preparation was aborted.`); + abortCleanup ??= Promise.resolve().then(() => + (options.terminateProcess ?? terminateProcessTree)(child), + ); + resolveAbortStarted(); + }; + options.signal.addEventListener("abort", handleAbort, { once: true }); + if (options.signal.aborted) handleAbort(); + let exitOutcome: { readonly code: number | null; readonly signal: NodeJS.Signals | null } | null = + null; + try { + exitOutcome = await Promise.race([closed, abortStarted.then(() => null)]); + if (abortCleanup) { + try { + await abortCleanup; + } catch (cleanupError) { + throw new AggregateError( + [abortReason, cleanupError], + `${command} preparation was interrupted and cleanup failed.`, + ); + } + exitOutcome = await Promise.race([ + closed, + delay(PREPARATION_CLOSE_TIMEOUT_MS).then(() => { + throw new Error( + `${command} did not close its stdio within ${PREPARATION_CLOSE_TIMEOUT_MS}ms after cleanup.`, + ); + }), + ]); + throw abortReason; + } + exitOutcome ??= await closed; + } finally { + options.signal.removeEventListener("abort", handleAbort); + } + if (spawnError) throw spawnError; + if (exitOutcome.code !== 0) { + const detail = [stdout, stderr].filter(Boolean).join("\n").trim(); + throw new Error( + `${command} ${args.join(" ")} failed with exit ${exitOutcome.code ?? "unknown"}${exitOutcome.signal ? ` signal ${exitOutcome.signal}` : ""}.${detail ? `\n${detail}` : ""}`, + ); + } return stdout.trim(); } @@ -267,6 +312,41 @@ export interface PackagedDesktopLaunchCommand { readonly cleanup?: () => Promise; } +type PackagedPreparationCommandRunner = typeof runPackagedPreparationCommand; + +export async function attachMacDiskImageForInspection( + archive: string, + mountPoint: string, + signal: AbortSignal, + runCommand: PackagedPreparationCommandRunner = runPackagedPreparationCommand, +): Promise<() => Promise> { + try { + await runCommand( + "hdiutil", + ["attach", "-readonly", "-nobrowse", "-mountpoint", mountPoint, archive], + { signal }, + ); + } catch (attachError) { + // An interrupted attach can mount the image before reporting failure. The + // command runner fully reaps the helper before rejection, so this detach + // cannot race a still-running attach. + try { + await runCommand("hdiutil", ["detach", "-force", mountPoint], { + signal: AbortSignal.timeout(30_000), + }); + } catch { + // A detach failure is expected when the image never mounted. Preserve + // the authoritative attach/cancellation error. + } + throw attachError; + } + return async () => { + await runCommand("hdiutil", ["detach", mountPoint], { + signal: AbortSignal.timeout(30_000), + }); + }; +} + export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchCommand): void { const forbiddenArgument = launch.args.find( (argument) => argument === "--no-sandbox" || argument.startsWith("--no-sandbox="), @@ -287,22 +367,12 @@ async function prepareMacLaunch( ): Promise { const archive = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); const isDiskImage = expectedAssetName.endsWith(".dmg"); - if (isDiskImage) { - await runPackagedPreparationCommand( - "hdiutil", - ["attach", "-readonly", "-nobrowse", "-mountpoint", extractionRoot, archive], - { signal }, - ); - } else { - await runPackagedPreparationCommand("ditto", ["-x", "-k", archive, extractionRoot], { signal }); - } const cleanup = isDiskImage - ? async () => { - await runPackagedPreparationCommand("hdiutil", ["detach", extractionRoot], { - signal: AbortSignal.timeout(30_000), - }); - } + ? await attachMacDiskImageForInspection(archive, extractionRoot, signal) : undefined; + if (!isDiskImage) { + await runPackagedPreparationCommand("ditto", ["-x", "-k", archive, extractionRoot], { signal }); + } try { const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); if (appBundles.length !== 1 || appBundles[0] !== "Scient.app") { @@ -809,10 +879,15 @@ export async function terminateProcessTree( timeout: WINDOWS_TASKKILL_TIMEOUT_MS, windowsHide: true, }); - if (await awaitTargetsExit(targets, 5_000)) return; const taskkillDetail = taskkillResult.error ? `could not start (${taskkillResult.error.message})` : `status ${taskkillResult.status ?? "unknown"}`; + if (taskkillResult.error || taskkillResult.status !== 0) { + throw new Error( + `Packaged Windows cleanup lost authoritative tree termination; root ${rootTarget.pid} taskkill ${taskkillDetail}.`, + ); + } + if (await awaitTargetsExit(targets, 5_000)) return; throw new Error( `Packaged process trees survived Windows cleanup; root ${rootTarget.pid} taskkill ${taskkillDetail}.`, ); @@ -1173,9 +1248,9 @@ async function verifyPackagedDesktopPayload( failures.push({ phase: "failure-diagnostic export failed", error }); } } - throw new Error(formatPackagedStartupFailures(failures, output, logTail), { - cause: failures[0]?.error, - }); + throw new Error( + redactPackagedStartupDiagnostic(formatPackagedStartupFailures(failures, output, logTail)), + ); } console.log( `Packaged ${options.platform}/${options.arch} startup smoke passed for ${expectedAssetName} from isolated Scient state.`, From 4e3b03415e4cce0989fb9a0bcd014f1c532e67db Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 02:38:34 +0300 Subject: [PATCH 12/30] Keep startup proof test lint-clean --- scripts/verify-packaged-desktop-startup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 026dc8d3..7cf4af79 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -373,7 +373,7 @@ describe("packaged desktop startup verification", () => { it("cancels a hung preparation command when the smoke receives SIGTERM", async () => { const source = new EventEmitter(); const termination = monitorPackagedStartupTermination(source); - const spawnProcess = vi.fn((_command, _args, options) => { + const spawnProcess = vi.fn((_command, _args, _options) => { const child = new EventEmitter() as ChildProcess; Object.assign(child, { exitCode: null, From 5d71978264b6b36a31196f2ba0fc36751a7e6346 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 02:53:20 +0300 Subject: [PATCH 13/30] fix(release): close packaged startup proof gaps --- .github/workflows/release.yml | 3 +- apps/desktop/src/main.ts | 11 +--- .../src/packagedStartupResponsiveness.test.ts | 52 +++++++++++++++++++ .../src/packagedStartupResponsiveness.ts | 35 +++++++++++++ scripts/build-release-desktop-artifact.sh | 7 +++ scripts/release-smoke.ts | 13 ++++- 6 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/packagedStartupResponsiveness.test.ts create mode 100644 apps/desktop/src/packagedStartupResponsiveness.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04678c17..2761ab23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -311,6 +311,7 @@ jobs: "${{ needs.preflight.outputs.version }}" - name: Build Windows desktop artifact + id: build_windows if: matrix.platform == 'win' shell: bash env: @@ -401,7 +402,7 @@ jobs: --arch "${{ matrix.arch }}" \ --version "${{ needs.preflight.outputs.version }}" \ --commit "${{ github.sha }}" \ - --allow-unsigned-windows "${{ needs.preflight.outputs.publish_release != 'true' || needs.preflight.outputs.unsigned_release == 'true' }}" \ + --allow-unsigned-windows "${{ steps.build_windows.outputs.windows_signed != 'true' }}" \ --windows-publisher-subject "$SCIENT_WINDOWS_PUBLISHER_SUBJECT" - name: Upload packaged startup failure diagnostics diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 18cefd09..0f253000 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -59,6 +59,7 @@ import { NetService } from "@synara/shared/Net"; import { RotatingFileSink } from "@synara/shared/logging"; import { ensureStaticSnapshot, findAsarArchivePath } from "@synara/shared/staticSnapshot"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; +import { waitForPackagedBackendResponsiveness } from "./packagedStartupResponsiveness"; import { resolveBackendNodeArgs } from "./backendNodeOptions"; import { backendProcessContainmentOptions, @@ -3586,15 +3587,7 @@ function createWindow(): BrowserWindow { `new Promise((resolve) => requestAnimationFrame(() => resolve(${PACKAGED_RENDERER_READINESS_EXPRESSION})))`, true, ), - fetch(`${backendHttpUrl}/health`, { - signal: AbortSignal.timeout(5_000), - }).then(async (response) => { - if (!response.ok) return false; - const payload = (await response.json()) as { - startupReady?: unknown; - }; - return payload.startupReady === true; - }), + waitForPackagedBackendResponsiveness(backendHttpUrl).then(() => true), packagedWindowVisibility, ]) .then(([rendererReady, backendReady, windowVisible]) => { diff --git a/apps/desktop/src/packagedStartupResponsiveness.test.ts b/apps/desktop/src/packagedStartupResponsiveness.test.ts new file mode 100644 index 00000000..ac8ed6ad --- /dev/null +++ b/apps/desktop/src/packagedStartupResponsiveness.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test, vi } from "vitest"; + +import { waitForPackagedBackendResponsiveness } from "./packagedStartupResponsiveness"; + +describe("waitForPackagedBackendResponsiveness", () => { + test("keeps polling through a cold backend until semantic readiness", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ startupReady: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ startupReady: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect( + waitForPackagedBackendResponsiveness("http://127.0.0.1:12345", { + fetchImpl, + intervalMs: 0, + timeoutMs: 100, + }), + ).resolves.toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + test("does not treat malformed health payloads as ready", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response("not-json", { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ startupReady: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect( + waitForPackagedBackendResponsiveness("http://127.0.0.1:12345", { + fetchImpl, + intervalMs: 0, + timeoutMs: 100, + }), + ).resolves.toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/packagedStartupResponsiveness.ts b/apps/desktop/src/packagedStartupResponsiveness.ts new file mode 100644 index 00000000..9c9da284 --- /dev/null +++ b/apps/desktop/src/packagedStartupResponsiveness.ts @@ -0,0 +1,35 @@ +// FILE: packagedStartupResponsiveness.ts +// Purpose: Waits for delayed packaged-startup proof to observe a semantically ready backend. +// Layer: Desktop startup utility +// Exports: waitForPackagedBackendResponsiveness + +import { waitForHttpReady } from "./backendReadiness"; + +const PACKAGED_RESPONSIVENESS_TIMEOUT_MS = 30_000; + +export interface PackagedBackendResponsivenessOptions { + readonly fetchImpl?: typeof fetch; + readonly intervalMs?: number; + readonly timeoutMs?: number; +} + +export async function waitForPackagedBackendResponsiveness( + baseUrl: string, + options?: PackagedBackendResponsivenessOptions, +): Promise { + await waitForHttpReady(baseUrl, { + path: "/health", + timeoutMs: options?.timeoutMs ?? PACKAGED_RESPONSIVENESS_TIMEOUT_MS, + ...(options?.intervalMs === undefined ? {} : { intervalMs: options.intervalMs }), + ...(options?.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), + isReady: async (response) => { + if (!response.ok) return false; + try { + const payload = (await response.json()) as { startupReady?: unknown }; + return payload.startupReady === true; + } catch { + return false; + } + }, + }); +} diff --git a/scripts/build-release-desktop-artifact.sh b/scripts/build-release-desktop-artifact.sh index 7978f6ce..cb4d863c 100644 --- a/scripts/build-release-desktop-artifact.sh +++ b/scripts/build-release-desktop-artifact.sh @@ -3,6 +3,7 @@ set -euo pipefail apple_key_path="" +windows_signed="false" cleanup_sensitive_files() { if [[ -n "$apple_key_path" ]]; then rm -f -- "$apple_key_path" @@ -85,9 +86,11 @@ elif [[ "$platform" == "win" ]]; then if has_all "${certificate_values[@]}" && ! has_any "${azure_values[@]}"; then echo "Windows signing enabled (standard Authenticode certificate)." + windows_signed="true" args+=(--signed) elif has_all "${azure_values[@]}" && ! has_any "${certificate_values[@]}"; then echo "Windows signing enabled (Azure Trusted Signing)." + windows_signed="true" args+=(--signed) elif has_any "${certificate_values[@]}" || has_any "${azure_values[@]}"; then if [[ "$PUBLISH_RELEASE" == "true" ]]; then @@ -107,6 +110,10 @@ else echo "Signing disabled for $platform." fi +if [[ "$platform" == "win" && -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'windows_signed=%s\n' "$windows_signed" >> "$GITHUB_OUTPUT" +fi + # Do not retry the whole macOS build: once notarization starts, a broad retry can # create duplicate Apple submissions. Retry individual pre-notarization network # operations at their owning layer instead. diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 730ce95d..c4f30178 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -140,6 +140,7 @@ function verifyReleaseRepositoryPolicy(): void { interface ReleaseWorkflowStep { readonly env?: Record; + readonly id?: string; readonly if?: string; readonly name?: string; readonly run?: string; @@ -330,7 +331,9 @@ function verifyReleaseWorkflowSafety(): void { !packagedStartupStep.run?.includes("node scripts/verify-packaged-desktop-startup.ts") || !packagedStartupStep.run.includes("--assets-dir release-publish") || !packagedStartupStep.run.includes('--commit "${{ github.sha }}"') || - !packagedStartupStep.run.includes("--allow-unsigned-windows") || + !packagedStartupStep.run.includes( + "--allow-unsigned-windows \"${{ steps.build_windows.outputs.windows_signed != 'true' }}\"", + ) || !packagedStartupStep.run.includes( '--windows-publisher-subject "$SCIENT_WINDOWS_PUBLISHER_SUBJECT"', ) || @@ -487,6 +490,9 @@ function verifyReleaseWorkflowSafety(): void { if (windowsBuildStep.if !== "matrix.platform == 'win'") { throw new Error("Expected Windows signing credentials to be gated to Windows builders."); } + if (windowsBuildStep.id !== "build_windows") { + throw new Error("Expected Windows packaging to expose its actual signing mode."); + } assertScopedSigningEnvironment(macBuildStep, appleSigningNames, windowsSigningNames); assertScopedSigningEnvironment(windowsBuildStep, windowsSigningNames, appleSigningNames); assertScopedSigningEnvironment( @@ -659,6 +665,11 @@ function verifyReleaseWorkflowSafety(): void { "Public Windows releases require a standard Authenticode certificate or Azure Trusted Signing.", "Expected public Windows releases to fail closed without signing.", ); + assertContains( + releaseBuildScript, + 'printf \'windows_signed=%s\\n\' "$windows_signed" >> "$GITHUB_OUTPUT"', + "Expected Windows packaging to report whether the produced artifact was signed.", + ); assertContains( workflow, 'node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"\n bun install --lockfile-only --ignore-scripts', From 412c094cba8664039e59db2890df1b41dd3a68bf Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 03:18:25 +0300 Subject: [PATCH 14/30] fix(release): harden packaged startup cleanup --- apps/desktop/src/main.ts | 25 ++- docs/release.md | 4 +- packages/shared/package.json | 4 + .../packagedStartupProcessOwnership.test.ts | 95 +++++++++ .../src/packagedStartupProcessOwnership.ts | 78 ++++++++ scripts/lib/windows-authenticode.ts | 59 ++++++ scripts/stage-whisper-runtime.ts | 50 +---- .../verify-packaged-desktop-startup.test.ts | 76 ++++++- scripts/verify-packaged-desktop-startup.ts | 187 +++++++++++------- 9 files changed, 454 insertions(+), 124 deletions(-) create mode 100644 packages/shared/src/packagedStartupProcessOwnership.test.ts create mode 100644 packages/shared/src/packagedStartupProcessOwnership.ts create mode 100644 scripts/lib/windows-authenticode.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0f253000..430fec77 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,6 +46,7 @@ import { autoUpdater, BaseUpdater, CancellationToken } from "electron-updater"; import type { ContextMenuItem } from "@synara/contracts"; import { makeScientBackendShutdownMessage } from "@synara/shared/backendControl"; +import { recordPackagedStartupOwnedProcess } from "@synara/shared/packagedStartupProcessOwnership"; import { getMacTrafficLightPosition } from "@synara/shared/desktopChrome"; import { SCIENT_APP_NAME, @@ -2858,18 +2859,34 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { } const captureBackendLogs = app.isPackaged && backendLogSink !== null; + const environment: NodeJS.ProcessEnv = { + ...backendEnv(), + ELECTRON_RUN_AS_NODE: "1", + }; + // The verifier-only capability authorizes cleanup of this backend after an + // Electron crash. The desktop writes the ownership record itself; neither + // the backend nor provider subprocesses need to inherit that capability. + delete environment.SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN; const child = ChildProcess.spawn(process.execPath, [...backendNodeArgs(), backendEntry], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. - env: { - ...backendEnv(), - ELECTRON_RUN_AS_NODE: "1", - }, + env: environment, // POSIX force termination targets this dedicated process group. Windows // uses taskkill /T after the same graceful IPC deadline. ...backendProcessContainmentOptions(captureBackendLogs), }); + try { + if (child.pid) { + recordPackagedStartupOwnedProcess(process.env, { + pid: child.pid, + processGroup: process.platform !== "win32", + }); + } + } catch (error) { + void forceTerminateBackendProcessTree(child).catch(() => undefined); + throw error; + } writeDesktopLogHeader( `backend process spawned generation=${generation} pid=${child.pid ?? "unknown"}`, ); diff --git a/docs/release.md b/docs/release.md index 1ee49bd2..3da3ada6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -269,7 +269,9 @@ Authenticode certificate subject (for example, the complete `CN=..., O=..., C=.. value reported by `Get-AuthenticodeSignature`). Before upload, native Windows startup proof requires both the collected NSIS installer and its extracted `Scient.exe` to have valid timestamped signatures from that exact subject. The -check is skipped only by the explicit unsigned early-access publication mode. +check is skipped only when the produced Windows artifact is actually unsigned, +which is permitted for build-only validation or an explicitly authorized +unsigned early-access publication. Signed artifacts are always inspected. ### Option A: standard Authenticode certificate diff --git a/packages/shared/package.json b/packages/shared/package.json index 03056400..f57bcb9d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -96,6 +96,10 @@ "types": "./src/backendControl.ts", "import": "./src/backendControl.ts" }, + "./packagedStartupProcessOwnership": { + "types": "./src/packagedStartupProcessOwnership.ts", + "import": "./src/packagedStartupProcessOwnership.ts" + }, "./conversationEdit": { "types": "./src/conversationEdit.ts", "import": "./src/conversationEdit.ts" diff --git a/packages/shared/src/packagedStartupProcessOwnership.test.ts b/packages/shared/src/packagedStartupProcessOwnership.test.ts new file mode 100644 index 00000000..9f957d16 --- /dev/null +++ b/packages/shared/src/packagedStartupProcessOwnership.test.ts @@ -0,0 +1,95 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + readPackagedStartupOwnedProcesses, + recordPackagedStartupOwnedProcess, + resolvePackagedStartupProcessOwnershipPath, +} from "./packagedStartupProcessOwnership"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("packaged startup process ownership", () => { + it("round-trips only process authority bearing the verifier token", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); + roots.push(root); + const environment = { + SCIENT_HOME: root, + SCIENT_PACKAGED_STARTUP_SMOKE: "1", + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "a".repeat(64), + }; + + recordPackagedStartupOwnedProcess(environment, { pid: 42, processGroup: true }); + + expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ + { schemaVersion: 1, token: "a".repeat(64), pid: 42, processGroup: true }, + ]); + expect( + readPackagedStartupOwnedProcesses({ + ...environment, + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "b".repeat(64), + }), + ).toEqual([]); + }); + + it("ignores malformed, truncated, and duplicated authority records", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); + roots.push(root); + const token = "c".repeat(64); + const environment = { + SCIENT_HOME: root, + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: token, + }; + const path = resolvePackagedStartupProcessOwnershipPath(root); + mkdirSync(join(root, "userdata"), { recursive: true }); + writeFileSync( + path, + [ + JSON.stringify({ schemaVersion: 1, token, pid: 84, processGroup: false }), + JSON.stringify({ schemaVersion: 1, token, pid: 84, processGroup: false }), + JSON.stringify({ schemaVersion: 1, token: "wrong", pid: 126, processGroup: false }), + "{truncated", + ].join("\n"), + ); + + expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ + { schemaVersion: 1, token, pid: 84, processGroup: false }, + ]); + }); + + it("does not write authority outside packaged startup verification", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); + roots.push(root); + + recordPackagedStartupOwnedProcess({ SCIENT_HOME: root }, { pid: 42, processGroup: true }); + + expect(readPackagedStartupOwnedProcesses({ SCIENT_HOME: root })).toEqual([]); + }); + + it("refuses weak cleanup capabilities and invalid process ids", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); + roots.push(root); + const environment = { + SCIENT_HOME: root, + SCIENT_PACKAGED_STARTUP_SMOKE: "1", + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "too-short", + }; + + expect(() => + recordPackagedStartupOwnedProcess(environment, { pid: 42, processGroup: true }), + ).toThrow("cleanup token"); + expect(() => + recordPackagedStartupOwnedProcess( + { ...environment, SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "d".repeat(64) }, + { pid: 0, processGroup: true }, + ), + ).toThrow("positive PID"); + }); +}); diff --git a/packages/shared/src/packagedStartupProcessOwnership.ts b/packages/shared/src/packagedStartupProcessOwnership.ts new file mode 100644 index 00000000..d307306e --- /dev/null +++ b/packages/shared/src/packagedStartupProcessOwnership.ts @@ -0,0 +1,78 @@ +import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export const PACKAGED_STARTUP_PROCESS_OWNERSHIP_FILE = "packaged-startup-processes.ndjson"; + +export interface PackagedStartupOwnedProcess { + readonly schemaVersion: 1; + readonly token: string; + readonly pid: number; + readonly processGroup: boolean; +} + +export function resolvePackagedStartupProcessOwnershipPath(scientHome: string): string { + return join(scientHome, "userdata", PACKAGED_STARTUP_PROCESS_OWNERSHIP_FILE); +} + +export function recordPackagedStartupOwnedProcess( + environment: NodeJS.ProcessEnv, + processDetails: Pick, +): void { + if (environment.SCIENT_PACKAGED_STARTUP_SMOKE !== "1") return; + const scientHome = environment.SCIENT_HOME?.trim(); + const token = environment.SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN?.trim(); + if (!scientHome || !token || token.length < 32) { + throw new Error("Packaged startup process ownership requires isolated home and cleanup token."); + } + if (!Number.isSafeInteger(processDetails.pid) || processDetails.pid <= 0) { + throw new Error("Packaged startup process ownership requires a positive PID."); + } + const path = resolvePackagedStartupProcessOwnershipPath(scientHome); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const record: PackagedStartupOwnedProcess = { + schemaVersion: 1, + token, + ...processDetails, + }; + appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 }); +} + +export function readPackagedStartupOwnedProcesses( + environment: NodeJS.ProcessEnv, +): ReadonlyArray { + const scientHome = environment.SCIENT_HOME?.trim(); + const expectedToken = environment.SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN?.trim(); + if (!scientHome || !expectedToken) return []; + let contents: string; + try { + contents = readFileSync(resolvePackagedStartupProcessOwnershipPath(scientHome), "utf8"); + } catch { + return []; + } + const records: PackagedStartupOwnedProcess[] = []; + for (const line of contents.split("\n")) { + if (!line.trim()) continue; + try { + const value = JSON.parse(line) as Partial; + if ( + value.schemaVersion === 1 && + value.token === expectedToken && + Number.isSafeInteger(value.pid) && + (value.pid ?? 0) > 0 && + typeof value.processGroup === "boolean" + ) { + records.push(value as PackagedStartupOwnedProcess); + } + } catch { + // A crash can truncate the final append. Earlier complete capability + // records remain authoritative and malformed lines grant no authority. + } + } + return records.filter( + (record, index, all) => + all.findIndex( + (candidate) => + candidate.pid === record.pid && candidate.processGroup === record.processGroup, + ) === index, + ); +} diff --git a/scripts/lib/windows-authenticode.ts b/scripts/lib/windows-authenticode.ts new file mode 100644 index 00000000..fa506fb2 --- /dev/null +++ b/scripts/lib/windows-authenticode.ts @@ -0,0 +1,59 @@ +export interface WindowsAuthenticodeSignatureDetails { + readonly signerSubject: string | null; + readonly signerThumbprint: string | null; + readonly status: string; + readonly statusMessage: string; + readonly timestampSubject: string | null; +} + +export const WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES = [ + "function Read-AuthenticodeSignature([string]$Path) {", + " $Signature = Get-AuthenticodeSignature -LiteralPath $Path", + " [pscustomobject]@{", + " status = [string]$Signature.Status", + " statusMessage = [string]$Signature.StatusMessage", + " signerSubject = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Subject }", + " signerThumbprint = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Thumbprint }", + " timestampSubject = if ($null -eq $Signature.TimeStamperCertificate) { $null } else { [string]$Signature.TimeStamperCertificate.Subject }", + " }", + "}", +] as const; + +export function isWindowsAuthenticodeSignatureDetails( + value: unknown, +): value is WindowsAuthenticodeSignatureDetails { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return ( + typeof candidate.status === "string" && + typeof candidate.statusMessage === "string" && + (typeof candidate.signerSubject === "string" || candidate.signerSubject === null) && + (typeof candidate.signerThumbprint === "string" || candidate.signerThumbprint === null) && + (typeof candidate.timestampSubject === "string" || candidate.timestampSubject === null) + ); +} + +function nonEmptySignatureValue(value: string | null, label: string): string { + const normalized = value?.trim(); + if (!normalized) throw new Error(`${label} is missing.`); + return normalized; +} + +export function assertValidTimestampedWindowsAuthenticodeSignature( + signature: WindowsAuthenticodeSignatureDetails, + label: string, +): { readonly signerSubject: string; readonly signerThumbprint: string } { + if (signature.status !== "Valid") { + throw new Error( + `${label} Authenticode signature is not valid (${signature.status}: ${signature.statusMessage}).`, + ); + } + nonEmptySignatureValue(signature.timestampSubject, `${label} timestamp signer`); + return { + signerSubject: nonEmptySignatureValue(signature.signerSubject, `${label} signer subject`), + signerThumbprint: nonEmptySignatureValue( + signature.signerThumbprint, + `${label} signer thumbprint`, + ), + }; +} diff --git a/scripts/stage-whisper-runtime.ts b/scripts/stage-whisper-runtime.ts index b286250f..19cf49e1 100644 --- a/scripts/stage-whisper-runtime.ts +++ b/scripts/stage-whisper-runtime.ts @@ -23,6 +23,14 @@ import { dirname, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveWindowsSystemRoot } from "@synara/shared/windowsProcess"; +import { + assertValidTimestampedWindowsAuthenticodeSignature, + isWindowsAuthenticodeSignatureDetails, + WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES, + type WindowsAuthenticodeSignatureDetails, +} from "./lib/windows-authenticode"; + +export type { WindowsAuthenticodeSignatureDetails } from "./lib/windows-authenticode"; export const WHISPER_CPP_VERSION = "v1.9.1"; @@ -90,14 +98,6 @@ export type WhisperRuntimeFileVerification = | "sha256" | "windows-authenticode-or-sha256"; -export interface WindowsAuthenticodeSignatureDetails { - readonly signerSubject: string | null; - readonly signerThumbprint: string | null; - readonly status: string; - readonly statusMessage: string; - readonly timestampSubject: string | null; -} - export interface WindowsAuthenticodeVerificationDetails { readonly app: WindowsAuthenticodeSignatureDetails; readonly executable: WindowsAuthenticodeSignatureDetails; @@ -232,16 +232,7 @@ export const WINDOWS_AUTHENTICODE_VERIFY_SCRIPT = [ ")", "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'", - "function Read-AuthenticodeSignature([string]$Path) {", - " $Signature = Get-AuthenticodeSignature -LiteralPath $Path", - " [pscustomobject]@{", - " status = [string]$Signature.Status", - " statusMessage = [string]$Signature.StatusMessage", - " signerSubject = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Subject }", - " signerThumbprint = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Thumbprint }", - " timestampSubject = if ($null -eq $Signature.TimeStamperCertificate) { $null } else { [string]$Signature.TimeStamperCertificate.Subject }", - " }", - "}", + ...WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES, "[pscustomobject]@{", " app = Read-AuthenticodeSignature $AppPath", " executable = Read-AuthenticodeSignature $ExecutablePath", @@ -524,20 +515,6 @@ async function verifyMacWhisperSignature(input: { assertMacWhisperSignatureDetails({ appDetails, executableDetails, mode: input.mode }); } -function isWindowsAuthenticodeSignatureDetails( - value: unknown, -): value is WindowsAuthenticodeSignatureDetails { - if (typeof value !== "object" || value === null) return false; - const candidate = value as Partial; - return ( - typeof candidate.status === "string" && - typeof candidate.statusMessage === "string" && - (typeof candidate.signerSubject === "string" || candidate.signerSubject === null) && - (typeof candidate.signerThumbprint === "string" || candidate.signerThumbprint === null) && - (typeof candidate.timestampSubject === "string" || candidate.timestampSubject === null) - ); -} - function nonEmptySignatureValue(value: string | null, label: string): string { const normalized = value?.trim(); if (!normalized) throw new Error(`${label} is missing.`); @@ -552,14 +529,7 @@ export function assertWindowsAuthenticodeSignatureDetails( ["Packaged whisper-server.exe", input.executable], ] as const; for (const [label, signature] of signatures) { - if (signature.status !== "Valid") { - throw new Error( - `${label} Authenticode signature is not valid (${signature.status}: ${signature.statusMessage}).`, - ); - } - nonEmptySignatureValue(signature.signerSubject, `${label} signer subject`); - nonEmptySignatureValue(signature.signerThumbprint, `${label} signer thumbprint`); - nonEmptySignatureValue(signature.timestampSubject, `${label} timestamp signer`); + assertValidTimestampedWindowsAuthenticodeSignature(signature, label); } const appPublisher = nonEmptySignatureValue( input.app.signerSubject, diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 7cf4af79..ca060fac 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -363,7 +363,10 @@ describe("packaged desktop startup verification", () => { await expect(termination.signal).resolves.toBe("SIGTERM"); expect(termination.readSignal()).toBe("SIGTERM"); expect(source.listenerCount("SIGINT")).toBe(1); - expect(source.listenerCount("SIGTERM")).toBe(0); + expect(source.listenerCount("SIGTERM")).toBe(1); + + source.emit("SIGTERM"); + expect(termination.readSignal()).toBe("SIGTERM"); termination.dispose(); expect(source.listenerCount("SIGINT")).toBe(0); @@ -436,6 +439,45 @@ describe("packaged desktop startup verification", () => { ]); }); + it("force-detaches a mounted DMG when ordinary cleanup reports it busy", async () => { + const calls: Array> = []; + const runCommand = vi.fn(async (_command: string, args: ReadonlyArray) => { + calls.push(args); + if (args[0] === "detach" && args[1] !== "-force") throw new Error("resource busy"); + return ""; + }) as unknown as typeof runPackagedPreparationCommand; + + const cleanup = await attachMacDiskImageForInspection( + "/tmp/Scient.dmg", + "/tmp/scient-mount", + new AbortController().signal, + runCommand, + ); + await cleanup(); + + expect(calls).toEqual([ + ["attach", "-readonly", "-nobrowse", "-mountpoint", "/tmp/scient-mount", "/tmp/Scient.dmg"], + ["detach", "/tmp/scient-mount"], + ["detach", "-force", "/tmp/scient-mount"], + ]); + }); + + it("reports both ordinary and forced DMG cleanup failures", async () => { + const runCommand = vi.fn(async (_command: string, args: ReadonlyArray) => { + if (args[0] === "detach") throw new Error(args[1] === "-force" ? "force failed" : "busy"); + return ""; + }) as unknown as typeof runPackagedPreparationCommand; + const cleanup = await attachMacDiskImageForInspection( + "/tmp/Scient.dmg", + "/tmp/scient-mount", + new AbortController().signal, + runCommand, + ); + + await expect(cleanup()).rejects.toThrow("normally or forcibly"); + expect(runCommand).toHaveBeenCalledTimes(3); + }); + it("rejects startup proof when the process handle closes before the exit event arrives", async () => { let now = 0; await expect( @@ -673,7 +715,7 @@ describe("packaged desktop startup verification", () => { }, [84], ), - ).rejects.toThrow("refusing to signal unverified backend PIDs"); + ).rejects.toThrow("survived authoritative"); expect(signals).toEqual([ { pid: 42, signal: "SIGTERM" }, { pid: 42, signal: "SIGKILL" }, @@ -708,7 +750,7 @@ describe("packaged desktop startup verification", () => { expect(exitWaits).toEqual([12_000]); }); - it("fails closed when the POSIX root exits before escalation", async () => { + it("uses spawn-recorded POSIX ownership when the desktop exits before escalation", async () => { const childState: { exitCode: number | null; pid: number; @@ -735,9 +777,33 @@ describe("packaged desktop startup verification", () => { waitForTargetsExit: async () => false, }, [84], + [{ pid: 84, processGroup: true }], ), - ).rejects.toThrow("refusing to signal a potentially reused process group"); - expect(signals).toEqual([{ pid: 42, signal: "SIGTERM" }]); + ).rejects.toThrow("survived authoritative"); + expect(signals).toEqual([ + { pid: 42, signal: "SIGTERM" }, + { pid: 84, signal: "SIGKILL" }, + ]); + }); + + it("terminates a token-recorded Windows backend after the desktop root exits", async () => { + const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; + const runTaskkill = vi.fn(() => ({ status: 0 })); + + await terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + targetIsAlive: () => true, + waitForTargetsExit: async (_targets, timeoutMs) => + timeoutMs === 5_000 && runTaskkill.mock.calls.length > 0, + }, + [84], + [{ pid: 84, processGroup: false }], + ); + + expect(runTaskkill).toHaveBeenCalledWith(84, 5_000); }); it("observes an orphaned POSIX process group without signaling it", async () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 8c5aca3d..d12572d2 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -4,6 +4,7 @@ // Layer: Release verification script import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { chmodSync, mkdirSync, @@ -18,6 +19,14 @@ import { tmpdir } from "node:os"; import { basename, dirname, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; +import { + assertValidTimestampedWindowsAuthenticodeSignature, + isWindowsAuthenticodeSignatureDetails, + WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES, + type WindowsAuthenticodeSignatureDetails, +} from "./lib/windows-authenticode"; +import { readPackagedStartupOwnedProcesses } from "@synara/shared/packagedStartupProcessOwnership"; + export type PackagedDesktopPlatform = "mac" | "win"; export interface PackagedDesktopStartupOptions { @@ -32,7 +41,7 @@ export interface PackagedDesktopStartupOptions { } interface TerminationSignalSource { - once(signal: NodeJS.Signals, listener: () => void): unknown; + on(signal: NodeJS.Signals, listener: () => void): unknown; removeListener(signal: NodeJS.Signals, listener: () => void): unknown; } @@ -58,7 +67,7 @@ export function monitorPackagedStartupTermination(source: TerminationSignalSourc resolveSignal(name); }; listeners.set(name, listener); - source.once(name, listener); + source.on(name, listener); } return { @@ -341,9 +350,22 @@ export async function attachMacDiskImageForInspection( throw attachError; } return async () => { - await runCommand("hdiutil", ["detach", mountPoint], { - signal: AbortSignal.timeout(30_000), - }); + try { + await runCommand("hdiutil", ["detach", mountPoint], { + signal: AbortSignal.timeout(30_000), + }); + } catch (detachError) { + try { + await runCommand("hdiutil", ["detach", "-force", mountPoint], { + signal: AbortSignal.timeout(30_000), + }); + } catch (forcedDetachError) { + throw new AggregateError( + [detachError, forcedDetachError], + `Failed to detach ${mountPoint} normally or forcibly.`, + ); + } + } }; } @@ -471,13 +493,7 @@ export function readWindowsExecutableArchitecture(executable: Uint8Array): strin return null; } -export interface WindowsReleaseSignatureDetails { - readonly status: string; - readonly statusMessage: string; - readonly signerSubject: string | null; - readonly signerThumbprint: string | null; - readonly timestampSubject: string | null; -} +export type WindowsReleaseSignatureDetails = WindowsAuthenticodeSignatureDetails; export function assertWindowsReleaseSignatureDetails( signatures: ReadonlyArray, @@ -485,39 +501,20 @@ export function assertWindowsReleaseSignatureDetails( ): void { for (const [index, signature] of signatures.entries()) { const label = index === 0 ? "Windows installer" : "Extracted Scient executable"; - if (signature.status !== "Valid") { - throw new Error( - `${label} Authenticode signature is not valid (${signature.status}: ${signature.statusMessage}).`, - ); - } - if (signature.signerSubject?.trim() !== expectedPublisherSubject) { + const { signerSubject } = assertValidTimestampedWindowsAuthenticodeSignature(signature, label); + if (signerSubject !== expectedPublisherSubject) { throw new Error( `${label} publisher ${signature.signerSubject ?? "missing"} does not match ${expectedPublisherSubject}.`, ); } - if (!signature.signerThumbprint?.trim()) { - throw new Error(`${label} signer thumbprint is missing.`); - } - if (!signature.timestampSubject?.trim()) { - throw new Error(`${label} timestamp signer is missing.`); - } } } const WINDOWS_RELEASE_SIGNATURE_SCRIPT = [ "param([string]$InstallerPath, [string]$ExecutablePath)", "$ErrorActionPreference = 'Stop'", - "function Read-Signature([string]$Path) {", - " $Signature = Get-AuthenticodeSignature -LiteralPath $Path", - " [pscustomobject]@{", - " status = [string]$Signature.Status", - " statusMessage = [string]$Signature.StatusMessage", - " signerSubject = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Subject }", - " signerThumbprint = if ($null -eq $Signature.SignerCertificate) { $null } else { [string]$Signature.SignerCertificate.Thumbprint }", - " timestampSubject = if ($null -eq $Signature.TimeStamperCertificate) { $null } else { [string]$Signature.TimeStamperCertificate.Subject }", - " }", - "}", - "@((Read-Signature $InstallerPath), (Read-Signature $ExecutablePath)) | ConvertTo-Json -Compress -Depth 4", + ...WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES, + "@((Read-AuthenticodeSignature $InstallerPath), (Read-AuthenticodeSignature $ExecutablePath)) | ConvertTo-Json -Compress -Depth 4", ].join("\r\n"); async function verifyWindowsReleaseSignatures( @@ -558,6 +555,9 @@ async function verifyWindowsReleaseSignatures( if (!Array.isArray(parsed) || parsed.length !== 2) { throw new Error("Windows Authenticode verifier returned invalid release signature details."); } + if (!parsed.every(isWindowsAuthenticodeSignatureDetails)) { + throw new Error("Windows Authenticode verifier returned malformed release signature details."); + } assertWindowsReleaseSignatureDetails(parsed, expectedPublisherSubject); } @@ -667,6 +667,7 @@ export function createPackagedDesktopSmokeEnvironment( SCIENT_HOME: scientHome, SCIENT_DISABLE_SHELL_ENV_SYNC: "1", SCIENT_PACKAGED_STARTUP_SMOKE: "1", + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: randomBytes(32).toString("hex"), SYNARA_DISABLE_AUTO_UPDATE: "1", SYNARA_TELEMETRY_ENABLED: "false", ELECTRON_ENABLE_LOGGING: "1", @@ -820,6 +821,7 @@ export async function terminateProcessTree( child: ChildProcess, dependencies: ProcessTerminationDependencies = {}, additionalProcessIds: ReadonlyArray = [], + recordedOwnedTargets: ReadonlyArray = [], ): Promise { const platform = dependencies.platform ?? process.platform; const childCanStillOwnProcesses = @@ -837,11 +839,19 @@ export async function terminateProcessTree( platform !== "win32" && child.pid && !rootTarget ? { pid: child.pid, processGroup: true } : null; - // Backend PIDs recovered from logs/runtime state are observation-only. They - // prove cleanup completion, but a bare PID is not durable signal authority: - // the OS may reuse it after an unlogged exit. + const ownedTargets = recordedOwnedTargets.filter( + (target, index, allTargets) => + target.pid > 0 && + allTargets.findIndex( + (candidate) => + candidate.pid === target.pid && candidate.processGroup === target.processGroup, + ) === index, + ); + // Backend PIDs recovered from logs/runtime state are observation-only. A + // matching tokened ownership record written synchronously at spawn is the + // separate authority that permits signaling a backend after Electron exits. const observedTargets = additionalProcessIds - .map((pid) => ({ pid, processGroup: platform !== "win32" })) + .map((pid) => ({ pid, processGroup: false })) .filter( (target, index, allTargets) => target.pid > 0 && @@ -850,9 +860,14 @@ export async function terminateProcessTree( const targets = [ ...(rootTarget ? [rootTarget] : []), ...(exitedRootGroupTarget ? [exitedRootGroupTarget] : []), - ...observedTargets.filter( + ...ownedTargets.filter( (target) => target.pid !== (rootTarget?.pid ?? exitedRootGroupTarget?.pid), ), + ...observedTargets.filter( + (target) => + target.pid !== (rootTarget?.pid ?? exitedRootGroupTarget?.pid) && + !ownedTargets.some((owned) => owned.pid === target.pid), + ), ]; if (targets.length === 0) { if (platform === "win32" && child.pid) { @@ -865,54 +880,67 @@ export async function terminateProcessTree( const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; if (!rootTarget) { if (await awaitTargetsExit(targets, 5_000)) return; - throw new Error( - `Recorded process candidates ${targets.map(({ pid }) => pid).join(", ")} remained after their parent exited; refusing to signal unverified PIDs or process groups.`, - ); - } - if (platform === "win32") { - // The live ChildProcess handle establishes root ownership; taskkill /T - // derives descendants from that root. Never signal observation-only PIDs. - const taskkillResult = - dependencies.runTaskkill?.(rootTarget.pid, WINDOWS_TASKKILL_TIMEOUT_MS) ?? - spawnSync("taskkill", ["/pid", String(rootTarget.pid), "/t", "/f"], { - stdio: "ignore", - timeout: WINDOWS_TASKKILL_TIMEOUT_MS, - windowsHide: true, - }); - const taskkillDetail = taskkillResult.error - ? `could not start (${taskkillResult.error.message})` - : `status ${taskkillResult.status ?? "unknown"}`; - if (taskkillResult.error || taskkillResult.status !== 0) { + if (ownedTargets.length === 0) { throw new Error( - `Packaged Windows cleanup lost authoritative tree termination; root ${rootTarget.pid} taskkill ${taskkillDetail}.`, + `Recorded process candidates ${targets.map(({ pid }) => pid).join(", ")} remained after their parent exited; refusing to signal unverified PIDs or process groups.`, ); } + } + if (platform === "win32") { + // A live ChildProcess handle owns the root. Tokened records written by that + // root at backend spawn retain equivalent authority if Electron exits first. + const authoritativeWindowsRoots = [ + ...(rootTarget ? [rootTarget] : []), + ...ownedTargets.filter((target) => target.pid !== rootTarget?.pid), + ]; + const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; + for (const target of authoritativeWindowsRoots) { + if (target !== rootTarget && !targetIsAlive({ ...target, processGroup: false })) continue; + const taskkillResult = + dependencies.runTaskkill?.(target.pid, WINDOWS_TASKKILL_TIMEOUT_MS) ?? + spawnSync("taskkill", ["/pid", String(target.pid), "/t", "/f"], { + stdio: "ignore", + timeout: WINDOWS_TASKKILL_TIMEOUT_MS, + windowsHide: true, + }); + const taskkillDetail = taskkillResult.error + ? `could not start (${taskkillResult.error.message})` + : `status ${taskkillResult.status ?? "unknown"}`; + if (taskkillResult.error || taskkillResult.status !== 0) { + throw new Error( + `Packaged Windows cleanup lost authoritative tree termination; root ${target.pid} taskkill ${taskkillDetail}.`, + ); + } + } if (await awaitTargetsExit(targets, 5_000)) return; throw new Error( - `Packaged process trees survived Windows cleanup; root ${rootTarget.pid} taskkill ${taskkillDetail}.`, + `Packaged process trees survived Windows cleanup; authoritative roots ${authoritativeWindowsRoots.map(({ pid }) => pid).join(", ")}.`, ); } const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; - sendSignal(rootTarget, "SIGTERM"); - // The desktop owns a bounded backend shutdown that can legitimately take up - // to ten seconds. Preserve its supervisor until that bound has elapsed so it - // can terminate the backend's separate process group itself. - if (await awaitTargetsExit(targets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)) return; - const childStillOwnsRoot = + if (rootTarget) { + sendSignal(rootTarget, "SIGTERM"); + // The desktop owns a bounded backend shutdown that can legitimately take + // up to ten seconds. Preserve its supervisor for that complete deadline. + if (await awaitTargetsExit(targets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)) return; + } + const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; + const rootStillOwned = + rootTarget !== null && child.pid === rootTarget.pid && child.exitCode === null && child.signalCode === null && (dependencies.childIsAlive ?? childProcessHandleIsAlive)(child); - if (!childStillOwnsRoot) { - throw new Error( - `Packaged root ${rootTarget.pid} exited before POSIX escalation; refusing to signal a potentially reused process group.`, - ); + const authoritativePosixTargets = [ + ...(rootStillOwned && rootTarget ? [rootTarget] : []), + ...ownedTargets.filter((target) => target.pid !== rootTarget?.pid), + ]; + for (const target of authoritativePosixTargets) { + if (targetIsAlive(target)) sendSignal(target, "SIGKILL"); } - const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; - if (targetIsAlive(rootTarget)) sendSignal(rootTarget, "SIGKILL"); if (await awaitTargetsExit(targets, 2_000)) return; throw new Error( - `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived root-only SIGTERM and SIGKILL; refusing to signal unverified backend PIDs.`, + `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived authoritative SIGTERM and SIGKILL cleanup.`, ); } @@ -1192,7 +1220,18 @@ async function verifyPackagedDesktopPayload( let processCleanupFailed = false; try { if (child) { - await terminateProcessTree(child, {}, readPackagedBackendProcessIds(environment)); + const ownedTargets = environment + ? readPackagedStartupOwnedProcesses(environment).map(({ pid, processGroup }) => ({ + pid, + processGroup, + })) + : []; + await terminateProcessTree( + child, + {}, + readPackagedBackendProcessIds(environment), + ownedTargets, + ); } } catch (error) { processCleanupFailed = true; From 537c46df77b0df89604268c629c00ca9d59c984f Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 03:32:48 +0300 Subject: [PATCH 15/30] fix(release): authenticate startup ownership records --- .../packagedStartupProcessOwnership.test.ts | 58 ++++++++++++++----- .../src/packagedStartupProcessOwnership.ts | 40 ++++++++++--- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/packagedStartupProcessOwnership.test.ts b/packages/shared/src/packagedStartupProcessOwnership.test.ts index 9f957d16..d7d74fde 100644 --- a/packages/shared/src/packagedStartupProcessOwnership.test.ts +++ b/packages/shared/src/packagedStartupProcessOwnership.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,7 +17,7 @@ afterEach(() => { }); describe("packaged startup process ownership", () => { - it("round-trips only process authority bearing the verifier token", () => { + it("round-trips authenticated process authority without persisting the verifier token", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); roots.push(root); const environment = { @@ -26,11 +26,22 @@ describe("packaged startup process ownership", () => { SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "a".repeat(64), }; - recordPackagedStartupOwnedProcess(environment, { pid: 42, processGroup: true }); + recordPackagedStartupOwnedProcess(environment, { + pid: 42, + processGroup: true, + }); expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ - { schemaVersion: 1, token: "a".repeat(64), pid: 42, processGroup: true }, + { + schemaVersion: 2, + pid: 42, + processGroup: true, + authenticator: expect.stringMatching(/^[0-9a-f]{64}$/), + }, ]); + expect(readFileSync(resolvePackagedStartupProcessOwnershipPath(root), "utf8")).not.toContain( + "a".repeat(64), + ); expect( readPackagedStartupOwnedProcesses({ ...environment, @@ -42,25 +53,38 @@ describe("packaged startup process ownership", () => { it("ignores malformed, truncated, and duplicated authority records", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); roots.push(root); - const token = "c".repeat(64); const environment = { SCIENT_HOME: root, - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: token, + SCIENT_PACKAGED_STARTUP_SMOKE: "1", + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "c".repeat(64), }; + recordPackagedStartupOwnedProcess(environment, { + pid: 84, + processGroup: false, + }); const path = resolvePackagedStartupProcessOwnershipPath(root); - mkdirSync(join(root, "userdata"), { recursive: true }); - writeFileSync( + const validRecord = readFileSync(path, "utf8").trim(); + appendFileSync( path, [ - JSON.stringify({ schemaVersion: 1, token, pid: 84, processGroup: false }), - JSON.stringify({ schemaVersion: 1, token, pid: 84, processGroup: false }), - JSON.stringify({ schemaVersion: 1, token: "wrong", pid: 126, processGroup: false }), + validRecord, + JSON.stringify({ + schemaVersion: 2, + pid: 126, + processGroup: false, + authenticator: "0".repeat(64), + }), "{truncated", ].join("\n"), ); expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ - { schemaVersion: 1, token, pid: 84, processGroup: false }, + { + schemaVersion: 2, + pid: 84, + processGroup: false, + authenticator: expect.stringMatching(/^[0-9a-f]{64}$/), + }, ]); }); @@ -83,11 +107,17 @@ describe("packaged startup process ownership", () => { }; expect(() => - recordPackagedStartupOwnedProcess(environment, { pid: 42, processGroup: true }), + recordPackagedStartupOwnedProcess(environment, { + pid: 42, + processGroup: true, + }), ).toThrow("cleanup token"); expect(() => recordPackagedStartupOwnedProcess( - { ...environment, SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "d".repeat(64) }, + { + ...environment, + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "d".repeat(64), + }, { pid: 0, processGroup: true }, ), ).toThrow("positive PID"); diff --git a/packages/shared/src/packagedStartupProcessOwnership.ts b/packages/shared/src/packagedStartupProcessOwnership.ts index d307306e..b6c2b420 100644 --- a/packages/shared/src/packagedStartupProcessOwnership.ts +++ b/packages/shared/src/packagedStartupProcessOwnership.ts @@ -1,13 +1,28 @@ import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; +import { createHmac, timingSafeEqual } from "node:crypto"; import { dirname, join } from "node:path"; export const PACKAGED_STARTUP_PROCESS_OWNERSHIP_FILE = "packaged-startup-processes.ndjson"; export interface PackagedStartupOwnedProcess { - readonly schemaVersion: 1; - readonly token: string; + readonly schemaVersion: 2; readonly pid: number; readonly processGroup: boolean; + readonly authenticator: string; +} + +function processOwnershipAuthenticator( + token: string, + processDetails: Pick, +): string { + return createHmac("sha256", token) + .update(`2\n${processDetails.pid}\n${processDetails.processGroup ? "1" : "0"}`) + .digest("hex"); +} + +function authenticatorMatches(expected: string, actual: unknown): boolean { + if (typeof actual !== "string" || !/^[0-9a-f]{64}$/i.test(actual)) return false; + return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(actual, "hex")); } export function resolvePackagedStartupProcessOwnershipPath(scientHome: string): string { @@ -30,11 +45,14 @@ export function recordPackagedStartupOwnedProcess( const path = resolvePackagedStartupProcessOwnershipPath(scientHome); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const record: PackagedStartupOwnedProcess = { - schemaVersion: 1, - token, + schemaVersion: 2, ...processDetails, + authenticator: processOwnershipAuthenticator(token, processDetails), }; - appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 }); + appendFileSync(path, `${JSON.stringify(record)}\n`, { + encoding: "utf8", + mode: 0o600, + }); } export function readPackagedStartupOwnedProcesses( @@ -55,11 +73,17 @@ export function readPackagedStartupOwnedProcesses( try { const value = JSON.parse(line) as Partial; if ( - value.schemaVersion === 1 && - value.token === expectedToken && + value.schemaVersion === 2 && Number.isSafeInteger(value.pid) && (value.pid ?? 0) > 0 && - typeof value.processGroup === "boolean" + typeof value.processGroup === "boolean" && + authenticatorMatches( + processOwnershipAuthenticator(expectedToken, { + pid: value.pid!, + processGroup: value.processGroup, + }), + value.authenticator, + ) ) { records.push(value as PackagedStartupOwnedProcess); } From 9e14465b9f73ddc5e02f6251a4177907143f6a62 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 03:42:04 +0300 Subject: [PATCH 16/30] fix(release): bind cleanup to process instances --- apps/desktop/src/backendProcessTree.test.ts | 4 ++ apps/desktop/src/backendProcessTree.ts | 3 +- apps/desktop/src/main.ts | 32 +++++++--- .../packagedStartupProcessOwnership.test.ts | 46 +++++++++++++- .../src/packagedStartupProcessOwnership.ts | 51 +++++++++++++-- .../verify-packaged-desktop-startup.test.ts | 53 ++++++++++++---- scripts/verify-packaged-desktop-startup.ts | 63 +++++++++++-------- 7 files changed, 196 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/backendProcessTree.test.ts b/apps/desktop/src/backendProcessTree.test.ts index c674f0cf..8e5461e5 100644 --- a/apps/desktop/src/backendProcessTree.test.ts +++ b/apps/desktop/src/backendProcessTree.test.ts @@ -21,6 +21,10 @@ describe("forceTerminateBackendProcessTree", () => { detached: false, stdio: ["ignore", "inherit", "inherit", "ipc"], }); + expect(backendProcessContainmentOptions(false, "darwin", false)).toEqual({ + detached: false, + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); }); it("kills the detached POSIX process group", async () => { diff --git a/apps/desktop/src/backendProcessTree.ts b/apps/desktop/src/backendProcessTree.ts index 73b062e7..a2f190ae 100644 --- a/apps/desktop/src/backendProcessTree.ts +++ b/apps/desktop/src/backendProcessTree.ts @@ -14,9 +14,10 @@ export interface ForceTerminateBackendProcessTreeOptions { export function backendProcessContainmentOptions( captureLogs: boolean, platform: NodeJS.Platform = process.platform, + isolatePosixProcessGroup = true, ): Pick { return { - detached: platform !== "win32", + detached: platform !== "win32" && isolatePosixProcessGroup, stdio: captureLogs ? ["ignore", "pipe", "pipe", "ipc"] : ["ignore", "inherit", "inherit", "ipc"], diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 430fec77..afcfdc26 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,7 +46,10 @@ import { autoUpdater, BaseUpdater, CancellationToken } from "electron-updater"; import type { ContextMenuItem } from "@synara/contracts"; import { makeScientBackendShutdownMessage } from "@synara/shared/backendControl"; -import { recordPackagedStartupOwnedProcess } from "@synara/shared/packagedStartupProcessOwnership"; +import { + readWindowsProcessInstanceId, + recordPackagedStartupOwnedProcess, +} from "@synara/shared/packagedStartupProcessOwnership"; import { getMacTrafficLightPosition } from "@synara/shared/desktopChrome"; import { SCIENT_APP_NAME, @@ -2863,24 +2866,35 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { ...backendEnv(), ELECTRON_RUN_AS_NODE: "1", }; - // The verifier-only capability authorizes cleanup of this backend after an - // Electron crash. The desktop writes the ownership record itself; neither - // the backend nor provider subprocesses need to inherit that capability. + // Windows records an authenticated process-creation identity so the verifier + // can reap this backend after an Electron crash without trusting a reused PID. + // POSIX smoke backends inherit the verifier-created Electron process group. + // Neither backend nor provider subprocesses inherit the verifier capability. delete environment.SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN; const child = ChildProcess.spawn(process.execPath, [...backendNodeArgs(), backendEntry], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. env: environment, - // POSIX force termination targets this dedicated process group. Windows - // uses taskkill /T after the same graceful IPC deadline. - ...backendProcessContainmentOptions(captureBackendLogs), + // Normal POSIX launches retain their dedicated backend process group. The + // packaged smoke keeps the backend in Electron's verifier-owned group. + // Windows uses taskkill /T after the same graceful IPC deadline. + ...backendProcessContainmentOptions( + captureBackendLogs, + process.platform, + process.env.SCIENT_PACKAGED_STARTUP_SMOKE !== "1", + ), }); try { - if (child.pid) { + if (child.pid && process.platform === "win32") { + const instanceId = readWindowsProcessInstanceId(child.pid, process.env); + if (!instanceId) { + throw new Error("Could not establish the packaged backend process instance identity."); + } recordPackagedStartupOwnedProcess(process.env, { pid: child.pid, - processGroup: process.platform !== "win32", + processGroup: false, + instanceId, }); } } catch (error) { diff --git a/packages/shared/src/packagedStartupProcessOwnership.test.ts b/packages/shared/src/packagedStartupProcessOwnership.test.ts index d7d74fde..b4d2cdb0 100644 --- a/packages/shared/src/packagedStartupProcessOwnership.test.ts +++ b/packages/shared/src/packagedStartupProcessOwnership.test.ts @@ -2,10 +2,11 @@ import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { readPackagedStartupOwnedProcesses, + readWindowsProcessInstanceId, recordPackagedStartupOwnedProcess, resolvePackagedStartupProcessOwnershipPath, } from "./packagedStartupProcessOwnership"; @@ -29,6 +30,7 @@ describe("packaged startup process ownership", () => { recordPackagedStartupOwnedProcess(environment, { pid: 42, processGroup: true, + instanceId: "638891234567890123", }); expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ @@ -36,6 +38,7 @@ describe("packaged startup process ownership", () => { schemaVersion: 2, pid: 42, processGroup: true, + instanceId: "638891234567890123", authenticator: expect.stringMatching(/^[0-9a-f]{64}$/), }, ]); @@ -61,6 +64,7 @@ describe("packaged startup process ownership", () => { recordPackagedStartupOwnedProcess(environment, { pid: 84, processGroup: false, + instanceId: "638891234567890456", }); const path = resolvePackagedStartupProcessOwnershipPath(root); const validRecord = readFileSync(path, "utf8").trim(); @@ -72,6 +76,7 @@ describe("packaged startup process ownership", () => { schemaVersion: 2, pid: 126, processGroup: false, + instanceId: "638891234567890789", authenticator: "0".repeat(64), }), "{truncated", @@ -83,6 +88,7 @@ describe("packaged startup process ownership", () => { schemaVersion: 2, pid: 84, processGroup: false, + instanceId: "638891234567890456", authenticator: expect.stringMatching(/^[0-9a-f]{64}$/), }, ]); @@ -92,7 +98,10 @@ describe("packaged startup process ownership", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); roots.push(root); - recordPackagedStartupOwnedProcess({ SCIENT_HOME: root }, { pid: 42, processGroup: true }); + recordPackagedStartupOwnedProcess( + { SCIENT_HOME: root }, + { pid: 42, processGroup: true, instanceId: "638891234567890123" }, + ); expect(readPackagedStartupOwnedProcesses({ SCIENT_HOME: root })).toEqual([]); }); @@ -110,6 +119,7 @@ describe("packaged startup process ownership", () => { recordPackagedStartupOwnedProcess(environment, { pid: 42, processGroup: true, + instanceId: "638891234567890123", }), ).toThrow("cleanup token"); expect(() => @@ -118,8 +128,38 @@ describe("packaged startup process ownership", () => { ...environment, SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "d".repeat(64), }, - { pid: 0, processGroup: true }, + { pid: 0, processGroup: true, instanceId: "638891234567890123" }, ), ).toThrow("positive PID"); + expect(() => + recordPackagedStartupOwnedProcess( + { + ...environment, + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "d".repeat(64), + }, + { pid: 42, processGroup: false, instanceId: "not-a-process-instance" }, + ), + ).toThrow("process instance id"); + }); + + it("reads the Windows process creation identity without invoking a shell", () => { + const runProcess = vi.fn(() => ({ + error: undefined, + status: 0, + stdout: "638891234567890123\r\n", + })); + + expect( + readWindowsProcessInstanceId( + 42, + { SystemRoot: "D:\\Windows" }, + runProcess as unknown as typeof import("node:child_process").spawnSync, + ), + ).toBe("638891234567890123"); + expect(runProcess).toHaveBeenCalledWith( + "D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + expect.arrayContaining(["-NonInteractive", "-Command"]), + expect.objectContaining({ shell: false, timeout: 5_000, windowsHide: true }), + ); }); }); diff --git a/packages/shared/src/packagedStartupProcessOwnership.ts b/packages/shared/src/packagedStartupProcessOwnership.ts index b6c2b420..4470c992 100644 --- a/packages/shared/src/packagedStartupProcessOwnership.ts +++ b/packages/shared/src/packagedStartupProcessOwnership.ts @@ -1,6 +1,9 @@ +import { spawnSync } from "node:child_process"; import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; import { createHmac, timingSafeEqual } from "node:crypto"; -import { dirname, join } from "node:path"; +import { dirname, join, win32 } from "node:path"; + +import { resolveWindowsSystemRoot } from "./windowsProcess"; export const PACKAGED_STARTUP_PROCESS_OWNERSHIP_FILE = "packaged-startup-processes.ndjson"; @@ -8,15 +11,18 @@ export interface PackagedStartupOwnedProcess { readonly schemaVersion: 2; readonly pid: number; readonly processGroup: boolean; + readonly instanceId: string; readonly authenticator: string; } function processOwnershipAuthenticator( token: string, - processDetails: Pick, + processDetails: Pick, ): string { return createHmac("sha256", token) - .update(`2\n${processDetails.pid}\n${processDetails.processGroup ? "1" : "0"}`) + .update( + `2\n${processDetails.pid}\n${processDetails.processGroup ? "1" : "0"}\n${processDetails.instanceId}`, + ) .digest("hex"); } @@ -29,9 +35,40 @@ export function resolvePackagedStartupProcessOwnershipPath(scientHome: string): return join(scientHome, "userdata", PACKAGED_STARTUP_PROCESS_OWNERSHIP_FILE); } +export function readWindowsProcessInstanceId( + pid: number, + environment: NodeJS.ProcessEnv = process.env, + runProcess: typeof spawnSync = spawnSync, +): string | null { + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + const powershell = win32.join( + resolveWindowsSystemRoot(environment), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const script = + `$ErrorActionPreference = 'Stop'; ` + + `((Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks).ToString([Globalization.CultureInfo]::InvariantCulture)`; + const result = runProcess( + powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], + { + encoding: "utf8", + shell: false, + timeout: 5_000, + windowsHide: true, + }, + ); + if (result.error || result.status !== 0 || typeof result.stdout !== "string") return null; + const instanceId = result.stdout.trim(); + return /^\d{10,32}$/.test(instanceId) ? instanceId : null; +} + export function recordPackagedStartupOwnedProcess( environment: NodeJS.ProcessEnv, - processDetails: Pick, + processDetails: Pick, ): void { if (environment.SCIENT_PACKAGED_STARTUP_SMOKE !== "1") return; const scientHome = environment.SCIENT_HOME?.trim(); @@ -42,6 +79,9 @@ export function recordPackagedStartupOwnedProcess( if (!Number.isSafeInteger(processDetails.pid) || processDetails.pid <= 0) { throw new Error("Packaged startup process ownership requires a positive PID."); } + if (!/^\d{10,32}$/.test(processDetails.instanceId)) { + throw new Error("Packaged startup process ownership requires a process instance id."); + } const path = resolvePackagedStartupProcessOwnershipPath(scientHome); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const record: PackagedStartupOwnedProcess = { @@ -77,10 +117,13 @@ export function readPackagedStartupOwnedProcesses( Number.isSafeInteger(value.pid) && (value.pid ?? 0) > 0 && typeof value.processGroup === "boolean" && + typeof value.instanceId === "string" && + /^\d{10,32}$/.test(value.instanceId) && authenticatorMatches( processOwnershipAuthenticator(expectedToken, { pid: value.pid!, processGroup: value.processGroup, + instanceId: value.instanceId, }), value.authenticator, ) diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index ca060fac..d35bcf11 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -750,7 +750,7 @@ describe("packaged desktop startup verification", () => { expect(exitWaits).toEqual([12_000]); }); - it("uses spawn-recorded POSIX ownership when the desktop exits before escalation", async () => { + it("keeps the verifier-created POSIX group authoritative when its leader exits", async () => { const childState: { exitCode: number | null; pid: number; @@ -782,6 +782,7 @@ describe("packaged desktop startup verification", () => { ).rejects.toThrow("survived authoritative"); expect(signals).toEqual([ { pid: 42, signal: "SIGTERM" }, + { pid: 42, signal: "SIGKILL" }, { pid: 84, signal: "SIGKILL" }, ]); }); @@ -794,19 +795,20 @@ describe("packaged desktop startup verification", () => { child, { platform: "win32", + readWindowsProcessInstanceId: () => "638891234567890123", runTaskkill, targetIsAlive: () => true, waitForTargetsExit: async (_targets, timeoutMs) => timeoutMs === 5_000 && runTaskkill.mock.calls.length > 0, }, [84], - [{ pid: 84, processGroup: false }], + [{ pid: 84, processGroup: false, instanceId: "638891234567890123" }], ); expect(runTaskkill).toHaveBeenCalledWith(84, 5_000); }); - it("observes an orphaned POSIX process group without signaling it", async () => { + it("reaps an orphaned verifier-created POSIX process group", async () => { const child = { exitCode: 0, pid: 42, @@ -815,18 +817,43 @@ describe("packaged desktop startup verification", () => { const observed: Array> = []; const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + await terminateProcessTree(child, { + platform: "darwin", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + targetIsAlive: () => true, + waitForTargetsExit: async (targets, timeoutMs) => { + observed.push(targets); + return timeoutMs === 2_000; + }, + }); + expect(observed).toEqual([ + [{ pid: 42, processGroup: true }], + [{ pid: 42, processGroup: true }], + ]); + expect(signals).toEqual([ + { pid: 42, signal: "SIGTERM" }, + { pid: 42, signal: "SIGKILL" }, + ]); + }); + + it("refuses a Windows backend record whose process instance was reused", async () => { + const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; + const runTaskkill = vi.fn(() => ({ status: 0 })); + await expect( - terminateProcessTree(child, { - platform: "darwin", - sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), - waitForTargetsExit: async (targets) => { - observed.push(targets); - return false; + terminateProcessTree( + child, + { + platform: "win32", + readWindowsProcessInstanceId: () => "638891234567890999", + runTaskkill, + waitForTargetsExit: async () => false, }, - }), - ).rejects.toThrow("refusing to signal unverified PIDs or process groups"); - expect(observed).toEqual([[{ pid: 42, processGroup: true }]]); - expect(signals).toEqual([]); + [84], + [{ pid: 84, processGroup: false, instanceId: "638891234567890123" }], + ), + ).rejects.toThrow("survived Windows cleanup"); + expect(runTaskkill).not.toHaveBeenCalled(); }); it("fails closed without signaling a recorded PID when the root is already gone", async () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index d12572d2..a0924b76 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -25,7 +25,10 @@ import { WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES, type WindowsAuthenticodeSignatureDetails, } from "./lib/windows-authenticode"; -import { readPackagedStartupOwnedProcesses } from "@synara/shared/packagedStartupProcessOwnership"; +import { + readPackagedStartupOwnedProcesses, + readWindowsProcessInstanceId, +} from "@synara/shared/packagedStartupProcessOwnership"; export type PackagedDesktopPlatform = "mac" | "win"; @@ -745,6 +748,7 @@ export function resolvePackagedDesktopLogPath(environment: NodeJS.ProcessEnv): s export interface ProcessTerminationTarget { readonly pid: number; readonly processGroup: boolean; + readonly instanceId?: string; } export interface ProcessTerminationDependencies { @@ -757,6 +761,7 @@ export interface ProcessTerminationDependencies { readonly error?: Error; readonly status: number | null; }; + readonly readWindowsProcessInstanceId?: (pid: number) => string | null; readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; readonly targetIsAlive?: (target: ProcessTerminationTarget) => boolean; readonly waitForTargetsExit?: ( @@ -832,9 +837,8 @@ export async function terminateProcessTree( child.pid && childCanStillOwnProcesses ? { pid: child.pid, processGroup: platform !== "win32" } : null; - // A POSIX process group can outlive its leader. Once the ChildProcess reports - // exit we no longer have durable signal authority, but we must still observe - // that original group before declaring cleanup complete and deleting evidence. + // The verifier creates this POSIX process group and keeps authority over it + // while any descendant remains, even after its original leader exits. const exitedRootGroupTarget = platform !== "win32" && child.pid && !rootTarget ? { pid: child.pid, processGroup: true } @@ -848,7 +852,7 @@ export async function terminateProcessTree( ) === index, ); // Backend PIDs recovered from logs/runtime state are observation-only. A - // matching tokened ownership record written synchronously at spawn is the + // matching HMAC record with the Windows process creation identity is the // separate authority that permits signaling a backend after Electron exits. const observedTargets = additionalProcessIds .map((pid) => ({ pid, processGroup: false })) @@ -878,7 +882,7 @@ export async function terminateProcessTree( return; } const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; - if (!rootTarget) { + if (!rootTarget && platform === "win32") { if (await awaitTargetsExit(targets, 5_000)) return; if (ownedTargets.length === 0) { throw new Error( @@ -887,15 +891,24 @@ export async function terminateProcessTree( } } if (platform === "win32") { - // A live ChildProcess handle owns the root. Tokened records written by that - // root at backend spawn retain equivalent authority if Electron exits first. - const authoritativeWindowsRoots = [ + // A live ChildProcess handle owns the root. Authenticated backend records + // retain authority only while their Windows process creation identity matches. + const candidateWindowsRoots = [ ...(rootTarget ? [rootTarget] : []), ...ownedTargets.filter((target) => target.pid !== rootTarget?.pid), ]; - const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; - for (const target of authoritativeWindowsRoots) { - if (target !== rootTarget && !targetIsAlive({ ...target, processGroup: false })) continue; + const authoritativeWindowsRoots: ProcessTerminationTarget[] = []; + const readInstanceId = + dependencies.readWindowsProcessInstanceId ?? + ((pid: number) => readWindowsProcessInstanceId(pid)); + for (const target of candidateWindowsRoots) { + if ( + target !== rootTarget && + (!target.instanceId || readInstanceId(target.pid) !== target.instanceId) + ) { + continue; + } + authoritativeWindowsRoots.push(target); const taskkillResult = dependencies.runTaskkill?.(target.pid, WINDOWS_TASKKILL_TIMEOUT_MS) ?? spawnSync("taskkill", ["/pid", String(target.pid), "/t", "/f"], { @@ -918,22 +931,17 @@ export async function terminateProcessTree( ); } const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; - if (rootTarget) { - sendSignal(rootTarget, "SIGTERM"); + const authoritativeRootGroup = rootTarget ?? exitedRootGroupTarget; + if (authoritativeRootGroup) { + sendSignal(authoritativeRootGroup, "SIGTERM"); // The desktop owns a bounded backend shutdown that can legitimately take // up to ten seconds. Preserve its supervisor for that complete deadline. if (await awaitTargetsExit(targets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)) return; } const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; - const rootStillOwned = - rootTarget !== null && - child.pid === rootTarget.pid && - child.exitCode === null && - child.signalCode === null && - (dependencies.childIsAlive ?? childProcessHandleIsAlive)(child); const authoritativePosixTargets = [ - ...(rootStillOwned && rootTarget ? [rootTarget] : []), - ...ownedTargets.filter((target) => target.pid !== rootTarget?.pid), + ...(authoritativeRootGroup ? [authoritativeRootGroup] : []), + ...ownedTargets.filter((target) => target.pid !== authoritativeRootGroup?.pid), ]; for (const target of authoritativePosixTargets) { if (targetIsAlive(target)) sendSignal(target, "SIGKILL"); @@ -1221,10 +1229,13 @@ async function verifyPackagedDesktopPayload( try { if (child) { const ownedTargets = environment - ? readPackagedStartupOwnedProcesses(environment).map(({ pid, processGroup }) => ({ - pid, - processGroup, - })) + ? readPackagedStartupOwnedProcesses(environment).map( + ({ pid, processGroup, instanceId }) => ({ + pid, + processGroup, + instanceId, + }), + ) : []; await terminateProcessTree( child, From b3bfae9acf2d12fd1cdb16c5daa764fdd4558754 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 03:42:49 +0300 Subject: [PATCH 17/30] fix(release): type cleanup authorities explicitly --- scripts/verify-packaged-desktop-startup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index a0924b76..e1f2ec05 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -833,13 +833,13 @@ export async function terminateProcessTree( (dependencies.childIsAlive ?? childProcessHandleIsAlive)(child) && child.exitCode === null && child.signalCode === null; - const rootTarget = + const rootTarget: ProcessTerminationTarget | null = child.pid && childCanStillOwnProcesses ? { pid: child.pid, processGroup: platform !== "win32" } : null; // The verifier creates this POSIX process group and keeps authority over it // while any descendant remains, even after its original leader exits. - const exitedRootGroupTarget = + const exitedRootGroupTarget: ProcessTerminationTarget | null = platform !== "win32" && child.pid && !rootTarget ? { pid: child.pid, processGroup: true } : null; From 2cca9ae3575330e6f5d9c2ff0868eff1580619f5 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 04:01:29 +0300 Subject: [PATCH 18/30] fix(release): close final startup proof findings --- apps/desktop/src/main.ts | 15 +-- .../packagedStartupProcessOwnership.test.ts | 67 ++++++++++ .../src/packagedStartupProcessOwnership.ts | 21 +++- .../verify-packaged-desktop-startup.test.ts | 118 ++++++++++++++++-- scripts/verify-packaged-desktop-startup.ts | 83 ++++++++---- 5 files changed, 259 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index afcfdc26..43336ecc 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,10 +46,7 @@ import { autoUpdater, BaseUpdater, CancellationToken } from "electron-updater"; import type { ContextMenuItem } from "@synara/contracts"; import { makeScientBackendShutdownMessage } from "@synara/shared/backendControl"; -import { - readWindowsProcessInstanceId, - recordPackagedStartupOwnedProcess, -} from "@synara/shared/packagedStartupProcessOwnership"; +import { recordWindowsPackagedStartupOwnedProcess } from "@synara/shared/packagedStartupProcessOwnership"; import { getMacTrafficLightPosition } from "@synara/shared/desktopChrome"; import { SCIENT_APP_NAME, @@ -2887,15 +2884,7 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { }); try { if (child.pid && process.platform === "win32") { - const instanceId = readWindowsProcessInstanceId(child.pid, process.env); - if (!instanceId) { - throw new Error("Could not establish the packaged backend process instance identity."); - } - recordPackagedStartupOwnedProcess(process.env, { - pid: child.pid, - processGroup: false, - instanceId, - }); + recordWindowsPackagedStartupOwnedProcess(process.env, child.pid); } } catch (error) { void forceTerminateBackendProcessTree(child).catch(() => undefined); diff --git a/packages/shared/src/packagedStartupProcessOwnership.test.ts b/packages/shared/src/packagedStartupProcessOwnership.test.ts index b4d2cdb0..2aa6c9a9 100644 --- a/packages/shared/src/packagedStartupProcessOwnership.test.ts +++ b/packages/shared/src/packagedStartupProcessOwnership.test.ts @@ -8,6 +8,7 @@ import { readPackagedStartupOwnedProcesses, readWindowsProcessInstanceId, recordPackagedStartupOwnedProcess, + recordWindowsPackagedStartupOwnedProcess, resolvePackagedStartupProcessOwnershipPath, } from "./packagedStartupProcessOwnership"; @@ -94,6 +95,36 @@ describe("packaged startup process ownership", () => { ]); }); + it("preserves distinct authenticated process instances when Windows reuses a PID", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); + roots.push(root); + const environment = { + SCIENT_HOME: root, + SCIENT_PACKAGED_STARTUP_SMOKE: "1", + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "e".repeat(64), + }; + recordPackagedStartupOwnedProcess(environment, { + pid: 84, + processGroup: false, + instanceId: "638891234567890456", + }); + recordPackagedStartupOwnedProcess(environment, { + pid: 84, + processGroup: false, + instanceId: "638891234567890789", + }); + + expect( + readPackagedStartupOwnedProcesses(environment).map(({ pid, instanceId }) => ({ + pid, + instanceId, + })), + ).toEqual([ + { pid: 84, instanceId: "638891234567890456" }, + { pid: 84, instanceId: "638891234567890789" }, + ]); + }); + it("does not write authority outside packaged startup verification", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); roots.push(root); @@ -106,6 +137,42 @@ describe("packaged startup process ownership", () => { expect(readPackagedStartupOwnedProcesses({ SCIENT_HOME: root })).toEqual([]); }); + it("does not probe Windows process identity outside packaged startup verification", () => { + const runProcess = vi.fn(); + + expect(() => + recordWindowsPackagedStartupOwnedProcess({ SCIENT_HOME: "/unused" }, 42, runProcess), + ).not.toThrow(); + + expect(runProcess).not.toHaveBeenCalled(); + }); + + it("records the probed Windows process instance during packaged startup verification", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); + roots.push(root); + const environment = { + SCIENT_HOME: root, + SCIENT_PACKAGED_STARTUP_SMOKE: "1", + SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "f".repeat(64), + SystemRoot: "D:\\Windows", + }; + const runProcess = vi.fn(() => ({ + error: undefined, + status: 0, + stdout: "638891234567890999\r\n", + })); + + recordWindowsPackagedStartupOwnedProcess( + environment, + 42, + runProcess as unknown as typeof import("node:child_process").spawnSync, + ); + + expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ + expect.objectContaining({ pid: 42, instanceId: "638891234567890999" }), + ]); + }); + it("refuses weak cleanup capabilities and invalid process ids", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); roots.push(root); diff --git a/packages/shared/src/packagedStartupProcessOwnership.ts b/packages/shared/src/packagedStartupProcessOwnership.ts index 4470c992..48e75134 100644 --- a/packages/shared/src/packagedStartupProcessOwnership.ts +++ b/packages/shared/src/packagedStartupProcessOwnership.ts @@ -66,6 +66,23 @@ export function readWindowsProcessInstanceId( return /^\d{10,32}$/.test(instanceId) ? instanceId : null; } +export function recordWindowsPackagedStartupOwnedProcess( + environment: NodeJS.ProcessEnv, + pid: number, + runProcess: typeof spawnSync = spawnSync, +): void { + if (environment.SCIENT_PACKAGED_STARTUP_SMOKE !== "1") return; + const instanceId = readWindowsProcessInstanceId(pid, environment, runProcess); + if (!instanceId) { + throw new Error("Could not establish the packaged backend process instance identity."); + } + recordPackagedStartupOwnedProcess(environment, { + pid, + processGroup: false, + instanceId, + }); +} + export function recordPackagedStartupOwnedProcess( environment: NodeJS.ProcessEnv, processDetails: Pick, @@ -139,7 +156,9 @@ export function readPackagedStartupOwnedProcesses( (record, index, all) => all.findIndex( (candidate) => - candidate.pid === record.pid && candidate.processGroup === record.processGroup, + candidate.pid === record.pid && + candidate.processGroup === record.processGroup && + candidate.instanceId === record.instanceId, ) === index, ); } diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index d35bcf11..5d6551d7 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -17,6 +17,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { attachMacDiskImageForInspection, assertPackagedLaunchCommandSafety, + assertUnsignedWindowsReleaseSignatureDetails, assertWindowsReleaseSignatureDetails, createPackagedDesktopSmokeEnvironment, expectedPackagedDesktopStartupAssetName, @@ -338,6 +339,35 @@ describe("packaged desktop startup verification", () => { ).toThrow("timestamp signer is missing"); }); + it("requires genuinely unsigned Windows payloads in explicit unsigned mode", () => { + const unsigned = { + status: "NotSigned", + statusMessage: "The file is not digitally signed.", + signerSubject: null, + signerThumbprint: null, + timestampSubject: null, + }; + expect(() => assertUnsignedWindowsReleaseSignatureDetails([unsigned, unsigned])).not.toThrow(); + expect(() => + assertUnsignedWindowsReleaseSignatureDetails([ + { ...unsigned, status: "HashMismatch" }, + unsigned, + ]), + ).toThrow("must be genuinely unsigned"); + expect(() => + assertUnsignedWindowsReleaseSignatureDetails([ + { + ...unsigned, + status: "Valid", + signerSubject: "CN=Other Publisher", + signerThumbprint: "FOREIGN", + timestampSubject: "CN=Timestamp", + }, + unsigned, + ]), + ).toThrow("must be genuinely unsigned"); + }); + it("accepts startup proof only after the process remains alive for the stability window", async () => { let now = 0; await expect( @@ -556,10 +586,13 @@ describe("packaged desktop startup verification", () => { { platform: "win32", childIsAlive: () => true, + readWindowsProcessInstanceId: () => "638891234567890123", runTaskkill, waitForTargetsExit: async () => false, }, [84], + [], + "638891234567890123", ), ).rejects.toThrow("survived Windows cleanup"); expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42]); @@ -573,12 +606,19 @@ describe("packaged desktop startup verification", () => { signalCode: null, } as unknown as ChildProcess; await expect( - terminateProcessTree(child, { - platform: "win32", - childIsAlive: () => true, - runTaskkill: () => ({ status: 1 }), - waitForTargetsExit: async () => true, - }), + terminateProcessTree( + child, + { + platform: "win32", + childIsAlive: () => true, + readWindowsProcessInstanceId: () => "638891234567890123", + runTaskkill: () => ({ status: 1 }), + waitForTargetsExit: async () => true, + }, + [], + [], + "638891234567890123", + ), ).rejects.toThrow("lost authoritative tree termination"); }); @@ -737,7 +777,7 @@ describe("packaged desktop startup verification", () => { platform: "darwin", childIsAlive: () => true, sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), - targetIsAlive: () => false, + targetIsAlive: () => true, waitForTargetsExit: async (_targets, timeoutMs) => { exitWaits.push(timeoutMs); return true; @@ -750,6 +790,24 @@ describe("packaged desktop startup verification", () => { expect(exitWaits).toEqual([12_000]); }); + it("does not signal a verifier-created POSIX group after it is already gone", async () => { + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + + await terminateProcessTree(child, { + platform: "darwin", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + targetIsAlive: () => false, + waitForTargetsExit: async () => true, + }); + + expect(signals).toEqual([]); + }); + it("keeps the verifier-created POSIX group authoritative when its leader exits", async () => { const childState: { exitCode: number | null; @@ -856,6 +914,52 @@ describe("packaged desktop startup verification", () => { expect(runTaskkill).not.toHaveBeenCalled(); }); + it("retains the newest authenticated backend when Windows reuses a PID", async () => { + const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; + const runTaskkill = vi.fn(() => ({ status: 0 })); + + await terminateProcessTree( + child, + { + platform: "win32", + readWindowsProcessInstanceId: () => "638891234567890999", + runTaskkill, + waitForTargetsExit: async (_targets, timeoutMs) => + timeoutMs === 5_000 && runTaskkill.mock.calls.length > 0, + }, + [84], + [ + { pid: 84, processGroup: false, instanceId: "638891234567890123" }, + { pid: 84, processGroup: false, instanceId: "638891234567890999" }, + ], + ); + + expect(runTaskkill).toHaveBeenCalledTimes(1); + expect(runTaskkill).toHaveBeenCalledWith(84, 5_000); + }); + + it("does not taskkill a Windows Electron root after its process instance is reused", async () => { + const child = { exitCode: null, pid: 42, signalCode: null } as unknown as ChildProcess; + const runTaskkill = vi.fn(() => ({ status: 0 })); + + await expect( + terminateProcessTree( + child, + { + platform: "win32", + childIsAlive: () => true, + readWindowsProcessInstanceId: () => "638891234567890999", + runTaskkill, + waitForTargetsExit: async () => false, + }, + [], + [], + "638891234567890123", + ), + ).rejects.toThrow("survived Windows cleanup"); + expect(runTaskkill).not.toHaveBeenCalled(); + }); + it("fails closed without signaling a recorded PID when the root is already gone", async () => { const child = { exitCode: 0, diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index e1f2ec05..37c50f57 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -513,6 +513,19 @@ export function assertWindowsReleaseSignatureDetails( } } +export function assertUnsignedWindowsReleaseSignatureDetails( + signatures: ReadonlyArray, +): void { + for (const [index, signature] of signatures.entries()) { + const label = index === 0 ? "Windows installer" : "Extracted Scient executable"; + if (signature.status !== "NotSigned") { + throw new Error( + `${label} must be genuinely unsigned, not ${signature.status}: ${signature.statusMessage}.`, + ); + } + } +} + const WINDOWS_RELEASE_SIGNATURE_SCRIPT = [ "param([string]$InstallerPath, [string]$ExecutablePath)", "$ErrorActionPreference = 'Stop'", @@ -523,7 +536,7 @@ const WINDOWS_RELEASE_SIGNATURE_SCRIPT = [ async function verifyWindowsReleaseSignatures( installer: string, executable: string, - expectedPublisherSubject: string, + expectedPublisherSubject: string | null, extractionRoot: string, signal: AbortSignal, ): Promise { @@ -561,7 +574,11 @@ async function verifyWindowsReleaseSignatures( if (!parsed.every(isWindowsAuthenticodeSignatureDetails)) { throw new Error("Windows Authenticode verifier returned malformed release signature details."); } - assertWindowsReleaseSignatureDetails(parsed, expectedPublisherSubject); + if (expectedPublisherSubject === null) { + assertUnsignedWindowsReleaseSignatureDetails(parsed); + } else { + assertWindowsReleaseSignatureDetails(parsed, expectedPublisherSubject); + } } async function prepareWindowsLaunch( @@ -605,19 +622,17 @@ async function prepareWindowsLaunch( `Expected exact Windows ${options.arch} executable, found ${executableArchitecture ?? "unknown"}.`, ); } - if (!options.allowUnsignedWindows) { - const expectedPublisherSubject = options.windowsPublisherSubject?.trim(); - if (!expectedPublisherSubject) { - throw new Error("Signed Windows startup proof requires an expected publisher subject."); - } - await verifyWindowsReleaseSignatures( - installer, - executables[0]!, - expectedPublisherSubject, - extractionRoot, - signal, - ); + const expectedPublisherSubject = options.windowsPublisherSubject?.trim() || null; + if (!options.allowUnsignedWindows && !expectedPublisherSubject) { + throw new Error("Signed Windows startup proof requires an expected publisher subject."); } + await verifyWindowsReleaseSignatures( + installer, + executables[0]!, + options.allowUnsignedWindows ? null : expectedPublisherSubject, + extractionRoot, + signal, + ); return { command: executables[0]!, args: [], cwd: dirname(executables[0]!) }; } @@ -827,6 +842,7 @@ export async function terminateProcessTree( dependencies: ProcessTerminationDependencies = {}, additionalProcessIds: ReadonlyArray = [], recordedOwnedTargets: ReadonlyArray = [], + rootProcessInstanceId?: string, ): Promise { const platform = dependencies.platform ?? process.platform; const childCanStillOwnProcesses = @@ -835,7 +851,13 @@ export async function terminateProcessTree( child.signalCode === null; const rootTarget: ProcessTerminationTarget | null = child.pid && childCanStillOwnProcesses - ? { pid: child.pid, processGroup: platform !== "win32" } + ? { + pid: child.pid, + processGroup: platform !== "win32", + ...(platform === "win32" && rootProcessInstanceId + ? { instanceId: rootProcessInstanceId } + : {}), + } : null; // The verifier creates this POSIX process group and keeps authority over it // while any descendant remains, even after its original leader exits. @@ -848,7 +870,9 @@ export async function terminateProcessTree( target.pid > 0 && allTargets.findIndex( (candidate) => - candidate.pid === target.pid && candidate.processGroup === target.processGroup, + candidate.pid === target.pid && + candidate.processGroup === target.processGroup && + candidate.instanceId === target.instanceId, ) === index, ); // Backend PIDs recovered from logs/runtime state are observation-only. A @@ -891,8 +915,9 @@ export async function terminateProcessTree( } } if (platform === "win32") { - // A live ChildProcess handle owns the root. Authenticated backend records - // retain authority only while their Windows process creation identity matches. + // A live ChildProcess handle establishes liveness, while the captured + // process-creation identity prevents numeric-PID reuse before taskkill. + // Authenticated backend records use the same identity boundary. const candidateWindowsRoots = [ ...(rootTarget ? [rootTarget] : []), ...ownedTargets.filter((target) => target.pid !== rootTarget?.pid), @@ -902,10 +927,7 @@ export async function terminateProcessTree( dependencies.readWindowsProcessInstanceId ?? ((pid: number) => readWindowsProcessInstanceId(pid)); for (const target of candidateWindowsRoots) { - if ( - target !== rootTarget && - (!target.instanceId || readInstanceId(target.pid) !== target.instanceId) - ) { + if (!target.instanceId || readInstanceId(target.pid) !== target.instanceId) { continue; } authoritativeWindowsRoots.push(target); @@ -931,14 +953,16 @@ export async function terminateProcessTree( ); } const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; + const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; const authoritativeRootGroup = rootTarget ?? exitedRootGroupTarget; if (authoritativeRootGroup) { - sendSignal(authoritativeRootGroup, "SIGTERM"); + if (targetIsAlive(authoritativeRootGroup)) { + sendSignal(authoritativeRootGroup, "SIGTERM"); + } // The desktop owns a bounded backend shutdown that can legitimately take // up to ten seconds. Preserve its supervisor for that complete deadline. if (await awaitTargetsExit(targets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)) return; } - const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; const authoritativePosixTargets = [ ...(authoritativeRootGroup ? [authoritativeRootGroup] : []), ...ownedTargets.filter((target) => target.pid !== authoritativeRootGroup?.pid), @@ -1172,6 +1196,7 @@ async function verifyPackagedDesktopPayload( let child: ChildProcess | null = null; let launch: PackagedDesktopLaunchCommand | null = null; let environment: NodeJS.ProcessEnv | null = null; + let rootProcessInstanceId: string | null = null; let logPath: string | null = null; let output = ""; const failures: Array<{ phase: string; error: unknown }> = []; @@ -1210,6 +1235,15 @@ async function verifyPackagedDesktopPayload( child.stdout?.on("data", recordOutput); child.stderr?.on("data", recordOutput); + if (process.platform === "win32") { + rootProcessInstanceId = child.pid + ? readWindowsProcessInstanceId(child.pid, environment) + : null; + if (!rootProcessInstanceId) { + throw new Error("Could not establish the packaged Electron process instance identity."); + } + } + await Promise.race([ waitForPackagedStartupProof({ timeoutMs: options.timeoutMs, @@ -1242,6 +1276,7 @@ async function verifyPackagedDesktopPayload( {}, readPackagedBackendProcessIds(environment), ownedTargets, + rootProcessInstanceId ?? undefined, ); } } catch (error) { From 280a16c444840a6b8bae9c7ca1b4b433ccd75825 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 04:29:28 +0300 Subject: [PATCH 19/30] fix(release): contain packaged startup by OS identity --- apps/desktop/src/main.ts | 17 +- docs/release.md | 7 +- packages/shared/package.json | 4 - .../packagedStartupProcessOwnership.test.ts | 232 ------------- .../src/packagedStartupProcessOwnership.ts | 164 --------- .../lib/packaged-startup-posix-sentinel.mjs | 53 +++ scripts/lib/packaged-startup-windows-job.ps1 | 213 ++++++++++++ scripts/release-smoke.ts | 42 +++ .../verify-packaged-desktop-startup.test.ts | 326 ++++++----------- scripts/verify-packaged-desktop-startup.ts | 327 ++++++++++-------- 10 files changed, 609 insertions(+), 776 deletions(-) delete mode 100644 packages/shared/src/packagedStartupProcessOwnership.test.ts delete mode 100644 packages/shared/src/packagedStartupProcessOwnership.ts create mode 100644 scripts/lib/packaged-startup-posix-sentinel.mjs create mode 100644 scripts/lib/packaged-startup-windows-job.ps1 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 43336ecc..de34a5b5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,7 +46,6 @@ import { autoUpdater, BaseUpdater, CancellationToken } from "electron-updater"; import type { ContextMenuItem } from "@synara/contracts"; import { makeScientBackendShutdownMessage } from "@synara/shared/backendControl"; -import { recordWindowsPackagedStartupOwnedProcess } from "@synara/shared/packagedStartupProcessOwnership"; import { getMacTrafficLightPosition } from "@synara/shared/desktopChrome"; import { SCIENT_APP_NAME, @@ -2863,11 +2862,9 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { ...backendEnv(), ELECTRON_RUN_AS_NODE: "1", }; - // Windows records an authenticated process-creation identity so the verifier - // can reap this backend after an Electron crash without trusting a reused PID. - // POSIX smoke backends inherit the verifier-created Electron process group. - // Neither backend nor provider subprocesses inherit the verifier capability. - delete environment.SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN; + // Packaged-smoke descendants remain inside the verifier-owned POSIX process + // group or Windows Job Object. Normal application launches retain their + // existing dedicated backend containment. const child = ChildProcess.spawn(process.execPath, [...backendNodeArgs(), backendEntry], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. @@ -2882,14 +2879,6 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { process.env.SCIENT_PACKAGED_STARTUP_SMOKE !== "1", ), }); - try { - if (child.pid && process.platform === "win32") { - recordWindowsPackagedStartupOwnedProcess(process.env, child.pid); - } - } catch (error) { - void forceTerminateBackendProcessTree(child).catch(() => undefined); - throw error; - } writeDesktopLogHeader( `backend process spawned generation=${generation} pid=${child.pid ?? "unknown"}`, ); diff --git a/docs/release.md b/docs/release.md index 3da3ada6..5cf404a2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -269,9 +269,10 @@ Authenticode certificate subject (for example, the complete `CN=..., O=..., C=.. value reported by `Get-AuthenticodeSignature`). Before upload, native Windows startup proof requires both the collected NSIS installer and its extracted `Scient.exe` to have valid timestamped signatures from that exact subject. The -check is skipped only when the produced Windows artifact is actually unsigned, -which is permitted for build-only validation or an explicitly authorized -unsigned early-access publication. Signed artifacts are always inspected. +same check inspects an explicitly unsigned artifact and requires both files to +report exactly `NotSigned`; invalid, hash-mismatched, or unexpectedly signed +files fail verification. Unsigned output is permitted only for build-only +validation or an explicitly authorized unsigned early-access publication. ### Option A: standard Authenticode certificate diff --git a/packages/shared/package.json b/packages/shared/package.json index f57bcb9d..03056400 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -96,10 +96,6 @@ "types": "./src/backendControl.ts", "import": "./src/backendControl.ts" }, - "./packagedStartupProcessOwnership": { - "types": "./src/packagedStartupProcessOwnership.ts", - "import": "./src/packagedStartupProcessOwnership.ts" - }, "./conversationEdit": { "types": "./src/conversationEdit.ts", "import": "./src/conversationEdit.ts" diff --git a/packages/shared/src/packagedStartupProcessOwnership.test.ts b/packages/shared/src/packagedStartupProcessOwnership.test.ts deleted file mode 100644 index 2aa6c9a9..00000000 --- a/packages/shared/src/packagedStartupProcessOwnership.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { - readPackagedStartupOwnedProcesses, - readWindowsProcessInstanceId, - recordPackagedStartupOwnedProcess, - recordWindowsPackagedStartupOwnedProcess, - resolvePackagedStartupProcessOwnershipPath, -} from "./packagedStartupProcessOwnership"; - -const roots: string[] = []; - -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); -}); - -describe("packaged startup process ownership", () => { - it("round-trips authenticated process authority without persisting the verifier token", () => { - const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); - roots.push(root); - const environment = { - SCIENT_HOME: root, - SCIENT_PACKAGED_STARTUP_SMOKE: "1", - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "a".repeat(64), - }; - - recordPackagedStartupOwnedProcess(environment, { - pid: 42, - processGroup: true, - instanceId: "638891234567890123", - }); - - expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ - { - schemaVersion: 2, - pid: 42, - processGroup: true, - instanceId: "638891234567890123", - authenticator: expect.stringMatching(/^[0-9a-f]{64}$/), - }, - ]); - expect(readFileSync(resolvePackagedStartupProcessOwnershipPath(root), "utf8")).not.toContain( - "a".repeat(64), - ); - expect( - readPackagedStartupOwnedProcesses({ - ...environment, - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "b".repeat(64), - }), - ).toEqual([]); - }); - - it("ignores malformed, truncated, and duplicated authority records", () => { - const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); - roots.push(root); - const environment = { - SCIENT_HOME: root, - SCIENT_PACKAGED_STARTUP_SMOKE: "1", - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "c".repeat(64), - }; - recordPackagedStartupOwnedProcess(environment, { - pid: 84, - processGroup: false, - instanceId: "638891234567890456", - }); - const path = resolvePackagedStartupProcessOwnershipPath(root); - const validRecord = readFileSync(path, "utf8").trim(); - appendFileSync( - path, - [ - validRecord, - JSON.stringify({ - schemaVersion: 2, - pid: 126, - processGroup: false, - instanceId: "638891234567890789", - authenticator: "0".repeat(64), - }), - "{truncated", - ].join("\n"), - ); - - expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ - { - schemaVersion: 2, - pid: 84, - processGroup: false, - instanceId: "638891234567890456", - authenticator: expect.stringMatching(/^[0-9a-f]{64}$/), - }, - ]); - }); - - it("preserves distinct authenticated process instances when Windows reuses a PID", () => { - const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); - roots.push(root); - const environment = { - SCIENT_HOME: root, - SCIENT_PACKAGED_STARTUP_SMOKE: "1", - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "e".repeat(64), - }; - recordPackagedStartupOwnedProcess(environment, { - pid: 84, - processGroup: false, - instanceId: "638891234567890456", - }); - recordPackagedStartupOwnedProcess(environment, { - pid: 84, - processGroup: false, - instanceId: "638891234567890789", - }); - - expect( - readPackagedStartupOwnedProcesses(environment).map(({ pid, instanceId }) => ({ - pid, - instanceId, - })), - ).toEqual([ - { pid: 84, instanceId: "638891234567890456" }, - { pid: 84, instanceId: "638891234567890789" }, - ]); - }); - - it("does not write authority outside packaged startup verification", () => { - const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); - roots.push(root); - - recordPackagedStartupOwnedProcess( - { SCIENT_HOME: root }, - { pid: 42, processGroup: true, instanceId: "638891234567890123" }, - ); - - expect(readPackagedStartupOwnedProcesses({ SCIENT_HOME: root })).toEqual([]); - }); - - it("does not probe Windows process identity outside packaged startup verification", () => { - const runProcess = vi.fn(); - - expect(() => - recordWindowsPackagedStartupOwnedProcess({ SCIENT_HOME: "/unused" }, 42, runProcess), - ).not.toThrow(); - - expect(runProcess).not.toHaveBeenCalled(); - }); - - it("records the probed Windows process instance during packaged startup verification", () => { - const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); - roots.push(root); - const environment = { - SCIENT_HOME: root, - SCIENT_PACKAGED_STARTUP_SMOKE: "1", - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "f".repeat(64), - SystemRoot: "D:\\Windows", - }; - const runProcess = vi.fn(() => ({ - error: undefined, - status: 0, - stdout: "638891234567890999\r\n", - })); - - recordWindowsPackagedStartupOwnedProcess( - environment, - 42, - runProcess as unknown as typeof import("node:child_process").spawnSync, - ); - - expect(readPackagedStartupOwnedProcesses(environment)).toEqual([ - expect.objectContaining({ pid: 42, instanceId: "638891234567890999" }), - ]); - }); - - it("refuses weak cleanup capabilities and invalid process ids", () => { - const root = mkdtempSync(join(tmpdir(), "scient-packaged-ownership-test-")); - roots.push(root); - const environment = { - SCIENT_HOME: root, - SCIENT_PACKAGED_STARTUP_SMOKE: "1", - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "too-short", - }; - - expect(() => - recordPackagedStartupOwnedProcess(environment, { - pid: 42, - processGroup: true, - instanceId: "638891234567890123", - }), - ).toThrow("cleanup token"); - expect(() => - recordPackagedStartupOwnedProcess( - { - ...environment, - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "d".repeat(64), - }, - { pid: 0, processGroup: true, instanceId: "638891234567890123" }, - ), - ).toThrow("positive PID"); - expect(() => - recordPackagedStartupOwnedProcess( - { - ...environment, - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: "d".repeat(64), - }, - { pid: 42, processGroup: false, instanceId: "not-a-process-instance" }, - ), - ).toThrow("process instance id"); - }); - - it("reads the Windows process creation identity without invoking a shell", () => { - const runProcess = vi.fn(() => ({ - error: undefined, - status: 0, - stdout: "638891234567890123\r\n", - })); - - expect( - readWindowsProcessInstanceId( - 42, - { SystemRoot: "D:\\Windows" }, - runProcess as unknown as typeof import("node:child_process").spawnSync, - ), - ).toBe("638891234567890123"); - expect(runProcess).toHaveBeenCalledWith( - "D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - expect.arrayContaining(["-NonInteractive", "-Command"]), - expect.objectContaining({ shell: false, timeout: 5_000, windowsHide: true }), - ); - }); -}); diff --git a/packages/shared/src/packagedStartupProcessOwnership.ts b/packages/shared/src/packagedStartupProcessOwnership.ts deleted file mode 100644 index 48e75134..00000000 --- a/packages/shared/src/packagedStartupProcessOwnership.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; -import { createHmac, timingSafeEqual } from "node:crypto"; -import { dirname, join, win32 } from "node:path"; - -import { resolveWindowsSystemRoot } from "./windowsProcess"; - -export const PACKAGED_STARTUP_PROCESS_OWNERSHIP_FILE = "packaged-startup-processes.ndjson"; - -export interface PackagedStartupOwnedProcess { - readonly schemaVersion: 2; - readonly pid: number; - readonly processGroup: boolean; - readonly instanceId: string; - readonly authenticator: string; -} - -function processOwnershipAuthenticator( - token: string, - processDetails: Pick, -): string { - return createHmac("sha256", token) - .update( - `2\n${processDetails.pid}\n${processDetails.processGroup ? "1" : "0"}\n${processDetails.instanceId}`, - ) - .digest("hex"); -} - -function authenticatorMatches(expected: string, actual: unknown): boolean { - if (typeof actual !== "string" || !/^[0-9a-f]{64}$/i.test(actual)) return false; - return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(actual, "hex")); -} - -export function resolvePackagedStartupProcessOwnershipPath(scientHome: string): string { - return join(scientHome, "userdata", PACKAGED_STARTUP_PROCESS_OWNERSHIP_FILE); -} - -export function readWindowsProcessInstanceId( - pid: number, - environment: NodeJS.ProcessEnv = process.env, - runProcess: typeof spawnSync = spawnSync, -): string | null { - if (!Number.isSafeInteger(pid) || pid <= 0) return null; - const powershell = win32.join( - resolveWindowsSystemRoot(environment), - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); - const script = - `$ErrorActionPreference = 'Stop'; ` + - `((Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks).ToString([Globalization.CultureInfo]::InvariantCulture)`; - const result = runProcess( - powershell, - ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], - { - encoding: "utf8", - shell: false, - timeout: 5_000, - windowsHide: true, - }, - ); - if (result.error || result.status !== 0 || typeof result.stdout !== "string") return null; - const instanceId = result.stdout.trim(); - return /^\d{10,32}$/.test(instanceId) ? instanceId : null; -} - -export function recordWindowsPackagedStartupOwnedProcess( - environment: NodeJS.ProcessEnv, - pid: number, - runProcess: typeof spawnSync = spawnSync, -): void { - if (environment.SCIENT_PACKAGED_STARTUP_SMOKE !== "1") return; - const instanceId = readWindowsProcessInstanceId(pid, environment, runProcess); - if (!instanceId) { - throw new Error("Could not establish the packaged backend process instance identity."); - } - recordPackagedStartupOwnedProcess(environment, { - pid, - processGroup: false, - instanceId, - }); -} - -export function recordPackagedStartupOwnedProcess( - environment: NodeJS.ProcessEnv, - processDetails: Pick, -): void { - if (environment.SCIENT_PACKAGED_STARTUP_SMOKE !== "1") return; - const scientHome = environment.SCIENT_HOME?.trim(); - const token = environment.SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN?.trim(); - if (!scientHome || !token || token.length < 32) { - throw new Error("Packaged startup process ownership requires isolated home and cleanup token."); - } - if (!Number.isSafeInteger(processDetails.pid) || processDetails.pid <= 0) { - throw new Error("Packaged startup process ownership requires a positive PID."); - } - if (!/^\d{10,32}$/.test(processDetails.instanceId)) { - throw new Error("Packaged startup process ownership requires a process instance id."); - } - const path = resolvePackagedStartupProcessOwnershipPath(scientHome); - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - const record: PackagedStartupOwnedProcess = { - schemaVersion: 2, - ...processDetails, - authenticator: processOwnershipAuthenticator(token, processDetails), - }; - appendFileSync(path, `${JSON.stringify(record)}\n`, { - encoding: "utf8", - mode: 0o600, - }); -} - -export function readPackagedStartupOwnedProcesses( - environment: NodeJS.ProcessEnv, -): ReadonlyArray { - const scientHome = environment.SCIENT_HOME?.trim(); - const expectedToken = environment.SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN?.trim(); - if (!scientHome || !expectedToken) return []; - let contents: string; - try { - contents = readFileSync(resolvePackagedStartupProcessOwnershipPath(scientHome), "utf8"); - } catch { - return []; - } - const records: PackagedStartupOwnedProcess[] = []; - for (const line of contents.split("\n")) { - if (!line.trim()) continue; - try { - const value = JSON.parse(line) as Partial; - if ( - value.schemaVersion === 2 && - Number.isSafeInteger(value.pid) && - (value.pid ?? 0) > 0 && - typeof value.processGroup === "boolean" && - typeof value.instanceId === "string" && - /^\d{10,32}$/.test(value.instanceId) && - authenticatorMatches( - processOwnershipAuthenticator(expectedToken, { - pid: value.pid!, - processGroup: value.processGroup, - instanceId: value.instanceId, - }), - value.authenticator, - ) - ) { - records.push(value as PackagedStartupOwnedProcess); - } - } catch { - // A crash can truncate the final append. Earlier complete capability - // records remain authoritative and malformed lines grant no authority. - } - } - return records.filter( - (record, index, all) => - all.findIndex( - (candidate) => - candidate.pid === record.pid && - candidate.processGroup === record.processGroup && - candidate.instanceId === record.instanceId, - ) === index, - ); -} diff --git a/scripts/lib/packaged-startup-posix-sentinel.mjs b/scripts/lib/packaged-startup-posix-sentinel.mjs new file mode 100644 index 00000000..c4793eb0 --- /dev/null +++ b/scripts/lib/packaged-startup-posix-sentinel.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const OUTCOME_FILE = "packaged-native-child-outcome.json"; +let outcomeWritten = false; + +function writeOutcome(value) { + if (outcomeWritten) return; + outcomeWritten = true; + const scientHome = process.env.SCIENT_HOME?.trim(); + if (!scientHome) throw new Error("POSIX packaged startup sentinel requires SCIENT_HOME."); + const path = join(scientHome, OUTCOME_FILE); + const temporaryPath = `${path}.${process.pid}.tmp`; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(temporaryPath, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(temporaryPath, path); +} + +const [command, cwd, serializedArgs] = process.argv.slice(2); +if (!command || !cwd || serializedArgs === undefined) { + throw new Error("POSIX packaged startup sentinel requires command, cwd, and serialized args."); +} +const parsedArgs = JSON.parse(serializedArgs); +if (!Array.isArray(parsedArgs) || !parsedArgs.every((value) => typeof value === "string")) { + throw new Error("POSIX packaged startup sentinel received invalid launch arguments."); +} + +// The verifier makes this sentinel the process-group leader. Ignoring TERM +// keeps the original PGID occupied until the verifier observes the native +// payload outcome and atomically finishes the whole group with SIGKILL. +process.on("SIGTERM", () => undefined); + +const child = spawn(command, parsedArgs, { + cwd, + env: process.env, + detached: false, + stdio: ["ignore", "inherit", "inherit"], +}); + +child.once("error", (error) => { + writeOutcome({ exited: null, launchError: { message: error.message } }); +}); +child.once("exit", (code, signal) => { + writeOutcome({ exited: { code, signal }, launchError: null }); +}); + +// Stay alive even after an early native-payload exit. This retained sentinel +// is the OS-owned identity that makes a later group signal reuse-safe. +setInterval(() => undefined, 60_000); +await new Promise(() => undefined); diff --git a/scripts/lib/packaged-startup-windows-job.ps1 b/scripts/lib/packaged-startup-windows-job.ps1 new file mode 100644 index 00000000..e931d092 --- /dev/null +++ b/scripts/lib/packaged-startup-windows-job.ps1 @@ -0,0 +1,213 @@ +param( + [Parameter(Mandatory = $true)][string]$ExecutablePath, + [Parameter(Mandatory = $true)][string]$WorkingDirectory +) + +$ErrorActionPreference = 'Stop' + +$source = @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +public static class ScientPackagedStartupJobLauncher +{ + private const uint CREATE_SUSPENDED = 0x00000004; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const int JobObjectExtendedLimitInformation = 9; + private const uint INFINITE = 0xFFFFFFFF; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFO + { + public uint cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + IntPtr job, + int informationClass, + IntPtr information, + uint informationLength + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess( + string applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + bool inheritHandles, + uint creationFlags, + IntPtr environment, + string currentDirectory, + ref STARTUPINFO startupInfo, + out PROCESS_INFORMATION processInformation + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess(IntPtr process, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + private static void ThrowLastError(string operation) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), operation); + } + + public static int Run(string executablePath, string workingDirectory) + { + if (executablePath.IndexOf('"') >= 0) + throw new ArgumentException("Executable path contains an invalid quote.", "executablePath"); + + IntPtr job = IntPtr.Zero; + IntPtr information = IntPtr.Zero; + PROCESS_INFORMATION child = new PROCESS_INFORMATION(); + bool childCreated = false; + bool childAssigned = false; + try + { + job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) ThrowLastError("CreateJobObject failed"); + + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = + new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int informationSize = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + information = Marshal.AllocHGlobal(informationSize); + Marshal.StructureToPtr(limits, information, false); + if (!SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + information, + (uint)informationSize + )) ThrowLastError("SetInformationJobObject failed"); + + STARTUPINFO startup = new STARTUPINFO(); + startup.cb = (uint)Marshal.SizeOf(typeof(STARTUPINFO)); + StringBuilder commandLine = new StringBuilder("\"" + executablePath + "\""); + if (!CreateProcess( + executablePath, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + false, + CREATE_SUSPENDED, + IntPtr.Zero, + workingDirectory, + ref startup, + out child + )) ThrowLastError("CreateProcess failed"); + childCreated = true; + + if (!AssignProcessToJobObject(job, child.hProcess)) + ThrowLastError("AssignProcessToJobObject failed"); + childAssigned = true; + if (ResumeThread(child.hThread) == UInt32.MaxValue) + ThrowLastError("ResumeThread failed"); + + WaitForSingleObject(child.hProcess, INFINITE); + uint exitCode; + if (!GetExitCodeProcess(child.hProcess, out exitCode)) + ThrowLastError("GetExitCodeProcess failed"); + return unchecked((int)exitCode); + } + finally + { + if (childCreated && !childAssigned && child.hProcess != IntPtr.Zero) + { + TerminateProcess(child.hProcess, 1); + WaitForSingleObject(child.hProcess, INFINITE); + } + if (child.hThread != IntPtr.Zero) CloseHandle(child.hThread); + if (child.hProcess != IntPtr.Zero) CloseHandle(child.hProcess); + if (information != IntPtr.Zero) Marshal.FreeHGlobal(information); + // Closing the final job handle atomically terminates every process + // that Electron created inside the kill-on-close job. + if (job != IntPtr.Zero) CloseHandle(job); + } + } +} +'@ + +Add-Type -TypeDefinition $source -Language CSharp +exit [ScientPackagedStartupJobLauncher]::Run($ExecutablePath, $WorkingDirectory) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index c4f30178..e5d4746c 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -364,6 +364,19 @@ function verifyReleaseWorkflowSafety(): void { resolve(repoRoot, "scripts/verify-packaged-desktop-startup.ts"), "utf8", ).replaceAll("\r\n", "\n"); + const posixStartupSentinel = readFileSync( + resolve(repoRoot, "scripts/lib/packaged-startup-posix-sentinel.mjs"), + "utf8", + ).replaceAll("\r\n", "\n"); + const windowsStartupJobLauncher = readFileSync( + resolve(repoRoot, "scripts/lib/packaged-startup-windows-job.ps1"), + "utf8", + ).replaceAll("\r\n", "\n"); + execFileSync( + process.execPath, + ["--input-type=module", "--eval", 'import("./scripts/verify-packaged-desktop-startup.ts")'], + { cwd: repoRoot, stdio: "pipe" }, + ); const desktopMain = readFileSync( resolve(repoRoot, "apps/desktop/src/main.ts"), "utf8", @@ -428,6 +441,35 @@ function verifyReleaseWorkflowSafety(): void { "PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST", "Expected packaged startup verification to inherit only explicit host variables.", ); + assertContains( + packagedStartupVerifier, + "spawnContainedPackagedDesktop", + "Expected native startup proof to launch inside verifier-owned OS containment.", + ); + assertNotContains( + packagedStartupVerifier, + "taskkill", + "Packaged startup cleanup must not restore numeric Windows process signaling authority.", + ); + assertContains( + posixStartupSentinel, + 'process.on("SIGTERM", () => undefined)', + "Expected the POSIX sentinel to retain process-group identity through graceful cleanup.", + ); + assertContains( + windowsStartupJobLauncher, + "JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE", + "Expected Windows startup proof to use kill-on-close Job Object containment.", + ); + if ( + windowsStartupJobLauncher.indexOf("AssignProcessToJobObject(job, child.hProcess)") < 0 || + windowsStartupJobLauncher.indexOf("AssignProcessToJobObject(job, child.hProcess)") > + windowsStartupJobLauncher.indexOf("ResumeThread(child.hThread)") + ) { + throw new Error( + "Expected Windows startup proof to assign the suspended Electron process before resume.", + ); + } assertContains( packagedStartupVerifier, 'log.includes("packaged main window visible")', diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 5d6551d7..c5962c03 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -30,11 +30,13 @@ import { readWindowsExecutableArchitecture, readPackagedDesktopLogTail, readPackagedBackendProcessIds, + readPackagedNativeChildOutcome, resolveExactPackagedDesktopStartupAsset, resolveNativePackagedDesktopPlatform, resolvePackagedDesktopLogPath, runPackagedPreparationCommand, sanitizePackagedDesktopInheritedEnvironment, + spawnContainedPackagedDesktop, terminateProcessTree, waitForPackagedStartupProof, writePackagedStartupFailureDiagnostics, @@ -571,102 +573,133 @@ describe("packaged desktop startup verification", () => { expect(readFileSync(path, "utf8")).not.toContain("very-secret"); }); - it("targets a live Windows root once and fails when its complete tree survives", async () => { + it("closes a live Windows Job Object launcher by process handle", async () => { const child = { exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ - status: 0, - })); + const terminateRoot = vi.fn(() => true); await expect( terminateProcessTree( child, { platform: "win32", childIsAlive: () => true, - readWindowsProcessInstanceId: () => "638891234567890123", - runTaskkill, + terminateRoot, waitForTargetsExit: async () => false, }, [84], - [], - "638891234567890123", ), - ).rejects.toThrow("survived Windows cleanup"); - expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42]); - expect(runTaskkill.mock.calls.map(([, timeoutMs]) => timeoutMs)).toEqual([5_000]); + ).rejects.toThrow("survived handle-bound cleanup"); + expect(terminateRoot).toHaveBeenCalledOnce(); + expect(terminateRoot).toHaveBeenCalledWith(child); }); - it("fails Windows cleanup when taskkill fails even if observed targets disappear", async () => { + it("fails closed when the Windows Job Object launcher handle cannot terminate", async () => { const child = { exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; await expect( - terminateProcessTree( - child, - { - platform: "win32", - childIsAlive: () => true, - readWindowsProcessInstanceId: () => "638891234567890123", - runTaskkill: () => ({ status: 1 }), - waitForTargetsExit: async () => true, - }, - [], - [], - "638891234567890123", - ), - ).rejects.toThrow("lost authoritative tree termination"); + terminateProcessTree(child, { + platform: "win32", + childIsAlive: () => true, + terminateRoot: () => false, + waitForTargetsExit: async () => true, + }), + ).rejects.toThrow("could not be terminated by handle"); }); - it("waits for recorded Windows backends without signaling reused PIDs after root exit", async () => { + it("only observes Job Object descendants after the Windows launcher exits", async () => { const child = { - exitCode: null, + exitCode: 0, pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ - status: 0, - })); + const terminateRoot = vi.fn(() => true); await terminateProcessTree( child, { platform: "win32", - childIsAlive: () => false, - runTaskkill, + terminateRoot, waitForTargetsExit: async () => true, }, [84], ); - expect(runTaskkill).not.toHaveBeenCalled(); + expect(terminateRoot).not.toHaveBeenCalled(); }); - it("observes detached Windows backend cleanup after the packaged root exits", async () => { - const child = { - exitCode: 0, - pid: 42, - signalCode: null, - } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ - status: 0, - })); + it("launches Windows suspended into a kill-on-close Job Object and macOS behind a sentinel", () => { + const spawnProcess = vi.fn(() => ({ pid: 42 }) as unknown as ChildProcess); + const launch = { command: "/payload/Scient", args: [], cwd: "/payload" }; - await terminateProcessTree( - child, - { - platform: "win32", - runTaskkill, - waitForTargetsExit: async () => true, - }, - [84], + spawnContainedPackagedDesktop( + launch, + { SystemRoot: "D:\\Windows" }, + "win32", + spawnProcess as unknown as typeof import("node:child_process").spawn, + ); + const windowsCall = ( + spawnProcess.mock.calls as unknown as Array<[string, string[], Record]> + )[0]!; + expect(windowsCall[0]).toBe("D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + expect(windowsCall[1]).toEqual( + expect.arrayContaining([ + "-File", + expect.stringMatching(/packaged-startup-windows-job\.ps1$/), + ]), + ); + expect(windowsCall[2]).toEqual(expect.objectContaining({ detached: false })); + const jobScriptPath = (windowsCall[1] as string[])[ + (windowsCall[1] as string[]).indexOf("-File") + 1 + ]!; + const jobScript = readFileSync(jobScriptPath, "utf8"); + expect(jobScript).toContain("CREATE_SUSPENDED"); + expect(jobScript).toContain("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"); + expect(jobScript.indexOf("AssignProcessToJobObject(job, child.hProcess)")).toBeLessThan( + jobScript.indexOf("ResumeThread(child.hThread)"), ); - expect(runTaskkill).not.toHaveBeenCalled(); + spawnProcess.mockClear(); + spawnContainedPackagedDesktop( + launch, + { SCIENT_HOME: "/isolated" }, + "darwin", + spawnProcess as unknown as typeof import("node:child_process").spawn, + ); + const posixCall = ( + spawnProcess.mock.calls as unknown as Array<[string, string[], Record]> + )[0]!; + expect(posixCall[0]).toBe(process.execPath); + expect(posixCall[1]).toEqual( + expect.arrayContaining([expect.stringMatching(/packaged-startup-posix-sentinel\.mjs$/)]), + ); + expect(posixCall[2]).toEqual(expect.objectContaining({ detached: true })); + }); + + it("reads the POSIX sentinel native-child outcome from isolated state", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-outcome-test-")); + temporaryRoots.push(root); + const environment = { SCIENT_HOME: root }; + + expect(readPackagedNativeChildOutcome(environment)).toEqual({ + exited: null, + launchError: null, + }); + writeFileSync( + join(root, "packaged-native-child-outcome.json"), + JSON.stringify({ exited: { code: 7, signal: null }, launchError: null }), + ); + expect(readPackagedNativeChildOutcome(environment)).toEqual({ + exited: { code: 7, signal: null }, + launchError: null, + }); + writeFileSync(join(root, "packaged-native-child-outcome.json"), "{malformed"); + expect(readPackagedNativeChildOutcome(environment).launchError).toBeInstanceOf(Error); }); it("recovers every spawned backend PID before runtime state is durable", () => { @@ -736,7 +769,7 @@ describe("packaged desktop startup verification", () => { expect(readPackagedBackendProcessIds(env)).toEqual([84]); }); - it("fails when a POSIX process tree survives TERM and KILL", async () => { + it("keeps the POSIX sentinel as group authority through TERM and KILL", async () => { const child = { exitCode: null, pid: 42, @@ -751,6 +784,7 @@ describe("packaged desktop startup verification", () => { childIsAlive: () => true, sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), targetIsAlive: () => true, + waitForPosixPayloadExit: async (timeoutMs) => timeoutMs === 12_000, waitForTargetsExit: async () => false, }, [84], @@ -762,35 +796,45 @@ describe("packaged desktop startup verification", () => { ]); }); - it("does not escalate a POSIX process tree that exited during the TERM grace period", async () => { + it("waits for the native POSIX payload before killing the retained sentinel group", async () => { const child = { exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; - const exitWaits: number[] = []; + const events: string[] = []; await terminateProcessTree( child, { platform: "darwin", childIsAlive: () => true, - sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + sendSignal: (target, signal) => { + events.push(signal); + signals.push({ pid: target.pid, signal }); + }, targetIsAlive: () => true, - waitForTargetsExit: async (_targets, timeoutMs) => { - exitWaits.push(timeoutMs); + waitForPosixPayloadExit: async (timeoutMs) => { + events.push(`wait:${timeoutMs}`); return true; }, + waitForTargetsExit: async (_targets, timeoutMs) => { + events.push(`reap:${timeoutMs}`); + return timeoutMs === 2_000; + }, }, [84], ); - expect(signals).toEqual([{ pid: 42, signal: "SIGTERM" }]); - expect(exitWaits).toEqual([12_000]); + expect(signals).toEqual([ + { pid: 42, signal: "SIGTERM" }, + { pid: 42, signal: "SIGKILL" }, + ]); + expect(events).toEqual(["SIGTERM", "wait:12000", "SIGKILL", "reap:2000"]); }); - it("does not signal a verifier-created POSIX group after it is already gone", async () => { + it("does not signal an already-gone POSIX sentinel", async () => { const child = { exitCode: 0, pid: 42, @@ -808,188 +852,48 @@ describe("packaged desktop startup verification", () => { expect(signals).toEqual([]); }); - it("keeps the verifier-created POSIX group authoritative when its leader exits", async () => { - const childState: { - exitCode: number | null; - pid: number; - signalCode: NodeJS.Signals | null; - } = { - exitCode: null, - pid: 42, - signalCode: null, - }; - const child = childState as unknown as ChildProcess; - const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; - - await expect( - terminateProcessTree( - child, - { - platform: "darwin", - childIsAlive: () => true, - sendSignal: (target, signal) => { - signals.push({ pid: target.pid, signal }); - if (signal === "SIGTERM") childState.exitCode = 0; - }, - targetIsAlive: () => true, - waitForTargetsExit: async () => false, - }, - [84], - [{ pid: 84, processGroup: true }], - ), - ).rejects.toThrow("survived authoritative"); - expect(signals).toEqual([ - { pid: 42, signal: "SIGTERM" }, - { pid: 42, signal: "SIGKILL" }, - { pid: 84, signal: "SIGKILL" }, - ]); - }); - - it("terminates a token-recorded Windows backend after the desktop root exits", async () => { - const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; - const runTaskkill = vi.fn(() => ({ status: 0 })); - - await terminateProcessTree( - child, - { - platform: "win32", - readWindowsProcessInstanceId: () => "638891234567890123", - runTaskkill, - targetIsAlive: () => true, - waitForTargetsExit: async (_targets, timeoutMs) => - timeoutMs === 5_000 && runTaskkill.mock.calls.length > 0, - }, - [84], - [{ pid: 84, processGroup: false, instanceId: "638891234567890123" }], - ); - - expect(runTaskkill).toHaveBeenCalledWith(84, 5_000); - }); - - it("reaps an orphaned verifier-created POSIX process group", async () => { + it("refuses numeric POSIX signaling after the retained sentinel exits early", async () => { const child = { exitCode: 0, pid: 42, signalCode: null, } as unknown as ChildProcess; - const observed: Array> = []; const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; - await terminateProcessTree(child, { - platform: "darwin", - sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), - targetIsAlive: () => true, - waitForTargetsExit: async (targets, timeoutMs) => { - observed.push(targets); - return timeoutMs === 2_000; - }, - }); - expect(observed).toEqual([ - [{ pid: 42, processGroup: true }], - [{ pid: 42, processGroup: true }], - ]); - expect(signals).toEqual([ - { pid: 42, signal: "SIGTERM" }, - { pid: 42, signal: "SIGKILL" }, - ]); - }); - - it("refuses a Windows backend record whose process instance was reused", async () => { - const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; - const runTaskkill = vi.fn(() => ({ status: 0 })); - await expect( terminateProcessTree( child, { - platform: "win32", - readWindowsProcessInstanceId: () => "638891234567890999", - runTaskkill, + platform: "darwin", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), waitForTargetsExit: async () => false, }, [84], - [{ pid: 84, processGroup: false, instanceId: "638891234567890123" }], - ), - ).rejects.toThrow("survived Windows cleanup"); - expect(runTaskkill).not.toHaveBeenCalled(); - }); - - it("retains the newest authenticated backend when Windows reuses a PID", async () => { - const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; - const runTaskkill = vi.fn(() => ({ status: 0 })); - - await terminateProcessTree( - child, - { - platform: "win32", - readWindowsProcessInstanceId: () => "638891234567890999", - runTaskkill, - waitForTargetsExit: async (_targets, timeoutMs) => - timeoutMs === 5_000 && runTaskkill.mock.calls.length > 0, - }, - [84], - [ - { pid: 84, processGroup: false, instanceId: "638891234567890123" }, - { pid: 84, processGroup: false, instanceId: "638891234567890999" }, - ], - ); - - expect(runTaskkill).toHaveBeenCalledTimes(1); - expect(runTaskkill).toHaveBeenCalledWith(84, 5_000); - }); - - it("does not taskkill a Windows Electron root after its process instance is reused", async () => { - const child = { exitCode: null, pid: 42, signalCode: null } as unknown as ChildProcess; - const runTaskkill = vi.fn(() => ({ status: 0 })); - - await expect( - terminateProcessTree( - child, - { - platform: "win32", - childIsAlive: () => true, - readWindowsProcessInstanceId: () => "638891234567890999", - runTaskkill, - waitForTargetsExit: async () => false, - }, - [], - [], - "638891234567890123", ), - ).rejects.toThrow("survived Windows cleanup"); - expect(runTaskkill).not.toHaveBeenCalled(); + ).rejects.toThrow("refusing numeric signaling authority"); + expect(signals).toEqual([]); }); - it("fails closed without signaling a recorded PID when the root is already gone", async () => { + it("fails closed when the Windows launcher exited but a Job Object descendant survived", async () => { const child = { exitCode: 0, pid: 42, signalCode: null, } as unknown as ChildProcess; - const runTaskkill = vi.fn((_pid: number, _timeoutMs: number) => ({ - status: 0, - })); + const terminateRoot = vi.fn(() => true); await expect( terminateProcessTree( child, { platform: "win32", - runTaskkill, + terminateRoot, waitForTargetsExit: async () => false, }, [84], ), - ).rejects.toThrow("refusing to signal unverified PIDs"); - expect(runTaskkill).not.toHaveBeenCalled(); - }); - - it("fails closed when a Windows root exits without any recorded descendants", async () => { - const child = { exitCode: 0, pid: 42, signalCode: null } as unknown as ChildProcess; - - await expect(terminateProcessTree(child, { platform: "win32" })).rejects.toThrow( - "exited before cleanup ownership was established", - ); + ).rejects.toThrow("survived handle-bound cleanup"); + expect(terminateRoot).not.toHaveBeenCalled(); }); it("prepares the isolated Scient macOS profile marker", () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 37c50f57..6d9deefb 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -3,8 +3,7 @@ // Purpose: Launches an exact collected desktop release payload from isolated temporary state. // Layer: Release verification script -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { randomBytes } from "node:crypto"; +import { spawn, type ChildProcess } from "node:child_process"; import { chmodSync, mkdirSync, @@ -24,11 +23,15 @@ import { isWindowsAuthenticodeSignatureDetails, WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES, type WindowsAuthenticodeSignatureDetails, -} from "./lib/windows-authenticode"; -import { - readPackagedStartupOwnedProcesses, - readWindowsProcessInstanceId, -} from "@synara/shared/packagedStartupProcessOwnership"; +} from "./lib/windows-authenticode.ts"; + +const PACKAGED_NATIVE_CHILD_OUTCOME_FILE = "packaged-native-child-outcome.json"; +const POSIX_SENTINEL_PATH = fileURLToPath( + new URL("./lib/packaged-startup-posix-sentinel.mjs", import.meta.url), +); +const WINDOWS_JOB_LAUNCHER_PATH = fileURLToPath( + new URL("./lib/packaged-startup-windows-job.ps1", import.meta.url), +); export type PackagedDesktopPlatform = "mac" | "win"; @@ -324,6 +327,63 @@ export interface PackagedDesktopLaunchCommand { readonly cleanup?: () => Promise; } +export function spawnContainedPackagedDesktop( + launch: PackagedDesktopLaunchCommand, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, + spawnProcess: typeof spawn = spawn, +): ChildProcess { + if (platform === "win32") { + if (launch.args.length > 0) { + throw new Error( + "Windows Job Object launcher does not accept packaged application arguments.", + ); + } + const systemRoot = environment.SystemRoot ?? environment.SYSTEMROOT ?? "C:\\Windows"; + const powershell = win32.join( + systemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + return spawnProcess( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + WINDOWS_JOB_LAUNCHER_PATH, + "-ExecutablePath", + launch.command, + "-WorkingDirectory", + launch.cwd, + ], + { + cwd: launch.cwd, + env: environment, + detached: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + } + return spawnProcess( + process.execPath, + [POSIX_SENTINEL_PATH, launch.command, launch.cwd, JSON.stringify(launch.args)], + { + cwd: launch.cwd, + env: environment, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); +} + type PackagedPreparationCommandRunner = typeof runPackagedPreparationCommand; export async function attachMacDiskImageForInspection( @@ -685,7 +745,6 @@ export function createPackagedDesktopSmokeEnvironment( SCIENT_HOME: scientHome, SCIENT_DISABLE_SHELL_ENV_SYNC: "1", SCIENT_PACKAGED_STARTUP_SMOKE: "1", - SCIENT_PACKAGED_STARTUP_CLEANUP_TOKEN: randomBytes(32).toString("hex"), SYNARA_DISABLE_AUTO_UPDATE: "1", SYNARA_TELEMETRY_ENABLED: "false", ELECTRON_ENABLE_LOGGING: "1", @@ -763,22 +822,15 @@ export function resolvePackagedDesktopLogPath(environment: NodeJS.ProcessEnv): s export interface ProcessTerminationTarget { readonly pid: number; readonly processGroup: boolean; - readonly instanceId?: string; } export interface ProcessTerminationDependencies { readonly platform?: NodeJS.Platform; readonly childIsAlive?: (child: ChildProcess) => boolean; - readonly runTaskkill?: ( - pid: number, - timeoutMs: number, - ) => { - readonly error?: Error; - readonly status: number | null; - }; - readonly readWindowsProcessInstanceId?: (pid: number) => string | null; + readonly terminateRoot?: (child: ChildProcess) => boolean; readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; readonly targetIsAlive?: (target: ProcessTerminationTarget) => boolean; + readonly waitForPosixPayloadExit?: (timeoutMs: number) => Promise; readonly waitForTargetsExit?: ( targets: ReadonlyArray, timeoutMs: number, @@ -786,7 +838,7 @@ export interface ProcessTerminationDependencies { } const POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 12_000; -const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000; +const WINDOWS_JOB_CLOSE_TIMEOUT_MS = 5_000; function childProcessHandleIsAlive(child: ChildProcess): boolean { if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false; @@ -841,8 +893,6 @@ export async function terminateProcessTree( child: ChildProcess, dependencies: ProcessTerminationDependencies = {}, additionalProcessIds: ReadonlyArray = [], - recordedOwnedTargets: ReadonlyArray = [], - rootProcessInstanceId?: string, ): Promise { const platform = dependencies.platform ?? process.platform; const childCanStillOwnProcesses = @@ -851,33 +901,11 @@ export async function terminateProcessTree( child.signalCode === null; const rootTarget: ProcessTerminationTarget | null = child.pid && childCanStillOwnProcesses - ? { - pid: child.pid, - processGroup: platform !== "win32", - ...(platform === "win32" && rootProcessInstanceId - ? { instanceId: rootProcessInstanceId } - : {}), - } + ? { pid: child.pid, processGroup: platform !== "win32" } : null; - // The verifier creates this POSIX process group and keeps authority over it - // while any descendant remains, even after its original leader exits. - const exitedRootGroupTarget: ProcessTerminationTarget | null = - platform !== "win32" && child.pid && !rootTarget - ? { pid: child.pid, processGroup: true } - : null; - const ownedTargets = recordedOwnedTargets.filter( - (target, index, allTargets) => - target.pid > 0 && - allTargets.findIndex( - (candidate) => - candidate.pid === target.pid && - candidate.processGroup === target.processGroup && - candidate.instanceId === target.instanceId, - ) === index, - ); - // Backend PIDs recovered from logs/runtime state are observation-only. A - // matching HMAC record with the Windows process creation identity is the - // separate authority that permits signaling a backend after Electron exits. + // Backend PIDs recovered from logs/runtime state are observation-only. The + // retained POSIX sentinel group or Windows kill-on-close Job Object owns + // every descendant; numeric backend PIDs are never signaling authority. const observedTargets = additionalProcessIds .map((pid) => ({ pid, processGroup: false })) .filter( @@ -887,89 +915,46 @@ export async function terminateProcessTree( ); const targets = [ ...(rootTarget ? [rootTarget] : []), - ...(exitedRootGroupTarget ? [exitedRootGroupTarget] : []), - ...ownedTargets.filter( - (target) => target.pid !== (rootTarget?.pid ?? exitedRootGroupTarget?.pid), - ), - ...observedTargets.filter( - (target) => - target.pid !== (rootTarget?.pid ?? exitedRootGroupTarget?.pid) && - !ownedTargets.some((owned) => owned.pid === target.pid), - ), + ...observedTargets.filter((target) => target.pid !== rootTarget?.pid), ]; - if (targets.length === 0) { - if (platform === "win32" && child.pid) { - throw new Error( - `Packaged Windows root ${child.pid} exited before cleanup ownership was established; preserving evidence because unobserved descendants may remain.`, - ); - } - return; - } + if (targets.length === 0) return; const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; - if (!rootTarget && platform === "win32") { - if (await awaitTargetsExit(targets, 5_000)) return; - if (ownedTargets.length === 0) { - throw new Error( - `Recorded process candidates ${targets.map(({ pid }) => pid).join(", ")} remained after their parent exited; refusing to signal unverified PIDs or process groups.`, - ); - } - } if (platform === "win32") { - // A live ChildProcess handle establishes liveness, while the captured - // process-creation identity prevents numeric-PID reuse before taskkill. - // Authenticated backend records use the same identity boundary. - const candidateWindowsRoots = [ - ...(rootTarget ? [rootTarget] : []), - ...ownedTargets.filter((target) => target.pid !== rootTarget?.pid), - ]; - const authoritativeWindowsRoots: ProcessTerminationTarget[] = []; - const readInstanceId = - dependencies.readWindowsProcessInstanceId ?? - ((pid: number) => readWindowsProcessInstanceId(pid)); - for (const target of candidateWindowsRoots) { - if (!target.instanceId || readInstanceId(target.pid) !== target.instanceId) { - continue; - } - authoritativeWindowsRoots.push(target); - const taskkillResult = - dependencies.runTaskkill?.(target.pid, WINDOWS_TASKKILL_TIMEOUT_MS) ?? - spawnSync("taskkill", ["/pid", String(target.pid), "/t", "/f"], { - stdio: "ignore", - timeout: WINDOWS_TASKKILL_TIMEOUT_MS, - windowsHide: true, - }); - const taskkillDetail = taskkillResult.error - ? `could not start (${taskkillResult.error.message})` - : `status ${taskkillResult.status ?? "unknown"}`; - if (taskkillResult.error || taskkillResult.status !== 0) { - throw new Error( - `Packaged Windows cleanup lost authoritative tree termination; root ${target.pid} taskkill ${taskkillDetail}.`, - ); + // The ChildProcess handle belongs to the verifier-only PowerShell job + // launcher. Terminating that handle closes its kill-on-close Job Object, + // atomically terminating Electron and every descendant without PID lookup. + if (rootTarget) { + const terminated = dependencies.terminateRoot?.(child) ?? child.kill(); + if (!terminated) { + throw new Error("Packaged Windows Job Object launcher could not be terminated by handle."); } } - if (await awaitTargetsExit(targets, 5_000)) return; + if (await awaitTargetsExit(targets, WINDOWS_JOB_CLOSE_TIMEOUT_MS)) return; throw new Error( - `Packaged process trees survived Windows cleanup; authoritative roots ${authoritativeWindowsRoots.map(({ pid }) => pid).join(", ")}.`, + `Packaged Windows Job Object tree survived handle-bound cleanup; observed processes ${targets.map(({ pid }) => pid).join(", ")}.`, + ); + } + + if (!rootTarget) { + if (await awaitTargetsExit(targets, 2_000)) return; + throw new Error( + `Packaged POSIX sentinel exited before cleanup while observed descendants ${targets.map(({ pid }) => pid).join(", ")} remained; refusing numeric signaling authority.`, ); } const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; - const authoritativeRootGroup = rootTarget ?? exitedRootGroupTarget; - if (authoritativeRootGroup) { - if (targetIsAlive(authoritativeRootGroup)) { - sendSignal(authoritativeRootGroup, "SIGTERM"); - } - // The desktop owns a bounded backend shutdown that can legitimately take - // up to ten seconds. Preserve its supervisor for that complete deadline. - if (await awaitTargetsExit(targets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)) return; - } - const authoritativePosixTargets = [ - ...(authoritativeRootGroup ? [authoritativeRootGroup] : []), - ...ownedTargets.filter((target) => target.pid !== authoritativeRootGroup?.pid), - ]; - for (const target of authoritativePosixTargets) { - if (targetIsAlive(target)) sendSignal(target, "SIGKILL"); + if (!targetIsAlive(rootTarget)) { + if (await awaitTargetsExit(targets, 2_000)) return; + throw new Error("Packaged POSIX sentinel disappeared before its descendants were reaped."); } + sendSignal(rootTarget, "SIGTERM"); + // The sentinel ignores TERM and remains the original process-group member + // while Electron performs its bounded graceful backend shutdown. Once the + // native payload exits (or the deadline expires), KILL targets a group whose + // identity cannot have been recycled because the sentinel still occupies it. + await (dependencies.waitForPosixPayloadExit?.(POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS) ?? + awaitTargetsExit(observedTargets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)); + if (targetIsAlive(rootTarget)) sendSignal(rootTarget, "SIGKILL"); if (await awaitTargetsExit(targets, 2_000)) return; throw new Error( `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived authoritative SIGTERM and SIGKILL cleanup.`, @@ -1065,6 +1050,66 @@ export interface PackagedDesktopChildOutcome { readonly launchError: Error | null; } +export function resolvePackagedNativeChildOutcomePath(environment: NodeJS.ProcessEnv): string { + const scientHome = environment.SCIENT_HOME?.trim(); + if (!scientHome) throw new Error("Packaged startup smoke requires an isolated SCIENT_HOME."); + return join(scientHome, PACKAGED_NATIVE_CHILD_OUTCOME_FILE); +} + +export function readPackagedNativeChildOutcome( + environment: NodeJS.ProcessEnv, +): PackagedDesktopChildOutcome { + try { + const parsed = JSON.parse( + readFileSync(resolvePackagedNativeChildOutcomePath(environment), "utf8"), + ) as { + readonly exited?: { readonly code?: unknown; readonly signal?: unknown } | null; + readonly launchError?: { readonly message?: unknown } | null; + }; + const exited = parsed.exited; + const launchError = parsed.launchError; + if ( + exited && + (typeof exited.code === "number" || exited.code === null) && + (typeof exited.signal === "string" || exited.signal === null) && + launchError === null + ) { + return { + exited: { + code: exited.code, + signal: exited.signal as NodeJS.Signals | null, + }, + launchError: null, + }; + } + if (exited === null && launchError && typeof launchError.message === "string") { + return { exited: null, launchError: new Error(launchError.message) }; + } + return { exited: null, launchError: new Error("Malformed packaged native child outcome.") }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { exited: null, launchError: null }; + } + return { + exited: null, + launchError: error instanceof Error ? error : new Error(String(error)), + }; + } +} + +async function waitForPackagedNativeChildOutcome( + environment: NodeJS.ProcessEnv, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const outcome = readPackagedNativeChildOutcome(environment); + if (outcome.exited || outcome.launchError) return true; + await delay(Math.min(100, Math.max(1, deadline - Date.now()))); + } + return false; +} + export function readPackagedDesktopLogTail(logPath: string, maxCharacters = 200_000): string { try { return readFileSync(logPath, "utf8").slice(-maxCharacters).trim(); @@ -1196,7 +1241,6 @@ async function verifyPackagedDesktopPayload( let child: ChildProcess | null = null; let launch: PackagedDesktopLaunchCommand | null = null; let environment: NodeJS.ProcessEnv | null = null; - let rootProcessInstanceId: string | null = null; let logPath: string | null = null; let output = ""; const failures: Array<{ phase: string; error: unknown }> = []; @@ -1211,13 +1255,7 @@ async function verifyPackagedDesktopPayload( environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); const launchLogPath = resolvePackagedDesktopLogPath(environment); logPath = launchLogPath; - child = spawn(launch.command, [...launch.args], { - cwd: launch.cwd, - env: environment, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); + child = spawnContainedPackagedDesktop(launch, environment); const childOutcome: { exited: PackagedDesktopChildOutcome["exited"]; @@ -1235,20 +1273,17 @@ async function verifyPackagedDesktopPayload( child.stdout?.on("data", recordOutput); child.stderr?.on("data", recordOutput); - if (process.platform === "win32") { - rootProcessInstanceId = child.pid - ? readWindowsProcessInstanceId(child.pid, environment) - : null; - if (!rootProcessInstanceId) { - throw new Error("Could not establish the packaged Electron process instance identity."); - } - } - await Promise.race([ waitForPackagedStartupProof({ timeoutMs: options.timeoutMs, hasProof: () => hasPackagedStartupProof(launchLogPath, options), - readOutcome: () => childOutcome, + readOutcome: () => { + if (process.platform === "win32") return childOutcome; + const payloadOutcome = readPackagedNativeChildOutcome(environment!); + return payloadOutcome.exited || payloadOutcome.launchError + ? payloadOutcome + : childOutcome; + }, isProcessAlive: () => childProcessHandleIsAlive(child!), }), termination.signal.then((signal) => { @@ -1262,21 +1297,17 @@ async function verifyPackagedDesktopPayload( let processCleanupFailed = false; try { if (child) { - const ownedTargets = environment - ? readPackagedStartupOwnedProcesses(environment).map( - ({ pid, processGroup, instanceId }) => ({ - pid, - processGroup, - instanceId, - }), - ) - : []; await terminateProcessTree( child, - {}, + { + ...(process.platform !== "win32" && environment + ? { + waitForPosixPayloadExit: (timeoutMs: number) => + waitForPackagedNativeChildOutcome(environment!, timeoutMs), + } + : {}), + }, readPackagedBackendProcessIds(environment), - ownedTargets, - rootProcessInstanceId ?? undefined, ); } } catch (error) { From 254cc2edf90ca869330b08ac00e40b679bf7d39b Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 04:44:34 +0300 Subject: [PATCH 20/30] fix(release): fail closed after sentinel loss --- .../verify-packaged-desktop-startup.test.ts | 18 +++++++++++++++++- scripts/verify-packaged-desktop-startup.ts | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index c5962c03..c1e5da42 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -834,7 +834,7 @@ describe("packaged desktop startup verification", () => { expect(events).toEqual(["SIGTERM", "wait:12000", "SIGKILL", "reap:2000"]); }); - it("does not signal an already-gone POSIX sentinel", async () => { + it("accepts an already-gone POSIX sentinel only with proven native completion", async () => { const child = { exitCode: 0, pid: 42, @@ -844,6 +844,7 @@ describe("packaged desktop startup verification", () => { await terminateProcessTree(child, { platform: "darwin", + posixPayloadCompletionProven: () => true, sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), targetIsAlive: () => false, waitForTargetsExit: async () => true, @@ -852,6 +853,21 @@ describe("packaged desktop startup verification", () => { expect(signals).toEqual([]); }); + it("fails closed when the POSIX sentinel vanished without any native outcome", async () => { + const child = { + exitCode: 1, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + + await expect( + terminateProcessTree(child, { + platform: "darwin", + posixPayloadCompletionProven: () => false, + }), + ).rejects.toThrow("sentinel vanished without a native-child outcome"); + }); + it("refuses numeric POSIX signaling after the retained sentinel exits early", async () => { const child = { exitCode: 0, diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 6d9deefb..18cf4e36 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -830,6 +830,7 @@ export interface ProcessTerminationDependencies { readonly terminateRoot?: (child: ChildProcess) => boolean; readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; readonly targetIsAlive?: (target: ProcessTerminationTarget) => boolean; + readonly posixPayloadCompletionProven?: () => boolean; readonly waitForPosixPayloadExit?: (timeoutMs: number) => Promise; readonly waitForTargetsExit?: ( targets: ReadonlyArray, @@ -917,7 +918,18 @@ export async function terminateProcessTree( ...(rootTarget ? [rootTarget] : []), ...observedTargets.filter((target) => target.pid !== rootTarget?.pid), ]; - if (targets.length === 0) return; + if (targets.length === 0) { + if ( + platform !== "win32" && + child.pid && + dependencies.posixPayloadCompletionProven?.() !== true + ) { + throw new Error( + "Packaged POSIX sentinel vanished without a native-child outcome; preserving evidence because unobserved descendants may remain.", + ); + } + return; + } const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; if (platform === "win32") { // The ChildProcess handle belongs to the verifier-only PowerShell job @@ -1302,6 +1314,10 @@ async function verifyPackagedDesktopPayload( { ...(process.platform !== "win32" && environment ? { + posixPayloadCompletionProven: () => { + const outcome = readPackagedNativeChildOutcome(environment!); + return Boolean(outcome.exited || outcome.launchError); + }, waitForPosixPayloadExit: (timeoutMs: number) => waitForPackagedNativeChildOutcome(environment!, timeoutMs), } From 82b7617f879e1e956cf87cac4e61b0ea85152364 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 05:02:38 +0300 Subject: [PATCH 21/30] fix(release): close packaged cleanup containment gaps --- scripts/lib/packaged-startup-windows-job.ps1 | 127 +++++++++++++---- scripts/release-smoke.ts | 18 ++- .../verify-packaged-desktop-startup.test.ts | 132 +++++++++++++++++- scripts/verify-packaged-desktop-startup.ts | 72 +++++++--- 4 files changed, 302 insertions(+), 47 deletions(-) diff --git a/scripts/lib/packaged-startup-windows-job.ps1 b/scripts/lib/packaged-startup-windows-job.ps1 index e931d092..cd8f9331 100644 --- a/scripts/lib/packaged-startup-windows-job.ps1 +++ b/scripts/lib/packaged-startup-windows-job.ps1 @@ -1,6 +1,8 @@ param( [Parameter(Mandatory = $true)][string]$ExecutablePath, - [Parameter(Mandatory = $true)][string]$WorkingDirectory + [Parameter(Mandatory = $true)][string]$WorkingDirectory, + [string]$PreResumeMarkerPath = '', + [string]$PreResumeGatePath = '' ) $ErrorActionPreference = 'Stop' @@ -8,15 +10,21 @@ $ErrorActionPreference = 'Stop' $source = @' using System; using System.ComponentModel; +using System.Globalization; +using System.IO; using System.Runtime.InteropServices; using System.Text; +using System.Threading; public static class ScientPackagedStartupJobLauncher { private const uint CREATE_SUSPENDED = 0x00000004; + private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000; private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const int ERROR_INSUFFICIENT_BUFFER = 122; private const int JobObjectExtendedLimitInformation = 9; private const uint INFINITE = 0xFFFFFFFF; + private static readonly IntPtr PROC_THREAD_ATTRIBUTE_JOB_LIST = new IntPtr(0x0002000D); [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct STARTUPINFO @@ -41,6 +49,13 @@ public static class ScientPackagedStartupJobLauncher public IntPtr hStdError; } + [StructLayout(LayoutKind.Sequential)] + private struct STARTUPINFOEX + { + public STARTUPINFO StartupInfo; + public IntPtr lpAttributeList; + } + [StructLayout(LayoutKind.Sequential)] private struct PROCESS_INFORMATION { @@ -97,9 +112,6 @@ public static class ScientPackagedStartupJobLauncher uint informationLength ); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CreateProcess( string applicationName, @@ -110,10 +122,32 @@ public static class ScientPackagedStartupJobLauncher uint creationFlags, IntPtr environment, string currentDirectory, - ref STARTUPINFO startupInfo, + ref STARTUPINFOEX startupInfo, out PROCESS_INFORMATION processInformation ); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool InitializeProcThreadAttributeList( + IntPtr attributeList, + int attributeCount, + int flags, + ref IntPtr size + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool UpdateProcThreadAttribute( + IntPtr attributeList, + uint flags, + IntPtr attribute, + IntPtr value, + IntPtr size, + IntPtr previousValue, + IntPtr returnSize + ); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); + [DllImport("kernel32.dll", SetLastError = true)] private static extern uint ResumeThread(IntPtr thread); @@ -123,9 +157,6 @@ public static class ScientPackagedStartupJobLauncher [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool TerminateProcess(IntPtr process, uint exitCode); - [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CloseHandle(IntPtr handle); @@ -134,16 +165,37 @@ public static class ScientPackagedStartupJobLauncher throw new Win32Exception(Marshal.GetLastWin32Error(), operation); } - public static int Run(string executablePath, string workingDirectory) + private static void AwaitPreResumeGate( + string markerPath, + string gatePath, + uint childProcessId + ) + { + bool hasMarker = !String.IsNullOrWhiteSpace(markerPath); + bool hasGate = !String.IsNullOrWhiteSpace(gatePath); + if (!hasMarker && !hasGate) return; + if (!hasMarker || !hasGate) + throw new ArgumentException("Pre-resume marker and gate paths must be supplied together."); + + File.WriteAllText(markerPath, childProcessId.ToString(CultureInfo.InvariantCulture)); + while (!File.Exists(gatePath)) Thread.Sleep(20); + } + + public static int Run( + string executablePath, + string workingDirectory, + string preResumeMarkerPath, + string preResumeGatePath + ) { if (executablePath.IndexOf('"') >= 0) throw new ArgumentException("Executable path contains an invalid quote.", "executablePath"); IntPtr job = IntPtr.Zero; IntPtr information = IntPtr.Zero; + IntPtr attributeList = IntPtr.Zero; + IntPtr jobList = IntPtr.Zero; PROCESS_INFORMATION child = new PROCESS_INFORMATION(); - bool childCreated = false; - bool childAssigned = false; try { job = CreateJobObject(IntPtr.Zero, null); @@ -162,8 +214,31 @@ public static class ScientPackagedStartupJobLauncher (uint)informationSize )) ThrowLastError("SetInformationJobObject failed"); - STARTUPINFO startup = new STARTUPINFO(); - startup.cb = (uint)Marshal.SizeOf(typeof(STARTUPINFO)); + IntPtr attributeListSize = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize); + if ( + Marshal.GetLastWin32Error() != ERROR_INSUFFICIENT_BUFFER || + attributeListSize == IntPtr.Zero + ) ThrowLastError("InitializeProcThreadAttributeList sizing failed"); + attributeList = Marshal.AllocHGlobal(attributeListSize); + if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize)) + ThrowLastError("InitializeProcThreadAttributeList failed"); + + jobList = Marshal.AllocHGlobal(IntPtr.Size); + Marshal.WriteIntPtr(jobList, job); + if (!UpdateProcThreadAttribute( + attributeList, + 0, + PROC_THREAD_ATTRIBUTE_JOB_LIST, + jobList, + new IntPtr(IntPtr.Size), + IntPtr.Zero, + IntPtr.Zero + )) ThrowLastError("UpdateProcThreadAttribute for Job Object failed"); + + STARTUPINFOEX startup = new STARTUPINFOEX(); + startup.StartupInfo.cb = (uint)Marshal.SizeOf(typeof(STARTUPINFOEX)); + startup.lpAttributeList = attributeList; StringBuilder commandLine = new StringBuilder("\"" + executablePath + "\""); if (!CreateProcess( executablePath, @@ -171,17 +246,16 @@ public static class ScientPackagedStartupJobLauncher IntPtr.Zero, IntPtr.Zero, false, - CREATE_SUSPENDED, + CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, IntPtr.Zero, workingDirectory, ref startup, out child )) ThrowLastError("CreateProcess failed"); - childCreated = true; - - if (!AssignProcessToJobObject(job, child.hProcess)) - ThrowLastError("AssignProcessToJobObject failed"); - childAssigned = true; + // PROC_THREAD_ATTRIBUTE_JOB_LIST makes Job membership atomic with + // process creation, before this launcher can be interrupted and + // before any native payload instruction is allowed to execute. + AwaitPreResumeGate(preResumeMarkerPath, preResumeGatePath, child.dwProcessId); if (ResumeThread(child.hThread) == UInt32.MaxValue) ThrowLastError("ResumeThread failed"); @@ -193,13 +267,11 @@ public static class ScientPackagedStartupJobLauncher } finally { - if (childCreated && !childAssigned && child.hProcess != IntPtr.Zero) - { - TerminateProcess(child.hProcess, 1); - WaitForSingleObject(child.hProcess, INFINITE); - } if (child.hThread != IntPtr.Zero) CloseHandle(child.hThread); if (child.hProcess != IntPtr.Zero) CloseHandle(child.hProcess); + if (attributeList != IntPtr.Zero) DeleteProcThreadAttributeList(attributeList); + if (jobList != IntPtr.Zero) Marshal.FreeHGlobal(jobList); + if (attributeList != IntPtr.Zero) Marshal.FreeHGlobal(attributeList); if (information != IntPtr.Zero) Marshal.FreeHGlobal(information); // Closing the final job handle atomically terminates every process // that Electron created inside the kill-on-close job. @@ -210,4 +282,9 @@ public static class ScientPackagedStartupJobLauncher '@ Add-Type -TypeDefinition $source -Language CSharp -exit [ScientPackagedStartupJobLauncher]::Run($ExecutablePath, $WorkingDirectory) +exit [ScientPackagedStartupJobLauncher]::Run( + $ExecutablePath, + $WorkingDirectory, + $PreResumeMarkerPath, + $PreResumeGatePath +) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index e5d4746c..0fc274e9 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -461,13 +461,25 @@ function verifyReleaseWorkflowSafety(): void { "JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE", "Expected Windows startup proof to use kill-on-close Job Object containment.", ); + assertContains( + windowsStartupJobLauncher, + "PROC_THREAD_ATTRIBUTE_JOB_LIST", + "Expected Windows startup proof to assign the native process to its Job Object atomically at creation.", + ); + assertNotContains( + windowsStartupJobLauncher, + "AssignProcessToJobObject", + "Windows startup proof must not restore a pre-containment process-creation window.", + ); if ( - windowsStartupJobLauncher.indexOf("AssignProcessToJobObject(job, child.hProcess)") < 0 || - windowsStartupJobLauncher.indexOf("AssignProcessToJobObject(job, child.hProcess)") > + windowsStartupJobLauncher.indexOf("if (!UpdateProcThreadAttribute(") < 0 || + windowsStartupJobLauncher.indexOf("if (!UpdateProcThreadAttribute(") > + windowsStartupJobLauncher.indexOf("if (!CreateProcess(") || + windowsStartupJobLauncher.indexOf("if (!CreateProcess(") > windowsStartupJobLauncher.indexOf("ResumeThread(child.hThread)") ) { throw new Error( - "Expected Windows startup proof to assign the suspended Electron process before resume.", + "Expected Windows startup proof to create Electron atomically inside the Job Object before resume.", ); } assertContains( diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index c1e5da42..5e5279a8 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -1,4 +1,4 @@ -import type { ChildProcess } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { existsSync, @@ -11,6 +11,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -23,6 +24,7 @@ import { expectedPackagedDesktopStartupAssetName, expectedPackagedDesktopStartupAssetNames, formatPackagedStartupFailures, + hasProvenPackagedNativeChildOutcome, hasPackagedStartupProof, isScientWindowsExecutable, monitorPackagedStartupTermination, @@ -659,8 +661,14 @@ describe("packaged desktop startup verification", () => { ]!; const jobScript = readFileSync(jobScriptPath, "utf8"); expect(jobScript).toContain("CREATE_SUSPENDED"); + expect(jobScript).toContain("EXTENDED_STARTUPINFO_PRESENT"); expect(jobScript).toContain("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"); - expect(jobScript.indexOf("AssignProcessToJobObject(job, child.hProcess)")).toBeLessThan( + expect(jobScript).toContain("PROC_THREAD_ATTRIBUTE_JOB_LIST"); + expect(jobScript).not.toContain("AssignProcessToJobObject"); + expect(jobScript.indexOf("if (!UpdateProcThreadAttribute(")).toBeLessThan( + jobScript.indexOf("if (!CreateProcess("), + ); + expect(jobScript.indexOf("if (!CreateProcess(")).toBeLessThan( jobScript.indexOf("ResumeThread(child.hThread)"), ); @@ -681,6 +689,78 @@ describe("packaged desktop startup verification", () => { expect(posixCall[2]).toEqual(expect.objectContaining({ detached: true })); }); + it.skipIf(process.platform !== "win32")( + "atomically kills the suspended Windows payload when its launcher is interrupted pre-resume", + async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-job-cancel-test-")); + temporaryRoots.push(root); + const markerPath = join(root, "pre-resume.marker"); + const gatePath = join(root, "pre-resume.gate"); + const powershell = join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const launcher = spawn( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + fileURLToPath(new URL("./lib/packaged-startup-windows-job.ps1", import.meta.url)), + "-ExecutablePath", + powershell, + "-WorkingDirectory", + root, + "-PreResumeMarkerPath", + markerPath, + "-PreResumeGatePath", + gatePath, + ], + { stdio: "ignore" }, + ); + + const waitUntil = async (predicate: () => boolean, timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } + return predicate(); + }; + const markerAppeared = await waitUntil(() => existsSync(markerPath), 15_000); + const payloadProcessId = markerAppeared ? Number(readFileSync(markerPath, "utf8")) : null; + const launcherTerminated = launcher.kill(); + const launcherExited = await waitUntil( + () => launcher.exitCode !== null || launcher.signalCode !== null, + 5_000, + ); + + expect(markerAppeared).toBe(true); + expect( + payloadProcessId !== null && Number.isInteger(payloadProcessId) && payloadProcessId > 0, + ).toBe(true); + expect(launcherTerminated).toBe(true); + expect(launcherExited).toBe(true); + expect( + await waitUntil(() => { + try { + process.kill(payloadProcessId!, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } + }, 5_000), + ).toBe(true); + }, + 30_000, + ); + it("reads the POSIX sentinel native-child outcome from isolated state", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-outcome-test-")); temporaryRoots.push(root); @@ -698,8 +778,14 @@ describe("packaged desktop startup verification", () => { exited: { code: 7, signal: null }, launchError: null, }); + expect(hasProvenPackagedNativeChildOutcome(environment)).toBe(true); writeFileSync(join(root, "packaged-native-child-outcome.json"), "{malformed"); expect(readPackagedNativeChildOutcome(environment).launchError).toBeInstanceOf(Error); + expect(hasProvenPackagedNativeChildOutcome(environment)).toBe(false); + rmSync(join(root, "packaged-native-child-outcome.json")); + mkdirSync(join(root, "packaged-native-child-outcome.json")); + expect(readPackagedNativeChildOutcome(environment).launchError).toBeInstanceOf(Error); + expect(hasProvenPackagedNativeChildOutcome(environment)).toBe(false); }); it("recovers every spawned backend PID before runtime state is durable", () => { @@ -868,6 +954,48 @@ describe("packaged desktop startup verification", () => { ).rejects.toThrow("sentinel vanished without a native-child outcome"); }); + it("fails closed after observed POSIX descendants exit without proven native completion", async () => { + const child = { + exitCode: 1, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + + await expect( + terminateProcessTree( + child, + { + platform: "darwin", + posixPayloadCompletionProven: () => false, + waitForTargetsExit: async () => true, + }, + [84], + ), + ).rejects.toThrow("sentinel vanished without a native-child outcome"); + }); + + it("fails closed when the POSIX sentinel disappears while cleanup is starting", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + + await expect( + terminateProcessTree( + child, + { + platform: "darwin", + childIsAlive: () => true, + posixPayloadCompletionProven: () => false, + targetIsAlive: () => false, + waitForTargetsExit: async () => true, + }, + [84], + ), + ).rejects.toThrow("sentinel vanished without a native-child outcome"); + }); + it("refuses numeric POSIX signaling after the retained sentinel exits early", async () => { const child = { exitCode: 0, diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 18cf4e36..ca6a2ded 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -948,7 +948,12 @@ export async function terminateProcessTree( } if (!rootTarget) { - if (await awaitTargetsExit(targets, 2_000)) return; + if (await awaitTargetsExit(targets, 2_000)) { + if (dependencies.posixPayloadCompletionProven?.() === true) return; + throw new Error( + "Packaged POSIX sentinel vanished without a native-child outcome; preserving evidence because unobserved descendants may remain.", + ); + } throw new Error( `Packaged POSIX sentinel exited before cleanup while observed descendants ${targets.map(({ pid }) => pid).join(", ")} remained; refusing numeric signaling authority.`, ); @@ -956,7 +961,12 @@ export async function terminateProcessTree( const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; if (!targetIsAlive(rootTarget)) { - if (await awaitTargetsExit(targets, 2_000)) return; + if (await awaitTargetsExit(targets, 2_000)) { + if (dependencies.posixPayloadCompletionProven?.() === true) return; + throw new Error( + "Packaged POSIX sentinel vanished without a native-child outcome; preserving evidence because unobserved descendants may remain.", + ); + } throw new Error("Packaged POSIX sentinel disappeared before its descendants were reaped."); } sendSignal(rootTarget, "SIGTERM"); @@ -1062,15 +1072,20 @@ export interface PackagedDesktopChildOutcome { readonly launchError: Error | null; } +interface InspectedPackagedDesktopChildOutcome { + readonly evidence: "pending" | "proven" | "invalid"; + readonly outcome: PackagedDesktopChildOutcome; +} + export function resolvePackagedNativeChildOutcomePath(environment: NodeJS.ProcessEnv): string { const scientHome = environment.SCIENT_HOME?.trim(); if (!scientHome) throw new Error("Packaged startup smoke requires an isolated SCIENT_HOME."); return join(scientHome, PACKAGED_NATIVE_CHILD_OUTCOME_FILE); } -export function readPackagedNativeChildOutcome( +function inspectPackagedNativeChildOutcome( environment: NodeJS.ProcessEnv, -): PackagedDesktopChildOutcome { +): InspectedPackagedDesktopChildOutcome { try { const parsed = JSON.parse( readFileSync(resolvePackagedNativeChildOutcomePath(environment), "utf8"), @@ -1087,36 +1102,60 @@ export function readPackagedNativeChildOutcome( launchError === null ) { return { - exited: { - code: exited.code, - signal: exited.signal as NodeJS.Signals | null, + evidence: "proven", + outcome: { + exited: { + code: exited.code, + signal: exited.signal as NodeJS.Signals | null, + }, + launchError: null, }, - launchError: null, }; } if (exited === null && launchError && typeof launchError.message === "string") { - return { exited: null, launchError: new Error(launchError.message) }; + return { + evidence: "proven", + outcome: { exited: null, launchError: new Error(launchError.message) }, + }; } - return { exited: null, launchError: new Error("Malformed packaged native child outcome.") }; + return { + evidence: "invalid", + outcome: { + exited: null, + launchError: new Error("Malformed packaged native child outcome."), + }, + }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { exited: null, launchError: null }; + return { evidence: "pending", outcome: { exited: null, launchError: null } }; } return { - exited: null, - launchError: error instanceof Error ? error : new Error(String(error)), + evidence: "invalid", + outcome: { + exited: null, + launchError: error instanceof Error ? error : new Error(String(error)), + }, }; } } +export function readPackagedNativeChildOutcome( + environment: NodeJS.ProcessEnv, +): PackagedDesktopChildOutcome { + return inspectPackagedNativeChildOutcome(environment).outcome; +} + +export function hasProvenPackagedNativeChildOutcome(environment: NodeJS.ProcessEnv): boolean { + return inspectPackagedNativeChildOutcome(environment).evidence === "proven"; +} + async function waitForPackagedNativeChildOutcome( environment: NodeJS.ProcessEnv, timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const outcome = readPackagedNativeChildOutcome(environment); - if (outcome.exited || outcome.launchError) return true; + if (hasProvenPackagedNativeChildOutcome(environment)) return true; await delay(Math.min(100, Math.max(1, deadline - Date.now()))); } return false; @@ -1315,8 +1354,7 @@ async function verifyPackagedDesktopPayload( ...(process.platform !== "win32" && environment ? { posixPayloadCompletionProven: () => { - const outcome = readPackagedNativeChildOutcome(environment!); - return Boolean(outcome.exited || outcome.launchError); + return hasProvenPackagedNativeChildOutcome(environment!); }, waitForPosixPayloadExit: (timeoutMs: number) => waitForPackagedNativeChildOutcome(environment!, timeoutMs), From 4f1c32e4ed7f7d5190571f6186f15ae5e31e1827 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 05:26:40 +0300 Subject: [PATCH 22/30] fix(release): fail closed on startup cleanup uncertainty --- apps/desktop/src/main.ts | 19 +++- .../src/packagedStartupSmokeAuthority.test.ts | 27 +++++ .../src/packagedStartupSmokeAuthority.ts | 20 ++++ .../lib/packaged-startup-posix-sentinel.mjs | 7 +- scripts/release-smoke.ts | 46 +++++++- .../verify-packaged-desktop-startup.test.ts | 99 ++++++++++++++--- scripts/verify-packaged-desktop-startup.ts | 104 ++++++++++-------- 7 files changed, 259 insertions(+), 63 deletions(-) create mode 100644 apps/desktop/src/packagedStartupSmokeAuthority.test.ts create mode 100644 apps/desktop/src/packagedStartupSmokeAuthority.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index de34a5b5..11fe032b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -60,6 +60,7 @@ import { RotatingFileSink } from "@synara/shared/logging"; import { ensureStaticSnapshot, findAsarArchivePath } from "@synara/shared/staticSnapshot"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { waitForPackagedBackendResponsiveness } from "./packagedStartupResponsiveness"; +import { isVerifierOwnedPackagedStartupSmoke } from "./packagedStartupSmokeAuthority"; import { resolveBackendNodeArgs } from "./backendNodeOptions"; import { backendProcessContainmentOptions, @@ -1007,7 +1008,7 @@ function resolveEmbeddedCommitHash(): string | null { } function writePackagedStartupSmokeIdentity(): void { - if (process.env.SCIENT_PACKAGED_STARTUP_SMOKE !== "1") return; + if (!isVerifierOwnedPackagedStartupSmoke(process.env, process.ppid, app.isPackaged)) return; const commit = resolveEmbeddedReleaseMetadata().fullCommitHash ?? "unknown"; writeDesktopLogHeader( `packaged identity name=${app.getName()} version=${app.getVersion()} commit=${commit}`, @@ -2876,7 +2877,7 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { ...backendProcessContainmentOptions( captureBackendLogs, process.platform, - process.env.SCIENT_PACKAGED_STARTUP_SMOKE !== "1", + !isVerifierOwnedPackagedStartupSmoke(process.env, process.ppid, app.isPackaged), ), }); writeDesktopLogHeader( @@ -2982,7 +2983,17 @@ function getBackendSupervisor(): DesktopBackendSupervisor { } }); }, - forceTerminateTree: (child) => forceTerminateBackendProcessTree(child), + forceTerminateTree: (child) => { + if ( + process.platform !== "win32" && + isVerifierOwnedPackagedStartupSmoke(process.env, process.ppid, app.isPackaged) + ) { + throw new Error( + "Verifier-owned packaged startup retains process-group cleanup authority; refusing unsafe backend PID-group signaling.", + ); + } + return forceTerminateBackendProcessTree(child); + }, onGenerationStarted: handleBackendGenerationStarted, onGenerationExited: handleBackendGenerationExited, onRestartScheduled: ({ delayMs, reason }) => { @@ -3588,7 +3599,7 @@ function createWindow(): BrowserWindow { writeDesktopLogHeader("renderer main frame loaded"); window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); - if (process.env.SCIENT_PACKAGED_STARTUP_SMOKE === "1") { + if (isVerifierOwnedPackagedStartupSmoke(process.env, process.ppid, app.isPackaged)) { setTimeout(() => { const generation = getBackendSupervisor().currentGeneration; void Promise.all([ diff --git a/apps/desktop/src/packagedStartupSmokeAuthority.test.ts b/apps/desktop/src/packagedStartupSmokeAuthority.test.ts new file mode 100644 index 00000000..d4b06c05 --- /dev/null +++ b/apps/desktop/src/packagedStartupSmokeAuthority.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { isVerifierOwnedPackagedStartupSmoke } from "./packagedStartupSmokeAuthority"; + +describe("isVerifierOwnedPackagedStartupSmoke", () => { + const authority = { + SCIENT_HOME: "/isolated/scient-home", + SCIENT_PACKAGED_STARTUP_SENTINEL_PID: "4321", + SCIENT_PACKAGED_STARTUP_SMOKE: "1", + }; + + it("accepts only a packaged child whose live parent matches the sentinel", () => { + expect(isVerifierOwnedPackagedStartupSmoke(authority, 4321, true)).toBe(true); + }); + + it("does not let an inherited smoke flag disable normal containment", () => { + expect( + isVerifierOwnedPackagedStartupSmoke( + { SCIENT_HOME: authority.SCIENT_HOME, SCIENT_PACKAGED_STARTUP_SMOKE: "1" }, + 4321, + true, + ), + ).toBe(false); + expect(isVerifierOwnedPackagedStartupSmoke(authority, 9876, true)).toBe(false); + expect(isVerifierOwnedPackagedStartupSmoke(authority, 4321, false)).toBe(false); + }); +}); diff --git a/apps/desktop/src/packagedStartupSmokeAuthority.ts b/apps/desktop/src/packagedStartupSmokeAuthority.ts new file mode 100644 index 00000000..7aa06776 --- /dev/null +++ b/apps/desktop/src/packagedStartupSmokeAuthority.ts @@ -0,0 +1,20 @@ +// FILE: packagedStartupSmokeAuthority.ts +// Purpose: Authenticates verifier-owned packaged-startup behavior in the shipped desktop process. +// Layer: Desktop main-process helper + +export const PACKAGED_STARTUP_SENTINEL_PID_ENV = "SCIENT_PACKAGED_STARTUP_SENTINEL_PID"; + +export function isVerifierOwnedPackagedStartupSmoke( + environment: NodeJS.ProcessEnv, + parentProcessId: number, + isPackaged: boolean, +): boolean { + if (!isPackaged || environment.SCIENT_PACKAGED_STARTUP_SMOKE !== "1") return false; + if (!environment.SCIENT_HOME?.trim()) return false; + const sentinelProcessId = Number(environment[PACKAGED_STARTUP_SENTINEL_PID_ENV]); + return ( + Number.isSafeInteger(sentinelProcessId) && + sentinelProcessId > 0 && + sentinelProcessId === parentProcessId + ); +} diff --git a/scripts/lib/packaged-startup-posix-sentinel.mjs b/scripts/lib/packaged-startup-posix-sentinel.mjs index c4793eb0..16b568ea 100644 --- a/scripts/lib/packaged-startup-posix-sentinel.mjs +++ b/scripts/lib/packaged-startup-posix-sentinel.mjs @@ -35,7 +35,12 @@ process.on("SIGTERM", () => undefined); const child = spawn(command, parsedArgs, { cwd, - env: process.env, + env: { + ...process.env, + // The desktop accepts smoke-only containment changes only from its direct + // retained sentinel parent; an inherited public flag is not authority. + SCIENT_PACKAGED_STARTUP_SENTINEL_PID: String(process.pid), + }, detached: false, stdio: ["ignore", "inherit", "inherit"], }); diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 0fc274e9..d9a52045 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -249,16 +249,55 @@ function verifyReleaseWorkflowSafety(): void { const parsedWorkflow = parseYaml(workflow) as { permissions?: Record; jobs?: { - build?: { permissions?: Record; steps?: Array }; + build?: { + permissions?: Record; + steps?: Array; + strategy?: { matrix?: { include?: Array> } }; + }; preflight?: { steps?: Array }; publish_cli?: { permissions?: Record }; release?: { permissions?: Record }; }; }; const buildSteps = parsedWorkflow.jobs?.build?.steps ?? []; + const buildMatrix = parsedWorkflow.jobs?.build?.strategy?.matrix?.include ?? []; const preflightSteps = parsedWorkflow.jobs?.preflight?.steps ?? []; const buildPermissions = parsedWorkflow.jobs?.build?.permissions ?? {}; const buildCheckout = buildSteps.find((step) => step.name === "Checkout"); + const requiredNativeBuildMatrix = [ + { + label: "macOS arm64", + runner: "macos-14", + platform: "mac", + target: "dmg", + arch: "arm64", + timeout_minutes: 120, + }, + { + label: "macOS x64", + runner: "macos-15-intel", + platform: "mac", + target: "dmg", + arch: "x64", + timeout_minutes: 120, + }, + { + label: "Linux x64", + runner: "ubuntu-24.04", + platform: "linux", + target: "AppImage", + arch: "x64", + timeout_minutes: 30, + }, + { + label: "Windows x64", + runner: "windows-2022", + platform: "win", + target: "nsis", + arch: "x64", + timeout_minutes: 30, + }, + ]; if ( parsedWorkflow.permissions?.contents !== "read" || Object.keys(parsedWorkflow.permissions ?? {}).length !== 1 || @@ -272,6 +311,11 @@ function verifyReleaseWorkflowSafety(): void { "Expected native payload execution to have read-only contents, no OIDC, and no persisted checkout credential.", ); } + if (JSON.stringify(buildMatrix) !== JSON.stringify(requiredNativeBuildMatrix)) { + throw new Error( + "Expected the exact macOS arm64, macOS x64, Linux x64, and Windows x64 native release matrix and runners.", + ); + } const requireBuildStep = (name: string) => { const step = buildSteps.find((candidate) => candidate.name === name); if (!step) { diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 5e5279a8..3109e7f4 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -28,6 +28,7 @@ import { hasPackagedStartupProof, isScientWindowsExecutable, monitorPackagedStartupTermination, + PackagedPreparationCleanupError, parsePackagedDesktopStartupArgs, readWindowsExecutableArchitecture, readPackagedDesktopLogTail, @@ -440,6 +441,32 @@ describe("packaged desktop startup verification", () => { termination.dispose(); }); + it("classifies failed preparation termination as cleanup failure", async () => { + const abortController = new AbortController(); + const spawnProcess = vi.fn(() => { + const child = new EventEmitter() as ChildProcess; + Object.assign(child, { + exitCode: null, + pid: 42, + signalCode: null, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + return child; + }) as unknown as typeof import("node:child_process").spawn; + const command = runPackagedPreparationCommand("ditto", ["hung.zip"], { + signal: abortController.signal, + spawnProcess, + terminateProcess: async () => { + throw new Error("tree survived"); + }, + }); + + abortController.abort(new Error("interrupted")); + + await expect(command).rejects.toBeInstanceOf(PackagedPreparationCleanupError); + }); + it("attempts to detach a partially mounted DMG after attach fails", async () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; const attachError = new Error("attach interrupted"); @@ -473,6 +500,39 @@ describe("packaged desktop startup verification", () => { ]); }); + it("reports an unknown forced-detach failure after interrupted DMG attach", async () => { + const runCommand = vi.fn(async (_command: string, args: ReadonlyArray) => { + if (args[0] === "attach") throw new Error("attach interrupted"); + throw new Error("resource still busy"); + }) as unknown as typeof runPackagedPreparationCommand; + + await expect( + attachMacDiskImageForInspection( + "/tmp/Scient.dmg", + "/tmp/scient-mount", + new AbortController().signal, + runCommand, + ), + ).rejects.toBeInstanceOf(PackagedPreparationCleanupError); + }); + + it("preserves the attach error when forced detach proves no mount exists", async () => { + const attachError = new Error("attach interrupted"); + const runCommand = vi.fn(async (_command: string, args: ReadonlyArray) => { + if (args[0] === "attach") throw attachError; + throw new Error("hdiutil: detach failed - No such file or directory"); + }) as unknown as typeof runPackagedPreparationCommand; + + await expect( + attachMacDiskImageForInspection( + "/tmp/Scient.dmg", + "/tmp/scient-mount", + new AbortController().signal, + runCommand, + ), + ).rejects.toBe(attachError); + }); + it("force-detaches a mounted DMG when ordinary cleanup reports it busy", async () => { const calls: Array> = []; const runCommand = vi.fn(async (_command: string, args: ReadonlyArray) => { @@ -779,6 +839,19 @@ describe("packaged desktop startup verification", () => { launchError: null, }); expect(hasProvenPackagedNativeChildOutcome(environment)).toBe(true); + for (const invalidOutcome of [ + { exited: { code: null, signal: null }, launchError: null }, + { exited: { code: 0, signal: "SIGKILL" }, launchError: null }, + { exited: { code: 0.5, signal: null }, launchError: null }, + { exited: { code: null, signal: "NOT_A_SIGNAL" }, launchError: null }, + { exited: null, launchError: { message: " " } }, + ]) { + writeFileSync( + join(root, "packaged-native-child-outcome.json"), + JSON.stringify(invalidOutcome), + ); + expect(hasProvenPackagedNativeChildOutcome(environment)).toBe(false); + } writeFileSync(join(root, "packaged-native-child-outcome.json"), "{malformed"); expect(readPackagedNativeChildOutcome(environment).launchError).toBeInstanceOf(Error); expect(hasProvenPackagedNativeChildOutcome(environment)).toBe(false); @@ -920,7 +993,7 @@ describe("packaged desktop startup verification", () => { expect(events).toEqual(["SIGTERM", "wait:12000", "SIGKILL", "reap:2000"]); }); - it("accepts an already-gone POSIX sentinel only with proven native completion", async () => { + it("fails closed when a POSIX sentinel is gone even after observed processes exit", async () => { const child = { exitCode: 0, pid: 42, @@ -928,13 +1001,14 @@ describe("packaged desktop startup verification", () => { } as unknown as ChildProcess; const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; - await terminateProcessTree(child, { - platform: "darwin", - posixPayloadCompletionProven: () => true, - sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), - targetIsAlive: () => false, - waitForTargetsExit: async () => true, - }); + await expect( + terminateProcessTree(child, { + platform: "darwin", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + targetIsAlive: () => false, + waitForTargetsExit: async () => true, + }), + ).rejects.toThrow("before authoritative whole-group cleanup"); expect(signals).toEqual([]); }); @@ -949,9 +1023,8 @@ describe("packaged desktop startup verification", () => { await expect( terminateProcessTree(child, { platform: "darwin", - posixPayloadCompletionProven: () => false, }), - ).rejects.toThrow("sentinel vanished without a native-child outcome"); + ).rejects.toThrow("before authoritative whole-group cleanup"); }); it("fails closed after observed POSIX descendants exit without proven native completion", async () => { @@ -966,12 +1039,11 @@ describe("packaged desktop startup verification", () => { child, { platform: "darwin", - posixPayloadCompletionProven: () => false, waitForTargetsExit: async () => true, }, [84], ), - ).rejects.toThrow("sentinel vanished without a native-child outcome"); + ).rejects.toThrow("before authoritative whole-group cleanup"); }); it("fails closed when the POSIX sentinel disappears while cleanup is starting", async () => { @@ -987,13 +1059,12 @@ describe("packaged desktop startup verification", () => { { platform: "darwin", childIsAlive: () => true, - posixPayloadCompletionProven: () => false, targetIsAlive: () => false, waitForTargetsExit: async () => true, }, [84], ), - ).rejects.toThrow("sentinel vanished without a native-child outcome"); + ).rejects.toThrow("before authoritative whole-group cleanup"); }); it("refuses numeric POSIX signaling after the retained sentinel exits early", async () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index ca6a2ded..bc1404cb 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -14,7 +14,7 @@ import { statSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; +import { constants as osConstants, tmpdir } from "node:os"; import { basename, dirname, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; @@ -167,6 +167,13 @@ export function parsePackagedDesktopStartupArgs( const PREPARATION_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; const PREPARATION_CLOSE_TIMEOUT_MS = 2_000; +export class PackagedPreparationCleanupError extends Error { + constructor(message: string, cleanupCause: unknown) { + super(message, { cause: cleanupCause }); + this.name = "PackagedPreparationCleanupError"; + } +} + function delay(milliseconds: number): Promise { return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); } @@ -234,19 +241,26 @@ export async function runPackagedPreparationCommand( try { await abortCleanup; } catch (cleanupError) { - throw new AggregateError( - [abortReason, cleanupError], + throw new PackagedPreparationCleanupError( `${command} preparation was interrupted and cleanup failed.`, + new AggregateError([abortReason, cleanupError]), + ); + } + try { + exitOutcome = await Promise.race([ + closed, + delay(PREPARATION_CLOSE_TIMEOUT_MS).then(() => { + throw new Error( + `${command} did not close its stdio within ${PREPARATION_CLOSE_TIMEOUT_MS}ms after cleanup.`, + ); + }), + ]); + } catch (cleanupError) { + throw new PackagedPreparationCleanupError( + `${command} preparation cleanup did not complete safely.`, + cleanupError, ); } - exitOutcome = await Promise.race([ - closed, - delay(PREPARATION_CLOSE_TIMEOUT_MS).then(() => { - throw new Error( - `${command} did not close its stdio within ${PREPARATION_CLOSE_TIMEOUT_MS}ms after cleanup.`, - ); - }), - ]); throw abortReason; } exitOutcome ??= await closed; @@ -406,9 +420,14 @@ export async function attachMacDiskImageForInspection( await runCommand("hdiutil", ["detach", "-force", mountPoint], { signal: AbortSignal.timeout(30_000), }); - } catch { - // A detach failure is expected when the image never mounted. Preserve - // the authoritative attach/cancellation error. + } catch (detachError) { + const message = detachError instanceof Error ? detachError.message : String(detachError); + if (!/not currently mounted|no such file or directory/i.test(message)) { + throw new PackagedPreparationCleanupError( + `Failed to establish that interrupted disk-image mount ${mountPoint} was detached.`, + new AggregateError([attachError, detachError]), + ); + } } throw attachError; } @@ -830,7 +849,6 @@ export interface ProcessTerminationDependencies { readonly terminateRoot?: (child: ChildProcess) => boolean; readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; readonly targetIsAlive?: (target: ProcessTerminationTarget) => boolean; - readonly posixPayloadCompletionProven?: () => boolean; readonly waitForPosixPayloadExit?: (timeoutMs: number) => Promise; readonly waitForTargetsExit?: ( targets: ReadonlyArray, @@ -919,13 +937,9 @@ export async function terminateProcessTree( ...observedTargets.filter((target) => target.pid !== rootTarget?.pid), ]; if (targets.length === 0) { - if ( - platform !== "win32" && - child.pid && - dependencies.posixPayloadCompletionProven?.() !== true - ) { + if (platform !== "win32" && child.pid) { throw new Error( - "Packaged POSIX sentinel vanished without a native-child outcome; preserving evidence because unobserved descendants may remain.", + "Packaged POSIX sentinel vanished before authoritative whole-group cleanup; preserving evidence because unobserved descendants may remain.", ); } return; @@ -948,26 +962,18 @@ export async function terminateProcessTree( } if (!rootTarget) { - if (await awaitTargetsExit(targets, 2_000)) { - if (dependencies.posixPayloadCompletionProven?.() === true) return; - throw new Error( - "Packaged POSIX sentinel vanished without a native-child outcome; preserving evidence because unobserved descendants may remain.", - ); - } + await awaitTargetsExit(targets, 2_000); throw new Error( - `Packaged POSIX sentinel exited before cleanup while observed descendants ${targets.map(({ pid }) => pid).join(", ")} remained; refusing numeric signaling authority.`, + `Packaged POSIX sentinel exited before authoritative whole-group cleanup; preserving evidence and refusing numeric signaling authority for observed descendants ${targets.map(({ pid }) => pid).join(", ")}.`, ); } const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; if (!targetIsAlive(rootTarget)) { - if (await awaitTargetsExit(targets, 2_000)) { - if (dependencies.posixPayloadCompletionProven?.() === true) return; - throw new Error( - "Packaged POSIX sentinel vanished without a native-child outcome; preserving evidence because unobserved descendants may remain.", - ); - } - throw new Error("Packaged POSIX sentinel disappeared before its descendants were reaped."); + await awaitTargetsExit(targets, 2_000); + throw new Error( + "Packaged POSIX sentinel disappeared before authoritative whole-group cleanup; preserving evidence because its group can no longer be signaled safely.", + ); } sendSignal(rootTarget, "SIGTERM"); // The sentinel ignores TERM and remains the original process-group member @@ -1077,6 +1083,8 @@ interface InspectedPackagedDesktopChildOutcome { readonly outcome: PackagedDesktopChildOutcome; } +const PACKAGED_NATIVE_CHILD_SIGNALS = new Set(Object.keys(osConstants.signals)); + export function resolvePackagedNativeChildOutcomePath(environment: NodeJS.ProcessEnv): string { const scientHome = environment.SCIENT_HOME?.trim(); if (!scientHome) throw new Error("Packaged startup smoke requires an isolated SCIENT_HOME."); @@ -1095,10 +1103,13 @@ function inspectPackagedNativeChildOutcome( }; const exited = parsed.exited; const launchError = parsed.launchError; + const validExitCode = + typeof exited?.code === "number" && Number.isSafeInteger(exited.code) && exited.code >= 0; + const validSignal = + typeof exited?.signal === "string" && PACKAGED_NATIVE_CHILD_SIGNALS.has(exited.signal); if ( exited && - (typeof exited.code === "number" || exited.code === null) && - (typeof exited.signal === "string" || exited.signal === null) && + ((validExitCode && exited.signal === null) || (exited.code === null && validSignal)) && launchError === null ) { return { @@ -1112,7 +1123,12 @@ function inspectPackagedNativeChildOutcome( }, }; } - if (exited === null && launchError && typeof launchError.message === "string") { + if ( + exited === null && + launchError && + typeof launchError.message === "string" && + launchError.message.trim().length > 0 + ) { return { evidence: "proven", outcome: { exited: null, launchError: new Error(launchError.message) }, @@ -1295,6 +1311,7 @@ async function verifyPackagedDesktopPayload( let logPath: string | null = null; let output = ""; const failures: Array<{ phase: string; error: unknown }> = []; + let processCleanupFailed = false; const termination = monitorPackagedStartupTermination(); try { const preparationSignal = AbortSignal.any([ @@ -1342,10 +1359,14 @@ async function verifyPackagedDesktopPayload( }), ]); } catch (error) { - failures.push({ phase: "startup verification failed", error }); + if (error instanceof PackagedPreparationCleanupError) { + processCleanupFailed = true; + failures.push({ phase: "preparation process cleanup failed", error }); + } else { + failures.push({ phase: "startup verification failed", error }); + } } - let processCleanupFailed = false; try { if (child) { await terminateProcessTree( @@ -1353,9 +1374,6 @@ async function verifyPackagedDesktopPayload( { ...(process.platform !== "win32" && environment ? { - posixPayloadCompletionProven: () => { - return hasProvenPackagedNativeChildOutcome(environment!); - }, waitForPosixPayloadExit: (timeoutMs: number) => waitForPackagedNativeChildOutcome(environment!, timeoutMs), } From 056524310dcd83ebb27178c0cfe681da94e7cf24 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 05:41:27 +0300 Subject: [PATCH 23/30] fix(release): authenticate Windows startup verifier --- scripts/lib/packaged-startup-windows-job.ps1 | 9 ++ .../verify-packaged-desktop-startup.test.ts | 95 ++++++++++++++++++- scripts/verify-packaged-desktop-startup.ts | 19 ++-- 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/scripts/lib/packaged-startup-windows-job.ps1 b/scripts/lib/packaged-startup-windows-job.ps1 index cd8f9331..dbc4ab0a 100644 --- a/scripts/lib/packaged-startup-windows-job.ps1 +++ b/scripts/lib/packaged-startup-windows-job.ps1 @@ -7,6 +7,15 @@ param( $ErrorActionPreference = 'Stop' +# CreateProcess inherits this launcher's process environment. Binding the +# verifier authority to this retained direct parent gives Windows the same +# non-inheritable-by-accident contract as the POSIX sentinel. +[Environment]::SetEnvironmentVariable( + 'SCIENT_PACKAGED_STARTUP_SENTINEL_PID', + [string]$PID, + 'Process' +) + $source = @' using System; using System.ComponentModel; diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 3109e7f4..aa62973a 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { existsSync, @@ -30,6 +30,7 @@ import { monitorPackagedStartupTermination, PackagedPreparationCleanupError, parsePackagedDesktopStartupArgs, + prepareMacLaunch, readWindowsExecutableArchitecture, readPackagedDesktopLogTail, readPackagedBackendProcessIds, @@ -572,6 +573,38 @@ describe("packaged desktop startup verification", () => { expect(runCommand).toHaveBeenCalledTimes(3); }); + it("preserves a mounted DMG when inspection and both detach attempts fail", async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-dmg-inspection-test-")); + temporaryRoots.push(root); + writeFileSync(join(root, "Scient-1.2.3-arm64.dmg"), "fixture"); + const extractionRoot = join(root, "mount"); + const executableDirectory = join(extractionRoot, "Scient.app", "Contents", "MacOS"); + mkdirSync(executableDirectory, { recursive: true }); + writeFileSync(join(executableDirectory, "Scient"), "fixture"); + const runCommand = vi.fn(async (_command: string, args: ReadonlyArray) => { + if (args[0] === "attach") return ""; + if (args[0] === "detach") throw new Error(args[1] === "-force" ? "force failed" : "busy"); + if (args[0] === "-extract") throw new Error("plist inspection failed"); + return ""; + }) as unknown as typeof runPackagedPreparationCommand; + + await expect( + prepareMacLaunch( + root, + extractionRoot, + "Scient-1.2.3-arm64.dmg", + { arch: "arm64", version: "1.2.3" }, + new AbortController().signal, + runCommand, + ), + ).rejects.toBeInstanceOf(PackagedPreparationCleanupError); + expect(runCommand).toHaveBeenCalledWith( + "hdiutil", + ["detach", "-force", extractionRoot], + expect.any(Object), + ); + }); + it("rejects startup proof when the process handle closes before the exit event arrives", async () => { let now = 0; await expect( @@ -724,6 +757,8 @@ describe("packaged desktop startup verification", () => { expect(jobScript).toContain("EXTENDED_STARTUPINFO_PRESENT"); expect(jobScript).toContain("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"); expect(jobScript).toContain("PROC_THREAD_ATTRIBUTE_JOB_LIST"); + expect(jobScript).toContain("SCIENT_PACKAGED_STARTUP_SENTINEL_PID"); + expect(jobScript).toContain("[string]$PID"); expect(jobScript).not.toContain("AssignProcessToJobObject"); expect(jobScript.indexOf("if (!UpdateProcThreadAttribute(")).toBeLessThan( jobScript.indexOf("if (!CreateProcess("), @@ -749,6 +784,64 @@ describe("packaged desktop startup verification", () => { expect(posixCall[2]).toEqual(expect.objectContaining({ detached: true })); }); + it.skipIf(process.platform !== "win32")( + "passes authenticated direct-parent authority through the actual Windows Job launcher", + async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-job-authority-test-")); + temporaryRoots.push(root); + const executable = join(root, "AuthorityProbe.exe"); + const markerPath = join(root, "authority.marker"); + const powershell = join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const source = [ + "using System;", + "using System.IO;", + "public static class AuthorityProbe {", + " public static void Main() {", + ' File.WriteAllText(Environment.GetEnvironmentVariable("SCIENT_AUTHORITY_MARKER_PATH"),', + ' Environment.GetEnvironmentVariable("SCIENT_PACKAGED_STARTUP_SENTINEL_PID") ?? "missing");', + " }", + "}", + ].join(" "); + execFileSync( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + `Add-Type -TypeDefinition $env:SCIENT_AUTHORITY_PROBE_SOURCE -Language CSharp -OutputAssembly '${executable.replaceAll("'", "''")}' -OutputType ConsoleApplication`, + ], + { env: { ...process.env, SCIENT_AUTHORITY_PROBE_SOURCE: source }, stdio: "pipe" }, + ); + + const launcher = spawnContainedPackagedDesktop( + { command: executable, args: [], cwd: root }, + { + ...process.env, + SCIENT_HOME: join(root, "scient-home"), + SCIENT_PACKAGED_STARTUP_SMOKE: "1", + SCIENT_AUTHORITY_MARKER_PATH: markerPath, + }, + "win32", + ); + await new Promise((resolve, reject) => { + launcher.once("error", reject); + launcher.once("exit", (code) => + code === 0 ? resolve() : reject(new Error(`Windows Job launcher exited ${code}.`)), + ); + }); + + expect(readFileSync(markerPath, "utf8")).toBe(String(launcher.pid)); + }, + 30_000, + ); + it.skipIf(process.platform !== "win32")( "atomically kills the suspended Windows payload when its launcher is interrupted pre-resume", async () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index bc1404cb..17f7999b 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -462,20 +462,21 @@ export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchC } } -async function prepareMacLaunch( +export async function prepareMacLaunch( assetsDirectory: string, extractionRoot: string, expectedAssetName: string, options: Pick, signal: AbortSignal, + runCommand: PackagedPreparationCommandRunner = runPackagedPreparationCommand, ): Promise { const archive = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); const isDiskImage = expectedAssetName.endsWith(".dmg"); const cleanup = isDiskImage - ? await attachMacDiskImageForInspection(archive, extractionRoot, signal) + ? await attachMacDiskImageForInspection(archive, extractionRoot, signal, runCommand) : undefined; if (!isDiskImage) { - await runPackagedPreparationCommand("ditto", ["-x", "-k", archive, extractionRoot], { signal }); + await runCommand("ditto", ["-x", "-k", archive, extractionRoot], { signal }); } try { const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); @@ -490,17 +491,17 @@ async function prepareMacLaunch( throw new Error(`Expected one macOS main executable, found ${executables.length}.`); } const infoPlist = join(appBundle, "Contents", "Info.plist"); - const bundleIdentifier = await runPackagedPreparationCommand( + const bundleIdentifier = await runCommand( "plutil", ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlist], { signal }, ); - const bundleVersion = await runPackagedPreparationCommand( + const bundleVersion = await runCommand( "plutil", ["-extract", "CFBundleShortVersionString", "raw", "-o", "-", infoPlist], { signal }, ); - const bundleExecutable = await runPackagedPreparationCommand( + const bundleExecutable = await runCommand( "plutil", ["-extract", "CFBundleExecutable", "raw", "-o", "-", infoPlist], { signal }, @@ -516,7 +517,7 @@ async function prepareMacLaunch( } const expectedArchitecture = options.arch === "x64" ? "x86_64" : options.arch; const executableArchitectures = ( - await runPackagedPreparationCommand("lipo", ["-archs", executables[0]!], { + await runCommand("lipo", ["-archs", executables[0]!], { signal, }) ) @@ -541,9 +542,9 @@ async function prepareMacLaunch( try { await cleanup(); } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], + throw new PackagedPreparationCleanupError( `Failed to inspect and detach ${basename(archive)}.`, + new AggregateError([error, cleanupError]), ); } } From 6f0591c920405108a6863a4b7c5ad81a46c9edf6 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 05:55:04 +0300 Subject: [PATCH 24/30] ci(release): exercise Windows startup launcher --- .github/workflows/ci.yml | 3 +++ scripts/release-smoke.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 047fcd02..e286036b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,6 +205,9 @@ jobs: bun run --cwd apps/desktop test src/desktopBackendSupervisor.test.ts src/backendProcessTree.test.ts bun run --cwd apps/server test src/desktopParentShutdown.test.ts + - name: Test packaged startup Windows launcher + run: bun x vitest run scripts/verify-packaged-desktop-startup.test.ts + - name: Exercise Windows release staging run: bun run release:smoke diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index d9a52045..5dc978f3 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -238,6 +238,10 @@ function verifyReleaseWorkflowSafety(): void { resolve(repoRoot, ".github/workflows/release.yml"), "utf8", ).replaceAll("\r\n", "\n"); + const ciWorkflow = readFileSync(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8").replaceAll( + "\r\n", + "\n", + ); const releaseBuildScript = readFileSync( resolve(repoRoot, "scripts/build-release-desktop-artifact.sh"), "utf8", @@ -259,6 +263,20 @@ function verifyReleaseWorkflowSafety(): void { release?: { permissions?: Record }; }; }; + const parsedCiWorkflow = parseYaml(ciWorkflow) as { + jobs?: { windows_process?: { steps?: Array } }; + }; + const windowsProcessSteps = parsedCiWorkflow.jobs?.windows_process?.steps ?? []; + const windowsLauncherTest = windowsProcessSteps.find( + (step) => step.name === "Test packaged startup Windows launcher", + ); + if ( + windowsLauncherTest?.run !== "bun x vitest run scripts/verify-packaged-desktop-startup.test.ts" + ) { + throw new Error( + "Expected Windows CI to compile and exercise packaged-startup Job launcher authority and cancellation.", + ); + } const buildSteps = parsedWorkflow.jobs?.build?.steps ?? []; const buildMatrix = parsedWorkflow.jobs?.build?.strategy?.matrix?.include ?? []; const preflightSteps = parsedWorkflow.jobs?.preflight?.steps ?? []; From 7b9ea4e772b96494804e83ada881ea49e7ddf804 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 06:31:57 +0300 Subject: [PATCH 25/30] fix(release): close packaged startup proof gaps --- apps/desktop/src/main.ts | 15 +++++- apps/server/src/os-jank.test.ts | 21 +++++++++ apps/server/src/os-jank.ts | 1 + .../Layers/ProviderRuntimeManager.test.ts | 42 +++++++++++++++++ .../provider/Layers/ProviderRuntimeManager.ts | 1 + .../verify-packaged-desktop-startup.test.ts | 47 +++++++++++++++++++ scripts/verify-packaged-desktop-startup.ts | 26 ++++++++-- 7 files changed, 148 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 11fe032b..97c4255c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3544,6 +3544,11 @@ function createWindow(): BrowserWindow { const packagedWindowVisibility = new Promise((resolveVisibility) => { resolvePackagedWindowVisibility = resolveVisibility; }); + const verifierOwnedPackagedStartupSmoke = isVerifierOwnedPackagedStartupSmoke( + process.env, + process.ppid, + app.isPackaged, + ); browserManager.setWindow(window); attachDesktopZoomFactorSync(window); @@ -3599,7 +3604,7 @@ function createWindow(): BrowserWindow { writeDesktopLogHeader("renderer main frame loaded"); window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); - if (isVerifierOwnedPackagedStartupSmoke(process.env, process.ppid, app.isPackaged)) { + if (verifierOwnedPackagedStartupSmoke) { setTimeout(() => { const generation = getBackendSupervisor().currentGeneration; void Promise.all([ @@ -3655,6 +3660,11 @@ function createWindow(): BrowserWindow { window.on("unresponsive", () => { writeDesktopLogHeader("renderer main window unresponsive"); }); + window.on("hide", () => { + if (verifierOwnedPackagedStartupSmoke) { + writeDesktopLogHeader("packaged main window hidden"); + } + }); window.once("ready-to-show", () => { // Preserve the original first-launch behavior, then respect the state saved // by subsequent closes. Normal bounds are restored before maximizing so the @@ -3707,6 +3717,9 @@ function createWindow(): BrowserWindow { } window.on("closed", () => { + if (verifierOwnedPackagedStartupSmoke) { + writeDesktopLogHeader("packaged main window closed"); + } if (mainWindow === window) { mainWindow = null; } diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index 81d53b15..0735ad9b 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -56,6 +56,27 @@ describe("fixPath", () => { expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); + it("does not inspect host shell state when environment sync is disabled", () => { + const env: NodeJS.ProcessEnv = { + SCIENT_DISABLE_SHELL_ENV_SYNC: "1", + SHELL: "/bin/zsh", + PATH: "/isolated/bin", + }; + const readPath = vi.fn(() => "/host/bin"); + const readLaunchctlPath = vi.fn(() => "/host/launchctl/bin"); + + fixPath({ + env, + platform: "darwin", + readPath, + readLaunchctlPath, + }); + + expect(readPath).not.toHaveBeenCalled(); + expect(readLaunchctlPath).not.toHaveBeenCalled(); + expect(env.PATH).toBe("/isolated/bin"); + }); + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index a201e6c3..21148c05 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -29,6 +29,7 @@ export function fixPath( if (platform !== "darwin" && platform !== "linux") return; const env = options.env ?? process.env; + if (env.SCIENT_DISABLE_SHELL_ENV_SYNC === "1") return; const logWarning = options.logWarning ?? logPathHydrationWarning; const readPath = options.readPath ?? readPathFromLoginShell; diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts index bd038303..7307e804 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.test.ts @@ -271,6 +271,48 @@ describe("ProviderRuntimeManager managed integrity", () => { }, ); + it.runIf(process.platform === "win32")( + "does not inspect persistent Windows environment when sync is disabled", + async () => { + const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-windows-isolated-env-")); + const previousDisabled = process.env.SCIENT_DISABLE_SHELL_ENV_SYNC; + const previousPath = process.env.PATH; + shellMocks.readWindowsPersistentEnvironmentAsync.mockClear(); + try { + process.env.SCIENT_DISABLE_SHELL_ENV_SYNC = "1"; + process.env.PATH = path.join(baseDir, "isolated-empty"); + mkdirSync(process.env.PATH); + + const configLayer = ServerConfig.layerTest(baseDir, baseDir).pipe( + Layer.provide(NodeServices.layer), + ); + const runtimeLayer = ProviderRuntimeManagerLive.pipe(Layer.provide(configLayer)); + const layer = Layer.mergeAll(configLayer, runtimeLayer).pipe( + Layer.provide(NodeServices.layer), + ); + await Effect.runPromise( + Effect.gen(function* () { + const manager = yield* ProviderRuntimeManager; + yield* manager.resolve("codex"); + }).pipe(Effect.provide(layer), Effect.scoped), + ); + + expect(shellMocks.readWindowsPersistentEnvironmentAsync).not.toHaveBeenCalled(); + } finally { + shellMocks.readWindowsPersistentEnvironmentAsync.mockImplementation( + async (): Promise>> => ({ + ...process.env, + }), + ); + if (previousDisabled === undefined) delete process.env.SCIENT_DISABLE_SHELL_ENV_SYNC; + else process.env.SCIENT_DISABLE_SHELL_ENV_SYNC = previousDisabled; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + rmSync(baseDir, { recursive: true, force: true }); + } + }, + ); + it("revalidates the executable after restart and rejects later corruption", async () => { const baseDir = mkdtempSync(path.join(os.tmpdir(), "scient-runtime-integrity-")); const previousPath = process.env.PATH; diff --git a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts index 8f5fb198..eadfbe01 100644 --- a/apps/server/src/provider/Layers/ProviderRuntimeManager.ts +++ b/apps/server/src/provider/Layers/ProviderRuntimeManager.ts @@ -484,6 +484,7 @@ export const ProviderRuntimeManagerLive = Layer.effect( const currentSystemEnvironment = async (): Promise>> => { if (process.platform !== "win32") return process.env; + if (process.env.SCIENT_DISABLE_SHELL_ENV_SYNC === "1") return process.env; if ( windowsEnvironmentCache && Date.now() - windowsEnvironmentReadAt < WINDOWS_ENVIRONMENT_CACHE_MS diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index aa62973a..a05eabf7 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -284,6 +284,8 @@ describe("packaged desktop startup verification", () => { "renderer main frame load failed code=-2 message=failed", "renderer main process gone reason=crashed exitCode=1", "renderer main window unresponsive", + "packaged main window hidden", + "packaged main window closed", "packaged responsiveness failed message=frozen", "backend process exited generation=1 pid=42 reason=unexpected exit", ]) { @@ -390,6 +392,24 @@ describe("packaged desktop startup verification", () => { expect(now).toBeGreaterThanOrEqual(1_000); }); + it("rejects proof invalidated by a window close during the stability window", async () => { + let now = 0; + let windowClosed = false; + await expect( + waitForPackagedStartupProof({ + timeoutMs: 1_000, + stableForMs: 500, + hasProof: () => !windowClosed, + readOutcome: () => ({ exited: null, launchError: null }), + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + windowClosed = true; + }, + }), + ).rejects.toThrow("timed out"); + }); + it("turns interrupt signals into an observable cleanup request and removes its listeners", async () => { const source = new EventEmitter(); const termination = monitorPackagedStartupTermination(source); @@ -468,6 +488,33 @@ describe("packaged desktop startup verification", () => { await expect(command).rejects.toBeInstanceOf(PackagedPreparationCleanupError); }); + it("fails closed when Windows preparation descendants are not Job-contained", async () => { + const abortController = new AbortController(); + const kill = vi.fn(() => true); + const spawnProcess = vi.fn(() => { + const child = new EventEmitter() as ChildProcess; + Object.assign(child, { + exitCode: null, + kill, + pid: 42, + signalCode: null, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + return child; + }) as unknown as typeof import("node:child_process").spawn; + const command = runPackagedPreparationCommand("7z", ["hung.exe"], { + platform: "win32", + signal: abortController.signal, + spawnProcess, + }); + + abortController.abort(new Error("interrupted")); + + await expect(command).rejects.toBeInstanceOf(PackagedPreparationCleanupError); + expect(kill).toHaveBeenCalledOnce(); + }); + it("attempts to detach a partially mounted DMG after attach fails", async () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; const attachError = new Error("attach interrupted"); diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 17f7999b..b25a655f 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -183,15 +183,17 @@ export async function runPackagedPreparationCommand( args: ReadonlyArray, options: { readonly cwd?: string; + readonly platform?: NodeJS.Platform; readonly signal: AbortSignal; readonly spawnProcess?: typeof spawn; readonly terminateProcess?: (child: ChildProcess) => Promise; }, ): Promise { if (options.signal.aborted) throw options.signal.reason; + const platform = options.platform ?? process.platform; const child = (options.spawnProcess ?? spawn)(command, [...args], { cwd: options.cwd, - detached: process.platform !== "win32", + detached: platform !== "win32", shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, @@ -226,9 +228,23 @@ export async function runPackagedPreparationCommand( }); const handleAbort = () => { abortReason = options.signal.reason ?? new Error(`${command} preparation was aborted.`); - abortCleanup ??= Promise.resolve().then(() => - (options.terminateProcess ?? terminateProcessTree)(child), - ); + abortCleanup ??= Promise.resolve().then(async () => { + if (options.terminateProcess) { + await options.terminateProcess(child); + return; + } + if (platform === "win32") { + // Preparation helpers do not run beneath the packaged desktop's Job + // launcher. Closing only the direct ChildProcess handle cannot prove + // that a helper's descendants were terminated, so preserve the + // evidence instead of claiming cleanup succeeded. + child.kill(); + throw new Error( + "Windows preparation cleanup cannot prove descendant termination without Job Object containment.", + ); + } + await terminateProcessTree(child, { platform }); + }); resolveAbortStarted(); }; options.signal.addEventListener("abort", handleAbort, { once: true }); @@ -1010,6 +1026,8 @@ export function hasPackagedStartupProof( !log.includes("renderer main frame load failed") && !log.includes("renderer main process gone") && !log.includes("renderer main window unresponsive") && + !log.includes("packaged main window hidden") && + !log.includes("packaged main window closed") && !log.includes("packaged responsiveness failed") && !log.includes("backend process exited generation=") ); From a4d3faaf297fafbb4f7e491a803e525a6251a6cd Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 07:01:26 +0300 Subject: [PATCH 26/30] fix(release): harden packaged startup certification --- apps/desktop/src/main.ts | 40 +--- .../packagedStartupWindowLifecycle.test.ts | 40 ++++ .../src/packagedStartupWindowLifecycle.ts | 20 ++ apps/server/src/codexAppServerManager.test.ts | 39 ++++ apps/server/src/codexProcessEnv.ts | 5 +- .../packagedStartupRendererReadiness.test.ts | 49 +++++ .../lib/packagedStartupRendererReadiness.ts | 17 ++ apps/web/src/main.tsx | 12 - apps/web/src/routes/__root.tsx | 24 +- scripts/lib/packaged-startup-windows-job.ps1 | 41 +++- scripts/release-smoke.ts | 38 +++- .../verify-packaged-desktop-startup.test.ts | 144 +++++++++++- scripts/verify-packaged-desktop-startup.ts | 208 ++++++++++++++---- 13 files changed, 575 insertions(+), 102 deletions(-) create mode 100644 apps/desktop/src/packagedStartupWindowLifecycle.test.ts create mode 100644 apps/desktop/src/packagedStartupWindowLifecycle.ts create mode 100644 apps/web/src/lib/packagedStartupRendererReadiness.test.ts create mode 100644 apps/web/src/lib/packagedStartupRendererReadiness.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 97c4255c..90c4e085 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -95,6 +95,7 @@ import { collectMacUpdateDiagnostics } from "./macUpdateDiagnostics"; import { openInitialBackendWindow } from "./initialBackendWindowOpen"; import { shouldAllowMediaPermissionRequest } from "./mediaPermissions"; import { PACKAGED_RENDERER_READINESS_EXPRESSION } from "./packagedStartupRendererReadiness"; +import { attachPackagedStartupWindowLifecycleProof } from "./packagedStartupWindowLifecycle"; import { installResumableUpdateDownloader, type ResumableDownloaderTarget, @@ -1483,11 +1484,7 @@ function configureApplicationMenu(): void { ? [ { role: "resetZoom" }, { role: "zoomIn", ...acceleratorProps("CmdOrCtrl+=") }, - { - role: "zoomIn", - ...acceleratorProps("CmdOrCtrl+Plus"), - visible: false, - }, + { role: "zoomIn", ...acceleratorProps("CmdOrCtrl+Plus"), visible: false }, { role: "zoomOut" }, ] : [ @@ -1799,14 +1796,8 @@ function showDesktopNotification(input: { function resolveUserDataPath(): string { const appDataBase = resolveDesktopAppDataBase(); const targetPath = resolveDesktopUserDataPath({ appDataBase, isDevelopment }); - const sourcePath = resolvePapiLabDesktopUserDataPath({ - appDataBase, - isDevelopment, - }); - const migration = seedDesktopUserDataProfileFromPapiLab({ - sourcePath, - targetPath, - }); + const sourcePath = resolvePapiLabDesktopUserDataPath({ appDataBase, isDevelopment }); + const migration = seedDesktopUserDataProfileFromPapiLab({ sourcePath, targetPath }); if (migration.status === "seed-failed") { console.warn("[desktop] Failed to seed Scient profile from PapiLab", migration.error); } @@ -1918,9 +1909,7 @@ function refreshMacIconCacheOnVersionChange(): void { // Read-only bundle: fall through to lsregister. } - const child = ChildProcess.spawn(LSREGISTER_PATH, ["-f", bundlePath], { - stdio: "ignore", - }); + const child = ChildProcess.spawn(LSREGISTER_PATH, ["-f", bundlePath], { stdio: "ignore" }); child.unref(); child.once("error", (error) => { console.warn("[desktop] Failed to refresh macOS icon cache after update", error); @@ -2646,9 +2635,7 @@ function configureAutoUpdater(): void { configuredGitHubUpdateSource = resolveGitHubUpdateSource(appUpdateYml); if (configuredGitHubUpdateSource !== null) { // The updater itself uses app-update.yml; this URL is only the human fallback. - setUpdateState({ - releaseUrl: buildGitHubReleasesPageUrl(configuredGitHubUpdateSource), - }); + setUpdateState({ releaseUrl: buildGitHubReleasesPageUrl(configuredGitHubUpdateSource) }); } autoUpdater.autoDownload = false; @@ -3470,9 +3457,7 @@ function getIconOption(): { icon: string } | Record { // (`show: false`), so this color is not expected to match a custom in-app theme exactly. function getWindowMaterialOptions(): BrowserWindowConstructorOptions { if (process.platform !== "darwin") { - return { - backgroundColor: nativeTheme.shouldUseDarkColors ? "#181818" : "#ffffff", - }; + return { backgroundColor: nativeTheme.shouldUseDarkColors ? "#181818" : "#ffffff" }; } return { vibrancy: "under-window", @@ -3660,10 +3645,10 @@ function createWindow(): BrowserWindow { window.on("unresponsive", () => { writeDesktopLogHeader("renderer main window unresponsive"); }); - window.on("hide", () => { - if (verifierOwnedPackagedStartupSmoke) { - writeDesktopLogHeader("packaged main window hidden"); - } + attachPackagedStartupWindowLifecycleProof({ + window, + enabled: verifierOwnedPackagedStartupSmoke, + writeFailureMarker: writeDesktopLogHeader, }); window.once("ready-to-show", () => { // Preserve the original first-launch behavior, then respect the state saved @@ -3717,9 +3702,6 @@ function createWindow(): BrowserWindow { } window.on("closed", () => { - if (verifierOwnedPackagedStartupSmoke) { - writeDesktopLogHeader("packaged main window closed"); - } if (mainWindow === window) { mainWindow = null; } diff --git a/apps/desktop/src/packagedStartupWindowLifecycle.test.ts b/apps/desktop/src/packagedStartupWindowLifecycle.test.ts new file mode 100644 index 00000000..af1e16e4 --- /dev/null +++ b/apps/desktop/src/packagedStartupWindowLifecycle.test.ts @@ -0,0 +1,40 @@ +import { EventEmitter } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { attachPackagedStartupWindowLifecycleProof } from "./packagedStartupWindowLifecycle"; + +describe("packaged startup window lifecycle proof", () => { + it("invalidates the production proof markers for hide and close events", () => { + const window = new EventEmitter(); + const writeFailureMarker = vi.fn(); + attachPackagedStartupWindowLifecycleProof({ + window, + enabled: true, + writeFailureMarker, + }); + + window.emit("hide"); + window.emit("closed"); + + expect(writeFailureMarker.mock.calls).toEqual([ + ["packaged main window hidden"], + ["packaged main window closed"], + ]); + }); + + it("does not observe ordinary user window lifecycle events", () => { + const window = new EventEmitter(); + const writeFailureMarker = vi.fn(); + attachPackagedStartupWindowLifecycleProof({ + window, + enabled: false, + writeFailureMarker, + }); + + window.emit("hide"); + window.emit("closed"); + + expect(writeFailureMarker).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/packagedStartupWindowLifecycle.ts b/apps/desktop/src/packagedStartupWindowLifecycle.ts new file mode 100644 index 00000000..3d531ba4 --- /dev/null +++ b/apps/desktop/src/packagedStartupWindowLifecycle.ts @@ -0,0 +1,20 @@ +export const PACKAGED_MAIN_WINDOW_HIDDEN_MARKER = "packaged main window hidden"; +export const PACKAGED_MAIN_WINDOW_CLOSED_MARKER = "packaged main window closed"; + +interface PackagedStartupWindowLifecycleSource { + on(event: "hide" | "closed", listener: () => void): unknown; +} + +export function attachPackagedStartupWindowLifecycleProof(input: { + readonly window: PackagedStartupWindowLifecycleSource; + readonly enabled: boolean; + readonly writeFailureMarker: (marker: string) => void; +}): void { + if (!input.enabled) return; + input.window.on("hide", () => { + input.writeFailureMarker(PACKAGED_MAIN_WINDOW_HIDDEN_MARKER); + }); + input.window.on("closed", () => { + input.writeFailureMarker(PACKAGED_MAIN_WINDOW_CLOSED_MARKER); + }); +} diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index b760382f..4a5f8181 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -424,6 +424,45 @@ describe("buildCodexProcessEnv", () => { expect(env.AZURE_OPENAI_API_KEY).toBe("existing-secret"); }); + it("keeps provider discovery isolated when shell environment synchronization is disabled", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "synara-codex-env-isolated-")); + try { + writeFileSync( + path.join(tempDir, "config.toml"), + [ + 'model_provider = "my-company-proxy"', + "", + '[model_providers."my-company-proxy"]', + 'env_key = "MY_COMPANY_PROXY_KEY"', + ].join("\n"), + "utf8", + ); + const readEnvironment = vi.fn(() => ({ + PATH: "/host/private/bin:/usr/bin", + SSH_AUTH_SOCK: "/host/private/ssh.sock", + MY_COMPANY_PROXY_KEY: "host-secret", + })); + + const env = await buildCodexProcessEnv({ + env: { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SCIENT_DISABLE_SHELL_ENV_SYNC: "1", + }, + homePath: tempDir, + platform: "darwin", + readEnvironment, + }); + + expect(readEnvironment).not.toHaveBeenCalled(); + expect(env.PATH).toBe("/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBeUndefined(); + expect(env.MY_COMPANY_PROXY_KEY).toBeUndefined(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("allows the configured desktop browser-use socket in the Codex sandbox", async () => { const env = await buildCodexProcessEnv({ env: { diff --git a/apps/server/src/codexProcessEnv.ts b/apps/server/src/codexProcessEnv.ts index f049b033..02962713 100644 --- a/apps/server/src/codexProcessEnv.ts +++ b/apps/server/src/codexProcessEnv.ts @@ -334,7 +334,10 @@ export async function buildCodexProcessEnv( : baseEnv; const platform = input.platform ?? process.platform; - if (platform === "darwin" || platform === "linux") { + if ( + effectiveEnv.SCIENT_DISABLE_SHELL_ENV_SYNC !== "1" && + (platform === "darwin" || platform === "linux") + ) { try { const shell = resolveLoginShell(platform, effectiveEnv.SHELL); const providerEnvKey = readActiveCodexProviderEnvKey(effectiveEnv); diff --git a/apps/web/src/lib/packagedStartupRendererReadiness.test.ts b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts new file mode 100644 index 00000000..82b89005 --- /dev/null +++ b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +import { markPackagedStartupRendererReadyAfterShellHydration } from "./packagedStartupRendererReadiness"; + +describe("packaged startup renderer readiness", () => { + it("marks readiness only after the authoritative shell snapshot hydrates", async () => { + let resolveHydration!: () => void; + const hydration = new Promise((resolve) => { + resolveHydration = resolve; + }); + const element = { dataset: {} as DOMStringMap }; + const pending = markPackagedStartupRendererReadyAfterShellHydration({ + hydrateShell: () => hydration, + element, + }); + + expect(element.dataset.scientRendererReady).toBeUndefined(); + resolveHydration(); + const clear = await pending; + expect(element.dataset.scientRendererReady).toBe("true"); + + clear(); + expect(element.dataset.scientRendererReady).toBeUndefined(); + }); + + it("does not certify a renderer whose shell hydration fails", async () => { + const element = { dataset: {} as DOMStringMap }; + const hydrateShell = vi.fn(async () => { + throw new Error("preload bridge unavailable"); + }); + + await expect( + markPackagedStartupRendererReadyAfterShellHydration({ hydrateShell, element }), + ).rejects.toThrow("preload bridge unavailable"); + expect(element.dataset.scientRendererReady).toBeUndefined(); + }); + + it("does not mark a router disposed while hydration was pending", async () => { + const element = { dataset: {} as DOMStringMap }; + + await markPackagedStartupRendererReadyAfterShellHydration({ + hydrateShell: async () => undefined, + element, + shouldMark: () => false, + }); + + expect(element.dataset.scientRendererReady).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/packagedStartupRendererReadiness.ts b/apps/web/src/lib/packagedStartupRendererReadiness.ts new file mode 100644 index 00000000..d5805eb6 --- /dev/null +++ b/apps/web/src/lib/packagedStartupRendererReadiness.ts @@ -0,0 +1,17 @@ +export const PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY = "scientRendererReady"; + +type RendererReadinessElement = Pick; + +export async function markPackagedStartupRendererReadyAfterShellHydration(input: { + readonly hydrateShell: () => Promise; + readonly element?: RendererReadinessElement; + readonly shouldMark?: () => boolean; +}): Promise<() => void> { + await input.hydrateShell(); + if (input.shouldMark && !input.shouldMark()) return () => undefined; + const element = input.element ?? document.documentElement; + element.dataset[PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY] = "true"; + return () => { + delete element.dataset[PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY]; + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8b35064f..486a794a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -12,17 +12,6 @@ import { isElectron } from "./env"; const router = getRouter(appHistory); -function PackagedStartupReadyMarker() { - React.useEffect(() => { - document.documentElement.dataset.scientRendererReady = "true"; - return () => { - delete document.documentElement.dataset.scientRendererReady; - }; - }, []); - - return null; -} - document.title = APP_DISPLAY_NAME; if (isElectron) { @@ -31,7 +20,6 @@ if (isElectron) { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - , ); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 0f7639d1..3724ee53 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -38,6 +38,7 @@ import { useFocusedChatContext } from "../focusedChatContext"; import { useFeedbackDialogStore } from "../feedbackDialogStore"; import type { FeedbackThreadContext } from "../feedback"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { markPackagedStartupRendererReadyAfterShellHydration } from "../lib/packagedStartupRendererReadiness"; import { serverConfigQueryOptions, serverQueryKeys, @@ -686,6 +687,8 @@ function EventRouter() { let reconcileThreadSubscriptionsChain = Promise.resolve(); let scopedSubscriptionsStarted = false; let scopedSubscriptionsStartInFlight: Promise | null = null; + let clearPackagedStartupRendererReady: (() => void) | null = null; + let packagedStartupRendererReadinessInFlight: Promise<() => void> | null = null; const beginThreadSubscription = (threadId: ThreadId) => { threadSnapshotSequenceById.delete(threadId); @@ -1164,7 +1167,24 @@ function EventRouter() { if (disposed) { return; } - await loadShellSnapshotOnce(); + packagedStartupRendererReadinessInFlight ??= + markPackagedStartupRendererReadyAfterShellHydration({ + hydrateShell: loadShellSnapshotOnce, + shouldMark: () => !disposed, + }); + const readiness = packagedStartupRendererReadinessInFlight; + try { + clearPackagedStartupRendererReady ??= await readiness; + } finally { + if (packagedStartupRendererReadinessInFlight === readiness) { + packagedStartupRendererReadinessInFlight = null; + } + } + if (disposed) { + clearPackagedStartupRendererReady?.(); + clearPackagedStartupRendererReady = null; + return; + } if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; @@ -1290,6 +1310,8 @@ function EventRouter() { return () => { flushPendingDomainEvents(); disposed = true; + clearPackagedStartupRendererReady?.(); + clearPackagedStartupRendererReady = null; window.clearTimeout(shellBootstrapFallbackTimer); window.clearInterval(threadDetailCatchupInterval); needsProviderInvalidation = false; diff --git a/scripts/lib/packaged-startup-windows-job.ps1 b/scripts/lib/packaged-startup-windows-job.ps1 index dbc4ab0a..4768e33c 100644 --- a/scripts/lib/packaged-startup-windows-job.ps1 +++ b/scripts/lib/packaged-startup-windows-job.ps1 @@ -1,21 +1,14 @@ param( - [Parameter(Mandatory = $true)][string]$ExecutablePath, - [Parameter(Mandatory = $true)][string]$WorkingDirectory, + [string]$ExecutablePath = '', + [string]$WorkingDirectory = '', + [string]$AssemblyPath = '', + [string]$CompileAssemblyPath = '', [string]$PreResumeMarkerPath = '', [string]$PreResumeGatePath = '' ) $ErrorActionPreference = 'Stop' -# CreateProcess inherits this launcher's process environment. Binding the -# verifier authority to this retained direct parent gives Windows the same -# non-inheritable-by-accident contract as the POSIX sentinel. -[Environment]::SetEnvironmentVariable( - 'SCIENT_PACKAGED_STARTUP_SENTINEL_PID', - [string]$PID, - 'Process' -) - $source = @' using System; using System.ComponentModel; @@ -290,7 +283,31 @@ public static class ScientPackagedStartupJobLauncher } '@ -Add-Type -TypeDefinition $source -Language CSharp +if (![string]::IsNullOrWhiteSpace($CompileAssemblyPath)) { + Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $CompileAssemblyPath -OutputType Library + exit 0 +} + +if ( + [string]::IsNullOrWhiteSpace($AssemblyPath) -or + [string]::IsNullOrWhiteSpace($ExecutablePath) -or + [string]::IsNullOrWhiteSpace($WorkingDirectory) +) { + throw 'AssemblyPath, ExecutablePath, and WorkingDirectory are required for launch.' +} + +# Load only the assembly compiled during the separately classified preparation +# phase. Runtime launch must not create compiler descendants before Job authority. +Add-Type -Path $AssemblyPath + +# CreateProcess inherits this launcher's process environment. Binding the +# verifier authority to this retained direct parent gives Windows the same +# non-inheritable-by-accident contract as the POSIX sentinel. +[Environment]::SetEnvironmentVariable( + 'SCIENT_PACKAGED_STARTUP_SENTINEL_PID', + [string]$PID, + 'Process' +) exit [ScientPackagedStartupJobLauncher]::Run( $ExecutablePath, $WorkingDirectory, diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 5dc978f3..d889c62e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -447,6 +447,14 @@ function verifyReleaseWorkflowSafety(): void { "\r\n", "\n", ); + const webRendererReadiness = readFileSync( + resolve(repoRoot, "apps/web/src/lib/packagedStartupRendererReadiness.ts"), + "utf8", + ).replaceAll("\r\n", "\n"); + const webRootRoute = readFileSync( + resolve(repoRoot, "apps/web/src/routes/__root.tsx"), + "utf8", + ).replaceAll("\r\n", "\n"); const rendererReadiness = readFileSync( resolve(repoRoot, "apps/desktop/src/packagedStartupRendererReadiness.ts"), "utf8", @@ -474,9 +482,19 @@ function verifyReleaseWorkflowSafety(): void { ); } assertContains( + webRendererReadiness, + "await input.hydrateShell();", + "Expected packaged startup readiness to wait for authoritative shell hydration.", + ); + assertContains( + webRootRoute, + "markPackagedStartupRendererReadyAfterShellHydration", + "Expected the server-welcome router lifecycle to own packaged renderer readiness.", + ); + assertNotContains( webMain, - 'document.documentElement.dataset.scientRendererReady = "true"', - "Expected packaged startup proof to depend on a renderer-owned React commit marker.", + "scientRendererReady", + "The renderer entrypoint must not certify readiness before its preload and server lifecycle complete.", ); assertContains( rendererReadiness, @@ -513,6 +531,22 @@ function verifyReleaseWorkflowSafety(): void { "taskkill", "Packaged startup cleanup must not restore numeric Windows process signaling authority.", ); + assertContains( + windowsStartupJobLauncher, + "Add-Type -Path $AssemblyPath", + "Expected runtime Windows launch to load the assembly compiled during preparation.", + ); + if ( + windowsStartupJobLauncher.indexOf("Add-Type -TypeDefinition $source") < 0 || + windowsStartupJobLauncher.indexOf("Add-Type -TypeDefinition $source") > + windowsStartupJobLauncher.indexOf("exit 0") || + windowsStartupJobLauncher.indexOf("exit 0") > + windowsStartupJobLauncher.indexOf("Add-Type -Path $AssemblyPath") + ) { + throw new Error( + "Expected Windows launcher compilation to exit in preparation before runtime Job launch.", + ); + } assertContains( posixStartupSentinel, 'process.on("SIGTERM", () => undefined)', diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index a05eabf7..8fa9136f 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -7,6 +7,7 @@ import { readFileSync, rmSync, statSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -20,6 +21,7 @@ import { assertPackagedLaunchCommandSafety, assertUnsignedWindowsReleaseSignatureDetails, assertWindowsReleaseSignatureDetails, + cleanupPackagedStartupTemporaryRoot, createPackagedDesktopSmokeEnvironment, expectedPackagedDesktopStartupAssetName, expectedPackagedDesktopStartupAssetNames, @@ -31,6 +33,7 @@ import { PackagedPreparationCleanupError, parsePackagedDesktopStartupArgs, prepareMacLaunch, + prepareWindowsJobLauncherAssembly, readWindowsExecutableArchitecture, readPackagedDesktopLogTail, readPackagedBackendProcessIds, @@ -410,6 +413,31 @@ describe("packaged desktop startup verification", () => { ).rejects.toThrow("timed out"); }); + it("re-reads the native child outcome at the stability acceptance boundary", async () => { + let now = 0; + let outcomeReads = 0; + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + stableForMs: 1_000, + hasProof: () => true, + readOutcome: () => { + outcomeReads += 1; + return outcomeReads === 7 + ? { exited: { code: 9, signal: null }, launchError: null } + : { exited: null, launchError: null }; + }, + isProcessAlive: () => true, + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + }, + }), + ).rejects.toThrow("code=9"); + expect(now).toBe(1_000); + expect(outcomeReads).toBe(7); + }); + it("turns interrupt signals into an observable cleanup request and removes its listeners", async () => { const source = new EventEmitter(); const termination = monitorPackagedStartupTermination(source); @@ -515,6 +543,34 @@ describe("packaged desktop startup verification", () => { expect(kill).toHaveBeenCalledOnce(); }); + it("compiles the Windows Job launcher only in the classified preparation phase", async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-job-prepare-test-")); + temporaryRoots.push(root); + const runCommand = vi.fn(async (_command: string, args: ReadonlyArray) => { + const outputIndex = args.indexOf("-CompileAssemblyPath"); + writeFileSync(args[outputIndex + 1]!, "prepared assembly"); + return ""; + }) as unknown as typeof runPackagedPreparationCommand; + + const assemblyPath = await prepareWindowsJobLauncherAssembly( + root, + new AbortController().signal, + runCommand, + ); + + expect(assemblyPath).toBe(join(root, "packaged-startup-windows-job.dll")); + expect(runCommand).toHaveBeenCalledWith( + expect.stringMatching(/powershell\.exe$/i), + expect.arrayContaining([ + "-File", + expect.stringMatching(/packaged-startup-windows-job\.ps1$/), + "-CompileAssemblyPath", + assemblyPath, + ]), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + it("attempts to detach a partially mounted DMG after attach fails", async () => { const calls: Array<{ command: string; args: ReadonlyArray }> = []; const attachError = new Error("attach interrupted"); @@ -628,6 +684,7 @@ describe("packaged desktop startup verification", () => { const executableDirectory = join(extractionRoot, "Scient.app", "Contents", "MacOS"); mkdirSync(executableDirectory, { recursive: true }); writeFileSync(join(executableDirectory, "Scient"), "fixture"); + writeFileSync(join(extractionRoot, "Scient.app", "Contents", "Info.plist"), "fixture"); const runCommand = vi.fn(async (_command: string, args: ReadonlyArray) => { if (args[0] === "attach") return ""; if (args[0] === "detach") throw new Error(args[1] === "-force" ? "force failed" : "busy"); @@ -652,6 +709,45 @@ describe("packaged desktop startup verification", () => { ); }); + it.each(["zip", "dmg"] as const)( + "rejects in-root and escaping Scient.app symlinks in the %s inspection path", + async (extension) => { + for (const targetLocation of ["inside", "outside"] as const) { + const root = mkdtempSync(join(tmpdir(), `scient-packaged-${extension}-symlink-test-`)); + temporaryRoots.push(root); + const expectedAssetName = `Scient-1.2.3-arm64.${extension}`; + writeFileSync(join(root, expectedAssetName), "fixture"); + const extractionRoot = join(root, "payload"); + mkdirSync(extractionRoot, { recursive: true }); + const target = + targetLocation === "inside" + ? join(extractionRoot, "bundle-target") + : join(root, "outside-bundle-target"); + mkdirSync(join(target, "Contents", "MacOS"), { recursive: true }); + writeFileSync(join(target, "Contents", "Info.plist"), "fixture"); + writeFileSync(join(target, "Contents", "MacOS", "Scient"), "fixture"); + symlinkSync(target, join(extractionRoot, "Scient.app"), "dir"); + const runCommandMock = vi.fn(async (_command: string, args: ReadonlyArray) => { + if (args[0] === "detach") return ""; + return ""; + }); + const runCommand = runCommandMock as unknown as typeof runPackagedPreparationCommand; + + await expect( + prepareMacLaunch( + root, + extractionRoot, + expectedAssetName, + { arch: "arm64", version: "1.2.3" }, + new AbortController().signal, + runCommand, + ), + ).rejects.toThrow("Expected the exact Scient.app bundle"); + expect(runCommandMock.mock.calls.some(([, args]) => args[0] === "-extract")).toBe(false); + } + }, + ); + it("rejects startup proof when the process handle closes before the exit event arrives", async () => { let now = 0; await expect( @@ -701,6 +797,28 @@ describe("packaged desktop startup verification", () => { ); }); + it("preserves top-level temporary evidence after process preparation cleanup fails", () => { + const remove = vi.fn(); + const temporaryRoot = "/tmp/scient-packaged-smoke-preserved"; + + const result = cleanupPackagedStartupTemporaryRoot({ + temporaryRoot, + processCleanupFailed: true, + remove, + }); + + expect(remove).not.toHaveBeenCalled(); + expect(result).toEqual({ + preserved: true, + failure: { + phase: "temporary-state cleanup skipped", + error: expect.objectContaining({ + message: `Preserved failed process evidence at ${temporaryRoot}.`, + }), + }, + }); + }); + it("exports bounded redacted failure evidence for hosted runners", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-diagnostics-test-")); temporaryRoots.push(root); @@ -777,7 +895,12 @@ describe("packaged desktop startup verification", () => { it("launches Windows suspended into a kill-on-close Job Object and macOS behind a sentinel", () => { const spawnProcess = vi.fn(() => ({ pid: 42 }) as unknown as ChildProcess); - const launch = { command: "/payload/Scient", args: [], cwd: "/payload" }; + const launch = { + command: "/payload/Scient", + args: [], + cwd: "/payload", + windowsJobAssemblyPath: "C:\\payload\\packaged-startup-windows-job.dll", + }; spawnContainedPackagedDesktop( launch, @@ -793,6 +916,8 @@ describe("packaged desktop startup verification", () => { expect.arrayContaining([ "-File", expect.stringMatching(/packaged-startup-windows-job\.ps1$/), + "-AssemblyPath", + launch.windowsJobAssemblyPath, ]), ); expect(windowsCall[2]).toEqual(expect.objectContaining({ detached: false })); @@ -805,6 +930,11 @@ describe("packaged desktop startup verification", () => { expect(jobScript).toContain("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"); expect(jobScript).toContain("PROC_THREAD_ATTRIBUTE_JOB_LIST"); expect(jobScript).toContain("SCIENT_PACKAGED_STARTUP_SENTINEL_PID"); + expect(jobScript).toContain("Add-Type -Path $AssemblyPath"); + expect(jobScript.indexOf("Add-Type -TypeDefinition $source")).toBeLessThan( + jobScript.indexOf("exit 0"), + ); + expect(jobScript.indexOf("exit 0")).toBeLessThan(jobScript.indexOf("Add-Type -Path")); expect(jobScript).toContain("[string]$PID"); expect(jobScript).not.toContain("AssignProcessToJobObject"); expect(jobScript.indexOf("if (!UpdateProcThreadAttribute(")).toBeLessThan( @@ -866,9 +996,13 @@ describe("packaged desktop startup verification", () => { ], { env: { ...process.env, SCIENT_AUTHORITY_PROBE_SOURCE: source }, stdio: "pipe" }, ); + const windowsJobAssemblyPath = await prepareWindowsJobLauncherAssembly( + root, + new AbortController().signal, + ); const launcher = spawnContainedPackagedDesktop( - { command: executable, args: [], cwd: root }, + { command: executable, args: [], cwd: root, windowsJobAssemblyPath }, { ...process.env, SCIENT_HOME: join(root, "scient-home"), @@ -903,6 +1037,10 @@ describe("packaged desktop startup verification", () => { "v1.0", "powershell.exe", ); + const windowsJobAssemblyPath = await prepareWindowsJobLauncherAssembly( + root, + new AbortController().signal, + ); const launcher = spawn( powershell, [ @@ -913,6 +1051,8 @@ describe("packaged desktop startup verification", () => { "Bypass", "-File", fileURLToPath(new URL("./lib/packaged-startup-windows-job.ps1", import.meta.url)), + "-AssemblyPath", + windowsJobAssemblyPath, "-ExecutablePath", powershell, "-WorkingDirectory", diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index b25a655f..e421c4d1 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -6,16 +6,18 @@ import { spawn, type ChildProcess } from "node:child_process"; import { chmodSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, + realpathSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { constants as osConstants, tmpdir } from "node:os"; -import { basename, dirname, join, resolve, win32 } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -311,6 +313,35 @@ function findFiles(root: string, predicate: (path: string) => boolean): string[] return matches.toSorted((left, right) => left.localeCompare(right)); } +function assertCanonicalPackagedPath(input: { + readonly root: string; + readonly candidate: string; + readonly label: string; + readonly kind: "directory" | "file"; +}): string { + const candidateStats = lstatSync(input.candidate); + if (candidateStats.isSymbolicLink()) { + throw new Error(`${input.label} must not be a symbolic link.`); + } + if ( + (input.kind === "directory" && !candidateStats.isDirectory()) || + (input.kind === "file" && !candidateStats.isFile()) + ) { + throw new Error(`${input.label} must be a regular ${input.kind}.`); + } + const canonicalRoot = realpathSync(input.root); + const canonicalCandidate = realpathSync(input.candidate); + const relativeCandidate = relative(canonicalRoot, canonicalCandidate); + if ( + relativeCandidate === ".." || + relativeCandidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || + isAbsolute(relativeCandidate) + ) { + throw new Error(`${input.label} resolves outside the inspected payload root.`); + } + return input.candidate; +} + export function expectedPackagedDesktopStartupAssetName( platform: PackagedDesktopPlatform, arch: string, @@ -354,6 +385,7 @@ export interface PackagedDesktopLaunchCommand { readonly command: string; readonly args: ReadonlyArray; readonly cwd: string; + readonly windowsJobAssemblyPath?: string; readonly cleanup?: () => Promise; } @@ -369,6 +401,9 @@ export function spawnContainedPackagedDesktop( "Windows Job Object launcher does not accept packaged application arguments.", ); } + if (!launch.windowsJobAssemblyPath) { + throw new Error("Windows Job Object launcher assembly was not prepared."); + } const systemRoot = environment.SystemRoot ?? environment.SYSTEMROOT ?? "C:\\Windows"; const powershell = win32.join( systemRoot, @@ -387,6 +422,8 @@ export function spawnContainedPackagedDesktop( "Bypass", "-File", WINDOWS_JOB_LAUNCHER_PATH, + "-AssemblyPath", + launch.windowsJobAssemblyPath, "-ExecutablePath", launch.command, "-WorkingDirectory", @@ -414,6 +451,38 @@ export function spawnContainedPackagedDesktop( ); } +function resolveWindowsPowerShell(environment: NodeJS.ProcessEnv = process.env): string { + const systemRoot = environment.SystemRoot ?? environment.SYSTEMROOT ?? "C:\\Windows"; + return win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); +} + +export async function prepareWindowsJobLauncherAssembly( + extractionRoot: string, + signal: AbortSignal, + runCommand: PackagedPreparationCommandRunner = runPackagedPreparationCommand, +): Promise { + const assemblyPath = join(extractionRoot, "packaged-startup-windows-job.dll"); + await runCommand( + resolveWindowsPowerShell(), + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + WINDOWS_JOB_LAUNCHER_PATH, + "-CompileAssemblyPath", + assemblyPath, + ], + { signal }, + ); + if (!lstatSync(assemblyPath).isFile()) { + throw new Error("Windows Job Object launcher preparation did not produce an assembly."); + } + return assemblyPath; +} + type PackagedPreparationCommandRunner = typeof runPackagedPreparationCommand; export async function attachMacDiskImageForInspection( @@ -495,18 +564,45 @@ export async function prepareMacLaunch( await runCommand("ditto", ["-x", "-k", archive, extractionRoot], { signal }); } try { - const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); - if (appBundles.length !== 1 || appBundles[0] !== "Scient.app") { + const appBundles = readdirSync(extractionRoot, { withFileTypes: true }).filter((entry) => + entry.name.endsWith(".app"), + ); + if ( + appBundles.length !== 1 || + appBundles[0]!.name !== "Scient.app" || + !appBundles[0]!.isDirectory() || + appBundles[0]!.isSymbolicLink() + ) { throw new Error(`Expected the exact Scient.app bundle in ${basename(archive)}.`); } - const appBundle = join(extractionRoot, appBundles[0]!); - const executables = findFiles(join(appBundle, "Contents", "MacOS"), (candidate) => - statSync(candidate).isFile(), - ); + const appBundle = assertCanonicalPackagedPath({ + root: extractionRoot, + candidate: join(extractionRoot, appBundles[0]!.name), + label: "Scient.app bundle", + kind: "directory", + }); + const executableDirectory = assertCanonicalPackagedPath({ + root: extractionRoot, + candidate: join(appBundle, "Contents", "MacOS"), + label: "Scient.app executable directory", + kind: "directory", + }); + const executables = findFiles(executableDirectory, (candidate) => statSync(candidate).isFile()); if (executables.length !== 1) { throw new Error(`Expected one macOS main executable, found ${executables.length}.`); } - const infoPlist = join(appBundle, "Contents", "Info.plist"); + const executable = assertCanonicalPackagedPath({ + root: extractionRoot, + candidate: executables[0]!, + label: "Scient.app main executable", + kind: "file", + }); + const infoPlist = assertCanonicalPackagedPath({ + root: extractionRoot, + candidate: join(appBundle, "Contents", "Info.plist"), + label: "Scient.app Info.plist", + kind: "file", + }); const bundleIdentifier = await runCommand( "plutil", ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlist], @@ -533,7 +629,7 @@ export async function prepareMacLaunch( } const expectedArchitecture = options.arch === "x64" ? "x86_64" : options.arch; const executableArchitectures = ( - await runCommand("lipo", ["-archs", executables[0]!], { + await runCommand("lipo", ["-archs", executable], { signal, }) ) @@ -548,7 +644,7 @@ export async function prepareMacLaunch( ); } return { - command: executables[0]!, + command: executable, args: [], cwd: appBundle, ...(cleanup ? { cleanup } : {}), @@ -638,14 +734,7 @@ async function verifyWindowsReleaseSignatures( ): Promise { const scriptPath = join(extractionRoot, "verify-release-signatures.ps1"); writeFileSync(scriptPath, WINDOWS_RELEASE_SIGNATURE_SCRIPT, { encoding: "utf8", mode: 0o600 }); - const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows"; - const powershell = win32.join( - systemRoot, - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); + const powershell = resolveWindowsPowerShell(); const output = await runPackagedPreparationCommand( powershell, [ @@ -729,7 +818,13 @@ async function prepareWindowsLaunch( extractionRoot, signal, ); - return { command: executables[0]!, args: [], cwd: dirname(executables[0]!) }; + const windowsJobAssemblyPath = await prepareWindowsJobLauncherAssembly(extractionRoot, signal); + return { + command: executables[0]!, + args: [], + cwd: dirname(executables[0]!), + windowsJobAssemblyPath, + }; } async function prepareLaunch( @@ -1249,6 +1344,17 @@ interface PackagedStartupProofWaitOptions { readonly stableForMs?: number; } +function assertPackagedDesktopChildStillRunning(outcome: PackagedDesktopChildOutcome): void { + if (outcome.launchError) { + throw new Error(`Packaged app could not start: ${outcome.launchError.message}`); + } + if (outcome.exited) { + throw new Error( + `Packaged app exited before stable startup proof (code=${outcome.exited.code ?? "null"}, signal=${outcome.exited.signal ?? "null"}).`, + ); + } +} + export async function waitForPackagedStartupProof({ timeoutMs, hasProof, @@ -1261,15 +1367,7 @@ export async function waitForPackagedStartupProof({ const deadline = now() + timeoutMs; let proofObservedAt: number | null = null; while (now() < deadline) { - const outcome = readOutcome(); - if (outcome.launchError) { - throw new Error(`Packaged app could not start: ${outcome.launchError.message}`); - } - if (outcome.exited) { - throw new Error( - `Packaged app exited before stable startup proof (code=${outcome.exited.code ?? "null"}, signal=${outcome.exited.signal ?? "null"}).`, - ); - } + assertPackagedDesktopChildStillRunning(readOutcome()); if (!isProcessAlive()) { throw new Error( "Packaged app exited before stable startup proof (process handle is closed).", @@ -1286,6 +1384,10 @@ export async function waitForPackagedStartupProof({ "Packaged app exited before stable startup proof (process handle is closed).", ); } + // POSIX uses a retained sentinel handle, so the native Electron child can + // exit while that handle is still alive. Re-read its durable outcome at + // the exact acceptance boundary instead of trusting the earlier sample. + assertPackagedDesktopChildStillRunning(readOutcome()); return; } } else { @@ -1296,6 +1398,36 @@ export async function waitForPackagedStartupProof({ throw new Error(`Packaged startup proof timed out after ${timeoutMs}ms.`); } +export function cleanupPackagedStartupTemporaryRoot(input: { + readonly temporaryRoot: string; + readonly processCleanupFailed: boolean; + readonly remove?: (path: string) => void; +}): { readonly preserved: boolean; readonly failure: { phase: string; error: unknown } | null } { + if (input.processCleanupFailed) { + return { + preserved: true, + failure: { + phase: "temporary-state cleanup skipped", + error: new Error(`Preserved failed process evidence at ${input.temporaryRoot}.`), + }, + }; + } + try { + (input.remove ?? ((path) => rmSync(path, { recursive: true, force: true })))( + input.temporaryRoot, + ); + return { preserved: false, failure: null }; + } catch (error) { + return { + preserved: true, + failure: { + phase: `temporary-state cleanup failed at ${input.temporaryRoot}`, + error, + }, + }; + } +} + export function resolveNativePackagedDesktopPlatform( platform: NodeJS.Platform, ): PackagedDesktopPlatform | null { @@ -1429,21 +1561,11 @@ async function verifyPackagedDesktopPayload( // Capture diagnostics after cleanup attempts but before deleting isolated state. const logTail = logPath ? readPackagedDesktopLogTail(logPath) : ""; - if (processCleanupFailed) { - failures.push({ - phase: "temporary-state cleanup skipped", - error: new Error(`Preserved failed process evidence at ${temporaryRoot}.`), - }); - } else { - try { - rmSync(temporaryRoot, { recursive: true, force: true }); - } catch (error) { - failures.push({ - phase: `temporary-state cleanup failed at ${temporaryRoot}`, - error, - }); - } - } + const temporaryCleanup = cleanupPackagedStartupTemporaryRoot({ + temporaryRoot, + processCleanupFailed, + }); + if (temporaryCleanup.failure) failures.push(temporaryCleanup.failure); if (failures.length > 0) { const details = formatPackagedStartupFailures(failures, output, logTail); From 29de13aee853e612d2f257022aa14c38741bffab Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 08:28:50 +0300 Subject: [PATCH 27/30] Fix packaged startup reconnect hydration --- .../packagedStartupRendererReadiness.test.ts | 64 ++++++++++++++++++- .../lib/packagedStartupRendererReadiness.ts | 31 +++++++++ apps/web/src/routes/__root.tsx | 34 ++++------ 3 files changed, 108 insertions(+), 21 deletions(-) diff --git a/apps/web/src/lib/packagedStartupRendererReadiness.test.ts b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts index 82b89005..fc67c7ff 100644 --- a/apps/web/src/lib/packagedStartupRendererReadiness.test.ts +++ b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { markPackagedStartupRendererReadyAfterShellHydration } from "./packagedStartupRendererReadiness"; +import { + createPackagedStartupRendererReadinessState, + hydrateShellForPackagedStartupRenderer, + markPackagedStartupRendererReadyAfterShellHydration, +} from "./packagedStartupRendererReadiness"; describe("packaged startup renderer readiness", () => { it("marks readiness only after the authoritative shell snapshot hydrates", async () => { @@ -46,4 +50,62 @@ describe("packaged startup renderer readiness", () => { expect(element.dataset.scientRendererReady).toBeUndefined(); }); + + it("awaits shell hydration again on a later server welcome", async () => { + const element = { dataset: {} as DOMStringMap }; + const state = createPackagedStartupRendererReadinessState(); + const firstHydration = vi.fn(async () => undefined); + await hydrateShellForPackagedStartupRenderer({ + hydrateShell: firstHydration, + state, + element, + }); + + let resolveReconnect!: () => void; + const reconnectHydration = vi.fn( + () => + new Promise((resolve) => { + resolveReconnect = resolve; + }), + ); + let reconnectSettled = false; + const reconnect = hydrateShellForPackagedStartupRenderer({ + hydrateShell: reconnectHydration, + state, + element, + }).then(() => { + reconnectSettled = true; + }); + + await Promise.resolve(); + expect(firstHydration).toHaveBeenCalledTimes(1); + expect(reconnectHydration).toHaveBeenCalledTimes(1); + expect(reconnectSettled).toBe(false); + resolveReconnect(); + await reconnect; + expect(reconnectSettled).toBe(true); + expect(element.dataset.scientRendererReady).toBe("true"); + }); + + it("propagates a later server-welcome hydration failure to its awaiting caller", async () => { + const element = { dataset: {} as DOMStringMap }; + const state = createPackagedStartupRendererReadinessState(); + await hydrateShellForPackagedStartupRenderer({ + hydrateShell: async () => undefined, + state, + element, + }); + + await expect( + hydrateShellForPackagedStartupRenderer({ + hydrateShell: async () => { + throw new Error("reconnect shell unavailable"); + }, + state, + element, + }), + ).rejects.toThrow("reconnect shell unavailable"); + expect(state.inFlight).toBeNull(); + expect(element.dataset.scientRendererReady).toBe("true"); + }); }); diff --git a/apps/web/src/lib/packagedStartupRendererReadiness.ts b/apps/web/src/lib/packagedStartupRendererReadiness.ts index d5805eb6..4ca06d39 100644 --- a/apps/web/src/lib/packagedStartupRendererReadiness.ts +++ b/apps/web/src/lib/packagedStartupRendererReadiness.ts @@ -2,6 +2,15 @@ export const PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY = "scientRendererReady" type RendererReadinessElement = Pick; +export interface PackagedStartupRendererReadinessState { + clear: (() => void) | null; + inFlight: Promise<() => void> | null; +} + +export function createPackagedStartupRendererReadinessState(): PackagedStartupRendererReadinessState { + return { clear: null, inFlight: null }; +} + export async function markPackagedStartupRendererReadyAfterShellHydration(input: { readonly hydrateShell: () => Promise; readonly element?: RendererReadinessElement; @@ -15,3 +24,25 @@ export async function markPackagedStartupRendererReadyAfterShellHydration(input: delete element.dataset[PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY]; }; } + +export async function hydrateShellForPackagedStartupRenderer(input: { + readonly hydrateShell: () => Promise; + readonly state: PackagedStartupRendererReadinessState; + readonly element?: RendererReadinessElement; + readonly shouldMark?: () => boolean; +}): Promise { + if (input.state.clear) { + await input.hydrateShell(); + return; + } + + input.state.inFlight ??= markPackagedStartupRendererReadyAfterShellHydration(input); + const readiness = input.state.inFlight; + try { + input.state.clear ??= await readiness; + } finally { + if (input.state.inFlight === readiness) { + input.state.inFlight = null; + } + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3724ee53..5c1a3f63 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -38,7 +38,10 @@ import { useFocusedChatContext } from "../focusedChatContext"; import { useFeedbackDialogStore } from "../feedbackDialogStore"; import type { FeedbackThreadContext } from "../feedback"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { markPackagedStartupRendererReadyAfterShellHydration } from "../lib/packagedStartupRendererReadiness"; +import { + createPackagedStartupRendererReadinessState, + hydrateShellForPackagedStartupRenderer, +} from "../lib/packagedStartupRendererReadiness"; import { serverConfigQueryOptions, serverQueryKeys, @@ -687,8 +690,7 @@ function EventRouter() { let reconcileThreadSubscriptionsChain = Promise.resolve(); let scopedSubscriptionsStarted = false; let scopedSubscriptionsStartInFlight: Promise | null = null; - let clearPackagedStartupRendererReady: (() => void) | null = null; - let packagedStartupRendererReadinessInFlight: Promise<() => void> | null = null; + const packagedStartupRendererReadiness = createPackagedStartupRendererReadinessState(); const beginThreadSubscription = (threadId: ThreadId) => { threadSnapshotSequenceById.delete(threadId); @@ -1167,22 +1169,14 @@ function EventRouter() { if (disposed) { return; } - packagedStartupRendererReadinessInFlight ??= - markPackagedStartupRendererReadyAfterShellHydration({ - hydrateShell: loadShellSnapshotOnce, - shouldMark: () => !disposed, - }); - const readiness = packagedStartupRendererReadinessInFlight; - try { - clearPackagedStartupRendererReady ??= await readiness; - } finally { - if (packagedStartupRendererReadinessInFlight === readiness) { - packagedStartupRendererReadinessInFlight = null; - } - } + await hydrateShellForPackagedStartupRenderer({ + hydrateShell: loadShellSnapshotOnce, + state: packagedStartupRendererReadiness, + shouldMark: () => !disposed, + }); if (disposed) { - clearPackagedStartupRendererReady?.(); - clearPackagedStartupRendererReady = null; + packagedStartupRendererReadiness.clear?.(); + packagedStartupRendererReadiness.clear = null; return; } @@ -1310,8 +1304,8 @@ function EventRouter() { return () => { flushPendingDomainEvents(); disposed = true; - clearPackagedStartupRendererReady?.(); - clearPackagedStartupRendererReady = null; + packagedStartupRendererReadiness.clear?.(); + packagedStartupRendererReadiness.clear = null; window.clearTimeout(shellBootstrapFallbackTimer); window.clearInterval(threadDetailCatchupInterval); needsProviderInvalidation = false; From fd361bc776f2195cf786562a7a010ce1fa0eb43d Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 08:55:53 +0300 Subject: [PATCH 28/30] fix(release): bind packaged startup containment lifetime --- apps/desktop/src/backendProcessTree.test.ts | 22 ++ apps/desktop/src/backendProcessTree.ts | 6 + apps/desktop/src/main.ts | 26 ++- .../packagedStartupRendererReadiness.test.ts | 65 +++++- .../lib/packagedStartupRendererReadiness.ts | 43 +++- apps/web/src/routes/__root.tsx | 16 +- .../lib/packaged-startup-posix-sentinel.mjs | 66 +++++- scripts/lib/packaged-startup-windows-job.ps1 | 68 +++++- scripts/release-smoke.ts | 38 +++- .../verify-packaged-desktop-startup.test.ts | 208 ++++++++++++++---- scripts/verify-packaged-desktop-startup.ts | 76 ++----- 11 files changed, 480 insertions(+), 154 deletions(-) diff --git a/apps/desktop/src/backendProcessTree.test.ts b/apps/desktop/src/backendProcessTree.test.ts index 8e5461e5..b11bb7b3 100644 --- a/apps/desktop/src/backendProcessTree.test.ts +++ b/apps/desktop/src/backendProcessTree.test.ts @@ -78,6 +78,28 @@ describe("forceTerminateBackendProcessTree", () => { ); }); + it.each(["darwin", "win32"] as const)( + "never signals a backend PID when external containment owns cleanup on %s", + async (platform) => { + const killProcessGroup = vi.fn(); + const spawnProcess = vi.fn(); + + await expect( + forceTerminateBackendProcessTree( + { pid: 4321 }, + { + platform, + retainedByExternalContainment: true, + killProcessGroup, + spawnProcess: spawnProcess as never, + }, + ), + ).rejects.toThrow("External packaged-startup containment retains cleanup authority"); + expect(killProcessGroup).not.toHaveBeenCalled(); + expect(spawnProcess).not.toHaveBeenCalled(); + }, + ); + it("does not treat a missing Windows root as successful descendant cleanup", async () => { const process = new EventEmitter(); const spawnProcess = vi.fn(() => process); diff --git a/apps/desktop/src/backendProcessTree.ts b/apps/desktop/src/backendProcessTree.ts index a2f190ae..ab3bbd12 100644 --- a/apps/desktop/src/backendProcessTree.ts +++ b/apps/desktop/src/backendProcessTree.ts @@ -7,6 +7,7 @@ import { resolveWindowsSystemRoot } from "@synara/shared/windowsProcess"; export interface ForceTerminateBackendProcessTreeOptions { readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; + readonly retainedByExternalContainment?: boolean; readonly killProcessGroup?: (pid: number, signal: NodeJS.Signals) => void; readonly spawnProcess?: typeof spawn; } @@ -58,6 +59,11 @@ export async function forceTerminateBackendProcessTree( child: Pick, options: ForceTerminateBackendProcessTreeOptions = {}, ): Promise { + if (options.retainedByExternalContainment) { + throw new Error( + "External packaged-startup containment retains cleanup authority; refusing backend PID-tree signaling.", + ); + } const pid = child.pid; if (!pid || pid <= 0) return; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 90c4e085..ced5e63b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2838,6 +2838,7 @@ class MissingBackendEntryError extends Error { } const backendGenerationRuntimes = new Map(); +const externallyContainedBackendChildren = new WeakSet(); function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { const backendEntry = resolveBackendEntry(); @@ -2853,6 +2854,14 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { // Packaged-smoke descendants remain inside the verifier-owned POSIX process // group or Windows Job Object. Normal application launches retain their // existing dedicated backend containment. + // Authenticate outer containment once for this generation. Recomputing after + // reparenting would lose authority and could incorrectly fall back to a + // backend PID or process-group signal that this child never owned. + const retainedByExternalContainment = isVerifierOwnedPackagedStartupSmoke( + process.env, + process.ppid, + app.isPackaged, + ); const child = ChildProcess.spawn(process.execPath, [...backendNodeArgs(), backendEntry], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. @@ -2864,9 +2873,10 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { ...backendProcessContainmentOptions( captureBackendLogs, process.platform, - !isVerifierOwnedPackagedStartupSmoke(process.env, process.ppid, app.isPackaged), + !retainedByExternalContainment, ), }); + if (retainedByExternalContainment) externallyContainedBackendChildren.add(child); writeDesktopLogHeader( `backend process spawned generation=${generation} pid=${child.pid ?? "unknown"}`, ); @@ -2971,15 +2981,11 @@ function getBackendSupervisor(): DesktopBackendSupervisor { }); }, forceTerminateTree: (child) => { - if ( - process.platform !== "win32" && - isVerifierOwnedPackagedStartupSmoke(process.env, process.ppid, app.isPackaged) - ) { - throw new Error( - "Verifier-owned packaged startup retains process-group cleanup authority; refusing unsafe backend PID-group signaling.", - ); - } - return forceTerminateBackendProcessTree(child); + return forceTerminateBackendProcessTree(child, { + retainedByExternalContainment: externallyContainedBackendChildren.has( + child as ChildProcess.ChildProcess, + ), + }); }, onGenerationStarted: handleBackendGenerationStarted, onGenerationExited: handleBackendGenerationExited, diff --git a/apps/web/src/lib/packagedStartupRendererReadiness.test.ts b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts index fc67c7ff..7ec3c1d8 100644 --- a/apps/web/src/lib/packagedStartupRendererReadiness.test.ts +++ b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createPackagedStartupRendererReadinessState, + disposePackagedStartupRendererReadiness, hydrateShellForPackagedStartupRenderer, markPackagedStartupRendererReadyAfterShellHydration, } from "./packagedStartupRendererReadiness"; @@ -77,6 +78,7 @@ describe("packaged startup renderer readiness", () => { reconnectSettled = true; }); + expect(element.dataset.scientRendererReady).toBeUndefined(); await Promise.resolve(); expect(firstHydration).toHaveBeenCalledTimes(1); expect(reconnectHydration).toHaveBeenCalledTimes(1); @@ -105,7 +107,68 @@ describe("packaged startup renderer readiness", () => { element, }), ).rejects.toThrow("reconnect shell unavailable"); - expect(state.inFlight).toBeNull(); + expect(element.dataset.scientRendererReady).toBeUndefined(); + }); + + it("lets only the latest overlapping welcome certify readiness", async () => { + const element = { dataset: {} as DOMStringMap }; + const state = createPackagedStartupRendererReadinessState(); + let resolveFirst!: () => void; + let resolveSecond!: () => void; + const first = hydrateShellForPackagedStartupRenderer({ + hydrateShell: () => new Promise((resolve) => (resolveFirst = resolve)), + state, + element, + }); + const second = hydrateShellForPackagedStartupRenderer({ + hydrateShell: () => new Promise((resolve) => (resolveSecond = resolve)), + state, + element, + }); + + resolveSecond(); + await second; + expect(element.dataset.scientRendererReady).toBe("true"); + + resolveFirst(); + await first; expect(element.dataset.scientRendererReady).toBe("true"); }); + + it("ignores a stale overlapping welcome rejection", async () => { + const element = { dataset: {} as DOMStringMap }; + const state = createPackagedStartupRendererReadinessState(); + let rejectFirst!: (error: Error) => void; + const first = hydrateShellForPackagedStartupRenderer({ + hydrateShell: () => new Promise((_resolve, reject) => (rejectFirst = reject)), + state, + element, + }); + const second = hydrateShellForPackagedStartupRenderer({ + hydrateShell: async () => undefined, + state, + element, + }); + + await second; + rejectFirst(new Error("stale shell unavailable")); + await expect(first).resolves.toBeUndefined(); + expect(element.dataset.scientRendererReady).toBe("true"); + }); + + it("does not let a pending welcome certify readiness after disposal", async () => { + const element = { dataset: {} as DOMStringMap }; + const state = createPackagedStartupRendererReadinessState(); + let resolveHydration!: () => void; + const pending = hydrateShellForPackagedStartupRenderer({ + hydrateShell: () => new Promise((resolve) => (resolveHydration = resolve)), + state, + element, + }); + + disposePackagedStartupRendererReadiness(state); + resolveHydration(); + await pending; + expect(element.dataset.scientRendererReady).toBeUndefined(); + }); }); diff --git a/apps/web/src/lib/packagedStartupRendererReadiness.ts b/apps/web/src/lib/packagedStartupRendererReadiness.ts index 4ca06d39..cc54b097 100644 --- a/apps/web/src/lib/packagedStartupRendererReadiness.ts +++ b/apps/web/src/lib/packagedStartupRendererReadiness.ts @@ -4,11 +4,12 @@ type RendererReadinessElement = Pick; export interface PackagedStartupRendererReadinessState { clear: (() => void) | null; - inFlight: Promise<() => void> | null; + generation: number; + disposed: boolean; } export function createPackagedStartupRendererReadinessState(): PackagedStartupRendererReadinessState { - return { clear: null, inFlight: null }; + return { clear: null, generation: 0, disposed: false }; } export async function markPackagedStartupRendererReadyAfterShellHydration(input: { @@ -31,18 +32,36 @@ export async function hydrateShellForPackagedStartupRenderer(input: { readonly element?: RendererReadinessElement; readonly shouldMark?: () => boolean; }): Promise { - if (input.state.clear) { + const generation = ++input.state.generation; + input.state.clear?.(); + input.state.clear = null; + + try { await input.hydrateShell(); + } catch (error) { + if (input.state.disposed || input.state.generation !== generation) return; + throw error; + } + if ( + input.state.disposed || + input.state.generation !== generation || + (input.shouldMark && !input.shouldMark()) + ) { return; } - input.state.inFlight ??= markPackagedStartupRendererReadyAfterShellHydration(input); - const readiness = input.state.inFlight; - try { - input.state.clear ??= await readiness; - } finally { - if (input.state.inFlight === readiness) { - input.state.inFlight = null; - } - } + const element = input.element ?? document.documentElement; + element.dataset[PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY] = "true"; + input.state.clear = () => { + delete element.dataset[PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY]; + }; +} + +export function disposePackagedStartupRendererReadiness( + state: PackagedStartupRendererReadinessState, +): void { + state.disposed = true; + state.generation += 1; + state.clear?.(); + state.clear = null; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5c1a3f63..9068568e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -40,6 +40,7 @@ import type { FeedbackThreadContext } from "../feedback"; import { isTerminalFocused } from "../lib/terminalFocus"; import { createPackagedStartupRendererReadinessState, + disposePackagedStartupRendererReadiness, hydrateShellForPackagedStartupRenderer, } from "../lib/packagedStartupRendererReadiness"; import { @@ -1165,18 +1166,16 @@ function EventRouter() { chatWorkspaceRoot: payload.chatWorkspaceRoot, studioWorkspaceRoot: payload.studioWorkspaceRoot, }); - await ensureScopedSubscriptions(); - if (disposed) { - return; - } await hydrateShellForPackagedStartupRenderer({ - hydrateShell: loadShellSnapshotOnce, + hydrateShell: async () => { + await ensureScopedSubscriptions(); + if (disposed) return; + await loadShellSnapshotOnce(); + }, state: packagedStartupRendererReadiness, shouldMark: () => !disposed, }); if (disposed) { - packagedStartupRendererReadiness.clear?.(); - packagedStartupRendererReadiness.clear = null; return; } @@ -1304,8 +1303,7 @@ function EventRouter() { return () => { flushPendingDomainEvents(); disposed = true; - packagedStartupRendererReadiness.clear?.(); - packagedStartupRendererReadiness.clear = null; + disposePackagedStartupRendererReadiness(packagedStartupRendererReadiness); window.clearTimeout(shellBootstrapFallbackTimer); window.clearInterval(threadDetailCatchupInterval); needsProviderInvalidation = false; diff --git a/scripts/lib/packaged-startup-posix-sentinel.mjs b/scripts/lib/packaged-startup-posix-sentinel.mjs index 16b568ea..7d4b7069 100644 --- a/scripts/lib/packaged-startup-posix-sentinel.mjs +++ b/scripts/lib/packaged-startup-posix-sentinel.mjs @@ -5,6 +5,8 @@ import { mkdirSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; const OUTCOME_FILE = "packaged-native-child-outcome.json"; +const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 12_000; +const VERIFIER_LIVENESS_POLL_MS = 250; let outcomeWritten = false; function writeOutcome(value) { @@ -19,19 +21,57 @@ function writeOutcome(value) { renameSync(temporaryPath, path); } -const [command, cwd, serializedArgs] = process.argv.slice(2); +const [serializedVerifierParentPid, command, cwd, serializedArgs] = process.argv.slice(2); +const verifierParentPid = Number(serializedVerifierParentPid); +if ( + !Number.isSafeInteger(verifierParentPid) || + verifierParentPid <= 0 || + process.ppid !== verifierParentPid +) { + throw new Error("POSIX packaged startup sentinel requires its authenticated verifier parent."); +} if (!command || !cwd || serializedArgs === undefined) { - throw new Error("POSIX packaged startup sentinel requires command, cwd, and serialized args."); + throw new Error( + "POSIX packaged startup sentinel requires verifier, command, cwd, and serialized args.", + ); } const parsedArgs = JSON.parse(serializedArgs); if (!Array.isArray(parsedArgs) || !parsedArgs.every((value) => typeof value === "string")) { throw new Error("POSIX packaged startup sentinel received invalid launch arguments."); } -// The verifier makes this sentinel the process-group leader. Ignoring TERM -// keeps the original PGID occupied until the verifier observes the native -// payload outcome and atomically finishes the whole group with SIGKILL. -process.on("SIGTERM", () => undefined); +// The verifier makes this sentinel the process-group leader. Only this retained +// group member may signal the group: a verifier-side delayed numeric signal +// could otherwise target a recycled PID after the sentinel exits. +let shutdownStarted = false; +let childExited = false; + +function killOwnedProcessGroup() { + try { + process.kill(-process.pid, "SIGKILL"); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } +} + +function beginSentinelOwnedShutdown(immediate = false) { + if (shutdownStarted) return; + shutdownStarted = true; + if (immediate) { + killOwnedProcessGroup(); + return; + } + try { + process.kill(-process.pid, "SIGTERM"); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + const forceTimer = setTimeout(killOwnedProcessGroup, GRACEFUL_SHUTDOWN_TIMEOUT_MS); + forceTimer.unref(); + if (childExited) setImmediate(killOwnedProcessGroup); +} + +process.on("SIGTERM", () => beginSentinelOwnedShutdown(false)); const child = spawn(command, parsedArgs, { cwd, @@ -49,10 +89,20 @@ child.once("error", (error) => { writeOutcome({ exited: null, launchError: { message: error.message } }); }); child.once("exit", (code, signal) => { + childExited = true; writeOutcome({ exited: { code, signal }, launchError: null }); + // Preserve the durable child outcome before ending the retained group. + if (shutdownStarted) setImmediate(killOwnedProcessGroup); }); -// Stay alive even after an early native-payload exit. This retained sentinel -// is the OS-owned identity that makes a later group signal reuse-safe. +// Direct-parent identity is the liveness authority. Reparenting proves the +// verifier died without relying on a reusable numeric PID lookup. +const verifierLivenessTimer = setInterval(() => { + if (process.ppid !== verifierParentPid) beginSentinelOwnedShutdown(true); +}, VERIFIER_LIVENESS_POLL_MS); +verifierLivenessTimer.unref(); + +// Stay alive even after an early native-payload exit. This retained sentinel is +// the sole group identity until requested cleanup or verifier death. setInterval(() => undefined, 60_000); await new Promise(() => undefined); diff --git a/scripts/lib/packaged-startup-windows-job.ps1 b/scripts/lib/packaged-startup-windows-job.ps1 index 4768e33c..5f3f5874 100644 --- a/scripts/lib/packaged-startup-windows-job.ps1 +++ b/scripts/lib/packaged-startup-windows-job.ps1 @@ -4,7 +4,8 @@ param( [string]$AssemblyPath = '', [string]$CompileAssemblyPath = '', [string]$PreResumeMarkerPath = '', - [string]$PreResumeGatePath = '' + [string]$PreResumeGatePath = '', + [int]$VerifierProcessId = 0 ) $ErrorActionPreference = 'Stop' @@ -16,7 +17,6 @@ using System.Globalization; using System.IO; using System.Runtime.InteropServices; using System.Text; -using System.Threading; public static class ScientPackagedStartupJobLauncher { @@ -26,6 +26,10 @@ public static class ScientPackagedStartupJobLauncher private const int ERROR_INSUFFICIENT_BUFFER = 122; private const int JobObjectExtendedLimitInformation = 9; private const uint INFINITE = 0xFFFFFFFF; + private const uint SYNCHRONIZE = 0x00100000; + private const uint WAIT_OBJECT_0 = 0x00000000; + private const uint WAIT_FAILED = 0xFFFFFFFF; + private const uint WAIT_TIMEOUT = 0x00000102; private static readonly IntPtr PROC_THREAD_ATTRIBUTE_JOB_LIST = new IntPtr(0x0002000D); [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] @@ -156,6 +160,17 @@ public static class ScientPackagedStartupJobLauncher [DllImport("kernel32.dll", SetLastError = true)] private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForMultipleObjects( + uint count, + IntPtr[] handles, + bool waitAll, + uint milliseconds + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); @@ -170,7 +185,8 @@ public static class ScientPackagedStartupJobLauncher private static void AwaitPreResumeGate( string markerPath, string gatePath, - uint childProcessId + uint childProcessId, + IntPtr verifierProcess ) { bool hasMarker = !String.IsNullOrWhiteSpace(markerPath); @@ -180,14 +196,23 @@ public static class ScientPackagedStartupJobLauncher throw new ArgumentException("Pre-resume marker and gate paths must be supplied together."); File.WriteAllText(markerPath, childProcessId.ToString(CultureInfo.InvariantCulture)); - while (!File.Exists(gatePath)) Thread.Sleep(20); + while (!File.Exists(gatePath)) + { + uint verifierWait = WaitForSingleObject(verifierProcess, 20); + if (verifierWait == WAIT_OBJECT_0) + throw new InvalidOperationException("Packaged startup verifier exited before payload resume."); + if (verifierWait == WAIT_FAILED) ThrowLastError("Verifier liveness wait failed"); + if (verifierWait != WAIT_TIMEOUT) + throw new InvalidOperationException("Unexpected verifier liveness wait result."); + } } public static int Run( string executablePath, string workingDirectory, string preResumeMarkerPath, - string preResumeGatePath + string preResumeGatePath, + uint verifierProcessId ) { if (executablePath.IndexOf('"') >= 0) @@ -197,9 +222,18 @@ public static class ScientPackagedStartupJobLauncher IntPtr information = IntPtr.Zero; IntPtr attributeList = IntPtr.Zero; IntPtr jobList = IntPtr.Zero; + IntPtr verifierProcess = IntPtr.Zero; PROCESS_INFORMATION child = new PROCESS_INFORMATION(); try { + if (verifierProcessId == 0) + throw new ArgumentException("Verifier process ID is required.", "verifierProcessId"); + // Open the verifier before creating any payload process. This handle + // remains bound to the original process object even if its numeric + // PID is later reused. + verifierProcess = OpenProcess(SYNCHRONIZE, false, verifierProcessId); + if (verifierProcess == IntPtr.Zero) ThrowLastError("OpenProcess for verifier failed"); + job = CreateJobObject(IntPtr.Zero, null); if (job == IntPtr.Zero) ThrowLastError("CreateJobObject failed"); @@ -257,11 +291,22 @@ public static class ScientPackagedStartupJobLauncher // PROC_THREAD_ATTRIBUTE_JOB_LIST makes Job membership atomic with // process creation, before this launcher can be interrupted and // before any native payload instruction is allowed to execute. - AwaitPreResumeGate(preResumeMarkerPath, preResumeGatePath, child.dwProcessId); + AwaitPreResumeGate( + preResumeMarkerPath, + preResumeGatePath, + child.dwProcessId, + verifierProcess + ); if (ResumeThread(child.hThread) == UInt32.MaxValue) ThrowLastError("ResumeThread failed"); - WaitForSingleObject(child.hProcess, INFINITE); + IntPtr[] livenessHandles = new IntPtr[] { child.hProcess, verifierProcess }; + uint waitResult = WaitForMultipleObjects(2, livenessHandles, false, INFINITE); + if (waitResult == WAIT_FAILED) ThrowLastError("WaitForMultipleObjects failed"); + if (waitResult == WAIT_OBJECT_0 + 1) + throw new InvalidOperationException("Packaged startup verifier exited before payload completion."); + if (waitResult != WAIT_OBJECT_0) + throw new InvalidOperationException("Unexpected packaged startup liveness wait result."); uint exitCode; if (!GetExitCodeProcess(child.hProcess, out exitCode)) ThrowLastError("GetExitCodeProcess failed"); @@ -278,6 +323,7 @@ public static class ScientPackagedStartupJobLauncher // Closing the final job handle atomically terminates every process // that Electron created inside the kill-on-close job. if (job != IntPtr.Zero) CloseHandle(job); + if (verifierProcess != IntPtr.Zero) CloseHandle(verifierProcess); } } } @@ -291,9 +337,10 @@ if (![string]::IsNullOrWhiteSpace($CompileAssemblyPath)) { if ( [string]::IsNullOrWhiteSpace($AssemblyPath) -or [string]::IsNullOrWhiteSpace($ExecutablePath) -or - [string]::IsNullOrWhiteSpace($WorkingDirectory) + [string]::IsNullOrWhiteSpace($WorkingDirectory) -or + $VerifierProcessId -le 0 ) { - throw 'AssemblyPath, ExecutablePath, and WorkingDirectory are required for launch.' + throw 'AssemblyPath, ExecutablePath, WorkingDirectory, and VerifierProcessId are required for launch.' } # Load only the assembly compiled during the separately classified preparation @@ -312,5 +359,6 @@ exit [ScientPackagedStartupJobLauncher]::Run( $ExecutablePath, $WorkingDirectory, $PreResumeMarkerPath, - $PreResumeGatePath + $PreResumeGatePath, + [uint32]$VerifierProcessId ) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index d889c62e..a77d0672 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -488,9 +488,19 @@ function verifyReleaseWorkflowSafety(): void { ); assertContains( webRootRoute, - "markPackagedStartupRendererReadyAfterShellHydration", + "hydrateShellForPackagedStartupRenderer", "Expected the server-welcome router lifecycle to own packaged renderer readiness.", ); + assertContains( + webRendererReadiness, + "input.state.generation !== generation", + "Expected renderer readiness to reject stale reconnect hydration generations.", + ); + assertContains( + webRootRoute, + "disposePackagedStartupRendererReadiness(packagedStartupRendererReadiness)", + "Expected router disposal to invalidate pending renderer readiness.", + ); assertNotContains( webMain, "scientRendererReady", @@ -549,8 +559,30 @@ function verifyReleaseWorkflowSafety(): void { } assertContains( posixStartupSentinel, - 'process.on("SIGTERM", () => undefined)', - "Expected the POSIX sentinel to retain process-group identity through graceful cleanup.", + 'process.on("SIGTERM", () => beginSentinelOwnedShutdown(false))', + "Expected the POSIX sentinel to own graceful process-group cleanup.", + ); + assertContains( + posixStartupSentinel, + "process.ppid !== verifierParentPid", + "Expected the POSIX sentinel to terminate its group when its verifier parent dies.", + ); + assertContains( + windowsStartupJobLauncher, + "OpenProcess(SYNCHRONIZE, false, verifierProcessId)", + "Expected Windows startup containment to retain a verifier process handle.", + ); + if ( + windowsStartupJobLauncher.indexOf("OpenProcess(SYNCHRONIZE, false, verifierProcessId)") < 0 || + windowsStartupJobLauncher.indexOf("OpenProcess(SYNCHRONIZE, false, verifierProcessId)") > + windowsStartupJobLauncher.indexOf("if (!CreateProcess(") + ) { + throw new Error("Expected Windows verifier liveness authority before payload creation."); + } + assertContains( + windowsStartupJobLauncher, + "WaitForMultipleObjects(2, livenessHandles, false, INFINITE)", + "Expected Windows Job cleanup to observe verifier death as well as payload exit.", ); assertContains( windowsStartupJobLauncher, diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 8fa9136f..3ba675e2 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -51,6 +51,24 @@ import { const temporaryRoots: string[] = []; +async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } + return predicate(); +} + +function processHasExited(pid: number): boolean { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + afterEach(() => { for (const root of temporaryRoots.splice(0)) { rmSync(root, { recursive: true, force: true }); @@ -929,6 +947,8 @@ describe("packaged desktop startup verification", () => { expect(jobScript).toContain("EXTENDED_STARTUPINFO_PRESENT"); expect(jobScript).toContain("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"); expect(jobScript).toContain("PROC_THREAD_ATTRIBUTE_JOB_LIST"); + expect(jobScript).toContain("OpenProcess(SYNCHRONIZE"); + expect(jobScript).toContain("WaitForMultipleObjects"); expect(jobScript).toContain("SCIENT_PACKAGED_STARTUP_SENTINEL_PID"); expect(jobScript).toContain("Add-Type -Path $AssemblyPath"); expect(jobScript.indexOf("Add-Type -TypeDefinition $source")).toBeLessThan( @@ -940,6 +960,9 @@ describe("packaged desktop startup verification", () => { expect(jobScript.indexOf("if (!UpdateProcThreadAttribute(")).toBeLessThan( jobScript.indexOf("if (!CreateProcess("), ); + expect(jobScript.indexOf("OpenProcess(SYNCHRONIZE")).toBeLessThan( + jobScript.indexOf("if (!CreateProcess("), + ); expect(jobScript.indexOf("if (!CreateProcess(")).toBeLessThan( jobScript.indexOf("ResumeThread(child.hThread)"), ); @@ -958,9 +981,49 @@ describe("packaged desktop startup verification", () => { expect(posixCall[1]).toEqual( expect.arrayContaining([expect.stringMatching(/packaged-startup-posix-sentinel\.mjs$/)]), ); + expect(posixCall[1][1]).toBe(String(process.pid)); expect(posixCall[2]).toEqual(expect.objectContaining({ detached: true })); }); + it.skipIf(process.platform === "win32")( + "kills the retained POSIX sentinel group when its verifier parent dies", + async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-sentinel-parent-death-test-")); + temporaryRoots.push(root); + const sentinelMarkerPath = join(root, "sentinel.pid"); + const payloadMarkerPath = join(root, "payload.pid"); + const sentinelPath = fileURLToPath( + new URL("./lib/packaged-startup-posix-sentinel.mjs", import.meta.url), + ); + const payloadSource = [ + 'const { writeFileSync } = require("node:fs");', + `writeFileSync(${JSON.stringify(payloadMarkerPath)}, String(process.pid));`, + "setInterval(() => undefined, 60000);", + ].join(""); + const verifierSource = [ + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + `const child = spawn(process.execPath, [${JSON.stringify(sentinelPath)}, String(process.pid), process.execPath, ${JSON.stringify(root)}, JSON.stringify(["-e", ${JSON.stringify(payloadSource)}])],`, + `{ cwd: ${JSON.stringify(root)}, env: { ...process.env, SCIENT_HOME: ${JSON.stringify(root)} }, detached: true, stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(sentinelMarkerPath)}, String(child.pid));`, + "setInterval(() => undefined, 60000);", + ].join(""); + const verifier = spawn(process.execPath, ["-e", verifierSource], { stdio: "ignore" }); + expect( + await waitUntil( + () => existsSync(sentinelMarkerPath) && existsSync(payloadMarkerPath), + 5_000, + ), + ).toBe(true); + const sentinelPid = Number(readFileSync(sentinelMarkerPath, "utf8")); + const payloadPid = Number(readFileSync(payloadMarkerPath, "utf8")); + expect(verifier.kill("SIGKILL")).toBe(true); + expect(await waitUntil(() => processHasExited(sentinelPid), 5_000)).toBe(true); + expect(await waitUntil(() => processHasExited(payloadPid), 5_000)).toBe(true); + }, + 15_000, + ); + it.skipIf(process.platform !== "win32")( "passes authenticated direct-parent authority through the actual Windows Job launcher", async () => { @@ -1061,18 +1124,12 @@ describe("packaged desktop startup verification", () => { markerPath, "-PreResumeGatePath", gatePath, + "-VerifierProcessId", + String(process.pid), ], { stdio: "ignore" }, ); - const waitUntil = async (predicate: () => boolean, timeoutMs: number): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return true; - await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); - } - return predicate(); - }; const markerAppeared = await waitUntil(() => existsSync(markerPath), 15_000); const payloadProcessId = markerAppeared ? Number(readFileSync(markerPath, "utf8")) : null; const launcherTerminated = launcher.kill(); @@ -1101,6 +1158,76 @@ describe("packaged desktop startup verification", () => { 30_000, ); + it.skipIf(process.platform !== "win32")( + "closes the Windows Job Object when its verifier process dies", + async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-job-parent-death-test-")); + temporaryRoots.push(root); + const executable = join(root, "LongLivedPayload.exe"); + const launcherMarkerPath = join(root, "launcher.pid"); + const payloadMarkerPath = join(root, "payload.pid"); + const powershell = join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const source = [ + "using System;", + "using System.Diagnostics;", + "using System.IO;", + "using System.Threading;", + "public static class LongLivedPayload {", + " public static void Main() {", + ' File.WriteAllText(Environment.GetEnvironmentVariable("SCIENT_PAYLOAD_MARKER_PATH"),', + " Process.GetCurrentProcess().Id.ToString());", + " Thread.Sleep(Timeout.Infinite);", + " }", + "}", + ].join(" "); + execFileSync( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + `Add-Type -TypeDefinition $env:SCIENT_PAYLOAD_SOURCE -Language CSharp -OutputAssembly '${executable.replaceAll("'", "''")}' -OutputType ConsoleApplication`, + ], + { env: { ...process.env, SCIENT_PAYLOAD_SOURCE: source }, stdio: "pipe" }, + ); + const windowsJobAssemblyPath = await prepareWindowsJobLauncherAssembly( + root, + new AbortController().signal, + ); + const launcherScript = fileURLToPath( + new URL("./lib/packaged-startup-windows-job.ps1", import.meta.url), + ); + const verifierSource = [ + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + `const child = spawn(${JSON.stringify(powershell)}, ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ${JSON.stringify(launcherScript)}, "-AssemblyPath", ${JSON.stringify(windowsJobAssemblyPath)}, "-ExecutablePath", ${JSON.stringify(executable)}, "-WorkingDirectory", ${JSON.stringify(root)}, "-VerifierProcessId", String(process.pid)],`, + `{ cwd: ${JSON.stringify(root)}, env: { ...process.env, SCIENT_HOME: ${JSON.stringify(join(root, "scient-home"))}, SCIENT_PAYLOAD_MARKER_PATH: ${JSON.stringify(payloadMarkerPath)} }, stdio: "ignore", windowsHide: true });`, + `writeFileSync(${JSON.stringify(launcherMarkerPath)}, String(child.pid));`, + "setInterval(() => undefined, 60000);", + ].join(""); + const verifier = spawn(process.execPath, ["-e", verifierSource], { stdio: "ignore" }); + expect( + await waitUntil( + () => existsSync(launcherMarkerPath) && existsSync(payloadMarkerPath), + 15_000, + ), + ).toBe(true); + const launcherPid = Number(readFileSync(launcherMarkerPath, "utf8")); + const payloadPid = Number(readFileSync(payloadMarkerPath, "utf8")); + expect(verifier.kill()).toBe(true); + expect(await waitUntil(() => processHasExited(launcherPid), 5_000)).toBe(true); + expect(await waitUntil(() => processHasExited(payloadPid), 5_000)).toBe(true); + }, + 30_000, + ); + it("reads the POSIX sentinel native-child outcome from isolated state", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-outcome-test-")); temporaryRoots.push(root); @@ -1208,69 +1335,57 @@ describe("packaged desktop startup verification", () => { expect(readPackagedBackendProcessIds(env)).toEqual([84]); }); - it("keeps the POSIX sentinel as group authority through TERM and KILL", async () => { + it("requests POSIX cleanup once through the retained sentinel handle", async () => { const child = { exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; - const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const terminateRoot = vi.fn(() => true); await expect( terminateProcessTree( child, { platform: "darwin", childIsAlive: () => true, - sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), - targetIsAlive: () => true, - waitForPosixPayloadExit: async (timeoutMs) => timeoutMs === 12_000, + terminateRoot, waitForTargetsExit: async () => false, }, [84], ), - ).rejects.toThrow("survived authoritative"); - expect(signals).toEqual([ - { pid: 42, signal: "SIGTERM" }, - { pid: 42, signal: "SIGKILL" }, - ]); + ).rejects.toThrow("survived sentinel-owned cleanup"); + expect(terminateRoot).toHaveBeenCalledOnce(); + expect(terminateRoot).toHaveBeenCalledWith(child); }); - it("waits for the native POSIX payload before killing the retained sentinel group", async () => { + it("waits for sentinel-owned POSIX cleanup without verifier-side escalation", async () => { const child = { exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; - const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; const events: string[] = []; + const terminateRoot = vi.fn(() => { + events.push("terminate-root"); + return true; + }); await terminateProcessTree( child, { platform: "darwin", childIsAlive: () => true, - sendSignal: (target, signal) => { - events.push(signal); - signals.push({ pid: target.pid, signal }); - }, - targetIsAlive: () => true, - waitForPosixPayloadExit: async (timeoutMs) => { - events.push(`wait:${timeoutMs}`); - return true; - }, + terminateRoot, waitForTargetsExit: async (_targets, timeoutMs) => { events.push(`reap:${timeoutMs}`); - return timeoutMs === 2_000; + return true; }, }, [84], ); - expect(signals).toEqual([ - { pid: 42, signal: "SIGTERM" }, - { pid: 42, signal: "SIGKILL" }, - ]); - expect(events).toEqual(["SIGTERM", "wait:12000", "SIGKILL", "reap:2000"]); + expect(terminateRoot).toHaveBeenCalledOnce(); + expect(events).toEqual(["terminate-root", "reap:14000"]); }); it("fails closed when a POSIX sentinel is gone even after observed processes exit", async () => { @@ -1279,18 +1394,17 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const terminateRoot = vi.fn(() => true); await expect( terminateProcessTree(child, { platform: "darwin", - sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), - targetIsAlive: () => false, + terminateRoot, waitForTargetsExit: async () => true, }), ).rejects.toThrow("before authoritative whole-group cleanup"); - expect(signals).toEqual([]); + expect(terminateRoot).not.toHaveBeenCalled(); }); it("fails closed when the POSIX sentinel vanished without any native outcome", async () => { @@ -1326,25 +1440,27 @@ describe("packaged desktop startup verification", () => { ).rejects.toThrow("before authoritative whole-group cleanup"); }); - it("fails closed when the POSIX sentinel disappears while cleanup is starting", async () => { + it("never performs numeric fallback when the POSIX sentinel disappears during cleanup", async () => { const child = { exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; + const terminateRoot = vi.fn(() => true); await expect( terminateProcessTree( child, { platform: "darwin", childIsAlive: () => true, - targetIsAlive: () => false, - waitForTargetsExit: async () => true, + terminateRoot, + waitForTargetsExit: async () => false, }, [84], ), - ).rejects.toThrow("before authoritative whole-group cleanup"); + ).rejects.toThrow("survived sentinel-owned cleanup"); + expect(terminateRoot).toHaveBeenCalledOnce(); }); it("refuses numeric POSIX signaling after the retained sentinel exits early", async () => { @@ -1353,20 +1469,20 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const terminateRoot = vi.fn(() => true); await expect( terminateProcessTree( child, { platform: "darwin", - sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + terminateRoot, waitForTargetsExit: async () => false, }, [84], ), ).rejects.toThrow("refusing numeric signaling authority"); - expect(signals).toEqual([]); + expect(terminateRoot).not.toHaveBeenCalled(); }); it("fails closed when the Windows launcher exited but a Job Object descendant survived", async () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index e421c4d1..9a1ce43a 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -428,6 +428,8 @@ export function spawnContainedPackagedDesktop( launch.command, "-WorkingDirectory", launch.cwd, + "-VerifierProcessId", + String(process.pid), ], { cwd: launch.cwd, @@ -440,7 +442,13 @@ export function spawnContainedPackagedDesktop( } return spawnProcess( process.execPath, - [POSIX_SENTINEL_PATH, launch.command, launch.cwd, JSON.stringify(launch.args)], + [ + POSIX_SENTINEL_PATH, + String(process.pid), + launch.command, + launch.cwd, + JSON.stringify(launch.args), + ], { cwd: launch.cwd, env: environment, @@ -959,16 +967,13 @@ export interface ProcessTerminationDependencies { readonly platform?: NodeJS.Platform; readonly childIsAlive?: (child: ChildProcess) => boolean; readonly terminateRoot?: (child: ChildProcess) => boolean; - readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; - readonly targetIsAlive?: (target: ProcessTerminationTarget) => boolean; - readonly waitForPosixPayloadExit?: (timeoutMs: number) => Promise; readonly waitForTargetsExit?: ( targets: ReadonlyArray, timeoutMs: number, ) => Promise; } -const POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 12_000; +const POSIX_SENTINEL_SHUTDOWN_TIMEOUT_MS = 14_000; const WINDOWS_JOB_CLOSE_TIMEOUT_MS = 5_000; function childProcessHandleIsAlive(child: ChildProcess): boolean { @@ -1012,14 +1017,6 @@ function waitForProcessTerminationTargets( }); } -function sendProcessTreeSignal(target: ProcessTerminationTarget, signal: NodeJS.Signals): void { - try { - process.kill(target.processGroup ? -target.pid : target.pid, signal); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; - } -} - export async function terminateProcessTree( child: ChildProcess, dependencies: ProcessTerminationDependencies = {}, @@ -1031,9 +1028,7 @@ export async function terminateProcessTree( child.exitCode === null && child.signalCode === null; const rootTarget: ProcessTerminationTarget | null = - child.pid && childCanStillOwnProcesses - ? { pid: child.pid, processGroup: platform !== "win32" } - : null; + child.pid && childCanStillOwnProcesses ? { pid: child.pid, processGroup: false } : null; // Backend PIDs recovered from logs/runtime state are observation-only. The // retained POSIX sentinel group or Windows kill-on-close Job Object owns // every descendant; numeric backend PIDs are never signaling authority. @@ -1079,25 +1074,19 @@ export async function terminateProcessTree( `Packaged POSIX sentinel exited before authoritative whole-group cleanup; preserving evidence and refusing numeric signaling authority for observed descendants ${targets.map(({ pid }) => pid).join(", ")}.`, ); } - const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; - const targetIsAlive = dependencies.targetIsAlive ?? processTerminationTargetIsAlive; - if (!targetIsAlive(rootTarget)) { - await awaitTargetsExit(targets, 2_000); + // Signal only the still-retained direct ChildProcess. The sentinel itself + // owns graceful whole-group termination and escalation from its unrecycled + // process-group identity. If it disappears, the verifier never falls back to + // a delayed numeric PID or process-group signal. + const terminated = dependencies.terminateRoot?.(child) ?? child.kill("SIGTERM"); + if (!terminated) { throw new Error( - "Packaged POSIX sentinel disappeared before authoritative whole-group cleanup; preserving evidence because its group can no longer be signaled safely.", + "Packaged POSIX sentinel could not accept handle-bound cleanup; preserving evidence and refusing numeric fallback signaling.", ); } - sendSignal(rootTarget, "SIGTERM"); - // The sentinel ignores TERM and remains the original process-group member - // while Electron performs its bounded graceful backend shutdown. Once the - // native payload exits (or the deadline expires), KILL targets a group whose - // identity cannot have been recycled because the sentinel still occupies it. - await (dependencies.waitForPosixPayloadExit?.(POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS) ?? - awaitTargetsExit(observedTargets, POSIX_DESKTOP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)); - if (targetIsAlive(rootTarget)) sendSignal(rootTarget, "SIGKILL"); - if (await awaitTargetsExit(targets, 2_000)) return; + if (await awaitTargetsExit(targets, POSIX_SENTINEL_SHUTDOWN_TIMEOUT_MS)) return; throw new Error( - `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived authoritative SIGTERM and SIGKILL cleanup.`, + `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived sentinel-owned cleanup.`, ); } @@ -1279,18 +1268,6 @@ export function hasProvenPackagedNativeChildOutcome(environment: NodeJS.ProcessE return inspectPackagedNativeChildOutcome(environment).evidence === "proven"; } -async function waitForPackagedNativeChildOutcome( - environment: NodeJS.ProcessEnv, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (hasProvenPackagedNativeChildOutcome(environment)) return true; - await delay(Math.min(100, Math.max(1, deadline - Date.now()))); - } - return false; -} - export function readPackagedDesktopLogTail(logPath: string, maxCharacters = 200_000): string { try { return readFileSync(logPath, "utf8").slice(-maxCharacters).trim(); @@ -1520,18 +1497,7 @@ async function verifyPackagedDesktopPayload( try { if (child) { - await terminateProcessTree( - child, - { - ...(process.platform !== "win32" && environment - ? { - waitForPosixPayloadExit: (timeoutMs: number) => - waitForPackagedNativeChildOutcome(environment!, timeoutMs), - } - : {}), - }, - readPackagedBackendProcessIds(environment), - ); + await terminateProcessTree(child, {}, readPackagedBackendProcessIds(environment)); } } catch (error) { processCleanupFailed = true; From 51a07d7f8826718731792c6b99f52b1ea9c8c74a Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 09:06:12 +0300 Subject: [PATCH 29/30] fix(release): request sentinel cleanup over IPC --- .../lib/packaged-startup-posix-sentinel.mjs | 11 ++ scripts/release-smoke.ts | 15 ++ .../verify-packaged-desktop-startup.test.ts | 155 +++++++++++++++--- scripts/verify-packaged-desktop-startup.ts | 36 +++- 4 files changed, 184 insertions(+), 33 deletions(-) diff --git a/scripts/lib/packaged-startup-posix-sentinel.mjs b/scripts/lib/packaged-startup-posix-sentinel.mjs index 7d4b7069..d8d36ba0 100644 --- a/scripts/lib/packaged-startup-posix-sentinel.mjs +++ b/scripts/lib/packaged-startup-posix-sentinel.mjs @@ -7,6 +7,7 @@ import { dirname, join } from "node:path"; const OUTCOME_FILE = "packaged-native-child-outcome.json"; const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 12_000; const VERIFIER_LIVENESS_POLL_MS = 250; +const SHUTDOWN_MESSAGE_TYPE = "scient-packaged-startup-shutdown"; let outcomeWritten = false; function writeOutcome(value) { @@ -72,6 +73,16 @@ function beginSentinelOwnedShutdown(immediate = false) { } process.on("SIGTERM", () => beginSentinelOwnedShutdown(false)); +process.on("message", (message) => { + if ( + message && + typeof message === "object" && + message.type === SHUTDOWN_MESSAGE_TYPE && + Object.keys(message).length === 1 + ) { + beginSentinelOwnedShutdown(false); + } +}); const child = spawn(command, parsedArgs, { cwd, diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index a77d0672..1ddbfaf0 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -562,6 +562,21 @@ function verifyReleaseWorkflowSafety(): void { 'process.on("SIGTERM", () => beginSentinelOwnedShutdown(false))', "Expected the POSIX sentinel to own graceful process-group cleanup.", ); + assertContains( + posixStartupSentinel, + 'process.on("message", (message)', + "Expected POSIX cleanup requests to use the retained sentinel IPC channel.", + ); + assertContains( + packagedStartupVerifier, + 'stdio: ["ignore", "pipe", "pipe", "ipc"]', + "Expected the POSIX sentinel launch to retain a cleanup control channel.", + ); + assertNotContains( + packagedStartupVerifier, + 'child.kill("SIGTERM")', + "POSIX verifier cleanup must never signal a reusable numeric sentinel PID.", + ); assertContains( posixStartupSentinel, "process.ppid !== verifierParentPid", diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 3ba675e2..14bd602a 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -1024,6 +1024,90 @@ describe("packaged desktop startup verification", () => { 15_000, ); + it.skipIf(process.platform === "win32")( + "cleans up the retained POSIX sentinel group through IPC without verifier PID signaling", + async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-sentinel-ipc-cleanup-test-")); + temporaryRoots.push(root); + const payloadMarkerPath = join(root, "payload.pid"); + const payloadSource = [ + 'const { writeFileSync } = require("node:fs");', + `writeFileSync(${JSON.stringify(payloadMarkerPath)}, String(process.pid));`, + "setInterval(() => undefined, 60000);", + ].join(""); + const sentinel = spawnContainedPackagedDesktop( + { command: process.execPath, args: ["-e", payloadSource], cwd: root }, + { ...process.env, SCIENT_HOME: root }, + process.platform, + ); + + expect(await waitUntil(() => existsSync(payloadMarkerPath), 5_000)).toBe(true); + const payloadPid = Number(readFileSync(payloadMarkerPath, "utf8")); + await terminateProcessTree(sentinel, {}, [payloadPid]); + + expect(processHasExited(sentinel.pid!)).toBe(true); + expect(processHasExited(payloadPid)).toBe(true); + expect(hasProvenPackagedNativeChildOutcome({ SCIENT_HOME: root })).toBe(true); + }, + 15_000, + ); + + it.skipIf(process.platform === "win32")( + "fails closed after native POSIX IPC loss and leaves cleanup to verifier-death authority", + async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-sentinel-ipc-loss-test-")); + temporaryRoots.push(root); + const sentinelMarkerPath = join(root, "sentinel.pid"); + const payloadMarkerPath = join(root, "payload.pid"); + const resultMarkerPath = join(root, "cleanup-result.txt"); + const verifierModuleUrl = new URL("./verify-packaged-desktop-startup.ts", import.meta.url) + .href; + const payloadSource = [ + 'const { writeFileSync } = require("node:fs");', + `writeFileSync(${JSON.stringify(payloadMarkerPath)}, String(process.pid));`, + "setInterval(() => undefined, 60000);", + ].join(""); + const verifierSource = [ + 'const { existsSync, readFileSync, writeFileSync } = require("node:fs");', + "void (async () => {", + `const verification = await import(${JSON.stringify(verifierModuleUrl)});`, + `const sentinel = verification.spawnContainedPackagedDesktop({ command: process.execPath, args: ["-e", ${JSON.stringify(payloadSource)}], cwd: ${JSON.stringify(root)} }, { ...process.env, SCIENT_HOME: ${JSON.stringify(root)} }, process.platform);`, + `writeFileSync(${JSON.stringify(sentinelMarkerPath)}, String(sentinel.pid));`, + `while (!existsSync(${JSON.stringify(payloadMarkerPath)})) await new Promise((resolve) => setTimeout(resolve, 25));`, + `const payloadPid = Number(readFileSync(${JSON.stringify(payloadMarkerPath)}, "utf8"));`, + "sentinel.disconnect();", + "try { await verification.terminateProcessTree(sentinel, {}, [payloadPid]);", + `writeFileSync(${JSON.stringify(resultMarkerPath)}, "unexpected success");`, + `} catch (error) { writeFileSync(${JSON.stringify(resultMarkerPath)}, error instanceof Error ? error.message : String(error)); }`, + "setInterval(() => undefined, 60000);", + "})();", + ].join(""); + const verifier = spawn(process.execPath, ["-e", verifierSource], { stdio: "ignore" }); + + expect( + await waitUntil( + () => + existsSync(sentinelMarkerPath) && + existsSync(payloadMarkerPath) && + existsSync(resultMarkerPath), + 8_000, + ), + ).toBe(true); + const sentinelPid = Number(readFileSync(sentinelMarkerPath, "utf8")); + const payloadPid = Number(readFileSync(payloadMarkerPath, "utf8")); + expect(readFileSync(resultMarkerPath, "utf8")).toContain( + "refusing numeric signaling authority", + ); + expect(processHasExited(sentinelPid)).toBe(false); + expect(processHasExited(payloadPid)).toBe(false); + + expect(verifier.kill("SIGKILL")).toBe(true); + expect(await waitUntil(() => processHasExited(sentinelPid), 5_000)).toBe(true); + expect(await waitUntil(() => processHasExited(payloadPid), 5_000)).toBe(true); + }, + 20_000, + ); + it.skipIf(process.platform !== "win32")( "passes authenticated direct-parent authority through the actual Windows Job launcher", async () => { @@ -1335,38 +1419,39 @@ describe("packaged desktop startup verification", () => { expect(readPackagedBackendProcessIds(env)).toEqual([84]); }); - it("requests POSIX cleanup once through the retained sentinel handle", async () => { + it("requests POSIX cleanup once through the retained sentinel IPC channel", async () => { const child = { + connected: true, exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; - const terminateRoot = vi.fn(() => true); + const requestPosixShutdown = vi.fn(() => true); await expect( terminateProcessTree( child, { platform: "darwin", - childIsAlive: () => true, - terminateRoot, + requestPosixShutdown, waitForTargetsExit: async () => false, }, [84], ), ).rejects.toThrow("survived sentinel-owned cleanup"); - expect(terminateRoot).toHaveBeenCalledOnce(); - expect(terminateRoot).toHaveBeenCalledWith(child); + expect(requestPosixShutdown).toHaveBeenCalledOnce(); + expect(requestPosixShutdown).toHaveBeenCalledWith(child); }); it("waits for sentinel-owned POSIX cleanup without verifier-side escalation", async () => { const child = { + connected: true, exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; const events: string[] = []; - const terminateRoot = vi.fn(() => { - events.push("terminate-root"); + const requestPosixShutdown = vi.fn(() => { + events.push("request-ipc-shutdown"); return true; }); @@ -1374,8 +1459,7 @@ describe("packaged desktop startup verification", () => { child, { platform: "darwin", - childIsAlive: () => true, - terminateRoot, + requestPosixShutdown, waitForTargetsExit: async (_targets, timeoutMs) => { events.push(`reap:${timeoutMs}`); return true; @@ -1384,8 +1468,8 @@ describe("packaged desktop startup verification", () => { [84], ); - expect(terminateRoot).toHaveBeenCalledOnce(); - expect(events).toEqual(["terminate-root", "reap:14000"]); + expect(requestPosixShutdown).toHaveBeenCalledOnce(); + expect(events).toEqual(["request-ipc-shutdown", "reap:14000"]); }); it("fails closed when a POSIX sentinel is gone even after observed processes exit", async () => { @@ -1394,17 +1478,17 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const terminateRoot = vi.fn(() => true); + const requestPosixShutdown = vi.fn(() => true); await expect( terminateProcessTree(child, { platform: "darwin", - terminateRoot, + requestPosixShutdown, waitForTargetsExit: async () => true, }), ).rejects.toThrow("before authoritative whole-group cleanup"); - expect(terminateRoot).not.toHaveBeenCalled(); + expect(requestPosixShutdown).not.toHaveBeenCalled(); }); it("fails closed when the POSIX sentinel vanished without any native outcome", async () => { @@ -1440,27 +1524,50 @@ describe("packaged desktop startup verification", () => { ).rejects.toThrow("before authoritative whole-group cleanup"); }); - it("never performs numeric fallback when the POSIX sentinel disappears during cleanup", async () => { + it("fails closed when the POSIX sentinel channel disappears during the cleanup request", async () => { const child = { + connected: true, exitCode: null, pid: 42, signalCode: null, } as unknown as ChildProcess; - const terminateRoot = vi.fn(() => true); + const requestPosixShutdown = vi.fn(() => false); await expect( terminateProcessTree( child, { platform: "darwin", - childIsAlive: () => true, - terminateRoot, + requestPosixShutdown, waitForTargetsExit: async () => false, }, [84], ), - ).rejects.toThrow("survived sentinel-owned cleanup"); - expect(terminateRoot).toHaveBeenCalledOnce(); + ).rejects.toThrow("control channel could not accept cleanup"); + expect(requestPosixShutdown).toHaveBeenCalledOnce(); + }); + + it("never requests or signals by PID after the POSIX sentinel channel is disconnected", async () => { + const child = { + connected: false, + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const requestPosixShutdown = vi.fn(() => true); + + await expect( + terminateProcessTree( + child, + { + platform: "darwin", + requestPosixShutdown, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("refusing numeric signaling authority"); + expect(requestPosixShutdown).not.toHaveBeenCalled(); }); it("refuses numeric POSIX signaling after the retained sentinel exits early", async () => { @@ -1469,20 +1576,20 @@ describe("packaged desktop startup verification", () => { pid: 42, signalCode: null, } as unknown as ChildProcess; - const terminateRoot = vi.fn(() => true); + const requestPosixShutdown = vi.fn(() => true); await expect( terminateProcessTree( child, { platform: "darwin", - terminateRoot, + requestPosixShutdown, waitForTargetsExit: async () => false, }, [84], ), ).rejects.toThrow("refusing numeric signaling authority"); - expect(terminateRoot).not.toHaveBeenCalled(); + expect(requestPosixShutdown).not.toHaveBeenCalled(); }); it("fails closed when the Windows launcher exited but a Job Object descendant survived", async () => { diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 9a1ce43a..ea964aaa 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -34,6 +34,9 @@ const POSIX_SENTINEL_PATH = fileURLToPath( const WINDOWS_JOB_LAUNCHER_PATH = fileURLToPath( new URL("./lib/packaged-startup-windows-job.ps1", import.meta.url), ); +const POSIX_SENTINEL_SHUTDOWN_MESSAGE = { + type: "scient-packaged-startup-shutdown", +} as const; export type PackagedDesktopPlatform = "mac" | "win"; @@ -453,7 +456,7 @@ export function spawnContainedPackagedDesktop( cwd: launch.cwd, env: environment, detached: true, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe", "ipc"], windowsHide: true, }, ); @@ -967,6 +970,7 @@ export interface ProcessTerminationDependencies { readonly platform?: NodeJS.Platform; readonly childIsAlive?: (child: ChildProcess) => boolean; readonly terminateRoot?: (child: ChildProcess) => boolean; + readonly requestPosixShutdown?: (child: ChildProcess) => boolean | Promise; readonly waitForTargetsExit?: ( targets: ReadonlyArray, timeoutMs: number, @@ -987,6 +991,17 @@ function childProcessHandleIsAlive(child: ChildProcess): boolean { } } +function requestPosixSentinelShutdown(child: ChildProcess): Promise { + if (!child.connected || !child.send) return Promise.resolve(false); + return new Promise((resolveRequest) => { + try { + child.send!(POSIX_SENTINEL_SHUTDOWN_MESSAGE, (error) => resolveRequest(error === null)); + } catch { + resolveRequest(false); + } + }); +} + function processTerminationTargetIsAlive(target: ProcessTerminationTarget): boolean { try { process.kill(target.processGroup ? -target.pid : target.pid, 0); @@ -1024,9 +1039,11 @@ export async function terminateProcessTree( ): Promise { const platform = dependencies.platform ?? process.platform; const childCanStillOwnProcesses = - (dependencies.childIsAlive ?? childProcessHandleIsAlive)(child) && child.exitCode === null && - child.signalCode === null; + child.signalCode === null && + (platform === "win32" + ? (dependencies.childIsAlive ?? childProcessHandleIsAlive)(child) + : child.connected === true); const rootTarget: ProcessTerminationTarget | null = child.pid && childCanStillOwnProcesses ? { pid: child.pid, processGroup: false } : null; // Backend PIDs recovered from logs/runtime state are observation-only. The @@ -1074,14 +1091,15 @@ export async function terminateProcessTree( `Packaged POSIX sentinel exited before authoritative whole-group cleanup; preserving evidence and refusing numeric signaling authority for observed descendants ${targets.map(({ pid }) => pid).join(", ")}.`, ); } - // Signal only the still-retained direct ChildProcess. The sentinel itself + // Request cleanup only over the retained IPC channel. The sentinel itself // owns graceful whole-group termination and escalation from its unrecycled - // process-group identity. If it disappears, the verifier never falls back to - // a delayed numeric PID or process-group signal. - const terminated = dependencies.terminateRoot?.(child) ?? child.kill("SIGTERM"); - if (!terminated) { + // process-group identity. A disconnected channel fails closed without any + // numeric PID or process-group signal from the verifier. + const requested = await (dependencies.requestPosixShutdown?.(child) ?? + requestPosixSentinelShutdown(child)); + if (!requested) { throw new Error( - "Packaged POSIX sentinel could not accept handle-bound cleanup; preserving evidence and refusing numeric fallback signaling.", + "Packaged POSIX sentinel control channel could not accept cleanup; preserving evidence and refusing numeric fallback signaling.", ); } if (await awaitTargetsExit(targets, POSIX_SENTINEL_SHUTDOWN_TIMEOUT_MS)) return; From 9a0d140a9de303c605f6b662054e3772c26e736c Mon Sep 17 00:00:00 2001 From: Yaacov Date: Sun, 26 Jul 2026 09:51:46 +0300 Subject: [PATCH 30/30] fix(release): bind Windows verifier authority to pipe --- .../packagedStartupRendererReadiness.test.ts | 41 +++----- .../lib/packagedStartupRendererReadiness.ts | 14 --- scripts/lib/packaged-startup-windows-job.ps1 | 99 +++++++++++-------- scripts/release-smoke.ts | 24 +++-- .../verify-packaged-desktop-startup.test.ts | 89 +++++++++++++++-- scripts/verify-packaged-desktop-startup.ts | 7 +- 6 files changed, 172 insertions(+), 102 deletions(-) diff --git a/apps/web/src/lib/packagedStartupRendererReadiness.test.ts b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts index 7ec3c1d8..55222896 100644 --- a/apps/web/src/lib/packagedStartupRendererReadiness.test.ts +++ b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts @@ -4,47 +4,32 @@ import { createPackagedStartupRendererReadinessState, disposePackagedStartupRendererReadiness, hydrateShellForPackagedStartupRenderer, - markPackagedStartupRendererReadyAfterShellHydration, } from "./packagedStartupRendererReadiness"; describe("packaged startup renderer readiness", () => { - it("marks readiness only after the authoritative shell snapshot hydrates", async () => { - let resolveHydration!: () => void; - const hydration = new Promise((resolve) => { - resolveHydration = resolve; - }); - const element = { dataset: {} as DOMStringMap }; - const pending = markPackagedStartupRendererReadyAfterShellHydration({ - hydrateShell: () => hydration, - element, - }); - - expect(element.dataset.scientRendererReady).toBeUndefined(); - resolveHydration(); - const clear = await pending; - expect(element.dataset.scientRendererReady).toBe("true"); - - clear(); - expect(element.dataset.scientRendererReady).toBeUndefined(); - }); - - it("does not certify a renderer whose shell hydration fails", async () => { + it("does not certify a renderer whose initial shell hydration fails", async () => { const element = { dataset: {} as DOMStringMap }; - const hydrateShell = vi.fn(async () => { - throw new Error("preload bridge unavailable"); - }); + const state = createPackagedStartupRendererReadinessState(); await expect( - markPackagedStartupRendererReadyAfterShellHydration({ hydrateShell, element }), + hydrateShellForPackagedStartupRenderer({ + hydrateShell: async () => { + throw new Error("preload bridge unavailable"); + }, + state, + element, + }), ).rejects.toThrow("preload bridge unavailable"); expect(element.dataset.scientRendererReady).toBeUndefined(); }); - it("does not mark a router disposed while hydration was pending", async () => { + it("does not mark a router that lost ownership while hydration completed", async () => { const element = { dataset: {} as DOMStringMap }; + const state = createPackagedStartupRendererReadinessState(); - await markPackagedStartupRendererReadyAfterShellHydration({ + await hydrateShellForPackagedStartupRenderer({ hydrateShell: async () => undefined, + state, element, shouldMark: () => false, }); diff --git a/apps/web/src/lib/packagedStartupRendererReadiness.ts b/apps/web/src/lib/packagedStartupRendererReadiness.ts index cc54b097..e81bfdb3 100644 --- a/apps/web/src/lib/packagedStartupRendererReadiness.ts +++ b/apps/web/src/lib/packagedStartupRendererReadiness.ts @@ -12,20 +12,6 @@ export function createPackagedStartupRendererReadinessState(): PackagedStartupRe return { clear: null, generation: 0, disposed: false }; } -export async function markPackagedStartupRendererReadyAfterShellHydration(input: { - readonly hydrateShell: () => Promise; - readonly element?: RendererReadinessElement; - readonly shouldMark?: () => boolean; -}): Promise<() => void> { - await input.hydrateShell(); - if (input.shouldMark && !input.shouldMark()) return () => undefined; - const element = input.element ?? document.documentElement; - element.dataset[PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY] = "true"; - return () => { - delete element.dataset[PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY]; - }; -} - export async function hydrateShellForPackagedStartupRenderer(input: { readonly hydrateShell: () => Promise; readonly state: PackagedStartupRendererReadinessState; diff --git a/scripts/lib/packaged-startup-windows-job.ps1 b/scripts/lib/packaged-startup-windows-job.ps1 index 5f3f5874..eb808984 100644 --- a/scripts/lib/packaged-startup-windows-job.ps1 +++ b/scripts/lib/packaged-startup-windows-job.ps1 @@ -4,8 +4,7 @@ param( [string]$AssemblyPath = '', [string]$CompileAssemblyPath = '', [string]$PreResumeMarkerPath = '', - [string]$PreResumeGatePath = '', - [int]$VerifierProcessId = 0 + [string]$PreResumeGatePath = '' ) $ErrorActionPreference = 'Stop' @@ -25,8 +24,8 @@ public static class ScientPackagedStartupJobLauncher private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; private const int ERROR_INSUFFICIENT_BUFFER = 122; private const int JobObjectExtendedLimitInformation = 9; - private const uint INFINITE = 0xFFFFFFFF; - private const uint SYNCHRONIZE = 0x00100000; + private const uint FILE_TYPE_PIPE = 0x0003; + private const int STD_INPUT_HANDLE = -10; private const uint WAIT_OBJECT_0 = 0x00000000; private const uint WAIT_FAILED = 0xFFFFFFFF; private const uint WAIT_TIMEOUT = 0x00000102; @@ -161,15 +160,20 @@ public static class ScientPackagedStartupJobLauncher private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); [DllImport("kernel32.dll", SetLastError = true)] - private static extern uint WaitForMultipleObjects( - uint count, - IntPtr[] handles, - bool waitAll, - uint milliseconds - ); + private static extern IntPtr GetStdHandle(int standardHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetFileType(IntPtr file); [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId); + private static extern bool PeekNamedPipe( + IntPtr namedPipe, + IntPtr buffer, + uint bufferSize, + IntPtr bytesRead, + IntPtr totalBytesAvailable, + IntPtr bytesLeftThisMessage + ); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); @@ -182,13 +186,30 @@ public static class ScientPackagedStartupJobLauncher throw new Win32Exception(Marshal.GetLastWin32Error(), operation); } + private static void EnsureVerifierControlConnected(IntPtr verifierControl) + { + // The verifier gives this launcher the read end of a fresh private pipe + // at CreateProcess time and exclusively retains the write end. Unlike a + // numeric PID, the inherited kernel object cannot be rebound to a later + // process if the verifier exits before this launcher starts running. + if (!PeekNamedPipe( + verifierControl, + IntPtr.Zero, + 0, + IntPtr.Zero, + IntPtr.Zero, + IntPtr.Zero + )) throw new InvalidOperationException("Packaged startup verifier control pipe closed."); + } + private static void AwaitPreResumeGate( string markerPath, string gatePath, uint childProcessId, - IntPtr verifierProcess + IntPtr verifierControl ) { + EnsureVerifierControlConnected(verifierControl); bool hasMarker = !String.IsNullOrWhiteSpace(markerPath); bool hasGate = !String.IsNullOrWhiteSpace(gatePath); if (!hasMarker && !hasGate) return; @@ -198,21 +219,17 @@ public static class ScientPackagedStartupJobLauncher File.WriteAllText(markerPath, childProcessId.ToString(CultureInfo.InvariantCulture)); while (!File.Exists(gatePath)) { - uint verifierWait = WaitForSingleObject(verifierProcess, 20); - if (verifierWait == WAIT_OBJECT_0) - throw new InvalidOperationException("Packaged startup verifier exited before payload resume."); - if (verifierWait == WAIT_FAILED) ThrowLastError("Verifier liveness wait failed"); - if (verifierWait != WAIT_TIMEOUT) - throw new InvalidOperationException("Unexpected verifier liveness wait result."); + EnsureVerifierControlConnected(verifierControl); + System.Threading.Thread.Sleep(20); } + EnsureVerifierControlConnected(verifierControl); } public static int Run( string executablePath, string workingDirectory, string preResumeMarkerPath, - string preResumeGatePath, - uint verifierProcessId + string preResumeGatePath ) { if (executablePath.IndexOf('"') >= 0) @@ -222,17 +239,16 @@ public static class ScientPackagedStartupJobLauncher IntPtr information = IntPtr.Zero; IntPtr attributeList = IntPtr.Zero; IntPtr jobList = IntPtr.Zero; - IntPtr verifierProcess = IntPtr.Zero; + IntPtr verifierControl = IntPtr.Zero; PROCESS_INFORMATION child = new PROCESS_INFORMATION(); try { - if (verifierProcessId == 0) - throw new ArgumentException("Verifier process ID is required.", "verifierProcessId"); - // Open the verifier before creating any payload process. This handle - // remains bound to the original process object even if its numeric - // PID is later reused. - verifierProcess = OpenProcess(SYNCHRONIZE, false, verifierProcessId); - if (verifierProcess == IntPtr.Zero) ThrowLastError("OpenProcess for verifier failed"); + verifierControl = GetStdHandle(STD_INPUT_HANDLE); + if (verifierControl == IntPtr.Zero || verifierControl == new IntPtr(-1)) + ThrowLastError("GetStdHandle for verifier control failed"); + if (GetFileType(verifierControl) != FILE_TYPE_PIPE) + throw new InvalidOperationException("Verifier control must be a private inherited pipe."); + EnsureVerifierControlConnected(verifierControl); job = CreateJobObject(IntPtr.Zero, null); if (job == IntPtr.Zero) ThrowLastError("CreateJobObject failed"); @@ -295,18 +311,20 @@ public static class ScientPackagedStartupJobLauncher preResumeMarkerPath, preResumeGatePath, child.dwProcessId, - verifierProcess + verifierControl ); if (ResumeThread(child.hThread) == UInt32.MaxValue) ThrowLastError("ResumeThread failed"); - IntPtr[] livenessHandles = new IntPtr[] { child.hProcess, verifierProcess }; - uint waitResult = WaitForMultipleObjects(2, livenessHandles, false, INFINITE); - if (waitResult == WAIT_FAILED) ThrowLastError("WaitForMultipleObjects failed"); - if (waitResult == WAIT_OBJECT_0 + 1) - throw new InvalidOperationException("Packaged startup verifier exited before payload completion."); - if (waitResult != WAIT_OBJECT_0) - throw new InvalidOperationException("Unexpected packaged startup liveness wait result."); + while (true) + { + uint payloadWait = WaitForSingleObject(child.hProcess, 20); + if (payloadWait == WAIT_OBJECT_0) break; + if (payloadWait == WAIT_FAILED) ThrowLastError("Payload liveness wait failed"); + if (payloadWait != WAIT_TIMEOUT) + throw new InvalidOperationException("Unexpected payload liveness wait result."); + EnsureVerifierControlConnected(verifierControl); + } uint exitCode; if (!GetExitCodeProcess(child.hProcess, out exitCode)) ThrowLastError("GetExitCodeProcess failed"); @@ -323,7 +341,6 @@ public static class ScientPackagedStartupJobLauncher // Closing the final job handle atomically terminates every process // that Electron created inside the kill-on-close job. if (job != IntPtr.Zero) CloseHandle(job); - if (verifierProcess != IntPtr.Zero) CloseHandle(verifierProcess); } } } @@ -337,10 +354,9 @@ if (![string]::IsNullOrWhiteSpace($CompileAssemblyPath)) { if ( [string]::IsNullOrWhiteSpace($AssemblyPath) -or [string]::IsNullOrWhiteSpace($ExecutablePath) -or - [string]::IsNullOrWhiteSpace($WorkingDirectory) -or - $VerifierProcessId -le 0 + [string]::IsNullOrWhiteSpace($WorkingDirectory) ) { - throw 'AssemblyPath, ExecutablePath, WorkingDirectory, and VerifierProcessId are required for launch.' + throw 'AssemblyPath, ExecutablePath, and WorkingDirectory are required for launch.' } # Load only the assembly compiled during the separately classified preparation @@ -359,6 +375,5 @@ exit [ScientPackagedStartupJobLauncher]::Run( $ExecutablePath, $WorkingDirectory, $PreResumeMarkerPath, - $PreResumeGatePath, - [uint32]$VerifierProcessId + $PreResumeGatePath ) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 1ddbfaf0..80c9cd36 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -584,20 +584,30 @@ function verifyReleaseWorkflowSafety(): void { ); assertContains( windowsStartupJobLauncher, - "OpenProcess(SYNCHRONIZE, false, verifierProcessId)", - "Expected Windows startup containment to retain a verifier process handle.", + "GetStdHandle(STD_INPUT_HANDLE)", + "Expected Windows startup containment to inherit a private verifier control pipe.", ); if ( - windowsStartupJobLauncher.indexOf("OpenProcess(SYNCHRONIZE, false, verifierProcessId)") < 0 || - windowsStartupJobLauncher.indexOf("OpenProcess(SYNCHRONIZE, false, verifierProcessId)") > + windowsStartupJobLauncher.indexOf("EnsureVerifierControlConnected(verifierControl)") < 0 || + windowsStartupJobLauncher.indexOf("EnsureVerifierControlConnected(verifierControl)") > windowsStartupJobLauncher.indexOf("if (!CreateProcess(") ) { - throw new Error("Expected Windows verifier liveness authority before payload creation."); + throw new Error("Expected Windows verifier pipe authority before payload creation."); } assertContains( windowsStartupJobLauncher, - "WaitForMultipleObjects(2, livenessHandles, false, INFINITE)", - "Expected Windows Job cleanup to observe verifier death as well as payload exit.", + "PeekNamedPipe(", + "Expected Windows Job cleanup to observe verifier pipe closure without PID lookup.", + ); + assertNotContains( + windowsStartupJobLauncher, + "VerifierProcessId", + "Windows verifier authority must not be reconstructed from a reusable numeric PID.", + ); + assertNotContains( + windowsStartupJobLauncher, + "OpenProcess(", + "Windows verifier authority must remain bound to the inherited control pipe.", ); assertContains( windowsStartupJobLauncher, diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 14bd602a..84ed113a 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -947,8 +947,11 @@ describe("packaged desktop startup verification", () => { expect(jobScript).toContain("EXTENDED_STARTUPINFO_PRESENT"); expect(jobScript).toContain("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"); expect(jobScript).toContain("PROC_THREAD_ATTRIBUTE_JOB_LIST"); - expect(jobScript).toContain("OpenProcess(SYNCHRONIZE"); - expect(jobScript).toContain("WaitForMultipleObjects"); + expect(jobScript).toContain("GetStdHandle(STD_INPUT_HANDLE)"); + expect(jobScript).toContain("PeekNamedPipe("); + expect(jobScript).toContain("EnsureVerifierControlConnected(verifierControl)"); + expect(jobScript).not.toContain("VerifierProcessId"); + expect(jobScript).not.toContain("OpenProcess("); expect(jobScript).toContain("SCIENT_PACKAGED_STARTUP_SENTINEL_PID"); expect(jobScript).toContain("Add-Type -Path $AssemblyPath"); expect(jobScript.indexOf("Add-Type -TypeDefinition $source")).toBeLessThan( @@ -960,12 +963,14 @@ describe("packaged desktop startup verification", () => { expect(jobScript.indexOf("if (!UpdateProcThreadAttribute(")).toBeLessThan( jobScript.indexOf("if (!CreateProcess("), ); - expect(jobScript.indexOf("OpenProcess(SYNCHRONIZE")).toBeLessThan( + expect(jobScript.indexOf("EnsureVerifierControlConnected(verifierControl)")).toBeLessThan( jobScript.indexOf("if (!CreateProcess("), ); expect(jobScript.indexOf("if (!CreateProcess(")).toBeLessThan( jobScript.indexOf("ResumeThread(child.hThread)"), ); + expect(windowsCall[1]).not.toContain("-VerifierProcessId"); + expect(windowsCall[2]).toEqual(expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"] })); spawnProcess.mockClear(); spawnContainedPackagedDesktop( @@ -1208,10 +1213,8 @@ describe("packaged desktop startup verification", () => { markerPath, "-PreResumeGatePath", gatePath, - "-VerifierProcessId", - String(process.pid), ], - { stdio: "ignore" }, + { stdio: ["pipe", "ignore", "ignore"] }, ); const markerAppeared = await waitUntil(() => existsSync(markerPath), 15_000); @@ -1242,6 +1245,76 @@ describe("packaged desktop startup verification", () => { 30_000, ); + it.skipIf(process.platform !== "win32")( + "closes the Windows Job before resume when the inherited verifier pipe is lost", + async () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-job-pipe-loss-test-")); + temporaryRoots.push(root); + const markerPath = join(root, "pre-resume.marker"); + const gatePath = join(root, "pre-resume.gate"); + const powershell = join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + const windowsJobAssemblyPath = await prepareWindowsJobLauncherAssembly( + root, + new AbortController().signal, + ); + const launcher = spawn( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + fileURLToPath(new URL("./lib/packaged-startup-windows-job.ps1", import.meta.url)), + "-AssemblyPath", + windowsJobAssemblyPath, + "-ExecutablePath", + powershell, + "-WorkingDirectory", + root, + "-PreResumeMarkerPath", + markerPath, + "-PreResumeGatePath", + gatePath, + ], + { stdio: ["pipe", "ignore", "ignore"] }, + ); + + const markerAppeared = await waitUntil(() => existsSync(markerPath), 15_000); + const payloadProcessId = markerAppeared ? Number(readFileSync(markerPath, "utf8")) : null; + launcher.stdin?.end(); + const launcherExited = await waitUntil( + () => launcher.exitCode !== null || launcher.signalCode !== null, + 5_000, + ); + + expect(markerAppeared).toBe(true); + expect( + payloadProcessId !== null && Number.isInteger(payloadProcessId) && payloadProcessId > 0, + ).toBe(true); + expect(launcherExited).toBe(true); + expect(launcher.exitCode).not.toBe(0); + expect( + await waitUntil(() => { + try { + process.kill(payloadProcessId!, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } + }, 5_000), + ).toBe(true); + }, + 30_000, + ); + it.skipIf(process.platform !== "win32")( "closes the Windows Job Object when its verifier process dies", async () => { @@ -1291,8 +1364,8 @@ describe("packaged desktop startup verification", () => { const verifierSource = [ 'const { spawn } = require("node:child_process");', 'const { writeFileSync } = require("node:fs");', - `const child = spawn(${JSON.stringify(powershell)}, ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ${JSON.stringify(launcherScript)}, "-AssemblyPath", ${JSON.stringify(windowsJobAssemblyPath)}, "-ExecutablePath", ${JSON.stringify(executable)}, "-WorkingDirectory", ${JSON.stringify(root)}, "-VerifierProcessId", String(process.pid)],`, - `{ cwd: ${JSON.stringify(root)}, env: { ...process.env, SCIENT_HOME: ${JSON.stringify(join(root, "scient-home"))}, SCIENT_PAYLOAD_MARKER_PATH: ${JSON.stringify(payloadMarkerPath)} }, stdio: "ignore", windowsHide: true });`, + `const child = spawn(${JSON.stringify(powershell)}, ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ${JSON.stringify(launcherScript)}, "-AssemblyPath", ${JSON.stringify(windowsJobAssemblyPath)}, "-ExecutablePath", ${JSON.stringify(executable)}, "-WorkingDirectory", ${JSON.stringify(root)}],`, + `{ cwd: ${JSON.stringify(root)}, env: { ...process.env, SCIENT_HOME: ${JSON.stringify(join(root, "scient-home"))}, SCIENT_PAYLOAD_MARKER_PATH: ${JSON.stringify(payloadMarkerPath)} }, stdio: ["pipe", "ignore", "ignore"], windowsHide: true });`, `writeFileSync(${JSON.stringify(launcherMarkerPath)}, String(child.pid));`, "setInterval(() => undefined, 60000);", ].join(""); diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index ea964aaa..f47c9c63 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -431,14 +431,15 @@ export function spawnContainedPackagedDesktop( launch.command, "-WorkingDirectory", launch.cwd, - "-VerifierProcessId", - String(process.pid), ], { cwd: launch.cwd, env: environment, detached: false, - stdio: ["ignore", "pipe", "pipe"], + // The verifier exclusively retains the writable end of this private + // stdin pipe. The Windows launcher observes the inherited read handle, + // avoiding any numeric-PID lookup or reuse window. + stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }, );