fix(daemon): keep lifecycle operations on the selected instance

Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop.
This commit is contained in:
Mohamed Boudra
2026-09-09 14:09:53 +02:00
parent fdf3b4b47f
commit 264276880e
166 changed files with 3824 additions and 4467 deletions
+8 -19
View File
@@ -1,14 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { ensurePrivateDirectory } from "@getpaseo/server";
import { readFile, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
const CLIENT_SESSION_KEY_FILE = join(
process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
"cli-client-id",
);
let cachedClientId: string | null = null;
import { join } from "node:path";
function normalizeClientId(value: string): string | null {
const trimmed = value.trim();
@@ -19,15 +12,12 @@ function generateClientId(): string {
return `cid_${randomUUID().replace(/-/g, "")}`;
}
export async function getOrCreateCliClientId(): Promise<string> {
if (cachedClientId) {
return cachedClientId;
}
export async function getOrCreateCliClientId(home: string): Promise<string> {
const clientSessionKeyFile = join(home, "cli-client-id");
try {
const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8"));
const existing = normalizeClientId(await readFile(clientSessionKeyFile, "utf8"));
if (existing) {
cachedClientId = existing;
return existing;
}
} catch (error) {
@@ -38,8 +28,7 @@ export async function getOrCreateCliClientId(): Promise<string> {
}
const nextValue = generateClientId();
await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true });
await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 });
cachedClientId = nextValue;
ensurePrivateDirectory(home);
await writeFile(clientSessionKeyFile, nextValue, { mode: 0o600 });
return nextValue;
}
+3 -1
View File
@@ -47,7 +47,9 @@ describe("CLI SSH transport", () => {
});
it("routes an SSH host through a local tunnel", async () => {
await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" });
await connectToDaemon({
target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" },
});
expect(mocks.createSshTunnel).toHaveBeenCalledWith({
host: "deploy@build-box",
+76 -151
View File
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
import { waitForDaemonReady, resolvePaseoHome, type DaemonInstance } from "@getpaseo/server";
import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
export type { DaemonTarget } from "./daemon-target.js";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
@@ -13,57 +14,44 @@ import {
} from "@getpaseo/protocol/connection-offer";
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import { WebSocket } from "ws";
import { getOrCreateCliClientId } from "./client-id.js";
import { resolveCliVersion } from "../version.js";
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
export interface ConnectOptions {
host?: string;
target: DaemonTarget;
timeout?: number;
instance?: DaemonInstance;
}
export interface DaemonConnectionCommandError {
code: "DAEMON_NOT_RUNNING";
message: string;
details: string;
}
const DEFAULT_HOST = "localhost:6767";
const DEFAULT_TIMEOUT = 15000;
const PID_FILENAME = "paseo.pid";
type TransportTarget =
| { type: "tcp"; url: string }
| { type: "ipc"; url: string; socketPath: string };
type DaemonTarget =
| {
type: "tcp";
url: string;
}
| {
type: "ipc";
url: string;
socketPath: string;
};
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST;
export function getDaemonHost(options: ConnectOptions): string {
return describeDaemonTarget(options.target);
}
export function buildDaemonConnectionCommandError(options: {
host?: string;
error: unknown;
}): DaemonConnectionCommandError {
const host = getDaemonHost({ host: options.host });
const message = options.error instanceof Error ? options.error.message : String(options.error);
export function buildDaemonConnectionCommandError(options: ConnectOptions & { error: unknown }) {
const error = options.error;
let message = error instanceof Error ? error.message : String(error);
if (error && typeof error === "object" && "message" in error) message = String(error.message);
if (options.target.kind === "endpoint")
message = message.replaceAll(options.target.host, describeDaemonTarget(options.target));
if (message.startsWith("Cannot connect to daemon at "))
return error as { code: string; message: string; details: string };
let code = "DAEMON_UNREACHABLE";
if (typeof error === "object" && error !== null && "code" in error) code = String(error.code);
else if (message === "Password required") code = "AUTH_REQUIRED";
else if (message === "Incorrect password") code = "AUTH_FAILED";
return {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: host.trim().startsWith("ssh://")
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
: "Start the daemon with: paseo daemon start",
code,
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
details:
options.target.kind === "instance"
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
};
}
@@ -118,100 +106,13 @@ export function normalizeDaemonHost(raw: string): string | null {
return trimmed.includes(":") ? trimmed : null;
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST;
}
function isIpcDaemonHost(host: string | null): host is string {
return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://"));
}
function isTcpDaemonHost(host: string | null): host is string {
return host !== null && !isIpcDaemonHost(host);
}
function readPidSocketTarget(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, PID_FILENAME);
if (!existsSync(pidPath)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as {
listen?: unknown;
sockPath?: unknown;
};
if (typeof parsed.listen === "string") return parsed.listen;
if (typeof parsed.sockPath === "string") return parsed.sockPath;
return null;
} catch {
return null;
}
}
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
if (isIpcDaemonHost(directEnvHost)) {
return directEnvHost;
}
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? "");
if (isIpcDaemonHost(pidHost)) {
return pidHost;
}
const config = loadConfig(paseoHome, { env });
const configuredHost = normalizeDaemonHost(config.listen);
return isIpcDaemonHost(configuredHost) ? configuredHost : null;
}
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen);
if (!isTcpDaemonHost(configuredHost)) {
return null;
}
return configuredHost === "127.0.0.1:6767" ? null : configuredHost;
}
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const paseoHome = resolvePaseoHome(env);
const candidates: string[] = [];
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome);
if (configuredIpcHost) {
candidates.push(configuredIpcHost);
}
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome);
if (configuredTcpHost) {
candidates.push(configuredTcpHost);
}
candidates.push(DEFAULT_HOST);
return Array.from(new Set(candidates));
}
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
const explicitHost = getExplicitDaemonHost(options?.host);
if (explicitHost) {
return [explicitHost];
}
return resolveDefaultDaemonHosts();
}
export function getExplicitDaemonHost(
host: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const explicitHost = host ?? env.PASEO_HOST;
return explicitHost?.trim() ? explicitHost : undefined;
}
function stripIpcPrefix(trimmed: string): string {
if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim();
if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim();
return trimmed;
}
export function resolveDaemonTarget(host: string): DaemonTarget {
export function resolveDaemonTarget(host: string): TransportTarget {
const trimmed = normalizeDaemonHost(host);
if (!trimmed) {
throw new Error(`Invalid daemon target: ${host}`);
@@ -363,12 +264,21 @@ function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null
}
}
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
const clientId = await getOrCreateCliClientId();
async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonClient> {
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
const deadline = Date.now() + timeout;
const explicitHost =
options.target.kind === "endpoint"
? options.target.host
: (
await waitForDaemonReady(options.target.home, {
timeoutMs: timeout,
instance: options.instance,
})
).listen;
const clientId = await getOrCreateCliClientId(resolvePaseoHome({}));
const nodeWebSocketFactory = createNodeWebSocketFactory();
const explicitHost = getExplicitDaemonHost(options?.host);
if (explicitHost?.trim().startsWith("ssh://")) {
const target = parseSshTransportUri(explicitHost.trim());
const tunnel = await createSshTunnel(target);
@@ -377,10 +287,20 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
tunnel.endpoint,
password,
clientId,
timeout,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
if ("client" in result) return result.client;
if ("client" in result) {
const close = result.client.close.bind(result.client);
result.client.close = async () => {
try {
await close();
} finally {
tunnel.close();
}
};
return result.client;
}
const failure = tunnel.failureDetail();
tunnel.close();
@@ -389,32 +309,37 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
}
const offer = parseHostOfferOrNull(explicitHost);
if (offer) {
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
return connectViaRelayOffer(
offer,
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
}
const hosts = resolveDaemonHostCandidates(options);
const result = await tryConnectHost(
explicitHost,
resolveDaemonPassword(explicitHost),
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
);
if ("client" in result) return result.client;
throw result.error;
}
async function tryNext(index: number, lastError: unknown): Promise<DaemonClient> {
if (index >= hosts.length) {
if (lastError instanceof Error) throw lastError;
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
}
const host = hosts[index];
const password = resolveDaemonPassword(host);
const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory);
if ("client" in result) {
return result.client;
}
return tryNext(index + 1, result.error);
export async function connectToDaemon(options: ConnectOptions): Promise<DaemonClient> {
try {
return await connectSelectedDaemon(options);
} catch (error) {
throw buildDaemonConnectionCommandError({ ...options, error });
}
return tryNext(0, null);
}
/**
* Try to connect to the daemon, returns null if connection fails
*/
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
export async function tryConnectToDaemon(options: ConnectOptions): Promise<DaemonClient | null> {
try {
return await connectToDaemon(options);
} catch {
+14 -11
View File
@@ -1,16 +1,19 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { addDaemonHostOption, withGlobalOptions } from "./command-options.js";
import { createCli } from "../cli.js";
import { withGlobalOptions } from "./command-options.js";
async function parseHost(argv: string[]): Promise<string | undefined> {
const program = new Command().exitOverride().option("--host <host>");
const program = createCli().exitOverride();
let receivedHost: string | undefined;
addDaemonHostOption(program.command("ls")).action(
withGlobalOptions((options: { host?: string }) => {
receivedHost = options.host;
}),
);
program.commands
.find((command) => command.name() === "ls")!
.action(
withGlobalOptions((options: { host?: string }) => {
receivedHost = options.host;
}),
);
await program.parseAsync(argv, { from: "user" });
return receivedHost;
@@ -25,10 +28,10 @@ describe("global command options", () => {
await expect(parseHost(["ls", "--host", "local:6767"])).resolves.toBe("local:6767");
});
it("uses the last explicit host", async () => {
await expect(parseHost(["--host", "first:6767", "ls", "--host", "last:6767"])).resolves.toBe(
"last:6767",
);
it("rejects conflicting parent and command selectors", async () => {
await expect(
parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]),
).rejects.toMatchObject({ code: "TARGET_AMBIGUOUS" });
});
it("lets local-only commands ignore a global host", async () => {
+25 -2
View File
@@ -1,8 +1,17 @@
import type { Command } from "commander";
import { selectDaemonTarget } from "./daemon-target.js";
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
const DAEMON_HOST_OPTION_DESCRIPTION =
"Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)";
"Explicit daemon endpoint: host:port, TCP, socket, SSH, or pairing URL (default: local home)";
const localCommands = new WeakSet<Command>();
export function addLocalDaemonOptions<T extends Command>(command: T): T {
localCommands.add(command);
command.option("--home <path>", "Local daemon home (default: ~/.paseo)");
return command;
}
export function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value]);
@@ -15,6 +24,8 @@ export function addJsonOption<T extends Command>(command: T): T {
export function addDaemonHostOption<T extends Command>(command: T): T {
command.option("--host <host>", DAEMON_HOST_OPTION_DESCRIPTION);
if (!command.options.some((option) => option.long === "--home"))
command.option("--home <path>", "Select a local daemon home (default: ~/.paseo)");
return command;
}
@@ -28,7 +39,19 @@ export function withGlobalOptions<Args extends unknown[], Result>(
return (...args) => {
const command = args.at(-1) as Command;
const mergedArgs = [...args];
mergedArgs[mergedArgs.length - 2] = command.optsWithGlobals();
const options = command.optsWithGlobals();
const selectors = { home: new Set<string>(), host: new Set<string>() };
for (let current: Command | null = command; current; current = current.parent) {
for (const key of ["home", "host"] as const) {
const value = current.opts()[key];
if (typeof value === "string") selectors[key].add(value);
}
}
if (selectors.home.size > 1 || selectors.host.size > 1)
throw { code: "TARGET_AMBIGUOUS", message: "Conflicting duplicate daemon selectors." };
const localOnly = localCommands.has(command);
options.daemonTarget = selectDaemonTarget(options, process.env, localOnly);
mergedArgs[mergedArgs.length - 2] = options;
return handler(...(mergedArgs as Args));
};
}
@@ -0,0 +1,32 @@
import { test, expect } from "vitest";
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
test("explicit selectors win over both environment selectors", () => {
const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" };
expect(selectDaemonTarget({ home: "/tmp/b" }, env)).toEqual({ kind: "instance", home: "/tmp/b" });
expect(selectDaemonTarget({ host: "chosen:23456" }, env)).toEqual({
kind: "endpoint",
host: "chosen:23456",
});
expect(() => selectDaemonTarget({}, env)).toThrow();
expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow();
});
test("local operations ignore routing environment but reject an explicit endpoint", () => {
expect(
selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true),
).toEqual({ kind: "instance", home: "/tmp/b" });
expect(() => selectDaemonTarget({ host: "chosen:23456" }, {}, true)).toThrow();
});
test("endpoint descriptions redact pairing material and credentials", () => {
expect(
describeDaemonTarget({
kind: "endpoint",
host: "tcp://user:private@example.test:23456?password=secret",
}),
).not.toMatch(/private|secret/);
expect(
describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }),
).not.toContain("private");
});
+51
View File
@@ -0,0 +1,51 @@
import { resolvePaseoHome } from "@getpaseo/server";
export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string };
export function selectDaemonTarget(
options: { home?: string; host?: string },
env: NodeJS.ProcessEnv = process.env,
localOnly = false,
): DaemonTarget {
for (const key of ["home", "host"] as const) {
if (options[key] !== undefined && !options[key]!.trim())
throw { code: "TARGET_INVALID", message: `--${key} requires a non-empty value.` };
}
if (options.home !== undefined && options.host !== undefined)
throw { code: "TARGET_AMBIGUOUS", message: "Choose either --home or --host, not both." };
if (localOnly) {
if (options.host !== undefined)
throw {
code: "LOCAL_OPERATION",
message: "This is a local operation; use --home. --host is not supported.",
};
return {
kind: "instance",
home: resolvePaseoHome({ PASEO_HOME: options.home ?? env.PASEO_HOME }),
};
}
if (options.home !== undefined)
return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: options.home }) };
if (options.host !== undefined) return { kind: "endpoint", host: options.host };
if (env.PASEO_HOME && env.PASEO_HOST)
throw {
code: "TARGET_AMBIGUOUS",
message: "PASEO_HOME and PASEO_HOST are both set. Choose --home or --host explicitly.",
};
if (env.PASEO_HOST) return { kind: "endpoint", host: env.PASEO_HOST };
return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: env.PASEO_HOME }) };
}
export function describeDaemonTarget(target: DaemonTarget): string {
if (target.kind === "instance") return `home ${target.home}`;
try {
const url = new URL(target.host);
if (url.password) url.password = "REDACTED";
for (const key of url.searchParams.keys())
if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED");
if (url.hash) url.hash = "REDACTED";
return url.toString();
} catch {
return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED");
}
}