Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
804a789
Secure Scient state initialization
yaacovcorcos Jul 20, 2026
3433cfb
Allow legacy migration state test
yaacovcorcos Jul 20, 2026
5e93c43
Supervise the desktop backend lifecycle
yaacovcorcos Jul 20, 2026
a1d6b63
Supervise desktop connection recovery
yaacovcorcos Jul 20, 2026
a37c476
Define safe RPC recovery policies
yaacovcorcos Jul 20, 2026
b4b2558
Surface connection recovery diagnostics
yaacovcorcos Jul 20, 2026
0cbae5a
Support Linux desktop development launch
yaacovcorcos Jul 21, 2026
11d3f6a
Merge remote-tracking branch 'origin/main' into agent/secure-state-init
yaacovcorcos Jul 21, 2026
df79276
Merge branch 'agent/secure-state-init' into agent/backend-supervisor
yaacovcorcos Jul 21, 2026
4167480
Merge branch 'agent/backend-supervisor' into agent/connection-supervisor
yaacovcorcos Jul 21, 2026
56c88c6
Merge branch 'agent/connection-supervisor' into agent/rpc-recovery-po…
yaacovcorcos Jul 21, 2026
cd6c418
Merge branch 'agent/rpc-recovery-policies' into agent/recovery-ux-dia…
yaacovcorcos Jul 21, 2026
cd89cd3
Merge branch 'agent/recovery-ux-diagnostics' into agent/linux-launch-…
yaacovcorcos Jul 21, 2026
958e088
Isolate provider dialog browser fixtures
yaacovcorcos Jul 21, 2026
65534c8
Merge branch 'agent/connection-supervisor' into agent/rpc-recovery-po…
yaacovcorcos Jul 21, 2026
050d5e6
Merge branch 'agent/rpc-recovery-policies' into agent/recovery-ux-dia…
yaacovcorcos Jul 21, 2026
a966cbf
Merge branch 'agent/recovery-ux-diagnostics' into agent/linux-launch-…
yaacovcorcos Jul 21, 2026
aabb53f
fix(desktop): fail closed on unsafe Linux sandbox
yaacovcorcos Jul 21, 2026
57439dd
style(desktop): format Linux launcher hardening
yaacovcorcos Jul 21, 2026
09e1147
fix(release): preserve AppImage sandbox
yaacovcorcos Jul 21, 2026
ba7b827
revert(release): keep AppImage migration out of launcher hardening
yaacovcorcos Jul 21, 2026
ee3a38c
fix(desktop): honor Linux user namespace sandbox
yaacovcorcos Jul 21, 2026
d677c5b
Merge remote-tracking branch 'origin/main' into agent/linux-launch-ha…
yaacovcorcos Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from "node:path";
import waitOn from "wait-on";

import { buildAppSnapHelper } from "./build-appsnap-helper.mjs";
import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs";

const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
const devServerUrl = `http://localhost:${port}`;
Expand Down Expand Up @@ -140,18 +140,18 @@ function startApp() {
return;
}

const app = spawn(
resolveElectronPath(),
const electronCommand = resolveElectronLaunchCommand(
[`--synara-dev-root=${desktopDir}`, "dist-electron/main.js"],
{
cwd: desktopDir,
env: {
...childEnv,
VITE_DEV_SERVER_URL: devServerUrl,
},
stdio: "inherit",
},
{ development: true },
);
const app = spawn(electronCommand.electronPath, electronCommand.args, {
cwd: desktopDir,
env: {
...childEnv,
VITE_DEV_SERVER_URL: devServerUrl,
},
stdio: "inherit",
});

currentApp = app;

Expand Down
85 changes: 85 additions & 0 deletions apps/desktop/scripts/electron-launcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
copyFileSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
Expand Down Expand Up @@ -146,3 +147,87 @@ export function resolveElectronPath() {

return buildMacLauncher(electronBinaryPath);
}

export function isLinuxSetuidSandboxConfigured(
electronBinaryPath,
{ platform = process.platform, lstat = lstatSync } = {},
) {
if (platform !== "linux") {
return true;
}

const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox");
try {
const sandboxStat = lstat(sandboxPath);
return sandboxStat.isFile() && sandboxStat.uid === 0 && (sandboxStat.mode & 0o7777) === 0o4755;
} catch {
return false;
}
}

export function isLinuxUserNamespaceSandboxAvailable({
platform = process.platform,
runUnshare = spawnSync,
} = {}) {
if (platform !== "linux") {
return true;
}

const result = runUnshare("unshare", ["-Ur", "true"], {
shell: false,
stdio: "ignore",
timeout: 5_000,
windowsHide: true,
});
return !result.error && result.status === 0;
}

export class LinuxSandboxConfigurationError extends Error {
constructor(sandboxPath) {
super(
`Electron needs either unprivileged user namespaces or a sandbox helper at ${sandboxPath} ` +
"that is a regular file owned by root with exact mode 4755. Enable one of those sandbox paths " +
"before launching Scient. For an isolated local development session only, " +
"set SCIENT_DEV_ALLOW_NO_SANDBOX=1 to accept the unsafe fallback explicitly.",
);
this.name = "LinuxSandboxConfigurationError";
this.sandboxPath = sandboxPath;
}
}

export function resolveLinuxSandboxArgs(
electronBinaryPath,
{
platform = process.platform,
lstat = lstatSync,
runUnshare = spawnSync,
development = isDevelopment,
env = process.env,
warn = console.warn,
} = {},
) {
if (
isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat }) ||
isLinuxUserNamespaceSandboxAvailable({ platform, runUnshare })
) {
return [];
}

