Compare commits

...

3 Commits

Author SHA1 Message Date
Mohamed Boudra
952e9e2974 fix(cli): clarify daemon executable status label 2026-07-06 16:40:40 +02:00
Mohamed Boudra
887845418e refactor(desktop): share daemon pid lock reader 2026-07-06 16:38:50 +02:00
Mohamed Boudra
f7c4ef774d fix(daemon): derive desktop management from executable
Desktop management now follows the daemon runtime path, so daemons started through the bundled CLI remain eligible for desktop upgrade restarts. New locks record executablePath; the old desktopManaged lock bit is accepted only for compatibility with existing locks.
2026-07-06 16:19:00 +02:00
10 changed files with 434 additions and 23 deletions

View File

@@ -37,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
---
## Runtime PID Lock
**Path:** `$PASEO_HOME/paseo.pid`
The daemon writes this file when it starts and removes it when it exits cleanly.
It is a runtime lock, not a durable settings file.
| Field | Type | Description |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `pid` | `number` | Owner process PID for stop/status operations |
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
| `hostname` | `string` | Host that created the lock |
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
Desktop-managed is a read-time classification by the desktop app: the daemon
executable must be the desktop app executable or live inside the same desktop
install. The CLI reports the raw `executablePath` but does not try to classify
it because an npm-installed CLI has no authoritative desktop install root.
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`

View File

@@ -25,6 +25,7 @@ export interface LocalDaemonPidInfo {
hostname?: string;
uid?: number;
listen?: string;
executablePath?: string;
desktopManaged?: boolean;
}
@@ -255,6 +256,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
listen: resolveListenField(parsed.listen, parsed.sockPath),
executablePath: typeof parsed.executablePath === "string" ? parsed.executablePath : undefined,
desktopManaged: parsed.desktopManaged === true ? true : undefined,
};
} catch {

View File

@@ -0,0 +1,99 @@
import type { Command } from "commander";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { CommandOptions } from "../../output/index.js";
import { runStatusCommand } from "./status.js";
const mocks = vi.hoisted(() => ({
execCommand: vi.fn(async () => ({ stdout: process.execPath, stderr: "" })),
findExecutable: vi.fn(async () => null),
getOrCreateServerId: vi.fn(() => "srv_test"),
loadConfig: vi.fn(() => ({
listen: "127.0.0.1:6767",
relayEnabled: true,
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: false,
relayPublicUseTls: false,
})),
resolvePaseoHome: vi.fn((env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo"),
spawnProcess: vi.fn(),
}));
vi.mock("@getpaseo/server", () => ({
execCommand: mocks.execCommand,
findExecutable: mocks.findExecutable,
getOrCreateServerId: mocks.getOrCreateServerId,
loadConfig: mocks.loadConfig,
resolvePaseoHome: mocks.resolvePaseoHome,
spawnProcess: mocks.spawnProcess,
}));
const tempRoots: string[] = [];
async function createStatusHome(pidLock: Record<string, unknown>): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-status-"));
tempRoots.push(root);
const home = path.join(root, ".paseo");
await mkdir(home, { recursive: true });
await writeFile(path.join(home, "paseo.pid"), JSON.stringify(pidLock));
return home;
}
async function readStatusJson(home: string): Promise<Record<string, unknown>> {
const previousPath = process.env.PATH;
try {
process.env.PATH = "";
const result = await runStatusCommand({ home } as CommandOptions, {} as Command);
return result.schema.serialize?.(result.data[0]) as Record<string, unknown>;
} finally {
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
}
}
describe("daemon status desktop management fields", () => {
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
test("reports executable identity without deriving desktop management in the CLI", async () => {
const executablePath =
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper";
const home = await createStatusHome({
pid: process.pid,
startedAt: "2026-07-06T00:00:00.000Z",
hostname: "dev-host",
uid: 501,
listen: "/tmp/paseo-status.sock",
executablePath,
});
await expect(readStatusJson(home)).resolves.toMatchObject({
daemonExecutablePath: executablePath,
desktopManaged: false,
});
});
test("keeps reporting legacy desktopManaged locks for old readers", async () => {
const home = await createStatusHome({
pid: process.pid,
startedAt: "2026-07-06T00:00:00.000Z",
hostname: "dev-host",
uid: 501,
listen: "/tmp/paseo-status.sock",
desktopManaged: true,
});
await expect(readStatusJson(home)).resolves.toMatchObject({
daemonExecutablePath: null,
desktopManaged: true,
});
});
});

View File

@@ -28,6 +28,7 @@ interface DaemonStatus {
owner: string | null;
logPath: string;
daemonNode: string;
daemonExecutablePath: string | null;
cliNode: string;
cliVersion: string;
daemonVersion: string | null;
@@ -126,6 +127,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
{ key: "Owner", value: status.owner ?? "-" },
{ key: "Logs", value: status.logPath },
{ key: "Daemon Node", value: status.daemonNode },
{ key: "PID Lock Executable", value: status.daemonExecutablePath ?? "-" },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
@@ -405,9 +407,11 @@ export async function runStatusCommand(
owner,
logPath: state.logPath,
daemonNode,
daemonExecutablePath: state.pidInfo?.executablePath ?? null,
cliNode,
cliVersion,
daemonVersion,
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
desktopManaged: state.pidInfo?.desktopManaged === true,
providers,
note,

View File

@@ -7,6 +7,8 @@ import { createDaemonCommandHandlers } from "./daemon-manager";
const mocks = vi.hoisted(() => ({
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
desktopExecutablePath: "/opt/Paseo/Paseo",
managedDaemonExecutablePath: "/opt/Paseo/resources/Paseo",
settings: {
releaseChannel: "stable",
daemon: {
@@ -23,7 +25,9 @@ const mocks = vi.hoisted(() => ({
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
getPath: vi.fn((name: string) =>
name === "exe" ? mocks.desktopExecutablePath : "/tmp/paseo-user-data",
),
getVersion: vi.fn(() => "1.2.3"),
isPackaged: true,
},
@@ -91,6 +95,30 @@ function createMockChildProcess(): MockChildProcess {
return child;
}
function pidLockPath(): string {
return `${mocks.paseoHome}/paseo.pid`;
}
function writePidLock(input: {
pid: number;
executablePath?: string;
desktopManaged?: boolean;
}): void {
mkdirSync(mocks.paseoHome, { recursive: true });
writeFileSync(pidLockPath(), JSON.stringify(input));
}
function writeManagedPidLock(pid: number): void {
writePidLock({
pid,
executablePath: mocks.managedDaemonExecutablePath,
});
}
function removePidLock(): void {
rmSync(pidLockPath(), { force: true });
}
function scheduleFailedStartup(child: MockChildProcess): void {
setImmediate(() => {
child.emit("exit", 1, null);
@@ -150,7 +178,54 @@ describe("daemon-manager commands", () => {
expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
});
it("derives desktop management from the pid lock executable path", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 4242,
listen: "127.0.0.1:6767",
daemonVersion: "1.2.3",
desktopManaged: false,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.desktop_daemon_status()).resolves.toEqual({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: null,
pid: 4242,
home: mocks.paseoHome,
version: "1.2.3",
desktopManaged: true,
error: null,
});
});
it("falls back to the legacy desktopManaged lock field for old locks", async () => {
writePidLock({ pid: 4242, desktopManaged: true });
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 4242,
listen: "127.0.0.1:6767",
daemonVersion: "1.2.3",
desktopManaged: false,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.desktop_daemon_status()).resolves.toMatchObject({
status: "running",
pid: 4242,
desktopManaged: true,
});
});
it("routes running desktop daemon stops through external CLI daemon stop", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "running",
@@ -159,7 +234,10 @@ describe("daemon-manager commands", () => {
listen: "127.0.0.1:6767",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
serverId: "",
@@ -226,6 +304,7 @@ describe("daemon-manager commands", () => {
});
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "stale_pid",
@@ -236,7 +315,10 @@ describe("daemon-manager commands", () => {
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
connectedDaemon: "unreachable",
@@ -269,6 +351,7 @@ describe("daemon-manager commands", () => {
});
it("records the renderer stop reason when stopping the desktop daemon", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "running",
@@ -277,7 +360,10 @@ describe("daemon-manager commands", () => {
listen: "127.0.0.1:6767",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped", reason: "lifecycle_shutdown_rpc" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
serverId: "",
@@ -302,6 +388,7 @@ describe("daemon-manager commands", () => {
});
it("uses a stale reachable desktop daemon when the version matches", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
@@ -330,6 +417,7 @@ describe("daemon-manager commands", () => {
});
it("restarts a stale reachable desktop daemon when the version differs", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "stale_pid",
@@ -350,21 +438,27 @@ describe("daemon-manager commands", () => {
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
})
.mockResolvedValueOnce({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-2",
pid: 8888,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.3",
desktopManaged: true,
.mockImplementationOnce(async () => {
writeManagedPidLock(8888);
return {
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-2",
pid: 8888,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.3",
desktopManaged: true,
};
});
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
const handlers = createDaemonCommandHandlers();

View File

@@ -39,6 +39,7 @@ import {
import type { DesktopSettings } from "../settings/desktop-settings.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const STARTUP_POLL_INTERVAL_MS = 200;
@@ -123,18 +124,43 @@ function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
}
export function isDesktopManagedDaemonRunningSync(): boolean {
function deriveDesktopManagedFromPidLock(lock: Record<string, unknown>): boolean {
const executablePath = toTrimmedString(lock.executablePath);
if (executablePath) {
return deriveDesktopManagedFromExecutablePath({
daemonExecutablePath: executablePath,
desktopExecutablePath: app.getPath("exe"),
platform: process.platform,
});
}
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
return lock.desktopManaged === true;
}
function readPidLockRecord(): Record<string, unknown> | null {
try {
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown };
if (lock.desktopManaged !== true) return false;
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
return isProcessRunning(lock.pid);
const lock = JSON.parse(raw) as unknown;
return isRecord(lock) ? lock : null;
} catch {
return false;
return null;
}
}
function readDesktopManagedFromPidLock(): boolean {
const lock = readPidLockRecord();
return lock !== null && deriveDesktopManagedFromPidLock(lock);
}
export function isDesktopManagedDaemonRunningSync(): boolean {
const lock = readPidLockRecord();
if (lock === null) return false;
if (!deriveDesktopManagedFromPidLock(lock)) return false;
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
return isProcessRunning(lock.pid);
}
function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record<string, unknown> {
return {
status: status.status,
@@ -276,7 +302,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
const hasRunningLocalProcess = localDaemon === "running";
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
const desktopManaged = payload.desktopManaged === true;
const desktopManaged = readDesktopManagedFromPidLock();
const apiReachable = connectedDaemon === "reachable";
let status: DesktopDaemonState = "stopped";
if (apiReachable || hasRunningLocalProcess) {
@@ -418,7 +444,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
detached: true,
envMode: "internal",
env: invocation.env,
envOverlay: { PASEO_DESKTOP_MANAGED: "1", PASEO_WEB_UI_ENABLED: "false" },
envOverlay: { PASEO_WEB_UI_ENABLED: "false" },
stdio: ["ignore", "ignore", "ignore"],
});

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed";
describe("desktop managed daemon executable derivation", () => {
it("treats the macOS Helper inside the app bundle as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
daemonExecutablePath:
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
platform: "darwin",
}),
).toBe(true);
});
it("rejects a macOS Helper from a different app bundle", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
daemonExecutablePath:
"/Applications/Other.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
platform: "darwin",
}),
).toBe(false);
});
it("treats executables inside the Windows install directory as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
daemonExecutablePath:
"c:\\users\\me\\appdata\\local\\programs\\paseo\\resources\\Paseo.exe",
platform: "win32",
}),
).toBe(true);
});
it("rejects system Node on Windows", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
daemonExecutablePath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
}),
).toBe(false);
});
it("treats Linux executables from the same install directory as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/opt/Paseo/Paseo",
daemonExecutablePath: "/opt/Paseo/resources/Paseo",
platform: "linux",
}),
).toBe(true);
});
it("rejects npm-installed CLI daemon executables", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/opt/Paseo/Paseo",
daemonExecutablePath: "/usr/local/bin/node",
platform: "linux",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,69 @@
import path from "node:path";
interface DesktopManagedInput {
daemonExecutablePath: string | null | undefined;
desktopExecutablePath: string;
platform: NodeJS.Platform;
}
function pathModuleForPlatform(platform: NodeJS.Platform): typeof path.win32 | typeof path.posix {
return platform === "win32" ? path.win32 : path.posix;
}
function normalizeForComparison(filePath: string, platform: NodeJS.Platform): string {
const pathModule = pathModuleForPlatform(platform);
const normalized = pathModule.normalize(filePath.trim());
return platform === "win32" ? normalized.toLowerCase() : normalized;
}
function resolveMacAppRoot(filePath: string): string | null {
const marker = ".app/";
const markerIndex = filePath.indexOf(marker);
if (markerIndex === -1) {
return filePath.endsWith(".app") ? filePath : null;
}
return filePath.slice(0, markerIndex + ".app".length);
}
function resolveDesktopInstallRoot(input: {
desktopExecutablePath: string;
platform: NodeJS.Platform;
}): string {
const normalized = normalizeForComparison(input.desktopExecutablePath, input.platform);
if (input.platform === "darwin") {
return resolveMacAppRoot(normalized) ?? path.posix.dirname(normalized);
}
return pathModuleForPlatform(input.platform).dirname(normalized);
}
function isSamePathOrInside(input: {
candidatePath: string;
parentPath: string;
platform: NodeJS.Platform;
}): boolean {
const pathModule = pathModuleForPlatform(input.platform);
const candidatePath = normalizeForComparison(input.candidatePath, input.platform);
const parentPath = normalizeForComparison(input.parentPath, input.platform);
const relative = pathModule.relative(parentPath, candidatePath);
return (
relative === "" ||
(relative.length > 0 && !relative.startsWith("..") && !pathModule.isAbsolute(relative))
);
}
export function deriveDesktopManagedFromExecutablePath(input: DesktopManagedInput): boolean {
if (!input.daemonExecutablePath?.trim()) {
return false;
}
const desktopInstallRoot = resolveDesktopInstallRoot({
desktopExecutablePath: input.desktopExecutablePath,
platform: input.platform,
});
return isSamePathOrInside({
candidatePath: input.daemonExecutablePath,
parentPath: desktopInstallRoot,
platform: input.platform,
});
}

View File

@@ -6,6 +6,29 @@ import { describe, expect, test } from "vitest";
import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js";
describe("pid-lock ownership", () => {
test("records executable identity without writing desktop management intent", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-exec-"));
const previousDesktopManaged = process.env.PASEO_DESKTOP_MANAGED;
try {
process.env.PASEO_DESKTOP_MANAGED = "1";
await acquirePidLock(paseoHome, null, { ownerPid: process.pid });
const lock = await getPidLockInfo(paseoHome);
expect(lock?.executablePath).toBe(process.execPath);
expect(lock?.desktopManaged).toBeUndefined();
} finally {
if (previousDesktopManaged === undefined) {
delete process.env.PASEO_DESKTOP_MANAGED;
} else {
process.env.PASEO_DESKTOP_MANAGED = previousDesktopManaged;
}
await releasePidLock(paseoHome, { ownerPid: process.pid });
await rm(paseoHome, { recursive: true, force: true });
}
});
test("writes and releases lock for explicit owner pid", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-"));
const ownerPid = process.pid + 10_000;
@@ -22,6 +45,7 @@ describe("pid-lock ownership", () => {
const lock = await getPidLockInfo(paseoHome);
expect(lock?.pid).toBe(ownerPid);
expect(lock?.listen).toBeNull();
expect(lock?.executablePath).toBe(process.execPath);
await (
updatePidLock as unknown as (

View File

@@ -10,6 +10,8 @@ export const pidLockInfoSchema = z.object({
hostname: z.string(),
uid: z.number(),
listen: z.string().nullable(),
executablePath: z.string().optional(),
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
desktopManaged: z.boolean().optional(),
});
@@ -99,7 +101,7 @@ export async function acquirePidLock(
hostname: hostname(),
uid: process.getuid?.() ?? 0,
listen,
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
executablePath: process.execPath,
};
let fd;