diff --git a/docs/data-model.md b/docs/data-model.md index 7becabdc72..c80e0f485b 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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` diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 06f124e13d..b3723971a8 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -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 { diff --git a/packages/cli/src/commands/daemon/status.test.ts b/packages/cli/src/commands/daemon/status.test.ts new file mode 100644 index 0000000000..95bfe76931 --- /dev/null +++ b/packages/cli/src/commands/daemon/status.test.ts @@ -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): Promise { + 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> { + 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; + } 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, + }); + }); +}); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 392279f4ad..bd70caff48 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -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: "Daemon 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, diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 695fbf72af..26cf17c54d 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -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(); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 10bae9a956..44456d4348 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -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,11 +124,36 @@ function logFilePath(): string { return path.join(getPaseoHome(), DAEMON_LOG_FILENAME); } +function deriveDesktopManagedFromPidLock(lock: Record): 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 readDesktopManagedFromPidLock(): boolean { + try { + const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8"); + const lock = JSON.parse(raw) as unknown; + return isRecord(lock) && deriveDesktopManagedFromPidLock(lock); + } catch { + return false; + } +} + export function isDesktopManagedDaemonRunningSync(): boolean { 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; + const lock = JSON.parse(raw) as unknown; + if (!isRecord(lock)) return false; + if (!deriveDesktopManagedFromPidLock(lock)) return false; if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false; return isProcessRunning(lock.pid); } catch { @@ -276,7 +302,7 @@ export async function resolveDesktopDaemonStatus(): Promise 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 { 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"], }); diff --git a/packages/desktop/src/daemon/desktop-managed.test.ts b/packages/desktop/src/daemon/desktop-managed.test.ts new file mode 100644 index 0000000000..0b3b1497f9 --- /dev/null +++ b/packages/desktop/src/daemon/desktop-managed.test.ts @@ -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); + }); +}); diff --git a/packages/desktop/src/daemon/desktop-managed.ts b/packages/desktop/src/daemon/desktop-managed.ts new file mode 100644 index 0000000000..727a8debc5 --- /dev/null +++ b/packages/desktop/src/daemon/desktop-managed.ts @@ -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, + }); +} diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 527655e1b6..c311f152a2 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -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 ( diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 96d663e891..27f6103273 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -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;