mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Daemon hardening epic
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
spawnSync: vi.fn(),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: mocks.spawnSync,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@getpaseo/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
|
||||
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
};
|
||||
});
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
pid = 4242;
|
||||
unref = vi.fn();
|
||||
}
|
||||
|
||||
function expectSupervisorLaunch(argv: string[]): void {
|
||||
const joined = argv.join(" ");
|
||||
expect(joined).toContain("supervisor-entrypoint");
|
||||
expect(joined).not.toContain("src/server/index.ts");
|
||||
expect(joined).not.toContain("dist/server/server/index.js");
|
||||
expect(joined).not.toContain("src/server/daemon-worker.ts");
|
||||
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
|
||||
}
|
||||
|
||||
describe("local daemon launch supervision", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mocks.spawnSync.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
});
|
||||
|
||||
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||
|
||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(mocks.spawnSync).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-relay");
|
||||
});
|
||||
|
||||
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChildProcess();
|
||||
mocks.spawnProcess.mockReturnValue(child);
|
||||
|
||||
const { startLocalDaemonDetached } = await import("./local-daemon.js");
|
||||
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
|
||||
expect(child.unref).toHaveBeenCalledOnce();
|
||||
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-mcp");
|
||||
});
|
||||
});
|
||||
105
packages/cli/tests/26-daemon-launch-supervision.test.ts
Normal file
105
packages/cli/tests/26-daemon-launch-supervision.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Regression: executable daemon launch commands must enter the supervisor.
|
||||
* The worker entry remains an implementation detail of supervisor-entrypoint.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const repoRoot = join(import.meta.dirname, "../../..");
|
||||
const serverPackagePath = join(repoRoot, "packages/server/package.json");
|
||||
const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts");
|
||||
const serverConnectionOfferE2ePath = join(
|
||||
repoRoot,
|
||||
"packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts",
|
||||
);
|
||||
const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts");
|
||||
const nixPackagePath = join(repoRoot, "nix/package.nix");
|
||||
|
||||
function assertNoDirectWorkerLaunch(label: string, command: string): void {
|
||||
assert(
|
||||
!command.includes("src/server/index.ts"),
|
||||
`${label} must not launch src/server/index.ts directly: ${command}`,
|
||||
);
|
||||
assert(
|
||||
!command.includes("dist/server/server/index.js"),
|
||||
`${label} must not launch dist/server/server/index.js directly: ${command}`,
|
||||
);
|
||||
assert(
|
||||
!command.includes("src/server/daemon-worker.ts"),
|
||||
`${label} must not launch src/server/daemon-worker.ts directly: ${command}`,
|
||||
);
|
||||
assert(
|
||||
!command.includes("dist/server/server/daemon-worker.js"),
|
||||
`${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void {
|
||||
assertNoDirectWorkerLaunch(label, source);
|
||||
assert(
|
||||
!/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source),
|
||||
`${label} must not spawn ../index.ts directly`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("=== Daemon Launch Supervision Regression ===\n");
|
||||
|
||||
console.log("Test 1: server package scripts launch supervisor-entrypoint");
|
||||
const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const startScript = serverPackage.scripts?.start ?? "";
|
||||
const devScript = serverPackage.scripts?.dev ?? "";
|
||||
const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? "";
|
||||
|
||||
assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript);
|
||||
assertNoDirectWorkerLaunch("server start script", startScript);
|
||||
assert(devScript.includes("scripts/dev-runner.ts"), devScript);
|
||||
assertNoDirectWorkerLaunch("server dev script", devScript);
|
||||
assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript);
|
||||
assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript);
|
||||
console.log("✓ server package scripts enter supervisor\n");
|
||||
|
||||
console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode");
|
||||
const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8");
|
||||
assert(
|
||||
appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'),
|
||||
"app e2e setup should spawn supervisor-entrypoint.ts with --dev",
|
||||
);
|
||||
assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup);
|
||||
console.log("✓ app e2e setup enters supervisor\n");
|
||||
|
||||
console.log("Test 3: server daemon e2e process launch enters supervisor");
|
||||
const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8");
|
||||
assert(
|
||||
serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"),
|
||||
"server daemon e2e process launch should use supervisor-entrypoint.ts",
|
||||
);
|
||||
assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e);
|
||||
console.log("✓ server daemon e2e process launch enters supervisor\n");
|
||||
|
||||
console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint");
|
||||
const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8");
|
||||
assert(
|
||||
desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'),
|
||||
"desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js",
|
||||
);
|
||||
assert(
|
||||
desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'),
|
||||
"desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts",
|
||||
);
|
||||
assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths);
|
||||
|
||||
const nixPackage = await readFile(nixPackagePath, "utf-8");
|
||||
assert(
|
||||
nixPackage.includes("dist/scripts/supervisor-entrypoint.js"),
|
||||
"Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js",
|
||||
);
|
||||
assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage);
|
||||
console.log("✓ desktop runtime and Nix wrapper enter supervisor\n");
|
||||
|
||||
console.log("=== Daemon launch supervision regression test passed ===");
|
||||
@@ -1,198 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Regression: unsupervised restart request should gracefully stop and exit 0,
|
||||
* so an external owner can decide whether to respawn.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { tryConnectToDaemon } from "../src/utils/client.ts";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
const pollIntervalMs = 100;
|
||||
const testEnv = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (await check()) return;
|
||||
if (Date.now() >= deadline) throw new Error(message);
|
||||
await sleep(pollIntervalMs);
|
||||
return poll();
|
||||
}
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
interface ExitResult {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}
|
||||
|
||||
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("timed out waiting for process exit"));
|
||||
}, timeoutMs);
|
||||
|
||||
processRef.once("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function canConnectToDaemon(host: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
async function poll(): Promise<boolean> {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 500 }).catch(() => null);
|
||||
if (client) {
|
||||
await client.close().catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
if (Date.now() >= deadline) return false;
|
||||
await sleep(pollIntervalMs);
|
||||
return poll();
|
||||
}
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
async function readPidLockPid(paseoHome: string): Promise<number | null> {
|
||||
const pidPath = join(paseoHome, "paseo.pid");
|
||||
try {
|
||||
const content = await readFile(pidPath, "utf-8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed.pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("=== Daemon Restart (unsupervised regression) ===\n");
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-unsupervised-"));
|
||||
const cliRoot = join(import.meta.dirname, "..");
|
||||
const host = `127.0.0.1:${port}`;
|
||||
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let recentDaemonLogs = "";
|
||||
|
||||
try {
|
||||
console.log("Test 1: start unsupervised daemon worker directly");
|
||||
|
||||
daemonProcess = spawn(process.execPath, ["--import", "tsx", "../server/src/server/index.ts"], {
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: host,
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
daemonProcess.stdout?.on("data", (chunk) => {
|
||||
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
daemonProcess.stderr?.on("data", (chunk) => {
|
||||
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
async () =>
|
||||
Boolean(daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) &&
|
||||
(await canConnectToDaemon(host, 1000)),
|
||||
120000,
|
||||
"daemon did not become running in time",
|
||||
);
|
||||
|
||||
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
|
||||
const lockPid = await readPidLockPid(paseoHome);
|
||||
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
|
||||
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
|
||||
|
||||
console.log("Test 2: restart request should gracefully stop and exit code 0");
|
||||
const client = await tryConnectToDaemon({ host, timeout: 5000 });
|
||||
assert(client, "daemon client should connect");
|
||||
|
||||
const exitPromise = waitForProcessExit(daemonProcess, 30000);
|
||||
try {
|
||||
const restartAck = await client.restartServer("settings_update");
|
||||
assert.strictEqual(
|
||||
restartAck.status,
|
||||
"restart_requested",
|
||||
"restart request should be acknowledged",
|
||||
);
|
||||
} finally {
|
||||
await client?.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
const exit = await exitPromise;
|
||||
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
|
||||
assert.strictEqual(
|
||||
exit.code,
|
||||
0,
|
||||
`daemon should exit with status 0, got code=${exit.code}\nRecent daemon logs:\n${recentDaemonLogs}`,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => (await readPidLockPid(paseoHome)) === null,
|
||||
15000,
|
||||
"pid lock was not released after unsupervised restart request",
|
||||
);
|
||||
|
||||
console.log("✓ unsupervised restart exited cleanly with code 0\n");
|
||||
} finally {
|
||||
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
|
||||
daemonProcess.kill("SIGTERM");
|
||||
await waitFor(
|
||||
() => !isProcessRunning(daemonProcess!.pid ?? -1),
|
||||
5000,
|
||||
"daemon cleanup timed out",
|
||||
).catch(() => {
|
||||
daemonProcess?.kill("SIGKILL");
|
||||
});
|
||||
}
|
||||
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("=== Unsupervised restart regression test passed ===");
|
||||
Reference in New Issue
Block a user