mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
* feat(plugins): enable registry installs by default * fix(plugins): explain explicit Git source for missing registry IDs * fix(plugins): tailor missing registry guidance to installation * test(app): sample footer geometry in one browser frame * fix(plugins): make registry source resolution deterministic Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures. * fix(plugins): recognize dot and Windows directory sources * test(cli): wait for readable daemon ownership before cancellation The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup. * test(app): expect missing directory install guidance
689 lines
27 KiB
TypeScript
689 lines
27 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("No agent found matching: 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));
|
|
// The lock file exists before its contents identify the supervisor.
|
|
let supervisorPid: number | undefined;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
supervisorPid = (await readDaemonInstance(home))?.pid;
|
|
return supervisorPid;
|
|
},
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toBeTypeOf("number");
|
|
await f.ok(["status", "--home", home]);
|
|
child.kill("SIGINT");
|
|
await exited;
|
|
expect(() => process.kill(supervisorPid!, 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.skipIf(process.platform === "win32")(
|
|
"restart confirms the replacement worker when a provider probe is slow",
|
|
async () => {
|
|
const f = await fixture();
|
|
const home = f.homes[0]!;
|
|
try {
|
|
await f.configure(home, `127.0.0.1:${await port()}`);
|
|
const provider = path.join(f.root, "slow-provider");
|
|
await writeFile(
|
|
provider,
|
|
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), 1800);\n`,
|
|
{ mode: 0o700 },
|
|
);
|
|
const configPath = path.join(home, "config.json");
|
|
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
config.agents = {
|
|
...config.agents,
|
|
providers: { claude: { command: { mode: "replace", argv: [provider] } } },
|
|
};
|
|
await writeFile(configPath, JSON.stringify(config));
|
|
const launch = await f.ok(["start", "--home", home, "--timeout", "30"]);
|
|
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("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(`No agent found matching: ${agentId}`);
|
|
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("No agent found matching");
|
|
} 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);
|