From 804a789793eda2b63e02908182e1c7c35afafe48 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 00:43:29 +0300 Subject: [PATCH 01/34] Secure Scient state initialization --- .github/workflows/ci.yml | 5 + .../src/desktopScientDataDirectories.test.ts | 78 ++++++++ .../src/desktopScientDataDirectories.ts | 31 ++++ apps/desktop/src/main.ts | 9 +- apps/server/src/config.ts | 16 +- apps/server/src/main.test.ts | 73 ++++++++ apps/server/src/main.ts | 9 + apps/server/src/privatePathPermissions.ts | 71 ++------ apps/server/src/serverLogger.ts | 8 +- .../src/serverPrivateDirectories.test.ts | 168 ++++++++++++++++++ packages/shared/package.json | 8 + packages/shared/src/privatePathPermissions.ts | 59 ++++++ packages/shared/src/scientDataDirectories.ts | 46 +++++ 13 files changed, 519 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/src/desktopScientDataDirectories.test.ts create mode 100644 apps/desktop/src/desktopScientDataDirectories.ts create mode 100644 apps/server/src/serverPrivateDirectories.test.ts create mode 100644 packages/shared/src/privatePathPermissions.ts create mode 100644 packages/shared/src/scientDataDirectories.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c56fbf319..f86f6704e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,11 @@ jobs: - name: Test Effect Windows process spawn run: bun run --cwd apps/server test src/windowsProcessEffect.test.ts + - name: Test Windows private state initialization + run: | + bun run --cwd apps/server test src/serverPrivateDirectories.test.ts + bun run --cwd apps/desktop test src/desktopScientDataDirectories.test.ts + - name: Exercise Windows release staging run: bun run release:smoke diff --git a/apps/desktop/src/desktopScientDataDirectories.test.ts b/apps/desktop/src/desktopScientDataDirectories.test.ts new file mode 100644 index 000000000..9ba8f4673 --- /dev/null +++ b/apps/desktop/src/desktopScientDataDirectories.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { PRIVATE_DIRECTORY_MODE } from "@synara/shared/privatePathPermissions"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ensurePrivateDesktopScientDataDirectoriesSync } from "./desktopScientDataDirectories"; +import { seedScientHomeFromPapiLab } from "./legacyPapiLabHomeMigration"; + +const temporaryRoots: string[] = []; + +function makeRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "scient-desktop-private-dirs-")); + temporaryRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("ensurePrivateDesktopScientDataDirectoriesSync", () => { + it.runIf(process.platform !== "win32")( + "repairs migrated state before desktop logging can use it", + () => { + const container = makeRoot(); + const legacyHome = path.join(container, ".papilab"); + const scientHome = path.join(container, ".scient"); + const legacyLogsDir = path.join(legacyHome, "userdata", "logs"); + fs.mkdirSync(legacyLogsDir, { recursive: true }); + fs.writeFileSync(path.join(legacyLogsDir, "server.log"), "legacy"); + fs.chmodSync(path.join(legacyHome, "userdata"), 0o775); + fs.chmodSync(legacyLogsDir, 0o775); + + expect( + seedScientHomeFromPapiLab({ sourcePath: legacyHome, targetPath: scientHome }).status, + ).toBe("seeded"); + const paths = ensurePrivateDesktopScientDataDirectoriesSync(scientHome); + + expect(fs.readFileSync(path.join(paths.logsDir, "server.log"), "utf8")).toBe("legacy"); + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).mode & 0o777, directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "creates a fresh desktop tree as owner-only under umask 002", + () => { + const scientHome = path.join(makeRoot(), ".scient"); + const previousUmask = process.umask(0o002); + let paths: ReturnType; + try { + paths = ensurePrivateDesktopScientDataDirectoriesSync(scientHome); + } finally { + process.umask(previousUmask); + } + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).mode & 0o777, directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + }, + ); + + it("creates every managed directory with Windows permission semantics", () => { + const paths = ensurePrivateDesktopScientDataDirectoriesSync( + path.join(makeRoot(), ".scient"), + "win32", + ); + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).isDirectory(), directoryPath).toBe(true); + } + }); +}); diff --git a/apps/desktop/src/desktopScientDataDirectories.ts b/apps/desktop/src/desktopScientDataDirectories.ts new file mode 100644 index 000000000..e0cd389bc --- /dev/null +++ b/apps/desktop/src/desktopScientDataDirectories.ts @@ -0,0 +1,31 @@ +import path from "node:path"; + +import { + ensurePrivateScientDirectoriesSync, + type ScientDataDirectoryPaths, +} from "@synara/shared/scientDataDirectories"; + +export function deriveDesktopScientDataDirectories(baseDir: string): ScientDataDirectoryPaths { + const stateDir = path.join(baseDir, "userdata"); + const logsDir = path.join(stateDir, "logs"); + return { + baseDir, + stateDir, + secretsDir: path.join(stateDir, "secrets"), + worktreesDir: path.join(baseDir, "worktrees"), + attachmentsDir: path.join(stateDir, "attachments"), + logsDir, + providerLogsDir: path.join(logsDir, "provider"), + terminalLogsDir: path.join(logsDir, "terminals"), + }; +} + +/** Secures desktop-owned state before logging or the backend child can touch it. */ +export function ensurePrivateDesktopScientDataDirectoriesSync( + baseDir: string, + platform: NodeJS.Platform = process.platform, +): ScientDataDirectoryPaths { + const paths = deriveDesktopScientDataDirectories(baseDir); + ensurePrivateScientDirectoriesSync(paths, platform); + return paths; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 774a99d76..b30b47d96 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -155,6 +155,7 @@ import { repairBrowserProfileFromBridgeManifest, seedDesktopUserDataProfileFromPapiLab, } from "./desktopUserDataProfile"; +import { ensurePrivateDesktopScientDataDirectoriesSync } from "./desktopScientDataDirectories"; import { seedScientHomeFromPapiLab } from "./legacyPapiLabHomeMigration"; import { isBrokenPipeError } from "./desktopProcessErrors"; import { @@ -229,7 +230,11 @@ if (legacyPapiLabHome) { } } const BASE_DIR = resolvedScientHome; -const STATE_DIR = Path.join(BASE_DIR, "userdata"); +// Migration must run before this call because it atomically renames a staged +// legacy home into a non-existent target. From this point onward every desktop +// writer and the backend child see the same secured Scient-owned boundaries. +const SCIENT_DATA_DIRECTORIES = ensurePrivateDesktopScientDataDirectoriesSync(BASE_DIR); +const STATE_DIR = SCIENT_DATA_DIRECTORIES.stateDir; const DESKTOP_WINDOW_STATE_PATH = Path.join(STATE_DIR, "desktop-window-state.json"); const DESKTOP_SCHEME = SCIENT_DESKTOP_SCHEME; const LEGACY_PAPILAB_DESKTOP_SCHEME = "papilab"; @@ -240,7 +245,7 @@ const APP_DISPLAY_NAME = isDevelopment ? `${SCIENT_APP_NAME} (Dev)` : SCIENT_APP const APP_USER_MODEL_ID = scientBundleId(isDevelopment); const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; -const LOG_DIR = Path.join(STATE_DIR, "logs"); +const LOG_DIR = SCIENT_DATA_DIRECTORIES.logsDir; const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const LOG_FILE_MAX_FILES = 10; const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e950d87f5..48d380225 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -10,6 +10,10 @@ import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; import OS from "node:os"; import pathPosix from "node:path/posix"; import pathWin32 from "node:path/win32"; +import { + ensurePrivateScientDirectoriesSync, + ensurePrivateScientStateDirectoriesSync, +} from "@synara/shared/scientDataDirectories"; import { realpathNearestExisting } from "./realpathNearestExisting"; @@ -160,9 +164,15 @@ export class ServerConfig extends ServiceMap.Service { + if (typeof baseDirOrPrefix === "string") { + // Explicit test fixtures commonly use shared roots such as /tmp. + // Secure Scient's children without changing or rejecting that root. + ensurePrivateScientStateDirectoriesSync(derivedPaths); + } else { + ensurePrivateScientDirectoriesSync({ baseDir, ...derivedPaths }); + } + }); const { homeDir, chatWorkspaceRoot, studioWorkspaceRoot } = yield* resolveCanonicalWorkspaceRoots({ homeDir: OS.homedir() }); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 47c10f3e3..24548a698 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -16,6 +16,7 @@ import { NetService } from "@synara/shared/Net"; import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { PRIVATE_DIRECTORY_MODE } from "./privatePathPermissions"; import { ServerSettingsService } from "./serverSettings"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./effectServer"; @@ -143,6 +144,20 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); assert.equal(resolvedConfig?.logProviderEvents, false); assert.equal(resolvedConfig?.logWebSocketEvents, false); + if (process.platform !== "win32" && resolvedConfig) { + for (const directoryPath of [ + resolvedConfig.baseDir, + resolvedConfig.stateDir, + resolvedConfig.secretsDir, + resolvedConfig.worktreesDir, + resolvedConfig.attachmentsDir, + resolvedConfig.logsDir, + resolvedConfig.providerLogsDir, + resolvedConfig.terminalLogsDir, + ]) { + assert.equal(fs.statSync(directoryPath).mode & 0o777, PRIVATE_DIRECTORY_MODE); + } + } assert.equal(stop.mock.calls.length, 1); }), ); @@ -240,6 +255,64 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("secures every Scient-owned state directory before server startup", () => + Effect.gen(function* () { + const preexistingLogsDir = path.join(defaultScientHome, "userdata", "logs"); + fs.mkdirSync(preexistingLogsDir, { recursive: true }); + fs.chmodSync(defaultScientHome, 0o775); + fs.chmodSync(path.join(defaultScientHome, "userdata"), 0o775); + fs.chmodSync(preexistingLogsDir, 0o775); + + const previousUmask = process.umask(0o002); + try { + yield* runCli([], { + SYNARA_MODE: "desktop", + SYNARA_NO_BROWSER: "true", + }); + } finally { + process.umask(previousUmask); + } + + assert.equal(start.mock.calls.length, 1); + const config = resolvedConfig; + if (!config) throw new Error("Expected server config to resolve before startup"); + const privateDirectories = [ + config.baseDir, + config.stateDir, + config.secretsDir, + config.worktreesDir, + config.attachmentsDir, + config.logsDir, + config.providerLogsDir, + config.terminalLogsDir, + ]; + for (const directoryPath of privateDirectories) { + assert.equal(fs.statSync(directoryPath).mode & 0o777, PRIVATE_DIRECTORY_MODE); + } + }), + ); + + it.effect("does not start when SCIENT_HOME is a symlink", () => + Effect.gen(function* () { + if (process.platform === "win32") return; + const container = makeTempHome("scient-main-symlink-"); + const target = path.join(container, "target"); + const symlink = path.join(container, "scient-home"); + fs.mkdirSync(target); + fs.chmodSync(target, 0o775); + fs.symlinkSync(target, symlink, "dir"); + + yield* runCli([], { + SCIENT_HOME: symlink, + SYNARA_MODE: "desktop", + SYNARA_NO_BROWSER: "true", + }).pipe(Effect.catch(() => Effect.void)); + + assert.equal(start.mock.calls.length, 0); + assert.equal(fs.statSync(target).mode & 0o777, 0o775); + }), + ); + it.effect("allows overriding desktop host with --host", () => Effect.gen(function* () { yield* runCli(["--host", "0.0.0.0"], { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5cc9d95ff..0f494f2ea 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -10,6 +10,7 @@ import OS from "node:os"; import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@synara/shared/Net"; +import { ensurePrivateScientDirectoriesSync } from "@synara/shared/scientDataDirectories"; import { DEFAULT_PORT, deriveServerPaths, @@ -174,6 +175,14 @@ const ServerConfigLive = (input: CliInput) => const baseDir = yield* resolveBaseDir(configuredHome); const userHomeDir = OS.homedir(); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + yield* Effect.try({ + try: () => ensurePrivateScientDirectoriesSync({ baseDir, ...derivedPaths }), + catch: (cause) => + new StartupError({ + message: `Failed to secure Scient application data at ${baseDir}`, + cause, + }), + }); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( diff --git a/apps/server/src/privatePathPermissions.ts b/apps/server/src/privatePathPermissions.ts index dc45e4782..9f52045ea 100644 --- a/apps/server/src/privatePathPermissions.ts +++ b/apps/server/src/privatePathPermissions.ts @@ -1,35 +1,27 @@ import fs from "node:fs"; import path from "node:path"; -export const PRIVATE_DIRECTORY_MODE = 0o700; -export const PRIVATE_FILE_MODE = 0o600; -export const PRIVATE_EXECUTABLE_FILE_MODE = 0o700; -const UNSUPPORTED_DIRECTORY_SYNC_CODES = new Set(["EINVAL", "ENOTSUP", "EBADF"]); - -export class PrivatePathPermissionError extends Error { - readonly path: string; - readonly operation: string; +import { + PRIVATE_DIRECTORY_MODE, + PRIVATE_EXECUTABLE_FILE_MODE, + PRIVATE_FILE_MODE, + PrivatePathPermissionError, + supportsPosixPermissions, + withPrivatePathContext, +} from "@synara/shared/privatePathPermissions"; + +export { + ensurePrivateDirectorySync, + PRIVATE_DIRECTORY_MODE, + PRIVATE_EXECUTABLE_FILE_MODE, + PRIVATE_FILE_MODE, + PrivatePathPermissionError, + supportsPosixPermissions, +} from "@synara/shared/privatePathPermissions"; - constructor(operation: string, targetPath: string, cause: unknown) { - super(`Failed to ${operation} private path ${targetPath}`, { cause }); - this.name = "PrivatePathPermissionError"; - this.path = targetPath; - this.operation = operation; - } -} - -function withPathContext(operation: string, targetPath: string, action: () => T): T { - try { - return action(); - } catch (cause) { - if (cause instanceof PrivatePathPermissionError) throw cause; - throw new PrivatePathPermissionError(operation, targetPath, cause); - } -} +const UNSUPPORTED_DIRECTORY_SYNC_CODES = new Set(["EINVAL", "ENOTSUP", "EBADF"]); -export function supportsPosixPermissions(platform: NodeJS.Platform = process.platform): boolean { - return platform !== "win32"; -} +const withPathContext = withPrivatePathContext; /** Flushes directory-entry changes where the platform exposes durable directory fsync. */ export async function syncDirectoryEntry( @@ -52,31 +44,6 @@ export async function syncDirectoryEntry( } } -export function ensurePrivateDirectorySync( - directoryPath: string, - platform: NodeJS.Platform = process.platform, -): void { - withPathContext("create", directoryPath, () => { - fs.mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); - }); - if (!supportsPosixPermissions(platform)) return; - - const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; - const descriptor = withPathContext("open without following symlinks", directoryPath, () => - fs.openSync(directoryPath, directoryFlags), - ); - try { - withPathContext("set mode on", directoryPath, () => { - if (!fs.fstatSync(descriptor).isDirectory()) { - throw new Error("Path is not a directory"); - } - fs.fchmodSync(descriptor, PRIVATE_DIRECTORY_MODE); - }); - } finally { - fs.closeSync(descriptor); - } -} - export function repairPrivateFileSync( filePath: string, options: { diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 1b90babaa..3c3ab24f4 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,16 +1,14 @@ -import fs from "node:fs"; - import { Effect, Logger } from "effect"; import * as Layer from "effect/Layer"; import { ServerConfig } from "./config"; +import { ensurePrivateDirectorySync } from "./privatePathPermissions"; export const ServerLoggerLive = Effect.gen(function* () { const { logsDir, serverLogPath } = yield* ServerConfig; - yield* Effect.sync(() => { - fs.mkdirSync(logsDir, { recursive: true }); - }); + // Keep the logger safe in isolation as well as behind normal config startup. + yield* Effect.sync(() => ensurePrivateDirectorySync(logsDir)); const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); diff --git a/apps/server/src/serverPrivateDirectories.test.ts b/apps/server/src/serverPrivateDirectories.test.ts new file mode 100644 index 000000000..428b8694d --- /dev/null +++ b/apps/server/src/serverPrivateDirectories.test.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { PRIVATE_DIRECTORY_MODE, PrivatePathPermissionError } from "./privatePathPermissions"; +import { + ensurePrivateScientDirectoriesSync, + type ScientDataDirectoryPaths, +} from "@synara/shared/scientDataDirectories"; + +const temporaryRoots: string[] = []; + +function makeRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "scient-private-dirs-")); + temporaryRoots.push(root); + return root; +} + +function makePaths(baseDir: string): ScientDataDirectoryPaths { + const stateDir = path.join(baseDir, "userdata"); + const logsDir = path.join(stateDir, "logs"); + return { + baseDir, + stateDir, + secretsDir: path.join(stateDir, "secrets"), + worktreesDir: path.join(baseDir, "worktrees"), + attachmentsDir: path.join(stateDir, "attachments"), + logsDir, + providerLogsDir: path.join(logsDir, "provider"), + terminalLogsDir: path.join(logsDir, "terminals"), + }; +} + +function permissionMode(targetPath: string): number { + return fs.statSync(targetPath).mode & 0o777; +} + +function orderedDirectoryPaths(paths: ScientDataDirectoryPaths): readonly string[] { + return [ + paths.baseDir, + paths.stateDir, + paths.secretsDir, + paths.attachmentsDir, + paths.logsDir, + paths.providerLogsDir, + paths.terminalLogsDir, + paths.worktreesDir, + ]; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("ensurePrivateScientDirectoriesSync", () => { + it.runIf(process.platform !== "win32")( + "creates every Scient-owned directory as owner-only under common umasks", + () => { + for (const umask of [0o000, 0o002, 0o022]) { + const container = makeRoot(); + const paths = makePaths(path.join(container, `scient-home-${umask.toString(8)}`)); + const previousUmask = process.umask(umask); + try { + ensurePrivateScientDirectoriesSync(paths); + } finally { + process.umask(previousUmask); + } + + for (const directoryPath of Object.values(paths)) { + expect(permissionMode(directoryPath), directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "repairs an existing group-writable application-data tree and is idempotent", + () => { + for (const insecureMode of [0o755, 0o775, 0o777]) { + const baseDir = path.join(makeRoot(), `scient-home-${insecureMode.toString(8)}`); + const paths = makePaths(baseDir); + for (const directoryPath of orderedDirectoryPaths(paths)) { + fs.mkdirSync(directoryPath, { recursive: true }); + fs.chmodSync(directoryPath, insecureMode); + } + + ensurePrivateScientDirectoriesSync(paths); + ensurePrivateScientDirectoriesSync(paths); + + for (const directoryPath of Object.values(paths)) { + expect(permissionMode(directoryPath), directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "refuses symlinks at every managed boundary without repairing their targets", + () => { + for (let targetIndex = 0; targetIndex < 8; targetIndex += 1) { + const container = makeRoot(); + const paths = makePaths(path.join(container, "scient-home")); + const orderedPaths = orderedDirectoryPaths(paths); + for (const precedingPath of orderedPaths.slice(0, targetIndex)) { + fs.mkdirSync(precedingPath, { recursive: true }); + fs.chmodSync(precedingPath, PRIVATE_DIRECTORY_MODE); + } + const externalTarget = path.join(container, `target-${targetIndex}`); + fs.mkdirSync(externalTarget); + fs.chmodSync(externalTarget, 0o775); + fs.symlinkSync(externalTarget, orderedPaths[targetIndex]!, "dir"); + + expect(() => ensurePrivateScientDirectoriesSync(paths)).toThrow(PrivatePathPermissionError); + expect(permissionMode(externalTarget)).toBe(0o775); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "reports a regular file at every expected directory boundary", + () => { + for (let targetIndex = 0; targetIndex < 8; targetIndex += 1) { + const container = makeRoot(); + const paths = makePaths(path.join(container, "scient-home")); + const orderedPaths = orderedDirectoryPaths(paths); + for (const precedingPath of orderedPaths.slice(0, targetIndex)) { + fs.mkdirSync(precedingPath, { recursive: true }); + fs.chmodSync(precedingPath, PRIVATE_DIRECTORY_MODE); + } + fs.writeFileSync(orderedPaths[targetIndex]!, "not-a-directory"); + + expect(() => ensurePrivateScientDirectoriesSync(paths)).toThrow(PrivatePathPermissionError); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "never changes a user project outside Scient application data", + () => { + const container = makeRoot(); + const projectDir = path.join(container, "project"); + fs.mkdirSync(projectDir, { mode: 0o775 }); + fs.chmodSync(projectDir, 0o775); + + ensurePrivateScientDirectoriesSync(makePaths(path.join(container, "scient-home"))); + + expect(permissionMode(projectDir)).toBe(0o775); + }, + ); + + it("creates directories without applying POSIX chmod semantics on Windows", () => { + const baseDir = path.join(makeRoot(), "scient-home"); + const paths = makePaths(baseDir); + fs.mkdirSync(baseDir, { mode: 0o755 }); + const originalMode = permissionMode(baseDir); + + ensurePrivateScientDirectoriesSync(paths, "win32"); + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).isDirectory()).toBe(true); + } + if (process.platform !== "win32") expect(permissionMode(baseDir)).toBe(originalMode); + }); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index 8c59343b8..4758a1849 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,14 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./privatePathPermissions": { + "types": "./src/privatePathPermissions.ts", + "import": "./src/privatePathPermissions.ts" + }, + "./scientDataDirectories": { + "types": "./src/scientDataDirectories.ts", + "import": "./src/scientDataDirectories.ts" + }, "./errorMessages": { "types": "./src/errorMessages.ts", "import": "./src/errorMessages.ts" diff --git a/packages/shared/src/privatePathPermissions.ts b/packages/shared/src/privatePathPermissions.ts new file mode 100644 index 000000000..2c8f5184f --- /dev/null +++ b/packages/shared/src/privatePathPermissions.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; + +export const PRIVATE_DIRECTORY_MODE = 0o700; +export const PRIVATE_FILE_MODE = 0o600; +export const PRIVATE_EXECUTABLE_FILE_MODE = 0o700; + +export class PrivatePathPermissionError extends Error { + readonly path: string; + readonly operation: string; + + constructor(operation: string, targetPath: string, cause: unknown) { + super(`Failed to ${operation} private path ${targetPath}`, { cause }); + this.name = "PrivatePathPermissionError"; + this.path = targetPath; + this.operation = operation; + } +} + +export function withPrivatePathContext( + operation: string, + targetPath: string, + action: () => T, +): T { + try { + return action(); + } catch (cause) { + if (cause instanceof PrivatePathPermissionError) throw cause; + throw new PrivatePathPermissionError(operation, targetPath, cause); + } +} + +export function supportsPosixPermissions(platform: NodeJS.Platform = process.platform): boolean { + return platform !== "win32"; +} + +export function ensurePrivateDirectorySync( + directoryPath: string, + platform: NodeJS.Platform = process.platform, +): void { + withPrivatePathContext("create", directoryPath, () => { + fs.mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + }); + if (!supportsPosixPermissions(platform)) return; + + const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; + const descriptor = withPrivatePathContext("open without following symlinks", directoryPath, () => + fs.openSync(directoryPath, directoryFlags), + ); + try { + withPrivatePathContext("set mode on", directoryPath, () => { + if (!fs.fstatSync(descriptor).isDirectory()) { + throw new Error("Path is not a directory"); + } + fs.fchmodSync(descriptor, PRIVATE_DIRECTORY_MODE); + }); + } finally { + fs.closeSync(descriptor); + } +} diff --git a/packages/shared/src/scientDataDirectories.ts b/packages/shared/src/scientDataDirectories.ts new file mode 100644 index 000000000..6009b6441 --- /dev/null +++ b/packages/shared/src/scientDataDirectories.ts @@ -0,0 +1,46 @@ +import { ensurePrivateDirectorySync } from "./privatePathPermissions"; + +export interface ScientDataDirectoryPaths { + readonly baseDir: string; + readonly stateDir: string; + readonly secretsDir: string; + readonly worktreesDir: string; + readonly attachmentsDir: string; + readonly logsDir: string; + readonly providerLogsDir: string; + readonly terminalLogsDir: string; +} + +type ScientStateDirectoryPaths = Omit; + +/** Secures children of an application-data home without modifying the home itself. */ +export function ensurePrivateScientStateDirectoriesSync( + paths: ScientStateDirectoryPaths, + platform: NodeJS.Platform = process.platform, +): void { + const privateDirectories = [ + paths.stateDir, + paths.secretsDir, + paths.attachmentsDir, + paths.logsDir, + paths.providerLogsDir, + paths.terminalLogsDir, + ]; + + for (const directoryPath of new Set(privateDirectories)) { + ensurePrivateDirectorySync(directoryPath, platform); + } +} + +/** + * Creates or repairs every security-boundary directory derived from + * SCIENT_HOME. User-selected project/workspace paths are deliberately absent. + */ +export function ensurePrivateScientDirectoriesSync( + paths: ScientDataDirectoryPaths, + platform: NodeJS.Platform = process.platform, +): void { + ensurePrivateDirectorySync(paths.baseDir, platform); + ensurePrivateScientStateDirectoriesSync(paths, platform); + ensurePrivateDirectorySync(paths.worktreesDir, platform); +} From 3433cfbcc3d78ddbc0aa26c265f0718433d7cf0a Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 02:30:34 +0300 Subject: [PATCH 02/34] Allow legacy migration state test --- scripts/check-brand-identity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-brand-identity.ts b/scripts/check-brand-identity.ts index 282bccf8d..bbefc9603 100644 --- a/scripts/check-brand-identity.ts +++ b/scripts/check-brand-identity.ts @@ -300,6 +300,7 @@ function containsForbiddenIdentity(value: string): boolean { const legacyPapiLabCompatibilityPaths = new Set([ "apps/desktop/src/desktopStorageMigration.ts", "apps/desktop/src/desktopStorageMigration.test.ts", + "apps/desktop/src/desktopScientDataDirectories.test.ts", "apps/desktop/src/desktopUserDataProfile.ts", "apps/desktop/src/desktopUserDataProfile.test.ts", "apps/desktop/src/legacyPapiLabHomeMigration.ts", From 5e93c43099fcf3b4c24f9a5e01c31ec8b0fe1d8b Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 01:00:14 +0300 Subject: [PATCH 03/34] Supervise the desktop backend lifecycle --- .github/workflows/ci.yml | 5 + apps/desktop/src/backendProcessTree.test.ts | 76 +++++ apps/desktop/src/backendProcessTree.ts | 73 ++++ .../src/desktopBackendSupervisor.test.ts | 217 ++++++++++++ apps/desktop/src/desktopBackendSupervisor.ts | 258 ++++++++++++++ apps/desktop/src/main.ts | 323 +++++++++--------- apps/server/src/desktopParentShutdown.test.ts | 32 ++ apps/server/src/desktopParentShutdown.ts | 23 ++ apps/server/src/main.ts | 5 +- packages/shared/package.json | 4 + packages/shared/src/backendControl.ts | 26 ++ 11 files changed, 874 insertions(+), 168 deletions(-) create mode 100644 apps/desktop/src/backendProcessTree.test.ts create mode 100644 apps/desktop/src/backendProcessTree.ts create mode 100644 apps/desktop/src/desktopBackendSupervisor.test.ts create mode 100644 apps/desktop/src/desktopBackendSupervisor.ts create mode 100644 apps/server/src/desktopParentShutdown.test.ts create mode 100644 apps/server/src/desktopParentShutdown.ts create mode 100644 packages/shared/src/backendControl.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f86f6704e..d79dd31d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,11 @@ jobs: bun run --cwd apps/server test src/serverPrivateDirectories.test.ts bun run --cwd apps/desktop test src/desktopScientDataDirectories.test.ts + - name: Test Windows backend lifecycle + run: | + 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: Exercise Windows release staging run: bun run release:smoke diff --git a/apps/desktop/src/backendProcessTree.test.ts b/apps/desktop/src/backendProcessTree.test.ts new file mode 100644 index 000000000..d177f08c9 --- /dev/null +++ b/apps/desktop/src/backendProcessTree.test.ts @@ -0,0 +1,76 @@ +import { EventEmitter } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { + backendProcessContainmentOptions, + forceTerminateBackendProcessTree, +} from "./backendProcessTree"; + +describe("forceTerminateBackendProcessTree", () => { + it("always reserves an IPC channel and isolates POSIX process groups", () => { + expect(backendProcessContainmentOptions(true, "linux")).toEqual({ + detached: true, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + expect(backendProcessContainmentOptions(false, "darwin")).toEqual({ + detached: true, + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); + expect(backendProcessContainmentOptions(false, "win32")).toEqual({ + detached: false, + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); + }); + + it("kills the detached POSIX process group", async () => { + const killProcessGroup = vi.fn(); + + await forceTerminateBackendProcessTree({ pid: 4321 }, { platform: "linux", killProcessGroup }); + + expect(killProcessGroup).toHaveBeenCalledWith(-4321, "SIGKILL"); + }); + + it("ignores a POSIX process group that already exited", async () => { + await expect( + forceTerminateBackendProcessTree( + { pid: 4321 }, + { + platform: "darwin", + killProcessGroup: () => { + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ESRCH"; + throw error; + }, + }, + ), + ).resolves.toBeUndefined(); + }); + + it("uses the Windows taskkill executable without a shell", async () => { + const process = new EventEmitter(); + const spawnProcess = vi.fn(() => process); + + const terminating = forceTerminateBackendProcessTree( + { pid: 4321 }, + { + platform: "win32", + env: { SystemRoot: "D:\\Windows" }, + spawnProcess: spawnProcess as never, + }, + ); + process.emit("exit", 0, null); + await terminating; + + expect(spawnProcess).toHaveBeenCalledWith( + "D:\\Windows\\System32\\taskkill.exe", + ["/PID", "4321", "/T", "/F"], + { + env: { SystemRoot: "D:\\Windows" }, + shell: false, + stdio: "ignore", + windowsHide: true, + }, + ); + }); +}); diff --git a/apps/desktop/src/backendProcessTree.ts b/apps/desktop/src/backendProcessTree.ts new file mode 100644 index 000000000..98f17691a --- /dev/null +++ b/apps/desktop/src/backendProcessTree.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import path from "node:path"; + +import { resolveWindowsSystemRoot } from "@synara/shared/windowsProcess"; + +export interface ForceTerminateBackendProcessTreeOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; + readonly killProcessGroup?: (pid: number, signal: NodeJS.Signals) => void; + readonly spawnProcess?: typeof spawn; +} + +export function backendProcessContainmentOptions( + captureLogs: boolean, + platform: NodeJS.Platform = process.platform, +): Pick { + return { + detached: platform !== "win32", + stdio: captureLogs + ? ["ignore", "pipe", "pipe", "ipc"] + : ["ignore", "inherit", "inherit", "ipc"], + }; +} + +function ignoreMissingProcess(error: unknown): void { + if ((error as NodeJS.ErrnoException)?.code !== "ESRCH") throw error; +} + +async function forceTerminateWindowsTree( + pid: number, + options: ForceTerminateBackendProcessTreeOptions, +): Promise { + const env = options.env ?? process.env; + const taskkill = path.win32.join(resolveWindowsSystemRoot(env), "System32", "taskkill.exe"); + const child = (options.spawnProcess ?? spawn)(taskkill, ["/PID", String(pid), "/T", "/F"], { + env, + shell: false, + stdio: "ignore", + windowsHide: true, + }); + + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0 || code === 128) { + resolve(); + return; + } + reject(new Error(`taskkill exited with status ${code ?? "null"}`)); + }); + }); +} + +/** Force-kills the backend and all descendants after graceful IPC shutdown timed out. */ +export async function forceTerminateBackendProcessTree( + child: Pick, + options: ForceTerminateBackendProcessTreeOptions = {}, +): Promise { + const pid = child.pid; + if (!pid || pid <= 0) return; + + if ((options.platform ?? process.platform) === "win32") { + await forceTerminateWindowsTree(pid, options); + return; + } + + try { + (options.killProcessGroup ?? process.kill)(-pid, "SIGKILL"); + } catch (error) { + ignoreMissingProcess(error); + } +} diff --git a/apps/desktop/src/desktopBackendSupervisor.test.ts b/apps/desktop/src/desktopBackendSupervisor.test.ts new file mode 100644 index 000000000..68ff24e23 --- /dev/null +++ b/apps/desktop/src/desktopBackendSupervisor.test.ts @@ -0,0 +1,217 @@ +import { EventEmitter } from "node:events"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + DesktopBackendSupervisor, + type DesktopBackendChild, + type DesktopBackendSupervisorOptions, +} from "./desktopBackendSupervisor"; + +class FakeBackendChild extends EventEmitter implements DesktopBackendChild { + readonly pid: number; + connected = true; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + readonly sent: unknown[] = []; + + constructor(pid: number) { + super(); + this.pid = pid; + } + + send(message: unknown): boolean { + this.sent.push(message); + return this.connected; + } + + spawn(): void { + this.emit("spawn"); + } + + fail(error: Error): void { + this.emit("error", error); + } + + exit(code: number | null = 0, signal: NodeJS.Signals | null = null): void { + this.exitCode = code; + this.signalCode = signal; + this.emit("exit", code, signal); + } +} + +function makeHarness(overrides: Partial = {}) { + const children: FakeBackendChild[] = []; + const prepared: number[] = []; + const exits: Array<{ generation: number; reason: string; expected: boolean }> = []; + const restarts: Array<{ attempt: number; delayMs: number; reason: string }> = []; + const forceTerminateTree = vi.fn(async (child: DesktopBackendChild) => { + (child as FakeBackendChild).exit(null, "SIGKILL"); + }); + const supervisor = new DesktopBackendSupervisor({ + prepareStart: async (generation) => { + prepared.push(generation); + }, + spawn: (generation) => { + const child = new FakeBackendChild(1_000 + generation); + children.push(child); + return child; + }, + requestGracefulShutdown: (child, reason) => + child.send?.({ type: "scient.backend.shutdown", reason }) ?? false, + forceTerminateTree, + onGenerationExited: (event) => exits.push(event), + onRestartScheduled: (event) => restarts.push(event), + ...overrides, + }); + return { children, exits, forceTerminateTree, prepared, restarts, supervisor }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("DesktopBackendSupervisor", () => { + it("serializes duplicate starts into one backend generation", async () => { + const harness = makeHarness(); + + await Promise.all([harness.supervisor.start(), harness.supervisor.start()]); + + expect(harness.prepared).toEqual([1]); + expect(harness.children).toHaveLength(1); + expect(harness.supervisor.currentGeneration?.number).toBe(1); + }); + + it("handles an error followed by exit exactly once", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const child = harness.children[0]!; + + child.fail(new Error("spawn failed")); + child.exit(1); + + expect(harness.exits).toEqual([ + { generation: 1, pid: 1001, reason: "error=spawn failed", expected: false }, + ]); + expect(harness.restarts).toEqual([{ attempt: 0, delayMs: 500, reason: "error=spawn failed" }]); + await vi.advanceTimersByTimeAsync(500); + expect(harness.children).toHaveLength(2); + }); + + it("backs off across unstable generations and resets only after readiness", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + + harness.children[0]!.exit(1); + await vi.advanceTimersByTimeAsync(500); + harness.children[1]!.exit(1); + await vi.advanceTimersByTimeAsync(1_000); + harness.supervisor.markReady(3); + harness.children[2]!.exit(1); + + expect(harness.restarts.map(({ attempt, delayMs }) => ({ attempt, delayMs }))).toEqual([ + { attempt: 0, delayMs: 500 }, + { attempt: 1, delayMs: 1_000 }, + { attempt: 0, delayMs: 500 }, + ]); + }); + + it("ignores late events from a closed generation", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const first = harness.children[0]!; + first.fail(new Error("first failure")); + await vi.advanceTimersByTimeAsync(500); + + first.exit(1); + + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.exits).toHaveLength(1); + expect(harness.restarts).toHaveLength(1); + }); + + it("uses graceful IPC and does not force-kill a backend that exits", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const child = harness.children[0]!; + + const stopping = harness.supervisor.stop("app quit"); + await Promise.resolve(); + child.exit(0); + await stopping; + + expect(child.sent).toEqual([{ type: "scient.backend.shutdown", reason: "app quit" }]); + expect(harness.forceTerminateTree).not.toHaveBeenCalled(); + expect(harness.exits[0]?.expected).toBe(true); + expect(harness.restarts).toHaveLength(0); + }); + + it("coalesces repeated stops and force-terminates the tree after timeout", async () => { + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 100, + forcedExitTimeoutMs: 50, + }); + await harness.supervisor.start(); + const child = harness.children[0]!; + + const firstStop = harness.supervisor.stop("first quit"); + const secondStop = harness.supervisor.stop("second quit"); + await vi.advanceTimersByTimeAsync(100); + await Promise.all([firstStop, secondStop]); + + expect(child.sent).toEqual([{ type: "scient.backend.shutdown", reason: "first quit" }]); + expect(harness.forceTerminateTree).toHaveBeenCalledOnce(); + expect(harness.restarts).toHaveLength(0); + }); + + it("force-terminates immediately when the IPC channel is unavailable", async () => { + const harness = makeHarness({ + requestGracefulShutdown: () => false, + gracefulShutdownTimeoutMs: 10_000, + }); + await harness.supervisor.start(); + + await harness.supervisor.stop("lost IPC"); + + expect(harness.forceTerminateTree).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not restart a start failure classified as fatal", async () => { + const fatalError = new Error("backend bundle missing"); + const onFatalStartFailure = vi.fn(); + const harness = makeHarness({ + prepareStart: async () => { + throw fatalError; + }, + classifyStartFailure: () => "fatal", + onFatalStartFailure, + }); + + await harness.supervisor.start(); + + expect(harness.supervisor.desiredRunning).toBe(false); + expect(harness.restarts).toHaveLength(0); + expect(onFatalStartFailure).toHaveBeenCalledWith(fatalError); + }); + + it("honors a start queued while graceful shutdown is still finishing", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const stopping = harness.supervisor.stop("updater handoff"); + const restarting = harness.supervisor.start(); + await Promise.resolve(); + first.exit(0); + await Promise.all([stopping, restarting]); + + expect(harness.children).toHaveLength(2); + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.restarts).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/desktopBackendSupervisor.ts b/apps/desktop/src/desktopBackendSupervisor.ts new file mode 100644 index 000000000..80476aefe --- /dev/null +++ b/apps/desktop/src/desktopBackendSupervisor.ts @@ -0,0 +1,258 @@ +import type { ScientBackendShutdownMessage } from "@synara/shared/backendControl"; + +export interface DesktopBackendChild { + readonly pid?: number | undefined; + readonly connected?: boolean | undefined; + readonly exitCode: number | null; + readonly signalCode: NodeJS.Signals | null; + once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + on(event: "error", listener: (error: Error) => void): this; + off(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + send?(message: ScientBackendShutdownMessage): boolean; +} + +export interface DesktopBackendGeneration { + readonly child: DesktopBackendChild; + readonly number: number; +} + +export interface DesktopBackendExit { + readonly generation: number; + readonly pid: number | null; + readonly reason: string; + readonly expected: boolean; +} + +export interface DesktopBackendSupervisorOptions { + readonly prepareStart: (generation: number) => Promise; + readonly spawn: (generation: number) => DesktopBackendChild; + readonly requestGracefulShutdown: (child: DesktopBackendChild, reason: string) => boolean; + readonly forceTerminateTree: (child: DesktopBackendChild) => Promise | void; + readonly onGenerationStarted?: (generation: DesktopBackendGeneration) => void; + readonly onGenerationExited?: (exit: DesktopBackendExit) => void; + readonly onRestartScheduled?: (input: { + readonly attempt: number; + readonly delayMs: number; + readonly reason: string; + }) => void; + readonly classifyStartFailure?: (error: unknown) => "fatal" | "retry"; + readonly onFatalStartFailure?: (error: unknown) => void; + readonly onError?: (error: unknown, context: string) => void; + readonly setTimer?: typeof setTimeout; + readonly clearTimer?: typeof clearTimeout; + readonly restartBaseDelayMs?: number; + readonly restartMaxDelayMs?: number; + readonly gracefulShutdownTimeoutMs?: number; + readonly forcedExitTimeoutMs?: number; +} + +interface ActiveGeneration extends DesktopBackendGeneration { + closed: boolean; +} + +const DEFAULT_RESTART_BASE_DELAY_MS = 500; +const DEFAULT_RESTART_MAX_DELAY_MS = 10_000; +const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 8_000; +const DEFAULT_FORCED_EXIT_TIMEOUT_MS = 2_000; + +function childHasExited(child: DesktopBackendChild): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +function exitReason(code: number | null, signal: NodeJS.Signals | null): string { + return `code=${code ?? "null"} signal=${signal ?? "null"}`; +} + +/** + * Owns exactly one desired desktop backend process. Every mutation is serialized, + * and generation checks prevent late events from an old child changing current state. + */ +export class DesktopBackendSupervisor { + readonly #options: DesktopBackendSupervisorOptions; + readonly #setTimer: typeof setTimeout; + readonly #clearTimer: typeof clearTimeout; + + #desiredRunning = false; + #active: ActiveGeneration | null = null; + #generation = 0; + #restartAttempt = 0; + #restartTimer: ReturnType | null = null; + #transition: Promise = Promise.resolve(); + readonly #stoppingGenerations = new Set(); + + constructor(options: DesktopBackendSupervisorOptions) { + this.#options = options; + this.#setTimer = options.setTimer ?? setTimeout; + this.#clearTimer = options.clearTimer ?? clearTimeout; + } + + get desiredRunning(): boolean { + return this.#desiredRunning; + } + + get currentGeneration(): DesktopBackendGeneration | null { + return this.#active ? { child: this.#active.child, number: this.#active.number } : null; + } + + start(): Promise { + this.#desiredRunning = true; + return this.#enqueue(() => this.#ensureStarted()); + } + + stop(reason: string): Promise { + this.#desiredRunning = false; + if (this.#active) this.#stoppingGenerations.add(this.#active.number); + this.#clearRestartTimer(); + return this.#enqueue(() => this.#stopActive(reason)); + } + + markReady(generation: number): void { + if ( + !this.#active || + this.#active.number !== generation || + this.#active.closed || + !this.#desiredRunning + ) { + return; + } + this.#restartAttempt = 0; + } + + #enqueue(action: () => Promise): Promise { + const next = this.#transition.then(action, action); + this.#transition = next.catch((error: unknown) => { + this.#options.onError?.(error, "backend lifecycle transition"); + }); + return next; + } + + async #ensureStarted(): Promise { + if (!this.#desiredRunning || this.#active) return; + + const generation = ++this.#generation; + try { + await this.#options.prepareStart(generation); + if (!this.#desiredRunning || this.#active) return; + + const child = this.#options.spawn(generation); + const active: ActiveGeneration = { child, number: generation, closed: false }; + this.#active = active; + this.#bindChild(active); + this.#options.onGenerationStarted?.(active); + } catch (error) { + if (!this.#desiredRunning) return; + if (this.#options.classifyStartFailure?.(error) === "fatal") { + this.#desiredRunning = false; + this.#options.onFatalStartFailure?.(error); + return; + } + this.#scheduleRestart(error instanceof Error ? error.message : String(error)); + } + } + + #bindChild(active: ActiveGeneration): void { + active.child.on("error", (error) => { + this.#handleGenerationClosed(active, `error=${error.message}`); + }); + active.child.once("exit", (code, signal) => { + this.#handleGenerationClosed(active, exitReason(code, signal)); + }); + } + + #handleGenerationClosed(active: ActiveGeneration, reason: string): void { + if (active.closed) return; + active.closed = true; + const wasCurrent = this.#active === active; + if (wasCurrent) this.#active = null; + const expected = this.#stoppingGenerations.delete(active.number) || !this.#desiredRunning; + this.#options.onGenerationExited?.({ + generation: active.number, + pid: active.child.pid ?? null, + reason, + expected, + }); + if (wasCurrent && !expected) this.#scheduleRestart(reason); + } + + #scheduleRestart(reason: string): void { + if (!this.#desiredRunning || this.#restartTimer) return; + const baseDelay = this.#options.restartBaseDelayMs ?? DEFAULT_RESTART_BASE_DELAY_MS; + const maxDelay = this.#options.restartMaxDelayMs ?? DEFAULT_RESTART_MAX_DELAY_MS; + const attempt = this.#restartAttempt; + const delayMs = Math.min(baseDelay * 2 ** attempt, maxDelay); + this.#restartAttempt += 1; + this.#options.onRestartScheduled?.({ attempt, delayMs, reason }); + this.#restartTimer = this.#setTimer(() => { + this.#restartTimer = null; + void this.#enqueue(() => this.#ensureStarted()); + }, delayMs); + this.#restartTimer.unref?.(); + } + + #clearRestartTimer(): void { + if (!this.#restartTimer) return; + this.#clearTimer(this.#restartTimer); + this.#restartTimer = null; + } + + async #stopActive(reason: string): Promise { + const active = this.#active; + if (!active) return; + this.#stoppingGenerations.add(active.number); + if (childHasExited(active.child)) { + this.#handleGenerationClosed(active, "already exited"); + return; + } + + const gracefulTimeoutMs = + this.#options.gracefulShutdownTimeoutMs ?? DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS; + const forcedExitTimeoutMs = this.#options.forcedExitTimeoutMs ?? DEFAULT_FORCED_EXIT_TIMEOUT_MS; + + const exitedGracefully = await this.#waitForExit(active, gracefulTimeoutMs, () => { + const sent = this.#options.requestGracefulShutdown(active.child, reason); + if (!sent) { + this.#options.onError?.( + new Error("Backend IPC shutdown request was unavailable."), + `generation ${active.number} graceful shutdown`, + ); + } + return sent; + }); + if (exitedGracefully) return; + + try { + await this.#options.forceTerminateTree(active.child); + } catch (error) { + this.#options.onError?.(error, `generation ${active.number} force termination`); + } + const exitedAfterForce = await this.#waitForExit(active, forcedExitTimeoutMs); + if (!exitedAfterForce) { + this.#handleGenerationClosed(active, "force termination timed out"); + } + } + + async #waitForExit( + active: ActiveGeneration, + timeoutMs: number, + begin?: () => boolean | void, + ): Promise { + if (active.closed || childHasExited(active.child)) return true; + + return await new Promise((resolve) => { + let settled = false; + const settle = (exited: boolean) => { + if (settled) return; + settled = true; + active.child.off("exit", onExit); + this.#clearTimer(timeout); + resolve(exited); + }; + const onExit = () => settle(true); + active.child.once("exit", onExit); + const timeout = this.#setTimer(() => settle(false), Math.max(0, timeoutMs)); + timeout.unref?.(); + if (begin?.() === false) settle(false); + if (active.closed || childHasExited(active.child)) settle(true); + }); + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b30b47d96..abc005e0a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,7 @@ import type { import { autoUpdater, BaseUpdater, CancellationToken } from "electron-updater"; import type { ContextMenuItem } from "@synara/contracts"; +import { makeScientBackendShutdownMessage } from "@synara/shared/backendControl"; import { getMacTrafficLightPosition } from "@synara/shared/desktopChrome"; import { SCIENT_APP_NAME, @@ -57,6 +58,15 @@ import { RotatingFileSink } from "@synara/shared/logging"; import { ensureStaticSnapshot, findAsarArchivePath } from "@synara/shared/staticSnapshot"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { resolveBackendNodeArgs } from "./backendNodeOptions"; +import { + backendProcessContainmentOptions, + forceTerminateBackendProcessTree, +} from "./backendProcessTree"; +import { + DesktopBackendSupervisor, + type DesktopBackendExit, + type DesktopBackendGeneration, +} from "./desktopBackendSupervisor"; import { bundleSignatureFromStats, isBundleStable, @@ -280,7 +290,7 @@ const browserPerfLoggingEnabled = process.env.SYNARA_BROWSER_PERF === "1"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; let mainWindow: BrowserWindow | null = null; -let backendProcess: ChildProcess.ChildProcess | null = null; +let backendSupervisor: DesktopBackendSupervisor | null = null; let backendPort = 0; let backendAuthToken = ""; let backendHttpUrl = ""; @@ -288,8 +298,6 @@ let backendWsUrl = ""; let backendReadinessAbortController: AbortController | null = null; let backendInitialWindowOpenInFlight: Promise | null = null; let backendListeningDetector: ServerListeningDetector | null = null; -let restartAttempt = 0; -let restartTimer: ReturnType | null = null; let isQuitting = false; let isUpdaterInstallPreparing = false; let isUpdaterQuitAndInstallInFlight = false; @@ -529,7 +537,8 @@ async function reserveBackendEndpoint(reason: string): Promise { } async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { - return await waitForBackendStartupReady({ + const generation = backendSupervisor?.currentGeneration?.number ?? null; + const source = await waitForBackendStartupReady({ listeningPromise: backendListeningDetector?.promise ?? null, waitForHttpReady: () => waitForBackendHttpReady(baseUrl, { @@ -551,6 +560,8 @@ async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | }), cancelHttpWait: cancelBackendReadinessWait, }); + if (generation !== null) backendSupervisor?.markReady(generation); + return source; } function ensureInitialBackendWindowOpen(baseUrl: string): void { @@ -1142,7 +1153,7 @@ function handleFatalStartupError(stage: string, error: unknown): void { `Stage: ${stage}\n${message}${detail}`, ); } - stopBackend(); + stopBackend(`fatal startup: ${stage}`); restoreStdIoCapture?.(); app.quit(); } @@ -2500,7 +2511,7 @@ async function installDownloadedUpdate(): Promise<{ isQuitting = true; isUpdaterInstallPreparing = true; clearUpdatePollTimer(); - await stopBackendAndWaitForExit(); + await stopBackendAndWaitForExit("updater install handoff"); await logMacUpdateDiagnostics("before install handoff"); isUpdaterQuitAndInstallInFlight = true; autoUpdater.quitAndInstall(); @@ -2739,45 +2750,24 @@ function backendEnv(): NodeJS.ProcessEnv { }; } -function scheduleBackendRestart(reason: string): void { - if (isQuitting || restartTimer) return; - - const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); - restartAttempt += 1; - safeConsoleError(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); - - restartTimer = setTimeout(() => { - restartTimer = null; - void restartBackendAfterCrash(reason); - }, delayMs); +interface BackendGenerationRuntime { + readonly listeningDetector: ServerListeningDetector; + readonly closeSession: (details: string) => void; } -async function restartBackendAfterCrash(reason: string): Promise { - if (isQuitting || backendProcess) { - return; +class MissingBackendEntryError extends Error { + constructor(readonly entryPath: string) { + super(`Missing packaged server entry at ${entryPath}`); + this.name = "MissingBackendEntryError"; } - - cancelBackendReadinessWait(); - try { - await reserveBackendEndpoint("backend restart"); - } catch (error) { - scheduleBackendRestart( - `failed to reserve restart port after ${reason}: ${formatErrorMessage(error)}`, - ); - return; - } - - startBackend(); - ensureInitialBackendWindowOpen(backendHttpUrl); } -function startBackend(): void { - if (isQuitting || backendProcess) return; +const backendGenerationRuntimes = new Map(); +function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { const backendEntry = resolveBackendEntry(); if (!FS.existsSync(backendEntry)) { - scheduleBackendRestart(`missing server entry at ${backendEntry}`); - return; + throw new MissingBackendEntryError(backendEntry); } const captureBackendLogs = app.isPackaged && backendLogSink !== null; @@ -2789,11 +2779,12 @@ function startBackend(): void { ...backendEnv(), ELECTRON_RUN_AS_NODE: "1", }, - stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", + // POSIX force termination targets this dedicated process group. Windows + // uses taskkill /T after the same graceful IPC deadline. + ...backendProcessContainmentOptions(captureBackendLogs), }); const listeningDetector = new ServerListeningDetector(); backendListeningDetector = listeningDetector; - backendProcess = child; let backendSessionClosed = false; const closeBackendSession = (details: string) => { if (backendSessionClosed) return; @@ -2805,120 +2796,133 @@ function startBackend(): void { `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`, ); captureBackendOutput(child); - - child.once("spawn", () => { - restartAttempt = 0; - }); - - child.on("error", (error) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail(error); - backendListeningDetector = null; - } - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); - scheduleBackendRestart(error.message); - }); - - child.on("exit", (code, signal) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail( - new Error( - `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, - ), - ); - backendListeningDetector = null; - } - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession( - `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, - ); - if (isQuitting) return; - const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; - scheduleBackendRestart(reason); + backendGenerationRuntimes.set(generation, { + listeningDetector, + closeSession: closeBackendSession, }); + return child; } -function stopBackend(): void { - cancelBackendReadinessWait(); - backendListeningDetector = null; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; +function handleBackendGenerationStarted(generation: DesktopBackendGeneration): void { + if (isDevelopment) { + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`backend generation=${generation.number} ready source=${source}`); + if (!mainWindow) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + } + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) return; + writeDesktopLogHeader( + `backend generation=${generation.number} readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check timed out", error); + if (!mainWindow) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created after readiness warning"); + } + }); + return; } - const child = backendProcess; - backendProcess = null; - if (!child) return; - - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGTERM"); - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - }, BACKEND_FORCE_KILL_DELAY_MS).unref(); + const hadWindow = (mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null) !== null; + ensureInitialBackendWindowOpen(backendHttpUrl); + if (hadWindow && backendInitialWindowOpenInFlight === null) { + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`backend generation=${generation.number} ready source=${source}`); + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) return; + console.warn("[desktop] restarted backend readiness check timed out", error); + }); } } -async function stopBackendAndWaitForExit(timeoutMs = BACKEND_SHUTDOWN_TIMEOUT_MS): Promise { +function handleBackendGenerationExited(exit: DesktopBackendExit): void { cancelBackendReadinessWait(); - backendListeningDetector = null; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; - const backendChild = child; - if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; - - await new Promise((resolve) => { - let settled = false; - let forceKillTimer: ReturnType | null = null; - let exitTimeoutTimer: ReturnType | null = null; - - function settle(): void { - if (settled) return; - settled = true; - backendChild.off("exit", onExit); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - } - if (exitTimeoutTimer) { - clearTimeout(exitTimeoutTimer); - } - resolve(); - } - - function onExit(): void { - settle(); + const runtime = backendGenerationRuntimes.get(exit.generation); + backendGenerationRuntimes.delete(exit.generation); + if (runtime) { + if (backendListeningDetector === runtime.listeningDetector) { + runtime.listeningDetector.fail( + new Error(`backend generation ${exit.generation} closed (${exit.reason})`), + ); + backendListeningDetector = null; } + runtime.closeSession( + `pid=${exit.pid ?? "unknown"} generation=${exit.generation} ${exit.reason}`, + ); + } +} - backendChild.once("exit", onExit); - backendChild.kill("SIGTERM"); - - const forceKillDelayMs = Math.min(BACKEND_FORCE_KILL_DELAY_MS, Math.max(1, timeoutMs - 500)); - forceKillTimer = setTimeout(() => { - if (backendChild.exitCode === null && backendChild.signalCode === null) { - backendChild.kill("SIGKILL"); +function getBackendSupervisor(): DesktopBackendSupervisor { + if (backendSupervisor) return backendSupervisor; + backendSupervisor = new DesktopBackendSupervisor({ + prepareStart: async (generation) => { + cancelBackendReadinessWait(); + await reserveBackendEndpoint( + generation === 1 ? "bootstrap" : `backend generation ${generation}`, + ); + }, + spawn: spawnBackendGeneration, + requestGracefulShutdown: (child, reason) => { + if (!child.send || child.connected === false) return false; + try { + return child.send(makeScientBackendShutdownMessage(reason)); + } catch { + return false; } - }, forceKillDelayMs); - forceKillTimer.unref(); + }, + forceTerminateTree: (child) => forceTerminateBackendProcessTree(child), + onGenerationStarted: handleBackendGenerationStarted, + onGenerationExited: handleBackendGenerationExited, + onRestartScheduled: ({ delayMs, reason }) => { + safeConsoleError( + `[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`, + ); + }, + onError: (error, context) => { + safeConsoleError(`[desktop] ${context}: ${formatErrorMessage(error)}`); + }, + classifyStartFailure: (error) => + error instanceof MissingBackendEntryError ? "fatal" : "retry", + onFatalStartFailure: (error) => handleFatalStartupError("backend", error), + gracefulShutdownTimeoutMs: BACKEND_FORCE_KILL_DELAY_MS, + forcedExitTimeoutMs: BACKEND_SHUTDOWN_TIMEOUT_MS - BACKEND_FORCE_KILL_DELAY_MS, + }); + return backendSupervisor; +} + +function startBackend(): void { + if (isQuitting) return; + void getBackendSupervisor() + .start() + .catch((error: unknown) => { + safeConsoleError(`[desktop] backend start failed: ${formatErrorMessage(error)}`); + }); +} - exitTimeoutTimer = setTimeout(() => { - settle(); - }, timeoutMs); - exitTimeoutTimer.unref(); +function stopBackend(reason = "desktop stop"): void { + cancelBackendReadinessWait(); + if (!backendSupervisor) return; + void backendSupervisor.stop(reason).catch((error: unknown) => { + safeConsoleError(`[desktop] backend stop failed: ${formatErrorMessage(error)}`); }); } +async function stopBackendAndWaitForExit(reason = "desktop shutdown"): Promise { + cancelBackendReadinessWait(); + if (!backendSupervisor) return; + try { + await backendSupervisor.stop(reason); + } catch (error) { + safeConsoleError(`[desktop] backend stop failed: ${formatErrorMessage(error)}`); + } +} + async function disposeBrowserUsePipeServerForShutdown(reason: string): Promise { const pipeServer = browserUsePipeServer; browserUsePipeServer = null; @@ -2950,7 +2954,7 @@ async function shutdownDesktopRuntime(reason: string): Promise { appSnapManager?.dispose(); appSnapManager = null; await disposeBrowserUsePipeServerForShutdown(reason); - await stopBackendAndWaitForExit(); + await stopBackendAndWaitForExit(reason); browserManager.dispose(); restoreStdIoCapture?.(); writeDesktopLogHeader(`${reason} shutdown complete`); @@ -3453,6 +3457,19 @@ function createWindow(): BrowserWindow { window.on("unmaximize", () => emitDesktopWindowState(window)); window.on("enter-full-screen", () => emitDesktopWindowState(window)); window.on("leave-full-screen", () => emitDesktopWindowState(window)); + if (process.platform === "win32") { + window.on("query-session-end", (event) => { + if (desktopShutdownComplete) return; + event.preventDefault(); + writeDesktopLogHeader("Windows query-session-end received"); + requestGracefulAppQuit("Windows session end"); + }); + window.on("session-end", () => { + if (desktopShutdownPromise) return; + writeDesktopLogHeader("Windows session-end received"); + void shutdownDesktopRuntime("Windows session end"); + }); + } window.on("close", () => { try { writeDesktopWindowState(DESKTOP_WINDOW_STATE_PATH, { @@ -3544,39 +3561,11 @@ if (!hasSingleInstanceLock) { async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); backendAuthToken = Crypto.randomBytes(24).toString("hex"); - await reserveBackendEndpoint("bootstrap"); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); - startBackend(); + await getBackendSupervisor().start(); writeDesktopLogHeader("bootstrap backend start requested"); - - if (isDevelopment) { - void waitForBackendWindowReady(backendHttpUrl) - .then((source) => { - writeDesktopLogHeader(`bootstrap backend ready source=${source}`); - if (!mainWindow) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); - } - }) - .catch((error) => { - if (isBackendReadinessAborted(error)) { - return; - } - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); - if (!mainWindow) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created after readiness warning"); - } - }); - return; - } - - ensureInitialBackendWindowOpen(backendHttpUrl); } app.on("before-quit", (event) => { diff --git a/apps/server/src/desktopParentShutdown.test.ts b/apps/server/src/desktopParentShutdown.test.ts new file mode 100644 index 000000000..cc4f470a9 --- /dev/null +++ b/apps/server/src/desktopParentShutdown.test.ts @@ -0,0 +1,32 @@ +import { EventEmitter } from "node:events"; + +import { Effect, Fiber } from "effect"; +import { describe, expect, it } from "vitest"; + +import { waitForDesktopParentShutdown } from "./desktopParentShutdown"; + +describe("waitForDesktopParentShutdown", () => { + it("ignores unrelated messages and completes for the shutdown protocol", async () => { + const source = new EventEmitter(); + const fiber = Effect.runFork(waitForDesktopParentShutdown(source)); + await new Promise((resolve) => setImmediate(resolve)); + + source.emit("message", { type: "other" }); + expect(fiber.pollUnsafe()).toBeUndefined(); + source.emit("message", { type: "scient.backend.shutdown", reason: "app quit" }); + + await Effect.runPromise(Fiber.join(fiber)); + expect(fiber.pollUnsafe()).toBeDefined(); + expect(source.listenerCount("message")).toBe(0); + }); + + it("removes its listener when the server scope is interrupted", async () => { + const source = new EventEmitter(); + const fiber = Effect.runFork(waitForDesktopParentShutdown(source)); + await new Promise((resolve) => setImmediate(resolve)); + + await Effect.runPromise(Fiber.interrupt(fiber)); + + expect(source.listenerCount("message")).toBe(0); + }); +}); diff --git a/apps/server/src/desktopParentShutdown.ts b/apps/server/src/desktopParentShutdown.ts new file mode 100644 index 000000000..277229ad1 --- /dev/null +++ b/apps/server/src/desktopParentShutdown.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect"; + +import { isScientBackendShutdownMessage } from "@synara/shared/backendControl"; + +export interface DesktopParentMessageSource { + on(event: "message", listener: (message: unknown) => void): unknown; + off(event: "message", listener: (message: unknown) => void): unknown; +} + +/** Completes when the Electron parent asks the scoped server runtime to shut down. */ +export function waitForDesktopParentShutdown( + source: DesktopParentMessageSource = process, +): Effect.Effect { + return Effect.callback((resume) => { + const onMessage = (message: unknown) => { + if (!isScientBackendShutdownMessage(message)) return; + source.off("message", onMessage); + resume(Effect.void); + }; + source.on("message", onMessage); + return Effect.sync(() => source.off("message", onMessage)); + }); +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0f494f2ea..30a09b8bf 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -11,6 +11,7 @@ import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceM import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@synara/shared/Net"; import { ensurePrivateScientDirectoriesSync } from "@synara/shared/scientDataDirectories"; +import { waitForDesktopParentShutdown } from "./desktopParentShutdown"; import { DEFAULT_PORT, deriveServerPaths, @@ -371,7 +372,9 @@ const makeServerProgram = (input: CliInput) => ); } - return yield* stopSignal; + return yield* config.mode === "desktop" + ? Effect.raceFirst(stopSignal, waitForDesktopParentShutdown()) + : stopSignal; }).pipe(Effect.provide(LayerLive(input))); /** diff --git a/packages/shared/package.json b/packages/shared/package.json index 4758a1849..400001c61 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -84,6 +84,10 @@ "types": "./src/browserSession.ts", "import": "./src/browserSession.ts" }, + "./backendControl": { + "types": "./src/backendControl.ts", + "import": "./src/backendControl.ts" + }, "./conversationEdit": { "types": "./src/conversationEdit.ts", "import": "./src/conversationEdit.ts" diff --git a/packages/shared/src/backendControl.ts b/packages/shared/src/backendControl.ts new file mode 100644 index 000000000..fd10ae5e6 --- /dev/null +++ b/packages/shared/src/backendControl.ts @@ -0,0 +1,26 @@ +export const SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE = "scient.backend.shutdown"; + +export interface ScientBackendShutdownMessage { + readonly type: typeof SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE; + readonly reason: string; +} + +export function makeScientBackendShutdownMessage(reason: string): ScientBackendShutdownMessage { + return { + type: SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE, + reason, + }; +} + +export function isScientBackendShutdownMessage( + message: unknown, +): message is ScientBackendShutdownMessage { + return ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE && + "reason" in message && + typeof message.reason === "string" + ); +} From a1d6b63b1d950183a775d25be71f87a5b2530596 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 02:02:38 +0300 Subject: [PATCH 04/34] Supervise desktop connection recovery --- .../src/desktopBackendSupervisor.test.ts | 71 ++++ apps/desktop/src/desktopBackendSupervisor.ts | 53 ++- apps/desktop/src/desktopConnectionWake.ts | 6 + .../src/initialBackendWindowOpen.test.ts | 17 + apps/desktop/src/initialBackendWindowOpen.ts | 2 + apps/desktop/src/main.ts | 39 +- apps/desktop/src/preload.ts | 11 + .../src/terminal/Layers/Manager.test.ts | 29 ++ apps/server/src/terminal/Layers/Manager.ts | 11 + apps/server/src/terminal/Services/Manager.ts | 4 + apps/server/src/wsRpc.ts | 12 +- apps/web/src/components/ChatView.browser.tsx | 13 +- .../src/components/EventRouter.browser.tsx | 122 ++++++ .../components/KeybindingsToast.browser.tsx | 11 +- .../components/terminal/terminalRuntime.ts | 185 +++++---- .../terminal/terminalRuntimeTypes.test.ts | 24 +- .../terminal/terminalRuntimeTypes.ts | 44 ++- apps/web/src/connectionSupervisor.test.ts | 222 +++++++++++ apps/web/src/connectionSupervisor.ts | 338 +++++++++++++++++ apps/web/src/projectTerminalRunner.test.ts | 2 + apps/web/src/routes/__root.tsx | 3 + apps/web/src/terminalActivity.test.ts | 4 + apps/web/src/test/effectRpcWebSocketMock.ts | 17 +- apps/web/src/wsNativeApi.test.ts | 2 + apps/web/src/wsTransport.ts | 351 +++++++++++------- apps/web/src/wsTransportEvents.ts | 2 +- packages/contracts/src/ipc.ts | 4 + packages/contracts/src/terminal.test.ts | 6 + packages/contracts/src/terminal.ts | 8 + 29 files changed, 1379 insertions(+), 234 deletions(-) create mode 100644 apps/desktop/src/desktopConnectionWake.ts create mode 100644 apps/web/src/connectionSupervisor.test.ts create mode 100644 apps/web/src/connectionSupervisor.ts diff --git a/apps/desktop/src/desktopBackendSupervisor.test.ts b/apps/desktop/src/desktopBackendSupervisor.test.ts index 68ff24e23..b05358b26 100644 --- a/apps/desktop/src/desktopBackendSupervisor.test.ts +++ b/apps/desktop/src/desktopBackendSupervisor.test.ts @@ -134,6 +134,39 @@ describe("DesktopBackendSupervisor", () => { expect(harness.restarts).toHaveLength(1); }); + it("restarts an alive generation that never becomes ready", async () => { + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 100, + forcedExitTimeoutMs: 50, + }); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const restarting = harness.supervisor.restartGeneration(1, "readiness timed out"); + await Promise.resolve(); + first.exit(0); + await restarting; + + expect(first.sent).toEqual([ + { type: "scient.backend.shutdown", reason: "readiness timed out" }, + ]); + expect(harness.restarts).toEqual([{ attempt: 0, delayMs: 500, reason: "readiness timed out" }]); + await vi.advanceTimersByTimeAsync(500); + expect(harness.supervisor.currentGeneration?.number).toBe(2); + }); + + it("ignores a readiness timeout reported by a stale generation", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + harness.children[0]!.exit(1); + await vi.advanceTimersByTimeAsync(500); + + await harness.supervisor.restartGeneration(1, "late readiness timeout"); + + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.restarts).toHaveLength(1); + }); + it("uses graceful IPC and does not force-kill a backend that exits", async () => { const harness = makeHarness(); await harness.supervisor.start(); @@ -181,6 +214,44 @@ describe("DesktopBackendSupervisor", () => { expect(vi.getTimerCount()).toBe(0); }); + it("does not overlap a replacement with a backend that survived force termination", async () => { + const onError = vi.fn(); + const onUnrecoverableGeneration = vi.fn(); + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 10, + forcedExitTimeoutMs: 10, + forceTerminateTree: vi.fn(async () => undefined), + onError, + onUnrecoverableGeneration, + }); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const restarting = harness.supervisor.restartGeneration(1, "readiness timed out"); + await vi.advanceTimersByTimeAsync(20); + await restarting; + + expect(harness.supervisor.currentGeneration?.number).toBe(1); + expect(harness.children).toHaveLength(1); + expect(harness.restarts).toHaveLength(0); + expect(harness.supervisor.desiredRunning).toBe(false); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "Backend remained alive after force termination." }), + "generation 1 restart blocked", + ); + expect(onUnrecoverableGeneration).toHaveBeenCalledWith({ + error: expect.objectContaining({ + message: "Backend remained alive after force termination.", + }), + generation: { child: first, number: 1 }, + reason: "readiness timed out", + }); + + first.exit(1); + expect(harness.restarts).toHaveLength(0); + expect(harness.supervisor.currentGeneration).toBeNull(); + }); + it("does not restart a start failure classified as fatal", async () => { const fatalError = new Error("backend bundle missing"); const onFatalStartFailure = vi.fn(); diff --git a/apps/desktop/src/desktopBackendSupervisor.ts b/apps/desktop/src/desktopBackendSupervisor.ts index 80476aefe..38258466b 100644 --- a/apps/desktop/src/desktopBackendSupervisor.ts +++ b/apps/desktop/src/desktopBackendSupervisor.ts @@ -37,6 +37,11 @@ export interface DesktopBackendSupervisorOptions { }) => void; readonly classifyStartFailure?: (error: unknown) => "fatal" | "retry"; readonly onFatalStartFailure?: (error: unknown) => void; + readonly onUnrecoverableGeneration?: (input: { + readonly error: Error; + readonly generation: DesktopBackendGeneration; + readonly reason: string; + }) => void; readonly onError?: (error: unknown, context: string) => void; readonly setTimer?: typeof setTimeout; readonly clearTimer?: typeof clearTimeout; @@ -103,7 +108,9 @@ export class DesktopBackendSupervisor { this.#desiredRunning = false; if (this.#active) this.#stoppingGenerations.add(this.#active.number); this.#clearRestartTimer(); - return this.#enqueue(() => this.#stopActive(reason)); + return this.#enqueue(async () => { + await this.#stopActive(reason); + }); } markReady(generation: number): void { @@ -118,6 +125,37 @@ export class DesktopBackendSupervisor { this.#restartAttempt = 0; } + restartGeneration(generation: number, reason: string): Promise { + return this.#enqueue(async () => { + if ( + !this.#desiredRunning || + !this.#active || + this.#active.number !== generation || + this.#active.closed + ) { + return; + } + const target = { child: this.#active.child, number: generation }; + const exited = await this.#stopActive(reason); + if (exited && this.#desiredRunning) { + this.#scheduleRestart(reason); + } else if (!exited) { + // A replacement must never overlap an old process that ignored force + // termination. Stop automatic recovery and surface a fatal lifecycle + // failure instead of leaving the app in an unreported half-alive state. + const error = new Error("Backend remained alive after force termination."); + this.#desiredRunning = false; + this.#clearRestartTimer(); + this.#options.onError?.(error, `generation ${generation} restart blocked`); + this.#options.onUnrecoverableGeneration?.({ + error, + generation: target, + reason, + }); + } + }); + } + #enqueue(action: () => Promise): Promise { const next = this.#transition.then(action, action); this.#transition = next.catch((error: unknown) => { @@ -195,13 +233,13 @@ export class DesktopBackendSupervisor { this.#restartTimer = null; } - async #stopActive(reason: string): Promise { + async #stopActive(reason: string): Promise { const active = this.#active; - if (!active) return; + if (!active) return true; this.#stoppingGenerations.add(active.number); if (childHasExited(active.child)) { this.#handleGenerationClosed(active, "already exited"); - return; + return true; } const gracefulTimeoutMs = @@ -218,7 +256,7 @@ export class DesktopBackendSupervisor { } return sent; }); - if (exitedGracefully) return; + if (exitedGracefully) return true; try { await this.#options.forceTerminateTree(active.child); @@ -226,9 +264,8 @@ export class DesktopBackendSupervisor { this.#options.onError?.(error, `generation ${active.number} force termination`); } const exitedAfterForce = await this.#waitForExit(active, forcedExitTimeoutMs); - if (!exitedAfterForce) { - this.#handleGenerationClosed(active, "force termination timed out"); - } + if (!exitedAfterForce) return false; + return true; } async #waitForExit( diff --git a/apps/desktop/src/desktopConnectionWake.ts b/apps/desktop/src/desktopConnectionWake.ts new file mode 100644 index 000000000..65d253354 --- /dev/null +++ b/apps/desktop/src/desktopConnectionWake.ts @@ -0,0 +1,6 @@ +// FILE: desktopConnectionWake.ts +// Purpose: Defines the main-to-renderer signal used to verify connections after native wake events. +// Layer: Desktop IPC +// Exports: DESKTOP_CONNECTION_WAKE_CHANNEL. + +export const DESKTOP_CONNECTION_WAKE_CHANNEL = "desktop:connection-wake"; diff --git a/apps/desktop/src/initialBackendWindowOpen.test.ts b/apps/desktop/src/initialBackendWindowOpen.test.ts index c5c99c864..bb3c222d8 100644 --- a/apps/desktop/src/initialBackendWindowOpen.test.ts +++ b/apps/desktop/src/initialBackendWindowOpen.test.ts @@ -91,4 +91,21 @@ describe("openInitialBackendWindow", () => { expect(options.createWindow).not.toHaveBeenCalled(); expect(options.waitForBackendWindowReady).not.toHaveBeenCalled(); }); + + it("reports a non-abort readiness failure to the lifecycle owner", async () => { + const error = new Error("backend stayed unready"); + const onReadinessFailure = vi.fn(); + const options = createOptions({ + waitForBackendWindowReady: vi.fn(async () => { + throw error; + }), + onReadinessFailure, + }); + + openInitialBackendWindow(options); + const watchedPromise = vi.mocked(options.setReadinessInFlight).mock.calls[0]?.[0]; + await expect(watchedPromise).resolves.toBeUndefined(); + + expect(onReadinessFailure).toHaveBeenCalledWith(error); + }); }); diff --git a/apps/desktop/src/initialBackendWindowOpen.ts b/apps/desktop/src/initialBackendWindowOpen.ts index 6864f537d..0677bdd40 100644 --- a/apps/desktop/src/initialBackendWindowOpen.ts +++ b/apps/desktop/src/initialBackendWindowOpen.ts @@ -17,6 +17,7 @@ export interface InitialBackendWindowOpenOptions { readonly isReadinessAborted: (error: unknown) => boolean; readonly formatErrorMessage: (error: unknown) => string; readonly warn: (message: string, error: unknown) => void; + readonly onReadinessFailure?: (error: unknown) => void; } export function openInitialBackendWindow(options: InitialBackendWindowOpenOptions): void { @@ -46,6 +47,7 @@ export function openInitialBackendWindow(options: InitialBackendWindowOpenOption `bootstrap backend readiness warning message=${options.formatErrorMessage(error)}`, ); options.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); + options.onReadinessFailure?.(error); }) .finally(() => { if (options.getReadinessInFlight() === nextOpen) { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index abc005e0a..b9eb36e0f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,6 +22,7 @@ import { Notification, nativeImage, nativeTheme, + powerMonitor, protocol, screen, session, @@ -36,6 +37,7 @@ import type { } from "electron"; import * as Effect from "effect/Effect"; import type { + DesktopConnectionWakeReason, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -76,6 +78,7 @@ import { } from "./bundleSwapDetection"; import { waitForBackendStartupReady } from "./backendStartupReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; import { LSREGISTER_PATH, parseLastLaunchVersion, @@ -467,6 +470,13 @@ function emitDesktopWindowState(window: BrowserWindow | null = mainWindow): void window.webContents.send(WINDOW_STATE_CHANNEL, getDesktopWindowState(window)); } +function emitDesktopConnectionWake(reason: DesktopConnectionWakeReason): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed() || window.webContents.isDestroyed()) continue; + window.webContents.send(DESKTOP_CONNECTION_WAKE_CHANNEL, reason); + } +} + function isSaveFileInput(input: unknown): input is { defaultFilename: string; contents: string; @@ -564,7 +574,14 @@ async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | return source; } -function ensureInitialBackendWindowOpen(baseUrl: string): void { +function restartBackendAfterReadinessFailure(generation: number): void { + writeDesktopLogHeader( + `backend generation=${generation} readiness failed; scheduling replacement`, + ); + void backendSupervisor?.restartGeneration(generation, "readiness check failed"); +} + +function ensureInitialBackendWindowOpen(baseUrl: string, generation?: number): void { openInitialBackendWindow({ isDevelopment, baseUrl, @@ -583,6 +600,11 @@ function ensureInitialBackendWindowOpen(baseUrl: string): void { warn: (message, error) => { console.warn(message, error); }, + ...(generation === undefined + ? {} + : { + onReadinessFailure: () => restartBackendAfterReadinessFailure(generation), + }), }); } @@ -2819,6 +2841,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v `backend generation=${generation.number} readiness warning message=${formatErrorMessage(error)}`, ); console.warn("[desktop] backend readiness check timed out", error); + restartBackendAfterReadinessFailure(generation.number); if (!mainWindow) { mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created after readiness warning"); @@ -2828,7 +2851,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v } const hadWindow = (mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null) !== null; - ensureInitialBackendWindowOpen(backendHttpUrl); + ensureInitialBackendWindowOpen(backendHttpUrl, generation.number); if (hadWindow && backendInitialWindowOpenInFlight === null) { void waitForBackendWindowReady(backendHttpUrl) .then((source) => { @@ -2837,6 +2860,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v .catch((error) => { if (isBackendReadinessAborted(error)) return; console.warn("[desktop] restarted backend readiness check timed out", error); + restartBackendAfterReadinessFailure(generation.number); }); } } @@ -2890,6 +2914,11 @@ function getBackendSupervisor(): DesktopBackendSupervisor { classifyStartFailure: (error) => error instanceof MissingBackendEntryError ? "fatal" : "retry", onFatalStartFailure: (error) => handleFatalStartupError("backend", error), + onUnrecoverableGeneration: ({ error, generation, reason }) => + handleFatalStartupError( + `backend generation ${generation.number} recovery (${reason})`, + error, + ), gracefulShutdownTimeoutMs: BACKEND_FORCE_KILL_DELAY_MS, forcedExitTimeoutMs: BACKEND_SHUTDOWN_TIMEOUT_MS - BACKEND_FORCE_KILL_DELAY_MS, }); @@ -3631,10 +3660,16 @@ if (hasSingleInstanceLock) { app.on("browser-window-focus", () => { handleDesktopAppForegrounded(); + emitDesktopConnectionWake("window-focus"); + }); + + powerMonitor.on("resume", () => { + emitDesktopConnectionWake("system-resume"); }); app.on("activate", () => { handleDesktopAppForegrounded(); + emitDesktopConnectionWake("app-activate"); if (BrowserWindow.getAllWindows().length === 0) { if (!isDevelopment) { ensureInitialBackendWindowOpen(backendHttpUrl); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0a7600a0c..eb9a5e55d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,7 @@ import { import { SERVER_TRANSCRIBE_VOICE_CHANNEL } from "./voiceTranscription"; import { STORAGE_MIGRATION_IPC_CHANNELS } from "./desktopStorageMigration"; import { APPSNAP_IPC_CHANNELS } from "./appSnapIpc"; +import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const SAVE_FILE_CHANNEL = "desktop:save-file"; @@ -45,6 +46,16 @@ function getDesktopWsUrl(): string | null { contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: getDesktopWsUrl, + onConnectionWake: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, reason: unknown) => { + if (reason !== "app-activate" && reason !== "window-focus" && reason !== "system-resume") { + return; + } + listener(reason); + }; + ipcRenderer.on(DESKTOP_CONNECTION_WAKE_CHANNEL, wrappedListener); + return () => ipcRenderer.removeListener(DESKTOP_CONNECTION_WAKE_CHANNEL, wrappedListener); + }, // Absolute path for OS-dropped File objects (folders with spaces/parens, etc.). getPathForFile: (file: File) => { try { diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 89c33a760..0091c5dad 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -509,6 +509,35 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("captures an exact monotonic output barrier in reconnect snapshots", async () => { + const { manager, ptyAdapter } = makeManager(); + const outputEvents: Array> = []; + manager.on("event", (event) => { + if (event.type === "output") outputEvents.push(event); + }); + const initial = await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + expect(initial.outputSequence).toBe(0); + expect(initial.outputEpoch).not.toBe(""); + process.emitData("before snapshot\n"); + await waitFor(() => outputEvents.length === 1); + + const snapshot = await manager.open(openInput()); + expect(snapshot.history).toContain("before snapshot"); + expect(snapshot.outputSequence).toBe(1); + expect(snapshot.outputEpoch).toBe(initial.outputEpoch); + + process.emitData("after snapshot\n"); + await waitFor(() => outputEvents.length === 2); + expect(outputEvents.map((event) => event.outputSequence)).toEqual([1, 2]); + expect(outputEvents.every((event) => event.outputEpoch === initial.outputEpoch)).toBe(true); + + manager.dispose(); + }); + it("includes live terminal mode replay preamble in reattach snapshots", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput()); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index a6cf16dd5..1a525a565 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -2,6 +2,7 @@ // Purpose: Implements server-side terminal sessions, cleanup orchestration, history persistence, and PTY output flow control. // Layer: Terminal infrastructure // Depends on: PTY adapters, process-tree cleanup helpers, shared terminal contracts, and server config. +import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; @@ -1001,6 +1002,7 @@ interface KillEscalationHandle { } export class TerminalManagerRuntime extends EventEmitter { + private readonly outputEpoch = randomUUID(); private readonly sessions = new Map(); private readonly logsDir: string; private managedWrapperBinDir: string | null; @@ -1124,6 +1126,8 @@ export class TerminalManagerRuntime extends EventEmitter modeReplayTracker: null, pendingOutputChunks: [], pendingOutputLength: 0, + outputSequence: 0, + outputEpoch: this.outputEpoch, outputFlushTimer: null, streamOutput: input.streamOutput ?? true, outputPaused: false, @@ -1333,6 +1337,8 @@ export class TerminalManagerRuntime extends EventEmitter modeReplayTracker: null, pendingOutputChunks: [], pendingOutputLength: 0, + outputSequence: 0, + outputEpoch: this.outputEpoch, outputFlushTimer: null, // Restart has no headless mode of its own; fresh sessions stream normally // and existing sessions (below) keep whatever mode they were opened with. @@ -1685,6 +1691,7 @@ export class TerminalManagerRuntime extends EventEmitter // history above, but skip the live broadcast so unviewed background output // never reaches the WebSocket fanout. if (session.streamOutput) { + session.outputSequence += 1; this.emitEvent({ type: "output", threadId: session.threadId, @@ -1692,6 +1699,8 @@ export class TerminalManagerRuntime extends EventEmitter createdAt: new Date().toISOString(), data, byteLength, + outputEpoch: session.outputEpoch, + outputSequence: session.outputSequence, }); } if (session.outputAckObserved) { @@ -2476,6 +2485,8 @@ export class TerminalManagerRuntime extends EventEmitter status: session.status, pid: session.pid, history: session.history.toString(), + outputEpoch: session.outputEpoch, + outputSequence: session.outputSequence, ...(replayPreamble.length > 0 ? { replayPreamble } : {}), exitCode: session.exitCode, exitSignal: session.exitSignal, diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 4d8615c41..556f6e250 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -63,6 +63,10 @@ export interface TerminalSessionState { pendingOutputChunks: string[]; /** Total UTF-8 byte length of buffered output chunks. */ pendingOutputLength: number; + /** Monotonic barrier for emitted output batches and reconnect snapshots. */ + outputSequence: number; + /** Identifies the server process that owns the output-sequence namespace. */ + outputEpoch: string; /** Timer handle for the next scheduled output flush. */ outputFlushTimer: ReturnType | null; /** diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 75778171e..dcd6430da 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -768,7 +768,7 @@ export const makeWsRpcLayer = () => [WS_METHODS.projectsListDevServers]: () => rpcEffect(devServerManager.list, "Failed to list dev servers"), [WS_METHODS.subscribeProjectDevServerEvents]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( devServerManager.list.pipe( Effect.map( @@ -1259,7 +1259,7 @@ export const makeWsRpcLayer = () => "Failed to update keybinding", ), [WS_METHODS.subscribeServerLifecycle]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( lifecycleEvents.snapshot.pipe( Effect.map((snapshot) => @@ -1284,7 +1284,7 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.subscribeServerConfig]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( loadServerConfig.pipe( Effect.map( @@ -1325,7 +1325,7 @@ export const makeWsRpcLayer = () => ), ).pipe(Stream.mapError((cause) => toWsRpcError(cause, "Server config stream failed"))), [WS_METHODS.subscribeServerProviderStatuses]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( providerHealth.getStatuses.pipe( Effect.flatMap(enrichProviderStatuses), @@ -1346,7 +1346,7 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.subscribeServerSettings]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( serverSettings.getSettings.pipe(Effect.map((settings) => ({ settings }))), ), @@ -1414,7 +1414,7 @@ export const makeWsRpcLayer = () => [WS_METHODS.automationArchiveRun]: (input) => rpcEffect(automationService.archiveRun(input), "Failed to update automation run"), [WS_METHODS.subscribeAutomationEvents]: () => - Stream.merge( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( automationService.list({}).pipe( Effect.map(({ definitions, runs }) => ({ diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e563edf54..10f598ac2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -44,6 +44,7 @@ import { useSplitViewStore } from "../splitViewStore"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -1021,6 +1022,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { const cwd = typeof body.cwd === "string" ? body.cwd : null; const branchName = cwd ? (fixture.gitBranchByCwd[cwd] ?? "main") : "main"; @@ -1082,6 +1086,8 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { status: "running", pid: 123, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: NOW_ISO, @@ -1250,8 +1256,13 @@ const worker = setupWorker( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || method === WS_METHODS.subscribeTerminalEvents || - method === WS_METHODS.subscribeOrchestrationDomainEvents + method === WS_METHODS.subscribeOrchestrationDomainEvents || + method === WS_METHODS.subscribeProjectDevServerEvents || + method === WS_METHODS.subscribeAutomationEvents ) { + // Keep unasserted streaming subscriptions open. Completing them with a + // unary `{}` response is a protocol error and correctly triggers the + // connection supervisor's recovery path. return; } sendEffectRpcExit(client, parsed.request.id, resolveWsRpc(requestBody)); diff --git a/apps/web/src/components/EventRouter.browser.tsx b/apps/web/src/components/EventRouter.browser.tsx index 61b24c771..14af5e33c 100644 --- a/apps/web/src/components/EventRouter.browser.tsx +++ b/apps/web/src/components/EventRouter.browser.tsx @@ -26,6 +26,7 @@ import { getRouter } from "../router"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -54,6 +55,10 @@ interface EffectRpcStreamHandle { acknowledgedChunkCount: number; } +interface ClosableEffectRpcWebSocketClient extends EffectRpcWebSocketClient { + readonly close: (code?: number, reason?: string) => void; +} + let fixture: TestFixture; let serverLifecycleStream: EffectRpcStreamHandle | null = null; let shellStream: EffectRpcStreamHandle | null = null; @@ -68,6 +73,7 @@ const subscribeThreadRequestCountById = new Map(); let subscribeThreadRequests: ThreadId[] = []; let replayEvents: OrchestrationEvent[] = []; let replayRequestCursors: number[] = []; +let activeWsClient: ClosableEffectRpcWebSocketClient | null = null; const mountedAppCleanups = new Set<() => Promise>(); const wsLink = ws.link(/ws(s)?:\/\/.*/); @@ -233,6 +239,9 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -275,6 +284,7 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { + activeWsClient = client; client.addEventListener("message", (event) => { if (typeof event.data !== "string") { return; @@ -495,6 +505,7 @@ describe("EventRouter scoped orchestration sync", () => { document.body.innerHTML = ""; serverLifecycleStream = null; shellStream = null; + activeWsClient = null; threadStreamByThreadId.clear(); delayNextThreadSnapshot = false; localStorage.clear(); @@ -1254,4 +1265,115 @@ describe("EventRouter scoped orchestration sync", () => { await mounted.cleanup(); } }); + + it("reconnects once and rebuilds scoped subscriptions from fresh server snapshots", async () => { + const mounted = await mountApp(); + + try { + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(1); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(1); + expect(activeWsClient).not.toBeNull(); + }, + { timeout: 4_000, interval: 16 }, + ); + + fixture = { + ...fixture, + snapshot: { + ...fixture.snapshot, + snapshotSequence: fixture.snapshot.snapshotSequence + 1, + threads: fixture.snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, title: "Recovered after reconnect" } : thread, + ), + }, + }; + activeWsClient?.close(1012, "test server restart"); + + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(2); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(2); + expect(getThreadFromState(useStore.getState(), THREAD_ID)?.title).toBe( + "Recovered after reconnect", + ); + }, + { timeout: 5_000, interval: 16 }, + ); + } finally { + fixture = buildFixture(); + await mounted.cleanup(); + } + }); + + it("does not let an older reconnect snapshot overwrite already-applied events", async () => { + const mounted = await mountApp(); + const latestMessageId = MessageId.makeUnsafe("msg-before-reconnect-boundary"); + + try { + await sendShellEventPush({ + kind: "thread-upserted", + sequence: 3, + thread: { + ...createShellSnapshotFromReadModel(fixture.snapshot).threads[0]!, + title: "Newest local title", + }, + }); + await sendThreadEventPush({ + sequence: 3, + eventId: EventId.makeUnsafe("event-before-reconnect-boundary"), + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: "2026-03-04T12:00:05.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: THREAD_ID, + messageId: latestMessageId, + role: "assistant", + text: "Already applied before reconnect", + turnId: TurnId.makeUnsafe("turn-before-reconnect-boundary"), + source: "native", + streaming: false, + createdAt: "2026-03-04T12:00:05.000Z", + updatedAt: "2026-03-04T12:00:05.000Z", + }, + }); + await vi.waitFor(() => { + const thread = getThreadFromState(useStore.getState(), THREAD_ID); + expect(thread?.title).toBe("Newest local title"); + expect(thread?.messages.some((message) => message.id === latestMessageId)).toBe(true); + }); + + fixture = { + ...fixture, + snapshot: { + ...fixture.snapshot, + snapshotSequence: 2, + threads: fixture.snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, title: "Older reconnect title" } : thread, + ), + }, + }; + activeWsClient?.close(1012, "test stale reconnect snapshot"); + + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(2); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(2); + const thread = getThreadFromState(useStore.getState(), THREAD_ID); + expect(thread?.title).toBe("Newest local title"); + expect(thread?.messages.some((message) => message.id === latestMessageId)).toBe(true); + }, + { timeout: 5_000, interval: 16 }, + ); + } finally { + fixture = buildFixture(); + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7917f35ef..482b5b602 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -21,6 +21,7 @@ import { getRouter } from "../router"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -166,6 +167,9 @@ function resolveWsRpc(tag: string): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -242,8 +246,13 @@ const worker = setupWorker( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || method === WS_METHODS.subscribeTerminalEvents || - method === WS_METHODS.subscribeOrchestrationDomainEvents + method === WS_METHODS.subscribeOrchestrationDomainEvents || + method === WS_METHODS.subscribeProjectDevServerEvents || + method === WS_METHODS.subscribeAutomationEvents ) { + // Keep unasserted streaming subscriptions open. Completing them with a + // unary `{}` response is a protocol error and correctly triggers the + // connection supervisor's recovery path. return; } sendEffectRpcExit(client, parsed.request.id, resolveWsRpc(method)); diff --git a/apps/web/src/components/terminal/terminalRuntime.ts b/apps/web/src/components/terminal/terminalRuntime.ts index 87de059d0..64757b63f 100644 --- a/apps/web/src/components/terminal/terminalRuntime.ts +++ b/apps/web/src/components/terminal/terminalRuntime.ts @@ -46,10 +46,13 @@ import { writeSystemMessage, } from "./terminalRuntimeAppearance"; import { terminalEventDispatcher } from "./terminalEventDispatcher"; -import type { - TerminalRuntimeConfig, - TerminalRuntimeEntry, - TerminalRuntimeViewState, +import { + acceptTerminalOutputSequence, + acceptTerminalSnapshotBarrier, + type TerminalOutputEvent, + type TerminalRuntimeConfig, + type TerminalRuntimeEntry, + type TerminalRuntimeViewState, } from "./terminalRuntimeTypes"; import { waitForTerminalFontReady } from "./terminalFontSettle"; import { observeTerminalWriteParsed } from "./terminalPerformance"; @@ -682,46 +685,88 @@ async function sendTerminalInput( } } +function beginTerminalSnapshotCapture(entry: TerminalRuntimeEntry): number { + entry.snapshotReconcileActive = true; + entry.snapshotBufferedOutputEvents.length = 0; + return ++entry.snapshotReconcileRequestId; +} + +function deliverTerminalOutputEvent(entry: TerminalRuntimeEntry, event: TerminalOutputEvent): void { + if (!acceptTerminalOutputSequence(entry, event.outputEpoch, event.outputSequence)) { + acknowledgeParsedOutput(entry, event.byteLength ?? terminalByteLength(event.data)); + return; + } + setRuntimeStatus(entry, "ready"); + scheduleWrite(entry, event.data, event.byteLength ?? terminalByteLength(event.data)); +} + +function finishTerminalSnapshotCapture(entry: TerminalRuntimeEntry, requestId: number): void { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) return; + entry.snapshotReconcileActive = false; + const buffered = entry.snapshotBufferedOutputEvents + .splice(0) + .toSorted((left, right) => left.outputSequence - right.outputSequence); + for (const event of buffered) deliverTerminalOutputEvent(entry, event); +} + +function completeTerminalSnapshotCapture( + entry: TerminalRuntimeEntry, + requestId: number, + snapshot: TerminalSessionSnapshot, + onComplete?: () => void, +): void { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) { + return; + } + if (!entry.opened || entry.hasHandledExit) { + finishTerminalSnapshotCapture(entry, requestId); + return; + } + + const finish = () => { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) return; + finishTerminalSnapshotCapture(entry, requestId); + setRuntimeStatus(entry, "ready"); + onComplete?.(); + }; + + // The sequence is the server-side barrier for this exact history. Live + // output is buffered while the snapshot RPC is in flight and only events + // newer than the barrier are appended after the authoritative replay. + if (acceptTerminalSnapshotBarrier(entry, snapshot.outputEpoch, snapshot.outputSequence)) { + if (snapshotHasReplayPayload(snapshot)) { + replaySnapshot(entry, snapshot, finish); + return; + } + } + finish(); +} + function reconcileTerminalSnapshot(entry: TerminalRuntimeEntry): void { - if (entry.disposed || !entry.opened || entry.hasHandledExit) return; + if (entry.disposed || !entry.opened || entry.hasHandledExit || entry.snapshotReconcileActive) { + return; + } const api = readNativeApi(); if (!api) return; - const outputEventVersionAtRequest = entry.outputEventVersion; - const requestId = ++entry.snapshotReconcileRequestId; + if (entry.snapshotReconcileTimer !== null) { + window.clearTimeout(entry.snapshotReconcileTimer); + entry.snapshotReconcileTimer = null; + } + + const requestId = beginTerminalSnapshotCapture(entry); setRuntimeStatus(entry, "connecting"); void api.terminal .open(buildOpenInput(entry)) .then((snapshot) => { - if ( - entry.disposed || - !entry.opened || - entry.hasHandledExit || - entry.snapshotReconcileRequestId !== requestId - ) { - return; - } - - if (entry.outputEventVersion !== outputEventVersionAtRequest) { - return; - } - - if (snapshotHasReplayPayload(snapshot)) { - replaySnapshot(entry, snapshot, () => { - if (!entry.disposed && entry.snapshotReconcileRequestId === requestId) { - setRuntimeStatus(entry, "ready"); - } - }); - return; - } - - setRuntimeStatus(entry, "ready"); + completeTerminalSnapshotCapture(entry, requestId, snapshot); }) .catch((error) => { if (entry.disposed || !entry.opened || entry.snapshotReconcileRequestId !== requestId) { return; } + finishTerminalSnapshotCapture(entry, requestId); setRuntimeStatus(entry, "error"); writeSystemMessage( entry.terminal, @@ -822,8 +867,12 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti pendingWriteLength: 0, pendingWriteBytes: 0, linkMatchCache: new Map(), - outputEventVersion: 0, + lastOutputEpoch: null, + lastOutputSequence: 0, + snapshotReconcileActive: false, + snapshotBufferedOutputEvents: [], snapshotReconcileRequestId: 0, + snapshotReconcileTimer: null, webglLoadFrame: null, themeRefreshFrame: 0, themeObserver: null, @@ -870,7 +919,7 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti reconcileTerminalSnapshot(entry); return; } - if (state === "connecting" || state === "closed") { + if (state === "connecting" || state === "reconnecting") { setRuntimeStatus(entry, "connecting"); } }); @@ -1006,14 +1055,19 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti entry.terminalId, (event) => { if (event.type === "output") { - setRuntimeStatus(entry, "ready"); - entry.outputEventVersion += 1; - scheduleWrite(entry, event.data, event.byteLength ?? terminalByteLength(event.data)); + if (entry.snapshotReconcileActive) { + entry.snapshotBufferedOutputEvents.push(event); + } else { + deliverTerminalOutputEvent(entry, event); + } return; } if (event.type === "started" || event.type === "restarted") { entry.hasHandledExit = false; + if (entry.snapshotReconcileActive) return; + entry.lastOutputEpoch = event.snapshot.outputEpoch; + entry.lastOutputSequence = event.snapshot.outputSequence; const shouldReplaySnapshot = event.type === "restarted" || snapshotHasReplayPayload(event.snapshot); if (shouldReplaySnapshot) { @@ -1091,53 +1145,30 @@ function openTerminal(entry: TerminalRuntimeEntry): void { entry.lastSentResize = null; entry.opened = true; setRuntimeStatus(entry, "connecting"); - const outputEventVersionAtOpen = entry.outputEventVersion; const openInput = buildOpenInput(entry); + const requestId = beginTerminalSnapshotCapture(entry); void api.terminal .open(openInput) .then((snapshot) => { - if (entry.disposed) return; - if ( - snapshotHasReplayPayload(snapshot) && - entry.outputEventVersion === outputEventVersionAtOpen - ) { - replaySnapshot(entry, snapshot, () => setRuntimeStatus(entry, "ready")); - } else if (entry.outputEventVersion === outputEventVersionAtOpen) { - setRuntimeStatus(entry, "ready"); - window.setTimeout(() => { - if ( - entry.disposed || - !entry.opened || - entry.outputEventVersion !== outputEventVersionAtOpen - ) { - return; - } - void api.terminal - .open(openInput) - .then((nextSnapshot) => { - if ( - entry.disposed || - entry.outputEventVersion !== outputEventVersionAtOpen || - !snapshotHasReplayPayload(nextSnapshot) - ) { - return; - } - replaySnapshot(entry, nextSnapshot, () => setRuntimeStatus(entry, "ready")); - }) - .catch(() => { - // Best-effort recovery only; the original open already succeeded. - }); - }, OPEN_SNAPSHOT_RECONCILE_DELAY_MS); - } - if (entry.viewState.autoFocus) { - window.requestAnimationFrame(() => { - entry.terminal.focus(); - }); - } + const shouldReconcileEmptySnapshot = !snapshotHasReplayPayload(snapshot); + completeTerminalSnapshotCapture(entry, requestId, snapshot, () => { + if (shouldReconcileEmptySnapshot && entry.lastOutputSequence === snapshot.outputSequence) { + entry.snapshotReconcileTimer = window.setTimeout(() => { + entry.snapshotReconcileTimer = null; + reconcileTerminalSnapshot(entry); + }, OPEN_SNAPSHOT_RECONCILE_DELAY_MS); + } + if (entry.viewState.autoFocus) { + window.requestAnimationFrame(() => { + entry.terminal.focus(); + }); + } + }); }) .catch((error) => { if (entry.disposed) return; + finishTerminalSnapshotCapture(entry, requestId); entry.opened = false; setRuntimeStatus(entry, "error"); writeSystemMessage(entry.terminal, describeErrorMessage(error, "Failed to open terminal")); @@ -1209,6 +1240,12 @@ export function disposeRuntimeEntry(entry: TerminalRuntimeEntry): void { // Closing a terminal should not synchronously paint queued output into a buffer // that is about to be destroyed; acknowledge and drop it to keep close latency low. clearPendingWrites(entry); + if (entry.snapshotReconcileTimer !== null) { + window.clearTimeout(entry.snapshotReconcileTimer); + entry.snapshotReconcileTimer = null; + } + entry.snapshotReconcileActive = false; + entry.snapshotBufferedOutputEvents.length = 0; entry.unsubscribeTerminalEvents?.(); entry.unsubscribeTerminalEvents = null; entry.querySuppressionDispose?.(); diff --git a/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts b/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts index 4d6183ce2..747c057d1 100644 --- a/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts +++ b/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts @@ -4,10 +4,32 @@ import { describe, expect, it } from "vitest"; -import { buildTerminalRuntimeKey } from "./terminalRuntimeTypes"; +import { + acceptTerminalOutputSequence, + acceptTerminalSnapshotBarrier, + buildTerminalRuntimeKey, +} from "./terminalRuntimeTypes"; describe("buildTerminalRuntimeKey", () => { it("builds a thread-scoped runtime key for terminal persistence", () => { expect(buildTerminalRuntimeKey("thread-123", "terminal-abc")).toBe("thread-123::terminal-abc"); }); }); + +describe("terminal output barriers", () => { + it("resets the live sequence namespace when the server epoch changes", () => { + const barrier = { lastOutputEpoch: "server-old", lastOutputSequence: 42 }; + + expect(acceptTerminalOutputSequence(barrier, "server-new", 1)).toBe(true); + expect(barrier).toEqual({ lastOutputEpoch: "server-new", lastOutputSequence: 1 }); + expect(acceptTerminalOutputSequence(barrier, "server-new", 1)).toBe(false); + }); + + it("rejects older snapshots only within the same server epoch", () => { + const barrier = { lastOutputEpoch: "server-old", lastOutputSequence: 42 }; + + expect(acceptTerminalSnapshotBarrier(barrier, "server-old", 41)).toBe(false); + expect(acceptTerminalSnapshotBarrier(barrier, "server-new", 0)).toBe(true); + expect(barrier).toEqual({ lastOutputEpoch: "server-new", lastOutputSequence: 0 }); + }); +}); diff --git a/apps/web/src/components/terminal/terminalRuntimeTypes.ts b/apps/web/src/components/terminal/terminalRuntimeTypes.ts index 7c60df77d..75645d2b2 100644 --- a/apps/web/src/components/terminal/terminalRuntimeTypes.ts +++ b/apps/web/src/components/terminal/terminalRuntimeTypes.ts @@ -5,6 +5,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebglAddon } from "@xterm/addon-webgl"; +import type { TerminalEvent } from "@synara/contracts"; import { type TerminalActivityState, type TerminalCliKind } from "@synara/shared/terminalThreads"; import { Terminal, type IDisposable } from "@xterm/xterm"; import type { TerminalLinkMatch } from "../../terminal-links"; @@ -48,8 +49,45 @@ export interface TerminalPendingWrite { queuedAt: number; } +export type TerminalOutputEvent = Extract; + export type TerminalRuntimeStatus = "connecting" | "replaying" | "ready" | "error"; +export interface TerminalOutputBarrier { + lastOutputEpoch: string | null; + lastOutputSequence: number; +} + +/** Advances a live-output barrier, resetting its sequence namespace after a server restart. */ +export function acceptTerminalOutputSequence( + barrier: TerminalOutputBarrier, + outputEpoch: string, + outputSequence: number, +): boolean { + if (outputEpoch !== barrier.lastOutputEpoch) { + barrier.lastOutputEpoch = outputEpoch; + barrier.lastOutputSequence = 0; + } + if (outputSequence <= barrier.lastOutputSequence) return false; + barrier.lastOutputSequence = outputSequence; + return true; +} + +/** Applies an authoritative snapshot unless a newer event in the same epoch already arrived. */ +export function acceptTerminalSnapshotBarrier( + barrier: TerminalOutputBarrier, + outputEpoch: string, + outputSequence: number, +): boolean { + if (outputEpoch !== barrier.lastOutputEpoch) { + barrier.lastOutputEpoch = outputEpoch; + barrier.lastOutputSequence = 0; + } + if (outputSequence < barrier.lastOutputSequence) return false; + barrier.lastOutputSequence = outputSequence; + return true; +} + export interface TerminalRuntimeEntry { runtimeKey: string; threadId: string; @@ -83,8 +121,12 @@ export interface TerminalRuntimeEntry { pendingWriteLength: number; pendingWriteBytes: number; linkMatchCache: Map; - outputEventVersion: number; + lastOutputEpoch: string | null; + lastOutputSequence: number; + snapshotReconcileActive: boolean; + snapshotBufferedOutputEvents: TerminalOutputEvent[]; snapshotReconcileRequestId: number; + snapshotReconcileTimer: number | null; webglLoadFrame: number | null; themeRefreshFrame: number; themeObserver: MutationObserver | null; diff --git a/apps/web/src/connectionSupervisor.test.ts b/apps/web/src/connectionSupervisor.test.ts new file mode 100644 index 000000000..29c0a008e --- /dev/null +++ b/apps/web/src/connectionSupervisor.test.ts @@ -0,0 +1,222 @@ +// FILE: connectionSupervisor.test.ts +// Purpose: Locks single-owner connection retry, generation, and wake-probe behavior. +// Layer: Web transport lifecycle tests +// Depends on: ConnectionSupervisor and deterministic timers. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; + +interface TestSession { + readonly id: number; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function makeHarness( + connect: (generation: number) => Promise = async (generation) => ({ + id: generation, + }), +) { + const closed: Array> = []; + const ready: Array> = []; + const retries: Array<{ attempt: number; delayMs: number; reason: string }> = []; + const probe = vi.fn(async () => undefined); + const supervisor = new ConnectionSupervisor({ + connect, + close: (session) => { + closed.push(session); + }, + probe, + random: () => 0.5, + onReady: (session) => ready.push(session), + onRetryScheduled: (retry) => retries.push(retry), + }); + return { closed, probe, ready, retries, supervisor }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ConnectionSupervisor", () => { + it("shares one validated generation across concurrent waiters", async () => { + const first = deferred(); + const connect = vi.fn(() => first.promise); + const harness = makeHarness(connect); + + const left = harness.supervisor.waitForSession(); + const right = harness.supervisor.waitForSession(); + expect(connect).toHaveBeenCalledOnce(); + + first.resolve({ id: 10 }); + + await expect(left).resolves.toEqual({ generation: 1, value: { id: 10 } }); + await expect(right).resolves.toEqual({ generation: 1, value: { id: 10 } }); + expect(harness.ready).toHaveLength(1); + expect(harness.supervisor.snapshot.phase).toBe("ready"); + }); + + it("backs off 1, 2, 4, 8, and 16 seconds with one retry owner", async () => { + const connect = vi.fn(async () => { + throw new Error("offline"); + }); + const harness = makeHarness(connect); + + harness.supervisor.start(); + await vi.advanceTimersByTimeAsync(0); + for (const delay of [1_000, 2_000, 4_000, 8_000]) { + await vi.advanceTimersByTimeAsync(delay); + } + + expect(harness.retries.map(({ delayMs }) => delayMs)).toEqual([ + 1_000, 2_000, 4_000, 8_000, 16_000, + ]); + expect(connect).toHaveBeenCalledTimes(5); + expect(vi.getTimerCount()).toBe(1); + harness.supervisor.dispose(); + }); + + it("settles a caller waiting on an unavailable connection without stopping recovery", async () => { + const neverConnects = deferred(); + const harness = makeHarness(() => neverConnects.promise); + const waiting = harness.supervisor.waitForSession({ timeoutMs: 250 }); + const rejection = expect(waiting).rejects.toThrow("Connection unavailable after 250ms"); + + await vi.advanceTimersByTimeAsync(250); + + await rejection; + expect(harness.supervisor.snapshot.phase).toBe("connecting"); + harness.supervisor.dispose(); + neverConnects.resolve({ id: 1 }); + }); + + it("ignores stale failures after a replacement generation becomes ready", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + + expect(harness.supervisor.invalidate(first.generation, "socket closed")).toBe(true); + await vi.advanceTimersByTimeAsync(1_000); + const second = await harness.supervisor.waitForSession(); + + expect(second.generation).toBe(2); + expect(harness.supervisor.invalidate(first.generation, "late stream exit")).toBe(false); + expect(harness.supervisor.currentSession).toEqual(second); + expect(harness.retries).toHaveLength(1); + }); + + it("waits for the old session to close before opening its replacement", async () => { + const closeFinished = deferred(); + const connect = vi.fn(async (generation: number) => ({ id: generation })); + const supervisor = new ConnectionSupervisor({ + connect, + close: () => closeFinished.promise, + probe: async () => undefined, + random: () => 0.5, + }); + const first = await supervisor.waitForSession(); + + supervisor.invalidate(first.generation, "socket closed"); + await vi.advanceTimersByTimeAsync(1_000); + expect(connect).toHaveBeenCalledOnce(); + + closeFinished.resolve(); + await vi.advanceTimersByTimeAsync(0); + const second = await supervisor.waitForSession(); + expect(second.generation).toBe(2); + expect(connect).toHaveBeenCalledTimes(2); + supervisor.dispose(); + }); + + it("recovers after bounded teardown when an old session never disposes", async () => { + const neverCloses = deferred(); + const onError = vi.fn(); + const connect = vi.fn(async (generation: number) => ({ id: generation })); + const supervisor = new ConnectionSupervisor({ + connect, + close: () => neverCloses.promise, + closeTimeoutMs: 250, + probe: async () => undefined, + random: () => 0.5, + onError, + }); + const first = await supervisor.waitForSession(); + + supervisor.invalidate(first.generation, "socket closed"); + await vi.advanceTimersByTimeAsync(249); + expect(connect).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(751); + const second = await supervisor.waitForSession(); + expect(second.generation).toBe(2); + expect(connect).toHaveBeenCalledTimes(2); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("disposal timed out") }), + "generation 1 invalidation", + ); + + supervisor.dispose(); + neverCloses.resolve(); + }); + + it("probes a ready session and reconnects when the probe fails", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + harness.probe.mockRejectedValueOnce(new Error("stale socket")); + + await harness.supervisor.probe("resume"); + + expect(harness.closed).toEqual([first]); + expect(harness.supervisor.snapshot).toMatchObject({ + phase: "reconnecting", + retryDelayMs: 1_000, + }); + await harness.supervisor.probe("window focus"); + const second = await harness.supervisor.waitForSession(); + expect(second.generation).toBe(2); + }); + + it("does not let an old generation's probe suppress probing its replacement", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + const oldProbe = deferred(); + harness.probe.mockImplementationOnce(() => oldProbe.promise).mockResolvedValueOnce(undefined); + + const firstProbe = harness.supervisor.probe("first focus"); + harness.supervisor.invalidate(first.generation, "stream closed"); + await vi.advanceTimersByTimeAsync(1_000); + const second = await harness.supervisor.waitForSession(); + await harness.supervisor.probe("second focus"); + + expect(second.generation).toBe(2); + expect(harness.probe).toHaveBeenCalledTimes(2); + oldProbe.resolve(undefined); + await firstProbe; + }); + + it("closes a connect result that arrives after disposal", async () => { + const pending = deferred(); + const harness = makeHarness(() => pending.promise); + const waiting = harness.supervisor.waitForSession(); + + harness.supervisor.dispose(); + pending.resolve({ id: 1 }); + + await expect(waiting).rejects.toThrow("disposed"); + await vi.advanceTimersByTimeAsync(0); + expect(harness.closed).toEqual([{ generation: 1, value: { id: 1 } }]); + expect(harness.supervisor.snapshot.phase).toBe("disposed"); + }); +}); diff --git a/apps/web/src/connectionSupervisor.ts b/apps/web/src/connectionSupervisor.ts new file mode 100644 index 000000000..444657fcf --- /dev/null +++ b/apps/web/src/connectionSupervisor.ts @@ -0,0 +1,338 @@ +// FILE: connectionSupervisor.ts +// Purpose: Owns one desired browser-to-server connection across retries and wake probes. +// Layer: Web transport lifecycle +// Exports: ConnectionSupervisor and its observable lifecycle snapshot. + +export type ConnectionSupervisorPhase = "connecting" | "ready" | "reconnecting" | "disposed"; + +export interface ConnectionSupervisorSession { + readonly generation: number; + readonly value: T; +} + +export interface ConnectionSupervisorSnapshot { + readonly phase: ConnectionSupervisorPhase; + readonly generation: number | null; + readonly retryAttempt: number; + readonly retryDelayMs: number | null; +} + +export interface ConnectionSupervisorOptions { + readonly connect: (generation: number) => Promise; + readonly close: (session: ConnectionSupervisorSession) => Promise | void; + readonly probe: (session: ConnectionSupervisorSession) => Promise; + readonly onReady?: (session: ConnectionSupervisorSession) => void; + readonly onInvalidated?: (session: ConnectionSupervisorSession, reason: string) => void; + readonly onSnapshot?: (snapshot: ConnectionSupervisorSnapshot) => void; + readonly onError?: (error: unknown, context: string) => void; + readonly onRetryScheduled?: (input: { + readonly attempt: number; + readonly delayMs: number; + readonly reason: string; + }) => void; + readonly setTimer?: typeof setTimeout; + readonly clearTimer?: typeof clearTimeout; + readonly random?: () => number; + readonly retryBaseDelayMs?: number; + readonly retryMaxDelayMs?: number; + readonly retryJitterRatio?: number; + /** + * Maximum time replacement creation waits for an old session to dispose. + * A timed-out session remains stale by generation and may finish disposing in + * the background, but it cannot indefinitely block recovery. + */ + readonly closeTimeoutMs?: number; +} + +interface SessionWaiter { + readonly resolve: (session: ConnectionSupervisorSession) => void; + readonly reject: (error: Error) => void; +} + +const DEFAULT_RETRY_BASE_DELAY_MS = 1_000; +const DEFAULT_RETRY_MAX_DELAY_MS = 16_000; +const DEFAULT_RETRY_JITTER_RATIO = 0.2; +const DEFAULT_CLOSE_TIMEOUT_MS = 5_000; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Serializes connection creation, invalidation, retry, and wake probing. Callers + * may report the same broken generation more than once; only the current generation + * can change state, so late stream exits cannot replace a healthy session. + */ +export class ConnectionSupervisor { + readonly #options: ConnectionSupervisorOptions; + readonly #setTimer: typeof setTimeout; + readonly #clearTimer: typeof clearTimeout; + readonly #random: () => number; + + #desiredRunning = false; + #disposed = false; + #generation = 0; + #active: ConnectionSupervisorSession | null = null; + #connectInFlight: Promise | null = null; + #closeInFlight: Promise | null = null; + #probeInFlight: { readonly generation: number; readonly promise: Promise } | null = null; + #retryTimer: ReturnType | null = null; + #retryAttempt = 0; + #hasBeenReady = false; + #snapshot: ConnectionSupervisorSnapshot = { + phase: "connecting", + generation: null, + retryAttempt: 0, + retryDelayMs: null, + }; + readonly #waiters = new Set>(); + + constructor(options: ConnectionSupervisorOptions) { + this.#options = options; + this.#setTimer = options.setTimer ?? globalThis.setTimeout.bind(globalThis); + this.#clearTimer = options.clearTimer ?? globalThis.clearTimeout.bind(globalThis); + this.#random = options.random ?? Math.random; + } + + get snapshot(): ConnectionSupervisorSnapshot { + return this.#snapshot; + } + + get currentSession(): ConnectionSupervisorSession | null { + return this.#active; + } + + start(): void { + if (this.#disposed) return; + this.#desiredRunning = true; + if (!this.#active && !this.#connectInFlight && !this.#retryTimer) { + this.#beginConnect(); + } + } + + waitForSession(options?: { + readonly timeoutMs?: number; + }): Promise> { + if (this.#disposed) { + return Promise.reject(new Error("Connection supervisor disposed")); + } + if (this.#active) return Promise.resolve(this.#active); + this.start(); + return new Promise((resolve, reject) => { + let timeout: ReturnType | null = null; + const waiter: SessionWaiter = { + resolve: (session) => { + if (timeout !== null) this.#clearTimer(timeout); + this.#waiters.delete(waiter); + resolve(session); + }, + reject: (error) => { + if (timeout !== null) this.#clearTimer(timeout); + this.#waiters.delete(waiter); + reject(error); + }, + }; + this.#waiters.add(waiter); + const timeoutMs = options?.timeoutMs; + if (timeoutMs !== undefined) { + timeout = this.#setTimer( + () => { + waiter.reject(new Error(`Connection unavailable after ${Math.max(0, timeoutMs)}ms`)); + }, + Math.max(0, timeoutMs), + ); + } + }); + } + + invalidate(generation: number, reason: string): boolean { + const active = this.#active; + if (this.#disposed || !active || active.generation !== generation) return false; + + this.#active = null; + this.#options.onInvalidated?.(active, reason); + this.#close(active, `generation ${generation} invalidation`); + this.#scheduleRetry(reason); + return true; + } + + probe(reason: string): Promise { + if (this.#disposed) return Promise.resolve(); + this.start(); + if (!this.#active) { + this.#retryNow(); + return this.#connectInFlight ?? Promise.resolve(); + } + if (this.#probeInFlight?.generation === this.#active.generation) { + return this.#probeInFlight.promise; + } + + const session = this.#active; + const probe = this.#options + .probe(session) + .catch((error: unknown) => { + if (this.#active?.generation !== session.generation) return; + this.#options.onError?.(error, `generation ${session.generation} wake probe`); + this.invalidate(session.generation, `${reason}: ${errorMessage(error)}`); + }) + .finally(() => { + if (this.#probeInFlight?.promise === probe) this.#probeInFlight = null; + }); + this.#probeInFlight = { generation: session.generation, promise: probe }; + return probe; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#desiredRunning = false; + this.#clearRetryTimer(); + this.#generation += 1; + + const active = this.#active; + this.#active = null; + if (active) { + this.#options.onInvalidated?.(active, "disposed"); + this.#close(active, `generation ${active.generation} disposal`); + } + const error = new Error("Connection supervisor disposed"); + for (const waiter of this.#waiters) waiter.reject(error); + this.#waiters.clear(); + this.#publish({ + phase: "disposed", + generation: null, + retryAttempt: this.#retryAttempt, + retryDelayMs: null, + }); + } + + #beginConnect(): void { + if (this.#disposed || !this.#desiredRunning || this.#active || this.#connectInFlight) { + return; + } + this.#clearRetryTimer(); + const generation = ++this.#generation; + this.#publish({ + phase: this.#hasBeenReady ? "reconnecting" : "connecting", + generation, + retryAttempt: this.#retryAttempt, + retryDelayMs: null, + }); + + const connectResult = this.#closeInFlight + ? this.#closeInFlight.then(() => this.#options.connect(generation)) + : this.#options.connect(generation); + const connecting = connectResult + .then((value) => { + const session = { generation, value } satisfies ConnectionSupervisorSession; + if (this.#disposed || !this.#desiredRunning || generation !== this.#generation) { + this.#close(session, `stale generation ${generation}`); + return; + } + this.#active = session; + this.#hasBeenReady = true; + this.#retryAttempt = 0; + this.#publish({ + phase: "ready", + generation, + retryAttempt: 0, + retryDelayMs: null, + }); + for (const waiter of this.#waiters) waiter.resolve(session); + this.#waiters.clear(); + this.#options.onReady?.(session); + }) + .catch((error: unknown) => { + if (this.#disposed || !this.#desiredRunning || generation !== this.#generation) return; + this.#options.onError?.(error, `generation ${generation} connect`); + this.#scheduleRetry(errorMessage(error)); + }) + .finally(() => { + if (this.#connectInFlight === connecting) { + this.#connectInFlight = null; + if (!this.#disposed && this.#desiredRunning && !this.#active && !this.#retryTimer) { + this.#beginConnect(); + } + } + }); + this.#connectInFlight = connecting; + } + + #scheduleRetry(reason: string): void { + if (this.#disposed || !this.#desiredRunning || this.#retryTimer) return; + const attempt = this.#retryAttempt; + const baseDelay = this.#options.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS; + const maxDelay = this.#options.retryMaxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS; + const jitterRatio = Math.max( + 0, + Math.min(this.#options.retryJitterRatio ?? DEFAULT_RETRY_JITTER_RATIO, 1), + ); + const exponentialDelay = Math.min(baseDelay * 2 ** attempt, maxDelay); + const jitterMultiplier = 1 + (this.#random() * 2 - 1) * jitterRatio; + const delayMs = Math.max(0, Math.round(exponentialDelay * jitterMultiplier)); + this.#retryAttempt += 1; + this.#publish({ + phase: this.#hasBeenReady ? "reconnecting" : "connecting", + generation: null, + retryAttempt: this.#retryAttempt, + retryDelayMs: delayMs, + }); + this.#options.onRetryScheduled?.({ attempt, delayMs, reason }); + this.#retryTimer = this.#setTimer(() => { + this.#retryTimer = null; + this.#beginConnect(); + }, delayMs); + } + + #retryNow(): void { + if (this.#disposed || !this.#desiredRunning || this.#active) return; + if (this.#retryTimer) { + this.#clearRetryTimer(); + } + this.#beginConnect(); + } + + #clearRetryTimer(): void { + if (!this.#retryTimer) return; + this.#clearTimer(this.#retryTimer); + this.#retryTimer = null; + } + + #close(session: ConnectionSupervisorSession, context: string): void { + const previousClose = this.#closeInFlight ?? Promise.resolve(); + const closing = previousClose + .then(() => this.#closeWithTimeout(session)) + .catch((error: unknown) => { + this.#options.onError?.(error, context); + }) + .finally(() => { + if (this.#closeInFlight === closing) this.#closeInFlight = null; + }); + this.#closeInFlight = closing; + } + + async #closeWithTimeout(session: ConnectionSupervisorSession): Promise { + const timeoutMs = Math.max(0, this.#options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS); + let timeout: ReturnType | null = null; + const close = Promise.resolve().then(() => this.#options.close(session)); + const timedOut = new Promise((_, reject) => { + timeout = this.#setTimer(() => { + reject( + new Error( + `Connection generation ${session.generation} disposal timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + }); + try { + await Promise.race([close, timedOut]); + } finally { + if (timeout !== null) this.#clearTimer(timeout); + } + } + + #publish(snapshot: ConnectionSupervisorSnapshot): void { + this.#snapshot = snapshot; + this.#options.onSnapshot?.(snapshot); + } +} diff --git a/apps/web/src/projectTerminalRunner.test.ts b/apps/web/src/projectTerminalRunner.test.ts index 5f11f2cf3..f8084d7fd 100644 --- a/apps/web/src/projectTerminalRunner.test.ts +++ b/apps/web/src/projectTerminalRunner.test.ts @@ -11,6 +11,8 @@ describe("runProjectCommandInTerminal", () => { status: "running", pid: 1234, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index de0abebd2..06a892f36 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1244,6 +1244,9 @@ function EventRouter() { const unsubShellEvent = api.orchestration.onShellEvent((item) => { if (item.kind === "snapshot") { + if (item.snapshot.snapshotSequence < shellSnapshotSequence) { + return; + } shellSnapshotSequence = item.snapshot.snapshotSequence; syncServerShellSnapshot(item.snapshot); reconcilePromotedDraftsFromShellThreads(item.snapshot.threads); diff --git a/apps/web/src/terminalActivity.test.ts b/apps/web/src/terminalActivity.test.ts index eefcf9a3e..a51795959 100644 --- a/apps/web/src/terminalActivity.test.ts +++ b/apps/web/src/terminalActivity.test.ts @@ -10,6 +10,8 @@ const snapshot: TerminalSessionSnapshot = { status: "running", pid: 1234, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: "2026-01-01T00:00:00.000Z", @@ -71,6 +73,8 @@ describe("terminalActivityFromEvent", () => { ...eventBase(), type: "output", data: "hello", + outputEpoch: "epoch-1", + outputSequence: 1, }), ).toBeNull(); expect( diff --git a/apps/web/src/test/effectRpcWebSocketMock.ts b/apps/web/src/test/effectRpcWebSocketMock.ts index 7c9376df5..360a22156 100644 --- a/apps/web/src/test/effectRpcWebSocketMock.ts +++ b/apps/web/src/test/effectRpcWebSocketMock.ts @@ -3,7 +3,12 @@ // Layer: Web test utility // Exports: helpers for request parsing plus Exit/Chunk/Pong responses. -import type { OrchestrationReadModel, OrchestrationShellSnapshot } from "@synara/contracts"; +import { + EnvironmentId, + type ExecutionEnvironmentDescriptor, + type OrchestrationReadModel, + type OrchestrationShellSnapshot, +} from "@synara/contracts"; export interface EffectRpcWebSocketClient { readonly send: (data: string) => void; @@ -157,3 +162,13 @@ export function createShellSnapshotFromReadModel( updatedAt: snapshot.updatedAt, }; } + +export function createTestEnvironmentDescriptor(): ExecutionEnvironmentDescriptor { + return { + environmentId: EnvironmentId.makeUnsafe("test-environment"), + label: "Browser test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }; +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ae5a57105..40ace20f6 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -348,6 +348,8 @@ describe("wsNativeApi", () => { createdAt: "2026-02-24T00:00:00.000Z", type: "output", data: "hello", + outputEpoch: "epoch-1", + outputSequence: 1, } as const; emitPush(WS_CHANNELS.terminalEvent, terminalEvent); diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 01ed74550..aaf89ccf8 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -29,6 +29,7 @@ import { Cause, Data, Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; +import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; import type { WsTransportState } from "./wsTransportEvents"; type PushListener = (message: WsPushMessage) => void; @@ -43,8 +44,16 @@ type RpcClientInstance = type SessionHandle = { readonly client: RpcClientInstance; readonly runtime: ManagedRuntime.ManagedRuntime; + readonly clientScope: Scope.Closeable; }; +type ActiveSession = ConnectionSupervisorSession; + +interface StreamCleanup { + readonly generation: number; + readonly cancel: () => void; +} + class WsTransportRpcError extends Data.TaggedError("WsTransportRpcError")<{ readonly message: string; readonly cause?: unknown; @@ -60,6 +69,8 @@ const makeRpcClient = RpcClient.make(WsRpcGroup); // opts out for known long-running calls (git actions, compaction, provider // updates) whose duration is bounded elsewhere. const REQUEST_TIMEOUT_MS = 60_000; +const SESSION_VALIDATION_TIMEOUT_MS = 15_000; +const WAKE_PROBE_TIMEOUT_MS = 5_000; function resolveRpcUrl(rawUrl: string): string { const url = new URL(rawUrl); @@ -97,6 +108,19 @@ function causeToError(cause: Cause.Cause): Error { return error instanceof Error ? error : new Error(String(error)); } +function connectionErrorSummary(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const socketCloseCode = message.match(/SocketCloseError:\s*(\d{4})/i)?.[1]; + if (socketCloseCode) return `socket closed (${socketCloseCode})`; + if (/timed?\s*out|timeout/i.test(message)) return "connection timed out"; + if (/schema|decode|encode|protocol/i.test(message)) return "protocol validation failed"; + if (/ECONNREFUSED|connection refused|server unavailable/i.test(message)) { + return "server unavailable"; + } + if (/disposed|interrupt/i.test(message)) return "connection closed"; + return "connection operation failed"; +} + function omitNullUserInputAnswers(input: unknown): unknown { if (!input || typeof input !== "object") { return input; @@ -135,24 +159,37 @@ export class WsTransport { private readonly stateListeners = new Set<(state: WsTransportState) => void>(); private readonly latestPushByChannel = new Map(); private sequence = 0; - private sessionVersion = 0; private state: WsTransportState = "connecting"; private disposed = false; - private runtime: ManagedRuntime.ManagedRuntime; - private clientScope: Scope.Closeable; - private clientPromise: Promise; - private reconnectPromise: Promise | null = null; - private reconnectFailures = 0; - private readonly streamCleanups = new Map void>(); + private readonly supervisor: ConnectionSupervisor; + private readonly streamCleanups = new Map(); + private readonly wakeCleanups: Array<() => void> = []; private shellSubscribed = false; private readonly threadSubscriptions = new Map(); constructor(url?: string) { this.explicitUrl = url ?? null; - const session = this.createSession(); - this.runtime = session.runtime; - this.clientScope = session.clientScope; - this.clientPromise = session.clientPromise; + this.supervisor = new ConnectionSupervisor({ + connect: () => this.createValidatedSession(), + close: ({ value }) => this.closeSession(value), + probe: ({ value }) => + this.validateSession(value, WAKE_PROBE_TIMEOUT_MS, WS_METHODS.serverGetEnvironment), + onReady: (session) => this.restoreStreams(session), + onInvalidated: (session) => this.stopGenerationStreams(session.generation), + onSnapshot: (snapshot) => { + if (snapshot.phase === "ready") this.setState("open"); + else if (snapshot.phase === "reconnecting") this.setState("reconnecting"); + else if (snapshot.phase === "connecting") this.setState("connecting"); + else this.setState("disposed"); + }, + onError: (error, context) => { + if (!this.disposed) { + console.warn(`[scient-connection] ${context}: ${connectionErrorSummary(error)}`); + } + }, + }); + this.installWakeRecovery(); + this.supervisor.start(); } async request( @@ -161,14 +198,12 @@ export class WsTransport { options?: { readonly timeoutMs?: number | null }, ): Promise { if (this.disposed) throw new Error("Transport disposed"); - const session = await this.getSession(); - - if (method === WS_METHODS.gitRunStackedAction) { - return (await this.runGitActionStream(session, params)) as T; - } + const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; if (method === ORCHESTRATION_WS_METHODS.subscribeShell) { this.shellSubscribed = true; + const session = await this.getSession(REQUEST_TIMEOUT_MS); + if (!this.shellSubscribed) return undefined as T; this.startShellStream(session); return undefined as T; } @@ -180,6 +215,8 @@ export class WsTransport { if (method === ORCHESTRATION_WS_METHODS.subscribeThread) { const threadId = (params as { threadId: string }).threadId; this.threadSubscriptions.set(threadId, params); + const session = await this.getSession(REQUEST_TIMEOUT_MS); + if (this.threadSubscriptions.get(threadId) !== params) return undefined as T; this.startThreadStream(session, threadId, params as never); return undefined as T; } @@ -190,19 +227,24 @@ export class WsTransport { return undefined as T; } + const session = await this.getSession(REQUEST_TIMEOUT_MS); + + if (method === WS_METHODS.gitRunStackedAction) { + return (await this.runGitActionStream(session, params)) as T; + } + const rpcInput = method === ORCHESTRATION_WS_METHODS.dispatchCommand ? (params as { command: unknown }).command : (params ?? {}); const normalizedRpcInput = omitNullUserInputAnswers(rpcInput); const call = ( - session.client as unknown as Record< + session.value.client as unknown as Record< string, (input: unknown) => Effect.Effect > )[method]; if (!call) throw new WsTransportRpcError({ message: `Unknown RPC method: ${method}` }); - const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; const rpcEffect = timeoutMs === null ? call(normalizedRpcInput) @@ -215,7 +257,7 @@ export class WsTransport { }), ), }); - return (await session.runtime.runPromise(rpcEffect)) as T; + return (await session.value.runtime.runPromise(rpcEffect)) as T; } subscribe( @@ -273,79 +315,76 @@ export class WsTransport { dispose() { if (this.disposed) return; this.disposed = true; - this.setState("disposed"); - for (const cleanup of this.streamCleanups.values()) cleanup(); + for (const cleanup of this.wakeCleanups.splice(0)) cleanup(); + for (const cleanup of this.streamCleanups.values()) cleanup.cancel(); this.streamCleanups.clear(); - // Dispose can race with initial connection or reconnect promises. Mark them - // handled before closing the runtime so test/browser teardown stays quiet. - void this.clientPromise.catch(() => undefined); - void this.reconnectPromise?.catch(() => undefined); - const runtime = this.runtime; - const clientScope = this.clientScope; - void runtime - .runPromise(Scope.close(clientScope, Exit.void)) - .catch(() => undefined) - .finally(() => { - void runtime.dispose().catch(() => undefined); - }); + this.supervisor.dispose(); } - private createSession() { - const sessionVersion = ++this.sessionVersion; + private async createValidatedSession(): Promise { const runtime = ManagedRuntime.make(makeProtocolLayer(makeSocketUrl(this.explicitUrl))); const clientScope = runtime.runSync(Scope.make()); - const clientPromise = runtime - .runPromise(Scope.provide(clientScope)(makeRpcClient)) - .then((client): SessionHandle => { - if (!this.disposed && this.sessionVersion === sessionVersion) { - this.setState("open"); - } - return { client, runtime }; - }) - .catch((error) => { - if (!this.disposed && this.sessionVersion === sessionVersion) { - this.setState("closed"); - } - throw error; - }); - return { runtime, clientScope, clientPromise }; - } - - private async getSession(): Promise { try { - const session = await this.clientPromise; - if (this.disposed) { - throw new Error("Transport disposed"); - } + const client = await runtime.runPromise(Scope.provide(clientScope)(makeRpcClient)); + const session = { client, clientScope, runtime } satisfies SessionHandle; + await this.validateSession( + session, + SESSION_VALIDATION_TIMEOUT_MS, + WS_METHODS.serverGetConfig, + ); return session; - } catch { - if (this.disposed) throw new Error("Transport disposed"); - return this.reconnect(); + } catch (error) { + await this.closeRuntime(runtime, clientScope); + throw error; } } - private reconnect(): Promise { - if (this.reconnectPromise) return this.reconnectPromise; - - const oldRuntime = this.runtime; - const oldClientScope = this.clientScope; - const staleStreamCleanups = [...this.streamCleanups.values()]; - this.streamCleanups.clear(); - for (const cleanup of staleStreamCleanups) cleanup(); + private validateSession( + session: SessionHandle, + timeoutMs: number, + method: string, + ): Promise { + const validate = ( + session.client as unknown as Record< + string, + (input: unknown) => Effect.Effect + > + )[method]; + if (!validate) + return Promise.reject(new Error(`Connection validation RPC unavailable: ${method}`)); + const validation = validate({}).pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + onTimeout: () => + Effect.fail( + new WsTransportRpcError({ + message: `Connection validation timed out after ${timeoutMs}ms: ${method}`, + }), + ), + }), + Effect.asVoid, + ); + return session.runtime.runPromise(validation); + } - this.setState("connecting"); + private closeSession(session: SessionHandle): Promise { + return this.closeRuntime(session.runtime, session.clientScope); + } - void oldRuntime - .runPromise(Scope.close(oldClientScope, Exit.void)) - .catch(() => undefined) - .finally(() => { - void oldRuntime.dispose().catch(() => undefined); - }); + private async closeRuntime( + runtime: ManagedRuntime.ManagedRuntime, + clientScope: Scope.Closeable, + ): Promise { + try { + await runtime.runPromise(Scope.close(clientScope, Exit.void)).catch(() => undefined); + } finally { + await runtime.dispose().catch(() => undefined); + } + } - this.reconnectPromise = this.openReconnectSession().finally(() => { - this.reconnectPromise = null; - }); - return this.reconnectPromise; + private getSession(timeoutMs: number): Promise { + if (this.disposed) return Promise.reject(new Error("Transport disposed")); + return this.supervisor.waitForSession({ timeoutMs }); } private setState(state: WsTransportState): void { @@ -360,31 +399,41 @@ export class WsTransport { } } - private async openReconnectSession(): Promise { - const delayMs = Math.min(500 * 2 ** this.reconnectFailures, 5_000); - this.reconnectFailures += 1; - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); - - const session = this.createSession(); - this.runtime = session.runtime; - this.clientScope = session.clientScope; - this.clientPromise = session.clientPromise; - - const handle = await session.clientPromise; - if (this.disposed) { - throw new Error("Transport disposed"); - } - this.reconnectFailures = 0; + private restoreStreams(session: ActiveSession): void { + if (this.disposed || this.supervisor.currentSession?.generation !== session.generation) return; for (const channel of this.listeners.keys()) { this.startChannelStream(channel as WsPushChannel); } if (this.shellSubscribed) { - this.startShellStream(handle); + this.startShellStream(session); } for (const [threadId, input] of this.threadSubscriptions) { - this.startThreadStream(handle, threadId, input); + this.startThreadStream(session, threadId, input); + } + } + + private installWakeRecovery(): void { + const probe = (reason: string) => { + void this.supervisor.probe(reason); + }; + if (typeof window.addEventListener === "function") { + const handleFocus = () => probe("window focus"); + window.addEventListener("focus", handleFocus); + this.wakeCleanups.push(() => window.removeEventListener("focus", handleFocus)); + } + if (typeof document !== "undefined" && typeof document.addEventListener === "function") { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") probe("document visible"); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + this.wakeCleanups.push(() => + document.removeEventListener("visibilitychange", handleVisibilityChange), + ); + } + const onConnectionWake = window.desktopBridge?.onConnectionWake; + if (onConnectionWake) { + this.wakeCleanups.push(onConnectionWake((reason) => probe(`desktop ${reason}`))); } - return handle; } private emit(channel: C, data: WsPushMessage["data"]): void { @@ -407,9 +456,17 @@ export class WsTransport { } private startChannelStream(channel: WsPushChannel): void { - void this.getSession() + void this.supervisor + .waitForSession() .then((session) => { - const { client } = session; + if ( + this.disposed || + !this.listeners.has(channel) || + this.supervisor.currentSession?.generation !== session.generation + ) { + return; + } + const { client } = session.value; if (isServerLifecyclePushChannel(channel)) { this.startLifecycleStream(session); @@ -477,8 +534,9 @@ export class WsTransport { }) .catch((error) => { if (!this.disposed && this.listeners.has(channel)) { - console.warn("WebSocket RPC channel failed to start", error); - window.setTimeout(() => this.startChannelStream(channel), 500); + console.warn( + `[scient-connection] channel ${channel} failed to start: ${connectionErrorSummary(error)}`, + ); } }); } @@ -501,11 +559,11 @@ export class WsTransport { return shouldKeepServerLifecycleStream(new Set(this.listeners.keys())); } - private startLifecycleStream(session: SessionHandle): void { + private startLifecycleStream(session: ActiveSession): void { this.startStream( session, "server.lifecycle", - session.client[WS_METHODS.subscribeServerLifecycle]({}), + session.value.client[WS_METHODS.subscribeServerLifecycle]({}), (event: ServerLifecycleStreamEvent) => { if (event.type === "welcome") { this.emit(WS_CHANNELS.serverWelcome, event.payload); @@ -516,94 +574,111 @@ export class WsTransport { ); } - private startShellStream(session: SessionHandle): void { + private startShellStream(session: ActiveSession): void { this.startStream( session, "orchestration.shell", - session.client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), + session.value.client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), (event: OrchestrationShellStreamItem) => this.emit(ORCHESTRATION_WS_CHANNELS.shellEvent, event), ); } - private startThreadStream(session: SessionHandle, threadId: string, input: unknown): void { + private startThreadStream(session: ActiveSession, threadId: string, input: unknown): void { + if (this.supervisor.currentSession?.generation !== session.generation) return; const key = `orchestration.thread:${threadId}`; this.stopStream(key); this.startStream( session, key, - session.client[ORCHESTRATION_WS_METHODS.subscribeThread](input as never), + session.value.client[ORCHESTRATION_WS_METHODS.subscribeThread](input as never), (event: OrchestrationThreadStreamItem) => this.emit(ORCHESTRATION_WS_CHANNELS.threadEvent, event), ); } private startStream( - session: SessionHandle, + session: ActiveSession, key: string, stream: unknown, listener: (event: T) => void, ): void { - if (this.streamCleanups.has(key)) return; + if (this.supervisor.currentSession?.generation !== session.generation) return; + const existing = this.streamCleanups.get(key); + if (existing?.generation === session.generation) return; + if (existing) { + this.streamCleanups.delete(key); + existing.cancel(); + } const runnableStream = stream as Stream.Stream; - const cancel = session.runtime.runCallback( - Stream.runForEach(runnableStream, (event) => Effect.sync(() => listener(event))), + const cancel = session.value.runtime.runCallback( + Stream.runForEach(runnableStream, (event) => + Effect.sync(() => { + if (this.supervisor.currentSession?.generation !== session.generation) return; + listener(event); + }), + ), { onExit: (exit) => { // A replacement or intentional stop removes this exact cleanup from // the map before cancellation. Ignore that stale stream's later exit; // otherwise it can reconnect over the replacement and lose events. - if (this.streamCleanups.get(key) !== cancel || this.disposed) { + if (this.streamCleanups.get(key)?.cancel !== cancel || this.disposed) { return; } this.streamCleanups.delete(key); if (Exit.isFailure(exit)) { const interrupted = Cause.hasInterruptsOnly(exit.cause); if (!interrupted) { - console.warn("WebSocket RPC stream failed", causeToError(exit.cause)); + console.warn( + `[scient-connection] stream ${key} failed: ${connectionErrorSummary(causeToError(exit.cause))}`, + ); } - window.setTimeout( - () => { - if (!this.disposed && !this.streamCleanups.has(key)) { - // Reconnecting restores every active channel, shell, and thread - // subscription. Restarting this stream again would replace the - // restored subscription and could drop its first event. - void this.reconnect().catch((error) => { - if (!this.disposed) { - console.warn("WebSocket RPC stream reconnect failed", error); - } - }); - } - }, - interrupted ? 0 : 500, + this.supervisor.invalidate( + session.generation, + interrupted ? `stream ${key} interrupted` : `stream ${key} failed`, ); + } else { + this.supervisor.invalidate(session.generation, `stream ${key} completed`); } }, }, ); - this.streamCleanups.set(key, cancel); + this.streamCleanups.set(key, { generation: session.generation, cancel }); } private stopStream(key: string): void { const cleanup = this.streamCleanups.get(key); if (!cleanup) return; this.streamCleanups.delete(key); - cleanup(); + cleanup.cancel(); + } + + private stopGenerationStreams(generation: number): void { + for (const [key, cleanup] of this.streamCleanups) { + if (cleanup.generation !== generation) continue; + this.streamCleanups.delete(key); + cleanup.cancel(); + } } private async runGitActionStream( - session: SessionHandle, + session: ActiveSession, params: unknown, ): Promise { let result: GitRunStackedActionResult | null = null; - await session.runtime.runPromise( - Stream.runForEach(session.client[WS_METHODS.gitRunStackedAction](params as never), (event) => - Effect.sync(() => { - this.emit(WS_CHANNELS.gitActionProgress, event as GitActionProgressEvent); - if ((event as GitActionProgressEvent).kind === "action_finished") { - result = (event as Extract).result; - } - }), + await session.value.runtime.runPromise( + Stream.runForEach( + session.value.client[WS_METHODS.gitRunStackedAction](params as never), + (event) => + Effect.sync(() => { + if (this.supervisor.currentSession?.generation !== session.generation) return; + this.emit(WS_CHANNELS.gitActionProgress, event as GitActionProgressEvent); + if ((event as GitActionProgressEvent).kind === "action_finished") { + result = (event as Extract) + .result; + } + }), ), ); if (!result) throw new Error("Git action stream completed without a final result."); diff --git a/apps/web/src/wsTransportEvents.ts b/apps/web/src/wsTransportEvents.ts index 34ea297e8..657f355fa 100644 --- a/apps/web/src/wsTransportEvents.ts +++ b/apps/web/src/wsTransportEvents.ts @@ -3,7 +3,7 @@ // Layer: Web transport utility // Exports: event helpers used by wsNativeApi and terminal runtime recovery. -export type WsTransportState = "connecting" | "open" | "closed" | "disposed"; +export type WsTransportState = "connecting" | "reconnecting" | "open" | "disposed"; export const SYNARA_WS_TRANSPORT_STATE_EVENT = "scient:ws-transport-state"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3f18dc657..1016c7145 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -406,6 +406,8 @@ export interface DesktopWindowState { isFullscreen: boolean; } +export type DesktopConnectionWakeReason = "app-activate" | "window-focus" | "system-resume"; + export interface ScientStorageSnapshot { readonly version: 1; readonly exportedAt: string; @@ -414,6 +416,8 @@ export interface ScientStorageSnapshot { export interface DesktopBridge { getWsUrl: () => string | null; + /** Native wake signals that should verify an apparently-open server connection. */ + onConnectionWake?: (listener: (reason: DesktopConnectionWakeReason) => void) => () => void; /** * Absolute filesystem path for a File from drag/drop or file inputs. * Electron only (`webUtils.getPathForFile`). Returns null when unavailable. diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index f3e14b7f9..a01444e6b 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -205,6 +205,8 @@ describe("TerminalSessionSnapshot", () => { status: "running", pid: 1234, history: "hello\n", + outputEpoch: "epoch-1", + outputSequence: 4, replayPreamble: "\u001b[?2004h\u001b[=7;1u", exitCode: null, exitSignal: null, @@ -223,6 +225,8 @@ describe("TerminalEvent", () => { terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "line\n", + outputEpoch: "epoch-1", + outputSequence: 1, }), ).toBe(true); }); @@ -235,6 +239,8 @@ describe("TerminalEvent", () => { terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "line\n", + outputEpoch: "epoch-1", + outputSequence: 2, byteLength: 5, }), ).toBe(true); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 78cbb66cf..9aa62dc01 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -100,6 +100,10 @@ export const TerminalSessionSnapshot = Schema.Struct({ status: TerminalSessionStatus, pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), history: Schema.String, + /** Identifies the server process that owns this sequence namespace. */ + outputEpoch: Schema.String.check(Schema.isNonEmpty()), + /** Monotonic output-event barrier captured with this authoritative history. */ + outputSequence: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), replayPreamble: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), exitCode: Schema.NullOr(Schema.Int), exitSignal: Schema.NullOr(Schema.Int), @@ -123,6 +127,10 @@ const TerminalOutputEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("output"), data: Schema.String, + /** Identifies the server process that owns this sequence namespace. */ + outputEpoch: Schema.String.check(Schema.isNonEmpty()), + /** Monotonic sequence within this server-side terminal session. */ + outputSequence: Schema.Int.check(Schema.isGreaterThan(0)), byteLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), }); From a37c4764f858ce14a76c475d06cb44d258ec8216 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 02:24:57 +0300 Subject: [PATCH 05/34] Define safe RPC recovery policies --- apps/web/src/rpcRecoveryPolicy.test.ts | 176 +++++++++++++++++++++++++ apps/web/src/rpcRecoveryPolicy.ts | 104 +++++++++++++++ apps/web/src/wsTransport.ts | 73 +++++++--- 3 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/rpcRecoveryPolicy.test.ts create mode 100644 apps/web/src/rpcRecoveryPolicy.ts diff --git a/apps/web/src/rpcRecoveryPolicy.test.ts b/apps/web/src/rpcRecoveryPolicy.test.ts new file mode 100644 index 000000000..4a7f61311 --- /dev/null +++ b/apps/web/src/rpcRecoveryPolicy.test.ts @@ -0,0 +1,176 @@ +// FILE: rpcRecoveryPolicy.test.ts +// Purpose: Locks conservative RPC replay boundaries and one-retry behavior. +// Layer: Web transport recovery policy tests + +import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@synara/contracts"; +import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { SocketCloseError, SocketError, SocketOpenError } from "effect/unstable/socket/Socket"; +import { describe, expect, it, vi } from "vitest"; + +import { + isRpcTransportFailure, + rpcRecoveryPolicyFor, + runRpcWithRecovery, +} from "./rpcRecoveryPolicy"; + +describe("rpcRecoveryPolicyFor", () => { + it("allows reviewed read-only methods to replay on a new generation", () => { + expect(rpcRecoveryPolicyFor(WS_METHODS.filesystemBrowse)).toBe("retry-on-new-generation"); + expect(rpcRecoveryPolicyFor(WS_METHODS.gitStatus)).toBe("retry-on-new-generation"); + expect(rpcRecoveryPolicyFor(ORCHESTRATION_WS_METHODS.getSnapshot)).toBe( + "retry-on-new-generation", + ); + }); + + it.each([ + WS_METHODS.projectsAdd, + WS_METHODS.projectsWriteFile, + WS_METHODS.gitPull, + WS_METHODS.gitRunStackedAction, + WS_METHODS.terminalOpen, + WS_METHODS.terminalWrite, + WS_METHODS.serverInstallProvider, + WS_METHODS.serverGenerateThreadRecap, + WS_METHODS.scientProjectInitializationPreview, + WS_METHODS.studioListThreadOutputs, + WS_METHODS.pullRequestsList, + WS_METHODS.pullRequestsDetail, + WS_METHODS.pullRequestsDiff, + WS_METHODS.serverListProviderUsage, + WS_METHODS.providerGetComposerCapabilities, + WS_METHODS.providerListSkills, + WS_METHODS.providerListModels, + WS_METHODS.pullRequestsComment, + WS_METHODS.automationRunNow, + ORCHESTRATION_WS_METHODS.dispatchCommand, + ORCHESTRATION_WS_METHODS.importThread, + ])("never replays a mutating or outcome-ambiguous method: %s", (method) => { + expect(rpcRecoveryPolicyFor(method)).toBe("never-replay"); + }); + + it("defaults unknown future methods to never replay", () => { + expect(rpcRecoveryPolicyFor("future.method")).toBe("never-replay"); + }); +}); + +describe("isRpcTransportFailure", () => { + it("recognizes structured RPC socket failures", () => { + expect( + isRpcTransportFailure(new RpcClientError({ reason: new SocketCloseError({ code: 1006 }) })), + ).toBe(true); + expect( + isRpcTransportFailure( + new RpcClientError({ + reason: new SocketOpenError({ kind: "Timeout", cause: "open timed out" }), + }), + ), + ).toBe(true); + expect( + isRpcTransportFailure(new SocketError({ reason: new SocketCloseError({ code: 1006 }) })), + ).toBe(true); + }); + + it.each([ + new Error("Git command timed out after 60000ms"), + new Error("provider connection is not authenticated"), + new Error("socket validation failed"), + { _tag: "GitCommandError", message: "connection timed out" }, + { _tag: "RpcClientError", reason: { _tag: "HttpClientError" } }, + ])("does not infer transport failure from semantic message text: %o", (error) => { + expect(isRpcTransportFailure(error)).toBe(false); + }); +}); + +describe("runRpcWithRecovery", () => { + it("replays a reviewed read exactly once on a replacement generation", async () => { + const run = vi + .fn<(session: { generation: number }) => Promise>() + .mockRejectedValueOnce(new Error("socket closed")) + .mockResolvedValueOnce("recovered"); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.filesystemBrowse, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover, + }), + ).resolves.toBe("recovered"); + expect(run.mock.calls.map(([session]) => session.generation)).toEqual([1, 2]); + expect(recover).toHaveBeenCalledOnce(); + }); + + it("does not replay a mutation even when recovery could replace the session", async () => { + const failure = new Error("outcome unknown"); + const run = vi.fn(async () => Promise.reject(failure)); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.projectsWriteFile, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover, + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + expect(recover).not.toHaveBeenCalled(); + }); + + it("does not replay a read without a genuinely new generation", async () => { + const failure = new Error("validation failed"); + const run = vi.fn(async () => Promise.reject(failure)); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.gitStatus, + session: { generation: 3 }, + run, + shouldRecover: () => true, + recover: async () => ({ generation: 3 }), + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + }); + + it("never attempts a third execution when the replay fails", async () => { + const firstFailure = new Error("first socket closed"); + const replayFailure = new Error("replacement failed"); + const run = vi + .fn<(session: { generation: number }) => Promise>() + .mockRejectedValueOnce(firstFailure) + .mockRejectedValueOnce(replayFailure); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.serverGetConfig, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover: async () => ({ generation: 2 }), + }), + ).rejects.toBe(replayFailure); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("preserves semantic errors even if another path already replaced the generation", async () => { + const failure = new Error("Git command timed out after 60000ms"); + const run = vi.fn(async () => Promise.reject(failure)); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.filesystemBrowse, + session: { generation: 1 }, + run, + shouldRecover: isRpcTransportFailure, + recover, + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + expect(recover).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/rpcRecoveryPolicy.ts b/apps/web/src/rpcRecoveryPolicy.ts new file mode 100644 index 000000000..4f1971be4 --- /dev/null +++ b/apps/web/src/rpcRecoveryPolicy.ts @@ -0,0 +1,104 @@ +// FILE: rpcRecoveryPolicy.ts +// Purpose: Defines and enforces the narrow set of RPCs that may replay after reconnection. +// Layer: Web transport recovery policy + +import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@synara/contracts"; + +export type RpcRecoveryPolicy = "retry-on-new-generation" | "never-replay"; + +type KnownRpcMethod = + | (typeof WS_METHODS)[keyof typeof WS_METHODS] + | (typeof ORCHESTRATION_WS_METHODS)[keyof typeof ORCHESTRATION_WS_METHODS]; + +// This allowlist is intentionally conservative. A method belongs here only when +// running it twice cannot create, update, delete, launch user-visible work, +// acknowledge, or spend on the user's behalf. New methods inherit +// `never-replay` until reviewed. +const RETRYABLE_READ_METHODS: ReadonlySet = new Set([ + WS_METHODS.projectsList, + WS_METHODS.projectsDiscoverScripts, + WS_METHODS.projectsListDirectories, + WS_METHODS.projectsSearchEntries, + WS_METHODS.projectsSearchLocalEntries, + WS_METHODS.projectsReadFile, + WS_METHODS.projectsListDevServers, + WS_METHODS.filesystemBrowse, + WS_METHODS.gitGithubRepository, + WS_METHODS.gitStatus, + WS_METHODS.gitReadWorkingTreeDiff, + WS_METHODS.gitListBranches, + WS_METHODS.gitStashInfo, + WS_METHODS.serverGetConfig, + WS_METHODS.serverGetEnvironment, + WS_METHODS.serverGetSettings, + WS_METHODS.serverListWorktrees, + WS_METHODS.serverListLocalServers, + WS_METHODS.serverGetProviderUsageSnapshot, + WS_METHODS.statsGetProfileStats, + WS_METHODS.statsGetProfileTokenStats, + WS_METHODS.serverGetDiagnostics, + WS_METHODS.automationList, + ORCHESTRATION_WS_METHODS.getSnapshot, + ORCHESTRATION_WS_METHODS.getShellSnapshot, + ORCHESTRATION_WS_METHODS.getTurnDiff, + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + ORCHESTRATION_WS_METHODS.replayEvents, +]); + +export function rpcRecoveryPolicyFor(method: string): RpcRecoveryPolicy { + return RETRYABLE_READ_METHODS.has(method as KnownRpcMethod) + ? "retry-on-new-generation" + : "never-replay"; +} + +export interface RpcRecoverySession { + readonly generation: number; +} + +const SOCKET_ERROR_REASONS = new Set([ + "SocketReadError", + "SocketWriteError", + "SocketOpenError", + "SocketCloseError", +]); + +function taggedReason(value: unknown): string | null { + if (!value || typeof value !== "object" || !("reason" in value)) return null; + const reason = value.reason; + if (!reason || typeof reason !== "object" || !("_tag" in reason)) return null; + return typeof reason._tag === "string" ? reason._tag : null; +} + +/** + * Recognizes Effect's structured socket failures without guessing from message + * text. In particular, application and Git timeouts are not transport failures. + */ +export function isRpcTransportFailure(error: unknown): boolean { + if (!error || typeof error !== "object" || !("_tag" in error)) return false; + if (error._tag !== "RpcClientError" && error._tag !== "SocketError") return false; + const reason = taggedReason(error); + return reason !== null && SOCKET_ERROR_REASONS.has(reason); +} + +/** + * Runs one RPC attempt and, for reviewed reads only, permits exactly one replay + * when recovery produces a different connection generation. + */ +export async function runRpcWithRecovery(input: { + readonly method: string; + readonly session: TSession; + readonly run: (session: TSession) => Promise; + readonly shouldRecover: (error: unknown) => boolean; + readonly recover: (session: TSession, error: unknown) => Promise; +}): Promise { + try { + return await input.run(input.session); + } catch (error) { + if (rpcRecoveryPolicyFor(input.method) === "never-replay" || !input.shouldRecover(error)) { + throw error; + } + const replacement = await input.recover(input.session, error); + if (!replacement || replacement.generation === input.session.generation) throw error; + return input.run(replacement); + } +} diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index aaf89ccf8..df13cec13 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -30,6 +30,7 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; +import { isRpcTransportFailure, runRpcWithRecovery } from "./rpcRecoveryPolicy"; import type { WsTransportState } from "./wsTransportEvents"; type PushListener = (message: WsPushMessage) => void; @@ -238,26 +239,14 @@ export class WsTransport { ? (params as { command: unknown }).command : (params ?? {}); const normalizedRpcInput = omitNullUserInputAnswers(rpcInput); - const call = ( - session.value.client as unknown as Record< - string, - (input: unknown) => Effect.Effect - > - )[method]; - if (!call) throw new WsTransportRpcError({ message: `Unknown RPC method: ${method}` }); - const rpcEffect = - timeoutMs === null - ? call(normalizedRpcInput) - : Effect.timeoutOrElse(call(normalizedRpcInput), { - duration: timeoutMs, - onTimeout: () => - Effect.fail( - new WsTransportRpcError({ - message: `RPC request timed out after ${timeoutMs}ms: ${method}`, - }), - ), - }); - return (await session.value.runtime.runPromise(rpcEffect)) as T; + return runRpcWithRecovery({ + method, + session, + run: (attemptSession) => + this.runRpcCall(attemptSession, method, normalizedRpcInput, timeoutMs), + shouldRecover: isRpcTransportFailure, + recover: (failedSession) => this.recoverReadSession(failedSession, method), + }); } subscribe( @@ -387,6 +376,50 @@ export class WsTransport { return this.supervisor.waitForSession({ timeoutMs }); } + private runRpcCall( + session: ActiveSession, + method: string, + input: unknown, + timeoutMs: number | null, + ): Promise { + const call = ( + session.value.client as unknown as Record< + string, + (input: unknown) => Effect.Effect + > + )[method]; + if (!call) { + return Promise.reject(new WsTransportRpcError({ message: `Unknown RPC method: ${method}` })); + } + const rpcEffect = + timeoutMs === null + ? call(input) + : Effect.timeoutOrElse(call(input), { + duration: timeoutMs, + onTimeout: () => + Effect.fail( + new WsTransportRpcError({ + message: `RPC request timed out after ${timeoutMs}ms: ${method}`, + }), + ), + }); + return session.value.runtime.runPromise(rpcEffect) as Promise; + } + + private async recoverReadSession( + failedSession: ActiveSession, + method: string, + ): Promise { + if (this.disposed) return null; + if (this.supervisor.currentSession?.generation === failedSession.generation) { + await this.supervisor.probe(`RPC ${method} failed`); + } + if (this.disposed || this.supervisor.currentSession?.generation === failedSession.generation) { + return null; + } + return this.getSession(REQUEST_TIMEOUT_MS); + } + private setState(state: WsTransportState): void { if (this.state === state) return; this.state = state; From b4b25583ef0b5a1a88582cfc3dc1c87b8a3c9e20 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 02:59:06 +0300 Subject: [PATCH 06/34] Surface connection recovery diagnostics --- apps/desktop/src/desktopDiagnostics.test.ts | 21 +++ apps/desktop/src/desktopDiagnostics.ts | 17 +++ apps/desktop/src/main.ts | 6 + apps/desktop/src/preload.ts | 4 + ...onnectionRecoveryNotifications.browser.tsx | 94 ++++++++++++ .../ConnectionRecoveryNotifications.tsx | 125 ++++++++++++++++ apps/web/src/components/ui/toast.tsx | 22 ++- apps/web/src/connectionRecoveryNotice.test.ts | 122 +++++++++++++++ apps/web/src/connectionRecoveryNotice.ts | 139 ++++++++++++++++++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/wsNativeApi.test.ts | 16 +- apps/web/src/wsNativeApi.ts | 2 +- apps/web/src/wsTransportEvents.test.ts | 78 ++++++++++ apps/web/src/wsTransportEvents.ts | 9 ++ packages/contracts/src/ipc.ts | 4 + 15 files changed, 653 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/desktopDiagnostics.test.ts create mode 100644 apps/desktop/src/desktopDiagnostics.ts create mode 100644 apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx create mode 100644 apps/web/src/components/ConnectionRecoveryNotifications.tsx create mode 100644 apps/web/src/connectionRecoveryNotice.test.ts create mode 100644 apps/web/src/connectionRecoveryNotice.ts create mode 100644 apps/web/src/wsTransportEvents.test.ts diff --git a/apps/desktop/src/desktopDiagnostics.test.ts b/apps/desktop/src/desktopDiagnostics.test.ts new file mode 100644 index 000000000..3939b3b1b --- /dev/null +++ b/apps/desktop/src/desktopDiagnostics.test.ts @@ -0,0 +1,21 @@ +// FILE: desktopDiagnostics.test.ts +// Purpose: Verifies native log-folder opening and actionable OS errors. +// Layer: Desktop diagnostics tests + +import { describe, expect, it, vi } from "vitest"; + +import { openDesktopLogsDirectory } from "./desktopDiagnostics"; + +describe("openDesktopLogsDirectory", () => { + it("opens the exact Scient logs directory", async () => { + const openPath = vi.fn(async () => ""); + await expect(openDesktopLogsDirectory("/tmp/scient/logs", openPath)).resolves.toBeUndefined(); + expect(openPath).toHaveBeenCalledWith("/tmp/scient/logs"); + }); + + it("surfaces the native shell error", async () => { + await expect( + openDesktopLogsDirectory("/tmp/scient/logs", async () => "No application is registered"), + ).rejects.toThrow("No application is registered"); + }); +}); diff --git a/apps/desktop/src/desktopDiagnostics.ts b/apps/desktop/src/desktopDiagnostics.ts new file mode 100644 index 000000000..f3f89cfe9 --- /dev/null +++ b/apps/desktop/src/desktopDiagnostics.ts @@ -0,0 +1,17 @@ +// FILE: desktopDiagnostics.ts +// Purpose: Exposes narrow native diagnostics actions that remain available when the backend is down. +// Layer: Desktop main-process support + +export const DESKTOP_DIAGNOSTICS_IPC_CHANNELS = { + openLogsDirectory: "desktop:diagnostics-open-logs-directory", +} as const; + +export async function openDesktopLogsDirectory( + logsDirectory: string, + openPath: (path: string) => Promise, +): Promise { + const errorMessage = await openPath(logsDirectory); + if (errorMessage.trim().length > 0) { + throw new Error(errorMessage); + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b9eb36e0f..05edb5c21 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -79,6 +79,7 @@ import { import { waitForBackendStartupReady } from "./backendStartupReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; +import { DESKTOP_DIAGNOSTICS_IPC_CHANNELS, openDesktopLogsDirectory } from "./desktopDiagnostics"; import { LSREGISTER_PATH, parseLastLaunchVersion, @@ -3225,6 +3226,11 @@ function registerIpcHandlers(): void { shell.showItemInFolder(resolvedPath); }); + ipcMain.removeHandler(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory); + ipcMain.handle(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory, async () => { + await openDesktopLogsDirectory(LOG_DIR, (path) => shell.openPath(path)); + }); + ipcMain.removeHandler(WINDOW_MINIMIZE_CHANNEL); ipcMain.handle(WINDOW_MINIMIZE_CHANNEL, async (event) => { const window = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index eb9a5e55d..dab31a3c9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,6 +10,7 @@ import { SERVER_TRANSCRIBE_VOICE_CHANNEL } from "./voiceTranscription"; import { STORAGE_MIGRATION_IPC_CHANNELS } from "./desktopStorageMigration"; import { APPSNAP_IPC_CHANNELS } from "./appSnapIpc"; import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; +import { DESKTOP_DIAGNOSTICS_IPC_CHANNELS } from "./desktopDiagnostics"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const SAVE_FILE_CHANNEL = "desktop:save-file"; @@ -75,6 +76,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { shell: { showInFolder: (path: string) => ipcRenderer.invoke(SHOW_IN_FOLDER_CHANNEL, path), }, + diagnostics: { + openLogsDirectory: () => ipcRenderer.invoke(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory), + }, clipboard: { writeImagePngDataUrl: (dataUrl: string) => ipcRenderer.invoke(CLIPBOARD_WRITE_IMAGE_CHANNEL, dataUrl), diff --git a/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx b/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx new file mode 100644 index 000000000..6ff79e1b9 --- /dev/null +++ b/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx @@ -0,0 +1,94 @@ +// FILE: ConnectionRecoveryNotifications.browser.tsx +// Purpose: Browser regressions for recovery-toast dismissal, visible timing, and copy errors. +// Layer: Browser UI test + +import "../index.css"; + +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRouter, +} from "@tanstack/react-router"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { ToastProvider, toastManager } from "./ui/toast"; + +function ToastHarness() { + return ; +} + +async function mountToastHarness() { + const host = document.createElement("div"); + document.body.append(host); + const rootRoute = createRootRoute({ component: ToastHarness }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: rootRoute, + }); + const screen = await render(, { container: host }); + + return async () => { + toastManager.close(); + await screen.unmount(); + host.remove(); + }; +} + +describe("connection recovery toast integration", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("runs the manager close callback used by swipe and Escape dismissal", async () => { + const cleanup = await mountToastHarness(); + const onClose = vi.fn(); + const toastId = toastManager.add({ onClose, title: "Reconnecting", timeout: 0 }); + + toastManager.close(toastId); + + expect(onClose).toHaveBeenCalledOnce(); + await cleanup(); + }); + + it("counts auto-dismiss time only while the window is visible and focused", async () => { + const cleanup = await mountToastHarness(); + const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(false); + toastManager.add({ + title: "Reconnected", + data: { allowCrossThreadVisibility: true, dismissAfterVisibleMs: 100 }, + timeout: 0, + }); + + await new Promise((resolve) => window.setTimeout(resolve, 200)); + expect(document.body.textContent).toContain("Reconnected"); + + hasFocus.mockReturnValue(true); + window.dispatchEvent(new FocusEvent("focus")); + await expect.poll(() => document.body.textContent).not.toContain("Reconnected"); + await cleanup(); + }); + + it("shows a visible error when diagnostic copy fails", async () => { + const cleanup = await mountToastHarness(); + vi.spyOn(navigator.clipboard, "writeText").mockRejectedValue(new Error("Clipboard denied")); + vi.spyOn(document, "execCommand").mockReturnValue(false); + toastManager.add({ + title: "Scient is still reconnecting", + data: { + allowCrossThreadVisibility: true, + copyLabel: "diagnostics", + copyText: "Scient connection diagnostics", + }, + timeout: 0, + }); + + await page.getByRole("button", { name: "Copy diagnostics" }).click(); + + await expect.poll(() => document.body.textContent).toContain("Could not copy diagnostics"); + await cleanup(); + }); +}); diff --git a/apps/web/src/components/ConnectionRecoveryNotifications.tsx b/apps/web/src/components/ConnectionRecoveryNotifications.tsx new file mode 100644 index 000000000..d2d16b9d7 --- /dev/null +++ b/apps/web/src/components/ConnectionRecoveryNotifications.tsx @@ -0,0 +1,125 @@ +// FILE: ConnectionRecoveryNotifications.tsx +// Purpose: Surfaces calm, actionable local-service connection recovery status. +// Layer: Global web application notifications + +import { useEffect, useRef } from "react"; + +import { APP_VERSION } from "../branding"; +import { + ConnectionRecoveryNoticeController, + formatConnectionRecoveryDiagnostics, +} from "../connectionRecoveryNotice"; +import { addWsTransportStateListener } from "../wsTransportEvents"; +import { toastManager } from "./ui/toast"; + +type RecoveryToastId = ReturnType; + +export function ConnectionRecoveryNotifications() { + const toastIdRef = useRef(null); + + useEffect(() => { + const clearToast = () => { + if (toastIdRef.current !== null) toastManager.close(toastIdRef.current); + toastIdRef.current = null; + }; + + let controller: ConnectionRecoveryNoticeController; + controller = new ConnectionRecoveryNoticeController({ + onClear: clearToast, + onRecovered: () => { + const toastId = toastIdRef.current; + if (toastId === null) return; + toastManager.update(toastId, { + type: "success", + title: "Reconnected", + description: "Scient is connected to its local service again.", + actionProps: undefined, + onClose: undefined, + data: { + allowCrossThreadVisibility: true, + dismissAfterVisibleMs: 3_000, + }, + timeout: 0, + }); + }, + onShow: () => { + toastIdRef.current = toastManager.add({ + type: "loading", + title: "Reconnecting…", + description: + "Scient is restoring its local connection. Open chats remain on this computer.", + onClose: () => { + controller.dismissCurrentOutage(); + toastIdRef.current = null; + }, + data: { allowCrossThreadVisibility: true }, + timeout: 0, + }); + }, + onShowDetails: (stateStartedAt) => { + const diagnostics = formatConnectionRecoveryDiagnostics({ + appVersion: APP_VERSION, + desktopApp: Boolean(window.desktopBridge), + generatedAt: new Date(), + navigatorOnline: typeof navigator.onLine === "boolean" ? navigator.onLine : null, + platform: navigator.platform, + state: "reconnecting", + stateStartedAt, + visibility: document.visibilityState, + }); + const openLogs = window.desktopBridge?.diagnostics?.openLogsDirectory; + const nextToast = { + type: "warning" as const, + title: "Scient is still reconnecting", + description: + "Scient keeps trying automatically. Copy the connection summary or open the logs for details.", + actionProps: undefined, + onClose: () => { + controller.dismissCurrentOutage(); + toastIdRef.current = null; + }, + data: { + allowCrossThreadVisibility: true, + copyLabel: "diagnostics", + copyText: diagnostics, + ...(openLogs + ? { + secondaryActionProps: { + children: "Open logs", + onClick: () => { + void openLogs().catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not open logs", + description: + error instanceof Error + ? error.message + : "The logs folder could not be opened.", + }); + }); + }, + }, + } + : {}), + }, + timeout: 0, + }; + if (toastIdRef.current === null) { + toastIdRef.current = toastManager.add(nextToast); + } else { + toastManager.update(toastIdRef.current, nextToast); + } + }, + }); + + const unsubscribe = addWsTransportStateListener((state) => controller.handleState(state), { + replayLatest: true, + }); + return () => { + unsubscribe(); + controller.dispose(); + }; + }, []); + + return null; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 8ae0e9c73..16b5a6486 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -34,6 +34,7 @@ import { type ThreadToastData = { allowCrossThreadVisibility?: boolean; + copyLabel?: string; copyText?: string; onClose?: () => void; secondaryActionProps?: React.ComponentProps; @@ -209,32 +210,44 @@ function ThreadToastVisibleAutoDismiss({ function ToastActions({ actionProps, + copyLabel, copyText, secondaryActionProps, }: { actionProps: ToastObject["actionProps"]; + copyLabel: string | undefined; copyText: string | undefined; secondaryActionProps: ThreadToastData["secondaryActionProps"]; }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onError: (error) => { + toastManager.add({ + type: "error", + title: copyLabel ? `Could not copy ${copyLabel}` : "Could not copy text", + description: error.message, + }); + }, + }); if (!actionProps && !copyText && !secondaryActionProps) return null; + const copyActionLabel = copyLabel ? `Copy ${copyLabel}` : "Copy error message"; + return (
{copyText && ( )} {actionProps && ( @@ -436,6 +449,7 @@ function ToastSurface({ {!compact ? ( diff --git a/apps/web/src/connectionRecoveryNotice.test.ts b/apps/web/src/connectionRecoveryNotice.test.ts new file mode 100644 index 000000000..1b7e5c0e0 --- /dev/null +++ b/apps/web/src/connectionRecoveryNotice.test.ts @@ -0,0 +1,122 @@ +// FILE: connectionRecoveryNotice.test.ts +// Purpose: Verifies recovery-notice timing and privacy-safe diagnostic copy. +// Layer: Web connection recovery presentation tests + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CONNECTION_DETAILS_DELAY_MS, + CONNECTION_NOTICE_DELAY_MS, + ConnectionRecoveryNoticeController, + formatConnectionRecoveryDiagnostics, +} from "./connectionRecoveryNotice"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("connection recovery notice", () => { + it("waits before notifying and reserves detailed help for sustained outages", () => { + expect(CONNECTION_NOTICE_DELAY_MS).toBeGreaterThanOrEqual(1_000); + expect(CONNECTION_DETAILS_DELAY_MS).toBeGreaterThan(CONNECTION_NOTICE_DELAY_MS); + }); + + it("stays silent for initial connection and brief reconnects", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("connecting"); + controller.handleState("open"); + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS - 1); + controller.handleState("open"); + + expect(callbacks.onShow).not.toHaveBeenCalled(); + expect(callbacks.onShowDetails).not.toHaveBeenCalled(); + expect(callbacks.onRecovered).not.toHaveBeenCalled(); + }); + + it("uses one notice through delayed details and recovery", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS); + expect(callbacks.onShow).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS - CONNECTION_NOTICE_DELAY_MS); + expect(callbacks.onShowDetails).toHaveBeenCalledOnce(); + + controller.handleState("open"); + expect(callbacks.onRecovered).toHaveBeenCalledOnce(); + }); + + it("cancels stale timers and respects manual dismissal across repeated cycles", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS); + controller.dismissCurrentOutage(); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS); + controller.handleState("open"); + expect(callbacks.onShowDetails).not.toHaveBeenCalled(); + expect(callbacks.onRecovered).not.toHaveBeenCalled(); + + controller.handleState("reconnecting"); + controller.handleState("open"); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS); + expect(callbacks.onShow).toHaveBeenCalledOnce(); + }); + + it("formats bounded local diagnostics without project, URL, command, or content fields", () => { + const diagnostics = formatConnectionRecoveryDiagnostics({ + appVersion: "0.5.7", + desktopApp: true, + generatedAt: new Date("2026-07-21T00:00:12.000Z"), + navigatorOnline: true, + platform: "Linux x86_64", + state: "reconnecting", + stateStartedAt: new Date("2026-07-21T00:00:00.000Z"), + visibility: "visible", + }); + + expect(diagnostics).toContain("Transport state: reconnecting"); + expect(diagnostics).toContain("Elapsed: 12s"); + expect(diagnostics).toContain("Platform: Linux x86_64"); + expect(diagnostics).not.toMatch(/project|conversation|command line|websocket url|token/i); + }); + + it("never reports a negative elapsed duration when clocks move backwards", () => { + const diagnostics = formatConnectionRecoveryDiagnostics({ + appVersion: "0.5.7", + desktopApp: false, + generatedAt: new Date("2026-07-21T00:00:00.000Z"), + navigatorOnline: null, + platform: "", + state: "connecting", + stateStartedAt: new Date("2026-07-21T00:00:03.000Z"), + visibility: "", + }); + + expect(diagnostics).toContain("Elapsed: 0s"); + expect(diagnostics).toContain("Browser online: unknown"); + }); +}); diff --git a/apps/web/src/connectionRecoveryNotice.ts b/apps/web/src/connectionRecoveryNotice.ts new file mode 100644 index 000000000..3d1da28e9 --- /dev/null +++ b/apps/web/src/connectionRecoveryNotice.ts @@ -0,0 +1,139 @@ +// FILE: connectionRecoveryNotice.ts +// Purpose: Owns privacy-safe copy and timing policy for local-service recovery notices. +// Layer: Web connection recovery presentation logic + +import type { WsTransportState } from "./wsTransportEvents"; + +export const CONNECTION_NOTICE_DELAY_MS = 1_500; +export const CONNECTION_DETAILS_DELAY_MS = 10_000; + +export interface ConnectionRecoveryNoticeCallbacks { + readonly onClear: () => void; + readonly onRecovered: () => void; + readonly onShow: (stateStartedAt: Date) => void; + readonly onShowDetails: (stateStartedAt: Date) => void; +} + +export interface ConnectionRecoveryNoticeClock { + readonly clearTimeout: (timer: ReturnType) => void; + readonly now: () => Date; + readonly setTimeout: (callback: () => void, delayMs: number) => ReturnType; +} + +const systemClock: ConnectionRecoveryNoticeClock = { + clearTimeout: (timer) => globalThis.clearTimeout(timer), + now: () => new Date(), + setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs), +}; + +/** + * Owns one post-ready reconnect notice. Initial connection remains on the + * existing startup surface, and every transition cancels stale timers. + */ +export class ConnectionRecoveryNoticeController { + readonly #callbacks: ConnectionRecoveryNoticeCallbacks; + readonly #clock: ConnectionRecoveryNoticeClock; + #detailsTimer: ReturnType | null = null; + #dismissed = false; + #noticeTimer: ReturnType | null = null; + #reconnecting = false; + #visible = false; + + constructor( + callbacks: ConnectionRecoveryNoticeCallbacks, + clock: ConnectionRecoveryNoticeClock = systemClock, + ) { + this.#callbacks = callbacks; + this.#clock = clock; + } + + handleState(state: WsTransportState): void { + if (state === "reconnecting") { + if (this.#reconnecting) return; + this.#reset(); + this.#callbacks.onClear(); + this.#reconnecting = true; + const stateStartedAt = this.#clock.now(); + this.#noticeTimer = this.#clock.setTimeout(() => { + this.#noticeTimer = null; + if (!this.#reconnecting || this.#dismissed) return; + this.#visible = true; + this.#callbacks.onShow(stateStartedAt); + }, CONNECTION_NOTICE_DELAY_MS); + this.#detailsTimer = this.#clock.setTimeout(() => { + this.#detailsTimer = null; + if (!this.#reconnecting || this.#dismissed || !this.#visible) return; + this.#callbacks.onShowDetails(stateStartedAt); + }, CONNECTION_DETAILS_DELAY_MS); + return; + } + + const shouldAnnounceRecovery = state === "open" && this.#visible && !this.#dismissed; + this.#reset(); + if (shouldAnnounceRecovery) this.#callbacks.onRecovered(); + else this.#callbacks.onClear(); + } + + dismissCurrentOutage(): void { + if (!this.#reconnecting) return; + this.#dismissed = true; + this.#visible = false; + this.#cancelTimer("details"); + } + + dispose(): void { + this.#reset(); + this.#callbacks.onClear(); + } + + #cancelTimer(kind: "details" | "notice"): void { + const timer = kind === "details" ? this.#detailsTimer : this.#noticeTimer; + if (timer !== null) this.#clock.clearTimeout(timer); + if (kind === "details") this.#detailsTimer = null; + else this.#noticeTimer = null; + } + + #reset(): void { + this.#cancelTimer("notice"); + this.#cancelTimer("details"); + this.#dismissed = false; + this.#reconnecting = false; + this.#visible = false; + } +} + +export interface ConnectionRecoveryDiagnosticsInput { + readonly appVersion: string; + readonly desktopApp: boolean; + readonly generatedAt: Date; + readonly navigatorOnline: boolean | null; + readonly platform: string; + readonly state: WsTransportState; + readonly stateStartedAt: Date; + readonly visibility: string; +} + +/** + * Produces a bounded local summary that intentionally excludes URLs, paths, + * project names, conversation content, process command lines, and credentials. + */ +export function formatConnectionRecoveryDiagnostics( + input: ConnectionRecoveryDiagnosticsInput, +): string { + const elapsedSeconds = Math.max( + 0, + Math.round((input.generatedAt.getTime() - input.stateStartedAt.getTime()) / 1_000), + ); + return [ + "Scient connection diagnostics", + `Generated: ${input.generatedAt.toISOString()}`, + `App version: ${input.appVersion}`, + `Transport state: ${input.state}`, + `State started: ${input.stateStartedAt.toISOString()}`, + `Elapsed: ${elapsedSeconds}s`, + `Platform: ${input.platform || "unknown"}`, + `Desktop app: ${input.desktopApp ? "yes" : "no"}`, + `Browser online: ${input.navigatorOnline === null ? "unknown" : input.navigatorOnline ? "yes" : "no"}`, + `Window visibility: ${input.visibility || "unknown"}`, + ].join("\n"); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 06a892f36..c91d643ce 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,7 @@ import { AppSnapCoordinator } from "../components/AppSnapCoordinator"; import { AppSnapWelcomeDialog } from "../components/AppSnapWelcomeDialog"; import { FeedbackDialog } from "../components/FeedbackDialog"; import { ProviderConnectionDialog } from "../components/ProviderConnectionDialog"; +import { ConnectionRecoveryNotifications } from "../components/ConnectionRecoveryNotifications"; import { SETTINGS_TARGETS } from "../settingsNavigation"; import ShortcutsDialog from "../components/ShortcutsDialog"; import WhatsNewDialog from "../components/WhatsNewDialog"; @@ -202,6 +203,7 @@ function RootRouteView() { + diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 40ace20f6..219ccc829 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -26,6 +26,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const requestMock = vi.fn<(...args: Array) => Promise>(); +const onStateChangeMock = vi.fn(() => () => undefined); const showContextMenuFallbackMock = vi.fn< ( @@ -62,9 +63,7 @@ vi.mock("./wsTransport", () => { WsTransport: class MockWsTransport { request = requestMock; subscribe = subscribeMock; - onStateChange() { - return () => undefined; - } + onStateChange = onStateChangeMock; getLatestPush(channel: string) { return latestPushByChannel.get(channel) ?? null; } @@ -116,6 +115,7 @@ const defaultProviders: ReadonlyArray = [ beforeEach(() => { vi.resetModules(); requestMock.mockReset(); + onStateChangeMock.mockClear(); showContextMenuFallbackMock.mockReset(); subscribeMock.mockClear(); channelListeners.clear(); @@ -130,6 +130,16 @@ afterEach(() => { }); describe("wsNativeApi", () => { + it("seeds renderer transport state from the new transport immediately", async () => { + const { createWsNativeApi } = await import("./wsNativeApi"); + + createWsNativeApi(); + + expect(onStateChangeMock).toHaveBeenCalledWith(expect.any(Function), { + replayCurrent: true, + }); + }); + it("delivers and caches valid server.welcome payloads", async () => { const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 69583a6ad..2c25ee8c7 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -322,7 +322,7 @@ export function createWsNativeApi(): NativeApi { } const transport = new WsTransport(); - transport.onStateChange((state) => emitWsTransportState(state)); + transport.onStateChange((state) => emitWsTransportState(state), { replayCurrent: true }); transport.subscribe(WS_CHANNELS.serverWelcome, (message) => { const payload = message.data; diff --git a/apps/web/src/wsTransportEvents.test.ts b/apps/web/src/wsTransportEvents.test.ts new file mode 100644 index 000000000..3edc096dc --- /dev/null +++ b/apps/web/src/wsTransportEvents.test.ts @@ -0,0 +1,78 @@ +// FILE: wsTransportEvents.test.ts +// Purpose: Verifies transport-state retention and late-subscriber replay. +// Layer: Web transport event tests + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + addWsTransportStateListener, + emitWsTransportState, + getLatestWsTransportState, +} from "./wsTransportEvents"; + +beforeEach(() => { + emitWsTransportState("connecting"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("wsTransportEvents", () => { + it("retains the latest state even when no browser event target exists", () => { + emitWsTransportState("reconnecting"); + expect(getLatestWsTransportState()).toBe("reconnecting"); + }); + + it("replays the latest state to a late subscriber when requested", () => { + emitWsTransportState("open"); + const listener = vi.fn(); + + const unsubscribe = addWsTransportStateListener(listener, { replayLatest: true }); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith("open"); + expect(() => unsubscribe()).not.toThrow(); + }); + + it("does not replay unless the caller opts in", () => { + const listener = vi.fn(); + addWsTransportStateListener(listener); + expect(listener).not.toHaveBeenCalled(); + }); + + it("delivers browser events until unsubscribe and ignores events without detail", () => { + const eventListeners = new Set<(event: Event) => void>(); + class TestCustomEvent { + readonly detail: T; + readonly type: string; + + constructor(type: string, init: { detail: T }) { + this.type = type; + this.detail = init.detail; + } + } + vi.stubGlobal("CustomEvent", TestCustomEvent); + vi.stubGlobal("window", { + addEventListener: (_type: string, listener: (event: Event) => void) => + eventListeners.add(listener), + dispatchEvent: (event: Event) => { + for (const listener of eventListeners) listener(event); + return true; + }, + removeEventListener: (_type: string, listener: (event: Event) => void) => + eventListeners.delete(listener), + }); + const listener = vi.fn(); + const unsubscribe = addWsTransportStateListener(listener); + + emitWsTransportState("open"); + for (const eventListener of eventListeners) eventListener({} as Event); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith("open"); + + unsubscribe(); + emitWsTransportState("reconnecting"); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/wsTransportEvents.ts b/apps/web/src/wsTransportEvents.ts index 657f355fa..9ebd02f52 100644 --- a/apps/web/src/wsTransportEvents.ts +++ b/apps/web/src/wsTransportEvents.ts @@ -11,8 +11,15 @@ export interface WsTransportStateEventDetail { state: WsTransportState; } +let latestWsTransportState: WsTransportState = "connecting"; + +export function getLatestWsTransportState(): WsTransportState { + return latestWsTransportState; +} + // Emits a browser-local event without leaking transport internals into UI code. export function emitWsTransportState(state: WsTransportState): void { + latestWsTransportState = state; if ( typeof window === "undefined" || typeof window.dispatchEvent !== "function" || @@ -31,7 +38,9 @@ export function emitWsTransportState(state: WsTransportState): void { // Subscribes to the shared transport state event. Returns an idempotent cleanup. export function addWsTransportStateListener( listener: (state: WsTransportState) => void, + options?: { readonly replayLatest?: boolean }, ): () => void { + if (options?.replayLatest) listener(latestWsTransportState); if (typeof window === "undefined" || typeof window.addEventListener !== "function") { return () => undefined; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1016c7145..d17920c13 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -440,6 +440,10 @@ export interface DesktopBridge { shell?: { showInFolder: (path: string) => Promise; }; + diagnostics?: { + /** Opens Scient's local logs without depending on the backend connection. */ + openLogsDirectory: () => Promise; + }; clipboard?: { writeImagePngDataUrl: (dataUrl: string) => Promise; }; From 0cbae5abe8c4dd4aeb4975dc3a435b61c9040e5a Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 03:11:02 +0300 Subject: [PATCH 07/34] Support Linux desktop development launch --- apps/desktop/scripts/dev-electron.mjs | 24 ++++---- apps/desktop/scripts/electron-launcher.mjs | 39 +++++++++++++ .../scripts/electron-launcher.test.mjs | 55 +++++++++++++++++++ apps/desktop/scripts/smoke-test.mjs | 6 +- apps/desktop/scripts/start-electron.mjs | 5 +- 5 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/scripts/electron-launcher.test.mjs diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 861301371..0676b2544 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -4,7 +4,7 @@ import { join } from "node:path"; import waitOn from "wait-on"; import { buildAppSnapHelper } from "./build-appsnap-helper.mjs"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); const devServerUrl = `http://localhost:${port}`; @@ -140,18 +140,18 @@ function startApp() { return; } - const app = spawn( - resolveElectronPath(), - [`--synara-dev-root=${desktopDir}`, "dist-electron/main.js"], - { - cwd: desktopDir, - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, - stdio: "inherit", + const electronCommand = resolveElectronLaunchCommand([ + `--synara-dev-root=${desktopDir}`, + "dist-electron/main.js", + ]); + const app = spawn(electronCommand.electronPath, electronCommand.args, { + cwd: desktopDir, + env: { + ...childEnv, + VITE_DEV_SERVER_URL: devServerUrl, }, - ); + stdio: "inherit", + }); currentApp = app; diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index e06f504a2..696c1fb00 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -146,3 +146,42 @@ export function resolveElectronPath() { return buildMacLauncher(electronBinaryPath); } + +export function isLinuxSetuidSandboxConfigured( + electronBinaryPath, + { platform = process.platform, stat = statSync } = {}, +) { + if (platform !== "linux") { + return true; + } + + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + try { + const sandboxStat = stat(sandboxPath); + return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; + } catch { + return false; + } +} + +export function resolveLinuxSandboxArgs( + electronBinaryPath, + { platform = process.platform, stat = statSync, warn = console.warn } = {}, +) { + if (isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, stat })) { + return []; + } + + warn( + "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; +} + +export function resolveElectronLaunchCommand(args = []) { + const electronPath = resolveElectronPath(); + return { + electronPath, + args: [...resolveLinuxSandboxArgs(electronPath), ...args], + }; +} diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs new file mode 100644 index 000000000..48dc82387 --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +import { isLinuxSetuidSandboxConfigured, resolveLinuxSandboxArgs } from "./electron-launcher.mjs"; + +const ELECTRON_PATH = "/repo/node_modules/electron/dist/electron"; +const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox"; + +describe("Linux Electron sandbox launch policy", () => { + it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => { + const stat = vi.fn(); + + expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", stat })).toEqual([]); + expect(stat).not.toHaveBeenCalled(); + }); + + it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => { + const stat = vi.fn(() => ({ mode: 0o104755, uid: 0 })); + + expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", stat })).toBe(true); + expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", stat })).toEqual([]); + expect(stat).toHaveBeenCalledWith(SANDBOX_PATH); + }); + + it.each([ + ["is not root-owned", { mode: 0o104755, uid: 1000 }], + ["is not setuid", { mode: 0o100755, uid: 0 }], + ["is group-writable", { mode: 0o104775, uid: 0 }], + ])("uses the local-development fallback when chrome-sandbox %s", (_reason, metadata) => { + const warn = vi.fn(); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + stat: () => metadata, + warn, + }), + ).toEqual(["--no-sandbox"]); + expect(warn).toHaveBeenCalledOnce(); + }); + + it("uses the local-development fallback when chrome-sandbox is missing", () => { + const warn = vi.fn(); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + stat: () => { + throw new Error("ENOENT"); + }, + warn, + }), + ).toEqual(["--no-sandbox"]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("launching local Electron")); + }); +}); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 677e33f2f..7fa88373f 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -2,14 +2,16 @@ import { spawn, spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); -const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); const mainJs = resolve(desktopDir, "dist-electron/main.js"); console.log("\nLaunching Electron smoke test..."); -const child = spawn(electronBin, [mainJs], { +const electronCommand = resolveElectronLaunchCommand([mainJs]); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", env: { diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 345c3843a..093f7239e 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { buildAppSnapHelper } from "./build-appsnap-helper.mjs"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; if (process.platform === "darwin") { buildAppSnapHelper({ arch: process.arch }); @@ -10,7 +10,8 @@ if (process.platform === "darwin") { const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"]); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, env: childEnv, From f1e0405da9f900ead2f97eef124c4e2ea1fb55d2 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 03:26:22 +0300 Subject: [PATCH 08/34] Gate packaged Linux desktop lifecycle --- .github/workflows/ci.yml | 43 ++ apps/web/package.json | 1 + apps/web/scripts/linux-appimage-smoke.mjs | 457 ++++++++++++++++++++++ package.json | 1 + 4 files changed, 502 insertions(+) create mode 100644 apps/web/scripts/linux-appimage-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d79dd31d2..60fdabbe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,49 @@ jobs: - name: Exercise Windows release staging run: bun run release:smoke + linux_packaged: + name: Packaged Linux Acceptance + needs: quality + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install packaged desktop runtime dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + ./apps/web/node_modules/.bin/playwright install-deps chromium + sudo apt-get install --no-install-recommends --yes libfuse2t64 + + - name: Build Linux AppImage + run: bun run dist:desktop:linux + + - name: Exercise packaged Linux lifecycle + run: bun run test:linux-appimage + + - name: Upload packaged Linux diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: linux-packaged-diagnostics + path: test-results/linux-appimage + if-no-files-found: ignore + release_smoke: name: Release Smoke runs-on: ubuntu-24.04 diff --git a/apps/web/package.json b/apps/web/package.json index e61c2c24b..1b7e1b3a5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "test:browser:stable:remaining": "vitest run --config vitest.browser.stable.config.ts --exclude src/components/KeybindingsToast.browser.tsx --exclude src/components/ChatView.browser.tsx --exclude src/components/EventRouter.browser.tsx", "test:browser:geometry": "vitest run --config vitest.browser.geometry.config.ts", "test:browser:install": "playwright install --with-deps chromium", + "test:linux-appimage": "node scripts/linux-appimage-smoke.mjs", "measure:lcp": "node scripts/measure-lcp.mjs" }, "dependencies": { diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs new file mode 100644 index 000000000..e7d09d482 --- /dev/null +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -0,0 +1,457 @@ +import { spawn } from "node:child_process"; +import { constants as FS_CONSTANTS } from "node:fs"; +import { + access, + chmod, + copyFile, + mkdtemp, + mkdir, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +const SCRIPT_DIR = fileURLToPath(new URL(".", import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, "../../.."); +const RELEASE_DIR = resolve(REPO_ROOT, process.env.SCIENT_LINUX_ARTIFACT_DIR || "release"); +const DIAGNOSTIC_DIR = resolve( + REPO_ROOT, + process.env.SCIENT_LINUX_SMOKE_ARTIFACT_DIR || "test-results/linux-appimage", +); +const STARTUP_TIMEOUT_MS = 45_000; +const ACTION_TIMEOUT_MS = 20_000; +const RECOVERY_TIMEOUT_MS = 30_000; +const PRIVATE_DIRECTORY_MODE = 0o700; + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +async function waitFor(description, operation, timeoutMs = ACTION_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + const result = await operation(); + if (result !== null && result !== false && result !== undefined) return result; + } catch (error) { + lastError = error; + } + await delay(100); + } + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + throw new Error(`Timed out waiting for ${description}${detail}`); +} + +async function reservePort() { + const server = createServer(); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not reserve a loopback debugging port."); + } + await new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())); + }); + return address.port; +} + +async function findAppImage() { + const entries = (await readdir(RELEASE_DIR, { withFileTypes: true })).filter( + (entry) => entry.isFile() && entry.name.endsWith(".AppImage"), + ); + if (entries.length !== 1) { + throw new Error( + `Expected exactly one AppImage in ${RELEASE_DIR}, found ${entries.map((entry) => entry.name).join(", ") || "none"}.`, + ); + } + const appImagePath = join(RELEASE_DIR, entries[0].name); + await chmod(appImagePath, 0o755); + await access(appImagePath, FS_CONSTANTS.X_OK); + return appImagePath; +} + +async function readRuntimeState(runtimeStatePath) { + const contents = await readFile(runtimeStatePath, "utf8"); + const state = JSON.parse(contents); + if (!Number.isInteger(state.pid) || state.pid <= 0 || typeof state.origin !== "string") { + throw new Error(`Invalid packaged server runtime state at ${runtimeStatePath}.`); + } + return state; +} + +async function waitForRuntimeState(runtimeStatePath, predicate = () => true) { + return waitFor( + "packaged backend runtime state", + async () => { + const state = await readRuntimeState(runtimeStatePath); + return predicate(state) ? state : null; + }, + STARTUP_TIMEOUT_MS, + ); +} + +async function assertPackagedBackendProcess(pid) { + const commandLine = (await readFile(`/proc/${pid}/cmdline`)) + .toString("utf8") + .split("\0") + .filter(Boolean); + if (!commandLine.some((argument) => argument.endsWith("apps/server/dist/index.mjs"))) { + throw new Error(`Refusing to signal unvalidated packaged backend PID ${pid}.`); + } +} + +async function assertPrivateDirectory(directoryPath) { + const metadata = await stat(directoryPath); + if (!metadata.isDirectory()) throw new Error(`${directoryPath} is not a directory.`); + const actualMode = metadata.mode & 0o777; + if (actualMode !== PRIVATE_DIRECTORY_MODE) { + throw new Error( + `Expected private directory ${directoryPath} to use mode 0700, found ${actualMode.toString(8).padStart(4, "0")}.`, + ); + } +} + +async function assertDirectoryMode(directoryPath, expectedMode) { + const actualMode = (await stat(directoryPath)).mode & 0o777; + if (actualMode !== expectedMode) { + throw new Error( + `Expected ${directoryPath} to retain mode ${expectedMode.toString(8).padStart(4, "0")}, found ${actualMode.toString(8).padStart(4, "0")}.`, + ); + } +} + +async function assertPrivateScientDirectories(scientHome) { + const stateDir = join(scientHome, "userdata"); + const directories = [ + scientHome, + stateDir, + join(stateDir, "secrets"), + join(stateDir, "attachments"), + join(stateDir, "logs"), + join(stateDir, "logs", "provider"), + join(stateDir, "logs", "terminals"), + join(scientHome, "worktrees"), + ]; + await Promise.all(directories.map(assertPrivateDirectory)); +} + +async function createDirtyScientDirectories(scientHome) { + const stateDir = join(scientHome, "userdata"); + const directories = [ + scientHome, + stateDir, + join(stateDir, "secrets"), + join(stateDir, "attachments"), + join(stateDir, "logs"), + join(stateDir, "logs", "provider"), + join(stateDir, "logs", "terminals"), + join(scientHome, "worktrees"), + ]; + for (const directoryPath of directories) { + await mkdir(directoryPath, { recursive: true, mode: 0o775 }); + await chmod(directoryPath, 0o775); + } +} + +async function connectToPackagedRenderer(debuggingPort, processOutput) { + const endpoint = `http://127.0.0.1:${debuggingPort}`; + await waitFor( + "Electron remote-debugging endpoint", + async () => { + const response = await fetch(`${endpoint}/json/version`); + return response.ok; + }, + STARTUP_TIMEOUT_MS, + ).catch((error) => { + throw new Error(`${error.message}\nPackaged process output:\n${processOutput()}`); + }); + + const browser = await chromium.connectOverCDP(endpoint, { timeout: STARTUP_TIMEOUT_MS }); + const context = browser.contexts()[0]; + if (!context) throw new Error("Packaged Electron exposed no browser context."); + const page = await waitFor( + "Scient packaged renderer", + async () => { + const candidate = context.pages().find((current) => current.url().startsWith("scient://")); + return candidate ?? null; + }, + STARTUP_TIMEOUT_MS, + ); + await page.getByRole("button", { name: "Add project", exact: true }).first().waitFor({ + state: "visible", + timeout: STARTUP_TIMEOUT_MS, + }); + return { browser, page }; +} + +async function addProjectByTypedPath(page, workspacePath) { + await page.keyboard.press("Control+Shift+O"); + const folderDialog = page.getByRole("dialog"); + const pathInput = folderDialog.getByPlaceholder("Enter project path (e.g. ~/projects/my-app)"); + await pathInput.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + await pathInput.fill(workspacePath); + const addButton = folderDialog.getByRole("button", { name: "Add", exact: true }); + await addButton.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + if ( + await folderDialog + .getByText(/SocketOpenError/) + .first() + .isVisible() + .catch(() => false) + ) { + throw new Error("Folder browsing failed with SocketOpenError."); + } + await addButton.click({ timeout: ACTION_TIMEOUT_MS }); + const emptyProjectChoice = page.getByRole("button", { + name: /Open an empty project/, + }); + await emptyProjectChoice.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + await emptyProjectChoice.click({ timeout: ACTION_TIMEOUT_MS }); +} + +async function waitForPersistedProject(databasePath, workspacePath) { + const { DatabaseSync } = await import("node:sqlite"); + return waitFor("project persistence", async () => { + let database; + try { + database = new DatabaseSync(databasePath, { readOnly: true }); + const row = database + .prepare("SELECT workspace_root FROM projection_projects WHERE workspace_root = ? LIMIT 1") + .get(workspacePath); + return row?.workspace_root === workspacePath; + } finally { + database?.close(); + } + }); +} + +function terminateProcessGroup(child, signal = "SIGTERM") { + if (!child.pid) return; + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } +} + +async function stopPackagedApp(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + const waitForExit = async (timeoutMs) => { + if (child.exitCode !== null || child.signalCode !== null) return true; + let onExit; + const exitPromise = new Promise((resolveExit) => { + onExit = () => resolveExit(true); + child.once("exit", onExit); + }); + const exited = await Promise.race([exitPromise, delay(timeoutMs).then(() => false)]); + if (!exited && onExit) child.off("exit", onExit); + return exited; + }; + terminateProcessGroup(child, "SIGTERM"); + const exited = await waitForExit(5_000); + if (!exited) { + terminateProcessGroup(child, "SIGKILL"); + await waitForExit(5_000); + } +} + +async function preserveFailureDiagnostics({ + scenarioName, + page, + output, + desktopLogPath, + serverLogPath, +}) { + await mkdir(DIAGNOSTIC_DIR, { recursive: true }); + await writeFile(join(DIAGNOSTIC_DIR, `${scenarioName}-process.log`), `${output}\n`, "utf8"); + await page + ?.screenshot({ path: join(DIAGNOSTIC_DIR, `${scenarioName}.png`), fullPage: true }) + .catch(() => undefined); + await copyFile(desktopLogPath, join(DIAGNOSTIC_DIR, `${scenarioName}-desktop-main.log`)).catch( + () => undefined, + ); + await copyFile(serverLogPath, join(DIAGNOSTIC_DIR, `${scenarioName}-server-child.log`)).catch( + () => undefined, + ); +} + +async function runScenario(appImagePath, scenario) { + const scenarioRoot = await mkdtemp(join(tmpdir(), `scient-appimage-${scenario.name}-`)); + const homeDir = join(scenarioRoot, "home"); + const configHome = join(scenarioRoot, "config"); + const cacheHome = join(scenarioRoot, "cache"); + const dataHome = join(scenarioRoot, "data"); + const runtimeDir = join(scenarioRoot, "runtime"); + const scientHome = join(scenarioRoot, "scient-home"); + const firstWorkspace = join(homeDir, `${scenario.name}-first-project`); + const secondWorkspace = join(homeDir, `${scenario.name}-after-recovery`); + const runtimeStatePath = join(scientHome, "userdata", "server-runtime.json"); + const databasePath = join(scientHome, "userdata", "state.sqlite"); + const desktopLogPath = join(scientHome, "userdata", "logs", "desktop-main.log"); + const serverLogPath = join(scientHome, "userdata", "logs", "server-child.log"); + let browser; + let child; + let page; + let output = ""; + + try { + await mkdir(homeDir, { recursive: true }); + await mkdir(configHome, { recursive: true }); + await mkdir(cacheHome, { recursive: true }); + await mkdir(dataHome, { recursive: true }); + await mkdir(runtimeDir, { recursive: true, mode: 0o700 }); + await chmod(runtimeDir, 0o700); + await mkdir(firstWorkspace, { mode: 0o775 }); + await chmod(firstWorkspace, 0o775); + if (scenario.crashBackend) { + await mkdir(secondWorkspace, { mode: 0o775 }); + await chmod(secondWorkspace, 0o775); + } + if (scenario.precreatePermissiveState) await createDirtyScientDirectories(scientHome); + + const debuggingPort = await reservePort(); + const previousUmask = process.umask(scenario.umask); + try { + child = spawn( + "xvfb-run", + [ + "-a", + appImagePath, + `--remote-debugging-port=${debuggingPort}`, + "--remote-debugging-address=127.0.0.1", + "--disable-gpu", + ], + { + detached: true, + env: { + ...process.env, + HOME: homeDir, + SCIENT_HOME: scientHome, + SYNARA_DISABLE_AUTO_UPDATE: "1", + XDG_CACHE_HOME: cacheHome, + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: dataHome, + XDG_RUNTIME_DIR: runtimeDir, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } finally { + process.umask(previousUmask); + } + + const recordOutput = (chunk) => { + output = `${output}${String(chunk)}`.slice(-200_000); + }; + child.stdout?.on("data", recordOutput); + child.stderr?.on("data", recordOutput); + child.once("exit", (code, signal) => { + if (code !== null && code !== 0) recordOutput(`\n[xvfb-run exited code=${code}]`); + if (signal) recordOutput(`\n[xvfb-run exited signal=${signal}]`); + }); + + const renderer = await connectToPackagedRenderer(debuggingPort, () => output); + browser = renderer.browser; + page = renderer.page; + const initialRuntime = await waitForRuntimeState(runtimeStatePath); + await waitFor( + "first packaged backend generation readiness", + async () => { + const log = await readFile(desktopLogPath, "utf8"); + return log.includes("backend generation=1 ready") ? log : null; + }, + STARTUP_TIMEOUT_MS, + ); + + await addProjectByTypedPath(page, firstWorkspace); + await waitForPersistedProject(databasePath, firstWorkspace); + await assertPrivateScientDirectories(scientHome); + await assertDirectoryMode(firstWorkspace, 0o775); + + if (scenario.crashBackend) { + await assertPackagedBackendProcess(initialRuntime.pid); + process.kill(initialRuntime.pid, "SIGKILL"); + const recoveredRuntime = await waitForRuntimeState( + runtimeStatePath, + (state) => state.pid !== initialRuntime.pid, + ); + await waitFor( + "second packaged backend generation readiness", + async () => { + const log = await readFile(desktopLogPath, "utf8"); + return log.includes("backend generation=2 ready") ? log : null; + }, + RECOVERY_TIMEOUT_MS, + ); + + await addProjectByTypedPath(page, secondWorkspace); + await waitForPersistedProject(databasePath, secondWorkspace); + await assertDirectoryMode(secondWorkspace, 0o775); + await assertPackagedBackendProcess(recoveredRuntime.pid); + process.kill(recoveredRuntime.pid, 0); + await delay(1_500); + const finalRuntime = await readRuntimeState(runtimeStatePath); + if (finalRuntime.pid !== recoveredRuntime.pid) { + throw new Error("Packaged backend restarted more than once after the controlled crash."); + } + const desktopLog = await readFile(desktopLogPath, "utf8"); + const restartCount = desktopLog.match(/backend exited unexpectedly/g)?.length ?? 0; + if (restartCount !== 1) { + throw new Error(`Expected one controlled backend restart, observed ${restartCount}.`); + } + } + + console.log(`Packaged Linux scenario passed: ${scenario.name}`); + } catch (error) { + await preserveFailureDiagnostics({ + scenarioName: scenario.name, + page, + output, + desktopLogPath, + serverLogPath, + }).catch(() => undefined); + throw new Error( + `${scenario.name} failed: ${error instanceof Error ? error.stack || error.message : String(error)}\nPackaged process output:\n${output}`, + { cause: error }, + ); + } finally { + await browser?.close().catch(() => undefined); + if (child) await stopPackagedApp(child).catch(() => undefined); + await rm(scenarioRoot, { recursive: true, force: true }); + } +} + +async function main() { + if (process.platform !== "linux") { + throw new Error("The packaged AppImage smoke test must run on Linux."); + } + const appImagePath = await findAppImage(); + console.log(`Testing packaged AppImage: ${basename(appImagePath)}`); + await runScenario(appImagePath, { + name: "fresh-profile", + umask: 0o022, + precreatePermissiveState: false, + crashBackend: false, + }); + await runScenario(appImagePath, { + name: "shared-group-umask", + umask: 0o002, + precreatePermissiveState: true, + crashBackend: true, + }); +} + +await main(); diff --git a/package.json b/package.json index 563e04933..841e34a90 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "brand:check": "node scripts/check-brand-identity.ts", "workflow:check": "bun scripts/check-workflow-actions.ts", "test:desktop-smoke": "turbo run smoke-test --filter=@synara/desktop", + "test:linux-appimage": "bun run --cwd apps/web test:linux-appimage", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "build:contracts": "turbo run build --filter=@synara/contracts", From 4839caa7291c1c7ef96bc8162b6382ebdd9fb19c Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 03:30:39 +0300 Subject: [PATCH 09/34] Harden packaged Linux acceptance --- .github/workflows/ci.yml | 10 +++ .gitignore | 1 + apps/web/scripts/linux-appimage-smoke.mjs | 79 ++++++++++++++++------- 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60fdabbe1..0ebb4abe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,16 @@ jobs: with: node-version-file: package.json + - name: Cache Bun and Turbo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.bun/install/cache + .turbo + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}-${{ hashFiles('turbo.json') }} + restore-keys: | + ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.gitignore b/.gitignore index f1fe8ad39..b712fd6ab 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ release/ .idea/ apps/web/.playwright apps/web/playwright-report +/test-results/linux-appimage/ .playwright-cli/ output/playwright/ apps/web/src/components/__screenshots__ diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index e7d09d482..d28bee791 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -203,6 +203,10 @@ async function addProjectByTypedPath(page, workspacePath) { const pathInput = folderDialog.getByPlaceholder("Enter project path (e.g. ~/projects/my-app)"); await pathInput.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); await pathInput.fill(workspacePath); + await folderDialog + .getByText(basename(workspacePath), { exact: true }) + .first() + .waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); const addButton = folderDialog.getByRole("button", { name: "Add", exact: true }); await addButton.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); if ( @@ -238,33 +242,38 @@ async function waitForPersistedProject(databasePath, workspacePath) { }); } -function terminateProcessGroup(child, signal = "SIGTERM") { - if (!child.pid) return; +function processGroupIsAlive(processGroupId) { try { - process.kill(-child.pid, signal); + process.kill(-processGroupId, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +} + +function signalProcessGroup(processGroupId, signal) { + try { + process.kill(-processGroupId, signal); } catch (error) { if (error?.code !== "ESRCH") throw error; } } async function stopPackagedApp(child) { - if (child.exitCode !== null || child.signalCode !== null) return; - const waitForExit = async (timeoutMs) => { - if (child.exitCode !== null || child.signalCode !== null) return true; - let onExit; - const exitPromise = new Promise((resolveExit) => { - onExit = () => resolveExit(true); - child.once("exit", onExit); - }); - const exited = await Promise.race([exitPromise, delay(timeoutMs).then(() => false)]); - if (!exited && onExit) child.off("exit", onExit); - return exited; - }; - terminateProcessGroup(child, "SIGTERM"); - const exited = await waitForExit(5_000); - if (!exited) { - terminateProcessGroup(child, "SIGKILL"); - await waitForExit(5_000); + if (!child.pid || !processGroupIsAlive(child.pid)) return; + const waitForGroupExit = (timeoutMs) => + waitFor( + "packaged Electron process group exit", + () => !processGroupIsAlive(child.pid), + timeoutMs, + ).catch(() => false); + + signalProcessGroup(child.pid, "SIGTERM"); + if (await waitForGroupExit(5_000)) return; + signalProcessGroup(child.pid, "SIGKILL"); + if (!(await waitForGroupExit(5_000))) { + throw new Error(`Packaged Electron process group ${child.pid} survived SIGKILL.`); } } @@ -274,6 +283,7 @@ async function preserveFailureDiagnostics({ output, desktopLogPath, serverLogPath, + runtimeStatePath, }) { await mkdir(DIAGNOSTIC_DIR, { recursive: true }); await writeFile(join(DIAGNOSTIC_DIR, `${scenarioName}-process.log`), `${output}\n`, "utf8"); @@ -286,6 +296,10 @@ async function preserveFailureDiagnostics({ await copyFile(serverLogPath, join(DIAGNOSTIC_DIR, `${scenarioName}-server-child.log`)).catch( () => undefined, ); + await copyFile( + runtimeStatePath, + join(DIAGNOSTIC_DIR, `${scenarioName}-server-runtime.json`), + ).catch(() => undefined); } async function runScenario(appImagePath, scenario) { @@ -306,6 +320,8 @@ async function runScenario(appImagePath, scenario) { let child; let page; let output = ""; + let scenarioError; + const cleanupErrors = []; try { await mkdir(homeDir, { recursive: true }); @@ -366,6 +382,12 @@ async function runScenario(appImagePath, scenario) { const renderer = await connectToPackagedRenderer(debuggingPort, () => output); browser = renderer.browser; page = renderer.page; + page.on("console", (message) => { + recordOutput(`\n[renderer:${message.type()}] ${message.text()}`); + }); + page.on("pageerror", (error) => { + recordOutput(`\n[renderer:pageerror] ${error.stack || error.message}`); + }); const initialRuntime = await waitForRuntimeState(runtimeStatePath); await waitFor( "first packaged backend generation readiness", @@ -422,15 +444,26 @@ async function runScenario(appImagePath, scenario) { output, desktopLogPath, serverLogPath, + runtimeStatePath, }).catch(() => undefined); - throw new Error( + scenarioError = new Error( `${scenario.name} failed: ${error instanceof Error ? error.stack || error.message : String(error)}\nPackaged process output:\n${output}`, { cause: error }, ); } finally { await browser?.close().catch(() => undefined); - if (child) await stopPackagedApp(child).catch(() => undefined); - await rm(scenarioRoot, { recursive: true, force: true }); + if (child) { + await stopPackagedApp(child).catch((error) => cleanupErrors.push(error)); + } + await rm(scenarioRoot, { recursive: true, force: true }).catch((error) => + cleanupErrors.push(error), + ); + } + if (scenarioError || cleanupErrors.length > 0) { + const errors = [scenarioError, ...cleanupErrors].filter(Boolean); + throw errors.length === 1 + ? errors[0] + : new AggregateError(errors, `${scenario.name} failed and cleanup was incomplete.`); } } From f25d51238bb92a47c1990adfa6e78c57af979212 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 03:48:41 +0300 Subject: [PATCH 10/34] Use packaged server readiness contract --- apps/web/scripts/linux-appimage-smoke.mjs | 27 ++++++++++++++--------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index d28bee791..36a652ab9 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -103,6 +103,19 @@ async function waitForRuntimeState(runtimeStatePath, predicate = () => true) { ); } +async function waitForBackendReady(runtimeState, description, timeoutMs = STARTUP_TIMEOUT_MS) { + return waitFor( + description, + async () => { + const response = await fetch(`${runtimeState.origin}/health`); + if (!response.ok) return null; + const health = await response.json(); + return health?.status === "ok" && health?.startupReady === true ? health : null; + }, + timeoutMs, + ); +} + async function assertPackagedBackendProcess(pid) { const commandLine = (await readFile(`/proc/${pid}/cmdline`)) .toString("utf8") @@ -389,12 +402,9 @@ async function runScenario(appImagePath, scenario) { recordOutput(`\n[renderer:pageerror] ${error.stack || error.message}`); }); const initialRuntime = await waitForRuntimeState(runtimeStatePath); - await waitFor( + await waitForBackendReady( + initialRuntime, "first packaged backend generation readiness", - async () => { - const log = await readFile(desktopLogPath, "utf8"); - return log.includes("backend generation=1 ready") ? log : null; - }, STARTUP_TIMEOUT_MS, ); @@ -410,12 +420,9 @@ async function runScenario(appImagePath, scenario) { runtimeStatePath, (state) => state.pid !== initialRuntime.pid, ); - await waitFor( + await waitForBackendReady( + recoveredRuntime, "second packaged backend generation readiness", - async () => { - const log = await readFile(desktopLogPath, "utf8"); - return log.includes("backend generation=2 ready") ? log : null; - }, RECOVERY_TIMEOUT_MS, ); From 958e08871f0aeb80c089a02fab545a8a545dccdd Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 04:07:41 +0300 Subject: [PATCH 11/34] Isolate provider dialog browser fixtures --- .../ProviderConnectionDialog.browser.tsx | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index 5e6635ba3..fadff29ce 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -103,6 +103,14 @@ describe("ProviderConnectionDialog", () => { }); it("starts official browser sign-in and shows background progress", async () => { + const initialProvider = { + provider: "codex", + status: "warning", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; const waitingProvider = { provider: "codex", status: "warning", @@ -120,15 +128,12 @@ describe("ProviderConnectionDialog", () => { }, } satisfies ServerProviderStatus; const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); - const restoreNativeApi = installNativeApi({ startProviderConnection }); - const queryClient = createQueryClient({ - provider: "codex", - status: "warning", - available: true, - authStatus: "unauthenticated", - checkedAt, - runtime: systemRuntime, + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + startProviderConnection, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog("codex", "settings"); const screen = await render( @@ -192,6 +197,14 @@ describe("ProviderConnectionDialog", () => { }>)( "starts the guided $provider connection flow", async ({ provider, method, title, primaryLabel }) => { + const initialProvider = { + provider, + status: "warning", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; const waitingProvider = { provider, status: "warning", @@ -209,15 +222,12 @@ describe("ProviderConnectionDialog", () => { }, } satisfies ServerProviderStatus; const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); - const restoreNativeApi = installNativeApi({ startProviderConnection }); - const queryClient = createQueryClient({ - provider, - status: "warning", - available: true, - authStatus: "unauthenticated", - checkedAt, - runtime: systemRuntime, + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + startProviderConnection, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog(provider, "settings"); const screen = await render( @@ -540,6 +550,22 @@ describe("ProviderConnectionDialog", () => { }); it("requires reviewed consent before starting a managed installation", async () => { + const initialProvider = { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "missing", + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + } satisfies ServerProviderStatus; const installingProvider = { provider: "antigravity", status: "error", @@ -577,23 +603,13 @@ describe("ProviderConnectionDialog", () => { expiresAt: "2026-07-19T12:10:00.000Z", }); const installProvider = vi.fn().mockResolvedValue({ providers: [installingProvider] }); - const restoreNativeApi = installNativeApi({ prepareProviderInstall, installProvider }); - const queryClient = createQueryClient({ - provider: "antigravity", - status: "error", - available: false, - authStatus: "unknown", - checkedAt, - runtime: { - source: "missing", - managedVersion: null, - canInstall: true, - canRepair: false, - canRollback: false, - canRemove: false, - message: "No usable provider runtime was found.", - }, + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + prepareProviderInstall, + installProvider, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings"); const screen = await render( From 3adb201ee3c2eb1e8ca1022cc4a4d225d9f48eb3 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 04:10:36 +0300 Subject: [PATCH 12/34] Verify graceful packaged process teardown --- apps/web/scripts/linux-appimage-smoke.mjs | 56 ++++++++++++++++++----- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index 36a652ab9..7571c9dbc 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -29,6 +29,7 @@ const DIAGNOSTIC_DIR = resolve( const STARTUP_TIMEOUT_MS = 45_000; const ACTION_TIMEOUT_MS = 20_000; const RECOVERY_TIMEOUT_MS = 30_000; +const GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS = 12_000; const PRIVATE_DIRECTORY_MODE = 0o700; function delay(milliseconds) { @@ -273,21 +274,37 @@ function signalProcessGroup(processGroupId, signal) { } } -async function stopPackagedApp(child) { - if (!child.pid || !processGroupIsAlive(child.pid)) return; - const waitForGroupExit = (timeoutMs) => +async function stopPackagedApp(child, backendProcessGroupId) { + if (!child.pid) return; + const processGroupIds = [...new Set([child.pid, backendProcessGroupId].filter(Boolean))]; + const livingProcessGroups = () => + processGroupIds.filter((processGroupId) => processGroupIsAlive(processGroupId)); + const waitForAllGroupsToExit = (timeoutMs) => waitFor( - "packaged Electron process group exit", - () => !processGroupIsAlive(child.pid), + "packaged Electron and backend process groups to exit", + () => livingProcessGroups().length === 0, timeoutMs, ).catch(() => false); - signalProcessGroup(child.pid, "SIGTERM"); - if (await waitForGroupExit(5_000)) return; - signalProcessGroup(child.pid, "SIGKILL"); - if (!(await waitForGroupExit(5_000))) { - throw new Error(`Packaged Electron process group ${child.pid} survived SIGKILL.`); + if (await waitForAllGroupsToExit(GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS)) return; + + const gracefulSurvivors = livingProcessGroups(); + for (const processGroupId of gracefulSurvivors) { + signalProcessGroup(processGroupId, "SIGTERM"); + } + if (!(await waitForAllGroupsToExit(5_000))) { + for (const processGroupId of livingProcessGroups()) { + signalProcessGroup(processGroupId, "SIGKILL"); + } + if (!(await waitForAllGroupsToExit(5_000))) { + throw new Error( + `Packaged process groups ${livingProcessGroups().join(", ")} survived SIGKILL.`, + ); + } } + throw new Error( + `Packaged application required forced cleanup after its ${GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS}ms graceful shutdown window; surviving process groups: ${gracefulSurvivors.join(", ")}.`, + ); } async function preserveFailureDiagnostics({ @@ -332,6 +349,7 @@ async function runScenario(appImagePath, scenario) { let browser; let child; let page; + let backendProcessGroupId; let output = ""; let scenarioError; const cleanupErrors = []; @@ -402,6 +420,7 @@ async function runScenario(appImagePath, scenario) { recordOutput(`\n[renderer:pageerror] ${error.stack || error.message}`); }); const initialRuntime = await waitForRuntimeState(runtimeStatePath); + backendProcessGroupId = initialRuntime.pid; await waitForBackendReady( initialRuntime, "first packaged backend generation readiness", @@ -420,6 +439,7 @@ async function runScenario(appImagePath, scenario) { runtimeStatePath, (state) => state.pid !== initialRuntime.pid, ); + backendProcessGroupId = recoveredRuntime.pid; await waitForBackendReady( recoveredRuntime, "second packaged backend generation readiness", @@ -458,9 +478,21 @@ async function runScenario(appImagePath, scenario) { { cause: error }, ); } finally { - await browser?.close().catch(() => undefined); + await browser?.close().catch((error) => cleanupErrors.push(error)); if (child) { - await stopPackagedApp(child).catch((error) => cleanupErrors.push(error)); + await stopPackagedApp(child, backendProcessGroupId).catch((error) => + cleanupErrors.push(error), + ); + } + if (cleanupErrors.length > 0) { + await preserveFailureDiagnostics({ + scenarioName: scenario.name, + page, + output, + desktopLogPath, + serverLogPath, + runtimeStatePath, + }).catch(() => undefined); } await rm(scenarioRoot, { recursive: true, force: true }).catch((error) => cleanupErrors.push(error), From 5a856313f3676e76f67b61a41378f7b404d57561 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 04:30:19 +0300 Subject: [PATCH 13/34] Align packaged lifecycle with app controls --- apps/web/scripts/linux-appimage-smoke.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index 7571c9dbc..9a9d0352f 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -221,7 +221,7 @@ async function addProjectByTypedPath(page, workspacePath) { .getByText(basename(workspacePath), { exact: true }) .first() .waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); - const addButton = folderDialog.getByRole("button", { name: "Add", exact: true }); + const addButton = folderDialog.getByRole("button", { name: /^Add\b/u }); await addButton.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); if ( await folderDialog @@ -286,6 +286,9 @@ async function stopPackagedApp(child, backendProcessGroupId) { timeoutMs, ).catch(() => false); + if (processGroupIsAlive(child.pid)) { + signalProcessGroup(child.pid, "SIGTERM"); + } if (await waitForAllGroupsToExit(GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS)) return; const gracefulSurvivors = livingProcessGroups(); From 61f56f32587fda6df230ac5d15a93d33b4029e1e Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 04:57:08 +0300 Subject: [PATCH 14/34] test(linux): shut down packaged app gracefully --- apps/web/scripts/linux-appimage-smoke.mjs | 120 +++++++++++++++++++++- 1 file changed, 115 insertions(+), 5 deletions(-) diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index 9a9d0352f..4ba9d87d8 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -7,6 +7,7 @@ import { mkdtemp, mkdir, readFile, + readlink, readdir, rm, stat, @@ -266,6 +267,14 @@ function processGroupIsAlive(processGroupId) { } } +function signalProcess(processId, signal) { + try { + process.kill(processId, signal); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } +} + function signalProcessGroup(processGroupId, signal) { try { process.kill(-processGroupId, signal); @@ -274,9 +283,108 @@ function signalProcessGroup(processGroupId, signal) { } } -async function stopPackagedApp(child, backendProcessGroupId) { +async function readLinuxProcessInfo(processId) { + const [statContents, commandLineContents, executablePath] = await Promise.all([ + readFile(`/proc/${processId}/stat`, "utf8"), + readFile(`/proc/${processId}/cmdline`), + readlink(`/proc/${processId}/exe`), + ]); + const commandEnd = statContents.lastIndexOf(")"); + if (commandEnd < 0) throw new Error(`Invalid /proc stat data for PID ${processId}.`); + const statFields = statContents + .slice(commandEnd + 2) + .trim() + .split(/\s+/u); + const parentProcessId = Number(statFields[1]); + const processGroupId = Number(statFields[2]); + const startTimeTicks = statFields[19]; + if (!Number.isInteger(parentProcessId) || !Number.isInteger(processGroupId) || !startTimeTicks) { + throw new Error(`Incomplete /proc stat identity for PID ${processId}.`); + } + return { + processId, + parentProcessId, + processGroupId, + startTimeTicks, + executablePath, + commandLine: commandLineContents.toString("utf8").split("\0").filter(Boolean), + }; +} + +async function processIdentityMatches(expectedProcess) { + try { + const currentProcess = await readLinuxProcessInfo(expectedProcess.processId); + return ( + currentProcess.startTimeTicks === expectedProcess.startTimeTicks && + currentProcess.executablePath === expectedProcess.executablePath + ); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ESRCH") return false; + throw error; + } +} + +async function findPackagedElectronProcess(launcherProcessId, debuggingPort) { + return waitFor("packaged Electron main process", async () => { + const processEntries = (await readdir("/proc", { withFileTypes: true })).filter( + (entry) => entry.isDirectory() && /^\d+$/u.test(entry.name), + ); + const processes = new Map(); + await Promise.all( + processEntries.map(async (entry) => { + const processId = Number(entry.name); + try { + processes.set(processId, await readLinuxProcessInfo(processId)); + } catch { + // Processes can exit while /proc is being inspected. + } + }), + ); + + const isDescendantOf = (processId, ancestorProcessId) => { + const visited = new Set(); + let currentProcessId = processId; + while (currentProcessId > 1 && !visited.has(currentProcessId)) { + visited.add(currentProcessId); + const parentProcessId = processes.get(currentProcessId)?.parentProcessId; + if (parentProcessId === ancestorProcessId) return true; + if (!parentProcessId) return false; + currentProcessId = parentProcessId; + } + return false; + }; + + const debuggingArgument = `--remote-debugging-port=${debuggingPort}`; + const candidates = [...processes.entries()].filter( + ([processId, processInfo]) => + isDescendantOf(processId, launcherProcessId) && + basename(processInfo.executablePath) === "scient" && + processInfo.commandLine.includes(debuggingArgument) && + !processInfo.commandLine.some((argument) => argument.startsWith("--type=")), + ); + const deepestCandidates = candidates.filter(([candidateProcessId]) => + candidates.every( + ([otherProcessId]) => + otherProcessId === candidateProcessId || + !isDescendantOf(otherProcessId, candidateProcessId), + ), + ); + if (deepestCandidates.length > 1) { + throw new Error( + `Found multiple packaged Electron main-process candidates: ${candidates + .map(([processId]) => processId) + .join(", ")}.`, + ); + } + return deepestCandidates[0]?.[1] ?? null; + }); +} + +async function stopPackagedApp(child, electronProcess, backendProcessGroupId) { if (!child.pid) return; - const processGroupIds = [...new Set([child.pid, backendProcessGroupId].filter(Boolean))]; + const processGroupIds = [ + ...new Set([child.pid, electronProcess?.processGroupId, backendProcessGroupId].filter(Boolean)), + ]; const livingProcessGroups = () => processGroupIds.filter((processGroupId) => processGroupIsAlive(processGroupId)); const waitForAllGroupsToExit = (timeoutMs) => @@ -286,8 +394,8 @@ async function stopPackagedApp(child, backendProcessGroupId) { timeoutMs, ).catch(() => false); - if (processGroupIsAlive(child.pid)) { - signalProcessGroup(child.pid, "SIGTERM"); + if (electronProcess && (await processIdentityMatches(electronProcess))) { + signalProcess(electronProcess.processId, "SIGTERM"); } if (await waitForAllGroupsToExit(GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS)) return; @@ -352,6 +460,7 @@ async function runScenario(appImagePath, scenario) { let browser; let child; let page; + let electronProcess; let backendProcessGroupId; let output = ""; let scenarioError; @@ -416,6 +525,7 @@ async function runScenario(appImagePath, scenario) { const renderer = await connectToPackagedRenderer(debuggingPort, () => output); browser = renderer.browser; page = renderer.page; + electronProcess = await findPackagedElectronProcess(child.pid, debuggingPort); page.on("console", (message) => { recordOutput(`\n[renderer:${message.type()}] ${message.text()}`); }); @@ -483,7 +593,7 @@ async function runScenario(appImagePath, scenario) { } finally { await browser?.close().catch((error) => cleanupErrors.push(error)); if (child) { - await stopPackagedApp(child, backendProcessGroupId).catch((error) => + await stopPackagedApp(child, electronProcess, backendProcessGroupId).catch((error) => cleanupErrors.push(error), ); } From 301b645032bfbf3556a2b9193561cfe6b5dc87c9 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 04:59:39 +0300 Subject: [PATCH 15/34] test(linux): validate cleanup process identities --- apps/web/scripts/linux-appimage-smoke.mjs | 52 ++++++++++++++++------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index 4ba9d87d8..db2516883 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -257,16 +257,6 @@ async function waitForPersistedProject(databasePath, workspacePath) { }); } -function processGroupIsAlive(processGroupId) { - try { - process.kill(-processGroupId, 0); - return true; - } catch (error) { - if (error?.code === "ESRCH") return false; - throw error; - } -} - function signalProcess(processId, signal) { try { process.kill(processId, signal); @@ -380,17 +370,47 @@ async function findPackagedElectronProcess(launcherProcessId, debuggingPort) { }); } +async function captureProcessGroupMembers(processGroupIds) { + const processGroupIdSet = new Set(processGroupIds); + const membersByProcessGroup = new Map( + processGroupIds.map((processGroupId) => [processGroupId, []]), + ); + const processEntries = (await readdir("/proc", { withFileTypes: true })).filter( + (entry) => entry.isDirectory() && /^\d+$/u.test(entry.name), + ); + await Promise.all( + processEntries.map(async (entry) => { + try { + const processInfo = await readLinuxProcessInfo(Number(entry.name)); + if (processGroupIdSet.has(processInfo.processGroupId)) { + membersByProcessGroup.get(processInfo.processGroupId)?.push(processInfo); + } + } catch { + // Processes can exit while /proc is being inspected. + } + }), + ); + return membersByProcessGroup; +} + async function stopPackagedApp(child, electronProcess, backendProcessGroupId) { if (!child.pid) return; const processGroupIds = [ ...new Set([child.pid, electronProcess?.processGroupId, backendProcessGroupId].filter(Boolean)), ]; - const livingProcessGroups = () => - processGroupIds.filter((processGroupId) => processGroupIsAlive(processGroupId)); + const trackedProcessGroups = await captureProcessGroupMembers(processGroupIds); + const livingProcessGroups = async () => { + const living = []; + for (const [processGroupId, originalMembers] of trackedProcessGroups) { + const identityMatches = await Promise.all(originalMembers.map(processIdentityMatches)); + if (identityMatches.some(Boolean)) living.push(processGroupId); + } + return living; + }; const waitForAllGroupsToExit = (timeoutMs) => waitFor( "packaged Electron and backend process groups to exit", - () => livingProcessGroups().length === 0, + async () => (await livingProcessGroups()).length === 0, timeoutMs, ).catch(() => false); @@ -399,17 +419,17 @@ async function stopPackagedApp(child, electronProcess, backendProcessGroupId) { } if (await waitForAllGroupsToExit(GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS)) return; - const gracefulSurvivors = livingProcessGroups(); + const gracefulSurvivors = await livingProcessGroups(); for (const processGroupId of gracefulSurvivors) { signalProcessGroup(processGroupId, "SIGTERM"); } if (!(await waitForAllGroupsToExit(5_000))) { - for (const processGroupId of livingProcessGroups()) { + for (const processGroupId of await livingProcessGroups()) { signalProcessGroup(processGroupId, "SIGKILL"); } if (!(await waitForAllGroupsToExit(5_000))) { throw new Error( - `Packaged process groups ${livingProcessGroups().join(", ")} survived SIGKILL.`, + `Packaged process groups ${(await livingProcessGroups()).join(", ")} survived SIGKILL.`, ); } } From aabb53f4553024651e9b802b2e7f7e57ba39c89e Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 08:29:36 +0300 Subject: [PATCH 16/34] fix(desktop): fail closed on unsafe Linux sandbox --- apps/desktop/scripts/dev-electron.mjs | 2 +- apps/desktop/scripts/electron-launcher.mjs | 50 ++++++++++--- .../scripts/electron-launcher.test.mjs | 70 +++++++++++++------ apps/desktop/scripts/smoke-test.mjs | 2 +- apps/desktop/scripts/start-electron.mjs | 4 +- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 0676b2544..c34c2229e 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -143,7 +143,7 @@ function startApp() { const electronCommand = resolveElectronLaunchCommand([ `--synara-dev-root=${desktopDir}`, "dist-electron/main.js", - ]); + ], { development: true }); const app = spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 696c1fb00..236f2a631 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -5,6 +5,7 @@ import { copyFileSync, cpSync, existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -149,7 +150,7 @@ export function resolveElectronPath() { export function isLinuxSetuidSandboxConfigured( electronBinaryPath, - { platform = process.platform, stat = statSync } = {}, + { platform = process.platform, lstat = lstatSync } = {}, ) { if (platform !== "linux") { return true; @@ -157,31 +158,58 @@ export function isLinuxSetuidSandboxConfigured( const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); try { - const sandboxStat = stat(sandboxPath); - return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; + const sandboxStat = lstat(sandboxPath); + return ( + sandboxStat.isFile() && + sandboxStat.uid === 0 && + (sandboxStat.mode & 0o7777) === 0o4755 + ); } catch { return false; } } +export class LinuxSandboxConfigurationError extends Error { + constructor(sandboxPath) { + super( + `Electron sandbox helper ${sandboxPath} must be a regular file owned by root with exact mode 4755. ` + + "Repair the helper before launching Scient. For an isolated local development session only, " + + "set SCIENT_DEV_ALLOW_NO_SANDBOX=1 to accept the unsafe fallback explicitly.", + ); + this.name = "LinuxSandboxConfigurationError"; + this.sandboxPath = sandboxPath; + } +} + export function resolveLinuxSandboxArgs( electronBinaryPath, - { platform = process.platform, stat = statSync, warn = console.warn } = {}, + { + platform = process.platform, + lstat = lstatSync, + development = isDevelopment, + env = process.env, + warn = console.warn, + } = {}, ) { - if (isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, stat })) { + if (isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat })) { return []; } - warn( - "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", - ); - return ["--no-sandbox"]; + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + if (development && env.SCIENT_DEV_ALLOW_NO_SANDBOX === "1") { + warn( + "[desktop-launcher] Unsafe development override accepted: launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; + } + + throw new LinuxSandboxConfigurationError(sandboxPath); } -export function resolveElectronLaunchCommand(args = []) { +export function resolveElectronLaunchCommand(args = [], options = {}) { const electronPath = resolveElectronPath(); return { electronPath, - args: [...resolveLinuxSandboxArgs(electronPath), ...args], + args: [...resolveLinuxSandboxArgs(electronPath, options), ...args], }; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 48dc82387..28c7f95bd 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -1,55 +1,79 @@ import { describe, expect, it, vi } from "vitest"; -import { isLinuxSetuidSandboxConfigured, resolveLinuxSandboxArgs } from "./electron-launcher.mjs"; +import { + isLinuxSetuidSandboxConfigured, + LinuxSandboxConfigurationError, + resolveLinuxSandboxArgs, +} from "./electron-launcher.mjs"; const ELECTRON_PATH = "/repo/node_modules/electron/dist/electron"; const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox"; describe("Linux Electron sandbox launch policy", () => { it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => { - const stat = vi.fn(); + const lstat = vi.fn(); - expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", stat })).toEqual([]); - expect(stat).not.toHaveBeenCalled(); + expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat })).toEqual([]); + expect(lstat).not.toHaveBeenCalled(); }); it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => { - const stat = vi.fn(() => ({ mode: 0o104755, uid: 0 })); + const lstat = vi.fn(() => ({ isFile: () => true, mode: 0o104755, uid: 0 })); - expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", stat })).toBe(true); - expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", stat })).toEqual([]); - expect(stat).toHaveBeenCalledWith(SANDBOX_PATH); + expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", lstat })).toBe(true); + expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat })).toEqual([]); + expect(lstat).toHaveBeenCalledWith(SANDBOX_PATH); }); it.each([ - ["is not root-owned", { mode: 0o104755, uid: 1000 }], - ["is not setuid", { mode: 0o100755, uid: 0 }], - ["is group-writable", { mode: 0o104775, uid: 0 }], - ])("uses the local-development fallback when chrome-sandbox %s", (_reason, metadata) => { - const warn = vi.fn(); + ["is not root-owned", { isFile: () => true, mode: 0o104755, uid: 1000 }], + ["is not setuid", { isFile: () => true, mode: 0o100755, uid: 0 }], + ["is group-writable", { isFile: () => true, mode: 0o104775, uid: 0 }], + ["has extra special bits", { isFile: () => true, mode: 0o106755, uid: 0 }], + ["is not a regular file", { isFile: () => false, mode: 0o104755, uid: 0 }], + ])("fails closed when chrome-sandbox %s", (_reason, metadata) => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => metadata, + }), + ).toThrow(LinuxSandboxConfigurationError); + }); - expect( + it("fails closed when chrome-sandbox is missing", () => { + expect(() => resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", - stat: () => metadata, - warn, + lstat: () => { + throw new Error("ENOENT"); + }, }), - ).toEqual(["--no-sandbox"]); - expect(warn).toHaveBeenCalledOnce(); + ).toThrow(expect.objectContaining({ sandboxPath: SANDBOX_PATH })); }); - it("uses the local-development fallback when chrome-sandbox is missing", () => { + it("allows --no-sandbox only through an explicit local-development override", () => { const warn = vi.fn(); expect( resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", - stat: () => { - throw new Error("ENOENT"); - }, + lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + development: true, + env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, warn, }), ).toEqual(["--no-sandbox"]); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("launching local Electron")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Unsafe development override")); + }); + + it("refuses the unsafe override outside development", () => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + development: false, + env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, + }), + ).toThrow(LinuxSandboxConfigurationError); }); }); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 7fa88373f..b864df9c5 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -10,7 +10,7 @@ const mainJs = resolve(desktopDir, "dist-electron/main.js"); console.log("\nLaunching Electron smoke test..."); -const electronCommand = resolveElectronLaunchCommand([mainJs]); +const electronCommand = resolveElectronLaunchCommand([mainJs], { development: false }); const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 093f7239e..2822a18c3 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -10,7 +10,9 @@ if (process.platform === "darwin") { const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"]); +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"], { + development: true, +}); const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, From d6966c00427c82beef8082b436bad2cec6cc3882 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 08:30:15 +0300 Subject: [PATCH 17/34] test(linux): bound packaged smoke requests --- .../scripts/linux-appimage-smoke-support.mjs | 61 ++++++ .../linux-appimage-smoke-support.test.mjs | 49 +++++ apps/web/scripts/linux-appimage-smoke.mjs | 193 +++++++++++++----- 3 files changed, 248 insertions(+), 55 deletions(-) create mode 100644 apps/web/scripts/linux-appimage-smoke-support.mjs create mode 100644 apps/web/scripts/linux-appimage-smoke-support.test.mjs diff --git a/apps/web/scripts/linux-appimage-smoke-support.mjs b/apps/web/scripts/linux-appimage-smoke-support.mjs new file mode 100644 index 000000000..6ea968102 --- /dev/null +++ b/apps/web/scripts/linux-appimage-smoke-support.mjs @@ -0,0 +1,61 @@ +const DEFAULT_FETCH_ATTEMPT_TIMEOUT_MS = 5_000; +const DEFAULT_WAIT_TIMEOUT_MS = 20_000; +const POLL_INTERVAL_MS = 100; + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +export async function waitFor(description, operation, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + try { + const result = await operation({ + deadline, + remainingMs: Math.max(0, deadline - Date.now()), + }); + if (result !== null && result !== false && result !== undefined) return result; + } catch (error) { + lastError = error; + } + + const remainingMs = deadline - Date.now(); + if (remainingMs > 0) await delay(Math.min(POLL_INTERVAL_MS, remainingMs)); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + throw new Error(`Timed out waiting for ${description}${detail}`, { cause: lastError }); +} + +export async function fetchWithinDeadline( + input, + { + deadline, + attemptTimeoutMs = DEFAULT_FETCH_ATTEMPT_TIMEOUT_MS, + consume = (response) => response, + requestInit, + }, +) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw new Error(`Deadline expired before requesting ${String(input)}.`); + + const timeoutMs = Math.max(1, Math.min(attemptTimeoutMs, remainingMs)); + const timeoutController = new AbortController(); + const signal = requestInit?.signal + ? AbortSignal.any([requestInit.signal, timeoutController.signal]) + : timeoutController.signal; + const timeout = setTimeout(() => { + timeoutController.abort( + new Error(`Request to ${String(input)} exceeded its ${timeoutMs}ms deadline.`), + ); + }, timeoutMs); + + try { + const response = await fetch(input, { ...requestInit, signal }); + return await consume(response); + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/web/scripts/linux-appimage-smoke-support.test.mjs b/apps/web/scripts/linux-appimage-smoke-support.test.mjs new file mode 100644 index 000000000..330cb6da9 --- /dev/null +++ b/apps/web/scripts/linux-appimage-smoke-support.test.mjs @@ -0,0 +1,49 @@ +import { createServer } from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { fetchWithinDeadline, waitFor } from "./linux-appimage-smoke-support.mjs"; + +describe("linux AppImage smoke polling", () => { + it("aborts a request that accepts a connection but never responds", async () => { + const sockets = new Set(); + const server = createServer(() => { + // Deliberately leave the request open to model a wedged health or CDP endpoint. + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test server port."); + const startedAt = Date.now(); + + await expect( + waitFor( + "non-responsive endpoint", + ({ deadline }) => + fetchWithinDeadline(`http://127.0.0.1:${address.port}/health`, { + deadline, + attemptTimeoutMs: 50, + consume: (response) => response.ok, + }), + 180, + ), + ).rejects.toThrow(/Timed out waiting for non-responsive endpoint/u); + + expect(Date.now() - startedAt).toBeLessThan(1_000); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())); + }); + } + }); +}); diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index db2516883..21ccf0553 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -20,6 +20,8 @@ import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; +import { fetchWithinDeadline, waitFor } from "./linux-appimage-smoke-support.mjs"; + const SCRIPT_DIR = fileURLToPath(new URL(".", import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, "../../.."); const RELEASE_DIR = resolve(REPO_ROOT, process.env.SCIENT_LINUX_ARTIFACT_DIR || "release"); @@ -31,28 +33,13 @@ const STARTUP_TIMEOUT_MS = 45_000; const ACTION_TIMEOUT_MS = 20_000; const RECOVERY_TIMEOUT_MS = 30_000; const GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS = 12_000; +const DIAGNOSTIC_CAPTURE_TIMEOUT_MS = 5_000; const PRIVATE_DIRECTORY_MODE = 0o700; function delay(milliseconds) { return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); } -async function waitFor(description, operation, timeoutMs = ACTION_TIMEOUT_MS) { - const deadline = Date.now() + timeoutMs; - let lastError; - while (Date.now() < deadline) { - try { - const result = await operation(); - if (result !== null && result !== false && result !== undefined) return result; - } catch (error) { - lastError = error; - } - await delay(100); - } - const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; - throw new Error(`Timed out waiting for ${description}${detail}`); -} - async function reservePort() { const server = createServer(); await new Promise((resolveListen, reject) => { @@ -108,12 +95,15 @@ async function waitForRuntimeState(runtimeStatePath, predicate = () => true) { async function waitForBackendReady(runtimeState, description, timeoutMs = STARTUP_TIMEOUT_MS) { return waitFor( description, - async () => { - const response = await fetch(`${runtimeState.origin}/health`); - if (!response.ok) return null; - const health = await response.json(); - return health?.status === "ok" && health?.startupReady === true ? health : null; - }, + ({ deadline }) => + fetchWithinDeadline(`${runtimeState.origin}/health`, { + deadline, + consume: async (response) => { + if (!response.ok) return null; + const health = await response.json(); + return health?.status === "ok" && health?.startupReady === true ? health : null; + }, + }), timeoutMs, ); } @@ -185,10 +175,11 @@ async function connectToPackagedRenderer(debuggingPort, processOutput) { const endpoint = `http://127.0.0.1:${debuggingPort}`; await waitFor( "Electron remote-debugging endpoint", - async () => { - const response = await fetch(`${endpoint}/json/version`); - return response.ok; - }, + ({ deadline }) => + fetchWithinDeadline(`${endpoint}/json/version`, { + deadline, + consume: (response) => response.ok, + }), STARTUP_TIMEOUT_MS, ).catch((error) => { throw new Error(`${error.message}\nPackaged process output:\n${processOutput()}`); @@ -393,46 +384,89 @@ async function captureProcessGroupMembers(processGroupIds) { return membersByProcessGroup; } -async function stopPackagedApp(child, electronProcess, backendProcessGroupId) { +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { name: "NonError", message: String(error) }; +} + +function serializeProcessGroups(membersByProcessGroup) { + return [...membersByProcessGroup].map(([processGroupId, members]) => ({ + processGroupId, + members, + })); +} + +async function stopPackagedApp(child, electronProcess, backendProcessGroupId, cleanupDiagnostics) { if (!child.pid) return; + cleanupDiagnostics.launcherProcessId = child.pid; + cleanupDiagnostics.launcherProcess = await readLinuxProcessInfo(child.pid).catch((error) => { + cleanupDiagnostics.identityReadErrors.push({ + processId: child.pid, + error: serializeError(error), + }); + return null; + }); + cleanupDiagnostics.electronProcess = electronProcess ?? null; + cleanupDiagnostics.backendProcessGroupId = backendProcessGroupId ?? null; const processGroupIds = [ ...new Set([child.pid, electronProcess?.processGroupId, backendProcessGroupId].filter(Boolean)), ]; + cleanupDiagnostics.processGroupIds = processGroupIds; const trackedProcessGroups = await captureProcessGroupMembers(processGroupIds); - const livingProcessGroups = async () => { - const living = []; + cleanupDiagnostics.trackedProcessGroups = serializeProcessGroups(trackedProcessGroups); + const captureLivingProcessGroups = async (phase) => { + const livingGroups = []; for (const [processGroupId, originalMembers] of trackedProcessGroups) { const identityMatches = await Promise.all(originalMembers.map(processIdentityMatches)); - if (identityMatches.some(Boolean)) living.push(processGroupId); + const livingMembers = originalMembers.filter((_, index) => identityMatches[index]); + if (livingMembers.length > 0) { + livingGroups.push({ processGroupId, members: livingMembers }); + } } - return living; + cleanupDiagnostics.survivors[phase] = livingGroups; + return livingGroups.map(({ processGroupId }) => processGroupId); }; const waitForAllGroupsToExit = (timeoutMs) => waitFor( "packaged Electron and backend process groups to exit", - async () => (await livingProcessGroups()).length === 0, + async () => (await captureLivingProcessGroups("latest-poll")).length === 0, timeoutMs, ).catch(() => false); if (electronProcess && (await processIdentityMatches(electronProcess))) { + cleanupDiagnostics.signals.push({ target: electronProcess.processId, signal: "SIGTERM" }); signalProcess(electronProcess.processId, "SIGTERM"); } - if (await waitForAllGroupsToExit(GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS)) return; + if (await waitForAllGroupsToExit(GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS)) { + cleanupDiagnostics.result = "graceful"; + await captureLivingProcessGroups("after-graceful-window"); + return; + } - const gracefulSurvivors = await livingProcessGroups(); + const gracefulSurvivors = await captureLivingProcessGroups("after-graceful-window"); for (const processGroupId of gracefulSurvivors) { + cleanupDiagnostics.signals.push({ target: -processGroupId, signal: "SIGTERM" }); signalProcessGroup(processGroupId, "SIGTERM"); } if (!(await waitForAllGroupsToExit(5_000))) { - for (const processGroupId of await livingProcessGroups()) { + const termSurvivors = await captureLivingProcessGroups("after-group-sigterm"); + for (const processGroupId of termSurvivors) { + cleanupDiagnostics.signals.push({ target: -processGroupId, signal: "SIGKILL" }); signalProcessGroup(processGroupId, "SIGKILL"); } if (!(await waitForAllGroupsToExit(5_000))) { - throw new Error( - `Packaged process groups ${(await livingProcessGroups()).join(", ")} survived SIGKILL.`, - ); + const killSurvivors = await captureLivingProcessGroups("after-group-sigkill"); + cleanupDiagnostics.result = "survived-sigkill"; + throw new Error(`Packaged process groups ${killSurvivors.join(", ")} survived SIGKILL.`); } } + cleanupDiagnostics.result = "forced-cleanup"; throw new Error( `Packaged application required forced cleanup after its ${GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS}ms graceful shutdown window; surviving process groups: ${gracefulSurvivors.join(", ")}.`, ); @@ -440,17 +474,23 @@ async function stopPackagedApp(child, electronProcess, backendProcessGroupId) { async function preserveFailureDiagnostics({ scenarioName, - page, + screenshot, output, desktopLogPath, serverLogPath, runtimeStatePath, + cleanupDiagnostics, }) { await mkdir(DIAGNOSTIC_DIR, { recursive: true }); await writeFile(join(DIAGNOSTIC_DIR, `${scenarioName}-process.log`), `${output}\n`, "utf8"); - await page - ?.screenshot({ path: join(DIAGNOSTIC_DIR, `${scenarioName}.png`), fullPage: true }) - .catch(() => undefined); + if (screenshot) { + await writeFile(join(DIAGNOSTIC_DIR, `${scenarioName}.png`), screenshot); + } + await writeFile( + join(DIAGNOSTIC_DIR, `${scenarioName}-cleanup.json`), + `${JSON.stringify(cleanupDiagnostics, null, 2)}\n`, + "utf8", + ); await copyFile(desktopLogPath, join(DIAGNOSTIC_DIR, `${scenarioName}-desktop-main.log`)).catch( () => undefined, ); @@ -484,7 +524,24 @@ async function runScenario(appImagePath, scenario) { let backendProcessGroupId; let output = ""; let scenarioError; + let finalScreenshot; const cleanupErrors = []; + const cleanupDiagnostics = { + scenarioName: scenario.name, + launcherProcessId: null, + launcherProcess: null, + electronProcess: null, + backendProcessGroupId: null, + processGroupIds: [], + trackedProcessGroups: [], + survivors: {}, + signals: [], + identityReadErrors: [], + screenshotError: null, + scenarioError: null, + errors: [], + result: "not-started", + }; try { await mkdir(homeDir, { recursive: true }); @@ -598,38 +655,64 @@ async function runScenario(appImagePath, scenario) { console.log(`Packaged Linux scenario passed: ${scenario.name}`); } catch (error) { - await preserveFailureDiagnostics({ - scenarioName: scenario.name, - page, - output, - desktopLogPath, - serverLogPath, - runtimeStatePath, - }).catch(() => undefined); scenarioError = new Error( `${scenario.name} failed: ${error instanceof Error ? error.stack || error.message : String(error)}\nPackaged process output:\n${output}`, { cause: error }, ); + cleanupDiagnostics.scenarioError = serializeError(scenarioError); } finally { + finalScreenshot = await page + ?.screenshot({ fullPage: true, timeout: DIAGNOSTIC_CAPTURE_TIMEOUT_MS }) + .catch((error) => { + cleanupDiagnostics.screenshotError = serializeError(error); + return undefined; + }); await browser?.close().catch((error) => cleanupErrors.push(error)); if (child) { - await stopPackagedApp(child, electronProcess, backendProcessGroupId).catch((error) => - cleanupErrors.push(error), - ); + await stopPackagedApp( + child, + electronProcess, + backendProcessGroupId, + cleanupDiagnostics, + ).catch((error) => cleanupErrors.push(error)); } - if (cleanupErrors.length > 0) { + cleanupDiagnostics.errors = cleanupErrors.map(serializeError); + let diagnosticsPreserved = false; + if (scenarioError || cleanupErrors.length > 0) { await preserveFailureDiagnostics({ scenarioName: scenario.name, - page, + screenshot: finalScreenshot, output, desktopLogPath, serverLogPath, runtimeStatePath, + cleanupDiagnostics, }).catch(() => undefined); + diagnosticsPreserved = true; } await rm(scenarioRoot, { recursive: true, force: true }).catch((error) => cleanupErrors.push(error), ); + if (cleanupDiagnostics.errors.length !== cleanupErrors.length) { + cleanupDiagnostics.errors = cleanupErrors.map(serializeError); + if (diagnosticsPreserved) { + await writeFile( + join(DIAGNOSTIC_DIR, `${scenario.name}-cleanup.json`), + `${JSON.stringify(cleanupDiagnostics, null, 2)}\n`, + "utf8", + ).catch(() => undefined); + } else { + await preserveFailureDiagnostics({ + scenarioName: scenario.name, + screenshot: finalScreenshot, + output, + desktopLogPath, + serverLogPath, + runtimeStatePath, + cleanupDiagnostics, + }).catch(() => undefined); + } + } } if (scenarioError || cleanupErrors.length > 0) { const errors = [scenarioError, ...cleanupErrors].filter(Boolean); From 9fe970ec23aae85653ff29d0e2e277b8a860cf72 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 08:34:59 +0300 Subject: [PATCH 18/34] ci(release): smoke exact desktop artifacts --- .github/workflows/release.yml | 36 +- scripts/release-smoke.ts | 95 ++++ .../verify-packaged-desktop-startup.test.ts | 158 +++++++ scripts/verify-packaged-desktop-startup.ts | 435 ++++++++++++++++++ 4 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-packaged-desktop-startup.test.ts create mode 100644 scripts/verify-packaged-desktop-startup.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72ffa4c6b..ce13f83e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,7 +201,7 @@ jobs: name: Build ${{ matrix.label }} needs: preflight runs-on: ${{ matrix.runner }} - timeout-minutes: 30 + timeout-minutes: 50 strategy: fail-fast: false matrix: @@ -413,6 +413,40 @@ jobs: fi fi + - name: Install Linux packaged smoke dependencies + if: ${{ matrix.platform == 'linux' }} + shell: bash + env: + DEBIAN_FRONTEND: noninteractive + run: | + set -euo pipefail + ./apps/web/node_modules/.bin/playwright install-deps chromium + sudo apt-get install --no-install-recommends --yes libfuse2t64 + + - name: Smoke exact packaged desktop startup + shell: bash + run: | + node scripts/verify-packaged-desktop-startup.ts \ + --assets-dir release-publish \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --version "${{ needs.preflight.outputs.version }}" + + - name: Exercise exact packaged Linux lifecycle + if: ${{ matrix.platform == 'linux' }} + env: + SCIENT_LINUX_ARTIFACT_DIR: release-publish + SCIENT_LINUX_SMOKE_ARTIFACT_DIR: test-results/linux-release-appimage + run: node apps/web/scripts/linux-appimage-smoke.mjs + + - name: Upload packaged Linux smoke diagnostics + if: ${{ failure() && matrix.platform == 'linux' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-linux-packaged-diagnostics-${{ matrix.arch }} + path: test-results/linux-release-appimage + if-no-files-found: ignore + - name: Upload build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 11c0cc2ec..33561e1e3 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -26,6 +26,10 @@ import { readReleaseUpdatePolicyConfig, resolveReleaseUpdatePolicy, } from "./lib/release-update-policy.ts"; +import { + assertPackagedLaunchCommandSafety, + createLinuxPackagedLaunchCommand, +} from "./verify-packaged-desktop-startup.ts"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -96,6 +100,17 @@ function assertNotContains(haystack: string, needle: string, message: string): v } } +function assertOrdered(haystack: string, needles: ReadonlyArray, message: string): void { + let previousIndex = -1; + for (const needle of needles) { + const index = haystack.indexOf(needle, previousIndex + 1); + if (index < 0 || index <= previousIndex) { + throw new Error(message); + } + previousIndex = index; + } +} + function verifyCanonicalIdentity(): void { const serverPackage = JSON.parse( readFileSync(resolve(repoRoot, "apps/server/package.json"), "utf8"), @@ -245,6 +260,86 @@ function verifyReleaseWorkflowSafety(): void { 'node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"\n bun install --lockfile-only --ignore-scripts', "Expected artifact builds to refresh lockfile metadata after aligning workspace versions.", ); + assertContains( + workflow, + "Smoke exact packaged desktop startup", + "Expected every native builder to launch its exact collected desktop payload.", + ); + assertContains( + workflow, + "node scripts/verify-packaged-desktop-startup.ts \\" + + "\n --assets-dir release-publish", + "Expected packaged startup verification to read from the exact release-publish payload.", + ); + assertContains( + workflow, + "if: ${{ matrix.platform == 'linux' }}\n shell: bash\n env:\n DEBIAN_FRONTEND: noninteractive", + "Expected Linux-only packaged smoke dependencies.", + ); + assertContains( + workflow, + "./apps/web/node_modules/.bin/playwright install-deps chromium\n sudo apt-get install --no-install-recommends --yes libfuse2t64", + "Expected the Linux release runner to install only the browser-system and AppImage runtime dependencies.", + ); + assertContains( + workflow, + "SCIENT_LINUX_ARTIFACT_DIR: release-publish\n SCIENT_LINUX_SMOKE_ARTIFACT_DIR: test-results/linux-release-appimage", + "Expected the deep Linux lifecycle smoke to exercise the exact collected AppImage.", + ); + assertContains( + workflow, + "run: node apps/web/scripts/linux-appimage-smoke.mjs", + "Expected Linux release builds to run the existing packaged lifecycle test.", + ); + assertOrdered( + workflow, + [ + "- name: Collect release assets", + "- name: Smoke exact packaged desktop startup", + "- name: Exercise exact packaged Linux lifecycle", + "- name: Upload build artifacts", + ], + "Expected exact-artifact startup and Linux lifecycle verification before artifact upload.", + ); + + const packagedStartupVerifier = readFileSync( + resolve(repoRoot, "scripts/verify-packaged-desktop-startup.ts"), + "utf8", + ); + assertContains( + packagedStartupVerifier, + "SCIENT_HOME: scientHome", + "Expected packaged startup verification to use isolated Scient state.", + ); + assertContains( + packagedStartupVerifier, + 'delete env.SYNARA_AUTH_TOKEN;\n delete env.ELECTRON_RUN_AS_NODE;', + "Expected packaged startup verification to remove inherited backend authority and Electron Node mode.", + ); + assertContains( + packagedStartupVerifier, + "Scient\\.exe", + "Expected packaged Windows verification to require Scient.exe.", + ); + assertNotContains( + packagedStartupVerifier, + "Synara\\.exe", + "Packaged startup verification must not retain the Synara executable identity.", + ); + + const packagedLinuxLaunch = createLinuxPackagedLaunchCommand( + "/release-publish/squashfs-root/AppRun", + "/release-publish/squashfs-root", + ); + assertPackagedLaunchCommandSafety(packagedLinuxLaunch); + if (packagedLinuxLaunch.args.some((argument) => argument.startsWith("--no-sandbox"))) { + throw new Error("Exact packaged Linux verification must not disable Electron's sandbox."); + } + assertContains( + packagedStartupVerifier, + "assertPackagedLaunchCommandSafety(launch);", + "Expected every packaged platform command to fail closed if sandbox bypass is introduced.", + ); } function verifyDesktopStageLockAuthority(): void { diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts new file mode 100644 index 000000000..fcc0b7e00 --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -0,0 +1,158 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + assertPackagedLaunchCommandSafety, + createLinuxPackagedLaunchCommand, + createPackagedDesktopSmokeEnvironment, + isScientWindowsExecutable, + parsePackagedDesktopStartupArgs, + resolveNativePackagedDesktopPlatform, + resolvePackagedDesktopLogPath, +} from "./verify-packaged-desktop-startup.ts"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("packaged desktop startup verification", () => { + it("parses a bounded native payload request", () => { + expect( + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "linux", + "--arch", + "x64", + "--version", + "1.2.3", + ]), + ).toEqual({ + assetsDirectory: expect.stringMatching(/release-publish$/), + platform: "linux", + arch: "x64", + version: "1.2.3", + timeoutMs: 60_000, + }); + + expect(() => + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "linux", + "--arch", + "x64", + "--version", + "1.2.3", + "--timeout-ms", + "4999", + ]), + ).toThrow("--timeout-ms must be an integer between 5000 and 180000"); + }); + + it("isolates Scient state and removes inherited runtime authority", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-env-test-")); + temporaryRoots.push(root); + + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "linux", version: "1.2.3" }, + { + PATH: process.env.PATH, + SCIENT_HOME: "/must/not/leak", + SYNARA_HOME: "/must/not/leak-either", + PAPILAB_HOME: "/must/not/leak-either", + SYNARA_AUTH_TOKEN: "must-not-leak", + ELECTRON_RUN_AS_NODE: "1", + }, + ); + + expect(env.SYNARA_HOME).toBeUndefined(); + expect(env.PAPILAB_HOME).toBeUndefined(); + expect(env.SYNARA_AUTH_TOKEN).toBeUndefined(); + expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + for (const name of [ + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "SCIENT_HOME", + ] as const) { + expect(env[name]?.startsWith(root)).toBe(true); + expect(existsSync(env[name]!)).toBe(true); + } + if (process.platform !== "win32") { + expect(statSync(env.XDG_RUNTIME_DIR!).mode & 0o777).toBe(0o700); + } + expect(resolvePackagedDesktopLogPath(env)).toBe( + join(env.SCIENT_HOME!, "userdata", "logs", "desktop-main.log"), + ); + }); + + it("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("keeps exact packaged Linux verification on the real sandboxed command line", () => { + const launch = createLinuxPackagedLaunchCommand( + "/tmp/scient-payload/AppRun", + "/tmp/scient-payload", + ); + + expect(launch).toEqual({ + command: "xvfb-run", + args: ["-a", "/tmp/scient-payload/AppRun", "--disable-gpu"], + cwd: "/tmp/scient-payload", + }); + expect(launch.args).not.toContain("--no-sandbox"); + 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")).toBe("linux"); + }); +}); diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts new file mode 100644 index 000000000..81810668b --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.ts @@ -0,0 +1,435 @@ +#!/usr/bin/env node +// FILE: verify-packaged-desktop-startup.ts +// Purpose: Launches an exact collected desktop release payload from isolated temporary state. +// Layer: Release verification script + +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type PackagedDesktopPlatform = "linux" | "mac" | "win"; + +export interface PackagedDesktopStartupOptions { + readonly assetsDirectory: string; + readonly platform: PackagedDesktopPlatform; + readonly arch: string; + readonly version: string; + readonly timeoutMs: number; +} + +export function parsePackagedDesktopStartupArgs( + argv: ReadonlyArray, +): PackagedDesktopStartupOptions { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || values.has(name)) { + throw new Error(`Invalid packaged startup argument near ${name ?? ""}.`); + } + values.set(name, value); + } + + const known = new Set(["--assets-dir", "--platform", "--arch", "--version", "--timeout-ms"]); + for (const name of values.keys()) { + if (!known.has(name)) throw new Error(`Unknown packaged startup argument: ${name}.`); + } + + const required = (name: string): string => { + const value = values.get(name)?.trim(); + if (!value) throw new Error(`Missing packaged startup argument: ${name}.`); + return value; + }; + + const platform = required("--platform"); + if (platform !== "linux" && platform !== "mac" && platform !== "win") { + throw new Error(`Unsupported packaged startup platform: ${platform}.`); + } + + const timeoutMs = Number(values.get("--timeout-ms") ?? "60000"); + if (!Number.isInteger(timeoutMs) || timeoutMs < 5_000 || timeoutMs > 180_000) { + throw new Error("--timeout-ms must be an integer between 5000 and 180000."); + } + + return { + assetsDirectory: resolve(required("--assets-dir")), + platform, + arch: required("--arch"), + version: required("--version"), + timeoutMs, + }; +} + +function runCommand(command: string, args: ReadonlyArray, cwd?: string): void { + const result = spawnSync(command, [...args], { + cwd, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + shell: false, + windowsHide: true, + }); + if (result.error) { + throw new Error(`${command} could not start: ${result.error.message}`); + } + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + const detail = output ? `\n${output}` : ""; + throw new Error( + `${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}.${detail}`, + ); + } +} + +function findFiles(root: string, predicate: (path: string) => boolean): string[] { + const matches: string[] = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.shift(); + if (!current) continue; + for (const entry of readdirSync(current, { withFileTypes: true })) { + const candidate = join(current, entry.name); + if (entry.isDirectory()) { + pending.push(candidate); + } else if (entry.isFile() && predicate(candidate)) { + matches.push(candidate); + } + } + } + return matches.sort((left, right) => left.localeCompare(right)); +} + +function requireSingleAsset(directory: string, suffix: string): string { + const matches = readdirSync(directory) + .map((entry) => join(directory, entry)) + .filter((candidate) => statSync(candidate).isFile() && candidate.endsWith(suffix)); + if (matches.length !== 1) { + throw new Error(`Expected one ${suffix} release asset, found ${matches.length}.`); + } + return matches[0]!; +} + +export interface PackagedDesktopLaunchCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; +} + +export function assertPackagedLaunchCommandSafety( + launch: PackagedDesktopLaunchCommand, +): void { + const forbiddenArgument = launch.args.find( + (argument) => argument === "--no-sandbox" || argument.startsWith("--no-sandbox="), + ); + if (forbiddenArgument) { + throw new Error( + `Packaged desktop verification must exercise the real sandboxed command line; refusing ${forbiddenArgument}.`, + ); + } +} + +function prepareMacLaunch( + assetsDirectory: string, + extractionRoot: string, +): PackagedDesktopLaunchCommand { + const archive = requireSingleAsset(assetsDirectory, ".zip"); + runCommand("ditto", ["-x", "-k", archive, extractionRoot]); + const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); + if (appBundles.length !== 1) { + throw new Error(`Expected one packaged macOS app in ${basename(archive)}.`); + } + const appBundle = join(extractionRoot, appBundles[0]!); + const executables = findFiles(join(appBundle, "Contents", "MacOS"), (candidate) => + statSync(candidate).isFile(), + ); + if (executables.length !== 1) { + throw new Error(`Expected one macOS main executable, found ${executables.length}.`); + } + return { command: executables[0]!, args: [], cwd: appBundle }; +} + +export function createLinuxPackagedLaunchCommand( + appRun: string, + cwd: string, +): PackagedDesktopLaunchCommand { + return { + command: "xvfb-run", + args: ["-a", appRun, "--disable-gpu"], + cwd, + }; +} + +function prepareLinuxLaunch( + assetsDirectory: string, + extractionRoot: string, +): PackagedDesktopLaunchCommand { + const collectedAppImage = requireSingleAsset(assetsDirectory, ".AppImage"); + const appImage = join(extractionRoot, basename(collectedAppImage)); + copyFileSync(collectedAppImage, appImage); + chmodSync(appImage, 0o755); + runCommand(appImage, ["--appimage-extract"], extractionRoot); + const appRun = join(extractionRoot, "squashfs-root", "AppRun"); + if (!existsSync(appRun)) { + throw new Error(`${basename(appImage)} did not extract a runnable AppRun payload.`); + } + chmodSync(appRun, 0o755); + return createLinuxPackagedLaunchCommand(appRun, join(extractionRoot, "squashfs-root")); +} + +export function isScientWindowsExecutable(candidate: string): boolean { + return /[/\\]Scient\.exe$/i.test(candidate); +} + +function prepareWindowsLaunch( + assetsDirectory: string, + extractionRoot: string, +): PackagedDesktopLaunchCommand { + const installer = requireSingleAsset(assetsDirectory, ".exe"); + const installerRoot = join(extractionRoot, "installer"); + const applicationRoot = join(extractionRoot, "application"); + mkdirSync(installerRoot, { recursive: true }); + mkdirSync(applicationRoot, { recursive: true }); + runCommand("7z", ["x", "-y", `-o${installerRoot}`, installer]); + const applicationArchives = findFiles(installerRoot, (candidate) => + /[/\\]app-(?:32|64|arm64)\.7z$/i.test(candidate), + ); + if (applicationArchives.length !== 1) { + throw new Error( + `Expected one embedded NSIS application archive, found ${applicationArchives.length}.`, + ); + } + runCommand("7z", ["x", "-y", `-o${applicationRoot}`, applicationArchives[0]!]); + const executables = findFiles(applicationRoot, isScientWindowsExecutable); + if (executables.length !== 1) { + throw new Error(`Expected one extracted Scient.exe, found ${executables.length}.`); + } + return { command: executables[0]!, args: [], cwd: dirname(executables[0]!) }; +} + +function prepareLaunch( + options: PackagedDesktopStartupOptions, + extractionRoot: string, +): PackagedDesktopLaunchCommand { + let launch: PackagedDesktopLaunchCommand; + if (options.platform === "mac") { + launch = prepareMacLaunch(options.assetsDirectory, extractionRoot); + } else if (options.platform === "linux") { + launch = prepareLinuxLaunch(options.assetsDirectory, extractionRoot); + } else { + launch = prepareWindowsLaunch(options.assetsDirectory, extractionRoot); + } + 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: NodeJS.ProcessEnv = { + ...inheritedEnvironment, + HOME: isolatedHome, + USERPROFILE: isolatedHome, + APPDATA: join(root, "appdata"), + LOCALAPPDATA: join(root, "localappdata"), + XDG_CONFIG_HOME: join(root, "xdg-config"), + XDG_CACHE_HOME: join(root, "xdg-cache"), + XDG_DATA_HOME: join(root, "xdg-data"), + XDG_RUNTIME_DIR: join(root, "xdg-runtime"), + SCIENT_HOME: scientHome, + SYNARA_DISABLE_AUTO_UPDATE: "1", + ELECTRON_ENABLE_LOGGING: "1", + }; + delete env.SYNARA_HOME; + delete env.PAPILAB_HOME; + delete env.SYNARA_AUTH_TOKEN; + delete env.ELECTRON_RUN_AS_NODE; + + for (const path of [ + env.HOME, + env.APPDATA, + env.LOCALAPPDATA, + env.XDG_CONFIG_HOME, + env.XDG_CACHE_HOME, + env.XDG_DATA_HOME, + env.SCIENT_HOME, + ]) { + if (path) mkdirSync(path, { recursive: true }); + } + if (env.XDG_RUNTIME_DIR) { + mkdirSync(env.XDG_RUNTIME_DIR, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") chmodSync(env.XDG_RUNTIME_DIR, 0o700); + } + + if (options.platform === "mac") { + const userDataPath = join(isolatedHome, "Library", "Application Support", "scient"); + mkdirSync(userDataPath, { recursive: true }); + // Prevent the packaged app's update-only icon repair from registering this + // temporary bundle in the runner's normal Launch Services database. + const launchVersionPath = join(userDataPath, "last-launch-version.json"); + writeFileSync(launchVersionPath, `${JSON.stringify({ version: options.version }, null, 2)}\n`); + } + return env; +} + +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"); +} + +function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); + return new Promise((resolveExit) => { + const finish = (exited: boolean) => { + clearTimeout(timer); + child.off("exit", onExit); + resolveExit(exited); + }; + const onExit = () => finish(true); + const timer = setTimeout(() => finish(false), timeoutMs); + child.once("exit", onExit); + }); +} + +async function terminateProcessTree(child: ChildProcess): Promise { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }); + await waitForExit(child, 5_000); + return; + } + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + if (await waitForExit(child, 5_000)) return; + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + await waitForExit(child, 2_000); +} + +function hasStartupProof(logPath: string): boolean { + try { + const log = readFileSync(logPath, "utf8"); + return ( + log.includes("app ready") && + log.includes("bootstrap main window created") && + log.includes("bootstrap backend ready source=") + ); + } catch { + return false; + } +} + +export function resolveNativePackagedDesktopPlatform( + platform: NodeJS.Platform, +): PackagedDesktopPlatform { + if (platform === "darwin") return "mac"; + if (platform === "win32") return "win"; + return "linux"; +} + +export async function verifyPackagedDesktopStartup( + options: PackagedDesktopStartupOptions, +): Promise { + const nativePlatform = resolveNativePackagedDesktopPlatform(process.platform); + if (nativePlatform !== options.platform) { + throw new Error( + `Packaged ${options.platform} startup smoke must run on its native host, not ${process.platform}.`, + ); + } + + const temporaryRoot = mkdtempSync(join(tmpdir(), `scient-packaged-smoke-${options.platform}-`)); + const extractionRoot = join(temporaryRoot, "payload"); + mkdirSync(extractionRoot, { recursive: true }); + + let child: ChildProcess | null = null; + let output = ""; + try { + const launch = prepareLaunch(options, extractionRoot); + const env = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); + const logPath = resolvePackagedDesktopLogPath(env); + child = spawn(launch.command, [...launch.args], { + cwd: launch.cwd, + env, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + const childOutcome: { + exited: { code: number | null; signal: NodeJS.Signals | null } | null; + launchError: Error | null; + } = { 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); + + const deadline = Date.now() + options.timeoutMs; + while (Date.now() < deadline) { + if (hasStartupProof(logPath)) { + console.log( + `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, + ); + return; + } + if (childOutcome.launchError) { + throw new Error(`Packaged app could not start: ${childOutcome.launchError.message}`); + } + if (childOutcome.exited) { + throw new Error( + `Packaged app exited before startup proof (code=${childOutcome.exited.code ?? "null"}, signal=${childOutcome.exited.signal ?? "null"}).`, + ); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 200)); + } + throw new Error(`Packaged startup proof timed out after ${options.timeoutMs}ms.`); + } catch (error) { + const detail = output.trim() ? `\nPackaged process output:\n${output.trim()}` : ""; + throw new Error( + `${error instanceof Error ? error.message : String(error)}${detail}`, + error instanceof Error ? { cause: error } : undefined, + ); + } finally { + if (child) { + await terminateProcessTree(child); + } + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + await verifyPackagedDesktopStartup(parsePackagedDesktopStartupArgs(process.argv.slice(2))); +} From 967b5d35e20355b26c89062a32112dd49810d1d3 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 08:35:34 +0300 Subject: [PATCH 19/34] test(linux): forbid packaged sandbox bypass --- .../scripts/linux-appimage-smoke-support.mjs | 9 +++++++++ .../linux-appimage-smoke-support.test.mjs | 13 +++++++++++- apps/web/scripts/linux-appimage-smoke.mjs | 20 +++++++++++-------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/web/scripts/linux-appimage-smoke-support.mjs b/apps/web/scripts/linux-appimage-smoke-support.mjs index 6ea968102..b54f4ac71 100644 --- a/apps/web/scripts/linux-appimage-smoke-support.mjs +++ b/apps/web/scripts/linux-appimage-smoke-support.mjs @@ -6,6 +6,15 @@ function delay(milliseconds) { return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); } +export function assertSandboxedPackagedArguments(args) { + const bypass = args.find( + (argument) => argument === "--no-sandbox" || argument.startsWith("--no-sandbox="), + ); + if (bypass) { + throw new Error(`Packaged Linux smoke must not disable Electron's sandbox: ${bypass}`); + } +} + export async function waitFor(description, operation, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { const deadline = Date.now() + timeoutMs; let lastError; diff --git a/apps/web/scripts/linux-appimage-smoke-support.test.mjs b/apps/web/scripts/linux-appimage-smoke-support.test.mjs index 330cb6da9..3d2196c2b 100644 --- a/apps/web/scripts/linux-appimage-smoke-support.test.mjs +++ b/apps/web/scripts/linux-appimage-smoke-support.test.mjs @@ -2,9 +2,20 @@ import { createServer } from "node:http"; import { describe, expect, it } from "vitest"; -import { fetchWithinDeadline, waitFor } from "./linux-appimage-smoke-support.mjs"; +import { + assertSandboxedPackagedArguments, + fetchWithinDeadline, + waitFor, +} from "./linux-appimage-smoke-support.mjs"; describe("linux AppImage smoke polling", () => { + it("fails closed if a packaged command disables Electron's sandbox", () => { + expect(() => + assertSandboxedPackagedArguments(["--disable-gpu", "--no-sandbox"]), + ).toThrow("must not disable Electron's sandbox"); + expect(() => assertSandboxedPackagedArguments(["--disable-gpu"])).not.toThrow(); + }); + it("aborts a request that accepts a connection but never responds", async () => { const sockets = new Set(); const server = createServer(() => { diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index 21ccf0553..6913e8ec0 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -20,7 +20,11 @@ import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; -import { fetchWithinDeadline, waitFor } from "./linux-appimage-smoke-support.mjs"; +import { + assertSandboxedPackagedArguments, + fetchWithinDeadline, + waitFor, +} from "./linux-appimage-smoke-support.mjs"; const SCRIPT_DIR = fileURLToPath(new URL(".", import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, "../../.."); @@ -559,17 +563,17 @@ async function runScenario(appImagePath, scenario) { if (scenario.precreatePermissiveState) await createDirtyScientDirectories(scientHome); const debuggingPort = await reservePort(); + const packagedArguments = [ + `--remote-debugging-port=${debuggingPort}`, + "--remote-debugging-address=127.0.0.1", + "--disable-gpu", + ]; + assertSandboxedPackagedArguments(packagedArguments); const previousUmask = process.umask(scenario.umask); try { child = spawn( "xvfb-run", - [ - "-a", - appImagePath, - `--remote-debugging-port=${debuggingPort}`, - "--remote-debugging-address=127.0.0.1", - "--disable-gpu", - ], + ["-a", appImagePath, ...packagedArguments], { detached: true, env: { From 57439dd4df6a42412d21f5a24223c3dd99bb3214 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 08:45:17 +0300 Subject: [PATCH 20/34] style(desktop): format Linux launcher hardening --- apps/desktop/scripts/dev-electron.mjs | 8 ++++---- apps/desktop/scripts/electron-launcher.mjs | 6 +----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index c34c2229e..acdeaf566 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -140,10 +140,10 @@ function startApp() { return; } - const electronCommand = resolveElectronLaunchCommand([ - `--synara-dev-root=${desktopDir}`, - "dist-electron/main.js", - ], { development: true }); + const electronCommand = resolveElectronLaunchCommand( + [`--synara-dev-root=${desktopDir}`, "dist-electron/main.js"], + { development: true }, + ); const app = spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 236f2a631..f0e729ff0 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -159,11 +159,7 @@ export function isLinuxSetuidSandboxConfigured( const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); try { const sandboxStat = lstat(sandboxPath); - return ( - sandboxStat.isFile() && - sandboxStat.uid === 0 && - (sandboxStat.mode & 0o7777) === 0o4755 - ); + return sandboxStat.isFile() && sandboxStat.uid === 0 && (sandboxStat.mode & 0o7777) === 0o4755; } catch { return false; } From a3a7bd977b17cd6da1d46b242579bd8b335aeed5 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 08:45:54 +0300 Subject: [PATCH 21/34] style(release): format packaged acceptance --- .../linux-appimage-smoke-support.test.mjs | 6 ++-- apps/web/scripts/linux-appimage-smoke.mjs | 30 ++++++++----------- scripts/release-smoke.ts | 2 +- scripts/verify-packaged-desktop-startup.ts | 4 +-- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/apps/web/scripts/linux-appimage-smoke-support.test.mjs b/apps/web/scripts/linux-appimage-smoke-support.test.mjs index 3d2196c2b..033e31bab 100644 --- a/apps/web/scripts/linux-appimage-smoke-support.test.mjs +++ b/apps/web/scripts/linux-appimage-smoke-support.test.mjs @@ -10,9 +10,9 @@ import { describe("linux AppImage smoke polling", () => { it("fails closed if a packaged command disables Electron's sandbox", () => { - expect(() => - assertSandboxedPackagedArguments(["--disable-gpu", "--no-sandbox"]), - ).toThrow("must not disable Electron's sandbox"); + expect(() => assertSandboxedPackagedArguments(["--disable-gpu", "--no-sandbox"])).toThrow( + "must not disable Electron's sandbox", + ); expect(() => assertSandboxedPackagedArguments(["--disable-gpu"])).not.toThrow(); }); diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index 6913e8ec0..489bc2325 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -571,24 +571,20 @@ async function runScenario(appImagePath, scenario) { assertSandboxedPackagedArguments(packagedArguments); const previousUmask = process.umask(scenario.umask); try { - child = spawn( - "xvfb-run", - ["-a", appImagePath, ...packagedArguments], - { - detached: true, - env: { - ...process.env, - HOME: homeDir, - SCIENT_HOME: scientHome, - SYNARA_DISABLE_AUTO_UPDATE: "1", - XDG_CACHE_HOME: cacheHome, - XDG_CONFIG_HOME: configHome, - XDG_DATA_HOME: dataHome, - XDG_RUNTIME_DIR: runtimeDir, - }, - stdio: ["ignore", "pipe", "pipe"], + child = spawn("xvfb-run", ["-a", appImagePath, ...packagedArguments], { + detached: true, + env: { + ...process.env, + HOME: homeDir, + SCIENT_HOME: scientHome, + SYNARA_DISABLE_AUTO_UPDATE: "1", + XDG_CACHE_HOME: cacheHome, + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: dataHome, + XDG_RUNTIME_DIR: runtimeDir, }, - ); + stdio: ["ignore", "pipe", "pipe"], + }); } finally { process.umask(previousUmask); } diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 33561e1e3..b43f130f1 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -313,7 +313,7 @@ function verifyReleaseWorkflowSafety(): void { ); assertContains( packagedStartupVerifier, - 'delete env.SYNARA_AUTH_TOKEN;\n delete env.ELECTRON_RUN_AS_NODE;', + "delete env.SYNARA_AUTH_TOKEN;\n delete env.ELECTRON_RUN_AS_NODE;", "Expected packaged startup verification to remove inherited backend authority and Electron Node mode.", ); assertContains( diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 81810668b..674109344 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -127,9 +127,7 @@ export interface PackagedDesktopLaunchCommand { readonly cwd: string; } -export function assertPackagedLaunchCommandSafety( - launch: PackagedDesktopLaunchCommand, -): void { +export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchCommand): void { const forbiddenArgument = launch.args.find( (argument) => argument === "--no-sandbox" || argument.startsWith("--no-sandbox="), ); From d0a14407f57b4e797458a6bbdcffc2a6ffa80614 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 09:02:17 +0300 Subject: [PATCH 22/34] fix(release): sanitize packaged smoke environment --- scripts/release-smoke.ts | 9 +++++++-- scripts/verify-packaged-desktop-startup.test.ts | 10 ++++------ scripts/verify-packaged-desktop-startup.ts | 12 ++++++------ 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index b43f130f1..7c1a665ef 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -313,8 +313,13 @@ function verifyReleaseWorkflowSafety(): void { ); assertContains( packagedStartupVerifier, - "delete env.SYNARA_AUTH_TOKEN;\n delete env.ELECTRON_RUN_AS_NODE;", - "Expected packaged startup verification to remove inherited backend authority and Electron Node mode.", + 'name.endsWith("_AUTH_TOKEN") || name.endsWith("_HOME")', + "Expected packaged startup verification to remove inherited product homes and authentication tokens.", + ); + assertContains( + packagedStartupVerifier, + "delete env.ELECTRON_RUN_AS_NODE;", + "Expected packaged startup verification to remove inherited Electron Node mode.", ); assertContains( packagedStartupVerifier, diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index fcc0b7e00..9b2f1fdd1 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -69,16 +69,14 @@ describe("packaged desktop startup verification", () => { { PATH: process.env.PATH, SCIENT_HOME: "/must/not/leak", - SYNARA_HOME: "/must/not/leak-either", - PAPILAB_HOME: "/must/not/leak-either", - SYNARA_AUTH_TOKEN: "must-not-leak", + LEGACY_PRODUCT_HOME: "/must/not/leak-either", + PROVIDER_AUTH_TOKEN: "must-not-leak", ELECTRON_RUN_AS_NODE: "1", }, ); - expect(env.SYNARA_HOME).toBeUndefined(); - expect(env.PAPILAB_HOME).toBeUndefined(); - expect(env.SYNARA_AUTH_TOKEN).toBeUndefined(); + expect(env.LEGACY_PRODUCT_HOME).toBeUndefined(); + expect(env.PROVIDER_AUTH_TOKEN).toBeUndefined(); expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); for (const name of [ "HOME", diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index 674109344..ca07aff4a 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -239,8 +239,11 @@ export function createPackagedDesktopSmokeEnvironment( ): NodeJS.ProcessEnv { const isolatedHome = join(root, "home"); const scientHome = join(root, "scient-home"); - const env: NodeJS.ProcessEnv = { - ...inheritedEnvironment, + const env: NodeJS.ProcessEnv = { ...inheritedEnvironment }; + for (const name of Object.keys(env)) { + if (name.endsWith("_AUTH_TOKEN") || name.endsWith("_HOME")) delete env[name]; + } + Object.assign(env, { HOME: isolatedHome, USERPROFILE: isolatedHome, APPDATA: join(root, "appdata"), @@ -252,10 +255,7 @@ export function createPackagedDesktopSmokeEnvironment( SCIENT_HOME: scientHome, SYNARA_DISABLE_AUTO_UPDATE: "1", ELECTRON_ENABLE_LOGGING: "1", - }; - delete env.SYNARA_HOME; - delete env.PAPILAB_HOME; - delete env.SYNARA_AUTH_TOKEN; + }); delete env.ELECTRON_RUN_AS_NODE; for (const path of [ From 09e114726458b36507fe09edf57f9ddfab350b24 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 09:36:08 +0300 Subject: [PATCH 23/34] fix(release): preserve AppImage sandbox --- scripts/build-desktop-artifact-mac-config.test.ts | 3 +++ scripts/lib/desktop-platform-build-config.ts | 6 ++++++ scripts/release-smoke.ts | 10 +++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/build-desktop-artifact-mac-config.test.ts b/scripts/build-desktop-artifact-mac-config.test.ts index 42b978772..938d21739 100644 --- a/scripts/build-desktop-artifact-mac-config.test.ts +++ b/scripts/build-desktop-artifact-mac-config.test.ts @@ -85,6 +85,9 @@ describe("createDesktopPlatformBuildConfig", () => { assert.equal(linux.mac, undefined); assert.equal(linux.extraFiles, undefined); assert.deepStrictEqual(linux.asarUnpack, ["node_modules/node-pty/**"]); + assert.deepStrictEqual(linux.appImage, { + executableArgs: [], + }); assert.deepStrictEqual(linux.linux, { target: ["AppImage"], executableName: "scient", diff --git a/scripts/lib/desktop-platform-build-config.ts b/scripts/lib/desktop-platform-build-config.ts index 711af1905..490dffc09 100644 --- a/scripts/lib/desktop-platform-build-config.ts +++ b/scripts/lib/desktop-platform-build-config.ts @@ -19,6 +19,7 @@ export const NODE_PTY_ASAR_UNPACK_GLOBS = ["node_modules/node-pty/**"] as const; export interface DesktopPlatformBuildConfig { readonly afterPack?: string; + readonly appImage?: Record; readonly asarUnpack?: ReadonlyArray; readonly extraFiles?: ReadonlyArray>; readonly files?: ReadonlyArray; @@ -102,6 +103,11 @@ export function createDesktopPlatformBuildConfig( if (input.platform === "linux") { return { ...nativePackaging, + // electron-builder's legacy AppImage target otherwise injects --no-sandbox + // into AppRun. An explicit empty list preserves Chromium's real sandbox. + appImage: { + executableArgs: [], + }, linux: { target: [input.target], executableName: "scient", diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 11c0cc2ec..975c6f23e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -121,7 +121,11 @@ 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 linuxBuildConfig = createDesktopPlatformBuildConfig({ + platform: "linux", + target: "AppImage", + }); + const linux = linuxBuildConfig.linux; if (!linux || linux.executableName !== "scient") { throw new Error("Expected Linux desktop releases to install the scient executable."); } @@ -133,6 +137,10 @@ function verifyCanonicalIdentity(): void { if (linux.syncDesktopName !== true) { throw new Error("Expected Linux desktop releases to synchronize the desktop entry name."); } + const appImageExecutableArgs = linuxBuildConfig.appImage?.executableArgs; + if (!Array.isArray(appImageExecutableArgs) || appImageExecutableArgs.length !== 0) { + throw new Error("Expected AppImage packaging to preserve Chromium's sandboxed command line."); + } const releasePolicy = readReleaseUpdatePolicyConfig(repoRoot); const resolvedPolicy = resolveReleaseUpdatePolicy("9.9.9", releasePolicy); From ba7b8274fbd5752a2d2073f54f0431e989cbd4c6 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 09:41:47 +0300 Subject: [PATCH 24/34] revert(release): keep AppImage migration out of launcher hardening --- scripts/build-desktop-artifact-mac-config.test.ts | 3 --- scripts/lib/desktop-platform-build-config.ts | 6 ------ scripts/release-smoke.ts | 10 +--------- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/scripts/build-desktop-artifact-mac-config.test.ts b/scripts/build-desktop-artifact-mac-config.test.ts index 938d21739..42b978772 100644 --- a/scripts/build-desktop-artifact-mac-config.test.ts +++ b/scripts/build-desktop-artifact-mac-config.test.ts @@ -85,9 +85,6 @@ describe("createDesktopPlatformBuildConfig", () => { assert.equal(linux.mac, undefined); assert.equal(linux.extraFiles, undefined); assert.deepStrictEqual(linux.asarUnpack, ["node_modules/node-pty/**"]); - assert.deepStrictEqual(linux.appImage, { - executableArgs: [], - }); assert.deepStrictEqual(linux.linux, { target: ["AppImage"], executableName: "scient", diff --git a/scripts/lib/desktop-platform-build-config.ts b/scripts/lib/desktop-platform-build-config.ts index 490dffc09..711af1905 100644 --- a/scripts/lib/desktop-platform-build-config.ts +++ b/scripts/lib/desktop-platform-build-config.ts @@ -19,7 +19,6 @@ export const NODE_PTY_ASAR_UNPACK_GLOBS = ["node_modules/node-pty/**"] as const; export interface DesktopPlatformBuildConfig { readonly afterPack?: string; - readonly appImage?: Record; readonly asarUnpack?: ReadonlyArray; readonly extraFiles?: ReadonlyArray>; readonly files?: ReadonlyArray; @@ -103,11 +102,6 @@ export function createDesktopPlatformBuildConfig( if (input.platform === "linux") { return { ...nativePackaging, - // electron-builder's legacy AppImage target otherwise injects --no-sandbox - // into AppRun. An explicit empty list preserves Chromium's real sandbox. - appImage: { - executableArgs: [], - }, linux: { target: [input.target], executableName: "scient", diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 975c6f23e..11c0cc2ec 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -121,11 +121,7 @@ function verifyCanonicalIdentity(): void { throw new Error("Expected packaged Scient clients to use the approved update channel."); } - const linuxBuildConfig = createDesktopPlatformBuildConfig({ - platform: "linux", - target: "AppImage", - }); - const linux = linuxBuildConfig.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."); } @@ -137,10 +133,6 @@ function verifyCanonicalIdentity(): void { if (linux.syncDesktopName !== true) { throw new Error("Expected Linux desktop releases to synchronize the desktop entry name."); } - const appImageExecutableArgs = linuxBuildConfig.appImage?.executableArgs; - if (!Array.isArray(appImageExecutableArgs) || appImageExecutableArgs.length !== 0) { - throw new Error("Expected AppImage packaging to preserve Chromium's sandboxed command line."); - } const releasePolicy = readReleaseUpdatePolicyConfig(repoRoot); const resolvedPolicy = resolveReleaseUpdatePolicy("9.9.9", releasePolicy); From 2bcce8bd9f412c6170d14f35b35780641dc875e7 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 09:52:15 +0300 Subject: [PATCH 25/34] test(release): fail closed on packaged startup proof --- apps/desktop/src/main.ts | 10 + apps/web/scripts/linux-appimage-smoke.mjs | 5 +- scripts/release-smoke.ts | 51 ++- .../verify-packaged-desktop-startup.test.ts | 161 ++++++++- scripts/verify-packaged-desktop-startup.ts | 339 ++++++++++++++---- 5 files changed, 478 insertions(+), 88 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 05edb5c21..a71fbede4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3474,9 +3474,19 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); }); window.webContents.on("did-finish-load", () => { + writeDesktopLogHeader("renderer main frame loaded"); window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedUrl, isMainFrame) => { + if (!isMainFrame) return; + writeDesktopLogHeader( + `renderer main frame load failed code=${errorCode} url=${validatedUrl} message=${errorDescription}`, + ); + }, + ); window.once("ready-to-show", () => { // Preserve the original first-launch behavior, then respect the state saved // by subsequent closes. Normal bounds are restored before maximizing so the diff --git a/apps/web/scripts/linux-appimage-smoke.mjs b/apps/web/scripts/linux-appimage-smoke.mjs index 489bc2325..aaf30b86d 100644 --- a/apps/web/scripts/linux-appimage-smoke.mjs +++ b/apps/web/scripts/linux-appimage-smoke.mjs @@ -20,6 +20,8 @@ import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; +import { sanitizePackagedDesktopInheritedEnvironment } from "../../../scripts/verify-packaged-desktop-startup.ts"; + import { assertSandboxedPackagedArguments, fetchWithinDeadline, @@ -574,7 +576,7 @@ async function runScenario(appImagePath, scenario) { child = spawn("xvfb-run", ["-a", appImagePath, ...packagedArguments], { detached: true, env: { - ...process.env, + ...sanitizePackagedDesktopInheritedEnvironment(process.env), HOME: homeDir, SCIENT_HOME: scientHome, SYNARA_DISABLE_AUTO_UPDATE: "1", @@ -603,6 +605,7 @@ async function runScenario(appImagePath, scenario) { browser = renderer.browser; page = renderer.page; electronProcess = await findPackagedElectronProcess(child.pid, debuggingPort); + assertSandboxedPackagedArguments(electronProcess.commandLine); page.on("console", (message) => { recordOutput(`\n[renderer:${message.type()}] ${message.text()}`); }); diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 7c1a665ef..209a52d91 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -305,7 +305,7 @@ function verifyReleaseWorkflowSafety(): void { const packagedStartupVerifier = readFileSync( resolve(repoRoot, "scripts/verify-packaged-desktop-startup.ts"), "utf8", - ); + ).replaceAll("\r\n", "\n"); assertContains( packagedStartupVerifier, "SCIENT_HOME: scientHome", @@ -313,13 +313,8 @@ function verifyReleaseWorkflowSafety(): void { ); assertContains( packagedStartupVerifier, - 'name.endsWith("_AUTH_TOKEN") || name.endsWith("_HOME")', - "Expected packaged startup verification to remove inherited product homes and authentication tokens.", - ); - assertContains( - packagedStartupVerifier, - "delete env.ELECTRON_RUN_AS_NODE;", - "Expected packaged startup verification to remove inherited Electron Node mode.", + "PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST", + "Expected packaged startup verification to inherit only explicitly allowed native host variables.", ); assertContains( packagedStartupVerifier, @@ -345,6 +340,46 @@ function verifyReleaseWorkflowSafety(): void { "assertPackagedLaunchCommandSafety(launch);", "Expected every packaged platform command to fail closed if sandbox bypass is introduced.", ); + assertContains( + packagedStartupVerifier, + 'log.includes("renderer main frame loaded")', + "Expected cross-platform packaged startup proof to require successful renderer loading.", + ); + + const packagedLinuxLifecycleSmoke = readFileSync( + resolve(repoRoot, "apps/web/scripts/linux-appimage-smoke.mjs"), + "utf8", + ).replaceAll("\r\n", "\n"); + assertContains( + packagedLinuxLifecycleSmoke, + "...sanitizePackagedDesktopInheritedEnvironment(process.env)", + "Expected deep packaged Linux verification to use the same inherited-environment allowlist.", + ); + assertNotContains( + packagedLinuxLifecycleSmoke, + "...process.env", + "Deep packaged Linux verification must not inherit credentials or developer runtime overrides.", + ); + assertContains( + packagedLinuxLifecycleSmoke, + "assertSandboxedPackagedArguments(electronProcess.commandLine);", + "Expected packaged Linux acceptance to validate the actual mounted Electron process arguments.", + ); + + const desktopMain = readFileSync( + resolve(repoRoot, "apps/desktop/src/main.ts"), + "utf8", + ).replaceAll("\r\n", "\n"); + assertContains( + desktopMain, + 'writeDesktopLogHeader("renderer main frame loaded")', + "Expected the desktop process to record successful renderer main-frame loading.", + ); + assertContains( + desktopMain, + '"did-fail-load"', + "Expected renderer main-frame load failures to be recorded for packaged diagnostics.", + ); } function verifyDesktopStageLockAuthority(): void { diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts index 9b2f1fdd1..e9a259378 100644 --- a/scripts/verify-packaged-desktop-startup.test.ts +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -1,17 +1,23 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import type { ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { assertPackagedLaunchCommandSafety, createLinuxPackagedLaunchCommand, createPackagedDesktopSmokeEnvironment, + expectedPackagedDesktopStartupAssetName, isScientWindowsExecutable, parsePackagedDesktopStartupArgs, + resolveExactPackagedDesktopStartupAsset, resolveNativePackagedDesktopPlatform, resolvePackagedDesktopLogPath, + sanitizePackagedDesktopInheritedEnvironment, + terminateProcessTree, + waitForPackagedStartupProof, } from "./verify-packaged-desktop-startup.ts"; const temporaryRoots: string[] = []; @@ -67,7 +73,11 @@ describe("packaged desktop startup verification", () => { root, { platform: "linux", 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", @@ -76,8 +86,12 @@ describe("packaged desktop startup verification", () => { ); expect(env.LEGACY_PRODUCT_HOME).toBeUndefined(); + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.OPENAI_API_KEY).toBeUndefined(); expect(env.PROVIDER_AUTH_TOKEN).toBeUndefined(); + expect(env.SCIENT_DEV_ALLOW_NO_SANDBOX).toBeUndefined(); expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(env.DISPLAY).toBe(":99"); for (const name of [ "HOME", "USERPROFILE", @@ -100,6 +114,149 @@ describe("packaged desktop startup verification", () => { ); }); + 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("linux", "x64", "1.2.3")).toBe( + "Scient-1.2.3-x86_64.AppImage", + ); + 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-x86_64.AppImage"); + writeFileSync(expected, "payload"); + expect(resolveExactPackagedDesktopStartupAsset(root, "Scient-1.2.3-x86_64.AppImage")).toBe( + expected, + ); + + writeFileSync(join(root, "Scient-1.2.2-x86_64.AppImage"), "stale payload"); + expect(() => + resolveExactPackagedDesktopStartupAsset(root, "Scient-1.2.3-x86_64.AppImage"), + ).toThrow("found 2 .AppImage payloads"); + }); + + it("does not accept proof from a packaged process that exits immediately", async () => { + let now = 0; + let outcome = { exited: null, launchError: null } as { + exited: { code: number | null; signal: NodeJS.Signals | null } | null; + launchError: Error | null; + }; + + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => outcome, + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + outcome = { exited: { code: 1, signal: null }, launchError: null }; + }, + }), + ).rejects.toThrow("exited before stable startup proof"); + }); + + it("accepts startup proof only after the process remains alive for the stability window", async () => { + let now = 0; + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => ({ exited: null, launchError: null }), + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + }, + }), + ).resolves.toBeUndefined(); + expect(now).toBeGreaterThanOrEqual(1_000); + }); + + it("fails when Windows process-tree cleanup cannot prove exit", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 1 })); + await expect( + terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived Windows cleanup"); + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42, 84]); + }); + + it("still cleans a detached Windows backend after the packaged root exits", async () => { + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + + await terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => true, + }, + [84], + ); + + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([84]); + }); + + it("fails when a POSIX process tree survives TERM and KILL", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + await expect( + terminateProcessTree( + child, + { + platform: "linux", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived SIGTERM and SIGKILL"); + expect(signals).toEqual([ + { pid: 42, signal: "SIGTERM" }, + { pid: 84, signal: "SIGTERM" }, + { pid: 42, signal: "SIGKILL" }, + { pid: 84, signal: "SIGKILL" }, + ]); + }); + it("prepares the isolated Scient macOS profile marker", () => { const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-mac-env-test-")); temporaryRoots.push(root); diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts index ca07aff4a..d98ddbdd3 100644 --- a/scripts/verify-packaged-desktop-startup.ts +++ b/scripts/verify-packaged-desktop-startup.ts @@ -108,15 +108,36 @@ function findFiles(root: string, predicate: (path: string) => boolean): string[] } } } - return matches.sort((left, right) => left.localeCompare(right)); + return matches.toSorted((left, right) => left.localeCompare(right)); } -function requireSingleAsset(directory: string, suffix: string): string { - const matches = readdirSync(directory) - .map((entry) => join(directory, entry)) - .filter((candidate) => statSync(candidate).isFile() && candidate.endsWith(suffix)); +export function expectedPackagedDesktopStartupAssetName( + platform: PackagedDesktopPlatform, + arch: string, + version: string, +): string { + const artifactArch = platform === "linux" && arch === "x64" ? "x86_64" : arch; + const extension = platform === "mac" ? ".zip" : platform === "linux" ? ".AppImage" : ".exe"; + return `Scient-${version}-${artifactArch}${extension}`; +} + +export function resolveExactPackagedDesktopStartupAsset( + directory: string, + expectedName: string, +): string { + const suffix = expectedName.slice(expectedName.lastIndexOf(".")); + const matches = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(suffix)) + .map((entry) => join(directory, entry.name)); if (matches.length !== 1) { - throw new Error(`Expected one ${suffix} release asset, found ${matches.length}.`); + 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]!; } @@ -141,8 +162,9 @@ export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchC function prepareMacLaunch( assetsDirectory: string, extractionRoot: string, + expectedAssetName: string, ): PackagedDesktopLaunchCommand { - const archive = requireSingleAsset(assetsDirectory, ".zip"); + const archive = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); runCommand("ditto", ["-x", "-k", archive, extractionRoot]); const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); if (appBundles.length !== 1) { @@ -172,8 +194,12 @@ export function createLinuxPackagedLaunchCommand( function prepareLinuxLaunch( assetsDirectory: string, extractionRoot: string, + expectedAssetName: string, ): PackagedDesktopLaunchCommand { - const collectedAppImage = requireSingleAsset(assetsDirectory, ".AppImage"); + const collectedAppImage = resolveExactPackagedDesktopStartupAsset( + assetsDirectory, + expectedAssetName, + ); const appImage = join(extractionRoot, basename(collectedAppImage)); copyFileSync(collectedAppImage, appImage); chmodSync(appImage, 0o755); @@ -193,8 +219,9 @@ export function isScientWindowsExecutable(candidate: string): boolean { function prepareWindowsLaunch( assetsDirectory: string, extractionRoot: string, + expectedAssetName: string, ): PackagedDesktopLaunchCommand { - const installer = requireSingleAsset(assetsDirectory, ".exe"); + const installer = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); const installerRoot = join(extractionRoot, "installer"); const applicationRoot = join(extractionRoot, "application"); mkdirSync(installerRoot, { recursive: true }); @@ -220,13 +247,18 @@ function prepareLaunch( options: PackagedDesktopStartupOptions, extractionRoot: string, ): PackagedDesktopLaunchCommand { + const expectedAssetName = expectedPackagedDesktopStartupAssetName( + options.platform, + options.arch, + options.version, + ); let launch: PackagedDesktopLaunchCommand; if (options.platform === "mac") { - launch = prepareMacLaunch(options.assetsDirectory, extractionRoot); + launch = prepareMacLaunch(options.assetsDirectory, extractionRoot, expectedAssetName); } else if (options.platform === "linux") { - launch = prepareLinuxLaunch(options.assetsDirectory, extractionRoot); + launch = prepareLinuxLaunch(options.assetsDirectory, extractionRoot, expectedAssetName); } else { - launch = prepareWindowsLaunch(options.assetsDirectory, extractionRoot); + launch = prepareWindowsLaunch(options.assetsDirectory, extractionRoot, expectedAssetName); } assertPackagedLaunchCommandSafety(launch); return launch; @@ -239,10 +271,7 @@ export function createPackagedDesktopSmokeEnvironment( ): NodeJS.ProcessEnv { const isolatedHome = join(root, "home"); const scientHome = join(root, "scient-home"); - const env: NodeJS.ProcessEnv = { ...inheritedEnvironment }; - for (const name of Object.keys(env)) { - if (name.endsWith("_AUTH_TOKEN") || name.endsWith("_HOME")) delete env[name]; - } + const env = sanitizePackagedDesktopInheritedEnvironment(inheritedEnvironment); Object.assign(env, { HOME: isolatedHome, USERPROFILE: isolatedHome, @@ -256,8 +285,6 @@ export function createPackagedDesktopSmokeEnvironment( SYNARA_DISABLE_AUTO_UPDATE: "1", ELECTRON_ENABLE_LOGGING: "1", }); - delete env.ELECTRON_RUN_AS_NODE; - for (const path of [ env.HOME, env.APPDATA, @@ -285,48 +312,153 @@ export function createPackagedDesktopSmokeEnvironment( 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"); } -function waitForExit(child: ChildProcess, timeoutMs: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); +export interface ProcessTerminationTarget { + readonly pid: number; + readonly processGroup: boolean; +} + +export interface ProcessTerminationDependencies { + readonly platform?: NodeJS.Platform; + readonly runTaskkill?: (pid: number) => { + readonly error?: Error; + readonly status: number | null; + }; + readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; + readonly waitForTargetsExit?: ( + targets: ReadonlyArray, + timeoutMs: number, + ) => Promise; +} + +function processTerminationTargetIsAlive(target: ProcessTerminationTarget): boolean { + try { + process.kill(target.processGroup ? -target.pid : target.pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function waitForProcessTerminationTargets( + targets: ReadonlyArray, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; return new Promise((resolveExit) => { - const finish = (exited: boolean) => { - clearTimeout(timer); - child.off("exit", onExit); - resolveExit(exited); + const poll = () => { + if (targets.every((target) => !processTerminationTargetIsAlive(target))) { + resolveExit(true); + return; + } + if (Date.now() >= deadline) { + resolveExit(false); + return; + } + setTimeout(poll, 100); }; - const onExit = () => finish(true); - const timer = setTimeout(() => finish(false), timeoutMs); - child.once("exit", onExit); + poll(); }); } -async function terminateProcessTree(child: ChildProcess): Promise { - if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; - if (process.platform === "win32") { - spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { - stdio: "ignore", - windowsHide: true, - }); - await waitForExit(child, 5_000); - return; - } +function sendProcessTreeSignal(target: ProcessTerminationTarget, signal: NodeJS.Signals): void { try { - process.kill(-child.pid, "SIGTERM"); - } catch { - child.kill("SIGTERM"); + process.kill(target.processGroup ? -target.pid : target.pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } - if (await waitForExit(child, 5_000)) return; - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - child.kill("SIGKILL"); +} + +export async function terminateProcessTree( + child: ChildProcess, + dependencies: ProcessTerminationDependencies = {}, + additionalProcessIds: ReadonlyArray = [], +): Promise { + const platform = dependencies.platform ?? process.platform; + const childCanStillOwnProcesses = + platform !== "win32" || (child.exitCode === null && child.signalCode === null); + const targets = [ + ...(child.pid && childCanStillOwnProcesses + ? [{ pid: child.pid, processGroup: platform !== "win32" }] + : []), + ...additionalProcessIds.map((pid) => ({ pid, processGroup: platform !== "win32" })), + ].filter( + (target, index, allTargets) => + target.pid > 0 && allTargets.findIndex((candidate) => candidate.pid === target.pid) === index, + ); + if (targets.length === 0) return; + const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; + if (platform === "win32") { + const taskkillResults = targets.map((target) => ({ + pid: target.pid, + result: + dependencies.runTaskkill?.(target.pid) ?? + spawnSync("taskkill", ["/pid", String(target.pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }), + })); + if (await awaitTargetsExit(targets, 5_000)) return; + const taskkillResult = taskkillResults + .map(({ pid, result }) => + result.error + ? `${pid}: could not start (${result.error.message})` + : `${pid}: status ${result.status ?? "unknown"}`, + ) + .join(", "); + throw new Error( + `Packaged process trees survived Windows cleanup; taskkill results: ${taskkillResult}.`, + ); } - await waitForExit(child, 2_000); + const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; + for (const target of targets) sendSignal(target, "SIGTERM"); + if (await awaitTargetsExit(targets, 5_000)) return; + for (const target of targets) sendSignal(target, "SIGKILL"); + if (await awaitTargetsExit(targets, 2_000)) return; + throw new Error( + `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived SIGTERM and SIGKILL.`, + ); } function hasStartupProof(logPath: string): boolean { @@ -335,6 +467,7 @@ function hasStartupProof(logPath: string): boolean { return ( log.includes("app ready") && log.includes("bootstrap main window created") && + log.includes("renderer main frame loaded") && log.includes("bootstrap backend ready source=") ); } catch { @@ -342,6 +475,65 @@ function hasStartupProof(logPath: string): boolean { } } +function readPackagedBackendProcessId(environment: NodeJS.ProcessEnv | null): number | null { + const scientHome = environment?.SCIENT_HOME; + if (!scientHome) return null; + try { + const state = JSON.parse( + readFileSync(join(scientHome, "userdata", "server-runtime.json"), "utf8"), + ) as { readonly pid?: unknown }; + return Number.isInteger(state.pid) && Number(state.pid) > 0 ? Number(state.pid) : null; + } catch { + return null; + } +} + +export interface PackagedDesktopChildOutcome { + readonly exited: { readonly code: number | null; readonly signal: NodeJS.Signals | null } | null; + readonly launchError: Error | null; +} + +interface PackagedStartupProofWaitOptions { + readonly timeoutMs: number; + readonly hasProof: () => boolean; + readonly readOutcome: () => PackagedDesktopChildOutcome; + readonly now?: () => number; + readonly delay?: (milliseconds: number) => Promise; + readonly stableForMs?: number; +} + +export async function waitForPackagedStartupProof({ + timeoutMs, + hasProof, + readOutcome, + now = Date.now, + delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)), + stableForMs = 1_000, +}: PackagedStartupProofWaitOptions): Promise { + const deadline = now() + timeoutMs; + let proofObservedAt: number | null = null; + while (now() < deadline) { + const outcome = readOutcome(); + if (outcome.launchError) { + throw new Error(`Packaged app could not start: ${outcome.launchError.message}`); + } + if (outcome.exited) { + throw new Error( + `Packaged app exited before stable startup proof (code=${outcome.exited.code ?? "null"}, signal=${outcome.exited.signal ?? "null"}).`, + ); + } + const currentTime = now(); + if (hasProof()) { + proofObservedAt ??= currentTime; + if (currentTime - proofObservedAt >= stableForMs) return; + } else { + proofObservedAt = null; + } + await delay(Math.min(200, Math.max(1, deadline - currentTime))); + } + throw new Error(`Packaged startup proof timed out after ${timeoutMs}ms.`); +} + export function resolveNativePackagedDesktopPlatform( platform: NodeJS.Platform, ): PackagedDesktopPlatform { @@ -365,22 +557,23 @@ export async function verifyPackagedDesktopStartup( mkdirSync(extractionRoot, { recursive: true }); let child: ChildProcess | null = null; + let environment: NodeJS.ProcessEnv | null = null; let output = ""; try { const launch = prepareLaunch(options, extractionRoot); - const env = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); - const logPath = resolvePackagedDesktopLogPath(env); + environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); + const logPath = resolvePackagedDesktopLogPath(environment); child = spawn(launch.command, [...launch.args], { cwd: launch.cwd, - env, + env: environment, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); const childOutcome: { - exited: { code: number | null; signal: NodeJS.Signals | null } | null; - launchError: Error | null; + exited: PackagedDesktopChildOutcome["exited"]; + launchError: PackagedDesktopChildOutcome["launchError"]; } = { exited: null, launchError: null }; child.once("exit", (code, signal) => { childOutcome.exited = { code, signal }; @@ -394,36 +587,28 @@ export async function verifyPackagedDesktopStartup( child.stdout?.on("data", recordOutput); child.stderr?.on("data", recordOutput); - const deadline = Date.now() + options.timeoutMs; - while (Date.now() < deadline) { - if (hasStartupProof(logPath)) { - console.log( - `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, - ); - return; - } - if (childOutcome.launchError) { - throw new Error(`Packaged app could not start: ${childOutcome.launchError.message}`); - } - if (childOutcome.exited) { - throw new Error( - `Packaged app exited before startup proof (code=${childOutcome.exited.code ?? "null"}, signal=${childOutcome.exited.signal ?? "null"}).`, - ); - } - await new Promise((resolveDelay) => setTimeout(resolveDelay, 200)); - } - throw new Error(`Packaged startup proof timed out after ${options.timeoutMs}ms.`); + await waitForPackagedStartupProof({ + timeoutMs: options.timeoutMs, + hasProof: () => hasStartupProof(logPath), + readOutcome: () => childOutcome, + }); + console.log( + `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, + ); } catch (error) { const detail = output.trim() ? `\nPackaged process output:\n${output.trim()}` : ""; - throw new Error( - `${error instanceof Error ? error.message : String(error)}${detail}`, - error instanceof Error ? { cause: error } : undefined, - ); + throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`, { + cause: error, + }); } finally { - if (child) { - await terminateProcessTree(child); + try { + if (child) { + const backendProcessId = readPackagedBackendProcessId(environment); + await terminateProcessTree(child, {}, backendProcessId === null ? [] : [backendProcessId]); + } + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); } - rmSync(temporaryRoot, { recursive: true, force: true }); } } From ee3a38c6b316c90174c9920461e4562e9d6332f4 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 09:54:25 +0300 Subject: [PATCH 26/34] fix(desktop): honor Linux user namespace sandbox --- apps/desktop/scripts/electron-launcher.mjs | 28 +++++++++++-- .../scripts/electron-launcher.test.mjs | 40 +++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index f0e729ff0..3c3075ba2 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -165,11 +165,29 @@ export function isLinuxSetuidSandboxConfigured( } } +export function isLinuxUserNamespaceSandboxAvailable({ + platform = process.platform, + runUnshare = spawnSync, +} = {}) { + if (platform !== "linux") { + return true; + } + + const result = runUnshare("unshare", ["-Ur", "true"], { + shell: false, + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + return !result.error && result.status === 0; +} + export class LinuxSandboxConfigurationError extends Error { constructor(sandboxPath) { super( - `Electron sandbox helper ${sandboxPath} must be a regular file owned by root with exact mode 4755. ` + - "Repair the helper before launching Scient. For an isolated local development session only, " + + `Electron needs either unprivileged user namespaces or a sandbox helper at ${sandboxPath} ` + + "that is a regular file owned by root with exact mode 4755. Enable one of those sandbox paths " + + "before launching Scient. For an isolated local development session only, " + "set SCIENT_DEV_ALLOW_NO_SANDBOX=1 to accept the unsafe fallback explicitly.", ); this.name = "LinuxSandboxConfigurationError"; @@ -182,12 +200,16 @@ export function resolveLinuxSandboxArgs( { platform = process.platform, lstat = lstatSync, + runUnshare = spawnSync, development = isDevelopment, env = process.env, warn = console.warn, } = {}, ) { - if (isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat })) { + if ( + isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat }) || + isLinuxUserNamespaceSandboxAvailable({ platform, runUnshare }) + ) { return []; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 28c7f95bd..b5d9e6b7c 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { isLinuxSetuidSandboxConfigured, + isLinuxUserNamespaceSandboxAvailable, LinuxSandboxConfigurationError, resolveLinuxSandboxArgs, } from "./electron-launcher.mjs"; @@ -12,17 +13,46 @@ const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox"; describe("Linux Electron sandbox launch policy", () => { it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => { const lstat = vi.fn(); + const runUnshare = vi.fn(); - expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat })).toEqual([]); + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat, runUnshare }), + ).toEqual([]); expect(lstat).not.toHaveBeenCalled(); + expect(runUnshare).not.toHaveBeenCalled(); }); it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => { const lstat = vi.fn(() => ({ isFile: () => true, mode: 0o104755, uid: 0 })); + const runUnshare = vi.fn(); expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", lstat })).toBe(true); - expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat })).toEqual([]); + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat, runUnshare }), + ).toEqual([]); expect(lstat).toHaveBeenCalledWith(SANDBOX_PATH); + expect(runUnshare).not.toHaveBeenCalled(); + }); + + it("keeps Chromium's sandbox when unprivileged user namespaces work without a helper", () => { + const runUnshare = vi.fn(() => ({ status: 0 })); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => { + throw new Error("ENOENT"); + }, + runUnshare, + }), + ).toEqual([]); + expect(runUnshare).toHaveBeenCalledWith("unshare", ["-Ur", "true"], { + shell: false, + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + expect(isLinuxUserNamespaceSandboxAvailable({ platform: "linux", runUnshare })).toBe(true); }); it.each([ @@ -36,17 +66,19 @@ describe("Linux Electron sandbox launch policy", () => { resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat: () => metadata, + runUnshare: () => ({ status: 1 }), }), ).toThrow(LinuxSandboxConfigurationError); }); - it("fails closed when chrome-sandbox is missing", () => { + it("fails closed when both chrome-sandbox and unprivileged user namespaces are unavailable", () => { expect(() => resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat: () => { throw new Error("ENOENT"); }, + runUnshare: () => ({ status: 1 }), }), ).toThrow(expect.objectContaining({ sandboxPath: SANDBOX_PATH })); }); @@ -58,6 +90,7 @@ describe("Linux Electron sandbox launch policy", () => { resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + runUnshare: () => ({ status: 1 }), development: true, env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, warn, @@ -71,6 +104,7 @@ describe("Linux Electron sandbox launch policy", () => { resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + runUnshare: () => ({ status: 1 }), development: false, env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, }), From 45c7526c4a34850560ee15b6ca25e3a197cde77e Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 16:50:28 +0300 Subject: [PATCH 27/34] release(linux): ship sandboxed Debian package --- .docs/scripts.md | 3 +- .github/workflows/ci.yml | 83 ++ .github/workflows/release.yml | 90 +- .gitignore | 2 + apps/desktop/src/main.ts | 28 +- apps/desktop/src/updateState.test.ts | 18 +- apps/desktop/src/updateState.ts | 5 +- apps/web/package.json | 1 + apps/web/scripts/linux-deb-smoke.mjs | 914 ++++++++++++++++++ .../scripts/linux-packaged-smoke-support.mjs | 70 ++ .../linux-packaged-smoke-support.test.mjs | 60 ++ docs/manual-platform-verification.md | 17 +- docs/release.md | 52 +- package.json | 4 +- packages/shared/src/desktopIdentity.test.ts | 6 + packages/shared/src/desktopIdentity.ts | 10 + .../build-desktop-artifact-mac-config.test.ts | 26 + scripts/build-desktop-artifact.ts | 4 +- scripts/lib/desktop-platform-build-config.ts | 10 + scripts/lib/release-update-policy.ts | 25 +- scripts/release-smoke.ts | 170 +++- scripts/release-update-policy.test.ts | 19 +- .../verify-packaged-desktop-startup.test.ts | 308 ++++++ scripts/verify-packaged-desktop-startup.ts | 603 ++++++++++++ 24 files changed, 2435 insertions(+), 93 deletions(-) create mode 100644 apps/web/scripts/linux-deb-smoke.mjs create mode 100644 apps/web/scripts/linux-packaged-smoke-support.mjs create mode 100644 apps/web/scripts/linux-packaged-smoke-support.test.mjs create mode 100644 scripts/verify-packaged-desktop-startup.test.ts create mode 100644 scripts/verify-packaged-desktop-startup.ts diff --git a/.docs/scripts.md b/.docs/scripts.md index 05f560ec7..c1be6c1ac 100644 --- a/.docs/scripts.md +++ b/.docs/scripts.md @@ -13,7 +13,8 @@ - `bun run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. - `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. - `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. -- `bun run dist:desktop:linux` — Builds a Linux AppImage into `./release`. +- `bun run dist:desktop:linux` — Builds the supported Linux Debian package into `./release`. +- `bun run dist:desktop:linux:appimage` — Builds the fail-closed AppImage compatibility artifact for packaging diagnostics; it is not the supported Ubuntu download. - `bun run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. ## Desktop `.dmg` packaging notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f97742eb0..ffc40659f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,89 @@ jobs: - name: Exercise Windows release staging run: bun run release:smoke + linux_packaged: + name: Packaged Linux Acceptance + needs: quality + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + + - name: Cache Bun and Turbo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.bun/install/cache + .turbo + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}-${{ hashFiles('turbo.json') }} + restore-keys: | + ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install packaged desktop runtime dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + ./apps/web/node_modules/.bin/playwright install-deps chromium + command -v apparmor_status >/dev/null 2>&1 || \ + sudo apt-get install --no-install-recommends --yes apparmor-utils + + - name: Build Linux Debian package + run: bun run dist:desktop:linux + + - name: Exercise installed Debian lifecycle + run: bun run test:linux-deb + + - name: Build fail-closed AppImage compatibility artifact + run: | + node scripts/build-desktop-artifact.ts \ + --platform linux \ + --target AppImage \ + --arch x64 \ + --output-dir release-appimage \ + --skip-build + + - name: Verify AppImage never injects a sandbox bypass + shell: bash + run: | + set -euo pipefail + appimage_path="$(find release-appimage -maxdepth 1 -type f -name '*.AppImage' -print -quit)" + test -n "$appimage_path" + chmod +x "$appimage_path" + extraction_directory="$(mktemp -d)" + trap 'rm -rf "$extraction_directory"' EXIT + ( + cd "$extraction_directory" + "$GITHUB_WORKSPACE/$appimage_path" --appimage-extract >/dev/null + ) + launcher="$extraction_directory/squashfs-root/AppRun" + test -f "$launcher" + if grep -F -- '--no-sandbox' "$launcher"; then + echo "Fail-closed AppImage launcher injects --no-sandbox." >&2 + exit 1 + fi + + - name: Upload packaged Linux diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: linux-packaged-diagnostics + path: test-results/linux-deb + if-no-files-found: ignore + release_smoke: name: Release Smoke runs-on: ubuntu-24.04 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd320d79b..930a8cae1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,10 +165,9 @@ jobs: required_assets=( latest-mac.yml latest.yml - latest-linux.yml + "${UPDATE_CHANNEL}-deb-linux.yml" "${UPDATE_CHANNEL}-mac.yml" "${UPDATE_CHANNEL}.yml" - "${UPDATE_CHANNEL}-linux.yml" ) for required_asset in "${required_assets[@]}"; do if ! grep -Fxq "$required_asset" <<< "$bridge_assets"; then @@ -221,7 +220,7 @@ jobs: - label: Linux x64 runner: ubuntu-24.04 platform: linux - target: AppImage + target: deb arch: x64 timeout_minutes: 30 - label: Windows x64 @@ -322,36 +321,6 @@ jobs: "${{ matrix.arch }}" \ "${{ needs.preflight.outputs.version }}" - - name: Verify Linux AppImage sandbox policy - if: matrix.platform == 'linux' - shell: bash - run: | - set -euo pipefail - - appimage_path="$(find release -maxdepth 1 -type f -name '*.AppImage' -print -quit)" - if [[ -z "$appimage_path" ]]; then - echo "Linux release did not produce an AppImage." >&2 - exit 1 - fi - - chmod +x "$appimage_path" - extraction_directory="$(mktemp -d)" - trap 'rm -rf "$extraction_directory"' EXIT - ( - cd "$extraction_directory" - "$GITHUB_WORKSPACE/$appimage_path" --appimage-extract >/dev/null - ) - - launcher="$extraction_directory/squashfs-root/AppRun" - if [[ ! -f "$launcher" ]]; then - echo "Extracted AppImage is missing AppRun." >&2 - exit 1 - fi - if grep -F -- '--no-sandbox' "$launcher"; then - echo "Packaged Linux launcher disables Electron's sandbox." >&2 - exit 1 - fi - - name: Collect release assets shell: bash run: | @@ -362,7 +331,7 @@ jobs: for pattern in \ "release/*.dmg" \ "release/*.zip" \ - "release/*.AppImage" \ + "release/*.deb" \ "release/*.exe" \ "release/*.blockmap" \ "release/latest*.yml"; do @@ -377,6 +346,42 @@ jobs: fi fi + - name: Install Linux packaged smoke dependencies + if: ${{ matrix.platform == 'linux' }} + shell: bash + env: + DEBIAN_FRONTEND: noninteractive + run: | + set -euo pipefail + ./apps/web/node_modules/.bin/playwright install-deps chromium + command -v apparmor_status >/dev/null 2>&1 || \ + sudo apt-get install --no-install-recommends --yes apparmor-utils + + - name: Smoke exact packaged desktop startup + if: ${{ matrix.platform != 'linux' }} + shell: bash + run: | + node scripts/verify-packaged-desktop-startup.ts \ + --assets-dir release-publish \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --version "${{ needs.preflight.outputs.version }}" + + - name: Exercise exact packaged Linux lifecycle + if: ${{ matrix.platform == 'linux' }} + env: + SCIENT_LINUX_ARTIFACT_DIR: release-publish + SCIENT_LINUX_SMOKE_ARTIFACT_DIR: test-results/linux-release-deb + run: node apps/web/scripts/linux-deb-smoke.mjs + + - name: Upload packaged Linux smoke diagnostics + if: ${{ failure() && matrix.platform == 'linux' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-linux-packaged-diagnostics-${{ matrix.arch }} + path: test-results/linux-release-deb + if-no-files-found: ignore + - name: Upload build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -463,7 +468,7 @@ jobs: mapfile -d '' payloads < <( find . -maxdepth 1 -type f \ - \( -name '*.dmg' -o -name '*.zip' -o -name '*.AppImage' -o -name '*.exe' \) \ + \( -name '*.dmg' -o -name '*.zip' -o -name '*.deb' -o -name '*.exe' \) \ -print0 | sort -z ) if [[ "${#payloads[@]}" -eq 0 ]]; then @@ -489,13 +494,12 @@ jobs: "Scient-${RELEASE_VERSION}-arm64.zip" "Scient-${RELEASE_VERSION}-x64.zip" "Scient-${RELEASE_VERSION}-x64.exe" - "Scient-${RELEASE_VERSION}-x86_64.AppImage" + "Scient-${RELEASE_VERSION}-amd64.deb" "latest-mac.yml" "latest.yml" - "latest-linux.yml" "${UPDATE_CHANNEL}-mac.yml" "${UPDATE_CHANNEL}.yml" - "${UPDATE_CHANNEL}-linux.yml" + "${UPDATE_CHANNEL}-deb-linux.yml" "SHA256SUMS.txt" ) @@ -512,7 +516,7 @@ jobs: "Scient-${RELEASE_VERSION}-arm64.zip" "Scient-${RELEASE_VERSION}-x64.zip" "Scient-${RELEASE_VERSION}-x64.exe" - "Scient-${RELEASE_VERSION}-x86_64.AppImage" + "Scient-${RELEASE_VERSION}-amd64.deb" ) for payload in "${required_payloads[@]}"; do if ! grep -Eq "^[0-9a-f]{64} ${payload}$" SHA256SUMS.txt; then @@ -550,7 +554,7 @@ jobs: channel_manifests=( "${UPDATE_CHANNEL}-mac.yml" "${UPDATE_CHANNEL}.yml" - "${UPDATE_CHANNEL}-linux.yml" + "${UPDATE_CHANNEL}-deb-linux.yml" ) existing_manifests=() for manifest in "${channel_manifests[@]}"; do @@ -624,7 +628,7 @@ jobs: files: | release-assets/*.dmg release-assets/*.zip - release-assets/*.AppImage + release-assets/*.deb release-assets/*.exe release-assets/*.blockmap release-assets/*.yml @@ -651,14 +655,14 @@ jobs: payloads=( release-assets/*.dmg release-assets/*.zip - release-assets/*.AppImage + release-assets/*.deb release-assets/*.exe release-assets/*.blockmap ) manifests=( "release-assets/${UPDATE_CHANNEL}-mac.yml" "release-assets/${UPDATE_CHANNEL}.yml" - "release-assets/${UPDATE_CHANNEL}-linux.yml" + "release-assets/${UPDATE_CHANNEL}-deb-linux.yml" ) if [[ "${#payloads[@]}" -eq 0 ]]; then diff --git a/.gitignore b/.gitignore index f1fe8ad39..b0b51fdf9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ release/ .idea/ apps/web/.playwright apps/web/playwright-report +/test-results/linux-deb/ +/test-results/linux-release-deb/ .playwright-cli/ output/playwright/ apps/web/src/components/__screenshots__ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 519aa5039..4cfc4d87a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -54,6 +54,7 @@ import { SCIENT_DESKTOP_UPDATE_CHANNEL, SCIENT_DESKTOP_UPDATES_ENABLED, scientBundleId, + scientDesktopUpdateChannel, } from "@synara/shared/desktopIdentity"; import { NetService } from "@synara/shared/Net"; import { RotatingFileSink } from "@synara/shared/logging"; @@ -930,6 +931,20 @@ function parseAppUpdateYml(): Record | null { } } +function readLinuxPackageType(): string | null { + if (process.platform !== "linux" || !app.isPackaged) return null; + if (process.env.APPIMAGE) return "AppImage"; + try { + const packageType = FS.readFileSync( + Path.join(process.resourcesPath, "package-type"), + "utf8", + ).trim(); + return packageType || null; + } catch { + return null; + } +} + function normalizeCommitHash(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -1371,6 +1386,7 @@ function resolveAutoUpdateDisabledReason(): string | null { isPackaged: app.isPackaged, platform: process.platform, appImage: process.env.APPIMAGE, + linuxPackageType: readLinuxPackageType() ?? undefined, disabledByEnv: process.env.SYNARA_DISABLE_AUTO_UPDATE === "1", hasUpdateFeedConfig: hasConfiguredUpdateFeed(), }); @@ -2603,7 +2619,7 @@ function configureAutoUpdater(): void { // Stable production builds use the dedicated Scient-owned update channel. // resolveAutoUpdateDisabledReason still keeps development, unpackaged, and // unsupported runtime environments away from the public feed. - autoUpdater.channel = SCIENT_DESKTOP_UPDATE_CHANNEL; + autoUpdater.channel = scientDesktopUpdateChannel(process.platform, readLinuxPackageType()); autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; autoUpdater.allowDowngrade = false; // Match electron-updater's native GitHub provider path; the packaged @@ -3502,9 +3518,19 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); }); window.webContents.on("did-finish-load", () => { + writeDesktopLogHeader("renderer main frame loaded"); window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedUrl, isMainFrame) => { + if (!isMainFrame) return; + writeDesktopLogHeader( + `renderer main frame load failed code=${errorCode} url=${validatedUrl} message=${errorDescription}`, + ); + }, + ); window.once("ready-to-show", () => { // Preserve the original first-launch behavior, then respect the state saved // by subsequent closes. Normal bounds are restored before maximizing so the diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index c423fc95f..0017189dc 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -226,7 +226,7 @@ describe("getAutoUpdateDisabledReason", () => { ).toContain("SYNARA_DISABLE_AUTO_UPDATE"); }); - it("reports linux non-AppImage builds as disabled", () => { + it("reports unsupported Linux package types as disabled", () => { expect( getAutoUpdateDisabledReason({ isDevelopment: false, @@ -236,7 +236,21 @@ describe("getAutoUpdateDisabledReason", () => { disabledByEnv: false, hasUpdateFeedConfig: true, }), - ).toContain("AppImage"); + ).toContain("Debian package"); + }); + + it("allows installed Debian packages to use their dedicated update channel", () => { + expect( + getAutoUpdateDisabledReason({ + isDevelopment: false, + isPackaged: true, + platform: "linux", + appImage: undefined, + linuxPackageType: "deb", + disabledByEnv: false, + hasUpdateFeedConfig: true, + }), + ).toBeNull(); }); }); diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts index e794177f1..3b3c5fc7b 100644 --- a/apps/desktop/src/updateState.ts +++ b/apps/desktop/src/updateState.ts @@ -153,6 +153,7 @@ export function getAutoUpdateDisabledReason(args: { isPackaged: boolean; platform: NodeJS.Platform; appImage?: string | undefined; + linuxPackageType?: string | undefined; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -165,8 +166,8 @@ export function getAutoUpdateDisabledReason(args: { if (args.disabledByEnv) { return "Automatic updates are disabled by the SYNARA_DISABLE_AUTO_UPDATE setting."; } - if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; + if (args.platform === "linux" && !args.appImage && args.linuxPackageType !== "deb") { + return "Automatic updates on Linux require an installed Scient Debian package."; } return null; } diff --git a/apps/web/package.json b/apps/web/package.json index 62e2ab5e6..0296bacb4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "test:browser:stable:remaining": "vitest run --config vitest.browser.stable.config.ts --exclude src/components/KeybindingsToast.browser.tsx --exclude src/components/ChatView.browser.tsx --exclude src/components/EventRouter.browser.tsx", "test:browser:geometry": "vitest run --config vitest.browser.geometry.config.ts", "test:browser:install": "playwright install --with-deps chromium", + "test:linux-deb": "node scripts/linux-deb-smoke.mjs", "measure:lcp": "node scripts/measure-lcp.mjs" }, "dependencies": { diff --git a/apps/web/scripts/linux-deb-smoke.mjs b/apps/web/scripts/linux-deb-smoke.mjs new file mode 100644 index 000000000..ae5d6c2ab --- /dev/null +++ b/apps/web/scripts/linux-deb-smoke.mjs @@ -0,0 +1,914 @@ +import { spawn, spawnSync } from "node:child_process"; +import { constants as FS_CONSTANTS } from "node:fs"; +import { + access, + copyFile, + mkdtemp, + mkdir, + readFile, + readlink, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +import { sanitizePackagedDesktopInheritedEnvironment } from "../../../scripts/verify-packaged-desktop-startup.ts"; + +import { + assertSandboxedPackagedArguments, + fetchWithinDeadline, + waitFor, +} from "./linux-packaged-smoke-support.mjs"; + +const SCRIPT_DIR = fileURLToPath(new URL(".", import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, "../../.."); +const RELEASE_DIR = resolve(REPO_ROOT, process.env.SCIENT_LINUX_ARTIFACT_DIR || "release"); +const DIAGNOSTIC_DIR = resolve( + REPO_ROOT, + process.env.SCIENT_LINUX_SMOKE_ARTIFACT_DIR || "test-results/linux-deb", +); +const DEBIAN_PACKAGE_NAME = "scient"; +const INSTALLED_APP_DIRECTORY = "/opt/Scient"; +const INSTALLED_EXECUTABLE = join(INSTALLED_APP_DIRECTORY, "scient"); +const INSTALLED_SANDBOX_HELPER = join(INSTALLED_APP_DIRECTORY, "chrome-sandbox"); +const BUNDLED_APPARMOR_PROFILE = join(INSTALLED_APP_DIRECTORY, "resources", "apparmor-profile"); +const INSTALLED_APPARMOR_PROFILE = "/etc/apparmor.d/scient"; +const STARTUP_TIMEOUT_MS = 45_000; +const ACTION_TIMEOUT_MS = 20_000; +const RECOVERY_TIMEOUT_MS = 30_000; +const GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS = 12_000; +const DIAGNOSTIC_CAPTURE_TIMEOUT_MS = 5_000; +const PRIVATE_DIRECTORY_MODE = 0o700; + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +async function reservePort() { + const server = createServer(); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not reserve a loopback debugging port."); + } + await new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())); + }); + return address.port; +} + +function runCommand(command, args, { allowFailure = false } = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + shell: false, + windowsHide: true, + }); + if (result.error) { + if (allowFailure && result.error.code === "ENOENT") return result; + throw new Error(`${command} could not start: ${result.error.message}`); + } + if (!allowFailure && result.status !== 0) { + const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error( + `${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}${detail ? `:\n${detail}` : "."}`, + ); + } + return result; +} + +async function findDebianPackage() { + const entries = (await readdir(RELEASE_DIR, { withFileTypes: true })).filter( + (entry) => entry.isFile() && entry.name.endsWith(".deb"), + ); + if (entries.length !== 1) { + throw new Error( + `Expected exactly one Debian package in ${RELEASE_DIR}, found ${entries.map((entry) => entry.name).join(", ") || "none"}.`, + ); + } + const packagePath = join(RELEASE_DIR, entries[0].name); + await access(packagePath, FS_CONSTANTS.R_OK); + return packagePath; +} + +function readDebianPackageField(packagePath, field) { + return runCommand("dpkg-deb", ["--field", packagePath, field]).stdout.trim(); +} + +function assertNoExistingScientInstallation() { + const result = runCommand("dpkg-query", ["--show", DEBIAN_PACKAGE_NAME], { + allowFailure: true, + }); + if (result.status === 0) { + throw new Error( + "Refusing to replace an existing system Scient installation during packaged acceptance.", + ); + } +} + +async function assertInstalledDebianSandbox(packagePath) { + const packageName = readDebianPackageField(packagePath, "Package"); + const architecture = readDebianPackageField(packagePath, "Architecture"); + if (packageName !== DEBIAN_PACKAGE_NAME) { + throw new Error(`Expected Debian package ${DEBIAN_PACKAGE_NAME}, found ${packageName}.`); + } + if (architecture !== "amd64") { + throw new Error(`Expected Debian architecture amd64, found ${architecture}.`); + } + + const executableMetadata = await stat(INSTALLED_EXECUTABLE); + if ( + !executableMetadata.isFile() || + executableMetadata.uid !== 0 || + (executableMetadata.mode & 0o7777) !== 0o755 + ) { + throw new Error( + `Expected ${INSTALLED_EXECUTABLE} to be a root-owned regular file with exact mode 0755.`, + ); + } + await access(INSTALLED_EXECUTABLE, FS_CONSTANTS.X_OK); + + const sandboxMetadata = await stat(INSTALLED_SANDBOX_HELPER); + const sandboxMode = sandboxMetadata.mode & 0o7777; + if ( + !sandboxMetadata.isFile() || + sandboxMetadata.uid !== 0 || + (sandboxMode !== 0o755 && sandboxMode !== 0o4755) + ) { + throw new Error( + `Expected ${INSTALLED_SANDBOX_HELPER} to be root-owned, regular, and exact mode 0755 or 4755; found ${sandboxMode.toString(8).padStart(4, "0")}.`, + ); + } + + const bundledProfile = await readFile(BUNDLED_APPARMOR_PROFILE, "utf8"); + if ( + !bundledProfile.includes(`"${INSTALLED_EXECUTABLE}"`) || + !bundledProfile.includes("userns,") + ) { + throw new Error( + `Bundled AppArmor profile ${BUNDLED_APPARMOR_PROFILE} does not grant user namespaces to the exact Scient executable.`, + ); + } + + const appArmorEnabled = + runCommand("apparmor_status", ["--enabled"], { + allowFailure: true, + }).status === 0; + if (appArmorEnabled) { + const profile = await readFile(INSTALLED_APPARMOR_PROFILE, "utf8"); + if (!profile.includes(`"${INSTALLED_EXECUTABLE}"`) || !profile.includes("userns,")) { + throw new Error( + `Installed AppArmor profile ${INSTALLED_APPARMOR_PROFILE} does not grant user namespaces to the exact Scient executable.`, + ); + } + } +} + +function installDebianPackage(packagePath) { + runCommand("sudo", [ + "env", + "DEBIAN_FRONTEND=noninteractive", + "apt-get", + "install", + "--yes", + packagePath, + ]); +} + +function uninstallDebianPackage() { + const query = runCommand("dpkg-query", ["--show", DEBIAN_PACKAGE_NAME], { + allowFailure: true, + }); + if (query.status !== 0) return; + runCommand("sudo", [ + "env", + "DEBIAN_FRONTEND=noninteractive", + "apt-get", + "purge", + "--yes", + DEBIAN_PACKAGE_NAME, + ]); +} + +async function readRuntimeState(runtimeStatePath) { + const contents = await readFile(runtimeStatePath, "utf8"); + const state = JSON.parse(contents); + if (!Number.isInteger(state.pid) || state.pid <= 0 || typeof state.origin !== "string") { + throw new Error(`Invalid packaged server runtime state at ${runtimeStatePath}.`); + } + return state; +} + +async function waitForRuntimeState(runtimeStatePath, predicate = () => true) { + return waitFor( + "packaged backend runtime state", + async () => { + const state = await readRuntimeState(runtimeStatePath); + return predicate(state) ? state : null; + }, + STARTUP_TIMEOUT_MS, + ); +} + +async function waitForBackendReady(runtimeState, description, timeoutMs = STARTUP_TIMEOUT_MS) { + return waitFor( + description, + ({ deadline }) => + fetchWithinDeadline(`${runtimeState.origin}/health`, { + deadline, + consume: async (response) => { + if (!response.ok) return null; + const health = await response.json(); + return health?.status === "ok" && health?.startupReady === true ? health : null; + }, + }), + timeoutMs, + ); +} + +async function assertPackagedBackendProcess(pid) { + const commandLine = (await readFile(`/proc/${pid}/cmdline`)) + .toString("utf8") + .split("\0") + .filter(Boolean); + if (!commandLine.some((argument) => argument.endsWith("apps/server/dist/index.mjs"))) { + throw new Error(`Refusing to signal unvalidated packaged backend PID ${pid}.`); + } +} + +async function assertPrivateDirectory(directoryPath) { + const metadata = await stat(directoryPath); + if (!metadata.isDirectory()) throw new Error(`${directoryPath} is not a directory.`); + const actualMode = metadata.mode & 0o777; + if (actualMode !== PRIVATE_DIRECTORY_MODE) { + throw new Error( + `Expected private directory ${directoryPath} to use mode 0700, found ${actualMode.toString(8).padStart(4, "0")}.`, + ); + } +} + +async function assertDirectoryMode(directoryPath, expectedMode) { + const actualMode = (await stat(directoryPath)).mode & 0o777; + if (actualMode !== expectedMode) { + throw new Error( + `Expected ${directoryPath} to retain mode ${expectedMode.toString(8).padStart(4, "0")}, found ${actualMode.toString(8).padStart(4, "0")}.`, + ); + } +} + +async function assertPrivateScientDirectories(scientHome) { + const stateDir = join(scientHome, "userdata"); + const directories = [ + scientHome, + stateDir, + join(stateDir, "secrets"), + join(stateDir, "attachments"), + join(stateDir, "logs"), + join(stateDir, "logs", "provider"), + join(stateDir, "logs", "terminals"), + join(scientHome, "worktrees"), + ]; + await Promise.all(directories.map(assertPrivateDirectory)); +} + +async function createDirtyScientDirectories(scientHome) { + const stateDir = join(scientHome, "userdata"); + const directories = [ + scientHome, + stateDir, + join(stateDir, "secrets"), + join(stateDir, "attachments"), + join(stateDir, "logs"), + join(stateDir, "logs", "provider"), + join(stateDir, "logs", "terminals"), + join(scientHome, "worktrees"), + ]; + for (const directoryPath of directories) { + await mkdir(directoryPath, { recursive: true, mode: 0o775 }); + await chmod(directoryPath, 0o775); + } +} + +async function connectToPackagedRenderer(debuggingPort, processOutput) { + const endpoint = `http://127.0.0.1:${debuggingPort}`; + await waitFor( + "Electron remote-debugging endpoint", + ({ deadline }) => + fetchWithinDeadline(`${endpoint}/json/version`, { + deadline, + consume: (response) => response.ok, + }), + STARTUP_TIMEOUT_MS, + ).catch((error) => { + throw new Error(`${error.message}\nPackaged process output:\n${processOutput()}`); + }); + + const browser = await chromium.connectOverCDP(endpoint, { timeout: STARTUP_TIMEOUT_MS }); + const context = browser.contexts()[0]; + if (!context) throw new Error("Packaged Electron exposed no browser context."); + const page = await waitFor( + "Scient packaged renderer", + async () => { + const candidate = context.pages().find((current) => current.url().startsWith("scient://")); + return candidate ?? null; + }, + STARTUP_TIMEOUT_MS, + ); + await page.getByRole("button", { name: "Add project", exact: true }).first().waitFor({ + state: "visible", + timeout: STARTUP_TIMEOUT_MS, + }); + return { browser, page }; +} + +async function addProjectByTypedPath(page, workspacePath) { + await page.keyboard.press("Control+Shift+O"); + const folderDialog = page.getByRole("dialog"); + const pathInput = folderDialog.getByPlaceholder("Enter project path (e.g. ~/projects/my-app)"); + await pathInput.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + await pathInput.fill(workspacePath); + await folderDialog + .getByText(basename(workspacePath), { exact: true }) + .first() + .waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + const addButton = folderDialog.getByRole("button", { name: /^Add\b/u }); + await addButton.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + if ( + await folderDialog + .getByText(/SocketOpenError/) + .first() + .isVisible() + .catch(() => false) + ) { + throw new Error("Folder browsing failed with SocketOpenError."); + } + await addButton.click({ timeout: ACTION_TIMEOUT_MS }); + const emptyProjectChoice = page.getByRole("button", { + name: /Open an empty project/, + }); + await emptyProjectChoice.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + await emptyProjectChoice.click({ timeout: ACTION_TIMEOUT_MS }); +} + +async function waitForPersistedProject(databasePath, workspacePath) { + const { DatabaseSync } = await import("node:sqlite"); + return waitFor("project persistence", async () => { + let database; + try { + database = new DatabaseSync(databasePath, { readOnly: true }); + const row = database + .prepare("SELECT workspace_root FROM projection_projects WHERE workspace_root = ? LIMIT 1") + .get(workspacePath); + return row?.workspace_root === workspacePath; + } finally { + database?.close(); + } + }); +} + +function signalProcess(processId, signal) { + try { + process.kill(processId, signal); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } +} + +function signalProcessGroup(processGroupId, signal) { + try { + process.kill(-processGroupId, signal); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } +} + +async function readLinuxProcessInfo(processId) { + const [statContents, statusContents, commandLineContents, executablePath] = await Promise.all([ + readFile(`/proc/${processId}/stat`, "utf8"), + readFile(`/proc/${processId}/status`, "utf8"), + readFile(`/proc/${processId}/cmdline`), + readlink(`/proc/${processId}/exe`), + ]); + const commandEnd = statContents.lastIndexOf(")"); + if (commandEnd < 0) throw new Error(`Invalid /proc stat data for PID ${processId}.`); + const statFields = statContents + .slice(commandEnd + 2) + .trim() + .split(/\s+/u); + const parentProcessId = Number(statFields[1]); + const processGroupId = Number(statFields[2]); + const startTimeTicks = statFields[19]; + if (!Number.isInteger(parentProcessId) || !Number.isInteger(processGroupId) || !startTimeTicks) { + throw new Error(`Incomplete /proc stat identity for PID ${processId}.`); + } + const uid = Number(/^Uid:\s+(\d+)/mu.exec(statusContents)?.[1]); + if (!Number.isInteger(uid)) + throw new Error(`Incomplete /proc user identity for PID ${processId}.`); + return { + processId, + parentProcessId, + processGroupId, + startTimeTicks, + uid, + executablePath, + commandLine: commandLineContents.toString("utf8").split("\0").filter(Boolean), + }; +} + +async function processIdentityMatches(expectedProcess) { + try { + const currentProcess = await readLinuxProcessInfo(expectedProcess.processId); + return ( + currentProcess.startTimeTicks === expectedProcess.startTimeTicks && + currentProcess.executablePath === expectedProcess.executablePath + ); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ESRCH") return false; + throw error; + } +} + +async function findPackagedElectronProcess(launcherProcessId, debuggingPort) { + return waitFor("packaged Electron main process", async () => { + const processEntries = (await readdir("/proc", { withFileTypes: true })).filter( + (entry) => entry.isDirectory() && /^\d+$/u.test(entry.name), + ); + const processes = new Map(); + await Promise.all( + processEntries.map(async (entry) => { + const processId = Number(entry.name); + try { + processes.set(processId, await readLinuxProcessInfo(processId)); + } catch { + // Processes can exit while /proc is being inspected. + } + }), + ); + + const isDescendantOf = (processId, ancestorProcessId) => { + const visited = new Set(); + let currentProcessId = processId; + while (currentProcessId > 1 && !visited.has(currentProcessId)) { + visited.add(currentProcessId); + const parentProcessId = processes.get(currentProcessId)?.parentProcessId; + if (parentProcessId === ancestorProcessId) return true; + if (!parentProcessId) return false; + currentProcessId = parentProcessId; + } + return false; + }; + + const debuggingArgument = `--remote-debugging-port=${debuggingPort}`; + const candidates = [...processes.entries()].filter( + ([processId, processInfo]) => + isDescendantOf(processId, launcherProcessId) && + basename(processInfo.executablePath) === "scient" && + processInfo.commandLine.includes(debuggingArgument) && + !processInfo.commandLine.some((argument) => argument.startsWith("--type=")), + ); + const deepestCandidates = candidates.filter(([candidateProcessId]) => + candidates.every( + ([otherProcessId]) => + otherProcessId === candidateProcessId || + !isDescendantOf(otherProcessId, candidateProcessId), + ), + ); + if (deepestCandidates.length > 1) { + throw new Error( + `Found multiple packaged Electron main-process candidates: ${candidates + .map(([processId]) => processId) + .join(", ")}.`, + ); + } + return deepestCandidates[0]?.[1] ?? null; + }); +} + +async function captureProcessGroupMembers(processGroupIds) { + const processGroupIdSet = new Set(processGroupIds); + const membersByProcessGroup = new Map( + processGroupIds.map((processGroupId) => [processGroupId, []]), + ); + const processEntries = (await readdir("/proc", { withFileTypes: true })).filter( + (entry) => entry.isDirectory() && /^\d+$/u.test(entry.name), + ); + await Promise.all( + processEntries.map(async (entry) => { + try { + const processInfo = await readLinuxProcessInfo(Number(entry.name)); + if (processGroupIdSet.has(processInfo.processGroupId)) { + membersByProcessGroup.get(processInfo.processGroupId)?.push(processInfo); + } + } catch { + // Processes can exit while /proc is being inspected. + } + }), + ); + return membersByProcessGroup; +} + +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { name: "NonError", message: String(error) }; +} + +function serializeProcessGroups(membersByProcessGroup) { + return [...membersByProcessGroup].map(([processGroupId, members]) => ({ + processGroupId, + members, + })); +} + +async function stopPackagedApp(child, electronProcess, backendProcessGroupId, cleanupDiagnostics) { + if (!child.pid) return; + cleanupDiagnostics.launcherProcessId = child.pid; + cleanupDiagnostics.launcherProcess = await readLinuxProcessInfo(child.pid).catch((error) => { + cleanupDiagnostics.identityReadErrors.push({ + processId: child.pid, + error: serializeError(error), + }); + return null; + }); + cleanupDiagnostics.electronProcess = electronProcess ?? null; + cleanupDiagnostics.backendProcessGroupId = backendProcessGroupId ?? null; + const processGroupIds = [ + ...new Set([child.pid, electronProcess?.processGroupId, backendProcessGroupId].filter(Boolean)), + ]; + cleanupDiagnostics.processGroupIds = processGroupIds; + const trackedProcessGroups = await captureProcessGroupMembers(processGroupIds); + cleanupDiagnostics.trackedProcessGroups = serializeProcessGroups(trackedProcessGroups); + const captureLivingProcessGroups = async (phase) => { + const livingGroups = []; + for (const [processGroupId, originalMembers] of trackedProcessGroups) { + const identityMatches = await Promise.all(originalMembers.map(processIdentityMatches)); + const livingMembers = originalMembers.filter((_, index) => identityMatches[index]); + if (livingMembers.length > 0) { + livingGroups.push({ processGroupId, members: livingMembers }); + } + } + cleanupDiagnostics.survivors[phase] = livingGroups; + return livingGroups.map(({ processGroupId }) => processGroupId); + }; + const waitForAllGroupsToExit = (timeoutMs) => + waitFor( + "packaged Electron and backend process groups to exit", + async () => (await captureLivingProcessGroups("latest-poll")).length === 0, + timeoutMs, + ).catch(() => false); + + if (electronProcess && (await processIdentityMatches(electronProcess))) { + cleanupDiagnostics.signals.push({ target: electronProcess.processId, signal: "SIGTERM" }); + signalProcess(electronProcess.processId, "SIGTERM"); + } + if (await waitForAllGroupsToExit(GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS)) { + cleanupDiagnostics.result = "graceful"; + await captureLivingProcessGroups("after-graceful-window"); + return; + } + + const gracefulSurvivors = await captureLivingProcessGroups("after-graceful-window"); + for (const processGroupId of gracefulSurvivors) { + cleanupDiagnostics.signals.push({ target: -processGroupId, signal: "SIGTERM" }); + signalProcessGroup(processGroupId, "SIGTERM"); + } + if (!(await waitForAllGroupsToExit(5_000))) { + const termSurvivors = await captureLivingProcessGroups("after-group-sigterm"); + for (const processGroupId of termSurvivors) { + cleanupDiagnostics.signals.push({ target: -processGroupId, signal: "SIGKILL" }); + signalProcessGroup(processGroupId, "SIGKILL"); + } + if (!(await waitForAllGroupsToExit(5_000))) { + const killSurvivors = await captureLivingProcessGroups("after-group-sigkill"); + cleanupDiagnostics.result = "survived-sigkill"; + throw new Error(`Packaged process groups ${killSurvivors.join(", ")} survived SIGKILL.`); + } + } + cleanupDiagnostics.result = "forced-cleanup"; + throw new Error( + `Packaged application required forced cleanup after its ${GRACEFUL_APP_SHUTDOWN_TIMEOUT_MS}ms graceful shutdown window; surviving process groups: ${gracefulSurvivors.join(", ")}.`, + ); +} + +async function preserveFailureDiagnostics({ + scenarioName, + screenshot, + output, + desktopLogPath, + serverLogPath, + runtimeStatePath, + cleanupDiagnostics, +}) { + await mkdir(DIAGNOSTIC_DIR, { recursive: true }); + await writeFile(join(DIAGNOSTIC_DIR, `${scenarioName}-process.log`), `${output}\n`, "utf8"); + if (screenshot) { + await writeFile(join(DIAGNOSTIC_DIR, `${scenarioName}.png`), screenshot); + } + await writeFile( + join(DIAGNOSTIC_DIR, `${scenarioName}-cleanup.json`), + `${JSON.stringify(cleanupDiagnostics, null, 2)}\n`, + "utf8", + ); + await copyFile(desktopLogPath, join(DIAGNOSTIC_DIR, `${scenarioName}-desktop-main.log`)).catch( + () => undefined, + ); + await copyFile(serverLogPath, join(DIAGNOSTIC_DIR, `${scenarioName}-server-child.log`)).catch( + () => undefined, + ); + await copyFile( + runtimeStatePath, + join(DIAGNOSTIC_DIR, `${scenarioName}-server-runtime.json`), + ).catch(() => undefined); +} + +async function runScenario(executablePath, scenario) { + const scenarioRoot = await mkdtemp(join(tmpdir(), `scient-deb-${scenario.name}-`)); + const homeDir = join(scenarioRoot, "home"); + const configHome = join(scenarioRoot, "config"); + const cacheHome = join(scenarioRoot, "cache"); + const dataHome = join(scenarioRoot, "data"); + const runtimeDir = join(scenarioRoot, "runtime"); + const scientHome = join(scenarioRoot, "scient-home"); + const firstWorkspace = join(homeDir, `${scenario.name}-first-project`); + const secondWorkspace = join(homeDir, `${scenario.name}-after-recovery`); + const runtimeStatePath = join(scientHome, "userdata", "server-runtime.json"); + const databasePath = join(scientHome, "userdata", "state.sqlite"); + const desktopLogPath = join(scientHome, "userdata", "logs", "desktop-main.log"); + const serverLogPath = join(scientHome, "userdata", "logs", "server-child.log"); + let browser; + let child; + let page; + let electronProcess; + let backendProcessGroupId; + let output = ""; + let scenarioError; + let finalScreenshot; + const cleanupErrors = []; + const cleanupDiagnostics = { + scenarioName: scenario.name, + launcherProcessId: null, + launcherProcess: null, + electronProcess: null, + backendProcessGroupId: null, + processGroupIds: [], + trackedProcessGroups: [], + survivors: {}, + signals: [], + identityReadErrors: [], + screenshotError: null, + scenarioError: null, + errors: [], + result: "not-started", + }; + + try { + await mkdir(homeDir, { recursive: true }); + await mkdir(configHome, { recursive: true }); + await mkdir(cacheHome, { recursive: true }); + await mkdir(dataHome, { recursive: true }); + await mkdir(runtimeDir, { recursive: true, mode: 0o700 }); + await chmod(runtimeDir, 0o700); + await mkdir(firstWorkspace, { mode: 0o775 }); + await chmod(firstWorkspace, 0o775); + if (scenario.crashBackend) { + await mkdir(secondWorkspace, { mode: 0o775 }); + await chmod(secondWorkspace, 0o775); + } + if (scenario.precreatePermissiveState) await createDirtyScientDirectories(scientHome); + + const debuggingPort = await reservePort(); + const packagedArguments = [ + `--remote-debugging-port=${debuggingPort}`, + "--remote-debugging-address=127.0.0.1", + "--disable-gpu", + ]; + assertSandboxedPackagedArguments(packagedArguments); + const previousUmask = process.umask(scenario.umask); + try { + child = spawn("xvfb-run", ["-a", executablePath, ...packagedArguments], { + detached: true, + cwd: dirname(executablePath), + env: { + ...sanitizePackagedDesktopInheritedEnvironment(process.env), + HOME: homeDir, + SCIENT_HOME: scientHome, + SYNARA_DISABLE_AUTO_UPDATE: "1", + XDG_CACHE_HOME: cacheHome, + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: dataHome, + XDG_RUNTIME_DIR: runtimeDir, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + } finally { + process.umask(previousUmask); + } + + const recordOutput = (chunk) => { + output = `${output}${String(chunk)}`.slice(-200_000); + }; + child.stdout?.on("data", recordOutput); + child.stderr?.on("data", recordOutput); + child.once("exit", (code, signal) => { + if (code !== null && code !== 0) recordOutput(`\n[xvfb-run exited code=${code}]`); + if (signal) recordOutput(`\n[xvfb-run exited signal=${signal}]`); + }); + + const renderer = await connectToPackagedRenderer(debuggingPort, () => output); + browser = renderer.browser; + page = renderer.page; + electronProcess = await findPackagedElectronProcess(child.pid, debuggingPort); + if (electronProcess.executablePath !== executablePath) { + throw new Error( + `Expected packaged Electron to run ${executablePath}, found ${electronProcess.executablePath}.`, + ); + } + if (electronProcess.uid !== process.getuid?.()) { + throw new Error( + `Expected packaged Electron to run as uid ${process.getuid?.()}, found ${electronProcess.uid}.`, + ); + } + assertSandboxedPackagedArguments(electronProcess.commandLine); + page.on("console", (message) => { + recordOutput(`\n[renderer:${message.type()}] ${message.text()}`); + }); + page.on("pageerror", (error) => { + recordOutput(`\n[renderer:pageerror] ${error.stack || error.message}`); + }); + const initialRuntime = await waitForRuntimeState(runtimeStatePath); + backendProcessGroupId = initialRuntime.pid; + await waitForBackendReady( + initialRuntime, + "first packaged backend generation readiness", + STARTUP_TIMEOUT_MS, + ); + + await addProjectByTypedPath(page, firstWorkspace); + await waitForPersistedProject(databasePath, firstWorkspace); + await assertPrivateScientDirectories(scientHome); + await assertDirectoryMode(firstWorkspace, 0o775); + + if (scenario.crashBackend) { + await assertPackagedBackendProcess(initialRuntime.pid); + process.kill(initialRuntime.pid, "SIGKILL"); + const recoveredRuntime = await waitForRuntimeState( + runtimeStatePath, + (state) => state.pid !== initialRuntime.pid, + ); + backendProcessGroupId = recoveredRuntime.pid; + await waitForBackendReady( + recoveredRuntime, + "second packaged backend generation readiness", + RECOVERY_TIMEOUT_MS, + ); + + await addProjectByTypedPath(page, secondWorkspace); + await waitForPersistedProject(databasePath, secondWorkspace); + await assertDirectoryMode(secondWorkspace, 0o775); + await assertPackagedBackendProcess(recoveredRuntime.pid); + process.kill(recoveredRuntime.pid, 0); + await delay(1_500); + const finalRuntime = await readRuntimeState(runtimeStatePath); + if (finalRuntime.pid !== recoveredRuntime.pid) { + throw new Error("Packaged backend restarted more than once after the controlled crash."); + } + const desktopLog = await readFile(desktopLogPath, "utf8"); + const restartCount = desktopLog.match(/backend exited unexpectedly/g)?.length ?? 0; + if (restartCount !== 1) { + throw new Error(`Expected one controlled backend restart, observed ${restartCount}.`); + } + } + + console.log(`Packaged Linux scenario passed: ${scenario.name}`); + } catch (error) { + scenarioError = new Error( + `${scenario.name} failed: ${error instanceof Error ? error.stack || error.message : String(error)}\nPackaged process output:\n${output}`, + { cause: error }, + ); + cleanupDiagnostics.scenarioError = serializeError(scenarioError); + } finally { + finalScreenshot = await page + ?.screenshot({ fullPage: true, timeout: DIAGNOSTIC_CAPTURE_TIMEOUT_MS }) + .catch((error) => { + cleanupDiagnostics.screenshotError = serializeError(error); + return undefined; + }); + await browser?.close().catch((error) => cleanupErrors.push(error)); + if (child) { + await stopPackagedApp( + child, + electronProcess, + backendProcessGroupId, + cleanupDiagnostics, + ).catch((error) => cleanupErrors.push(error)); + } + cleanupDiagnostics.errors = cleanupErrors.map(serializeError); + let diagnosticsPreserved = false; + if (scenarioError || cleanupErrors.length > 0) { + await preserveFailureDiagnostics({ + scenarioName: scenario.name, + screenshot: finalScreenshot, + output, + desktopLogPath, + serverLogPath, + runtimeStatePath, + cleanupDiagnostics, + }).catch(() => undefined); + diagnosticsPreserved = true; + } + await rm(scenarioRoot, { recursive: true, force: true }).catch((error) => + cleanupErrors.push(error), + ); + if (cleanupDiagnostics.errors.length !== cleanupErrors.length) { + cleanupDiagnostics.errors = cleanupErrors.map(serializeError); + if (diagnosticsPreserved) { + await writeFile( + join(DIAGNOSTIC_DIR, `${scenario.name}-cleanup.json`), + `${JSON.stringify(cleanupDiagnostics, null, 2)}\n`, + "utf8", + ).catch(() => undefined); + } else { + await preserveFailureDiagnostics({ + scenarioName: scenario.name, + screenshot: finalScreenshot, + output, + desktopLogPath, + serverLogPath, + runtimeStatePath, + cleanupDiagnostics, + }).catch(() => undefined); + } + } + } + if (scenarioError || cleanupErrors.length > 0) { + const errors = [scenarioError, ...cleanupErrors].filter(Boolean); + throw errors.length === 1 + ? errors[0] + : new AggregateError(errors, `${scenario.name} failed and cleanup was incomplete.`); + } +} + +async function main() { + if (process.platform !== "linux") { + throw new Error("The installed Debian-package smoke test must run on Linux."); + } + const packagePath = await findDebianPackage(); + console.log(`Testing installed Debian package: ${basename(packagePath)}`); + assertNoExistingScientInstallation(); + let installationAttempted = false; + try { + installationAttempted = true; + installDebianPackage(packagePath); + await assertInstalledDebianSandbox(packagePath); + await runScenario(INSTALLED_EXECUTABLE, { + name: "fresh-profile", + umask: 0o022, + precreatePermissiveState: false, + crashBackend: false, + }); + await runScenario(INSTALLED_EXECUTABLE, { + name: "shared-group-umask", + umask: 0o002, + precreatePermissiveState: true, + crashBackend: true, + }); + } finally { + if (installationAttempted) { + uninstallDebianPackage(); + await access(INSTALLED_EXECUTABLE).then( + () => { + throw new Error(`Debian package removal left ${INSTALLED_EXECUTABLE} installed.`); + }, + (error) => { + if (error?.code !== "ENOENT") throw error; + }, + ); + await access(INSTALLED_APPARMOR_PROFILE).then( + () => { + throw new Error(`Debian package removal left ${INSTALLED_APPARMOR_PROFILE} installed.`); + }, + (error) => { + if (error?.code !== "ENOENT") throw error; + }, + ); + } + } +} + +await main(); diff --git a/apps/web/scripts/linux-packaged-smoke-support.mjs b/apps/web/scripts/linux-packaged-smoke-support.mjs new file mode 100644 index 000000000..b54f4ac71 --- /dev/null +++ b/apps/web/scripts/linux-packaged-smoke-support.mjs @@ -0,0 +1,70 @@ +const DEFAULT_FETCH_ATTEMPT_TIMEOUT_MS = 5_000; +const DEFAULT_WAIT_TIMEOUT_MS = 20_000; +const POLL_INTERVAL_MS = 100; + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +export function assertSandboxedPackagedArguments(args) { + const bypass = args.find( + (argument) => argument === "--no-sandbox" || argument.startsWith("--no-sandbox="), + ); + if (bypass) { + throw new Error(`Packaged Linux smoke must not disable Electron's sandbox: ${bypass}`); + } +} + +export async function waitFor(description, operation, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + try { + const result = await operation({ + deadline, + remainingMs: Math.max(0, deadline - Date.now()), + }); + if (result !== null && result !== false && result !== undefined) return result; + } catch (error) { + lastError = error; + } + + const remainingMs = deadline - Date.now(); + if (remainingMs > 0) await delay(Math.min(POLL_INTERVAL_MS, remainingMs)); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + throw new Error(`Timed out waiting for ${description}${detail}`, { cause: lastError }); +} + +export async function fetchWithinDeadline( + input, + { + deadline, + attemptTimeoutMs = DEFAULT_FETCH_ATTEMPT_TIMEOUT_MS, + consume = (response) => response, + requestInit, + }, +) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw new Error(`Deadline expired before requesting ${String(input)}.`); + + const timeoutMs = Math.max(1, Math.min(attemptTimeoutMs, remainingMs)); + const timeoutController = new AbortController(); + const signal = requestInit?.signal + ? AbortSignal.any([requestInit.signal, timeoutController.signal]) + : timeoutController.signal; + const timeout = setTimeout(() => { + timeoutController.abort( + new Error(`Request to ${String(input)} exceeded its ${timeoutMs}ms deadline.`), + ); + }, timeoutMs); + + try { + const response = await fetch(input, { ...requestInit, signal }); + return await consume(response); + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/web/scripts/linux-packaged-smoke-support.test.mjs b/apps/web/scripts/linux-packaged-smoke-support.test.mjs new file mode 100644 index 000000000..d991f7fc0 --- /dev/null +++ b/apps/web/scripts/linux-packaged-smoke-support.test.mjs @@ -0,0 +1,60 @@ +import { createServer } from "node:http"; + +import { describe, expect, it } from "vitest"; + +import { + assertSandboxedPackagedArguments, + fetchWithinDeadline, + waitFor, +} from "./linux-packaged-smoke-support.mjs"; + +describe("packaged Linux smoke polling", () => { + it("fails closed if a packaged command disables Electron's sandbox", () => { + expect(() => assertSandboxedPackagedArguments(["--disable-gpu", "--no-sandbox"])).toThrow( + "must not disable Electron's sandbox", + ); + expect(() => assertSandboxedPackagedArguments(["--disable-gpu"])).not.toThrow(); + }); + + it("aborts a request that accepts a connection but never responds", async () => { + const sockets = new Set(); + const server = createServer(() => { + // Deliberately leave the request open to model a wedged health or CDP endpoint. + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test server port."); + const startedAt = Date.now(); + + await expect( + waitFor( + "non-responsive endpoint", + ({ deadline }) => + fetchWithinDeadline(`http://127.0.0.1:${address.port}/health`, { + deadline, + attemptTimeoutMs: 50, + consume: (response) => response.ok, + }), + 180, + ), + ).rejects.toThrow(/Timed out waiting for non-responsive endpoint/u); + + expect(Date.now() - startedAt).toBeLessThan(1_000); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())); + }); + } + }); +}); diff --git a/docs/manual-platform-verification.md b/docs/manual-platform-verification.md index 51cc7484c..3703495af 100644 --- a/docs/manual-platform-verification.md +++ b/docs/manual-platform-verification.md @@ -35,7 +35,7 @@ Before a Tier A or B run, confirm all of the following: - The OS user has never launched Scient or the provider being tested. - Scient data, provider configuration, browser profiles, credentials, and relevant environment variables are absent from that user profile. -- The test starts from an exact installer or AppImage with a recorded filename, +- The test starts from an exact installer or package with a recorded filename, SHA-256 digest, source commit, and build run. - The workspace contains only public, disposable fixtures. Never expose a real project or personal account merely to test the UI. @@ -115,22 +115,25 @@ another supported distribution when a change touches packaging or desktop integration. Preserve separate Wayland and X11 results if the behavior differs. 1. Restore a clean VM snapshot with no Scient or provider configuration. -2. Download the exact AppImage and record `uname -a`, desktop session, display +2. Download the exact `.deb` and record `uname -a`, desktop session, display scaling, and its SHA-256 digest. -3. Mark it executable and launch it from the desktop as an ordinary user. +3. Install it through the system package installer, then launch Scient as an + ordinary user. Record the privilege prompt used for installation separately + from the unprivileged application launch. 4. Record missing runtime libraries, sandbox errors, desktop integration prompts, and file-opening behavior instead of repairing the image silently. Tier C shell launch: ```bash -chmod +x ./Scient-*.AppImage -SCIENT_HOME="$(mktemp -d)" ./Scient-*.AppImage +sudo apt install ./Scient-*-amd64.deb +SCIENT_HOME="$(mktemp -d)" scient ``` Do not add `--no-sandbox` merely to obtain a green result. If the packaged app -requires it, that is a release defect or an explicitly documented platform -limitation. +requires it, that is a release defect. The AppImage compatibility artifact +remains fail-closed and is not the supported Ubuntu route because stock Ubuntu +24.04 can block its randomized mount path from obtaining a Chromium sandbox. ## Running a manual verification diff --git a/docs/release.md b/docs/release.md index 908a6ec0a..8a335f377 100644 --- a/docs/release.md +++ b/docs/release.md @@ -13,12 +13,13 @@ This document covers build-only native validation, promotion through the protect - Builds four artifacts in parallel: - macOS `arm64` DMG - macOS `x64` DMG - - Linux `x64` AppImage + - Linux `x64` Debian package - Windows `x64` NSIS installer - 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. -- Publishes default `latest*.yml` metadata plus byte-identical `scient*.yml` aliases on every stable release. +- Publishes the macOS and Windows `latest` metadata plus dedicated `scient` + aliases, and publishes Debian updates only as `scient-deb-linux.yml`. - Keeps the historical 0.4.x compatibility release unchanged; current stable payloads stay on their own GitHub Latest release. - Publishes prerelease installers only on their versioned GitHub prerelease; prereleases never replace the stable `scient` update manifests. - Publishes the CLI package (`apps/server`, npm package `@scientfactory/cli`, executable `scient`) with OIDC trusted publishing when `SCIENT_PUBLISH_CLI=1`. @@ -28,11 +29,20 @@ This document covers build-only native validation, promotion through the protect ## Desktop auto-update notes - Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`. -- Client update checks are enabled in packaged production builds by `SCIENT_DESKTOP_UPDATES_ENABLED = true`. Development builds, unpackaged builds, builds without `app-update.yml`, Linux builds not running as an AppImage, and installations with `SYNARA_DISABLE_AUTO_UPDATE=1` remain disabled. -- Linux AppImage launch is fail-closed: release packaging removes electron-builder's - automatic `--no-sandbox` fallback. A host must provide working unprivileged user - namespaces or a correctly configured Chromium sandbox helper; Scient never trades - sandboxing for startup compatibility. +- Client update checks are enabled in packaged production builds by + `SCIENT_DESKTOP_UPDATES_ENABLED = true`. Development builds, unpackaged + builds, builds without `app-update.yml`, unsupported Linux package types, and + installations with `SYNARA_DISABLE_AUTO_UPDATE=1` remain disabled. +- The supported Ubuntu route is the installed Debian package. Its package + scripts install electron-builder's AppArmor user-namespace profile and manage + the Chromium sandbox helper. Acceptance launches the installed executable as + a normal user and rejects the real process if any `--no-sandbox` argument is + present. +- The AppImage compatibility build remains fail-closed and is checked in CI for + sandbox-bypass injection, but it is not published as the supported Ubuntu + download. Stock Ubuntu 24.04 can deny the randomized AppImage mount path the + user namespace needed by Chromium; Scient does not answer that denial by + disabling the sandbox. - `v0.5.6` was published with client update checks disabled. Existing `v0.5.6` installations therefore require one manual installation of the first updater-enabled release; the application cannot remotely enable code that is @@ -43,20 +53,29 @@ This document covers build-only native validation, promotion through the protect - The desktop UI shows a rocket update button while preparing and switches to an install action once the update is ready. - Provider: GitHub Releases (`provider: github`) configured at build time. - Repository visibility: public. The authenticated private-repository provider does not honor custom channel filenames. -- Runtime channel: `scient`. Stable releases publish both `latest` and `scient` metadata; the configured 0.4.x compatibility release remains available for historical migration. +- Runtime channels: macOS and Windows use `scient`; installed Debian packages + use `scient-deb`. The format-specific Linux channel prevents an older + AppImage updater from downloading a `.deb` and attempting a cross-format + replacement. - Repository slug source: - `SCIENT_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`) is required when releases are enabled. - The workflow requires it to equal the current GitHub repository and requires that repository to be public. - Required Scient release assets for updater: - - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads) - - `scient-mac.yml`, `scient.yml`, and `scient-linux.yml` metadata - - every stable release includes both `scient-mac.yml`, `scient.yml`, `scient-linux.yml` and `latest-mac.yml`, `latest.yml`, `latest-linux.yml` + - platform installers (`.exe`, `.dmg`, `.deb`, plus macOS `.zip` for Squirrel.Mac update payloads) + - `scient-mac.yml`, `scient.yml`, and `scient-deb-linux.yml` metadata + - stable releases intentionally omit `latest-linux.yml` and + `scient-linux.yml`; those names belong to legacy AppImage updaters - `*.blockmap` files, except the macOS update `.zip.blockmap` removed after zip repack - Enforced upgrade path: - - Stable clean Scient releases are created with `make_latest=true` and carry both six-manifest filenames in the versioned release. + - Stable clean Scient releases are created with `make_latest=true` and carry + the two default macOS/Windows manifests plus the three dedicated + platform-channel manifests in the versioned release. - The historical 0.4.x compatibility release remains available for predecessor migration and is never overwritten by a 0.5.x release. - Clean releases do not mirror payloads onto the historical compatibility release, so the 0.4.x line remains immutable. - - Clean-release publication fails closed if either the default Latest manifests or the dedicated `scient` aliases are missing. + - Clean-release publication fails closed if the macOS/Windows Latest + manifests or any dedicated platform alias is missing. + - Existing AppImage users require a one-time manual installation of the + Debian package. There is deliberately no automatic cross-format updater. - Production desktop builds omit web/server/desktop source maps by default to keep update payloads small. Set the inherited `SYNARA_WEB_SOURCEMAP=1` or `SYNARA_SERVER_SOURCEMAP=1`, or the Scient-owned `SCIENT_DESKTOP_SOURCEMAP=1`, only for a diagnostic release that needs them. - macOS metadata note: - The build initially emits `latest-mac.yml` for both Intel and Apple Silicon. @@ -106,6 +125,10 @@ Checklist: - Set `SCIENT_DESKTOP_RELEASES_ENABLED=true` only after `SCIENT_DESKTOP_UPDATE_REPOSITORY=ScientFactory/scient-desktop` is configured and the release candidate is ready for native CI validation. - The desktop updater expects the pinned compatibility release in this repository to include the generated updater metadata files, not just the installers. - The published release title should read `Scient vX.Y.Z`. +- Before publishing the first Debian-based Linux release, deploy the coordinated + website change that selects `Scient-*-amd64.deb`, labels it as a Debian + package, and gives package-manager installation instructions. The current + AppImage download selector must not remain the public default. - By default, the first-party desktop release path does not require CLI publish or post-release version-bump automation. - Optional jobs stay disabled unless repository variables enable them: - `SCIENT_PUBLISH_CLI=1` @@ -151,7 +174,8 @@ The override applies only to Windows: notarization, a stapled ticket, and final artifact verification. There is no unsigned public macOS lane because ad-hoc releases do not retain a stable privacy identity across updates. -- Linux keeps the existing AppImage behavior and is unaffected by the override. +- Linux keeps the required sandboxed Debian-package behavior and is unaffected + by the override. Never enable unsigned publication while a platform has a partial signing-secret configuration. Remove incomplete secrets or finish the signing setup first. diff --git a/package.json b/package.json index 991a4af3c..8b4d07317 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "brand:check": "node scripts/check-brand-identity.ts", "workflow:check": "bun scripts/check-workflow-actions.ts", "test:desktop-smoke": "turbo run smoke-test --filter=@synara/desktop", + "test:linux-deb": "bun run --cwd apps/web test:linux-deb", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "build:contracts": "turbo run build --filter=@synara/contracts", @@ -46,7 +47,8 @@ "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", - "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", + "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target deb --arch x64", + "dist:desktop:linux:appimage": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "scient:upstream-check": "bun scripts/scient-upstream-check.ts", diff --git a/packages/shared/src/desktopIdentity.test.ts b/packages/shared/src/desktopIdentity.test.ts index 2377d4fcb..e04dab566 100644 --- a/packages/shared/src/desktopIdentity.test.ts +++ b/packages/shared/src/desktopIdentity.test.ts @@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest"; import { SCIENT_APP_NAME, SCIENT_DESKTOP_ENTRY_URL, + SCIENT_DESKTOP_DEB_UPDATE_CHANNEL, SCIENT_DESKTOP_ORIGIN, SCIENT_DESKTOP_UPDATE_CHANNEL, SCIENT_DESKTOP_UPDATES_ENABLED, SCIENT_DEVELOPMENT_BUNDLE_ID, SCIENT_PRODUCTION_BUNDLE_ID, scientBundleId, + scientDesktopUpdateChannel, } from "./desktopIdentity"; describe("desktopIdentity", () => { @@ -27,6 +29,10 @@ describe("desktopIdentity", () => { it("enables the approved Scient-owned release channel", () => { expect(SCIENT_DESKTOP_UPDATE_CHANNEL).toBe("scient"); + expect(SCIENT_DESKTOP_DEB_UPDATE_CHANNEL).toBe("scient-deb"); expect(SCIENT_DESKTOP_UPDATES_ENABLED).toBe(true); + expect(scientDesktopUpdateChannel("linux", "deb")).toBe("scient-deb"); + expect(scientDesktopUpdateChannel("linux", "AppImage")).toBe("scient"); + expect(scientDesktopUpdateChannel("darwin", null)).toBe("scient"); }); }); diff --git a/packages/shared/src/desktopIdentity.ts b/packages/shared/src/desktopIdentity.ts index 09f133eb4..3cd0c335e 100644 --- a/packages/shared/src/desktopIdentity.ts +++ b/packages/shared/src/desktopIdentity.ts @@ -6,10 +6,20 @@ export const SCIENT_DESKTOP_SCHEME = "scient"; export const SCIENT_DESKTOP_ORIGIN = `${SCIENT_DESKTOP_SCHEME}://app`; export const SCIENT_DESKTOP_ENTRY_URL = `${SCIENT_DESKTOP_ORIGIN}/index.html`; export const SCIENT_DESKTOP_UPDATE_CHANNEL = "scient"; +export const SCIENT_DESKTOP_DEB_UPDATE_CHANNEL = "scient-deb"; export const SCIENT_DESKTOP_UPDATES_ENABLED = true; export const SCIENT_PRODUCTION_BUNDLE_ID = "com.scientfactory.scient"; export const SCIENT_DEVELOPMENT_BUNDLE_ID = `${SCIENT_PRODUCTION_BUNDLE_ID}.dev`; +export function scientDesktopUpdateChannel( + platform: NodeJS.Platform, + linuxPackageType: string | null, +): string { + return platform === "linux" && linuxPackageType === "deb" + ? SCIENT_DESKTOP_DEB_UPDATE_CHANNEL + : SCIENT_DESKTOP_UPDATE_CHANNEL; +} + export function scientBundleId(isDevelopment: boolean): string { return isDevelopment ? SCIENT_DEVELOPMENT_BUNDLE_ID : SCIENT_PRODUCTION_BUNDLE_ID; } diff --git a/scripts/build-desktop-artifact-mac-config.test.ts b/scripts/build-desktop-artifact-mac-config.test.ts index 6631cf27e..b0f38efdd 100644 --- a/scripts/build-desktop-artifact-mac-config.test.ts +++ b/scripts/build-desktop-artifact-mac-config.test.ts @@ -160,6 +160,32 @@ describe("createDesktopPlatformBuildConfig", () => { }); }); + it("gives the supported Debian package a stable system identity", () => { + const config = createDesktopPlatformBuildConfig({ + platform: "linux", + target: "deb", + }); + + assert.deepStrictEqual(config.deb, { + packageName: "scient", + maintainer: "ScientFactory", + vendor: "ScientFactory", + }); + assert.deepStrictEqual(config.linux, { + target: ["deb"], + executableName: "scient", + executableArgs: [], + syncDesktopName: true, + icon: "icon.png", + category: "Development", + desktop: { + entry: { + StartupWMClass: "scient", + }, + }, + }); + }); + it("keeps Windows signing optional", () => { const config = createDesktopPlatformBuildConfig({ platform: "win", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5e33d9a0d..9b6036844 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -98,7 +98,7 @@ const PLATFORM_CONFIG: Record = { }, linux: { cliFlag: "--linux", - defaultTarget: "AppImage", + defaultTarget: "deb", archChoices: ["x64", "arm64"], }, win: { @@ -221,6 +221,7 @@ interface StagePackageJson { readonly private: true; readonly description: string; readonly author: string; + readonly homepage: string; readonly main: string; readonly build: Record; readonly dependencies: Record; @@ -1045,6 +1046,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( private: true, description: "Scient desktop build", author: "Yaacov Corcos", + homepage: "https://github.com/ScientFactory/scient-desktop", main: "apps/desktop/dist-electron/main.js", build: platformBuildConfig, dependencies: { diff --git a/scripts/lib/desktop-platform-build-config.ts b/scripts/lib/desktop-platform-build-config.ts index 72a5218f0..ab11c561f 100644 --- a/scripts/lib/desktop-platform-build-config.ts +++ b/scripts/lib/desktop-platform-build-config.ts @@ -27,6 +27,7 @@ export interface DesktopPlatformBuildConfig { readonly afterSign?: string; readonly afterPack?: string; readonly asarUnpack?: ReadonlyArray; + readonly deb?: Record; readonly extraFiles?: ReadonlyArray>; readonly extraResources?: ReadonlyArray>; readonly files?: ReadonlyArray; @@ -141,6 +142,15 @@ export function createDesktopPlatformBuildConfig( if (input.platform === "linux") { return { ...nativePackaging, + ...(input.target.toLowerCase() === "deb" + ? { + deb: { + packageName: "scient", + maintainer: "ScientFactory", + vendor: "ScientFactory", + }, + } + : {}), linux: { target: [input.target], executableName: "scient", diff --git a/scripts/lib/release-update-policy.ts b/scripts/lib/release-update-policy.ts index 7270604ed..93d45312b 100644 --- a/scripts/lib/release-update-policy.ts +++ b/scripts/lib/release-update-policy.ts @@ -3,7 +3,7 @@ // releases publish through GitHub's Latest updater feed and retain the packaged app's // dedicated `scient` channel aliases. -import { constants, copyFileSync, existsSync, readFileSync } from "node:fs"; +import { constants, copyFileSync, existsSync, readFileSync, rmSync } from "node:fs"; import { resolve } from "node:path"; export type ReleaseLane = "bridge" | "clean"; @@ -111,6 +111,15 @@ export function channelManifestNames(channel: string): readonly string[] { return [`${channel}-mac.yml`, `${channel}.yml`, `${channel}-linux.yml`]; } +export function cleanReleaseManifestNames(channel: string): readonly string[] { + const [macManifest, windowsManifest] = channelManifestNames(channel); + const linuxDebManifest = channelManifestNames(`${channel}-deb`)[2]; + if (!macManifest || !windowsManifest || !linuxDebManifest) { + throw new Error(`Could not resolve release manifest names for channel ${channel}.`); + } + return [macManifest, windowsManifest, linuxDebManifest]; +} + function copyChannelManifests( assetDirectory: string, sourceNames: readonly string[], @@ -137,7 +146,10 @@ export function prepareReleaseUpdateManifests( ): readonly string[] { const normalizedConfig = validateReleaseUpdatePolicyConfig(config); const sourceNames = ["latest-mac.yml", "latest.yml", "latest-linux.yml"] as const; - const destinationNames = channelManifestNames(normalizedConfig.channel); + const destinationNames = + normalizedConfig.lane === "bridge" + ? channelManifestNames(normalizedConfig.channel) + : cleanReleaseManifestNames(normalizedConfig.channel); if (normalizedConfig.lane === "bridge") { const missing = sourceNames.filter((name) => !existsSync(resolve(assetDirectory, name))); if (missing.length > 0) { @@ -151,9 +163,10 @@ export function prepareReleaseUpdateManifests( if (missing.length > 0) { throw new Error(`Latest release is missing update manifests: ${missing.join(", ")}`); } - // Stable 0.5.x releases are GitHub Latest, but shipped desktop binaries still - // request the dedicated `scient` channel. Keep both filenames in the same - // release so existing installations and new Latest installs use the same feed. + // macOS and Windows continue on the dedicated Scient channel. Debian packages + // use a format-specific channel so an older AppImage updater can never download + // a .deb and attempt an unsafe cross-format replacement. copyChannelManifests(assetDirectory, sourceNames, destinationNames); - return [...sourceNames, ...destinationNames]; + rmSync(resolve(assetDirectory, "latest-linux.yml")); + return [sourceNames[0], sourceNames[1], ...destinationNames]; } diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index d38c94015..240b1347f 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; import { + SCIENT_DESKTOP_DEB_UPDATE_CHANNEL, SCIENT_DESKTOP_UPDATES_ENABLED, SCIENT_DESKTOP_UPDATE_CHANNEL, SCIENT_PRODUCTION_BUNDLE_ID, @@ -37,6 +38,10 @@ import { readReleaseUpdatePolicyConfig, resolveReleaseUpdatePolicy, } from "./lib/release-update-policy.ts"; +import { + assertPackagedLaunchCommandSafety, + createLinuxPackagedLaunchCommand, +} from "./verify-packaged-desktop-startup.ts"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -159,6 +164,17 @@ function assertScopedSigningEnvironment( } } +function assertOrdered(haystack: string, needles: ReadonlyArray, message: string): void { + let previousIndex = -1; + for (const needle of needles) { + const index = haystack.indexOf(needle, previousIndex + 1); + if (index < 0 || index <= previousIndex) { + throw new Error(message); + } + previousIndex = index; + } +} + function verifyCanonicalIdentity(): void { const serverPackage = JSON.parse( readFileSync(resolve(repoRoot, "apps/server/package.json"), "utf8"), @@ -180,17 +196,26 @@ function verifyCanonicalIdentity(): void { if (SCIENT_DESKTOP_UPDATE_CHANNEL !== "scient") { throw new Error(`Unexpected desktop update channel: ${SCIENT_DESKTOP_UPDATE_CHANNEL}.`); } + if (SCIENT_DESKTOP_DEB_UPDATE_CHANNEL !== "scient-deb") { + throw new Error( + `Unexpected Debian desktop update channel: ${SCIENT_DESKTOP_DEB_UPDATE_CHANNEL}.`, + ); + } if (!SCIENT_DESKTOP_UPDATES_ENABLED) { throw new Error("Expected packaged Scient clients to use the approved update channel."); } - const linux = createDesktopPlatformBuildConfig({ platform: "linux", target: "AppImage" }).linux; + const linuxConfig = createDesktopPlatformBuildConfig({ platform: "linux", target: "deb" }); + const linux = linuxConfig.linux; if (!linux || linux.executableName !== "scient") { throw new Error("Expected Linux desktop releases to install the scient executable."); } if (!Array.isArray(linux.executableArgs) || linux.executableArgs.length !== 0) { throw new Error("Expected Linux desktop entries to preserve Electron's sandbox."); } + if (linuxConfig.deb?.packageName !== "scient" || linuxConfig.deb.maintainer !== "ScientFactory") { + throw new Error("Expected Debian releases to use the canonical Scient package identity."); + } const requireFromElectronBuilder = createRequire( realpathSync(resolve(repoRoot, "node_modules/electron-builder/package.json")), ); @@ -228,6 +253,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", @@ -463,18 +492,28 @@ function verifyReleaseWorkflowSafety(): void { ); assertContains( workflow, - '"Scient-${RELEASE_VERSION}-x86_64.AppImage"', - "Expected the public contract to validate the Linux AppImage filename.", + '"Scient-${RELEASE_VERSION}-amd64.deb"', + "Expected the public contract to validate the Linux Debian filename.", ); assertContains( workflow, - "Verify Linux AppImage sandbox policy", - "Expected the release workflow to inspect the packaged Linux launcher.", + '"${UPDATE_CHANNEL}-deb-linux.yml"', + "Expected Debian updates to use a format-specific channel.", ); - assertContains( + assertNotContains( workflow, + '"${UPDATE_CHANNEL}-linux.yml"', + "Debian releases must not overwrite the legacy AppImage update channel.", + ); + assertContains( + ciWorkflow, + "Verify AppImage never injects a sandbox bypass", + "Expected CI to retain an honest fail-closed AppImage packaging check.", + ); + assertContains( + ciWorkflow, "grep -F -- '--no-sandbox' \"$launcher\"", - "Expected the release workflow to reject AppImages that disable Electron's sandbox.", + "Expected CI to reject AppImages that disable Electron's sandbox.", ); assertContains( workflow, @@ -486,6 +525,123 @@ function verifyReleaseWorkflowSafety(): void { 'node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"\n bun install --lockfile-only --ignore-scripts', "Expected artifact builds to refresh lockfile metadata after aligning workspace versions.", ); + assertContains( + workflow, + "if: ${{ matrix.platform != 'linux' }}", + "Expected non-Linux native builders to use the cross-platform startup smoke.", + ); + assertContains( + workflow, + "node scripts/verify-packaged-desktop-startup.ts \\" + + "\n --assets-dir release-publish", + "Expected packaged startup verification to read from the exact release-publish payload.", + ); + assertContains( + workflow, + "if: ${{ matrix.platform == 'linux' }}\n shell: bash\n env:\n DEBIAN_FRONTEND: noninteractive", + "Expected Linux-only packaged smoke dependencies.", + ); + assertContains( + workflow, + "./apps/web/node_modules/.bin/playwright install-deps chromium", + "Expected the Linux release runner to install browser-system dependencies.", + ); + assertContains( + workflow, + "SCIENT_LINUX_ARTIFACT_DIR: release-publish\n SCIENT_LINUX_SMOKE_ARTIFACT_DIR: test-results/linux-release-deb", + "Expected the deep Linux lifecycle smoke to exercise the exact collected Debian package.", + ); + assertContains( + workflow, + "run: node apps/web/scripts/linux-deb-smoke.mjs", + "Expected Linux release builds to run the installed Debian lifecycle test.", + ); + assertOrdered( + workflow, + [ + "- name: Collect release assets", + "- name: Smoke exact packaged desktop startup", + "- name: Exercise exact packaged Linux lifecycle", + "- name: Upload build artifacts", + ], + "Expected exact-artifact startup and Linux lifecycle verification before artifact upload.", + ); + + const packagedStartupVerifier = readFileSync( + resolve(repoRoot, "scripts/verify-packaged-desktop-startup.ts"), + "utf8", + ).replaceAll("\r\n", "\n"); + assertContains( + packagedStartupVerifier, + "SCIENT_HOME: scientHome", + "Expected packaged startup verification to use isolated Scient state.", + ); + assertContains( + packagedStartupVerifier, + "PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST", + "Expected packaged startup verification to inherit only explicitly allowed native host variables.", + ); + assertContains( + packagedStartupVerifier, + "Scient\\.exe", + "Expected packaged Windows verification to require Scient.exe.", + ); + assertNotContains( + packagedStartupVerifier, + "Synara\\.exe", + "Packaged startup verification must not retain the Synara executable identity.", + ); + + const packagedLinuxLaunch = createLinuxPackagedLaunchCommand("/opt/Scient/scient", "/opt/Scient"); + assertPackagedLaunchCommandSafety(packagedLinuxLaunch); + if (packagedLinuxLaunch.args.some((argument) => argument.startsWith("--no-sandbox"))) { + throw new Error("Exact packaged Linux verification must not disable Electron's sandbox."); + } + assertContains( + packagedStartupVerifier, + "assertPackagedLaunchCommandSafety(launch);", + "Expected every packaged platform command to fail closed if sandbox bypass is introduced.", + ); + assertContains( + packagedStartupVerifier, + 'log.includes("renderer main frame loaded")', + "Expected cross-platform packaged startup proof to require successful renderer loading.", + ); + + const packagedLinuxLifecycleSmoke = readFileSync( + resolve(repoRoot, "apps/web/scripts/linux-deb-smoke.mjs"), + "utf8", + ).replaceAll("\r\n", "\n"); + assertContains( + packagedLinuxLifecycleSmoke, + "...sanitizePackagedDesktopInheritedEnvironment(process.env)", + "Expected deep packaged Linux verification to use the same inherited-environment allowlist.", + ); + assertNotContains( + packagedLinuxLifecycleSmoke, + "...process.env", + "Deep packaged Linux verification must not inherit credentials or developer runtime overrides.", + ); + assertContains( + packagedLinuxLifecycleSmoke, + "assertSandboxedPackagedArguments(electronProcess.commandLine);", + "Expected packaged Linux acceptance to validate the actual mounted Electron process arguments.", + ); + + const desktopMain = readFileSync( + resolve(repoRoot, "apps/desktop/src/main.ts"), + "utf8", + ).replaceAll("\r\n", "\n"); + assertContains( + desktopMain, + 'writeDesktopLogHeader("renderer main frame loaded")', + "Expected the desktop process to record successful renderer main-frame loading.", + ); + assertContains( + desktopMain, + '"did-fail-load"', + "Expected renderer main-frame load failures to be recorded for packaged diagnostics.", + ); } function verifyDesktopStageLockAuthority(): void { diff --git a/scripts/release-update-policy.test.ts b/scripts/release-update-policy.test.ts index 557bbe386..dc46e448f 100644 --- a/scripts/release-update-policy.test.ts +++ b/scripts/release-update-policy.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { channelManifestNames, + cleanReleaseManifestNames, prepareReleaseUpdateManifests, resolveReleaseUpdatePolicy, type ReleaseUpdatePolicyConfig, @@ -52,7 +53,7 @@ describe("release update policy", () => { ); }); - it("keeps clean release metadata on Latest and dedicated channel filenames", () => { + it("keeps desktop channels format-safe across the AppImage to Debian migration", () => { const root = mkdtempSync(join(tmpdir(), "scient-release-policy-")); try { mkdirSync(root, { recursive: true }); @@ -61,19 +62,21 @@ describe("release update policy", () => { } expect(prepareReleaseUpdateManifests(root, cleanConfig)).toEqual([ - ...defaultManifestNames, - ...channelManifestNames("scient"), + "latest-mac.yml", + "latest.yml", + ...cleanReleaseManifestNames("scient"), ]); - for (const name of defaultManifestNames) { + for (const name of ["latest-mac.yml", "latest.yml"]) { expect(readFileSync(resolve(root, name), "utf8")).toBe(name); } - for (const [index, channelName] of channelManifestNames("scient").entries()) { + for (const [index, channelName] of cleanReleaseManifestNames("scient").entries()) { const defaultName = defaultManifestNames[index]; if (!defaultName) throw new Error(`Missing default manifest mapping for ${channelName}`); - expect(readFileSync(resolve(root, channelName), "utf8")).toBe( - readFileSync(resolve(root, defaultName), "utf8"), - ); + expect(readFileSync(resolve(root, channelName), "utf8")).toBe(defaultName); } + expect(existsSync(resolve(root, "latest-linux.yml"))).toBe(false); + expect(existsSync(resolve(root, "scient-linux.yml"))).toBe(false); + expect(readFileSync(resolve(root, "scient-deb-linux.yml"), "utf8")).toBe("latest-linux.yml"); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts new file mode 100644 index 000000000..52252b3d1 --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -0,0 +1,308 @@ +import type { ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + assertPackagedLaunchCommandSafety, + createLinuxPackagedLaunchCommand, + createPackagedDesktopSmokeEnvironment, + expectedPackagedDesktopStartupAssetName, + isScientWindowsExecutable, + parsePackagedDesktopStartupArgs, + resolveExactPackagedDesktopStartupAsset, + resolveNativePackagedDesktopPlatform, + resolvePackagedDesktopLogPath, + sanitizePackagedDesktopInheritedEnvironment, + terminateProcessTree, + waitForPackagedStartupProof, +} from "./verify-packaged-desktop-startup.ts"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("packaged desktop startup verification", () => { + it("parses a bounded native payload request", () => { + expect( + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "linux", + "--arch", + "x64", + "--version", + "1.2.3", + ]), + ).toEqual({ + assetsDirectory: expect.stringMatching(/release-publish$/), + platform: "linux", + arch: "x64", + version: "1.2.3", + timeoutMs: 60_000, + }); + + expect(() => + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "linux", + "--arch", + "x64", + "--version", + "1.2.3", + "--timeout-ms", + "4999", + ]), + ).toThrow("--timeout-ms must be an integer between 5000 and 180000"); + }); + + it("isolates Scient state and removes inherited runtime authority", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-env-test-")); + temporaryRoots.push(root); + + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "linux", version: "1.2.3" }, + { + DISPLAY: ":99", + NODE_OPTIONS: "--require /tmp/untrusted.js", + OPENAI_API_KEY: "must-not-leak", + PATH: process.env.PATH, + SCIENT_DEV_ALLOW_NO_SANDBOX: "1", + SCIENT_HOME: "/must/not/leak", + LEGACY_PRODUCT_HOME: "/must/not/leak-either", + PROVIDER_AUTH_TOKEN: "must-not-leak", + ELECTRON_RUN_AS_NODE: "1", + }, + ); + + expect(env.LEGACY_PRODUCT_HOME).toBeUndefined(); + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.PROVIDER_AUTH_TOKEN).toBeUndefined(); + expect(env.SCIENT_DEV_ALLOW_NO_SANDBOX).toBeUndefined(); + expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(env.DISPLAY).toBe(":99"); + for (const name of [ + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "SCIENT_HOME", + ] as const) { + expect(env[name]?.startsWith(root)).toBe(true); + expect(existsSync(env[name]!)).toBe(true); + } + if (process.platform !== "win32") { + expect(statSync(env.XDG_RUNTIME_DIR!).mode & 0o777).toBe(0o700); + } + expect(resolvePackagedDesktopLogPath(env)).toBe( + join(env.SCIENT_HOME!, "userdata", "logs", "desktop-main.log"), + ); + }); + + it("allowlists only host variables needed to launch a native packaged app", () => { + expect( + sanitizePackagedDesktopInheritedEnvironment({ + DISPLAY: ":99", + ELECTRON_RUN_AS_NODE: "1", + NODE_OPTIONS: "--inspect", + OPENAI_API_KEY: "secret", + PATH: "/usr/bin", + SystemRoot: "C:\\Windows", + }), + ).toEqual({ DISPLAY: ":99", PATH: "/usr/bin", SystemRoot: "C:\\Windows" }); + }); + + it("requires the exact versioned and architecture-specific release asset", () => { + expect(expectedPackagedDesktopStartupAssetName("linux", "x64", "1.2.3")).toBe( + "Scient-1.2.3-amd64.deb", + ); + 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-amd64.deb"); + writeFileSync(expected, "payload"); + expect(resolveExactPackagedDesktopStartupAsset(root, "Scient-1.2.3-amd64.deb")).toBe(expected); + + writeFileSync(join(root, "Scient-1.2.2-amd64.deb"), "stale payload"); + expect(() => resolveExactPackagedDesktopStartupAsset(root, "Scient-1.2.3-amd64.deb")).toThrow( + "found 2 .deb payloads", + ); + }); + + it("does not accept proof from a packaged process that exits immediately", async () => { + let now = 0; + let outcome = { exited: null, launchError: null } as { + exited: { code: number | null; signal: NodeJS.Signals | null } | null; + launchError: Error | null; + }; + + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => outcome, + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + outcome = { exited: { code: 1, signal: null }, launchError: null }; + }, + }), + ).rejects.toThrow("exited before stable startup proof"); + }); + + it("accepts startup proof only after the process remains alive for the stability window", async () => { + let now = 0; + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => ({ exited: null, launchError: null }), + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + }, + }), + ).resolves.toBeUndefined(); + expect(now).toBeGreaterThanOrEqual(1_000); + }); + + it("fails when Windows process-tree cleanup cannot prove exit", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 1 })); + await expect( + terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived Windows cleanup"); + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42, 84]); + }); + + it("still cleans a detached Windows backend after the packaged root exits", async () => { + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + + await terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => true, + }, + [84], + ); + + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([84]); + }); + + it("fails when a POSIX process tree survives TERM and KILL", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + await expect( + terminateProcessTree( + child, + { + platform: "linux", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived SIGTERM and SIGKILL"); + expect(signals).toEqual([ + { pid: 42, signal: "SIGTERM" }, + { pid: 84, signal: "SIGTERM" }, + { pid: 42, signal: "SIGKILL" }, + { pid: 84, signal: "SIGKILL" }, + ]); + }); + + it("prepares the isolated Scient macOS profile marker", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-mac-env-test-")); + temporaryRoots.push(root); + + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "mac", version: "1.2.3" }, + { PATH: process.env.PATH }, + ); + const markerPath = join( + env.HOME!, + "Library", + "Application Support", + "scient", + "last-launch-version.json", + ); + + expect(JSON.parse(readFileSync(markerPath, "utf8"))).toEqual({ version: "1.2.3" }); + }); + + it("recognizes only the Scient Windows executable identity", () => { + expect(isScientWindowsExecutable("C:\\payload\\Scient.exe")).toBe(true); + expect(isScientWindowsExecutable("C:\\payload\\scient.EXE")).toBe(true); + expect(isScientWindowsExecutable("C:\\payload\\Synara.exe")).toBe(false); + expect(isScientWindowsExecutable("C:\\payload\\Scient Helper.exe")).toBe(false); + }); + + it("keeps exact packaged Linux verification on the real sandboxed command line", () => { + const launch = createLinuxPackagedLaunchCommand("/opt/Scient/scient", "/opt/Scient"); + + expect(launch).toEqual({ + command: "xvfb-run", + args: ["-a", "/opt/Scient/scient", "--disable-gpu"], + cwd: "/opt/Scient", + }); + expect(launch.args).not.toContain("--no-sandbox"); + 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")).toBe("linux"); + }); +}); diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts new file mode 100644 index 000000000..1dd6b51bb --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.ts @@ -0,0 +1,603 @@ +#!/usr/bin/env node +// FILE: verify-packaged-desktop-startup.ts +// Purpose: Launches an exact collected desktop release payload from isolated temporary state. +// Layer: Release verification script + +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type PackagedDesktopPlatform = "linux" | "mac" | "win"; + +export interface PackagedDesktopStartupOptions { + readonly assetsDirectory: string; + readonly platform: PackagedDesktopPlatform; + readonly arch: string; + readonly version: string; + readonly timeoutMs: number; +} + +export function parsePackagedDesktopStartupArgs( + argv: ReadonlyArray, +): PackagedDesktopStartupOptions { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || values.has(name)) { + throw new Error(`Invalid packaged startup argument near ${name ?? ""}.`); + } + values.set(name, value); + } + + const known = new Set(["--assets-dir", "--platform", "--arch", "--version", "--timeout-ms"]); + for (const name of values.keys()) { + if (!known.has(name)) throw new Error(`Unknown packaged startup argument: ${name}.`); + } + + const required = (name: string): string => { + const value = values.get(name)?.trim(); + if (!value) throw new Error(`Missing packaged startup argument: ${name}.`); + return value; + }; + + const platform = required("--platform"); + if (platform !== "linux" && platform !== "mac" && platform !== "win") { + throw new Error(`Unsupported packaged startup platform: ${platform}.`); + } + + const timeoutMs = Number(values.get("--timeout-ms") ?? "60000"); + if (!Number.isInteger(timeoutMs) || timeoutMs < 5_000 || timeoutMs > 180_000) { + throw new Error("--timeout-ms must be an integer between 5000 and 180000."); + } + + return { + assetsDirectory: resolve(required("--assets-dir")), + platform, + arch: required("--arch"), + version: required("--version"), + timeoutMs, + }; +} + +function runCommand(command: string, args: ReadonlyArray, cwd?: string): void { + const result = spawnSync(command, [...args], { + cwd, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + shell: false, + windowsHide: true, + }); + if (result.error) { + throw new Error(`${command} could not start: ${result.error.message}`); + } + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + const detail = output ? `\n${output}` : ""; + throw new Error( + `${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}.${detail}`, + ); + } +} + +function findFiles(root: string, predicate: (path: string) => boolean): string[] { + const matches: string[] = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.shift(); + if (!current) continue; + for (const entry of readdirSync(current, { withFileTypes: true })) { + const candidate = join(current, entry.name); + if (entry.isDirectory()) { + pending.push(candidate); + } else if (entry.isFile() && predicate(candidate)) { + matches.push(candidate); + } + } + } + return matches.toSorted((left, right) => left.localeCompare(right)); +} + +export function expectedPackagedDesktopStartupAssetName( + platform: PackagedDesktopPlatform, + arch: string, + version: string, +): string { + const artifactArch = platform === "linux" && arch === "x64" ? "amd64" : arch; + const extension = platform === "mac" ? ".zip" : platform === "linux" ? ".deb" : ".exe"; + return `Scient-${version}-${artifactArch}${extension}`; +} + +export function resolveExactPackagedDesktopStartupAsset( + directory: string, + expectedName: string, +): string { + const suffix = expectedName.slice(expectedName.lastIndexOf(".")); + const matches = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(suffix)) + .map((entry) => join(directory, entry.name)); + if (matches.length !== 1) { + throw new Error( + `Expected exactly ${expectedName}; found ${matches.length} ${suffix} payloads: ${matches.map((match) => basename(match)).join(", ") || "none"}.`, + ); + } + if (basename(matches[0]!) !== expectedName) { + throw new Error( + `Expected exact release asset ${expectedName}, found ${basename(matches[0]!)}.`, + ); + } + return matches[0]!; +} + +export interface PackagedDesktopLaunchCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; +} + +export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchCommand): void { + const forbiddenArgument = launch.args.find( + (argument) => argument === "--no-sandbox" || argument.startsWith("--no-sandbox="), + ); + if (forbiddenArgument) { + throw new Error( + `Packaged desktop verification must exercise the real sandboxed command line; refusing ${forbiddenArgument}.`, + ); + } +} + +function prepareMacLaunch( + assetsDirectory: string, + extractionRoot: string, + expectedAssetName: string, +): PackagedDesktopLaunchCommand { + const archive = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); + runCommand("ditto", ["-x", "-k", archive, extractionRoot]); + const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); + if (appBundles.length !== 1) { + throw new Error(`Expected one packaged macOS app in ${basename(archive)}.`); + } + const appBundle = join(extractionRoot, appBundles[0]!); + const executables = findFiles(join(appBundle, "Contents", "MacOS"), (candidate) => + statSync(candidate).isFile(), + ); + if (executables.length !== 1) { + throw new Error(`Expected one macOS main executable, found ${executables.length}.`); + } + return { command: executables[0]!, args: [], cwd: appBundle }; +} + +export function createLinuxPackagedLaunchCommand( + executable: string, + cwd: string, +): PackagedDesktopLaunchCommand { + return { + command: "xvfb-run", + args: ["-a", executable, "--disable-gpu"], + cwd, + }; +} + +function prepareLinuxLaunch( + assetsDirectory: string, + expectedAssetName: string, +): PackagedDesktopLaunchCommand { + resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); + return createLinuxPackagedLaunchCommand("/opt/Scient/scient", "/opt/Scient"); +} + +export function isScientWindowsExecutable(candidate: string): boolean { + return /[/\\]Scient\.exe$/i.test(candidate); +} + +function prepareWindowsLaunch( + assetsDirectory: string, + extractionRoot: string, + expectedAssetName: string, +): PackagedDesktopLaunchCommand { + const installer = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); + const installerRoot = join(extractionRoot, "installer"); + const applicationRoot = join(extractionRoot, "application"); + mkdirSync(installerRoot, { recursive: true }); + mkdirSync(applicationRoot, { recursive: true }); + runCommand("7z", ["x", "-y", `-o${installerRoot}`, installer]); + const applicationArchives = findFiles(installerRoot, (candidate) => + /[/\\]app-(?:32|64|arm64)\.7z$/i.test(candidate), + ); + if (applicationArchives.length !== 1) { + throw new Error( + `Expected one embedded NSIS application archive, found ${applicationArchives.length}.`, + ); + } + runCommand("7z", ["x", "-y", `-o${applicationRoot}`, applicationArchives[0]!]); + const executables = findFiles(applicationRoot, isScientWindowsExecutable); + if (executables.length !== 1) { + throw new Error(`Expected one extracted Scient.exe, found ${executables.length}.`); + } + return { command: executables[0]!, args: [], cwd: dirname(executables[0]!) }; +} + +function prepareLaunch( + options: PackagedDesktopStartupOptions, + extractionRoot: string, +): PackagedDesktopLaunchCommand { + const expectedAssetName = expectedPackagedDesktopStartupAssetName( + options.platform, + options.arch, + options.version, + ); + let launch: PackagedDesktopLaunchCommand; + if (options.platform === "mac") { + launch = prepareMacLaunch(options.assetsDirectory, extractionRoot, expectedAssetName); + } else if (options.platform === "linux") { + launch = prepareLinuxLaunch(options.assetsDirectory, expectedAssetName); + } else { + launch = prepareWindowsLaunch(options.assetsDirectory, extractionRoot, expectedAssetName); + } + assertPackagedLaunchCommandSafety(launch); + return launch; +} + +export function createPackagedDesktopSmokeEnvironment( + root: string, + options: Pick, + inheritedEnvironment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const isolatedHome = join(root, "home"); + const scientHome = join(root, "scient-home"); + const env = sanitizePackagedDesktopInheritedEnvironment(inheritedEnvironment); + Object.assign(env, { + HOME: isolatedHome, + USERPROFILE: isolatedHome, + APPDATA: join(root, "appdata"), + LOCALAPPDATA: join(root, "localappdata"), + XDG_CONFIG_HOME: join(root, "xdg-config"), + XDG_CACHE_HOME: join(root, "xdg-cache"), + XDG_DATA_HOME: join(root, "xdg-data"), + XDG_RUNTIME_DIR: join(root, "xdg-runtime"), + SCIENT_HOME: scientHome, + SYNARA_DISABLE_AUTO_UPDATE: "1", + ELECTRON_ENABLE_LOGGING: "1", + }); + for (const path of [ + env.HOME, + env.APPDATA, + env.LOCALAPPDATA, + env.XDG_CONFIG_HOME, + env.XDG_CACHE_HOME, + env.XDG_DATA_HOME, + env.SCIENT_HOME, + ]) { + if (path) mkdirSync(path, { recursive: true }); + } + if (env.XDG_RUNTIME_DIR) { + mkdirSync(env.XDG_RUNTIME_DIR, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") chmodSync(env.XDG_RUNTIME_DIR, 0o700); + } + + if (options.platform === "mac") { + const userDataPath = join(isolatedHome, "Library", "Application Support", "scient"); + mkdirSync(userDataPath, { recursive: true }); + // Prevent the packaged app's update-only icon repair from registering this + // temporary bundle in the runner's normal Launch Services database. + const launchVersionPath = join(userDataPath, "last-launch-version.json"); + writeFileSync(launchVersionPath, `${JSON.stringify({ version: options.version }, null, 2)}\n`); + } + return env; +} + +const PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST = new Set([ + "COMSPEC", + "ComSpec", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "LANG", + "LC_ALL", + "LC_CTYPE", + "PATH", + "PATHEXT", + "Path", + "SYSTEMROOT", + "SystemRoot", + "TEMP", + "TMP", + "TMPDIR", + "WAYLAND_DISPLAY", + "WINDIR", + "XAUTHORITY", + "XDG_CURRENT_DESKTOP", + "XDG_DATA_DIRS", + "XDG_SESSION_TYPE", + "windir", +]); + +export function sanitizePackagedDesktopInheritedEnvironment( + inheritedEnvironment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(inheritedEnvironment).filter( + ([name, value]) => + value !== undefined && PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST.has(name), + ), + ); +} + +export function resolvePackagedDesktopLogPath(environment: NodeJS.ProcessEnv): string { + const scientHome = environment.SCIENT_HOME; + if (!scientHome) throw new Error("Packaged startup smoke requires an isolated SCIENT_HOME."); + return join(scientHome, "userdata", "logs", "desktop-main.log"); +} + +export interface ProcessTerminationTarget { + readonly pid: number; + readonly processGroup: boolean; +} + +export interface ProcessTerminationDependencies { + readonly platform?: NodeJS.Platform; + readonly runTaskkill?: (pid: number) => { + readonly error?: Error; + readonly status: number | null; + }; + readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; + readonly waitForTargetsExit?: ( + targets: ReadonlyArray, + timeoutMs: number, + ) => Promise; +} + +function processTerminationTargetIsAlive(target: ProcessTerminationTarget): boolean { + try { + process.kill(target.processGroup ? -target.pid : target.pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function waitForProcessTerminationTargets( + targets: ReadonlyArray, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolveExit) => { + const poll = () => { + if (targets.every((target) => !processTerminationTargetIsAlive(target))) { + resolveExit(true); + return; + } + if (Date.now() >= deadline) { + resolveExit(false); + return; + } + setTimeout(poll, 100); + }; + poll(); + }); +} + +function sendProcessTreeSignal(target: ProcessTerminationTarget, signal: NodeJS.Signals): void { + try { + process.kill(target.processGroup ? -target.pid : target.pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +export async function terminateProcessTree( + child: ChildProcess, + dependencies: ProcessTerminationDependencies = {}, + additionalProcessIds: ReadonlyArray = [], +): Promise { + const platform = dependencies.platform ?? process.platform; + const childCanStillOwnProcesses = + platform !== "win32" || (child.exitCode === null && child.signalCode === null); + const targets = [ + ...(child.pid && childCanStillOwnProcesses + ? [{ pid: child.pid, processGroup: platform !== "win32" }] + : []), + ...additionalProcessIds.map((pid) => ({ pid, processGroup: platform !== "win32" })), + ].filter( + (target, index, allTargets) => + target.pid > 0 && allTargets.findIndex((candidate) => candidate.pid === target.pid) === index, + ); + if (targets.length === 0) return; + const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; + if (platform === "win32") { + const taskkillResults = targets.map((target) => ({ + pid: target.pid, + result: + dependencies.runTaskkill?.(target.pid) ?? + spawnSync("taskkill", ["/pid", String(target.pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }), + })); + if (await awaitTargetsExit(targets, 5_000)) return; + const taskkillResult = taskkillResults + .map(({ pid, result }) => + result.error + ? `${pid}: could not start (${result.error.message})` + : `${pid}: status ${result.status ?? "unknown"}`, + ) + .join(", "); + throw new Error( + `Packaged process trees survived Windows cleanup; taskkill results: ${taskkillResult}.`, + ); + } + const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; + for (const target of targets) sendSignal(target, "SIGTERM"); + if (await awaitTargetsExit(targets, 5_000)) return; + for (const target of targets) sendSignal(target, "SIGKILL"); + if (await awaitTargetsExit(targets, 2_000)) return; + throw new Error( + `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived SIGTERM and SIGKILL.`, + ); +} + +function hasStartupProof(logPath: string): boolean { + try { + const log = readFileSync(logPath, "utf8"); + return ( + log.includes("app ready") && + log.includes("bootstrap main window created") && + log.includes("renderer main frame loaded") && + log.includes("bootstrap backend ready source=") + ); + } catch { + return false; + } +} + +function readPackagedBackendProcessId(environment: NodeJS.ProcessEnv | null): number | null { + const scientHome = environment?.SCIENT_HOME; + if (!scientHome) return null; + try { + const state = JSON.parse( + readFileSync(join(scientHome, "userdata", "server-runtime.json"), "utf8"), + ) as { readonly pid?: unknown }; + return Number.isInteger(state.pid) && Number(state.pid) > 0 ? Number(state.pid) : null; + } catch { + return null; + } +} + +export interface PackagedDesktopChildOutcome { + readonly exited: { readonly code: number | null; readonly signal: NodeJS.Signals | null } | null; + readonly launchError: Error | null; +} + +interface PackagedStartupProofWaitOptions { + readonly timeoutMs: number; + readonly hasProof: () => boolean; + readonly readOutcome: () => PackagedDesktopChildOutcome; + readonly now?: () => number; + readonly delay?: (milliseconds: number) => Promise; + readonly stableForMs?: number; +} + +export async function waitForPackagedStartupProof({ + timeoutMs, + hasProof, + readOutcome, + now = Date.now, + delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)), + stableForMs = 1_000, +}: PackagedStartupProofWaitOptions): Promise { + const deadline = now() + timeoutMs; + let proofObservedAt: number | null = null; + while (now() < deadline) { + const outcome = readOutcome(); + if (outcome.launchError) { + throw new Error(`Packaged app could not start: ${outcome.launchError.message}`); + } + if (outcome.exited) { + throw new Error( + `Packaged app exited before stable startup proof (code=${outcome.exited.code ?? "null"}, signal=${outcome.exited.signal ?? "null"}).`, + ); + } + const currentTime = now(); + if (hasProof()) { + proofObservedAt ??= currentTime; + if (currentTime - proofObservedAt >= stableForMs) return; + } else { + proofObservedAt = null; + } + await delay(Math.min(200, Math.max(1, deadline - currentTime))); + } + throw new Error(`Packaged startup proof timed out after ${timeoutMs}ms.`); +} + +export function resolveNativePackagedDesktopPlatform( + platform: NodeJS.Platform, +): PackagedDesktopPlatform { + if (platform === "darwin") return "mac"; + if (platform === "win32") return "win"; + return "linux"; +} + +export async function verifyPackagedDesktopStartup( + options: PackagedDesktopStartupOptions, +): Promise { + const nativePlatform = resolveNativePackagedDesktopPlatform(process.platform); + if (nativePlatform !== options.platform) { + throw new Error( + `Packaged ${options.platform} startup smoke must run on its native host, not ${process.platform}.`, + ); + } + + const temporaryRoot = mkdtempSync(join(tmpdir(), `scient-packaged-smoke-${options.platform}-`)); + const extractionRoot = join(temporaryRoot, "payload"); + mkdirSync(extractionRoot, { recursive: true }); + + let child: ChildProcess | null = null; + let environment: NodeJS.ProcessEnv | null = null; + let output = ""; + try { + const launch = prepareLaunch(options, extractionRoot); + environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); + const logPath = resolvePackagedDesktopLogPath(environment); + child = spawn(launch.command, [...launch.args], { + cwd: launch.cwd, + env: environment, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + const childOutcome: { + exited: PackagedDesktopChildOutcome["exited"]; + launchError: PackagedDesktopChildOutcome["launchError"]; + } = { exited: null, launchError: null }; + child.once("exit", (code, signal) => { + childOutcome.exited = { code, signal }; + }); + child.once("error", (error) => { + childOutcome.launchError = error; + }); + const recordOutput = (chunk: unknown) => { + output = `${output}${String(chunk)}`.slice(-200_000); + }; + child.stdout?.on("data", recordOutput); + child.stderr?.on("data", recordOutput); + + await waitForPackagedStartupProof({ + timeoutMs: options.timeoutMs, + hasProof: () => hasStartupProof(logPath), + readOutcome: () => childOutcome, + }); + console.log( + `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, + ); + } catch (error) { + const detail = output.trim() ? `\nPackaged process output:\n${output.trim()}` : ""; + throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`, { + cause: error, + }); + } finally { + try { + if (child) { + const backendProcessId = readPackagedBackendProcessId(environment); + await terminateProcessTree(child, {}, backendProcessId === null ? [] : [backendProcessId]); + } + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + await verifyPackagedDesktopStartup(parsePackagedDesktopStartupArgs(process.argv.slice(2))); +} From 8607d313d0364714f1c7a23d53e686103bab2edd Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 17:18:21 +0300 Subject: [PATCH 28/34] fix(release): close Debian acceptance gaps --- .github/workflows/release.yml | 11 +++++++++++ apps/web/scripts/linux-deb-smoke.mjs | 1 + scripts/release-smoke.ts | 15 +++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 930a8cae1..488e7d98c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,6 +132,17 @@ jobs: RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }} run: node scripts/resolve-release-update-policy.ts "$RELEASE_VERSION" + - name: Verify current Linux packaging lane + env: + RELEASE_LANE: ${{ steps.release_meta.outputs.release_lane }} + run: | + set -euo pipefail + if [[ "$RELEASE_LANE" != "clean" ]]; then + echo "The current release workflow supports only the clean Debian-package lane." >&2 + echo "Historical compatibility releases must use their original tagged workflow and artifacts." >&2 + exit 1 + fi + - name: Verify public updater repository env: GH_TOKEN: ${{ github.token }} diff --git a/apps/web/scripts/linux-deb-smoke.mjs b/apps/web/scripts/linux-deb-smoke.mjs index ae5d6c2ab..4e9fc95a0 100644 --- a/apps/web/scripts/linux-deb-smoke.mjs +++ b/apps/web/scripts/linux-deb-smoke.mjs @@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; import { constants as FS_CONSTANTS } from "node:fs"; import { access, + chmod, copyFile, mkdtemp, mkdir, diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 240b1347f..bfbb79a31 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -490,6 +490,16 @@ function verifyReleaseWorkflowSafety(): void { "Verify public download contract", "Expected the release workflow to validate every public platform download.", ); + assertContains( + workflow, + "Verify current Linux packaging lane", + "Expected the Debian release workflow to reject historical compatibility-lane publication.", + ); + assertContains( + workflow, + 'if [[ "$RELEASE_LANE" != "clean" ]]', + "Expected current Linux publication to fail closed outside the clean Debian lane.", + ); assertContains( workflow, '"Scient-${RELEASE_VERSION}-amd64.deb"', @@ -612,6 +622,11 @@ function verifyReleaseWorkflowSafety(): void { resolve(repoRoot, "apps/web/scripts/linux-deb-smoke.mjs"), "utf8", ).replaceAll("\r\n", "\n"); + assertContains( + packagedLinuxLifecycleSmoke, + " chmod,\n", + "Expected deep packaged Linux verification to import the chmod operation used for isolated runtime and workspace permissions.", + ); assertContains( packagedLinuxLifecycleSmoke, "...sanitizePackagedDesktopInheritedEnvironment(process.env)", From 7833a4b8c830670500ee2b926ec6157b7e446b99 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 17:23:50 +0300 Subject: [PATCH 29/34] fix(updater): prefer installed Linux package identity --- apps/desktop/src/main.ts | 14 ++++++++------ apps/desktop/src/updateState.test.ts | 21 +++++++++++++++++++++ apps/desktop/src/updateState.ts | 9 +++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4cfc4d87a..900f5704b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -106,6 +106,7 @@ import { hasDownloadProgressAdvanced, isExpectedStalledDownloadCancellationError, isUpdateVersionNewer, + resolveLinuxPackageType, shouldBroadcastDownloadProgress, shouldCheckForUpdatesOnForeground, } from "./updateState"; @@ -933,16 +934,17 @@ function parseAppUpdateYml(): Record | null { function readLinuxPackageType(): string | null { if (process.platform !== "linux" || !app.isPackaged) return null; - if (process.env.APPIMAGE) return "AppImage"; + let resourcePackageType: string | null = null; try { - const packageType = FS.readFileSync( + resourcePackageType = FS.readFileSync( Path.join(process.resourcesPath, "package-type"), "utf8", ).trim(); - return packageType || null; - } catch { - return null; - } + } catch {} + return resolveLinuxPackageType({ + resourcePackageType, + appImage: process.env.APPIMAGE, + }); } function normalizeCommitHash(value: unknown): string | null { diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index 0017189dc..3748e3fdd 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -9,6 +9,7 @@ import { isExpectedStalledDownloadCancellationError, isUpdateVersionNewer, nextStatusAfterDownloadFailure, + resolveLinuxPackageType, shouldCheckForUpdatesOnForeground, shouldBroadcastDownloadProgress, } from "./updateState"; @@ -32,6 +33,26 @@ const baseState: DesktopUpdateState = { releaseUrl: null, }; +describe("resolveLinuxPackageType", () => { + it("prefers the packaged resource marker over an inherited AppImage variable", () => { + expect( + resolveLinuxPackageType({ + resourcePackageType: " deb\n", + appImage: "/tmp/parent.AppImage", + }), + ).toBe("deb"); + }); + + it("uses the AppImage marker only when packaged metadata is absent", () => { + expect( + resolveLinuxPackageType({ + resourcePackageType: null, + appImage: "/tmp/Scient.AppImage", + }), + ).toBe("AppImage"); + }); +}); + describe("getDownloadStallTimeoutMessage", () => { it("formats the no-progress timeout in seconds", () => { expect(getDownloadStallTimeoutMessage(90_000)).toBe( diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts index 3b3c5fc7b..79d1131e3 100644 --- a/apps/desktop/src/updateState.ts +++ b/apps/desktop/src/updateState.ts @@ -5,6 +5,15 @@ export type DownloadProgressSample = { readonly transferred?: number | null; }; +export function resolveLinuxPackageType(args: { + readonly resourcePackageType: string | null; + readonly appImage?: string | undefined; +}): string | null { + const packagedType = args.resourcePackageType?.trim(); + if (packagedType) return packagedType; + return args.appImage ? "AppImage" : null; +} + export function getDownloadStallTimeoutMessage(timeoutMs: number): string { const timeoutSeconds = Math.max(1, Math.round(timeoutMs / 1000)); return `Download stalled after ${timeoutSeconds} seconds without progress. Try again.`; From f965e7a7341fec52f276fb213d44a6ddd6b37535 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 17:49:24 +0300 Subject: [PATCH 30/34] fix(ci): follow packaged add-project source flow --- apps/web/scripts/linux-deb-smoke.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/scripts/linux-deb-smoke.mjs b/apps/web/scripts/linux-deb-smoke.mjs index 4e9fc95a0..1d2b34fa3 100644 --- a/apps/web/scripts/linux-deb-smoke.mjs +++ b/apps/web/scripts/linux-deb-smoke.mjs @@ -336,6 +336,9 @@ async function connectToPackagedRenderer(debuggingPort, processOutput) { async function addProjectByTypedPath(page, workspacePath) { await page.keyboard.press("Control+Shift+O"); const folderDialog = page.getByRole("dialog"); + const localFolderSource = folderDialog.getByText("Local folder", { exact: true }); + await localFolderSource.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + await localFolderSource.click({ timeout: ACTION_TIMEOUT_MS }); const pathInput = folderDialog.getByPlaceholder("Enter project path (e.g. ~/projects/my-app)"); await pathInput.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); await pathInput.fill(workspacePath); From 13ef7817e731f496f5a968bcc6b9b0ebdd01ecd5 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 18:15:00 +0300 Subject: [PATCH 31/34] fix(ci): follow packaged path browser labels --- apps/web/scripts/linux-deb-smoke.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/scripts/linux-deb-smoke.mjs b/apps/web/scripts/linux-deb-smoke.mjs index 1d2b34fa3..18e9c62d5 100644 --- a/apps/web/scripts/linux-deb-smoke.mjs +++ b/apps/web/scripts/linux-deb-smoke.mjs @@ -339,7 +339,7 @@ async function addProjectByTypedPath(page, workspacePath) { const localFolderSource = folderDialog.getByText("Local folder", { exact: true }); await localFolderSource.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); await localFolderSource.click({ timeout: ACTION_TIMEOUT_MS }); - const pathInput = folderDialog.getByPlaceholder("Enter project path (e.g. ~/projects/my-app)"); + const pathInput = folderDialog.getByPlaceholder("Type or browse a folder path"); await pathInput.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); await pathInput.fill(workspacePath); await folderDialog From 852130aedc37013dfbc335105fc64c25bc5f56b3 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 18:42:13 +0300 Subject: [PATCH 32/34] fix(ci): use current packaged folder action --- apps/web/scripts/linux-deb-smoke.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/scripts/linux-deb-smoke.mjs b/apps/web/scripts/linux-deb-smoke.mjs index 18e9c62d5..2438074eb 100644 --- a/apps/web/scripts/linux-deb-smoke.mjs +++ b/apps/web/scripts/linux-deb-smoke.mjs @@ -346,8 +346,8 @@ async function addProjectByTypedPath(page, workspacePath) { .getByText(basename(workspacePath), { exact: true }) .first() .waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); - const addButton = folderDialog.getByRole("button", { name: /^Add\b/u }); - await addButton.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + const openButton = folderDialog.getByRole("button", { name: "Open Enter", exact: true }); + await openButton.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); if ( await folderDialog .getByText(/SocketOpenError/) @@ -357,7 +357,7 @@ async function addProjectByTypedPath(page, workspacePath) { ) { throw new Error("Folder browsing failed with SocketOpenError."); } - await addButton.click({ timeout: ACTION_TIMEOUT_MS }); + await openButton.click({ timeout: ACTION_TIMEOUT_MS }); const emptyProjectChoice = page.getByRole("button", { name: /Open an empty project/, }); From e6191eaa186be1009d1ebeca097807fb0a6caa19 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 19:11:52 +0300 Subject: [PATCH 33/34] fix(ci): inspect persisted state after shutdown --- apps/web/scripts/linux-deb-smoke.mjs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/web/scripts/linux-deb-smoke.mjs b/apps/web/scripts/linux-deb-smoke.mjs index 2438074eb..835898efd 100644 --- a/apps/web/scripts/linux-deb-smoke.mjs +++ b/apps/web/scripts/linux-deb-smoke.mjs @@ -663,6 +663,7 @@ async function runScenario(executablePath, scenario) { let output = ""; let scenarioError; let finalScreenshot; + const expectedPersistedWorkspaces = []; const cleanupErrors = []; const cleanupDiagnostics = { scenarioName: scenario.name, @@ -764,7 +765,7 @@ async function runScenario(executablePath, scenario) { ); await addProjectByTypedPath(page, firstWorkspace); - await waitForPersistedProject(databasePath, firstWorkspace); + expectedPersistedWorkspaces.push(firstWorkspace); await assertPrivateScientDirectories(scientHome); await assertDirectoryMode(firstWorkspace, 0o775); @@ -783,7 +784,7 @@ async function runScenario(executablePath, scenario) { ); await addProjectByTypedPath(page, secondWorkspace); - await waitForPersistedProject(databasePath, secondWorkspace); + expectedPersistedWorkspaces.push(secondWorkspace); await assertDirectoryMode(secondWorkspace, 0o775); await assertPackagedBackendProcess(recoveredRuntime.pid); process.kill(recoveredRuntime.pid, 0); @@ -798,8 +799,6 @@ async function runScenario(executablePath, scenario) { throw new Error(`Expected one controlled backend restart, observed ${restartCount}.`); } } - - console.log(`Packaged Linux scenario passed: ${scenario.name}`); } catch (error) { scenarioError = new Error( `${scenario.name} failed: ${error instanceof Error ? error.stack || error.message : String(error)}\nPackaged process output:\n${output}`, @@ -822,6 +821,13 @@ async function runScenario(executablePath, scenario) { cleanupDiagnostics, ).catch((error) => cleanupErrors.push(error)); } + if (!scenarioError && cleanupErrors.length === 0) { + for (const workspacePath of expectedPersistedWorkspaces) { + await waitForPersistedProject(databasePath, workspacePath).catch((error) => + cleanupErrors.push(error), + ); + } + } cleanupDiagnostics.errors = cleanupErrors.map(serializeError); let diagnosticsPreserved = false; if (scenarioError || cleanupErrors.length > 0) { @@ -866,6 +872,7 @@ async function runScenario(executablePath, scenario) { ? errors[0] : new AggregateError(errors, `${scenario.name} failed and cleanup was incomplete.`); } + console.log(`Packaged Linux scenario passed: ${scenario.name}`); } async function main() { From 3fccd497dbf34ff50cfe9954eef7267d39c3463e Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 19:42:29 +0300 Subject: [PATCH 34/34] fix(ci): settle project creation before recovery --- apps/web/scripts/linux-deb-smoke.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/scripts/linux-deb-smoke.mjs b/apps/web/scripts/linux-deb-smoke.mjs index 835898efd..b32b36db3 100644 --- a/apps/web/scripts/linux-deb-smoke.mjs +++ b/apps/web/scripts/linux-deb-smoke.mjs @@ -335,7 +335,7 @@ async function connectToPackagedRenderer(debuggingPort, processOutput) { async function addProjectByTypedPath(page, workspacePath) { await page.keyboard.press("Control+Shift+O"); - const folderDialog = page.getByRole("dialog"); + const folderDialog = page.getByRole("dialog", { name: "Add project" }); const localFolderSource = folderDialog.getByText("Local folder", { exact: true }); await localFolderSource.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); await localFolderSource.click({ timeout: ACTION_TIMEOUT_MS }); @@ -363,6 +363,11 @@ async function addProjectByTypedPath(page, workspacePath) { }); await emptyProjectChoice.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); await emptyProjectChoice.click({ timeout: ACTION_TIMEOUT_MS }); + await folderDialog.waitFor({ state: "hidden", timeout: ACTION_TIMEOUT_MS }); + await page + .getByText(basename(workspacePath), { exact: true }) + .first() + .waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); } async function waitForPersistedProject(databasePath, workspacePath) {