mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
3 Commits
v0.2.0
...
derive-des
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952e9e2974 | ||
|
|
887845418e | ||
|
|
f7c4ef774d |
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
99
packages/cli/src/commands/daemon/status.test.ts
Normal file
99
packages/cli/src/commands/daemon/status.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
67
packages/desktop/src/daemon/desktop-managed.test.ts
Normal file
67
packages/desktop/src/daemon/desktop-managed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
packages/desktop/src/daemon/desktop-managed.ts
Normal file
69
packages/desktop/src/daemon/desktop-managed.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user