Files
paseo/packages/desktop/src/daemon/daemon-manager.ts
T
Mohamed Boudra 772ff8d6f3 fix(desktop): preserve Linux sandboxing wherever the host allows it (#4447)
* fix(desktop): preserve Linux sandboxing wherever the host allows it

Chromium checks its Linux sandbox before main.js, while package installation probes run as root and miss AppArmor restrictions on desktop users. Choose the launch flags before exec and always install the SUID alternative for native packages. Test published artifacts as an ordinary user with namespaces both denied and allowed; never repair permissions in the smoke harness.

* fix(desktop): retain the Linux release build baseline

* fix(ci): allow canceled desktop smoke uploads to stop

* test(desktop): verify the installed RPM sandbox under AppArmor

* fix(ci): resolve packaged desktop dependencies before installation

* chore(desktop): keep QA evidence out of the repository
2026-09-11 09:05:26 +02:00

479 lines
16 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { app, ipcMain, powerMonitor } from "electron";
import log from "electron-log/main";
import {
resolvePaseoHome,
startDaemonInstance,
DaemonInstanceError,
stopDaemonInstance,
readDaemonInstance,
isSameDaemonInstance,
type DaemonInstance,
} from "@getpaseo/server";
import {
copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile,
garbageCollectManagedAttachmentFiles,
readManagedFileBase64,
writeAttachmentBase64,
writeAttachmentBytes,
} from "../features/attachments.js";
import {
checkForAppUpdate,
downloadAndInstallUpdate,
type AppUpdateCheckIntent,
type AppReleaseChannel,
} from "../features/auto-updater.js";
import {
getBundledCliShimPath,
getCliInstallStatus,
installCli,
} from "../integrations/cli-install/index.js";
import {
openLocalTransportSession,
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js";
import {
createDesktopSettingsCommandHandlers,
type DesktopCommandHandler,
} from "../settings/desktop-settings-commands.js";
import type { DesktopSettings } from "../settings/desktop-settings.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
import { describeSandbox } from "../diagnostics/sandbox.js";
import { getDesktopAppLogs } from "../diagnostics/app-logs.js";
import { getDesktopUpdaterDiagnostics } from "../diagnostics/updater.js";
import {
deleteLegacySkillSelection,
readLegacySkillSelection,
} from "../integrations/legacy-skill-selection.js";
import { tailFile } from "../diagnostics/tail-file.js";
const DAEMON_LOG_FILENAME = "daemon.log";
let ownedLaunch: { home: string; instance: DaemonInstance } | null = null;
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
const DESKTOP_DAEMON_STOP_REASON_VALUES = [
"manual_ipc",
"settings",
"host_remove",
"quit",
"app_update",
"version_mismatch",
"restart",
] as const;
export type DesktopDaemonStopReason = (typeof DESKTOP_DAEMON_STOP_REASON_VALUES)[number];
const DESKTOP_DAEMON_STOP_REASONS = new Set<string>(DESKTOP_DAEMON_STOP_REASON_VALUES);
const DEFAULT_DESKTOP_DAEMON_STOP_REASON: DesktopDaemonStopReason = "manual_ipc";
export interface DesktopDaemonStatus {
serverId: string;
status: DesktopDaemonState;
listen: string | null;
hostname: string | null;
pid: number | null;
home: string;
version: string | null;
desktopManaged: boolean;
ownedByDesktop: boolean;
startedAt: string | null;
error: string | null;
}
interface DesktopDaemonLogs {
logPath: string;
contents: string;
}
function parseReleaseChannel(
args: Record<string, unknown> | undefined,
): AppReleaseChannel | undefined {
if (args?.releaseChannel === "beta") {
return "beta";
}
if (args?.releaseChannel === "stable") {
return "stable";
}
return undefined;
}
function parseAppUpdateCheckIntent(
args: Record<string, unknown> | undefined,
): AppUpdateCheckIntent {
return args?.intent === "manual" ? "manual" : "automatic";
}
function parseDesktopDaemonStopReason(
args: Record<string, unknown> | undefined,
): DesktopDaemonStopReason {
const reason = args?.reason;
if (typeof reason === "string" && DESKTOP_DAEMON_STOP_REASONS.has(reason)) {
return reason as DesktopDaemonStopReason;
}
return DEFAULT_DESKTOP_DAEMON_STOP_REASON;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function getPaseoHome(): string {
return resolvePaseoHome(process.env);
}
function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
}
export function isDesktopManagedDaemonRunningSync(): boolean {
if (!ownedLaunch) return false;
try {
const lock = JSON.parse(readFileSync(path.join(ownedLaunch.home, "paseo.pid"), "utf8"));
return isSameDaemonInstance(lock, ownedLaunch.instance) && isProcessRunning(lock.pid);
} catch {
return false;
}
}
export async function stopDesktopDaemonViaCli(
reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON,
): Promise<void> {
await stopDesktopDaemon(reason);
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err && err.code === "EPERM") {
return true;
}
return false;
}
}
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
log.info("[desktop daemon]", message, {
pid: process.pid,
...details,
});
}
function statusFromDaemonProbe(
payload: Record<string, unknown>,
home: string,
): DesktopDaemonStatus {
const local = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
const processAlive = local === "running" || local === "not_ready";
let status: DesktopDaemonState = "stopped";
if (local === "not_ready") status = "starting";
if (local === "running") status = "running";
return {
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
status,
listen: typeof payload.listen === "string" ? payload.listen : null,
hostname:
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
pid: processAlive && typeof payload.pid === "number" ? payload.pid : null,
home,
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
desktopManaged: payload.desktopManaged === true,
startedAt: typeof payload.startedAt === "string" ? payload.startedAt : null,
ownedByDesktop: Boolean(
ownedLaunch &&
ownedLaunch.home === home &&
payload.pid === ownedLaunch.instance.pid &&
payload.startedAt === ownedLaunch.instance.startedAt,
),
error: null,
};
}
function resolveDesktopAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
}
try {
const packageJsonPath = path.join(__dirname, "..", "..", "package.json");
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
version?: unknown;
};
if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
return pkg.version.trim();
}
} catch {
// Fall back to Electron's default version if the package metadata is unavailable.
}
return app.getVersion();
}
// ---------------------------------------------------------------------------
// Daemon lifecycle
// ---------------------------------------------------------------------------
export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
const home = getPaseoHome();
try {
const payload = (await runExternalCliJsonCommand([
"daemon",
"status",
"--home",
home,
"--json",
])) as Record<string, unknown>;
return statusFromDaemonProbe(payload, home);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage });
return {
serverId: "",
status: "errored",
listen: null,
hostname: null,
pid: null,
home,
version: null,
desktopManaged: false,
ownedByDesktop: false,
startedAt: null,
error: errorMessage,
};
}
}
function normalizeVersion(version: string | null): string | null {
const trimmed = version?.trim();
if (!trimmed) return null;
return trimmed.replace(/^v/i, "");
}
function shouldRestartForVersion(current: DesktopDaemonStatus): boolean {
if (!current.ownedByDesktop) return false;
const appVersion = normalizeVersion(resolveDesktopAppVersion());
const daemonVersion = normalizeVersion(current.version);
return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion);
}
function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
if (!settings.daemon.manageBuiltInDaemon) {
throw new Error("Built-in daemon management is disabled.");
}
}
async function startDaemon(): Promise<DesktopDaemonStatus> {
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
const current = await resolveDesktopDaemonStatus();
logDesktopDaemonLifecycle("initial status check before start", {
status: current.status,
pid: current.pid,
listen: current.listen,
serverId: current.serverId || null,
error: current.error,
desktopManaged: current.desktopManaged,
});
if (current.status === "running" || current.status === "starting") {
if (shouldRestartForVersion(current)) {
logDesktopDaemonLifecycle("daemon version mismatch, restarting", {
appVersion: normalizeVersion(resolveDesktopAppVersion()),
daemonVersion: normalizeVersion(current.version),
});
await stopDesktopDaemon("version_mismatch");
} else {
return current;
}
}
const home = getPaseoHome();
const invocation = createNodeEntrypointInvocation({
entrypoint: resolveDaemonRunnerEntrypoint(),
argvMode: "node-script",
args: [],
baseEnv: process.env,
});
try {
await startDaemonInstance({
home,
timeoutMs: 30_000,
...invocation,
env: { ...invocation.env, PASEO_CLI: getBundledCliShimPath() },
mode: "managed",
desktopManaged: true,
onAcquired: (instance) => {
ownedLaunch = { home, instance };
},
});
} catch (error) {
if (!(error instanceof DaemonInstanceError && error.code === "DAEMON_NOT_READY")) throw error;
}
return resolveDesktopDaemonStatus();
}
export async function stopDesktopDaemon(
reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON,
confirmedInstance?: { pid: number; startedAt: string },
): Promise<DesktopDaemonStatus> {
const home = getPaseoHome();
const instance = await readDaemonInstance(home);
const owned = Boolean(
instance &&
ownedLaunch &&
ownedLaunch.home === home &&
isSameDaemonInstance(instance, ownedLaunch.instance),
);
const explicit =
reason === "manual_ipc" &&
confirmedInstance &&
instance &&
instance.pid === confirmedInstance.pid &&
instance.startedAt === confirmedInstance.startedAt;
if (confirmedInstance && !explicit)
throw new Error(
"Daemon changed since confirmation; inspect its current home and PID before stopping it.",
);
if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus();
logDesktopDaemonLifecycle("stopping captured supervisor", { reason, pid: instance.pid, owned });
await stopDaemonInstance(home, {
instance,
timeoutMs: 15_000,
requestShutdown: async (ready) => {
await runExternalCliJsonCommand(["daemon", "stop", "--host", ready.listen, "--json"]);
},
});
if (owned) ownedLaunch = null;
return resolveDesktopDaemonStatus();
}
async function restartDaemon(): Promise<DesktopDaemonStatus> {
await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]);
return resolveDesktopDaemonStatus();
}
function getDaemonLogs(): DesktopDaemonLogs {
const logPath = logFilePath();
return {
logPath,
contents: tailFile(logPath, 100),
};
}
async function getCliDaemonStatus(): Promise<string> {
return await runExternalCliTextCommand(["daemon", "status", "--home", getPaseoHome()]);
}
async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> {
const status = await resolveDesktopDaemonStatus();
if (status.status !== "running") {
return { version: null, error: "Daemon is not running." };
}
return {
version: status.version,
error: status.version ? null : "Running daemon did not report a version.",
};
}
async function resolveRequestedReleaseChannel(
args: Record<string, unknown> | undefined,
): Promise<AppReleaseChannel> {
return parseReleaseChannel(args) ?? (await getDesktopSettingsStore().get()).releaseChannel;
}
// ---------------------------------------------------------------------------
// IPC registration
// ---------------------------------------------------------------------------
export function createDaemonCommandHandlers(): Record<string, DesktopCommandHandler> {
return {
...createDesktopSettingsCommandHandlers({ settingsStore: getDesktopSettingsStore() }),
desktop_get_runtime_info: () => ({
appVersion: resolveDesktopAppVersion(),
runningUnderARM64Translation: isRunningUnderARM64Translation(),
}),
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
start_desktop_daemon: () => startDaemon(),
stop_desktop_daemon: (args) =>
stopDesktopDaemon(
parseDesktopDaemonStopReason(args),
typeof args?.pid === "number" && typeof args.startedAt === "string"
? { pid: args.pid, startedAt: args.startedAt }
: undefined,
),
restart_desktop_daemon: () => restartDaemon(),
desktop_daemon_logs: () => getDaemonLogs(),
desktop_sandbox_diagnostics: () =>
describeSandbox({
disabled: app.commandLine.hasSwitch("no-sandbox"),
launcherReason: process.env.PASEO_DESKTOP_SANDBOX_REASON,
}),
desktop_app_logs: () => getDesktopAppLogs(),
desktop_update_diagnostics: () => getDesktopUpdaterDiagnostics(),
desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000,
cli_daemon_status: () => getCliDaemonStatus(),
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
write_attachment_bytes: (args) => writeAttachmentBytes(args ?? {}),
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
read_file_base64: (args) => readManagedFileBase64(args ?? {}),
delete_attachment_file: (args) => deleteManagedAttachmentFile(args ?? {}),
garbage_collect_attachment_files: (args) => garbageCollectManagedAttachmentFiles(args ?? {}),
open_local_daemon_transport: async (args) => await openLocalTransportSession(args),
send_local_daemon_transport_message: async (args) => {
await sendLocalTransportMessage(
args as { sessionId: string; text?: string; binaryBase64?: string },
);
},
close_local_daemon_transport: (args) => {
const sessionId =
typeof args === "object" && args !== null && "sessionId" in args
? (args as { sessionId: string }).sessionId
: "";
if (sessionId) closeLocalTransportSession(sessionId);
},
check_app_update: async (args) => {
const currentVersion = resolveDesktopAppVersion();
return checkForAppUpdate({
currentVersion,
releaseChannel: await resolveRequestedReleaseChannel(args),
intent: parseAppUpdateCheckIntent(args),
});
},
install_app_update: async (args) => {
const currentVersion = resolveDesktopAppVersion();
return downloadAndInstallUpdate(
{ currentVersion, releaseChannel: await resolveRequestedReleaseChannel(args) },
async () => {
await stopDesktopDaemon("app_update");
},
);
},
get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(),
get_cli_install_status: () => getCliInstallStatus(),
read_legacy_skill_selection: () => readLegacySkillSelection(),
delete_legacy_skill_selection: () => deleteLegacySkillSelection(),
};
}
export function registerDaemonManager(): void {
const handlers = createDaemonCommandHandlers();
ipcMain.handle(
"paseo:invoke",
async (_event, command: string, args?: Record<string, unknown>) => {
const handler = handlers[command];
if (!handler) {
throw new Error(`Unknown desktop command: ${command}`);
}
return await handler(args);
},
);
}