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 bd34a1c2f..ffb337f17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,6 +199,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 686b4c1ad..912642c85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,6 +143,17 @@ jobs: - name: Verify release-note catalog structure run: node scripts/verify-release-notes.ts "${{ steps.release_meta.outputs.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 }} @@ -176,10 +187,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 @@ -224,7 +234,7 @@ jobs: - label: Linux x64 runner: ubuntu-24.04 platform: linux - target: AppImage + target: deb arch: x64 timeout_minutes: 30 - label: Windows x64 @@ -325,36 +335,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: | @@ -365,7 +345,7 @@ jobs: for pattern in \ "release/*.dmg" \ "release/*.zip" \ - "release/*.AppImage" \ + "release/*.deb" \ "release/*.exe" \ "release/*.blockmap" \ "release/latest*.yml"; do @@ -380,6 +360,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: @@ -466,7 +482,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 @@ -492,13 +508,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" ) @@ -515,7 +530,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 @@ -553,7 +568,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 @@ -627,7 +642,7 @@ jobs: files: | release-assets/*.dmg release-assets/*.zip - release-assets/*.AppImage + release-assets/*.deb release-assets/*.exe release-assets/*.blockmap release-assets/*.yml @@ -654,14 +669,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..900f5704b 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"; @@ -105,6 +106,7 @@ import { hasDownloadProgressAdvanced, isExpectedStalledDownloadCancellationError, isUpdateVersionNewer, + resolveLinuxPackageType, shouldBroadcastDownloadProgress, shouldCheckForUpdatesOnForeground, } from "./updateState"; @@ -930,6 +932,21 @@ function parseAppUpdateYml(): Record | null { } } +function readLinuxPackageType(): string | null { + if (process.platform !== "linux" || !app.isPackaged) return null; + let resourcePackageType: string | null = null; + try { + resourcePackageType = FS.readFileSync( + Path.join(process.resourcesPath, "package-type"), + "utf8", + ).trim(); + } catch {} + return resolveLinuxPackageType({ + resourcePackageType, + appImage: process.env.APPIMAGE, + }); +} + function normalizeCommitHash(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -1371,6 +1388,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 +2621,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 +3520,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..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( @@ -226,7 +247,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 +257,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..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.`; @@ -153,6 +162,7 @@ export function getAutoUpdateDisabledReason(args: { isPackaged: boolean; platform: NodeJS.Platform; appImage?: string | undefined; + linuxPackageType?: string | undefined; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -165,8 +175,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..b32b36db3 --- /dev/null +++ b/apps/web/scripts/linux-deb-smoke.mjs @@ -0,0 +1,930 @@ +import { spawn, spawnSync } from "node:child_process"; +import { constants as FS_CONSTANTS } from "node:fs"; +import { + access, + chmod, + 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", { 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 }); + 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 + .getByText(basename(workspacePath), { exact: true }) + .first() + .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/) + .first() + .isVisible() + .catch(() => false) + ) { + throw new Error("Folder browsing failed with SocketOpenError."); + } + await openButton.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 }); + 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) { + 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 expectedPersistedWorkspaces = []; + 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); + expectedPersistedWorkspaces.push(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); + expectedPersistedWorkspaces.push(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}.`); + } + } + } 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)); + } + 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) { + 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.`); + } + console.log(`Packaged Linux scenario passed: ${scenario.name}`); +} + +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 3cfa04d9e..7ab948dce 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` @@ -200,7 +223,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 29482486e..0294817b3 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", "test:desktop-browser-overlay-lifecycle": "bun run --cwd apps/desktop test:browser-overlay-lifecycle", "fmt": "oxfmt", "fmt:check": "oxfmt --check", @@ -47,7 +48,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", "release:notes:check": "node scripts/verify-release-notes.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 af70e70bf..86112d67a 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)), ".."); @@ -160,6 +165,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"), @@ -181,17 +197,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")), ); @@ -229,6 +254,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", @@ -499,18 +528,38 @@ function verifyReleaseWorkflowSafety(): void { ); assertContains( workflow, - '"Scient-${RELEASE_VERSION}-x86_64.AppImage"', - "Expected the public contract to validate the Linux AppImage filename.", + "Verify current Linux packaging lane", + "Expected the Debian release workflow to reject historical compatibility-lane publication.", ); assertContains( workflow, - "Verify Linux AppImage sandbox policy", - "Expected the release workflow to inspect the packaged Linux launcher.", + 'if [[ "$RELEASE_LANE" != "clean" ]]', + "Expected current Linux publication to fail closed outside the clean Debian lane.", ); assertContains( workflow, + '"Scient-${RELEASE_VERSION}-amd64.deb"', + "Expected the public contract to validate the Linux Debian filename.", + ); + assertContains( + workflow, + '"${UPDATE_CHANNEL}-deb-linux.yml"', + "Expected Debian updates to use a format-specific channel.", + ); + 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, @@ -522,6 +571,128 @@ 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, + " chmod,\n", + "Expected deep packaged Linux verification to import the chmod operation used for isolated runtime and workspace permissions.", + ); + 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))); +}