Show a host's new version after its daemon restarts (#5351)

* Show a host's new version after its daemon restarts

The app wrote a host's server info into the session store only when a
client was first mounted, and a reconnect reuses the same client, so
after a daemon upgrade or restart the host page and version checks kept
the old version until the app restarted.

The host runtime now projects the client's current server info into the
session store on every connection change, keeping the last known value
while disconnected. The React mount effect that duplicated this write is
removed.

Closes #5236

Co-authored-by: Leonid <127580858+bashrusakh@users.noreply.github.com>

* Keep a caller-owned test daemon home across startup retries

A failed start removed the home even with cleanup disabled, so a retry on a
taken port came back with a new serverId. The host restart spec now drives a
restartable host daemon helper that owns its port, home, and teardown.

* Remove only the test daemon dirs a failed start created

A failed attempt now removes the temp home and static dirs it created and
leaves caller-supplied ones, so cleanup: false no longer leaks per retry.

* Use function declarations in the test daemon helpers

* test(app): cover host versions across daemon disconnect and reconnect

The host settings badge retains its last known version during a transport failure; the help menu should show the replacement version after reconnect without changing another host.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Leonid <127580858+bashrusakh@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-09-28 17:40:41 +01:00
committed by GitHub
co-authored by Leonid Mohamed Boudra
parent 7bb7d4ed08
commit b85b44aea1
10 changed files with 468 additions and 47 deletions
@@ -0,0 +1,111 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { openHostSection, selectSettingsHost } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import {
startRestartableHostDaemon,
type RestartableHostDaemon,
} from "../support/helpers/versioned-host-daemon";
const PREVIOUS_VERSION = "0.8.0";
const UPDATED_VERSION = "0.9.1";
const HOST_LABEL = "Version restart QA";
const NO_RELOAD_MARKER = "before-restart";
interface ReloadMarkerWindow {
__paseoE2eHostVersionMarker?: string;
}
test.describe.configure({ timeout: 120_000 });
let hostDaemon: RestartableHostDaemon | null = null;
test.afterEach(async () => {
await hostDaemon?.dispose();
hostDaemon = null;
});
test("host page shows the restarted daemon's version without reloading", async ({ page }) => {
const host = await startRestartableHostDaemon(PREVIOUS_VERSION);
hostDaemon = host;
const otherHostVersion =
await test.step("the connected host reports its current daemon version", async () => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: host.serverId,
label: HOST_LABEL,
port: host.port,
});
const version = await readHelpHostVersion(page, getServerId());
await expectHelpHostVersion(page, host.serverId, PREVIOUS_VERSION);
await openHostPage(page, host);
await expect(hostIdentity(page)).toContainText("Online");
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
await markPageForNoReloadCheck(page, NO_RELOAD_MARKER);
return version;
});
await test.step("the last known version remains on the host page when the host stops", async () => {
await host.stop();
await expect(hostIdentity(page)).toContainText("Error");
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
});
await test.step("the host daemon restarts on the new version", async () => {
await host.restartWithVersion(UPDATED_VERSION);
});
await test.step("the host page badge follows the new version", async () => {
await expect(hostVersionBadge(page, UPDATED_VERSION)).toBeVisible({ timeout: 30_000 });
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toHaveCount(0);
await page.getByRole("button", { name: "Back" }).click();
await expectHelpHostVersion(page, host.serverId, UPDATED_VERSION);
expect(await readHelpHostVersion(page, getServerId())).toBe(otherHostVersion);
await expectNoReloadSinceMarker(page);
});
});
async function readHelpHostVersion(page: Page, serverId: string): Promise<string> {
await page.getByTestId("sidebar-help").click();
const row = page.getByTestId(`sidebar-help-host-version-${serverId}`);
await expect(row).toBeVisible();
const text = await row.innerText();
await page.keyboard.press("Escape");
return text;
}
async function expectHelpHostVersion(page: Page, serverId: string, version: string): Promise<void> {
const text = await readHelpHostVersion(page, serverId);
expect(text).toContain(`v${version}`);
}
async function openHostPage(page: Page, host: RestartableHostDaemon): Promise<void> {
await openSettings(page);
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "host");
}
function hostIdentity(page: Page) {
return page.getByTestId("host-page-identity");
}
function hostVersionBadge(page: Page, version: string) {
return hostIdentity(page).getByText(`v${version}`, { exact: true });
}
async function markPageForNoReloadCheck(page: Page, marker: string): Promise<void> {
// The marker has to cross into the page as an argument: the callback runs in the browser, so a
// captured Node-side constant is not defined there.
await page.evaluate((value) => {
(window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker = value;
}, marker);
}
async function expectNoReloadSinceMarker(page: Page): Promise<void> {
const marker = await page.evaluate(
() => (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker ?? null,
);
expect(marker).toBe(NO_RELOAD_MARKER);
}
@@ -41,6 +41,13 @@ async function getAvailablePort(): Promise<number> {
});
}
export async function getAvailableHostDaemonPort(): Promise<number> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
return port;
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const deadline = Date.now() + 90_000;
let lastError: unknown = null;
@@ -81,9 +88,7 @@ export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
): Promise<IsolatedHostDaemon> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
const port = await getAvailableHostDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
@@ -0,0 +1,176 @@
import { mkdtemp, rm } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { getAvailableHostDaemonPort } from "./isolated-host-daemon";
import { killProcessTree, spawnTsx } from "./spawn-node";
const VERSIONED_DAEMON_ENTRYPOINT = path.resolve(
__dirname,
"../../../../server/src/server/test-utils/versioned-daemon.ts",
);
const READY_TIMEOUT_MS = 30_000;
// A host daemon that can be replaced by a daemon reporting another version under the same
// endpoint and serverId, the way an upgraded host comes back.
export interface RestartableHostDaemon {
serverId: string;
port: number;
stop(): Promise<void>;
restartWithVersion(version: string): Promise<void>;
dispose(): Promise<void>;
}
export async function startRestartableHostDaemon(version: string): Promise<RestartableHostDaemon> {
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-restartable-host-"));
const port = await getAvailableHostDaemonPort();
let current: VersionedHostDaemon | null = null;
async function dispose(): Promise<void> {
await current?.stop();
current = null;
await rm(paseoHomeRoot, { recursive: true, force: true });
}
try {
current = await startVersionedHostDaemon({ version, port, paseoHomeRoot });
} catch (error) {
await dispose();
throw error;
}
const { serverId } = current;
return {
serverId,
port,
async stop() {
await current?.stop();
current = null;
await waitForPortReleased(port);
},
async restartWithVersion(nextVersion) {
await current?.stop();
current = null;
await waitForPortReleased(port);
current = await startVersionedHostDaemon({ version: nextVersion, port, paseoHomeRoot });
if (current.serverId !== serverId) {
throw new Error(
`Restarted host daemon reported serverId ${current.serverId}, expected ${serverId}`,
);
}
},
dispose,
};
}
interface VersionedHostDaemon {
serverId: string;
port: number;
version: string;
stop(): Promise<void>;
}
interface VersionedHostDaemonOptions {
version: string;
port: number;
paseoHomeRoot: string;
}
// Starts the versioned test daemon on an explicit port with a caller-owned PASEO_HOME. The caller
// removes the home root.
async function startVersionedHostDaemon(
options: VersionedHostDaemonOptions,
): Promise<VersionedHostDaemon> {
const child = spawnTsx(
VERSIONED_DAEMON_ENTRYPOINT,
[options.version, String(options.port), options.paseoHomeRoot],
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
);
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
let output = "";
child.stdout?.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
child.once("error", ready.reject);
child.once("exit", () => {
ready.reject(
new Error(`Versioned daemon ${options.version} exited before reporting ready:\n${output}`),
);
});
child.once("message", (message) => {
if (!isReadyMessage(message)) {
ready.reject(new Error("Invalid versioned daemon startup message"));
return;
}
ready.resolve(message);
});
const timeout = setTimeout(
() =>
ready.reject(
new Error(
`Versioned daemon ${options.version} did not report ready within ${READY_TIMEOUT_MS}ms:\n${output}`,
),
),
READY_TIMEOUT_MS,
);
let stopped = false;
async function stop(): Promise<void> {
if (stopped) return;
stopped = true;
await killProcessTree(child);
}
try {
const readyMessage = await ready.promise;
if (readyMessage.port !== options.port) {
throw new Error(
`Versioned daemon ${options.version} bound port ${readyMessage.port}, expected ${options.port}`,
);
}
return {
serverId: readyMessage.serverId,
port: readyMessage.port,
version: options.version,
stop,
};
} catch (error) {
await stop();
throw error;
} finally {
clearTimeout(timeout);
}
}
// A replacement daemon can only bind the port the stopped daemon just released.
async function waitForPortReleased(port: number, timeoutMs = 15_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isPortAvailable(port)) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Port ${port} was not released within ${timeoutMs}ms`);
}
function isReadyMessage(message: unknown): message is { port: number; serverId: string } {
return (
typeof message === "object" &&
message !== null &&
"port" in message &&
typeof message.port === "number" &&
"serverId" in message &&
typeof message.serverId === "string"
);
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
+2 -28
View File
@@ -32,6 +32,7 @@ import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import {
selectAgentTimelineState,
toDaemonServerInfo,
useSessionStore,
type SessionState,
} from "@/stores/session-store";
@@ -318,24 +319,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[serverId],
);
useEffect(() => {
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
return;
}
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
}, [client, serverId, updateSessionServerInfo]);
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
serverId,
@@ -568,16 +551,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
if (serverInfo) {
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
return;
}
});
+43 -1
View File
@@ -158,17 +158,28 @@ class FakeDaemonClient {
}
public ownedSubscriptions = true;
private daemonVersion: string | null = "0.8.0";
getLastServerInfoMessage(): ReturnType<DaemonClient["getLastServerInfoMessage"]> {
if (this.daemonVersion === null) return null;
return {
status: "server_info",
serverId: "srv_test",
hostname: "test",
version: "0.8.0",
version: this.daemonVersion,
features: { ownedSubscriptions: this.ownedSubscriptions },
};
}
// Like the real client, the server info is cleared while disconnected and replaced by the
// restarted daemon's handshake before the client reports connected again.
daemonRestartsAs(version: string): void {
this.daemonVersion = null;
this.setConnectionState({ status: "disconnected", reason: "Connection lost" });
this.daemonVersion = version;
this.setConnectionState({ status: "connected" });
}
subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void {
this.listeners.add(listener);
listener(this.state);
@@ -2129,6 +2140,37 @@ describe("HostRuntimeStore", () => {
expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined();
});
it("shows the restarted daemon's version after the same client reconnects", async () => {
const host = makeHost({
serverId: "srv_restarted",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host: hostProfile }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: hostProfile.serverId,
hostname: hostProfile.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.8.0");
fakeClient.daemonRestartsAs("0.9.1");
expect(store.getSnapshot(host.serverId)?.client).toBe(fakeClient);
expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.9.1");
store.syncHosts([]);
});
it("drains snapshot and buffered running transitions exactly once", async () => {
const host = makeHost({
serverId: "srv_legacy_transitions",
+8
View File
@@ -50,6 +50,7 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
useSessionStore,
toDaemonServerInfo,
type Agent,
type WorkspaceDescriptor,
type ProjectDescriptor,
@@ -2266,6 +2267,13 @@ export class HostRuntimeStore {
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
// change its version and features) only reaches the store here. The client clears it while
// disconnected; keep the last known value until the next handshake.
const serverInfo = snapshot.client.getLastServerInfoMessage();
if (serverInfo) {
sessionStore.updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
}
}
private clearHostReplica(serverId: string): void {
+13
View File
@@ -285,6 +285,19 @@ export interface DaemonServerInfo {
features?: ServerInfoStatusPayload["features"];
}
export function toDaemonServerInfo(serverInfo: ServerInfoStatusPayload): DaemonServerInfo {
return {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname ?? null,
version: serverInfo.version ?? null,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
};
}
export interface AgentTimelineCursorState {
epoch: string;
startSeq: number;
@@ -0,0 +1,61 @@
import { existsSync } from "node:fs";
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon } from "./paseo-daemon.js";
describe("createTestPaseoDaemon", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
});
test("keeps a caller-owned home when startup on a taken port fails", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-owned-home-"));
cleanups.push(() => rm(paseoHomeRoot, { recursive: true, force: true }));
const ownedFile = path.join(paseoHomeRoot, "owned-by-caller");
await writeFile(ownedFile, "keep");
const takenPort = await occupyPort(cleanups);
await expect(
createTestPaseoDaemon({
listenPort: takenPort,
paseoHomeRoot,
staticDir: path.join(paseoHomeRoot, "static"),
cleanup: false,
mcpEnabled: false,
}),
).rejects.toMatchObject({ code: "EADDRINUSE" });
expect(existsSync(ownedFile)).toBe(true);
}, 60_000);
test("removes the directories it created when startup on a taken port fails", async () => {
const scratchTmp = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-scratch-"));
cleanups.push(() => rm(scratchTmp, { recursive: true, force: true }));
for (const name of ["TMPDIR", "TMP", "TEMP"]) vi.stubEnv(name, scratchTmp);
const takenPort = await occupyPort(cleanups);
await expect(
createTestPaseoDaemon({ listenPort: takenPort, cleanup: false, mcpEnabled: false }),
).rejects.toMatchObject({ code: "EADDRINUSE" });
expect(await readdir(scratchTmp)).toEqual([]);
}, 60_000);
});
async function occupyPort(cleanups: Array<() => Promise<void>>): Promise<number> {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
cleanups.push(() => new Promise<void>((resolve) => server.close(() => resolve())));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Port holder did not bind a TCP port");
}
return address.port;
}
@@ -21,6 +21,7 @@ interface TestPaseoDaemonOptions {
downloadTokenTtlMs?: number;
corsAllowedOrigins?: string[];
listen?: string;
listenPort?: number;
logger?: Parameters<typeof createPaseoDaemon>[1];
mcpEnabled?: boolean;
mcpDebug?: boolean;
@@ -99,7 +100,8 @@ export async function createTestPaseoDaemon(
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options);
const { config, paseoHomeRoot, paseoHome, staticDir, createdDirs } =
await prepareTestDaemonConfig(options);
const logger = options.logger ?? pino({ level: "silent" });
const daemon = await createPaseoDaemon(config, logger, {
builtinPlugins: options.builtinPlugins ?? new BuiltinPluginLoader(undefined, []),
@@ -120,10 +122,7 @@ export async function createTestPaseoDaemon(
await daemon.agentManager.flush().catch(() => undefined);
if (options.cleanup ?? true) {
await new Promise((r) => setTimeout(r, 50));
await Promise.all([
rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
]);
await removeDirs([paseoHomeRoot, staticDir]);
}
};
@@ -138,10 +137,8 @@ export async function createTestPaseoDaemon(
} catch (error) {
lastError = error;
await daemon.stop().catch(() => undefined);
await Promise.all([
rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }),
]);
// A failed attempt removes only what it created: a caller-supplied home keeps its serverId.
await removeDirs(createdDirs);
if (
(!isAddressInUseError(error) && !isStartupTimeoutError(error)) ||
@@ -160,19 +157,33 @@ interface PreparedTestDaemonConfig {
paseoHomeRoot: string;
paseoHome: string;
staticDir: string;
createdDirs: string[];
}
async function removeDirs(dirs: string[]): Promise<void> {
await Promise.all(
dirs.map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })),
);
}
async function createTempDir(prefix: string, createdDirs: string[]): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
createdDirs.push(dir);
return dir;
}
async function prepareTestDaemonConfig(
options: TestPaseoDaemonOptions,
): Promise<PreparedTestDaemonConfig> {
const paseoHomeRoot =
options.paseoHomeRoot ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-home-")));
const createdDirs: string[] = [];
const paseoHomeRoot = options.paseoHomeRoot ?? (await createTempDir("paseo-home-", createdDirs));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
await mkdir(paseoHome, { recursive: true });
const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-")));
const staticDir = options.staticDir ?? (await createTempDir("paseo-static-", createdDirs));
const listenHost = options.listen ?? "127.0.0.1";
const listenPort = options.listenPort ?? 0;
const config: PaseoDaemonConfig = {
listen: `${listenHost}:0`,
listen: `${listenHost}:${listenPort}`,
paseoHome,
daemonVersion: options.daemonVersion,
desktopManaged: options.desktopManaged,
@@ -207,7 +218,7 @@ async function prepareTestDaemonConfig(
pluginsEnabled: options.pluginsEnabled,
plugins: options.plugins,
};
return { config, paseoHomeRoot, paseoHome, staticDir };
return { config, paseoHomeRoot, paseoHome, staticDir, createdDirs };
}
function isAddressInUseError(error: unknown): boolean {
@@ -1,8 +1,28 @@
// Browser compatibility tests run a real daemon in a separate Node runtime.
//
// argv: <daemonVersion> [port] [paseoHomeRoot]
// A fixed port and a caller-owned home let a test stop this daemon and start a
// replacement under the same endpoint and serverId.
import path from "node:path";
import { createTestPaseoDaemon } from "./paseo-daemon.js";
const [, , daemonVersion, portArg, paseoHomeRoot] = process.argv;
const listenPort = portArg ? Number(portArg) : 0;
if (!Number.isInteger(listenPort) || listenPort < 0) {
throw new Error(`Invalid versioned daemon port: ${portArg ?? ""}`);
}
const daemon = await createTestPaseoDaemon({
daemonVersion: process.argv[2],
daemonVersion,
...(listenPort > 0 ? { listenPort } : {}),
...(paseoHomeRoot
? {
paseoHomeRoot,
staticDir: path.join(paseoHomeRoot, "static"),
// The caller removes the home so a replacement daemon keeps the same serverId.
cleanup: false,
}
: {}),
pluginsEnabled: true,
mcpEnabled: false,
corsAllowedOrigins: ["*"],