const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox");
if (development && env.SCIENT_DEV_ALLOW_NO_SANDBOX === "1") {
warn(
"[desktop-launcher] Unsafe development override accepted: launching local Electron with --no-sandbox.",
);
return ["--no-sandbox"];
}

throw new LinuxSandboxConfigurationError(sandboxPath);
}

export function resolveElectronLaunchCommand(args = [], options = {}) {
const electronPath = resolveElectronPath();
return {
electronPath,
args: [...resolveLinuxSandboxArgs(electronPath, options), ...args],
};
}
113 changes: 113 additions & 0 deletions apps/desktop/scripts/electron-launcher.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";

import {
isLinuxSetuidSandboxConfigured,
isLinuxUserNamespaceSandboxAvailable,
LinuxSandboxConfigurationError,
resolveLinuxSandboxArgs,
} from "./electron-launcher.mjs";

const ELECTRON_PATH = "/repo/node_modules/electron/dist/electron";
const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox";

describe("Linux Electron sandbox launch policy", () => {
it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => {
const lstat = vi.fn();
const runUnshare = vi.fn();

expect(
resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat, runUnshare }),
).toEqual([]);
expect(lstat).not.toHaveBeenCalled();
expect(runUnshare).not.toHaveBeenCalled();
});

it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => {
const lstat = vi.fn(() => ({ isFile: () => true, mode: 0o104755, uid: 0 }));
const runUnshare = vi.fn();

expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", lstat })).toBe(true);
expect(
resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat, runUnshare }),
).toEqual([]);
expect(lstat).toHaveBeenCalledWith(SANDBOX_PATH);
expect(runUnshare).not.toHaveBeenCalled();
});

it("keeps Chromium's sandbox when unprivileged user namespaces work without a helper", () => {
const runUnshare = vi.fn(() => ({ status: 0 }));

expect(
resolveLinuxSandboxArgs(ELECTRON_PATH, {
platform: "linux",
lstat: () => {
throw new Error("ENOENT");
},
runUnshare,
}),
).toEqual([]);
expect(runUnshare).toHaveBeenCalledWith("unshare", ["-Ur", "true"], {
shell: false,
stdio: "ignore",
timeout: 5_000,
windowsHide: true,
});
expect(isLinuxUserNamespaceSandboxAvailable({ platform: "linux", runUnshare })).toBe(true);
});

it.each([
["is not root-owned", { isFile: () => true, mode: 0o104755, uid: 1000 }],
["is not setuid", { isFile: () => true, mode: 0o100755, uid: 0 }],
["is group-writable", { isFile: () => true, mode: 0o104775, uid: 0 }],
["has extra special bits", { isFile: () => true, mode: 0o106755, uid: 0 }],
["is not a regular file", { isFile: () => false, mode: 0o104755, uid: 0 }],
])("fails closed when chrome-sandbox %s", (_reason, metadata) => {
expect(() =>
resolveLinuxSandboxArgs(ELECTRON_PATH, {
platform: "linux",
lstat: () => metadata,
runUnshare: () => ({ status: 1 }),
}),
).toThrow(LinuxSandboxConfigurationError);
});

it("fails closed when both chrome-sandbox and unprivileged user namespaces are unavailable", () => {
expect(() =>
resolveLinuxSandboxArgs(ELECTRON_PATH, {
platform: "linux",
lstat: () => {
throw new Error("ENOENT");
},
runUnshare: () => ({ status: 1 }),
}),
).toThrow(expect.objectContaining({ sandboxPath: SANDBOX_PATH }));
});

it("allows --no-sandbox only through an explicit local-development override", () => {
const warn = vi.fn();

expect(
resolveLinuxSandboxArgs(ELECTRON_PATH, {
platform: "linux",
lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }),
runUnshare: () => ({ status: 1 }),
development: true,
env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" },
warn,
}),
).toEqual(["--no-sandbox"]);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Unsafe development override"));
});

it("refuses the unsafe override outside development", () => {
expect(() =>
resolveLinuxSandboxArgs(ELECTRON_PATH, {
platform: "linux",
lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }),
runUnshare: () => ({ status: 1 }),
development: false,
env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" },
}),
).toThrow(LinuxSandboxConfigurationError);
});
});
6 changes: 4 additions & 2 deletions apps/desktop/scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import { spawn, spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { resolveElectronLaunchCommand } from "./electron-launcher.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const desktopDir = resolve(__dirname, "..");
const electronBin = resolve(desktopDir, "node_modules/.bin/electron");
const mainJs = resolve(desktopDir, "dist-electron/main.js");

console.log("\nLaunching Electron smoke test...");

const child = spawn(electronBin, [mainJs], {
const electronCommand = resolveElectronLaunchCommand([mainJs], { development: false });
const child = spawn(electronCommand.electronPath, electronCommand.args, {
stdio: ["pipe", "pipe", "pipe"],
detached: process.platform !== "win32",
env: {
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/scripts/start-electron.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";

import { buildAppSnapHelper } from "./build-appsnap-helper.mjs";
import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs";

if (process.platform === "darwin") {
buildAppSnapHelper({ arch: process.arch });
Expand All @@ -10,7 +10,10 @@ if (process.platform === "darwin") {
const childEnv = { ...process.env };
delete childEnv.ELECTRON_RUN_AS_NODE;

const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], {
const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"], {
development: true,
});
const child = spawn(electronCommand.electronPath, electronCommand.args, {
stdio: "inherit",
cwd: desktopDir,
env: childEnv,
Expand Down
Loading