mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 07:54:38 +00:00
* feat(auth): authenticate daemon clients through hello Give local CLI and desktop clients a per-run credential while remote clients send the host password inside the protocol. Keep relay passwordless admission for this mobile release so existing saved hosts keep working until the follow-up enforcement release. Move password recovery for saved hosts into host settings and keep pairing credentials in the add flow. Include isolated web QA evidence for both flows. * fix(auth): preserve Hub bootstrap and local credential expectations Hub-owned sockets already carry admission; keep their hello handling synchronous through session bootstrap while resolving credentials asynchronously for ordinary sockets. Keep the resolver out of lightweight configuration imports. Update CLI expectations for same-home credentials, let a rejected connection yield to another usable connection, and clear pairing secrets when add-flow targets change. * fix(pairing): retain passwords across edits to the same target Track the last valid pairing host while a URI is edited. This preserves an entered password for whitespace and partial edits but clears it when a different host becomes the target. * test(auth): align local credential checks across platforms Expect the CLI to authenticate through the credential in its selected home, and verify POSIX file modes only where Node exposes them. * feat(app): move host password into a settings row with a modal The host page rendered an always-visible password form above Name on every host. Replace it with a Password row under a Security section below Appearance; tapping it opens a modal with Save and, when a password is saved, Clear. The connection-error callout keeps its guidance and gains a Set password button that opens the same modal. Drop the unused pairing.hostPassword body/reopen/saveError strings. * chore: remove committed QA screenshots QA evidence stays out of the repository; the PR body describes it in words. * feat(app): keep the host password out of the host page The saved password is connection data, entered only when a host is added. A Password setting on the host page reads as the daemon's own password, so the row, modal, and callout button are gone. A rejected host shows the daemon's reason with guidance to remove the host and add it again with the password. useHostMutations no longer exposes setHostPassword; relay imports still set it inside the runtime. * feat(app): show host connection errors in an error alert HostConnectionError renders the existing Alert with the connection error as its title. Auth rejections add the re-add guidance as the description; other errors show the title only. The Overview and Connections pages share it. * style(app): soften alert borders to half-opacity accent * feat(app): size alerts on the button scale Alert takes a size prop (xs/sm/md/lg, default md) whose geometry lives next to the button's in control-geometry: one font size for title and description, the button icon size centered on the first text line, button padding and radius, and a one-line alert as tall as the button of the same size. Consumers pick sm among settings rows, cards, panels, and modals, and md when the alert is the whole page or sheet state. * style(app): roomier, rounder alerts with a tight title block Alert sm padding returns to the original 12/16 and every size is at least borderRadius.xl. Title and description sit a small gap apart with no line height override. The icon slot carries a zero-width text strut in the alert's font so the icon centers on the first line for titled, title-only, and description-only alerts. * refactor(app): align the alert icon with a centered lead row Drop the zero-width strut. The icon and the alert's first text (title, else description) share one row centered on each other; the description and actions sit below, indented by the icon slot width plus gap so they start at the lead text's left edge. * refactor(app): drop the unused Alert icon override
651 lines
25 KiB
TypeScript
651 lines
25 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile, stat } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
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";
|
|
|
|
const repo = fileURLToPath(new URL("../../../../..", import.meta.url));
|
|
const cli = path.join(repo, "packages/cli/dist/index.js");
|
|
|
|
async function port() {
|
|
const server = net.createServer();
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const value = (server.address() as net.AddressInfo).port;
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
if (value === 6767 || value === 6768) throw new Error("Unsafe test port");
|
|
return value;
|
|
}
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(path.join(tmpdir(), "paseo lifecycle "));
|
|
const env = {
|
|
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
|
|
HOME: root,
|
|
USERPROFILE: root,
|
|
};
|
|
const homes = [path.join(root, "a"), path.join(root, "b")];
|
|
const owned = new Map<string, { pid: number; startedAt: string }>();
|
|
async function run(args: string[], overrides: NodeJS.ProcessEnv = {}) {
|
|
const child = spawn(process.execPath, [cli, ...args], {
|
|
cwd: root,
|
|
env: { ...env, ...overrides },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stdout = "",
|
|
stderr = "";
|
|
child.stdout.on("data", (data) => {
|
|
stdout += data;
|
|
});
|
|
child.stderr.on("data", (data) => {
|
|
stderr += data;
|
|
});
|
|
const code = await new Promise<number | null>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGTERM");
|
|
reject(new Error(`CLI timed out: ${args.join(" ")}\n${stderr}`));
|
|
}, 45_000);
|
|
child.once("error", reject);
|
|
child.once("close", (exitCode) => {
|
|
clearTimeout(timer);
|
|
resolve(exitCode);
|
|
});
|
|
});
|
|
for (const home of homes) {
|
|
try {
|
|
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
|
owned.set(home, lock);
|
|
} catch {
|
|
/* No acquired launch. */
|
|
}
|
|
}
|
|
return { code, stdout, stderr, json: () => JSON.parse(stdout) };
|
|
}
|
|
async function ok(args: string[], overrides?: NodeJS.ProcessEnv) {
|
|
const result = await run(["--json", ...args], overrides);
|
|
expect(result.code, `${args.join(" ")}\n${result.stdout}\n${result.stderr}`).toBe(0);
|
|
return result.json();
|
|
}
|
|
async function liveStatus(home: string, overrides?: NodeJS.ProcessEnv) {
|
|
let status;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
status = await ok(["status", "--home", home], overrides);
|
|
return status;
|
|
},
|
|
{ timeout: 30_000 },
|
|
)
|
|
// A connected socket can outlive a status RPC timeout without reporting the worker.
|
|
.toMatchObject({ connectedDaemon: "reachable", workerPid: expect.any(Number) });
|
|
return status;
|
|
}
|
|
async function configure(home: string, listen: string) {
|
|
for (const [field, value] of [
|
|
["daemon.listen", listen],
|
|
["daemon.relay.enabled", "false"],
|
|
["features.webUi.enabled", "false"],
|
|
["features.dictation.enabled", "false"],
|
|
["features.voiceMode.enabled", "false"],
|
|
]) {
|
|
await ok(["daemon", "config", "set", field!, value!, "--home", home]);
|
|
}
|
|
}
|
|
async function close() {
|
|
for (const [home, captured] of owned) {
|
|
try {
|
|
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
|
if (lock.pid === captured.pid && lock.startedAt === captured.startedAt) {
|
|
await run(["daemon", "stop", "--home", home, "--force", "--timeout", "2"]);
|
|
}
|
|
} catch {
|
|
/* Already exited. */
|
|
}
|
|
}
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
return { root, homes, env, run, ok, liveStatus, configure, close };
|
|
}
|
|
|
|
test("managed two-home restart retains its supervisor and never routes ordinary commands or stop to the other home", async () => {
|
|
const f = await fixture();
|
|
const [a, b] = f.homes as [string, string];
|
|
const portA = await port(),
|
|
portB = await port(),
|
|
movedPort = await port();
|
|
try {
|
|
await f.configure(a, `127.0.0.1:${portA}`);
|
|
await f.configure(b, `127.0.0.1:${portB}`);
|
|
const launchA = await f.ok(["start", "--home", a, "--timeout", "30"]);
|
|
const poisoned = {
|
|
PASEO_HOME: a,
|
|
PASEO_HOST: `127.0.0.1:${portA}`,
|
|
PASEO_LISTEN: `127.0.0.1:${portA}`,
|
|
PORT: String(portA),
|
|
PASEO_WEB_UI_ENABLED: "true",
|
|
PASEO_RELAY_ENABLED: "true",
|
|
};
|
|
const launchB = await f.ok(["daemon", "start", "--home", b, "--timeout", "30"], poisoned);
|
|
expect(launchA.listen).toBe(`127.0.0.1:${portA}`);
|
|
expect(launchB.listen).toBe(`127.0.0.1:${portB}`);
|
|
const beforeA = await f.liveStatus(a);
|
|
const beforeB = await f.liveStatus(b, poisoned);
|
|
if (process.platform !== "win32") {
|
|
for (const home of [a, b]) expect((await stat(home)).mode & 0o777).toBe(0o700);
|
|
expect(existsSync(path.join(f.root, ".paseo"))).toBe(false);
|
|
}
|
|
|
|
const repoB = path.join(f.root, "project-b");
|
|
await mkdir(repoB);
|
|
await f.ok(["project", "create", repoB, "--home", b], poisoned);
|
|
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
|
|
expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1);
|
|
const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned);
|
|
expect(stream.stderr).toContain("Agent not found: missing-agent");
|
|
const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned);
|
|
expect(restart.supervisorPid).toBe(launchB.pid);
|
|
expect(restart.workerPid).not.toBe(beforeB.workerPid);
|
|
const saved = await f.ok(
|
|
["daemon", "config", "set", "daemon.listen", `127.0.0.1:${movedPort}`, "--home", b],
|
|
poisoned,
|
|
);
|
|
expect(saved.restartRequiredPaths).toContain("daemon.listen");
|
|
await f.ok(["daemon", "restart", "--home", b, "--timeout", "30"], poisoned);
|
|
const moved = await f.liveStatus(b);
|
|
expect(moved.listen).toBe(`127.0.0.1:${movedPort}`);
|
|
expect(moved.pid).toBe(launchB.pid);
|
|
await f.ok(["daemon", "stop", "--home", b], poisoned);
|
|
const stoppedQuery = await f.run(["--json", "project", "ls", "--home", b], poisoned);
|
|
expect(stoppedQuery.code).toBe(1);
|
|
expect(stoppedQuery.stderr).toContain("DAEMON_NOT_RUNNING");
|
|
const afterA = await f.liveStatus(a);
|
|
expect(afterA.workerPid).toBe(beforeA.workerPid);
|
|
expect(afterA.pid).toBe(beforeA.pid);
|
|
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
test("removed flags and ambiguous targets fail before side effects; observation does not create a home", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
try {
|
|
await f.ok(["status", "--home", home]);
|
|
await f.ok(["daemon", "config", "get", "--home", home]);
|
|
expect(existsSync(home)).toBe(false);
|
|
for (const args of [
|
|
["start", "--port", "12345"],
|
|
["start", "--foreground"],
|
|
["daemon", "restart", "--no-relay"],
|
|
]) {
|
|
const result = await f.run(["--json", ...args, "--home", home]);
|
|
expect(result.code).toBe(1);
|
|
expect(result.stderr).toContain("REMOVED_LAUNCH_OPTION");
|
|
expect(existsSync(home)).toBe(false);
|
|
}
|
|
const ambiguous = await f.run(["--home", home, "project", "ls", "--host", "unused:12345"]);
|
|
expect(ambiguous.stderr).toContain("Choose either --home or --host");
|
|
expect(existsSync(home)).toBe(false);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 30_000);
|
|
|
|
test("local status uses the credential and reports the server id of a password-protected daemon", 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: "reachable",
|
|
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];
|
|
const portA = await port(),
|
|
portB = await port();
|
|
try {
|
|
await f.configure(a, `127.0.0.1:${portA}`);
|
|
await f.configure(b, `127.0.0.1:${portA}`);
|
|
await f.ok(["start", "--home", a, "--timeout", "30"]);
|
|
const before = await f.liveStatus(a);
|
|
const failed = await f.run(["--json", "start", "--home", b, "--timeout", "30"]);
|
|
expect(failed.code).toBe(1);
|
|
expect(failed.stderr).toContain("DAEMON_START_FAILED");
|
|
await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portB}`, "--home", b]);
|
|
await f.ok(["start", "--home", b, "--timeout", "30"]);
|
|
await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portA}`, "--home", b]);
|
|
const restart = await f.run(["--json", "restart", "--home", b, "--timeout", "30"]);
|
|
expect(restart.code).toBe(1);
|
|
expect(restart.stderr).toContain("RESTART_NOT_CONFIRMED");
|
|
await f.ok(["daemon", "stop", "--home", b]);
|
|
expect((await f.liveStatus(a)).workerPid).toBe(before.workerPid);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 120_000);
|
|
|
|
test("a slow worker outlives the caller's readiness deadline and remains stoppable", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${await port()}`);
|
|
const preload = path.join(f.root, "slow-worker.mjs");
|
|
await writeFile(
|
|
preload,
|
|
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 5000));',
|
|
);
|
|
const launch = await f.run(["--json", "start", "--home", home, "--timeout", "1"], {
|
|
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
|
|
});
|
|
expect(launch.code).toBe(1);
|
|
expect(launch.stderr).toContain("DAEMON_NOT_READY");
|
|
expect(launch.stderr).toContain("remains running");
|
|
expect(launch.stderr).toContain("Logs:");
|
|
expect(launch.stderr).toContain("Status:");
|
|
expect(launch.stderr).toContain("Stop:");
|
|
const pending = await f.ok(["status", "--home", home]);
|
|
expect(pending.localDaemon).toBe("not_ready");
|
|
expect(pending.listen).toBeNull();
|
|
process.kill(pending.pid, 0);
|
|
const already = await f.ok(["start", "--home", home]);
|
|
expect(already.action).toBe("already_running");
|
|
expect(already.pid).toBe(pending.pid);
|
|
await f.ok(["project", "ls", "--home", home]);
|
|
expect((await f.ok(["status", "--home", home])).pid).toBe(pending.pid);
|
|
await f.ok(["daemon", "stop", "--home", home]);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|
|
|
|
test("worker restart preserves an already-running legacy supervisor's launch flags", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
const filePort = await port(),
|
|
launchPort = await port();
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${filePort}`);
|
|
const launch = await startDaemonInstance({
|
|
home,
|
|
command: process.execPath,
|
|
args: [
|
|
path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js"),
|
|
"--no-relay",
|
|
"--no-web-ui",
|
|
],
|
|
// The legacy CLI translated --port into PASEO_LISTEN before spawning.
|
|
env: { ...f.env, PASEO_LISTEN: `127.0.0.1:${launchPort}` },
|
|
mode: "deployment",
|
|
timeoutMs: 30_000,
|
|
});
|
|
const before = await f.liveStatus(home);
|
|
expect(before.listen).toBe(`127.0.0.1:${launchPort}`);
|
|
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"], {
|
|
PASEO_LISTEN: `127.0.0.1:${filePort}`,
|
|
});
|
|
expect(restarted.supervisorPid).toBe(launch.instance.pid);
|
|
expect(restarted.workerPid).not.toBe(before.workerPid);
|
|
expect((await f.ok(["status", "--home", home])).listen).toBe(before.listen);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|
|
|
|
test.skipIf(process.platform === "win32")(
|
|
"POSIX stop ignores a wrong endpoint even with copied identities; supported live identity overrides still restart",
|
|
async () => {
|
|
const f = await fixture();
|
|
const [a, b] = f.homes as [string, string];
|
|
try {
|
|
await f.configure(a, `127.0.0.1:${await port()}`);
|
|
await f.configure(b, `127.0.0.1:${await port()}`);
|
|
await writeFile(path.join(b, "server-id"), "saved-b");
|
|
await f.ok(["start", "--home", a, "--timeout", "30"]);
|
|
await f.ok(["start", "--home", b, "--timeout", "30"], { PASEO_SERVER_ID: "live-b" });
|
|
const beforeA = await f.liveStatus(a);
|
|
expect((await f.liveStatus(b)).serverId).toBe("live-b");
|
|
await f.ok(["restart", "--home", b, "--timeout", "30"]);
|
|
const bLock = JSON.parse(await readFile(path.join(b, "paseo.pid"), "utf8"));
|
|
await writeFile(
|
|
path.join(b, "paseo.pid"),
|
|
JSON.stringify({ ...bLock, listen: beforeA.listen }),
|
|
);
|
|
await writeFile(path.join(b, "server-id"), beforeA.serverId);
|
|
await f.ok(["daemon", "stop", "--home", b]);
|
|
expect((await f.liveStatus(a)).workerPid).toBe(beforeA.workerPid);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
|
|
"explicit cancellation before readiness cleans up only this launch: %j",
|
|
async (...launchArgs) => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
let child: ReturnType<typeof spawn> | undefined;
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${await port()}`);
|
|
const preload = path.join(f.root, "slow-worker.mjs");
|
|
await writeFile(
|
|
preload,
|
|
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
|
|
);
|
|
child = spawn(process.execPath, [cli, ...launchArgs, "--home", home], {
|
|
cwd: repo,
|
|
env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` },
|
|
stdio: "ignore",
|
|
});
|
|
const exited = new Promise((resolve) => child!.once("exit", resolve));
|
|
await expect
|
|
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
|
|
.toBe(true);
|
|
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
|
await f.ok(["status", "--home", home]);
|
|
child.kill("SIGINT");
|
|
await exited;
|
|
expect(() => process.kill(lock.pid, 0)).toThrow();
|
|
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
|
|
} finally {
|
|
child?.kill("SIGTERM");
|
|
await f.close();
|
|
}
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
test.runIf(process.platform === "win32")(
|
|
"Windows unbound stop requires explicit force",
|
|
async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${await port()}`);
|
|
const preload = path.join(f.root, "slow-worker.mjs");
|
|
await writeFile(
|
|
preload,
|
|
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
|
|
);
|
|
const pending = await f.run(["--json", "start", "--home", home, "--timeout", "1"], {
|
|
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
|
|
});
|
|
expect(pending.stderr).toContain("DAEMON_NOT_READY");
|
|
const refused = await f.run(["--json", "daemon", "stop", "--home", home]);
|
|
expect(refused.code).toBe(1);
|
|
expect(refused.stderr).toContain("STOP_NO_GRACEFUL_CHANNEL");
|
|
const stopped = await f.ok(["daemon", "stop", "--home", home, "--force"]);
|
|
expect(stopped.forced).toBe(true);
|
|
expect(stopped.usedLifecycleRpc).toBe(false);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
test("empty explicit selectors never select the ambient daemon or create local state", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${await port()}`);
|
|
await f.ok(["start", "--home", home]);
|
|
const before = await f.liveStatus(home);
|
|
for (const args of [
|
|
["daemon", "stop", "--home", ""],
|
|
["daemon", "stop", "--host", ""],
|
|
["start", "--home", ""],
|
|
["daemon", "config", "set", "daemon.relay.enabled", "true", "--home", ""],
|
|
]) {
|
|
const refused = await f.run(["--json", ...args], { PASEO_HOME: home });
|
|
expect(refused.code).toBe(1);
|
|
expect(refused.stderr).toContain("TARGET_INVALID");
|
|
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
|
|
expect(existsSync(path.join(f.root, "paseo.pid"))).toBe(false);
|
|
expect(existsSync(path.join(f.root, "config.json"))).toBe(false);
|
|
}
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|
|
|
|
test("IPv6 publication supports home-selected query and worker restart", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
const endpoint = `[::1]:${await port()}`;
|
|
try {
|
|
await f.configure(home, endpoint);
|
|
const launch = await f.ok(["start", "--home", home, "--timeout", "30"]);
|
|
expect(launch.listen).toBe(endpoint);
|
|
expect(await f.ok(["project", "ls", "--home", home])).toEqual([]);
|
|
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]);
|
|
expect(restarted.supervisorPid).toBe(launch.pid);
|
|
expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|
|
|
|
test("raw log following subscribes to a stored agent that exists only in B", async () => {
|
|
const f = await fixture();
|
|
const [a, b] = f.homes as [string, string];
|
|
let follower: ReturnType<typeof spawn> | undefined;
|
|
try {
|
|
await f.configure(a, `127.0.0.1:${await port()}`);
|
|
await f.configure(b, `127.0.0.1:${await port()}`);
|
|
const agentId = "4de8b157-7614-4c38-b189-e50e57fb9498";
|
|
const now = new Date().toISOString();
|
|
await mkdir(path.join(b, "agents"));
|
|
await writeFile(
|
|
path.join(b, "agents", `${agentId}.json`),
|
|
JSON.stringify({
|
|
id: agentId,
|
|
provider: "codex",
|
|
cwd: f.root,
|
|
title: "B-only stored agent",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastStatus: "closed",
|
|
persistence: null,
|
|
}),
|
|
);
|
|
const launchA = await f.ok(["start", "--home", a]);
|
|
await f.ok(["start", "--home", b]);
|
|
const absent = await f.run(["logs", agentId, "--home", a]);
|
|
expect(absent.stderr).toContain("Agent not found");
|
|
follower = spawn(
|
|
process.execPath,
|
|
[cli, "logs", agentId, "--follow", "--tail", "0", "--home", b],
|
|
{
|
|
cwd: f.root,
|
|
env: { ...f.env, PASEO_HOME: a, PASEO_HOST: launchA.listen },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
let output = "",
|
|
errors = "";
|
|
follower.stdout!.on("data", (data) => {
|
|
output += data;
|
|
});
|
|
follower.stderr!.on("data", (data) => {
|
|
errors += data;
|
|
});
|
|
await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;");
|
|
expect(errors).not.toContain("Agent not found");
|
|
} finally {
|
|
if (follower && follower.exitCode === null) {
|
|
const exited = new Promise((resolve) => follower!.once("exit", resolve));
|
|
follower.kill("SIGINT");
|
|
await exited;
|
|
}
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|
|
|
|
test("a connection pinned to an earlier supervisor refuses its replacement", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${await port()}`);
|
|
await f.ok(["start", "--home", home]);
|
|
const captured = (await readDaemonInstance(home))!;
|
|
await f.ok(["daemon", "stop", "--home", home]);
|
|
await f.ok(["start", "--home", home]);
|
|
const before = await f.liveStatus(home);
|
|
await expect(
|
|
connectToDaemon({ target: { kind: "instance", home }, instance: captured, timeout: 1000 }),
|
|
).rejects.toMatchObject({ code: "DAEMON_REPLACED" });
|
|
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|
|
|
|
test("foreground deployment retains environment until its owner ends the launch; endpoint stop reports acknowledgment", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
const fileEndpoint = `127.0.0.1:${await port()}`,
|
|
deploymentEndpoint = `127.0.0.1:${await port()}`,
|
|
changedEndpoint = `127.0.0.1:${await port()}`;
|
|
let deployment: ReturnType<typeof spawn> | undefined;
|
|
try {
|
|
await f.configure(home, fileEndpoint);
|
|
deployment = spawn(process.execPath, [cli, "daemon", "run", "--home", home], {
|
|
cwd: f.root,
|
|
env: { ...f.env, PASEO_LISTEN: deploymentEndpoint, PASEO_RELAY_ENABLED: "false" },
|
|
stdio: "ignore",
|
|
});
|
|
await expect
|
|
.poll(async () => (await readDaemonInstance(home))?.listen, { timeout: 30_000 })
|
|
.toBe(deploymentEndpoint);
|
|
const before = await f.liveStatus(home);
|
|
expect(before.configuredListen).toBe(fileEndpoint);
|
|
expect(await f.ok(["daemon", "run", "--home", home])).toMatchObject({
|
|
action: "already_running",
|
|
pid: before.pid,
|
|
});
|
|
expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid);
|
|
const changed = await f.ok([
|
|
"daemon",
|
|
"config",
|
|
"set",
|
|
"daemon.listen",
|
|
changedEndpoint,
|
|
"--home",
|
|
home,
|
|
]);
|
|
expect(changed.overrideControlledPaths).toContain("daemon.listen");
|
|
const restarted = await f.ok(["restart", "--home", home], { PASEO_LISTEN: fileEndpoint });
|
|
expect(restarted.supervisorPid).toBe(before.pid);
|
|
expect((await f.ok(["status", "--home", home])).listen).toBe(deploymentEndpoint);
|
|
const exited = new Promise((resolve) => deployment!.once("exit", resolve));
|
|
const remote = await f.ok(["daemon", "stop", "--host", deploymentEndpoint]);
|
|
expect(remote.action).toBe("shutdown_requested");
|
|
await exited;
|
|
const managed = await f.ok(["start", "--home", home]);
|
|
expect(managed.listen).toBe(changedEndpoint);
|
|
expect(managed.pid).not.toBe(before.pid);
|
|
} finally {
|
|
if (deployment?.exitCode === null) deployment.kill("SIGTERM");
|
|
await f.close();
|
|
}
|
|
}, 90_000);
|
|
|
|
test("malformed configuration cannot turn a readiness timeout into launch cancellation", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
let starter: ReturnType<typeof spawn> | undefined;
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${await port()}`);
|
|
const configPath = path.join(home, "config.json");
|
|
const saved = await readFile(configPath, "utf8");
|
|
const preload = path.join(f.root, "slow-worker.mjs");
|
|
await writeFile(
|
|
preload,
|
|
'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));',
|
|
);
|
|
starter = spawn(process.execPath, [cli, "--json", "start", "--home", home, "--timeout", "2"], {
|
|
cwd: f.root,
|
|
env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let errors = "";
|
|
starter.stderr!.on("data", (data) => {
|
|
errors += data;
|
|
});
|
|
const exited = new Promise((resolve) => starter!.once("exit", resolve));
|
|
await expect
|
|
.poll(async () => (await readDaemonInstance(home))?.pid, { timeout: 10_000 })
|
|
.toBeTruthy();
|
|
const captured = (await readDaemonInstance(home))!;
|
|
await f.ok(["status", "--home", home]);
|
|
await writeFile(configPath, "{");
|
|
await exited;
|
|
expect(errors).toContain("DAEMON_NOT_READY");
|
|
expect(errors).toContain(path.join(home, "daemon.log"));
|
|
process.kill(captured.pid, 0);
|
|
await writeFile(configPath, saved);
|
|
await f.ok(["status", "--home", home]);
|
|
} finally {
|
|
if (starter?.exitCode === null) starter.kill("SIGTERM");
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|
|
|
|
test("a background start that fails before the supervisor logs names the cause in a log that exists", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
const configPath = path.join(home, "config.json");
|
|
try {
|
|
await mkdir(home, { recursive: true });
|
|
await writeFile(configPath, '{"version":1,');
|
|
const failed = await f.run(["daemon", "start", "--home", home, "--timeout", "30"]);
|
|
expect(failed.code).toBe(1);
|
|
expect(failed.stderr).toContain(`Logs: ${path.join(home, "daemon.log")}`);
|
|
expect(failed.stderr).toContain(`Invalid JSON in ${configPath}`);
|
|
expect(await readFile(path.join(home, "daemon.log"), "utf8")).toContain(
|
|
`Invalid JSON in ${configPath}`,
|
|
);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|
|
|
|
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${await port()}`);
|
|
await f.ok(["daemon", "config", "set", "daemon.mcp.enabled", "false", "--home", home]);
|
|
for (const flags of [[], ["--no-mcp"]]) {
|
|
const onboard = await f.run(["onboard", "--home", home, "--voice", "disable", ...flags]);
|
|
expect(onboard.code, onboard.stderr).toBe(0);
|
|
expect(
|
|
(await f.ok(["daemon", "config", "get", "daemon.mcp.enabled", "--home", home])).value,
|
|
).toBe(false);
|
|
}
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
}, 60_000);
|