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/.github/workflows/release.yml b/.github/workflows/release.yml index 3f97cf84..54c78ffc 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 @@ -208,6 +207,8 @@ jobs: build: name: Build ${{ matrix.label }} + permissions: + contents: read needs: preflight runs-on: ${{ matrix.runner }} timeout-minutes: ${{ matrix.timeout_minutes }} @@ -245,6 +246,7 @@ jobs: with: ref: ${{ needs.preflight.outputs.ref }} fetch-depth: 0 + persist-credentials: false - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -309,6 +311,7 @@ jobs: "${{ needs.preflight.outputs.version }}" - name: Build Windows desktop artifact + id: build_windows if: matrix.platform == 'win' shell: bash env: @@ -386,6 +389,31 @@ jobs: fi fi + - name: Smoke exact packaged desktop startup + if: ${{ matrix.platform != 'linux' }} + 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 \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --version "${{ needs.preflight.outputs.version }}" \ + --commit "${{ github.sha }}" \ + --allow-unsigned-windows "${{ steps.build_windows.outputs.windows_signed != 'true' }}" \ + --windows-publisher-subject "$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 with: @@ -395,6 +423,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 @@ -432,6 +463,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/backendProcessTree.test.ts b/apps/desktop/src/backendProcessTree.test.ts index c674f0cf..b11bb7b3 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 () => { @@ -74,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 73b062e7..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; } @@ -14,9 +15,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"], @@ -57,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 ff680d47..ced5e63b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -59,6 +59,8 @@ 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 { isVerifierOwnedPackagedStartupSmoke } from "./packagedStartupSmokeAuthority"; import { resolveBackendNodeArgs } from "./backendNodeOptions"; import { backendProcessContainmentOptions, @@ -92,13 +94,15 @@ import { 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, } 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 +205,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"; @@ -263,6 +267,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; @@ -315,7 +320,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; @@ -576,6 +585,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; @@ -933,18 +945,27 @@ function parseAppUpdateYml(): Record | null { } function normalizeCommitHash(value: unknown): string | 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 { if (typeof value !== "string") { return null; } const trimmed = value.trim(); - if (!COMMIT_HASH_PATTERN.test(trimmed)) { + if (!FULL_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) { @@ -953,7 +974,11 @@ 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; } @@ -965,10 +990,15 @@ 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; @@ -978,6 +1008,14 @@ function resolveEmbeddedCommitHash(): string | null { return resolveEmbeddedReleaseMetadata().commitHash; } +function writePackagedStartupSmokeIdentity(): void { + 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}`, + ); +} + function resolveAboutCommitHash(): string | null { if (aboutCommitHashCache !== undefined) { return aboutCommitHashCache; @@ -2800,6 +2838,7 @@ class MissingBackendEntryError extends Error { } const backendGenerationRuntimes = new Map(); +const externallyContainedBackendChildren = new WeakSet(); function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { const backendEntry = resolveBackendEntry(); @@ -2808,18 +2847,39 @@ function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { } const captureBackendLogs = app.isPackaged && backendLogSink !== null; + const environment: NodeJS.ProcessEnv = { + ...backendEnv(), + ELECTRON_RUN_AS_NODE: "1", + }; + // 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. // Run the child in Node mode so this backend process does not become a GUI app instance. - env: { - ...backendEnv(), - ELECTRON_RUN_AS_NODE: "1", - }, - // POSIX force termination targets this dedicated process group. Windows - // uses taskkill /T after the same graceful IPC deadline. - ...backendProcessContainmentOptions(captureBackendLogs), + env: environment, + // 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, + !retainedByExternalContainment, + ), }); + if (retainedByExternalContainment) externallyContainedBackendChildren.add(child); + writeDesktopLogHeader( + `backend process spawned generation=${generation} pid=${child.pid ?? "unknown"}`, + ); const listeningDetector = new ServerListeningDetector(); backendListeningDetector = listeningDetector; let backendSessionClosed = false; @@ -2881,6 +2941,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); @@ -2917,7 +2980,13 @@ function getBackendSupervisor(): DesktopBackendSupervisor { } }); }, - forceTerminateTree: (child) => forceTerminateBackendProcessTree(child), + forceTerminateTree: (child) => { + return forceTerminateBackendProcessTree(child, { + retainedByExternalContainment: externallyContainedBackendChildren.has( + child as ChildProcess.ChildProcess, + ), + }); + }, onGenerationStarted: handleBackendGenerationStarted, onGenerationExited: handleBackendGenerationExited, onRestartScheduled: ({ delayMs, reason }) => { @@ -3462,6 +3531,15 @@ function createWindow(): BrowserWindow { backgroundThrottling: true, }, }); + let resolvePackagedWindowVisibility!: (visible: boolean) => void; + const packagedWindowVisibility = new Promise((resolveVisibility) => { + resolvePackagedWindowVisibility = resolveVisibility; + }); + const verifierOwnedPackagedStartupSmoke = isVerifierOwnedPackagedStartupSmoke( + process.env, + process.ppid, + app.isPackaged, + ); browserManager.setWindow(window); attachDesktopZoomFactorSync(window); @@ -3514,8 +3592,69 @@ 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(); + if (verifierOwnedPackagedStartupSmoke) { + setTimeout(() => { + const generation = getBackendSupervisor().currentGeneration; + void Promise.all([ + window.webContents.executeJavaScript( + `new Promise((resolve) => requestAnimationFrame(() => resolve(${PACKAGED_RENDERER_READINESS_EXPRESSION})))`, + true, + ), + waitForPackagedBackendResponsiveness(backendHttpUrl).then(() => true), + packagedWindowVisibility, + ]) + .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 || + windowVisible !== true || + !window.isVisible() + ) { + 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", + (_event, errorCode, errorDescription, _validatedUrl, isMainFrame) => { + if (!isMainFrame) return; + writeDesktopLogHeader( + `renderer main frame load failed code=${errorCode} message=${errorDescription}`, + ); + }, + ); + 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"); + }); + attachPackagedStartupWindowLifecycleProof({ + window, + enabled: verifierOwnedPackagedStartupSmoke, + writeFailureMarker: writeDesktopLogHeader, }); window.once("ready-to-show", () => { // Preserve the original first-launch behavior, then respect the state saved @@ -3525,6 +3664,10 @@ function createWindow(): BrowserWindow { window.maximize(); } window.show(); + if (window.isVisible()) { + writeDesktopLogHeader("packaged main window visible"); + resolvePackagedWindowVisibility(true); + } emitDesktopWindowState(window); }); @@ -3677,8 +3820,9 @@ if (hasSingleInstanceLock) { app .whenReady() .then(async () => { - writeDesktopLogHeader("app ready"); configureAppIdentity(); + writeDesktopLogHeader("app ready"); + writePackagedStartupSmokeIdentity(); applyLegacyMacDockIcon(); refreshMacIconCacheOnVersionChange(); configureMediaPermissions(); 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/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/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/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/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/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/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/apps/web/src/lib/packagedStartupRendererReadiness.test.ts b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts new file mode 100644 index 00000000..55222896 --- /dev/null +++ b/apps/web/src/lib/packagedStartupRendererReadiness.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createPackagedStartupRendererReadinessState, + disposePackagedStartupRendererReadiness, + hydrateShellForPackagedStartupRenderer, +} from "./packagedStartupRendererReadiness"; + +describe("packaged startup renderer readiness", () => { + it("does not certify a renderer whose initial shell hydration fails", async () => { + const element = { dataset: {} as DOMStringMap }; + const state = createPackagedStartupRendererReadinessState(); + + await expect( + 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 that lost ownership while hydration completed", async () => { + const element = { dataset: {} as DOMStringMap }; + const state = createPackagedStartupRendererReadinessState(); + + await hydrateShellForPackagedStartupRenderer({ + hydrateShell: async () => undefined, + state, + element, + shouldMark: () => false, + }); + + 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; + }); + + expect(element.dataset.scientRendererReady).toBeUndefined(); + 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(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 new file mode 100644 index 00000000..e81bfdb3 --- /dev/null +++ b/apps/web/src/lib/packagedStartupRendererReadiness.ts @@ -0,0 +1,53 @@ +export const PACKAGED_STARTUP_RENDERER_READY_DATASET_KEY = "scientRendererReady"; + +type RendererReadinessElement = Pick; + +export interface PackagedStartupRendererReadinessState { + clear: (() => void) | null; + generation: number; + disposed: boolean; +} + +export function createPackagedStartupRendererReadinessState(): PackagedStartupRendererReadinessState { + return { clear: null, generation: 0, disposed: false }; +} + +export async function hydrateShellForPackagedStartupRenderer(input: { + readonly hydrateShell: () => Promise; + readonly state: PackagedStartupRendererReadinessState; + readonly element?: RendererReadinessElement; + readonly shouldMark?: () => boolean; +}): Promise { + 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; + } + + 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 0f7639d1..9068568e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -38,6 +38,11 @@ import { useFocusedChatContext } from "../focusedChatContext"; import { useFeedbackDialogStore } from "../feedbackDialogStore"; import type { FeedbackThreadContext } from "../feedback"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { + createPackagedStartupRendererReadinessState, + disposePackagedStartupRendererReadiness, + hydrateShellForPackagedStartupRenderer, +} from "../lib/packagedStartupRendererReadiness"; import { serverConfigQueryOptions, serverQueryKeys, @@ -686,6 +691,7 @@ function EventRouter() { let reconcileThreadSubscriptionsChain = Promise.resolve(); let scopedSubscriptionsStarted = false; let scopedSubscriptionsStartInFlight: Promise | null = null; + const packagedStartupRendererReadiness = createPackagedStartupRendererReadinessState(); const beginThreadSubscription = (threadId: ThreadId) => { threadSnapshotSequenceById.delete(threadId); @@ -1160,11 +1166,18 @@ function EventRouter() { chatWorkspaceRoot: payload.chatWorkspaceRoot, studioWorkspaceRoot: payload.studioWorkspaceRoot, }); - await ensureScopedSubscriptions(); + await hydrateShellForPackagedStartupRenderer({ + hydrateShell: async () => { + await ensureScopedSubscriptions(); + if (disposed) return; + await loadShellSnapshotOnce(); + }, + state: packagedStartupRendererReadiness, + shouldMark: () => !disposed, + }); if (disposed) { return; } - await loadShellSnapshotOnce(); if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; @@ -1290,6 +1303,7 @@ function EventRouter() { return () => { flushPendingDomainEvents(); disposed = true; + disposePackagedStartupRendererReadiness(packagedStartupRendererReadiness); window.clearTimeout(shellBootstrapFallbackTimer); window.clearInterval(threadDetailCatchupInterval); needsProviderInvalidation = false; diff --git a/docs/release.md b/docs/release.md index 3cfa04d9..5cf404a2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -15,6 +15,11 @@ 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, semantic backend + readiness, successful renderer main-frame loading, and no intervening native + 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. @@ -259,6 +264,16 @@ 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 +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 This path supports an OV/EV code-signing certificate accepted by electron-builder. 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/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/lib/packaged-startup-posix-sentinel.mjs b/scripts/lib/packaged-startup-posix-sentinel.mjs new file mode 100644 index 00000000..d8d36ba0 --- /dev/null +++ b/scripts/lib/packaged-startup-posix-sentinel.mjs @@ -0,0 +1,119 @@ +#!/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"; +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) { + 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 [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 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. 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)); +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, + 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"], +}); + +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); +}); + +// 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 new file mode 100644 index 00000000..eb808984 --- /dev/null +++ b/scripts/lib/packaged-startup-windows-job.ps1 @@ -0,0 +1,379 @@ +param( + [string]$ExecutablePath = '', + [string]$WorkingDirectory = '', + [string]$AssemblyPath = '', + [string]$CompileAssemblyPath = '', + [string]$PreResumeMarkerPath = '', + [string]$PreResumeGatePath = '' +) + +$ErrorActionPreference = 'Stop' + +$source = @' +using System; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +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 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; + private static readonly IntPtr PROC_THREAD_ATTRIBUTE_JOB_LIST = new IntPtr(0x0002000D); + + [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 STARTUPINFOEX + { + public STARTUPINFO StartupInfo; + public IntPtr lpAttributeList; + } + + [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", 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 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); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + 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 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); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + private static void ThrowLastError(string operation) + { + 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 verifierControl + ) + { + EnsureVerifierControlConnected(verifierControl); + 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)) + { + EnsureVerifierControlConnected(verifierControl); + System.Threading.Thread.Sleep(20); + } + EnsureVerifierControlConnected(verifierControl); + } + + 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; + IntPtr verifierControl = IntPtr.Zero; + PROCESS_INFORMATION child = new PROCESS_INFORMATION(); + try + { + 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"); + + 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"); + + 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, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + false, + CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, + IntPtr.Zero, + workingDirectory, + ref startup, + out child + )) ThrowLastError("CreateProcess failed"); + // 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, + verifierControl + ); + if (ResumeThread(child.hThread) == UInt32.MaxValue) + ThrowLastError("ResumeThread failed"); + + 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"); + return unchecked((int)exitCode); + } + finally + { + 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. + if (job != IntPtr.Zero) CloseHandle(job); + } + } +} +'@ + +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, + $PreResumeMarkerPath, + $PreResumeGatePath +) 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/release-smoke.ts b/scripts/release-smoke.ts index af70e70b..80c9cd36 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 }); @@ -137,9 +140,12 @@ function verifyReleaseRepositoryPolicy(): void { interface ReleaseWorkflowStep { readonly env?: Record; + readonly id?: string; readonly if?: string; readonly name?: string; readonly run?: string; + readonly uses?: string; + readonly with?: Record; } function assertScopedSigningEnvironment( @@ -185,7 +191,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."); } @@ -229,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", @@ -238,13 +251,89 @@ function verifyReleaseWorkflowSafety(): void { "utf8", ).replaceAll("\r\n", "\n"); const parsedWorkflow = parseYaml(workflow) as { + permissions?: Record; jobs?: { - build?: { steps?: Array }; + build?: { + permissions?: Record; + steps?: Array; + strategy?: { matrix?: { include?: Array> } }; + }; preflight?: { steps?: Array }; + publish_cli?: { permissions?: Record }; + 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 ?? []; + 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 || + 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.", + ); + } + 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) { @@ -288,6 +377,299 @@ 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") || + !packagedStartupStep.run.includes('--commit "${{ github.sha }}"') || + !packagedStartupStep.run.includes( + "--allow-unsigned-windows \"${{ steps.build_windows.outputs.windows_signed != 'true' }}\"", + ) || + !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.", + ); + } + 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", + ).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", + ).replaceAll("\r\n", "\n"); + const webMain = readFileSync(resolve(repoRoot, "apps/web/src/main.tsx"), "utf8").replaceAll( + "\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", + ).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.", + ); + 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( + webRendererReadiness, + "await input.hydrateShell();", + "Expected packaged startup readiness to wait for authoritative shell hydration.", + ); + assertContains( + webRootRoute, + "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", + "The renderer entrypoint must not certify readiness before its preload and server lifecycle complete.", + ); + assertContains( + rendererReadiness, + "document.documentElement.dataset.scientRendererReady === 'true'", + "Expected packaged startup proof to reject a renderer that never committed React.", + ); + assertContains( + packagedStartupVerifier, + "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, + 'SCIENT_DISABLE_SHELL_ENV_SYNC: "1"', + "Expected packaged startup verification to prevent host environment rehydration.", + ); + assertContains( + packagedStartupVerifier, + "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( + 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", () => 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", + "Expected the POSIX sentinel to terminate its group when its verifier parent dies.", + ); + assertContains( + windowsStartupJobLauncher, + "GetStdHandle(STD_INPUT_HANDLE)", + "Expected Windows startup containment to inherit a private verifier control pipe.", + ); + if ( + windowsStartupJobLauncher.indexOf("EnsureVerifierControlConnected(verifierControl)") < 0 || + windowsStartupJobLauncher.indexOf("EnsureVerifierControlConnected(verifierControl)") > + windowsStartupJobLauncher.indexOf("if (!CreateProcess(") + ) { + throw new Error("Expected Windows verifier pipe authority before payload creation."); + } + assertContains( + windowsStartupJobLauncher, + "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, + "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("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 create Electron atomically inside the Job Object before resume.", + ); + } + 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")', + "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("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")', + "Expected packaged startup proof to reject an unresponsive renderer process.", + ); + assertNotContains( + packagedStartupVerifier, + '"linux" | "mac" | "win"', + "The extracted startup verifier must not absorb deferred Linux packaging policy.", + ); const appleSigningNames = [ "CSC_LINK", "CSC_KEY_PASSWORD", @@ -315,6 +697,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( @@ -487,6 +872,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', @@ -529,6 +919,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/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 new file mode 100644 index 00000000..84ed113a --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -0,0 +1,1735 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} 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"; + +import { + attachMacDiskImageForInspection, + assertPackagedLaunchCommandSafety, + assertUnsignedWindowsReleaseSignatureDetails, + assertWindowsReleaseSignatureDetails, + cleanupPackagedStartupTemporaryRoot, + createPackagedDesktopSmokeEnvironment, + expectedPackagedDesktopStartupAssetName, + expectedPackagedDesktopStartupAssetNames, + formatPackagedStartupFailures, + hasProvenPackagedNativeChildOutcome, + hasPackagedStartupProof, + isScientWindowsExecutable, + monitorPackagedStartupTermination, + PackagedPreparationCleanupError, + parsePackagedDesktopStartupArgs, + prepareMacLaunch, + prepareWindowsJobLauncherAssembly, + readWindowsExecutableArchitecture, + readPackagedDesktopLogTail, + readPackagedBackendProcessIds, + readPackagedNativeChildOutcome, + resolveExactPackagedDesktopStartupAsset, + resolveNativePackagedDesktopPlatform, + resolvePackagedDesktopLogPath, + runPackagedPreparationCommand, + sanitizePackagedDesktopInheritedEnvironment, + spawnContainedPackagedDesktop, + terminateProcessTree, + waitForPackagedStartupProof, + writePackagedStartupFailureDiagnostics, +} from "./verify-packaged-desktop-startup.ts"; + +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 }); + } +}); + +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", + "--commit", + "0123456789abcdef0123456789abcdef01234567", + ]), + ).toEqual({ + assetsDirectory: expect.stringMatching(/release-publish$/), + platform: "mac", + arch: "x64", + version: "1.2.3", + commit: "0123456789abcdef0123456789abcdef01234567", + timeoutMs: 60_000, + }); + + expect(() => + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "mac", + "--arch", + "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", () => { + 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.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 [ + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "TEMP", + "TMP", + "TMPDIR", + "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("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 { + 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", + "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", + ]; + + for (const omittedMarker of requiredMarkers) { + writeFileSync( + logPath, + requiredMarkers.filter((marker) => marker !== omittedMarker).join("\n"), + ); + expect( + hasPackagedStartupProof(logPath, { + version: "1.2.3", + commit: "0123456789abcdef0123456789abcdef01234567", + }), + ).toBe(false); + } + + writeFileSync(logPath, requiredMarkers.join("\n")); + 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 main window hidden", + "packaged main window closed", + "packaged responsiveness failed message=frozen", + "backend process exited generation=1 pid=42 reason=unexpected exit", + ]) { + writeFileSync(logPath, [...requiredMarkers, failureMarker].join("\n")); + 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("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("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( + 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("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("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); + + 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(1); + + source.emit("SIGTERM"); + expect(termination.readSignal()).toBe("SIGTERM"); + + termination.dispose(); + expect(source.listenerCount("SIGINT")).toBe(0); + 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, { + exitCode: null, + pid: 42, + signalCode: null, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + 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("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("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("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"); + 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("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) => { + 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("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"); + 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"); + 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.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( + 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("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("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); + + 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("closes a live Windows Job Object launcher by process handle", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const terminateRoot = vi.fn(() => true); + await expect( + terminateProcessTree( + child, + { + platform: "win32", + childIsAlive: () => true, + terminateRoot, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived handle-bound cleanup"); + expect(terminateRoot).toHaveBeenCalledOnce(); + expect(terminateRoot).toHaveBeenCalledWith(child); + }); + + 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, + terminateRoot: () => false, + waitForTargetsExit: async () => true, + }), + ).rejects.toThrow("could not be terminated by handle"); + }); + + it("only observes Job Object descendants after the Windows launcher exits", async () => { + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const terminateRoot = vi.fn(() => true); + + await terminateProcessTree( + child, + { + platform: "win32", + terminateRoot, + waitForTargetsExit: async () => true, + }, + [84], + ); + + expect(terminateRoot).not.toHaveBeenCalled(); + }); + + 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", + windowsJobAssemblyPath: "C:\\payload\\packaged-startup-windows-job.dll", + }; + + 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$/), + "-AssemblyPath", + launch.windowsJobAssemblyPath, + ]), + ); + 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("EXTENDED_STARTUPINFO_PRESENT"); + expect(jobScript).toContain("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"); + expect(jobScript).toContain("PROC_THREAD_ATTRIBUTE_JOB_LIST"); + 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( + 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( + jobScript.indexOf("if (!CreateProcess("), + ); + 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( + 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[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")( + "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 () => { + 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 windowsJobAssemblyPath = await prepareWindowsJobLauncherAssembly( + root, + new AbortController().signal, + ); + + const launcher = spawnContainedPackagedDesktop( + { command: executable, args: [], cwd: root, windowsJobAssemblyPath }, + { + ...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 () => { + 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 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; + 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.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 () => { + 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)}],`, + `{ 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(""); + 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); + 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, + }); + 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); + 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", () => { + 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("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("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 requestPosixShutdown = vi.fn(() => true); + await expect( + terminateProcessTree( + child, + { + platform: "darwin", + requestPosixShutdown, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived sentinel-owned cleanup"); + 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 requestPosixShutdown = vi.fn(() => { + events.push("request-ipc-shutdown"); + return true; + }); + + await terminateProcessTree( + child, + { + platform: "darwin", + requestPosixShutdown, + waitForTargetsExit: async (_targets, timeoutMs) => { + events.push(`reap:${timeoutMs}`); + return true; + }, + }, + [84], + ); + + 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 () => { + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const requestPosixShutdown = vi.fn(() => true); + + await expect( + terminateProcessTree(child, { + platform: "darwin", + requestPosixShutdown, + waitForTargetsExit: async () => true, + }), + ).rejects.toThrow("before authoritative whole-group cleanup"); + + expect(requestPosixShutdown).not.toHaveBeenCalled(); + }); + + 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", + }), + ).rejects.toThrow("before authoritative whole-group cleanup"); + }); + + 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", + waitForTargetsExit: async () => true, + }, + [84], + ), + ).rejects.toThrow("before authoritative whole-group cleanup"); + }); + + 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 requestPosixShutdown = vi.fn(() => false); + await expect( + terminateProcessTree( + child, + { + platform: "darwin", + requestPosixShutdown, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).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 () => { + const child = { + exitCode: 0, + 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("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 terminateRoot = vi.fn(() => true); + + await expect( + terminateProcessTree( + child, + { + platform: "win32", + terminateRoot, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived handle-bound cleanup"); + expect(terminateRoot).not.toHaveBeenCalled(); + }); + + 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..f47c9c63 --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.ts @@ -0,0 +1,1589 @@ +#!/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, 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, isAbsolute, join, relative, resolve, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + assertValidTimestampedWindowsAuthenticodeSignature, + isWindowsAuthenticodeSignatureDetails, + WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES, + type WindowsAuthenticodeSignatureDetails, +} 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), +); +const POSIX_SENTINEL_SHUTDOWN_MESSAGE = { + type: "scient-packaged-startup-shutdown", +} as const; + +export type PackagedDesktopPlatform = "mac" | "win"; + +export interface PackagedDesktopStartupOptions { + readonly assetsDirectory: string; + readonly platform: PackagedDesktopPlatform; + readonly arch: string; + readonly version: string; + readonly commit: string; + readonly timeoutMs: number; + readonly allowUnsignedWindows?: boolean; + readonly windowsPublisherSubject?: string; +} + +interface TerminationSignalSource { + on(signal: NodeJS.Signals, listener: () => void): unknown; + removeListener(signal: NodeJS.Signals, listener: () => void): unknown; +} + +export function monitorPackagedStartupTermination(source: TerminationSignalSource = process): { + readonly signal: Promise; + readonly abortSignal: AbortSignal; + 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>(); + 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); + source.on(name, listener); + } + + return { + signal, + abortSignal: abortController.signal, + readSignal: () => observedSignal, + dispose: () => { + for (const [name, listener] of listeners) { + source.removeListener(name, listener); + } + listeners.clear(); + }, + }; +} + +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", + "--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}.`); + } + + 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 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."); + } + 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")), + platform, + arch, + version: required("--version"), + commit, + timeoutMs, + ...(platform === "win" + ? { + allowUnsignedWindows: allowUnsignedWindowsValue === "true", + ...(windowsPublisherSubject ? { windowsPublisherSubject } : {}), + } + : {}), + }; +} + +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)); +} + +export async function runPackagedPreparationCommand( + command: string, + 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: platform !== "win32", + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + 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); + }); + 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(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 }); + 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 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, + ); + } + 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(); +} + +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)); +} + +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, + version: string, +): string { + const extension = platform === "mac" ? ".zip" : ".exe"; + 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, +): 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; + readonly windowsJobAssemblyPath?: string; + 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.", + ); + } + 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, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + return spawnProcess( + powershell, + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + WINDOWS_JOB_LAUNCHER_PATH, + "-AssemblyPath", + launch.windowsJobAssemblyPath, + "-ExecutablePath", + launch.command, + "-WorkingDirectory", + launch.cwd, + ], + { + cwd: launch.cwd, + env: environment, + detached: false, + // 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, + }, + ); + } + return spawnProcess( + process.execPath, + [ + POSIX_SENTINEL_PATH, + String(process.pid), + launch.command, + launch.cwd, + JSON.stringify(launch.args), + ], + { + cwd: launch.cwd, + env: environment, + detached: true, + stdio: ["ignore", "pipe", "pipe", "ipc"], + windowsHide: true, + }, + ); +} + +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( + 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 (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; + } + return async () => { + 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.`, + ); + } + } + }; +} + +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}.`, + ); + } +} + +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, runCommand) + : undefined; + if (!isDiskImage) { + await runCommand("ditto", ["-x", "-k", archive, extractionRoot], { signal }); + } + try { + 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 = 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 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], + { signal }, + ); + const bundleVersion = await runCommand( + "plutil", + ["-extract", "CFBundleShortVersionString", "raw", "-o", "-", infoPlist], + { signal }, + ); + const bundleExecutable = await runCommand( + "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 runCommand("lipo", ["-archs", executable], { + 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: executable, + args: [], + cwd: appBundle, + ...(cleanup ? { cleanup } : {}), + }; + } catch (error) { + if (cleanup) { + try { + await cleanup(); + } catch (cleanupError) { + throw new PackagedPreparationCleanupError( + `Failed to inspect and detach ${basename(archive)}.`, + new AggregateError([error, cleanupError]), + ); + } + } + throw error; + } +} + +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; +} + +export type WindowsReleaseSignatureDetails = WindowsAuthenticodeSignatureDetails; + +export function assertWindowsReleaseSignatureDetails( + signatures: ReadonlyArray, + expectedPublisherSubject: string, +): void { + for (const [index, signature] of signatures.entries()) { + const label = index === 0 ? "Windows installer" : "Extracted Scient executable"; + const { signerSubject } = assertValidTimestampedWindowsAuthenticodeSignature(signature, label); + if (signerSubject !== expectedPublisherSubject) { + throw new Error( + `${label} publisher ${signature.signerSubject ?? "missing"} does not match ${expectedPublisherSubject}.`, + ); + } + } +} + +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'", + ...WINDOWS_AUTHENTICODE_READER_FUNCTION_LINES, + "@((Read-AuthenticodeSignature $InstallerPath), (Read-AuthenticodeSignature $ExecutablePath)) | ConvertTo-Json -Compress -Depth 4", +].join("\r\n"); + +async function verifyWindowsReleaseSignatures( + installer: string, + executable: string, + expectedPublisherSubject: string | null, + extractionRoot: string, + signal: AbortSignal, +): Promise { + const scriptPath = join(extractionRoot, "verify-release-signatures.ps1"); + writeFileSync(scriptPath, WINDOWS_RELEASE_SIGNATURE_SCRIPT, { encoding: "utf8", mode: 0o600 }); + const powershell = resolveWindowsPowerShell(); + 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."); + } + if (!parsed.every(isWindowsAuthenticodeSignatureDetails)) { + throw new Error("Windows Authenticode verifier returned malformed release signature details."); + } + if (expectedPublisherSubject === null) { + assertUnsignedWindowsReleaseSignatureDetails(parsed); + } else { + assertWindowsReleaseSignatureDetails(parsed, expectedPublisherSubject); + } +} + +async function prepareWindowsLaunch( + assetsDirectory: string, + extractionRoot: string, + expectedAssetName: string, + 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 }); + await runPackagedPreparationCommand("7z", ["x", "-y", `-o${installerRoot}`, installer], { + signal, + }); + 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}.`, + ); + } + 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}.`); + } + const executableArchitecture = readWindowsExecutableArchitecture(readFileSync(executables[0]!)); + if (executableArchitecture !== options.arch) { + throw new Error( + `Expected exact Windows ${options.arch} executable, found ${executableArchitecture ?? "unknown"}.`, + ); + } + 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, + ); + const windowsJobAssemblyPath = await prepareWindowsJobLauncherAssembly(extractionRoot, signal); + return { + command: executables[0]!, + args: [], + cwd: dirname(executables[0]!), + windowsJobAssemblyPath, + }; +} + +async function prepareLaunch( + options: PackagedDesktopStartupOptions, + extractionRoot: string, + expectedAssetName: string, + signal: AbortSignal, +): Promise { + const launch = + options.platform === "mac" + ? await prepareMacLaunch( + options.assetsDirectory, + extractionRoot, + expectedAssetName, + options, + signal, + ) + : await prepareWindowsLaunch( + options.assetsDirectory, + extractionRoot, + expectedAssetName, + options, + signal, + ); + 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"), + 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", + }); + for (const path of [ + env.HOME, + env.APPDATA, + env.LOCALAPPDATA, + env.XDG_CONFIG_HOME, + env.XDG_CACHE_HOME, + env.XDG_DATA_HOME, + env.TEMP, + 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 childIsAlive?: (child: ChildProcess) => boolean; + readonly terminateRoot?: (child: ChildProcess) => boolean; + readonly requestPosixShutdown?: (child: ChildProcess) => boolean | Promise; + readonly waitForTargetsExit?: ( + targets: ReadonlyArray, + timeoutMs: number, + ) => Promise; +} + +const POSIX_SENTINEL_SHUTDOWN_TIMEOUT_MS = 14_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; + 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 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); + 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(); + }); +} + +export async function terminateProcessTree( + child: ChildProcess, + dependencies: ProcessTerminationDependencies = {}, + additionalProcessIds: ReadonlyArray = [], +): Promise { + const platform = dependencies.platform ?? process.platform; + const childCanStillOwnProcesses = + child.exitCode === 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 + // 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( + (target, index, allTargets) => + target.pid > 0 && + allTargets.findIndex((candidate) => candidate.pid === target.pid) === index, + ); + const targets = [ + ...(rootTarget ? [rootTarget] : []), + ...observedTargets.filter((target) => target.pid !== rootTarget?.pid), + ]; + if (targets.length === 0) { + if (platform !== "win32" && child.pid) { + throw new Error( + "Packaged POSIX sentinel vanished before authoritative whole-group cleanup; 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 + // 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, WINDOWS_JOB_CLOSE_TIMEOUT_MS)) return; + throw new Error( + `Packaged Windows Job Object tree survived handle-bound cleanup; observed processes ${targets.map(({ pid }) => pid).join(", ")}.`, + ); + } + + if (!rootTarget) { + await awaitTargetsExit(targets, 2_000); + throw new Error( + `Packaged POSIX sentinel exited before authoritative whole-group cleanup; preserving evidence and refusing numeric signaling authority for observed descendants ${targets.map(({ pid }) => pid).join(", ")}.`, + ); + } + // Request cleanup only over the retained IPC channel. The sentinel itself + // owns graceful whole-group termination and escalation from its unrecycled + // 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 control channel could not accept cleanup; preserving evidence and refusing numeric fallback signaling.", + ); + } + if (await awaitTargetsExit(targets, POSIX_SENTINEL_SHUTDOWN_TIMEOUT_MS)) return; + throw new Error( + `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived sentinel-owned cleanup.`, + ); +} + +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("packaged main window visible") && + 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 main window hidden") && + !log.includes("packaged main window closed") && + !log.includes("packaged responsiveness failed") && + !log.includes("backend process exited generation=") + ); + } catch { + return false; + } +} + +export function readPackagedBackendProcessIds(environment: NodeJS.ProcessEnv | null): number[] { + 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) { + 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 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]; +} + +export interface PackagedDesktopChildOutcome { + readonly exited: { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + } | null; + readonly launchError: Error | null; +} + +interface InspectedPackagedDesktopChildOutcome { + readonly evidence: "pending" | "proven" | "invalid"; + 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."); + return join(scientHome, PACKAGED_NATIVE_CHILD_OUTCOME_FILE); +} + +function inspectPackagedNativeChildOutcome( + environment: NodeJS.ProcessEnv, +): InspectedPackagedDesktopChildOutcome { + 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; + 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 && + ((validExitCode && exited.signal === null) || (exited.code === null && validSignal)) && + launchError === null + ) { + return { + evidence: "proven", + outcome: { + exited: { + code: exited.code, + signal: exited.signal as NodeJS.Signals | null, + }, + launchError: null, + }, + }; + } + if ( + exited === null && + launchError && + typeof launchError.message === "string" && + launchError.message.trim().length > 0 + ) { + return { + evidence: "proven", + outcome: { exited: null, launchError: new Error(launchError.message) }, + }; + } + return { + evidence: "invalid", + outcome: { + exited: null, + launchError: new Error("Malformed packaged native child outcome."), + }, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { evidence: "pending", outcome: { exited: null, launchError: null } }; + } + return { + 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"; +} + +export function readPackagedDesktopLogTail(logPath: string, maxCharacters = 200_000): string { + try { + return readFileSync(logPath, "utf8").slice(-maxCharacters).trim(); + } catch { + return ""; + } +} + +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}`; +} + +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; + 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; +} + +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, + readOutcome, + isProcessAlive = () => true, + 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) { + assertPackagedDesktopChildStillRunning(readOutcome()); + 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) { + // 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).", + ); + } + // 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 { + 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 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 { + if (platform === "darwin") return "mac"; + if (platform === "win32") return "win"; + return null; +} + +async function verifyPackagedDesktopPayload( + options: PackagedDesktopStartupOptions, + expectedAssetName: string, +): 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}-${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 }> = []; + let processCleanupFailed = false; + const termination = monitorPackagedStartupTermination(); + try { + 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; + child = spawnContainedPackagedDesktop(launch, environment); + + 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 Promise.race([ + waitForPackagedStartupProof({ + timeoutMs: options.timeoutMs, + hasProof: () => hasPackagedStartupProof(launchLogPath, options), + 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) => { + throw new Error(`Packaged startup verification interrupted by ${signal}.`); + }), + ]); + } catch (error) { + if (error instanceof PackagedPreparationCleanupError) { + processCleanupFailed = true; + failures.push({ phase: "preparation process cleanup failed", error }); + } else { + failures.push({ phase: "startup verification failed", error }); + } + } + + try { + if (child) { + await terminateProcessTree(child, {}, readPackagedBackendProcessIds(environment)); + } + } catch (error) { + processCleanupFailed = true; + 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 ( + 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) : ""; + const temporaryCleanup = cleanupPackagedStartupTemporaryRoot({ + temporaryRoot, + processCleanupFailed, + }); + if (temporaryCleanup.failure) failures.push(temporaryCleanup.failure); + + 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( + redactPackagedStartupDiagnostic(formatPackagedStartupFailures(failures, output, logTail)), + ); + } + console.log( + `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))); +}