From e07da55f8199f6c1eb4344a348855938c3d3dd85 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:00:01 +0200 Subject: [PATCH] Show Open in editor for a password-protected desktop daemon (#5335) * fix(daemon): report the local daemon's server id when a password is set Since #4575, `paseo daemon status` only learned the server id from an authenticated connection. The desktop app runs that command without the daemon password, so a password-protected desktop daemon reported no server id, the app treated it as remote, and hid Open in editor. The worker now announces its server id with readiness and the supervisor publishes it in the instance record next to the listen address, so status reports the running daemon's identity from local state. Closes #5300 * fix(cli): omit serverId from status until a daemon publishes one * chore: rerun CI after macOS file observation flake --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/commands/daemon/lifecycle.e2e.test.ts | 21 +++++++++++++++++++ packages/cli/src/commands/daemon/status.ts | 1 + .../server/scripts/supervisor-entrypoint.ts | 7 ++++--- .../server/scripts/supervisor.logging.test.ts | 2 +- .../scripts/supervisor.readiness.test.ts | 4 ++-- packages/server/scripts/supervisor.ts | 17 +++++++++++---- packages/server/src/server/bootstrap.ts | 2 ++ packages/server/src/server/daemon-worker.ts | 7 ++++++- packages/server/src/server/pid-lock.ts | 3 ++- 9 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index 03e4d6bdb5..b1f1f785d5 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -5,6 +5,7 @@ import net from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { hashDaemonPassword } from "@getpaseo/server/auth"; import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server/daemon-control"; import { expect, test } from "vitest"; import { connectToDaemon } from "../../utils/client.js"; @@ -196,6 +197,26 @@ test("removed flags and ambiguous targets fail before side effects; observation } }, 30_000); +test("status reports the server id of a password-protected daemon to a caller without the password", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const configPath = path.join(home, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.daemon.auth = { password: await hashDaemonPassword("secret") }; + await writeFile(configPath, JSON.stringify(config)); + await f.ok(["start", "--home", home, "--timeout", "30"]); + const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" }); + expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({ + connectedDaemon: "auth_required", + serverId: authenticated.serverId, + }); + } finally { + await f.close(); + } +}, 60_000); + test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => { const f = await fixture(); const [a, b] = f.homes as [string, string]; diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index d053ef11ea..14c20534e6 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -124,6 +124,7 @@ function localStatus(home: string, instance: Awaited { : undefined, restartOnCrash: true, logFile: supervisorLogFile, - onWorkerReady: async ({ listen }) => { - await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); + onWorkerReady: async ({ listen, serverId }) => { + await updatePidLock(paseoHome, { listen, serverId }, { ownerPid: process.pid }); }, - onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }), + onWorkerExit: () => + updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }), onSupervisorExit: releaseLock, }); requestSupervisorShutdown = supervisor.requestShutdown; diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 3eb5af9b32..cbb8b51309 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -38,7 +38,7 @@ async function runSupervisorFixture(options: { await writeFile( workerPath, - `process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`, + `process.send?.({ type: "paseo:ready", listen: "fixture", serverId: "srv_fixture" });\n${options.workerSource}`, ); await writeFile( runnerPath, diff --git a/packages/server/scripts/supervisor.readiness.test.ts b/packages/server/scripts/supervisor.readiness.test.ts index 297299d810..bfd02b1402 100644 --- a/packages/server/scripts/supervisor.readiness.test.ts +++ b/packages/server/scripts/supervisor.readiness.test.ts @@ -92,7 +92,7 @@ test("a replacement worker must independently reach ready", async () => { process.on("message", message => { if (message.type === "paseo:graceful-shutdown") process.exit(0); }); - process.send({ type: "paseo:ready", listen: "test-endpoint" }); + process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }); process.send({ type: "paseo:restart" }); `); expect(result.code).toBe(1); @@ -102,7 +102,7 @@ test("a replacement worker must independently reach ready", async () => { test("exit clears publication after an in-flight ready write", async () => { const result = await runWorker( ` - process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0)); + process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }, () => process.exit(0)); `, 100, ); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 5d48491f2a..4798753668 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -23,6 +23,7 @@ type WorkerLifecycleMessage = | { type: "paseo:ready"; listen: string; + serverId: string; } | { type: "paseo:restart"; @@ -50,7 +51,7 @@ interface SupervisorOptions { args: string[]; env?: NodeJS.ProcessEnv; } | null; - onWorkerReady?: (message: { listen: string }) => Promise | void; + onWorkerReady?: (message: { listen: string; serverId: string }) => Promise | void; onWorkerExit?: () => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; @@ -78,11 +79,14 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { }; } if (type === "paseo:ready") { - const listen = (msg as { listen?: unknown }).listen; + const { listen, serverId } = msg as { listen?: unknown; serverId?: unknown }; if (typeof listen !== "string" || listen.trim().length === 0) { return null; } - return { type: "paseo:ready", listen }; + if (typeof serverId !== "string" || serverId.trim().length === 0) { + return null; + } + return { type: "paseo:ready", listen, serverId }; } if (type === "paseo:restart") { const reason = (msg as { reason?: unknown }).reason; @@ -289,7 +293,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController reachedReady = true; writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen }); publication = publication - .then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen })) + .then(() => + options.onWorkerReady?.({ + listen: lifecycleMessage.listen, + serverId: lifecycleMessage.serverId, + }), + ) .catch((error) => { lifecycleFailed = true; const message = error instanceof Error ? error.message : String(error); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index c90da96eef..4f76b9356a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -471,6 +471,7 @@ export interface PaseoDaemon { start(): Promise; stop(): Promise; getListenTarget(): ListenTarget | null; + getServerId(): string; } export interface PaseoDaemonDependencies { @@ -1831,6 +1832,7 @@ export async function createPaseoDaemon( start, stop, getListenTarget: () => boundListenTarget, + getServerId: () => serverId, }; } diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index 2adc4ab982..bc5acbc404 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -17,6 +17,7 @@ type SupervisorLifecycleMessage = | { type: "paseo:ready"; listen: string; + serverId: string; } | { type: "paseo:restart"; @@ -327,7 +328,11 @@ async function main() { if (!listen) { throw new Error("Daemon did not expose a listen target after startup"); } - sendSupervisorLifecycleMessage({ type: "paseo:ready", listen }); + sendSupervisorLifecycleMessage({ + type: "paseo:ready", + listen, + serverId: daemon.getServerId(), + }); } catch (err) { logger.fatal({ err }, "Daemon failed to start listening"); throw err; diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 3005dce9d0..551d5be392 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -11,6 +11,7 @@ export const pidLockInfoSchema = z.object({ hostname: z.string(), uid: z.number(), listen: z.string().nullable(), + serverId: z.string().nullable().optional(), desktopManaged: z.boolean().optional(), heartbeat: z.literal(true).optional(), }); @@ -319,7 +320,7 @@ export function startPidLockHeartbeat( export async function updatePidLock( paseoHome: string, - patch: { listen: string | null }, + patch: { listen: string; serverId: string } | { listen: null; serverId: null }, options?: { ownerPid?: number }, ): Promise { const pidPath = getPidFilePath(paseoHome);