diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df4b86f9f1..9a2e293848 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,12 +201,35 @@ jobs: - name: Install virtual display if: ${{ runner.os == 'Linux' }} - run: sudo apt-get update && sudo apt-get install -y xvfb xauth + run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool - name: Install Playwright browser if: ${{ runner.os == 'Linux' }} run: npx playwright install chromium + - name: Verify Windows daemon process control + if: ${{ runner.os == 'Windows' }} + run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home" + + - name: Run desktop lifecycle E2E (Linux) + if: ${{ runner.os == 'Linux' }} + run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop + env: + PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle + + - name: Upload desktop lifecycle screenshots + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() && runner.os == 'Linux' }} + with: + name: desktop-lifecycle + path: ${{ runner.temp }}/desktop-lifecycle + if-no-files-found: ignore + retention-days: 7 + + - name: Run desktop lifecycle E2E (Windows) + if: ${{ runner.os == 'Windows' }} + run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop + - name: Run desktop renderer E2E if: ${{ runner.os == 'Linux' }} run: npm run test:e2e:renderer --workspace=@getpaseo/desktop diff --git a/docs/architecture.md b/docs/architecture.md index f305407903..96d8cdcc56 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,7 +145,7 @@ traffic. Workspace assignments stay on the workspace directory sequence. Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`). - `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode` -- `paseo daemon start/stop/restart/status/pair/set-password` +- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password` - `paseo terminal ls/create/capture/send-keys/kill` - `paseo script ls/start/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` @@ -429,12 +429,16 @@ $PASEO_HOME/ ├── config.json # Daemon config (mutable) ├── daemon-keypair.json # Daemon identity for relay/E2EE ├── push-tokens.json # Mobile push tokens -├── paseo.sock / paseo.pid # Local IPC socket and pidfile +├── paseo.pid # Supervisor identity and published bound endpoint └── daemon.log # Daemon trace logs (rotated) ``` +The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock. + +Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management). + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` -2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`). +2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform. 3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption diff --git a/docs/development.md b/docs/development.md index 2d8d86aa79..caba38adc9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -444,16 +444,17 @@ so the port may already be bound. `portScript` takes precedence when both values The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default. -Enable it for a running daemon with: +Enable it in persistent configuration: ```bash -paseo daemon start --web-ui +paseo daemon config set features.webUi.enabled true +paseo daemon start ``` Or set the environment variable: ```bash -PASEO_WEB_UI_ENABLED=true paseo daemon start +PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Or persist it in `config.json`: @@ -564,7 +565,7 @@ npm run cli -- --host ssh://user@host ls -a ``` Set `PASEO_HOST` to use the same target across invocations. An explicit -`--host` overrides the environment variable. +selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon). In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh). diff --git a/docs/docker.md b/docs/docker.md index 15e22081bb..058dee929a 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -22,6 +22,8 @@ the web UI. The served app receives a same-origin connection hint and connects back to that daemon. Static UI files load without daemon auth; API and WebSocket requests still require `PASEO_PASSWORD` when one is configured. +Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement. + ## Quick Start ```bash diff --git a/packages/app/e2e/browser/daemon-lifecycle.spec.ts b/packages/app/e2e/browser/daemon-lifecycle.spec.ts new file mode 100644 index 0000000000..440b22d3e0 --- /dev/null +++ b/packages/app/e2e/browser/daemon-lifecycle.spec.ts @@ -0,0 +1,27 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "../support/fixtures"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { + openDaemonOverview, + restartDaemonInSettings, + expectDaemonRestartComplete, + readWorkerPid, +} from "../support/helpers/daemon-lifecycle"; + +test("settings restart waits for a replacement worker while retaining the supervisor", async ({ + page, +}, testInfo) => { + const daemon = await startIsolatedHostDaemon(randomUUID()); + try { + const supervisor = daemon.getPid(); + const previousWorker = await readWorkerPid(daemon); + await openDaemonOverview(page, daemon); + await restartDaemonInSettings(page); + await expectDaemonRestartComplete(page); + expect(await readWorkerPid(daemon)).not.toBe(previousWorker); + expect(daemon.getPid()).toBe(supervisor); + await page.screenshot({ path: testInfo.outputPath("restart-confirmed.png"), fullPage: true }); + } finally { + await daemon.close(); + } +}); diff --git a/packages/app/e2e/support/global-setup.ts b/packages/app/e2e/support/global-setup.ts index a7e25fb50b..a2511b6190 100644 --- a/packages/app/e2e/support/global-setup.ts +++ b/packages/app/e2e/support/global-setup.ts @@ -16,7 +16,8 @@ export interface WaitForServerOptions { type ServerProbe = (host: string, port: number) => Promise; const RESERVED_LOCAL_PORTS = new Set([ - 6767, // Developer daemon. + 6767, // Installed daemon. + 6768, // Developer daemon. 61680, // OpenCode's default local server. ]); diff --git a/packages/app/e2e/support/helpers/daemon-lifecycle.ts b/packages/app/e2e/support/helpers/daemon-lifecycle.ts new file mode 100644 index 0000000000..6779039f1f --- /dev/null +++ b/packages/app/e2e/support/helpers/daemon-lifecycle.ts @@ -0,0 +1,42 @@ +import { expect, type Page } from "@playwright/test"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { openSettings } from "./app"; +import { openHostSection, openSettingsHost, seedSavedSettingsHosts } from "./settings"; +import type { IsolatedHostDaemon } from "./isolated-host-daemon"; + +export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon) { + await seedSavedSettingsHosts(page, [ + { serverId: daemon.serverId, label: "Lifecycle host", endpoint: `127.0.0.1:${daemon.port}` }, + ]); + await page.reload(); + await openSettings(page); + await openSettingsHost(page, daemon.serverId); + await openHostSection(page, daemon.serverId, "host"); +} + +export async function restartDaemonInSettings(page: Page) { + const button = page.getByRole("button", { name: "Restart", exact: true }); + await expect(button).toBeEnabled(); + page.once("dialog", (dialog) => dialog.accept()); + await button.click(); + await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled(); +} + +export async function expectDaemonRestartComplete(page: Page) { + await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({ + timeout: 30_000, + }); +} + +export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise { + const client = await connectDaemonClient({ + port: daemon.port, + clientIdPrefix: "lifecycle-observer", + }); + try { + return (await client.getDaemonStatus()).pid; + } finally { + await client.close(); + } +} diff --git a/packages/app/e2e/support/helpers/isolated-host-daemon.ts b/packages/app/e2e/support/helpers/isolated-host-daemon.ts index da22a9ce16..1467f3d572 100644 --- a/packages/app/e2e/support/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/support/helpers/isolated-host-daemon.ts @@ -83,7 +83,7 @@ export async function startIsolatedHostDaemon( ): Promise { const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0); let port = await getAvailablePort(); - while (port === 6767 || port === primaryPort) port = await getAvailablePort(); + while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort(); const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon"); diff --git a/packages/app/e2e/support/helpers/packaged-web-daemon.ts b/packages/app/e2e/support/helpers/packaged-web-daemon.ts index 33182df0d0..cd622ff27a 100644 --- a/packages/app/e2e/support/helpers/packaged-web-daemon.ts +++ b/packages/app/e2e/support/helpers/packaged-web-daemon.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import net from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -59,23 +59,37 @@ export async function startPackagedWebDaemon(input: { const serverId = `relay-deployment-${Date.now().toString(36)}`; const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo"); const env: NodeJS.ProcessEnv = { - ...process.env, + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + HOME: home, + USERPROFILE: home, CI: "true", NODE_ENV: "development", PASEO_NODE_ENV: "development", PASEO_SERVER_ID: serverId, - PASEO_RELAY_ENDPOINT: input.relayEndpoint, - PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint, - PASEO_RELAY_USE_TLS: "false", - PASEO_RELAY_PUBLIC_USE_TLS: "false", }; try { - await execFileAsync( - paseo, - ["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"], - { env }, + await writeFile( + path.join(home, "config.json"), + JSON.stringify({ + daemon: { + listen: `127.0.0.1:${port}`, + relay: { + enabled: true, + endpoint: input.relayEndpoint, + publicEndpoint: input.relayEndpoint, + useTls: false, + publicUseTls: false, + }, + }, + features: { + webUi: { enabled: true }, + dictation: { enabled: false }, + voiceMode: { enabled: false }, + }, + }), ); + await execFileAsync(paseo, ["daemon", "start", "--home", home], { env }); const origin = `http://127.0.0.1:${port}`; await waitForWebUi(origin, home); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 1dec26d5dd..bb5c1536a6 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -12,7 +12,11 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; -import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { + getCliDaemonStatus, + shouldUseDesktopDaemon, + confirmAndStopDesktopDaemon, +} from "@/desktop/daemon/desktop-daemon"; import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings"; @@ -330,6 +334,17 @@ export function LocalDaemonSection() { const daemonStatus = data?.status ?? null; const daemonLogs = data?.logs ?? null; const daemonVersion = daemonStatus?.version ?? null; + const [isStopping, setIsStopping] = useState(false); + const handleStop = useCallback(() => { + setIsStopping(true); + void confirmAndStopDesktopDaemon() + .then((status) => { + if (status) setStatus(status); + return undefined; + }) + .catch((error) => Alert.alert(t("desktop.daemon.lifecycle.stopFailed"), String(error))) + .finally(() => setIsStopping(false)); + }, [setStatus, t]); const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonStatusStateText = @@ -445,6 +460,26 @@ export function LocalDaemonSection() { isLoadingCliStatus={isLoadingCliStatus} /> + {daemonStatus?.pid ? ( + + + + + {daemonStatus.ownedByDesktop + ? t("desktop.daemon.lifecycle.owned") + : t("desktop.daemon.lifecycle.attached")} + + {daemonStatus.home} + + + + + ) : null} + {daemonVersionMismatch ? ( {t("desktop.daemon.versionMismatch")} diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts index c63f4e13de..831e40f25f 100644 --- a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts @@ -11,10 +11,16 @@ const runningManagedStatus: DesktopDaemonStatus = { home: "/tmp/paseo", version: "1.0.0", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, }; -const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" }; +const stoppedStatus: DesktopDaemonStatus = { + ...runningManagedStatus, + status: "stopped", + ownedByDesktop: false, +}; function makeDeps(overrides?: { confirm?: () => Promise; @@ -128,9 +134,9 @@ describe("executeDaemonManagementToggle", () => { expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus }); }); - it("skips stop when daemon is running but not desktop-managed", async () => { + it("skips stop for an attached daemon even with a legacy desktop-managed flag", async () => { const { deps, calls } = makeDeps(); - const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false }; + const manuallyManagedStatus = { ...runningManagedStatus, ownedByDesktop: false }; const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps); diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.ts index efec0ad20c..ea08cbfcdb 100644 --- a/packages/app/src/desktop/daemon/daemon-management-toggle.ts +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.ts @@ -14,7 +14,7 @@ export type DaemonManagementToggleResult = export async function executeDaemonManagementToggle( currentlyManaging: boolean, - daemonStatus: Pick | null, + daemonStatus: Pick | null, deps: DaemonManagementToggleDeps, ): Promise { if (!currentlyManaging) { @@ -32,7 +32,7 @@ export async function executeDaemonManagementToggle( // state reflects what was actually applied if the stop fails. await deps.persistSettings({ manageBuiltInDaemon: false }); - if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + if (daemonStatus?.ownedByDesktop && ["running", "starting"].includes(daemonStatus.status)) { const newStatus = await deps.stopDaemon(); return { kind: "disabled", newStatus }; } diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index f465c32ac1..cc7574dc21 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -1,3 +1,5 @@ +import { i18n } from "@/i18n/i18next"; +import { confirmDialog } from "@/utils/confirm-dialog"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; @@ -21,6 +23,8 @@ export interface DesktopDaemonStatus { home: string; version: string | null; desktopManaged: boolean; + ownedByDesktop: boolean; + startedAt: string | null; error: string | null; } @@ -128,6 +132,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus { home: toStringOrNull(raw.home) ?? "", version: toStringOrNull(raw.version), desktopManaged: raw.desktopManaged === true, + ownedByDesktop: raw.ownedByDesktop === true, + startedAt: typeof raw.startedAt === "string" ? raw.startedAt : null, error: toStringOrNull(raw.error), }; } @@ -190,6 +196,33 @@ export async function stopDesktopDaemon( return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason })); } +export async function confirmAndStopDesktopDaemon(): Promise { + const captured = await getDesktopDaemonStatus(); + if (!captured.pid || !captured.startedAt) return captured; + const ownership = captured.ownedByDesktop + ? i18n.t("desktop.daemon.lifecycle.ownedMessage") + : i18n.t("desktop.daemon.lifecycle.attachedMessage"); + const confirmed = await confirmDialog({ + title: i18n.t("desktop.daemon.lifecycle.stopTitle"), + message: i18n.t("desktop.daemon.lifecycle.stopMessage", { + ownership, + home: captured.home, + pid: captured.pid, + }), + confirmLabel: i18n.t("desktop.daemon.lifecycle.stop"), + cancelLabel: i18n.t("common.actions.cancel"), + destructive: true, + }); + if (!confirmed) return null; + return parseDesktopDaemonStatus( + await invokeDesktopCommand("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }), + ); +} + export async function restartDesktopDaemon(): Promise { return parseDesktopDaemonStatus(await invokeDesktopCommand("restart_desktop_daemon")); } diff --git a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts index 16076b4d6d..71bbce378c 100644 --- a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts +++ b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts @@ -58,8 +58,16 @@ export function useBuiltInDaemonManagement( confirm: () => confirmDialog({ title: t("desktop.daemon.management.pauseTitle"), - message: t("desktop.daemon.management.pauseMessage"), - confirmLabel: t("desktop.daemon.management.pauseAndStop"), + message: t( + daemonStatus?.ownedByDesktop + ? "desktop.daemon.management.pauseMessage" + : "desktop.daemon.lifecycle.pauseAttached", + ), + confirmLabel: t( + daemonStatus?.ownedByDesktop + ? "desktop.daemon.management.pauseAndStop" + : "desktop.daemon.lifecycle.pause", + ), cancelLabel: t("common.actions.cancel"), destructive: true, }), diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts index 1ffa37b950..34c4482a4c 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts @@ -15,6 +15,8 @@ function makeSources(): DesktopDiagnosticSources { home: "/paseo/home", version: "1.2.3", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, }), getDaemonLogs: async () => ({ diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 30adccccd7..728374a242 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1274,6 +1274,7 @@ export const ar: TranslationResources = { detail: "إيقاف البرنامج الخفي المحلي.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "حالة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e738f13e82..3046971400 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1284,6 +1284,23 @@ export const en = { }, daemon: { title: "Daemon", + lifecycle: { + owned: "Launched by this Desktop session", + attached: "Attached to an existing daemon", + ownedMessage: "This daemon was launched by this Desktop session.", + attachedMessage: "This daemon was not launched by this Desktop session.", + stopTitle: "Stop local daemon?", + stopMessage: + "{{ownership}}\nHome: {{home}}\nSupervisor PID: {{pid}}\nRunning agent work will be interrupted.", + stop: "Stop daemon", + stopping: "Stopping…", + stopFailed: "Unable to stop daemon", + pauseAttached: "Pause automatic daemon management? The attached daemon will keep running.", + pause: "Pause management", + workerUpdated: "Worker updated to {{version}}", + supervisorRefresh: + "The running supervisor retains its original code. Its launcher must stop and start it to refresh the supervisor.", + }, status: { title: "Status", builtInOnly: "Only the built-in desktop daemon is shown here", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2c4abbc907..0eb0e3b092 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1308,6 +1308,7 @@ export const es: TranslationResources = { detail: "Deteniendo el demonio local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Estado", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6b26d97812..e46c5701ec 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1308,6 +1308,7 @@ export const fr: TranslationResources = { detail: "Arrêt du démon local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Statut", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index bcb85cd9a6..09d32471a3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1286,6 +1286,7 @@ export const ja: TranslationResources = { detail: "ローカルデーモンを停止中。", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "デーモン", status: { title: "ステータス", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6b995ffc72..8d9ca884b0 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1281,6 +1281,7 @@ export const ko: TranslationResources = { detail: "로컬 데몬을 중지하는 중입니다.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "데몬", status: { title: "상태", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e6b1acdb32..75dcd62990 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1299,6 +1299,7 @@ export const ptBR: TranslationResources = { detail: "Parando o daemon local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Status", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 23b29edb13..5cd4942ef4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1291,6 +1291,7 @@ export const ru: TranslationResources = { detail: "Остановка локального демона.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Демон", status: { title: "Статус", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 32442aba5f..6c908d90ad 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1263,6 +1263,7 @@ export const zhCN: TranslationResources = { detail: "正在停止本地 daemon。", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "状态", diff --git a/packages/app/src/runtime/daemon-start-service.test.ts b/packages/app/src/runtime/daemon-start-service.test.ts index a988a09e0e..e9e76fe1ad 100644 --- a/packages/app/src/runtime/daemon-start-service.test.ts +++ b/packages/app/src/runtime/daemon-start-service.test.ts @@ -36,6 +36,8 @@ function makeStatus(overrides: Partial = {}): DesktopDaemon home: "/home", version: "0.0.0", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, ...overrides, }; diff --git a/packages/app/src/screens/settings/daemon-lifecycle.test.ts b/packages/app/src/screens/settings/daemon-lifecycle.test.ts new file mode 100644 index 0000000000..c50006534a --- /dev/null +++ b/packages/app/src/screens/settings/daemon-lifecycle.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "vitest"; +import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; + +test("settings restart completes when a replacement worker is observed without a sampled disconnect", async () => { + let pid = 10; + await restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + restartServer: async () => { + pid = 11; + }, + }); + expect(pid).toBe(11); +}); + +test("a status permission failure prevents the restart request", async () => { + let restarted = false; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => { + throw new Error("Permission denied"); + }, + restartServer: async () => { + restarted = true; + }, + }), + ).rejects.toThrow("Permission denied"); + expect(restarted).toBe(false); +}); + +test("a different responder fails confirmation immediately", async () => { + let serverId = "daemon"; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid: 10, version: "1.0.0", serverId }), + restartServer: async () => { + serverId = "other"; + }, + }), + ).rejects.toThrow("identity changed"); +}); + +test("a lost restart acknowledgment can still confirm the replacement", async () => { + let pid = 10; + await restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + restartServer: async () => { + pid = 11; + throw Object.assign(new Error("Connection lost"), { code: "DAEMON_CONNECTION_LOST" }); + }, + }); + expect(pid).toBe(11); +}); + +test("installation and worker version confirmation are separate outcomes", async () => { + let pid = 10; + await expect( + updateDaemonFromSettings("daemon", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + updateDaemon: async () => { + pid = 11; + return { success: true, error: null, newVersion: "2.0.0" }; + }, + }), + ).rejects.toThrow("Package installed; replacement worker version was not confirmed"); +}); + +test("an installed version is confirmed only in its replacement worker", async () => { + let pid = 10, + version = "1.0.0"; + await expect( + updateDaemonFromSettings("daemon", { + getStatus: async () => ({ pid, version, serverId: "daemon" }), + updateDaemon: async () => { + pid = 11; + version = "2.0.0"; + return { success: true, error: null, newVersion: version }; + }, + }), + ).resolves.toEqual({ workerVersion: "2.0.0" }); +}); + +test("RPC errors mentioning transport are not retried", async () => { + let requested = false; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => { + if (requested) + throw Object.assign(new Error("Connection policy denied by plugin"), { + code: "permission_denied", + }); + return { pid: 10, version: "1.0.0", serverId: "daemon" }; + }, + restartServer: async () => { + requested = true; + }, + }), + ).rejects.toThrow("Connection policy denied by plugin"); +}); diff --git a/packages/app/src/screens/settings/daemon-lifecycle.ts b/packages/app/src/screens/settings/daemon-lifecycle.ts new file mode 100644 index 0000000000..c4e9d83f5a --- /dev/null +++ b/packages/app/src/screens/settings/daemon-lifecycle.ts @@ -0,0 +1,94 @@ +interface WorkerStatus { + pid: number; + serverId: string; + version: string | null; +} +interface StatusReader { + getStatus: () => Promise; +} + +export interface SettingsDaemonRestartDeps extends StatusReader { + restartServer: (reason: string) => Promise; +} + +/** Acknowledgment starts the wait; only a different ready worker completes it. */ +export async function restartDaemonFromSettings( + hostServerId: string, + reason: string, + deps: SettingsDaemonRestartDeps, +): Promise { + const previous = await readSelectedWorker(hostServerId, deps); + let acknowledged = false; + try { + await deps.restartServer(reason); + acknowledged = true; + } catch (error) { + if (!isReconnectFailure(error)) throw error; + } + try { + await observeReplacement(previous, deps); + } catch (error) { + throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error }); + } +} + +export async function updateDaemonFromSettings( + hostServerId: string, + deps: StatusReader & { + updateDaemon: () => Promise<{ + success: boolean; + error: string | null; + newVersion: string | null; + }>; + }, +): Promise<{ workerVersion: string }> { + const previous = await readSelectedWorker(hostServerId, deps); + const installed = await deps.updateDaemon(); + if (!installed.success) throw new Error(installed.error ?? "Package installation failed"); + try { + const worker = await observeReplacement(previous, deps); + if (!worker.version || !installed.newVersion || worker.version !== installed.newVersion) { + throw new Error( + `Expected installed version ${installed.newVersion ?? "unknown"}; observed worker ${worker.version}.`, + ); + } + return { workerVersion: worker.version }; + } catch (error) { + throw new Error( + `Package installed; replacement worker version was not confirmed. ${String(error)}`, + { cause: error }, + ); + } +} + +async function readSelectedWorker(serverId: string, deps: StatusReader): Promise { + const worker = await deps.getStatus(); + if (worker.serverId !== serverId) throw new Error("Daemon identity changed"); + return worker; +} + +async function observeReplacement( + previous: WorkerStatus, + deps: StatusReader, +): Promise { + const deadline = Date.now() + 600_000; + while (Date.now() < deadline) { + try { + const current = await readSelectedWorker(previous.serverId, deps); + if (current.pid !== previous.pid) return current; + } catch (error) { + if (!isReconnectFailure(error)) throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error("Replacement worker could not be confirmed. Check daemon status and logs."); +} + +function isReconnectFailure(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + ["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"].includes(String(error.code)), + ); +} diff --git a/packages/app/src/screens/settings/daemon-restart.test.ts b/packages/app/src/screens/settings/daemon-restart.test.ts deleted file mode 100644 index 51b6c4d121..0000000000 --- a/packages/app/src/screens/settings/daemon-restart.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; -import { restartDaemonFromSettings, type SettingsDaemonRestartDeps } from "./daemon-restart"; - -const runningDesktopDaemonStatus: DesktopDaemonStatus = { - serverId: "local-desktop", - status: "running", - listen: null, - hostname: null, - pid: 123, - home: "/tmp/paseo", - version: "1.0.0", - desktopManaged: true, - error: null, -}; - -const desktopSettings = { - daemon: { - manageBuiltInDaemon: true, - }, -}; - -function makeDeps(overrides?: { - isElectron?: boolean; - desktopDaemonStatus?: DesktopDaemonStatus; - desktopSettings?: typeof desktopSettings; - desktopSettingsError?: Error; - restartDesktopDaemon?: () => Promise; - restartServer?: (reason: string) => Promise; -}) { - const calls: string[] = []; - const deps: SettingsDaemonRestartDeps = { - getIsElectron: () => overrides?.isElectron ?? true, - getDesktopDaemonStatus: async () => { - calls.push("desktop-status"); - return overrides?.desktopDaemonStatus ?? runningDesktopDaemonStatus; - }, - getDesktopSettings: async () => { - calls.push("desktop-settings"); - if (overrides?.desktopSettingsError) { - throw overrides.desktopSettingsError; - } - return overrides?.desktopSettings ?? desktopSettings; - }, - restartDesktopDaemon: - overrides?.restartDesktopDaemon ?? - (async () => { - calls.push("desktop-restart"); - return runningDesktopDaemonStatus; - }), - restartServer: - overrides?.restartServer ?? - (async (reason) => { - calls.push(`rpc-restart:${reason}`); - }), - }; - return { calls, deps }; -} - -describe("restartDaemonFromSettings", () => { - it("restarts the local desktop-managed daemon through the desktop bridge", async () => { - const { calls, deps } = makeDeps(); - - await restartDaemonFromSettings(" local-desktop ", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]); - }); - - it("restarts remote hosts over the daemon RPC without reading desktop settings", async () => { - const { calls, deps } = makeDeps({ - desktopSettingsError: new Error("Unreadable desktop settings."), - }); - - await restartDaemonFromSettings("remote-host", "settings_daemon_restart_remote", deps); - - expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_remote"]); - }); - - it("keeps manually managed local daemons on the RPC path without reading desktop settings", async () => { - const { calls, deps } = makeDeps({ - desktopDaemonStatus: { ...runningDesktopDaemonStatus, desktopManaged: false }, - desktopSettingsError: new Error("Unreadable desktop settings."), - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_local"]); - }); - - it("keeps the RPC path when built-in daemon management is disabled", async () => { - const { calls, deps } = makeDeps({ - desktopSettings: { - ...desktopSettings, - daemon: { ...desktopSettings.daemon, manageBuiltInDaemon: false }, - }, - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual([ - "desktop-status", - "desktop-settings", - "rpc-restart:settings_daemon_restart_local", - ]); - }); - - it("uses the RPC path outside Electron without reading desktop daemon status or settings", async () => { - const { calls, deps } = makeDeps({ - isElectron: false, - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["rpc-restart:settings_daemon_restart_local"]); - }); - - it("surfaces desktop restart failures without falling back to worker recycle", async () => { - const { calls, deps } = makeDeps({ - restartDesktopDaemon: async () => { - calls.push("desktop-restart"); - throw new Error("Desktop restart failed."); - }, - }); - - await expect( - restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps), - ).rejects.toThrow("Desktop restart failed."); - - expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]); - }); -}); diff --git a/packages/app/src/screens/settings/daemon-restart.ts b/packages/app/src/screens/settings/daemon-restart.ts deleted file mode 100644 index 61cce091e2..0000000000 --- a/packages/app/src/screens/settings/daemon-restart.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; - -interface DesktopDaemonRestartStatus { - desktopManaged: boolean; - serverId: string; -} - -interface DesktopDaemonRestartSettings { - daemon: { - manageBuiltInDaemon: boolean; - }; -} - -export interface SettingsDaemonRestartDeps { - getIsElectron: () => boolean; - getDesktopDaemonStatus: () => Promise; - getDesktopSettings: () => Promise; - restartDesktopDaemon: () => Promise; - restartServer: (reason: string) => Promise; -} - -async function isLocalDesktopManagedDaemon( - hostServerId: string, - deps: SettingsDaemonRestartDeps, -): Promise { - if (!deps.getIsElectron()) { - return false; - } - - const desktopDaemonStatus = await deps.getDesktopDaemonStatus(); - if (!desktopDaemonStatus.desktopManaged) { - return false; - } - - const normalizedHostServerId = hostServerId.trim(); - const normalizedDesktopServerId = desktopDaemonStatus.serverId.trim(); - - if (normalizedHostServerId.length === 0 || normalizedHostServerId !== normalizedDesktopServerId) { - return false; - } - - const desktopSettings = await deps.getDesktopSettings(); - return desktopSettings.daemon.manageBuiltInDaemon; -} - -export async function restartDaemonFromSettings( - hostServerId: string, - reason: string, - deps: SettingsDaemonRestartDeps, -): Promise { - if (await isLocalDesktopManagedDaemon(hostServerId, deps)) { - await deps.restartDesktopDaemon(); - return; - } - - await deps.restartServer(reason); -} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index ffe07c308e..23c7815294 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -33,16 +33,10 @@ import { ProfileDraft, TerminalProfileEditModal, } from "@/screens/settings/terminal-profile-edit-modal"; -import { getIsElectron } from "@/constants/platform"; -import { - getDesktopDaemonStatus, - restartDesktopDaemon, - startDesktopDaemon, - stopDesktopDaemon, -} from "@/desktop/daemon/desktop-daemon"; +import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; -import { loadDesktopSettings, useDesktopSettings } from "@/desktop/settings/desktop-settings"; +import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; @@ -72,8 +66,16 @@ import { ICON_SIZE } from "@/styles/theme"; import type { Theme } from "@/styles/theme"; import { getProviderIcon } from "@/components/provider-icons"; import { BrowserToolsOptInCard } from "./browser-tools-card"; -import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect"; -import { restartDaemonFromSettings } from "./daemon-restart"; +import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; + +const ThemedRestart = withUnistyles(RotateCw); +const ThemedUpdate = withUnistyles(ArrowUpToLine); +const lifecycleIconProps = (theme: Theme) => ({ + size: theme.iconSize.sm, + color: theme.colors.foreground, +}); +const restartIcon = ; +const updateIcon = ; const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedArrowDown = withUnistyles(ArrowDown); @@ -553,14 +555,8 @@ function ConnectionRow({ ); } -const delay = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - function RestartDaemonCard({ host }: { host: HostProfile }) { const { t } = useTranslation(); - const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); const runtime = getHostRuntimeStore(); @@ -578,68 +574,20 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { [host.serverId, runtime], ); - const waitForCondition = useCallback( - async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isMountedRef.current) return false; - if (predicate()) return true; - await delay(intervalMs); - } - return predicate(); - }, - [], - ); - const waitForDaemonRestart = useCallback( - async (restartRequest: Promise) => { - const disconnectTimeoutMs = 30000; - const reconnectTimeoutMs = 30000; - const requestFailureDisconnectGraceMs = 2000; - const disconnectedPromise = isHostConnected() - ? waitForCondition(() => !isHostConnected(), disconnectTimeoutMs) - : Promise.resolve(true); - const restartResult = await restartRequest.then( - () => ({ status: "accepted" as const }), - async (error) => ({ - status: "rejected" as const, - error, - disconnectedAfterFailure: await waitForCondition( - () => !isHostConnected(), - requestFailureDisconnectGraceMs, - 100, - ), - }), - ); - if (!isMountedRef.current) return; - - if (restartResult.status === "rejected" && !restartResult.disconnectedAfterFailure) { - console.error(`[HostPage] Failed to restart daemon ${host.label}`, restartResult.error); - setIsRestarting(false); + async (request: Promise) => { + try { + await request; + } catch (error) { Alert.alert( t("settings.host.daemon.restart.requestFailedTitle"), - t("settings.host.daemon.restart.requestFailedMessage"), + error instanceof Error ? error.message : String(error), ); - return; - } - - const disconnected = - restartResult.status === "rejected" - ? restartResult.disconnectedAfterFailure - : await disconnectedPromise; - const reconnected = - disconnected && (await waitForCondition(() => isHostConnected(), reconnectTimeoutMs)); - if (isMountedRef.current) { - setIsRestarting(false); - if (!reconnected) { - Alert.alert( - t("settings.host.daemon.restart.unableToReconnectTitle"), - t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }), - ); - } + } finally { + if (isMountedRef.current) setIsRestarting(false); } }, - [host.label, isHostConnected, t, waitForCondition], + [t], ); const handleRestart = useCallback(() => { @@ -672,11 +620,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { host.serverId, `settings_daemon_restart_${host.serverId}`, { - getIsElectron, - getDesktopDaemonStatus, - getDesktopSettings: loadDesktopSettings, - restartDesktopDaemon, restartServer: (reason) => daemonClient.restartServer(reason), + getStatus: async () => ({ + ...(await daemonClient.getDaemonStatus({ timeout: 1500 })), + serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", + version: daemonClient.getLastServerInfoMessage()?.version ?? null, + }), }, ); void waitForDaemonRestart(restartRequest); @@ -691,11 +640,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { }); }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]); - const restartIcon = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foreground], - ); - return ( @@ -722,12 +666,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { type DaemonUpdateState = | { status: "idle" } + | { status: "complete"; workerVersion: string } | { status: "updating"; phase: string } | { status: "failed"; title: string; message: string }; function UpdateDaemonCard({ host }: { host: HostProfile }) { const { t } = useTranslation(); - const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); const runtime = getHostRuntimeStore(); @@ -759,55 +703,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), [host.serverId, runtime], ); - const hasReconnectedAfter = useCallback( - (startMarker: DaemonConnectionMarker | null) => - hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker), - [host.serverId, runtime], - ); - - const waitForCondition = useCallback( - async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isMountedRef.current) return false; - if (predicate()) return true; - await delay(intervalMs); - } - return predicate(); - }, - [], - ); - - const waitForDaemonRestart = useCallback( - async (startMarker: DaemonConnectionMarker | null) => { - const disconnectTimeoutMs = 15000; - const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while - if (!hasReconnectedAfter(startMarker) && isHostConnected()) { - await waitForCondition( - () => !isHostConnected() || hasReconnectedAfter(startMarker), - disconnectTimeoutMs, - ); - } - const reconnected = - hasReconnectedAfter(startMarker) || - (await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs)); - if (isMountedRef.current) { - if (!reconnected) { - setUpdateState({ - status: "failed", - title: t("settings.host.daemon.update.unableToReconnectTitle"), - message: t("settings.host.daemon.update.unableToReconnectMessage", { - name: host.label, - }), - }); - return; - } - setUpdateState({ status: "idle" }); - } - }, - [hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition], - ); - const handleUpdate = useCallback(() => { if (!daemonClient) { setUpdateState({ @@ -835,13 +730,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }) .then((confirmed) => { if (!confirmed || !isMountedRef.current) return; - const startSnapshot = runtime.getSnapshot(host.serverId); - const startMarker = startSnapshot - ? { - clientGeneration: startSnapshot.clientGeneration, - lastOnlineAt: startSnapshot.lastOnlineAt, - } - : null; setUpdateState({ status: "updating", phase: t("settings.host.daemon.update.phaseStarting"), @@ -875,24 +763,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }); unsubscribeRef.current = unsubscribe; - void daemonClient - .updateDaemon(requestId) - .then((response) => { + void updateDaemonFromSettings(host.serverId, { + updateDaemon: () => daemonClient.updateDaemon(requestId), + getStatus: async () => ({ + ...(await daemonClient.getDaemonStatus({ timeout: 1500 })), + serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", + version: daemonClient.getLastServerInfoMessage()?.version ?? null, + }), + }) + .then(({ workerVersion }) => { unsubscribeRef.current = null; unsubscribe(); - if (!response.success) { - if (!isMountedRef.current) return undefined; - setUpdateState({ - status: "failed", - title: t("settings.host.daemon.update.requestFailedTitle"), - message: t("settings.host.daemon.update.requestFailedMessage", { - error: response.error ?? "Unknown error", - }), - }); - return undefined; - } - // Update succeeded — wait for daemon to restart and reconnect - void waitForDaemonRestart(startMarker); + if (isMountedRef.current) setUpdateState({ status: "complete", workerVersion }); return undefined; }) .catch((error) => { @@ -919,15 +801,10 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { message: t("settings.host.daemon.update.dialogFailedMessage"), }); }); - }, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]); - - const updateIcon = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foreground], - ); + }, [daemonClient, host.label, host.serverId, isHostConnected, t]); const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged); - if (!shouldShowUpdate) { + if (!shouldShowUpdate && updateState.status !== "complete") { return null; } @@ -956,6 +833,15 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { {buttonLabel} + {updateState.status === "complete" ? ( + + ) : null} {updateState.status === "failed" ? ( ( () => ({ - title: isLocalDaemon + title: stopsOwnedDaemon ? t("settings.host.daemon.remove.localConfirmTitle") : t("settings.host.daemon.remove.title"), }), - [isLocalDaemon, t], + [stopsOwnedDaemon, t], ); const destructiveTextStyle = useMemo( @@ -1292,7 +1179,7 @@ function RemoveHostSection({ try { await updateSettings({ daemon: { manageBuiltInDaemon: false } }); didDisableDaemonManagement = true; - if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + if (daemonStatus?.status === "running" && daemonStatus.ownedByDesktop) { setStatus(await stopDesktopDaemon("host_remove")); didStopDaemon = true; } @@ -1360,7 +1247,7 @@ function RemoveHostSection({ : t("settings.host.daemon.remove.title")} - {isLocalDaemon + {stopsOwnedDaemon ? t("settings.host.daemon.remove.localHint") : t("settings.host.daemon.remove.hint")} @@ -1386,7 +1273,7 @@ function RemoveHostSection({ testID="remove-host-confirm-modal" > - {isLocalDaemon + {stopsOwnedDaemon ? t("settings.host.daemon.remove.localConfirmMessage") : t("settings.host.daemon.remove.confirmMessage", { name: host.label })} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 275d8f6170..5e965601d9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,3 +1,4 @@ +import { pairCommand } from "./commands/daemon/pair.js"; import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; @@ -15,9 +16,9 @@ import { createHeartbeatCommand } from "./commands/heartbeat/index.js"; import { createHubCommand } from "./commands/hub/index.js"; import { createHooksCommand } from "./commands/hooks.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; -import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; -import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js"; -import { runDaemonReloadCommand } from "./commands/daemon/reload.js"; +import { daemonStatusCommand } from "./commands/daemon/status.js"; +import { daemonRestartCommand } from "./commands/daemon/restart.js"; +import { daemonReloadCommand } from "./commands/daemon/reload.js"; import { addLsOptions, runLsCommand } from "./commands/agent/ls.js"; import { addRunOptions, runRunCommand } from "./commands/agent/run.js"; import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js"; @@ -35,19 +36,12 @@ import { onboardCommand } from "./commands/onboard.js"; import { addDaemonHostOption, addJsonAndDaemonHostOptions, - addJsonOption, withGlobalOptions, } from "./utils/command-options.js"; import { resolveCliVersion } from "./version.js"; const VERSION = resolveCliVersion(); -function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { - if (typeof hostnames === "string") return hostnames; - if (typeof allowedHosts === "string") return allowedHosts; - return undefined; -} - export function createCli(): Command { const program = new Command(); @@ -125,51 +119,10 @@ export function createCli(): Command { program.addCommand(daemonStartCommand()); program.addCommand(createHooksCommand()); - addJsonOption( - program - .command("status") - .description('Show local daemon status (alias for "paseo daemon status")'), - ) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runDaemonStatusCommand)); - - addJsonAndDaemonHostOptions( - program.command("reload").description('Reload daemon config (alias for "paseo daemon reload")'), - ).action(withOutput(runDaemonReloadCommand)); - - addJsonOption( - program - .command("restart") - .description('Restart local daemon (alias for "paseo daemon restart")'), - ) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--timeout ", "Wait timeout before force step (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option( - "--listen ", - "Listen target for restarted daemon (host:port, port, or unix socket)", - ) - .option("--port ", "Port for restarted daemon listen target") - .option("--relay", "Enable relay on restarted daemon") - .option("--no-relay", "Disable relay on restarted daemon") - .option("--no-mcp", "Disable Agent MCP on restarted daemon") - .option( - "--hostnames ", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts ").hideHelp()) - .action( - withOutput((...args) => { - const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; - return runDaemonRestartCommand( - { - ...options, - hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts), - }, - command, - ); - }), - ); + program.addCommand(daemonStatusCommand()); + program.addCommand(daemonRestartCommand()); + program.addCommand(daemonReloadCommand()); + program.addCommand(pairCommand()); // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()); @@ -207,5 +160,20 @@ export function createCli(): Command { // Added in v0.2.0; remove after 2027-01-17. program.addCommand(createWorktreeCommand(), { hidden: true }); + const enforceSelectorDuplicates = (command: Command) => { + for (const option of command.options) { + if (option.long !== "--home" && option.long !== "--host") continue; + option.argParser((value: string, previous: string | undefined) => { + if (previous !== undefined && previous !== value) + throw { + code: "TARGET_AMBIGUOUS", + message: `Conflicting duplicate ${option.long} selectors.`, + }; + return value; + }); + } + command.commands.forEach(enforceSelectorDuplicates); + }; + enforceSelectorDuplicates(program); return program; } diff --git a/packages/cli/src/commands/agent/archive.ts b/packages/cli/src/commands/agent/archive.ts index ff9f80afa2..c7809ba565 100644 --- a/packages/cli/src/commands/agent/archive.ts +++ b/packages/cli/src/commands/agent/archive.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -44,8 +43,6 @@ export async function runArchiveCommand( options: AgentArchiveOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -56,18 +53,7 @@ export async function runArchiveCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 1118d4c905..36cb8e4fc0 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -5,17 +5,17 @@ export function addAttachOptions(cmd: Command): Command { .description("Attach to a running agent's output stream") .argument("", "Agent ID (or prefix)"); } -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, } from "../../utils/timeline.js"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; export interface AgentAttachOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; [key: string]: unknown; } @@ -105,23 +105,13 @@ export async function runAttachCommand( options: AgentAttachOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id) { console.error("Error: Agent ID required"); console.error("Usage: paseo attach "); process.exit(1); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`Error: Cannot connect to daemon at ${host}: ${message}`); - console.error("Start the daemon with: paseo daemon start"); - process.exit(1); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: id }); diff --git a/packages/cli/src/commands/agent/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts index 56aa83dc65..60d5cec50f 100644 --- a/packages/cli/src/commands/agent/delete.test.ts +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + import { runDeleteCommand } from "./delete.js"; const agent = { @@ -27,7 +29,7 @@ vi.mock("../../utils/client.js", () => ({ describe("runDeleteCommand", () => { it("force-deletes a running agent when graceful cancellation is refused", async () => { - const result = await runDeleteCommand(agent.id, {}, {} as never); + const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never); expect(cancelAgent).toHaveBeenCalledWith(agent.id); expect(deleteAgent).toHaveBeenCalledWith(agent.id); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index c8502c9a19..f1499b51f4 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; export function addDeleteOptions(cmd: Command): Command { @@ -39,8 +38,6 @@ export async function runDeleteCommand( options: AgentDeleteOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id && !options.all && !options.cwd) { const error: CommandError = { code: "MISSING_ARGUMENT", @@ -50,18 +47,7 @@ export async function runDeleteCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }); @@ -97,6 +83,7 @@ export async function runDeleteCommand( await client.deleteAgent(agent.id); return { ok: true as const, id: agent.id }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); return { ok: false as const, id: agent.id, message }; } diff --git a/packages/cli/src/commands/agent/detach.ts b/packages/cli/src/commands/agent/detach.ts index 5de8ae8c9a..9de223dffb 100644 --- a/packages/cli/src/commands/agent/detach.ts +++ b/packages/cli/src/commands/agent/detach.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandError, CommandOptions, @@ -26,17 +25,7 @@ export async function runDetachCommand( options: CommandOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - } satisfies CommandError; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const payload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/import.test.ts b/packages/cli/src/commands/agent/import.test.ts index 48a678de4a..761b2a0f1e 100644 --- a/packages/cli/src/commands/agent/import.test.ts +++ b/packages/cli/src/commands/agent/import.test.ts @@ -43,6 +43,7 @@ describe("resolveImportCwd", () => { const result = await runImportCommand( "pi-session-1", { + daemonTarget: { kind: "endpoint", host: "example.test:12345" }, provider: "pi", cwd: "/tmp/project", }, diff --git a/packages/cli/src/commands/agent/import.ts b/packages/cli/src/commands/agent/import.ts index d3d27d627b..fa42b6860a 100644 --- a/packages/cli/src/commands/agent/import.ts +++ b/packages/cli/src/commands/agent/import.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { collectMultiple } from "../../utils/command-options.js"; import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; import { agentRunSchema, type AgentRunResult } from "./run.js"; @@ -94,28 +94,11 @@ export function resolveImportCwd(explicitCwd: string | undefined, defaultCwd: st return cwd; } -async function connectToDaemonOrThrow( - hostOption: string | undefined, - host: string, -): Promise>> { - try { - return await connectToDaemon({ host: hostOption }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } -} - export async function runImportCommand( sessionIdArg: string, options: AgentImportOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); const sessionId = sessionIdArg.trim(); if (!sessionId) { throw { @@ -129,7 +112,7 @@ export async function runImportCommand( const cwd = resolveImportCwd(options.cwd, process.cwd()); const labels = parseImportLabels(options.label); - const client = await connectToDaemonOrThrow(options.host, host); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agent = await client.importAgent({ diff --git a/packages/cli/src/commands/agent/inspect.ts b/packages/cli/src/commands/agent/inspect.ts index 93f1da3d4d..bad8808526 100644 --- a/packages/cli/src/commands/agent/inspect.ts +++ b/packages/cli/src/commands/agent/inspect.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; export function addInspectOptions(cmd: Command): Command { @@ -217,8 +217,6 @@ export async function runInspectCommand( options: AgentInspectOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -229,18 +227,7 @@ export async function runInspectCommand( throw error; } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdArg }); diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index 32652d3e0e..d32ab549a8 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions } from "../../output/index.js"; import { fetchProjectedTimelineItems, @@ -91,23 +91,13 @@ export async function runLogsCommand( options: AgentLogsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id) { console.error("Error: Agent ID required"); console.error("Usage: paseo agent logs "); process.exit(1); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`Error: Cannot connect to daemon at ${host}: ${message}`); - console.error("Start the daemon with: paseo daemon start"); - process.exit(1); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: id }); @@ -157,6 +147,7 @@ export async function runLogsCommand( const transcript = formatAgentActivityTranscript(timelineItems, tailCount); console.log(transcript); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); console.error(`Error: Failed to get logs: ${message}`); await client.close().catch(() => {}); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 37978d9011..bb2189c7c4 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; import { collectMultiple } from "../../utils/command-options.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; @@ -104,21 +104,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem { export type AgentLsResult = ListResult; -function daemonConnectionFailure(host: string, cause: unknown): CommandError { - const reason = cause instanceof Error ? cause.message : String(cause); - const isSsh = host.trim().startsWith("ssh://"); - return { - code: "DAEMON_NOT_RUNNING", - message: `Cannot reach the daemon at ${host}: ${reason}`, - details: isSsh - ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it." - : [ - "Start a local daemon with: paseo daemon start", - "To use another daemon, pass --host or set PASEO_HOST.", - ].join("\n"), - }; -} - export interface AgentLsOptions extends CommandOptions { /** -a: Include archived agents */ all?: boolean; @@ -185,14 +170,7 @@ export async function runLsCommand( options: AgentLsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - throw daemonConnectionFailure(host, err); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const normalizedThinkingOptionId = options.thinking?.trim(); diff --git a/packages/cli/src/commands/agent/mode.ts b/packages/cli/src/commands/agent/mode.ts index 46229b580c..cbd7bfbd56 100644 --- a/packages/cli/src/commands/agent/mode.ts +++ b/packages/cli/src/commands/agent/mode.ts @@ -55,7 +55,7 @@ export async function runModeCommand( _command: Command, ): Promise { const normalizedMode = mode?.trim(); - const host = getDaemonHost({ host: options.host }); + const host = getDaemonHost({ target: options.daemonTarget }); // Validate arguments if (!options.list && !normalizedMode) { @@ -64,7 +64,7 @@ export async function runModeCommand( let client: Awaited> | undefined; try { - client = await connectToDaemon({ host: options.host }); + client = await connectToDaemon({ target: options.daemonTarget }); const fetchResult = await client.fetchAgent({ agentId: id }); if (!fetchResult) { const error: CommandError = { diff --git a/packages/cli/src/commands/agent/open.ts b/packages/cli/src/commands/agent/open.ts index 333ec61b4e..2119bd130b 100644 --- a/packages/cli/src/commands/agent/open.ts +++ b/packages/cli/src/commands/agent/open.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { openDesktopWithAgent } from "../open.js"; import type { CommandError, @@ -36,12 +36,7 @@ async function resolveServerId(options: CommandOptions): Promise { return explicitServerId; } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (error) { - throw buildDaemonConnectionCommandError({ host: options.host, error }); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const serverId = client.getLastServerInfoMessage()?.serverId.trim(); if (!serverId) { diff --git a/packages/cli/src/commands/agent/reload.ts b/packages/cli/src/commands/agent/reload.ts index 607dfa278a..f6cda480e2 100644 --- a/packages/cli/src/commands/agent/reload.ts +++ b/packages/cli/src/commands/agent/reload.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -40,8 +39,6 @@ export async function runReloadCommand( options: AgentReloadOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { code: "MISSING_AGENT_ID", @@ -51,18 +48,7 @@ export async function runReloadCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 755638d4d3..8f707c5e0c 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -6,6 +6,8 @@ import { type AgentRunOptions, } from "./run"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + describe("managed agent caller context", () => { it("propagates a trimmed PASEO_AGENT_ID", () => { expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent"); @@ -65,8 +67,13 @@ describe("runRunCommand option validation", () => { } }); - async function expectInvalidOptions(options: AgentRunOptions, messageMatch: RegExp) { - await expect(runRunCommand("do something", options, {} as never)).rejects.toMatchObject({ + async function expectInvalidOptions( + options: Omit, + messageMatch: RegExp, + ) { + await expect( + runRunCommand("do something", { ...options, daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS", message: expect.stringMatching(messageMatch), }); @@ -84,7 +91,11 @@ describe("runRunCommand option validation", () => { // must clear validation. It still fails later (provider resolution), which // is enough to prove the new guard did not reject it. await expect( - runRunCommand("do something", { newWorkspace: "worktree", provider: undefined }, {} as never), + runRunCommand( + "do something", + { newWorkspace: "worktree", provider: undefined, daemonTarget }, + {} as never, + ), ).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index a642909952..c72049347d 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -1,7 +1,7 @@ import { Command, Option } from "commander"; import { getStructuredAgentResponse, StructuredAgentResponseError } from "@getpaseo/server"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -140,6 +140,7 @@ function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined { function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) { const newWorkspace = resolveNewWorkspaceKind(options) ?? "local"; return buildWorkspaceSource({ + daemonTarget: options.daemonTarget, isolation: newWorkspace, path: cwd, mode: options.worktreeMode, @@ -181,6 +182,7 @@ function loadOutputSchema(value: string): Record { try { source = readFileSync(resolve(trimmed), "utf8"); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "INVALID_OUTPUT_SCHEMA", @@ -195,6 +197,7 @@ function loadOutputSchema(value: string): Record { try { parsed = JSON.parse(source); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "INVALID_OUTPUT_SCHEMA", @@ -406,6 +409,7 @@ function parseWaitTimeoutOption(waitTimeout: string | undefined): number { } return ms; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "INVALID_TIMEOUT", @@ -432,6 +436,7 @@ function loadRunImages( mimeType, }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err }); } @@ -482,22 +487,6 @@ function parseKeyValueFlags( return labels; } -async function connectToDaemonOrThrow( - hostOption: string | undefined, - host: string, -): Promise { - try { - return await connectToDaemon({ host: hostOption }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } -} - // A workspace is the explicit home of a run: it owns the directory the agent // runs in. The CLI resolves one before creating any agent, so no run leans on // createAgent's legacy cwd->workspace fallback. @@ -587,7 +576,6 @@ export async function runRunCommand( options: AgentRunOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined; validateRunOptions(prompt, options, outputSchema); @@ -596,7 +584,7 @@ export async function runRunCommand( const resolvedProviderModel = resolveProviderAndModel(options); const resolvedTitle = options.title ?? options.name; - const client = await connectToDaemonOrThrow(options.host, host); + const client = await connectToDaemon({ target: options.daemonTarget }); try { // Resolve working directory diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index 06249289b7..aa9b5a24cc 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -1,6 +1,6 @@ import { type Command } from "commander"; import { collectMultiple } from "../../utils/command-options.js"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -78,6 +78,7 @@ async function readImageFiles( return { data: buffer.toString("base64"), mimeType }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "IMAGE_READ_ERROR", @@ -128,6 +129,7 @@ async function resolvePromptInput(options: { try { return await readFile(resolve(promptFilePath), "utf8"); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "PROMPT_FILE_READ_ERROR", @@ -166,8 +168,6 @@ export async function runSendCommand( options: AgentSendOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -184,18 +184,7 @@ export async function runSendCommand( promptFile: options.promptFile, }); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { // Read image files if provided diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 42d269fcf2..3207bca4f8 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; import type { CommandOptions, @@ -42,8 +41,6 @@ export async function runStopCommand( options: AgentStopOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments - need either an id, --all, or --cwd if (!id && !options.all && !options.cwd) { const error: CommandError = { @@ -54,18 +51,7 @@ export async function runStopCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/update.ts b/packages/cli/src/commands/agent/update.ts index d23ad2d3ac..f41150b563 100644 --- a/packages/cli/src/commands/agent/update.ts +++ b/packages/cli/src/commands/agent/update.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -203,8 +203,6 @@ export async function runUpdateCommand( options: AgentUpdateOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -217,18 +215,7 @@ export async function runUpdateCommand( const changes = parseAgentChanges(options); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdArg }); diff --git a/packages/cli/src/commands/agent/wait.ts b/packages/cli/src/commands/agent/wait.ts index 6c800746cf..3e5e5b533a 100644 --- a/packages/cli/src/commands/agent/wait.ts +++ b/packages/cli/src/commands/agent/wait.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -72,6 +72,7 @@ function parseWaitTimeout(timeout: string | undefined): { timeoutLabel: `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`, }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "INVALID_TIMEOUT", @@ -142,8 +143,6 @@ export async function runWaitCommand( options: AgentWaitOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - if (!agentIdArg || agentIdArg.trim().length === 0) { throw { code: "MISSING_AGENT_ID", @@ -154,18 +153,7 @@ export async function runWaitCommand( const { timeoutMs, timeoutLabel } = parseWaitTimeout(options.timeout); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { try { diff --git a/packages/cli/src/commands/clone.ts b/packages/cli/src/commands/clone.ts index 6986934c30..cf3001b775 100644 --- a/packages/cli/src/commands/clone.ts +++ b/packages/cli/src/commands/clone.ts @@ -1,7 +1,6 @@ import type { Command } from "commander"; import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js"; +import { connectToDaemon } from "../utils/client.js"; import type { CommandError, OutputSchema, SingleResult } from "../output/index.js"; import type { CommandOptions } from "../output/with-output.js"; @@ -45,12 +44,7 @@ export async function runCloneCommand( throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names"); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - throw buildDaemonConnectionCommandError({ host: options.host, error: err }); - } + const client = await connectToDaemon({ target: options.daemonTarget }); if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) { await client.close().catch(() => {}); diff --git a/packages/cli/src/commands/daemon/config.ts b/packages/cli/src/commands/daemon/config.ts new file mode 100644 index 0000000000..c07354c5d6 --- /dev/null +++ b/packages/cli/src/commands/daemon/config.ts @@ -0,0 +1,116 @@ +import { addLocalDaemonOptions } from "../../utils/command-options.js"; +import { Command } from "commander"; +import { + readDaemonInstance, + readPersistedConfig, + getPersistedConfigValue, + editPersistedConfig, +} from "@getpaseo/server"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; + +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(key) + ? "[redacted]" + : redact(item), + ]), + ); +} + +function result(data: Record) { + return { + type: "single" as const, + data, + schema: { + idField: () => "configuration", + columns: [], + renderHuman: () => JSON.stringify(data, null, 2), + }, + }; +} + +function homeOf(options: CommandOptions) { + if (options.daemonTarget.kind !== "instance") + throw new Error("Configuration edits require --home"); + return options.daemonTarget.home; +} + +async function applySaved(home: string, options: CommandOptions) { + const nextCommand = `paseo daemon start --home ${JSON.stringify(home)}`; + const instance = await readDaemonInstance(home); + if (!instance?.listen) + return result({ + action: "saved", + applied: false, + message: "Saved; not applied to a running daemon", + nextCommand, + }); + let client; + try { + client = await connectToDaemon({ target: options.daemonTarget, timeout: 1_500 }); + } catch { + return result({ + action: "saved", + applied: false, + message: "Saved; not applied to a running daemon", + nextCommand: `paseo daemon reload --home ${JSON.stringify(home)}`, + }); + } + try { + const reload = await client.reloadDaemonConfig(); + return result({ action: "saved", ...reload }); + } catch (error) { + throw { code: "CONFIG_SAVED_RELOAD_FAILED", message: `Saved; reload failed: ${String(error)}` }; + } finally { + await client.close(); + } +} + +export function daemonConfigCommand(): Command { + const config = new Command("config").description( + "Read or edit persistent local configuration (local operation)", + ); + addLocalDaemonOptions(config.command("get [path]")).action( + withOutput(async (field: string | undefined, options: CommandOptions, _command: Command) => { + const persisted = readPersistedConfig(homeOf(options)); + const value = field ? getPersistedConfigValue(persisted, field) : persisted; + return result({ + source: "configured", + path: field ?? null, + set: value !== undefined, + value: + field && /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(field) + ? "[redacted]" + : redact(value), + }); + }), + ); + addLocalDaemonOptions(config.command("set ")) + .option("--string", "Interpret value literally as a string") + .action( + withOutput(async (field: string, raw: string, options: CommandOptions, _command: Command) => { + let value: unknown = raw; + if (!options.string) { + try { + value = JSON.parse(raw); + } catch { + /* Unquoted input is a string. */ + } + } + editPersistedConfig(homeOf(options), field, { value }); + return applySaved(homeOf(options), options); + }), + ); + addLocalDaemonOptions(config.command("unset ")).action( + withOutput(async (field: string, options: CommandOptions, _command: Command) => { + editPersistedConfig(homeOf(options), field, { unset: true }); + return applySaved(homeOf(options), options); + }), + ); + return config; +} diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 869d87607c..5f9a8f842d 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -1,81 +1,32 @@ -import { Command, Option } from "commander"; -import { startCommand } from "./start.js"; -import { runStatusCommand } from "./status.js"; -import { runStopCommand } from "./stop.js"; -import { runRestartCommand } from "./restart.js"; +import { Command } from "commander"; +import { startCommand, daemonRunCommand } from "./start.js"; +import { daemonStatusCommand } from "./status.js"; +import { daemonStopCommand } from "./stop.js"; +import { daemonRestartCommand } from "./restart.js"; import { runSetPasswordCommand } from "./set-password.js"; import { pairCommand } from "./pair.js"; -import { runDaemonReloadCommand } from "./reload.js"; +import { daemonReloadCommand } from "./reload.js"; +import { daemonConfigCommand } from "./config.js"; import { withOutput } from "../../output/index.js"; -import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js"; - -function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { - if (typeof hostnames === "string") return hostnames; - if (typeof allowedHosts === "string") return allowedHosts; - return undefined; -} +import { addJsonOption, addLocalDaemonOptions } from "../../utils/command-options.js"; export function createDaemonCommand(): Command { const daemon = new Command("daemon").description("Manage the Paseo daemon"); - - daemon.addCommand(startCommand()); - daemon.addCommand(pairCommand()); - - addJsonAndDaemonHostOptions( - daemon.command("reload").description("Reload config.json without restarting the daemon"), - ).action(withOutput(runDaemonReloadCommand)); - - addJsonOption(daemon.command("status").description("Show local daemon status")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runStatusCommand)); - - addJsonOption(daemon.command("stop").description("Stop the local daemon")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--timeout ", "Wait timeout before failing (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option("--kill-timeout ", "Wait after SIGKILL before failing (default: 3)") - .action(withOutput(runStopCommand)); - - addJsonOption(daemon.command("restart").description("Restart the local daemon")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--timeout ", "Wait timeout before force step (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option( - "--listen ", - "Listen target for restarted daemon (host:port, port, or unix socket)", - ) - .option("--port ", "Port for restarted daemon listen target") - .option("--relay", "Enable relay on restarted daemon") - .option("--no-relay", "Disable relay on restarted daemon") - .option("--no-mcp", "Disable Agent MCP on restarted daemon") - .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") - .option("--web-ui", "Enable the bundled daemon web UI on restarted daemon") - .option("--no-web-ui", "Disable the bundled daemon web UI on restarted daemon") - .option( - "--hostnames ", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts ").hideHelp()) - .action( - withOutput((...args) => { - const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; - return runRestartCommand( - { - ...options, - hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts), - }, - command, - ); - }), - ); - + for (const command of [ + startCommand(), + daemonRunCommand(), + daemonStatusCommand(), + daemonStopCommand(), + daemonRestartCommand(), + daemonReloadCommand(), + pairCommand(), + daemonConfigCommand(), + ]) + daemon.addCommand(command); addJsonOption( - daemon - .command("set-password") - .description("Prompt for and save a hashed daemon password to config.json"), - ) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runSetPasswordCommand)); - + addLocalDaemonOptions( + daemon.command("set-password").description("Save a hashed daemon password (local operation)"), + ), + ).action(withOutput(runSetPasswordCommand)); return daemon; } diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts new file mode 100644 index 0000000000..ea6cfc48f9 --- /dev/null +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -0,0 +1,609 @@ +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 { startDaemonInstance, readDaemonInstance } from "@getpaseo/server"; +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((resolve) => server.listen(0, "127.0.0.1", resolve)); + const value = (server.address() as net.AddressInfo).port; + await new Promise((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(); + 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((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.connectedDaemon; + }, + { timeout: 30_000 }, + ) + .toBe("reachable"); + 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.ok(["--home", b, "daemon", "status"], poisoned); + if (process.platform !== "win32") { + for (const home of [a, b, path.join(f.root, ".paseo")]) + expect((await stat(home)).mode & 0o777).toBe(0o700); + } + + 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("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 | 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 | 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 | 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 | 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("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); diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts deleted file mode 100644 index 03449fa0cb..0000000000 --- a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { EventEmitter } from "node:events"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -import { - type DaemonLaunchRuntime, - type DetachedDaemonProcess, - resolveLocalDaemonState, - startLocalDaemonDetached, - startLocalDaemonForeground, -} from "./local-daemon.js"; - -type RecordedDaemonLaunch = - | { - mode: "detached"; - command: string; - args: string[]; - options: Parameters[2]; - } - | { - mode: "foreground"; - command: string; - args: string[]; - options: Parameters[2]; - }; - -class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess { - pid = 4242; - wasUnreferenced = false; - - unref(): void { - this.wasUnreferenced = true; - } -} - -class FakeDaemonRuntime implements DaemonLaunchRuntime { - readonly recordedLaunches: RecordedDaemonLaunch[] = []; - readonly daemonProcess = new FakeDaemonProcess(); - foregroundStatus = 0; - runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts"; - - resolveRunnerEntry(): string { - return this.runnerEntry; - } - - resolveHome(env: NodeJS.ProcessEnv): string { - return env.PASEO_HOME ?? "/tmp/paseo"; - } - - spawnDetached( - command: string, - args: string[], - options: Parameters[2], - ): DetachedDaemonProcess { - this.recordedLaunches.push({ mode: "detached", command, args, options }); - return this.daemonProcess; - } - - spawnForeground( - command: string, - args: string[], - options: Parameters[2], - ) { - this.recordedLaunches.push({ mode: "foreground", command, args, options }); - return { status: this.foregroundStatus, error: undefined }; - } -} - -const tempRoots: string[] = []; - -async function createPaseoHome(config: unknown): Promise { - const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-daemon-")); - tempRoots.push(root); - const paseoHome = path.join(root, ".paseo"); - await mkdir(paseoHome, { recursive: true }); - await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2)); - return paseoHome; -} - -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(); - }); - - afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), - ); - vi.restoreAllMocks(); - }); - - test("foreground start spawns supervisor-entrypoint instead of server/index", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.command).toBe(process.execPath); - expectSupervisorLaunch(launch?.args ?? []); - expect(launch?.args).toContain("--no-relay"); - }); - - test("detached start spawns supervisor-entrypoint instead of server/index", async () => { - vi.useFakeTimers(); - const runtime = new FakeDaemonRuntime(); - - const resultPromise = startLocalDaemonDetached( - { home: "/tmp/paseo-test", mcp: false }, - runtime, - ); - await vi.advanceTimersByTimeAsync(1200); - const result = await resultPromise; - - expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" }); - expect(runtime.daemonProcess.wasUnreferenced).toBe(true); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("detached"); - expect(launch?.command).toBe(process.execPath); - expectSupervisorLaunch(launch?.args ?? []); - expect(launch?.args).toContain("--no-mcp"); - }); - - test("relay TLS flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - relayUseTls: true, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--relay-use-tls"); - expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true"); - }); - - test("web UI flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - webUi: true, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--web-ui"); - expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("true"); - }); - - test("no-web UI flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - webUi: false, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--no-web-ui"); - expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("false"); - }); - - test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => { - const home = await createPaseoHome({ - version: 1, - daemon: { - relay: { - endpoint: "10.0.0.5:51185", - publicEndpoint: "paseo.example.com", - useTls: false, - publicUseTls: true, - }, - }, - }); - - const state = resolveLocalDaemonState({ home }); - - expect(state.relayEndpoint).toBe("paseo.example.com"); - expect(state.relayUseTls).toBe(false); - expect(state.relayPublicUseTls).toBe(true); - }); -}); diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index a7afb4e6b7..049cfd0bd9 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -1,184 +1,9 @@ -import { spawnSync, type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { Command, Option } from "commander"; +import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server"; -import treeKill from "tree-kill"; -import { tryConnectToDaemon } from "../../utils/client.js"; - -export interface DaemonStartOptions { - port?: string; - listen?: string; - home?: string; - foreground?: boolean; - relay?: boolean; - relayUseTls?: boolean; - mcp?: boolean; - injectMcp?: boolean; - webUi?: boolean; - hostnames?: string; -} - -export interface LocalDaemonPidInfo { - pid: number; - startedAt?: string; - hostname?: string; - uid?: number; - listen?: string; - desktopManaged?: boolean; -} - -export interface LocalDaemonState { - home: string; - listen: string; - relayEnabled: boolean; - relayEndpoint: string; - relayUseTls: boolean; - relayPublicUseTls: boolean; - logPath: string; - pidPath: string; - pidInfo: LocalDaemonPidInfo | null; - running: boolean; - stalePidFile: boolean; -} - -export interface DetachedStartResult { - pid: number | null; - logPath: string; -} - -export interface StopLocalDaemonOptions { - home?: string; - timeoutMs?: number; - killTimeoutMs?: number; - force?: boolean; -} - -export interface StopLocalDaemonResult { - action: "stopped" | "not_running"; - home: string; - pid: number | null; - forced: boolean; - usedLifecycleRpc: boolean; - reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill"; - message: string; -} - -interface ProcessExitDetails { - code: number | null; - signal: NodeJS.Signals | null; - error?: Error; -} - -type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails); - -export interface DetachedDaemonProcess extends Pick {} - -export interface ForegroundDaemonProcessResult { - status: number | null; - error?: Error; -} - -export interface DaemonLaunchRuntime { - resolveRunnerEntry(): string; - resolveHome(env: NodeJS.ProcessEnv): string; - spawnDetached( - command: string, - args: string[], - options: Parameters[2], - ): DetachedDaemonProcess; - spawnForeground( - command: string, - args: string[], - options: Parameters[2], - ): ForegroundDaemonProcessResult; -} - -const DETACHED_STARTUP_GRACE_MS = 1200; -const PID_POLL_INTERVAL_MS = 100; -const DAEMON_LOG_FILENAME = "daemon.log"; -const DAEMON_PID_FILENAME = "paseo.pid"; - -export const DEFAULT_STOP_TIMEOUT_MS = 15_000; -export const DEFAULT_KILL_TIMEOUT_MS = 3_000; - +import { startDaemonInstance, resolvePaseoHome } from "@getpaseo/server"; const require = createRequire(import.meta.url); - -const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = { - resolveRunnerEntry: resolveDaemonRunnerEntry, - resolveHome: resolvePaseoHome, - spawnDetached: spawnProcess, - spawnForeground: spawnSync, -}; - -const startupReady = (): DetachedStartupResult => ({ exitedEarly: false }); - -const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({ - exitedEarly: true, - ...details, -}); - -function envWithHome(home?: string): NodeJS.ProcessEnv { - if (!home) { - return process.env; - } - - return { ...process.env, PASEO_HOME: home }; -} - -function buildRunnerArgs(options: DaemonStartOptions): string[] { - const args: string[] = []; - if (options.relay === true) { - args.push("--relay"); - } - if (options.relay === false) { - args.push("--no-relay"); - } - if (options.relayUseTls === true) { - args.push("--relay-use-tls"); - } - - if (options.mcp === false) { - args.push("--no-mcp"); - } - if (options.injectMcp === false) { - args.push("--no-inject-mcp"); - } - if (options.webUi === true) { - args.push("--web-ui"); - } - if (options.webUi === false) { - args.push("--no-web-ui"); - } - - return args; -} - -function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { - const childEnv: NodeJS.ProcessEnv = { ...process.env }; - if (options.home) { - childEnv.PASEO_HOME = options.home; - } - if (options.listen) { - childEnv.PASEO_LISTEN = options.listen; - } else if (options.port) { - childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`; - } - if (options.hostnames) { - childEnv.PASEO_HOSTNAMES = options.hostnames; - } - if (options.relayUseTls === true) { - childEnv.PASEO_RELAY_USE_TLS = "true"; - } - if (options.webUi === true) { - childEnv.PASEO_WEB_UI_ENABLED = "true"; - } - if (options.webUi === false) { - childEnv.PASEO_WEB_UI_ENABLED = "false"; - } - return childEnv; -} - function resolveServerRunnerFromDir(currentDir: string): string | null { const packageJsonPath = path.join(currentDir, "package.json"); if (!existsSync(packageJsonPath)) return null; @@ -215,550 +40,72 @@ function resolveDaemonRunnerEntry(): string { throw new Error("Unable to resolve @getpaseo/server package root for daemon runner"); } -function pidFilePath(paseoHome: string): string { - return path.join(paseoHome, DAEMON_PID_FILENAME); -} - -function resolveListenField(listen: unknown, sockPath: unknown): string | undefined { - if (typeof listen === "string") return listen; - if (typeof sockPath === "string") return sockPath; - return undefined; -} - -function resolveStopMessage( - forced: boolean, - lifecycleRequested: boolean, - fallbackMessage: string | null | undefined, -): string { - if (forced) return "Daemon owner process was force-stopped"; - if (lifecycleRequested) return "Daemon stopped gracefully"; - return fallbackMessage ?? "Daemon stopped via owner PID signal"; -} - -function resolveStopReason( - forced: boolean, - lifecycleRequested: boolean, -): StopLocalDaemonResult["reason"] { - if (forced) return "owner_pid_sigkill"; - if (lifecycleRequested) return "lifecycle_shutdown_rpc"; - return "owner_pid_signal"; -} - -function readPidFile(pidPath: string): LocalDaemonPidInfo | null { +export async function launchLocalDaemon(options: { + home: string; + timeoutMs?: number; + foreground?: boolean; +}) { + const abort = new AbortController(); + const cancel = () => abort.abort(); + process.once("SIGINT", cancel); + process.once("SIGTERM", cancel); try { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record; - const pidValue = parsed.pid; - if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) { - return null; - } - - return { - pid: pidValue, - startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined, - hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined, - uid: typeof parsed.uid === "number" ? parsed.uid : undefined, - listen: resolveListenField(parsed.listen, parsed.sockPath), - desktopManaged: parsed.desktopManaged === true ? true : undefined, - }; - } catch { - return null; - } -} - -function tailFile(filePath: string, lines = 30): string | null { - try { - const content = readFileSync(filePath, "utf-8"); - return content.split("\n").filter(Boolean).slice(-lines).join("\n"); - } catch { - return null; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function readNodeErrnoCode(error: unknown): string | undefined { - if (typeof error !== "object" || error === null || !("code" in error)) { - return undefined; - } - - return typeof error.code === "string" ? error.code : undefined; -} - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "EPERM") { - return true; - } - return false; - } -} - -function signalProcess(pid: number, signal: NodeJS.Signals): boolean { - try { - process.kill(pid, signal); - return true; - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "ESRCH") { - return false; - } - throw err; - } -} - -function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean { - if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) { - return false; - } - - try { - return signalProcess(pid, signal); - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "EPERM") { - return true; - } - throw err; - } -} - -async function signalProcessTreeSafely(pid: number, signal: NodeJS.Signals): Promise { - if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) { - return false; - } - - return new Promise((resolve, reject) => { - treeKill(pid, signal, (err) => { - if (!err) { - resolve(true); - return; - } - - const code = readNodeErrnoCode(err); - if (code === "ESRCH") { - resolve(false); - return; - } - if (code === "EPERM") { - resolve(true); - return; - } - reject(err); + const entry = resolveDaemonRunnerEntry(); + return await startDaemonInstance({ + home: resolvePaseoHome({ PASEO_HOME: options.home }), + command: process.execPath, + args: [...(entry.endsWith(".ts") ? ["--import", "tsx"] : []), entry], + env: process.env, + mode: options.foreground ? "deployment" : "managed", + foreground: options.foreground, + timeoutMs: options.timeoutMs, + signal: abort.signal, + onReady: options.foreground + ? (instance) => + process.stdout.write(`Listening on ${instance.listen} (PID ${instance.pid})\n`) + : undefined, }); - }); -} - -async function signalProcessTreeOrOwnerSafely( - pid: number, - signal: NodeJS.Signals, -): Promise { - try { - return await signalProcessTreeSafely(pid, signal); - } catch { - return signalProcessSafely(pid, signal); - } -} - -async function waitForPidExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - async function poll(): Promise { - if (!isProcessRunning(pid)) return true; - if (Date.now() >= deadline) return !isProcessRunning(pid); - await sleep(PID_POLL_INTERVAL_MS); - return poll(); - } - return poll(); -} - -async function waitForDaemonUnreachable( - state: LocalDaemonState, - timeoutMs: number, -): Promise { - const host = resolveTcpHostFromListen(state.listen); - if (!host) { - return true; - } - - const reachableHost = host; - const deadline = Date.now() + timeoutMs; - async function poll(): Promise { - const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 }); - if (!client) { - return true; - } - await client.close().catch(() => undefined); - if (Date.now() >= deadline) { - const finalClient = await tryConnectToDaemon({ - host: reachableHost, - timeout: PID_POLL_INTERVAL_MS, - }); - if (!finalClient) { - return true; - } - await finalClient.close().catch(() => undefined); - return false; - } - await sleep(PID_POLL_INTERVAL_MS); - return poll(); - } - - return poll(); -} - -function removeStalePidFile(state: LocalDaemonState): void { - if (!state.stalePidFile) { - return; - } - - try { - unlinkSync(state.pidPath); - } catch { - // Best-effort cleanup only. The successful lifecycle stop is authoritative. - } -} - -function createNotRunningStopResult( - state: LocalDaemonState, - pid: number | null, - message: string, -): StopLocalDaemonResult { - return { - action: "not_running", - home: state.home, - pid, - forced: false, - usedLifecycleRpc: false, - reason: "not_running", - message, - }; -} - -function createStopTimeoutError( - state: LocalDaemonState, - pid: number | null, - timeoutMs: number, -): Error { - if (!state.running) { - const host = resolveTcpHostFromListen(state.listen); - return new Error( - `Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil( - timeoutMs / 1000, - )}s`, - ); - } - return new Error( - `Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`, - ); -} - -async function signalDaemonOwnerForStop( - state: LocalDaemonState, - pid: number | null, -): Promise { - if (pid === null) { - return createNotRunningStopResult(state, null, "Daemon is not running"); - } - - const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM"); - if (signaled) { - return null; - } - - return createNotRunningStopResult(state, pid, "Daemon process was already stopped"); -} - -async function waitForStopAfterRequest(args: { - state: LocalDaemonState; - pid: number | null; - timeoutMs: number; - killTimeoutMs: number; - force?: boolean; -}): Promise<{ stopped: boolean; forced: boolean }> { - const { state, pid, timeoutMs, killTimeoutMs, force } = args; - let stopped = - state.running && pid !== null - ? await waitForPidExit(pid, timeoutMs) - : await waitForDaemonUnreachable(state, timeoutMs); - - if (!stopped && force && state.running && pid !== null) { - await signalProcessTreeOrOwnerSafely(pid, "SIGKILL"); - stopped = await waitForPidExit(pid, killTimeoutMs); - return { stopped, forced: true }; - } - - return { stopped, forced: false }; -} - -type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string }; - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function resolveLocalPaseoHome(home?: string): string { - return resolvePaseoHome(envWithHome(home)); -} - -export function resolveTcpHostFromListen(listen: string): string | null { - const normalized = listen.trim(); - if (!normalized) { - return null; - } - - if ( - normalized.startsWith("/") || - normalized.startsWith("unix://") || - normalized.startsWith("pipe://") || - normalized.startsWith("\\\\.\\pipe\\") || - /^[A-Za-z]:[/\\]/.test(normalized) - ) { - return null; - } - - if (/^\d+$/.test(normalized)) { - return `127.0.0.1:${normalized}`; - } - - if (normalized.includes(":")) { - return normalized; - } - - return null; -} - -export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState { - const env: NodeJS.ProcessEnv = { - ...envWithHome(options.home), - // Status should reflect local persisted config + pid file, not inherited daemon env overrides. - // This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available. - PASEO_LISTEN: undefined, - PASEO_HOSTNAMES: undefined, - PASEO_ALLOWED_HOSTS: undefined, - PASEO_RELAY_ENABLED: undefined, - PASEO_RELAY_ENDPOINT: undefined, - PASEO_RELAY_PUBLIC_ENDPOINT: undefined, - PASEO_RELAY_USE_TLS: undefined, - PASEO_RELAY_PUBLIC_USE_TLS: undefined, - }; - const home = resolvePaseoHome(env); - const config = loadConfig(home, { env }); - const pidPath = pidFilePath(home); - const logPath = path.join(home, DAEMON_LOG_FILENAME); - const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null; - const running = pidInfo ? isProcessRunning(pidInfo.pid) : false; - const listen = pidInfo?.listen ?? config.listen; - - return { - home, - listen, - relayEnabled: config.relayEnabled ?? true, - relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443", - relayUseTls: config.relayUseTls ?? false, - relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false, - logPath, - pidPath, - pidInfo, - running, - stalePidFile: Boolean(pidInfo) && !running, - }; -} - -export function tailDaemonLog(home?: string, lines = 30): string | null { - const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME); - return tailFile(logPath, lines); -} - -export async function startLocalDaemonDetached( - options: DaemonStartOptions, - runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime, -): Promise { - if (options.listen && options.port) { - throw new Error("Cannot use --listen and --port together"); - } - - const daemonRunnerEntry = runtime.resolveRunnerEntry(); - const childEnv = buildChildEnv(options); - - const paseoHome = runtime.resolveHome(childEnv); - const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME); - const child = runtime.spawnDetached( - process.execPath, - [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], - { - detached: true, - envMode: "internal", - env: childEnv, - stdio: ["ignore", "ignore", "ignore"], - }, - ); - - child.unref(); - - const startup = await new Promise((resolve) => { - let settled = false; - - const finish = (value: DetachedStartupResult) => { - if (settled) return; - settled = true; - resolve(value); - }; - - const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS); - - child.once("error", (error) => { - clearTimeout(timer); - finish(startupExited({ code: null, signal: null, error })); - }); - - child.once("exit", (code, signal) => { - clearTimeout(timer); - finish(startupExited({ code, signal })); - }); - }); - - if (startup.exitedEarly) { - const reason = startup.error - ? startup.error.message - : `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`; - const recentLogs = tailFile(logPath); - throw new Error( - [ - `Daemon failed to start in background (${reason}).`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join("\n\n"), - ); - } - - return { - pid: child.pid ?? null, - logPath, - }; -} - -export function startLocalDaemonForeground( - options: DaemonStartOptions, - runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime, -): number { - if (options.listen && options.port) { - throw new Error("Cannot use --listen and --port together"); - } - - const daemonRunnerEntry = runtime.resolveRunnerEntry(); - const childEnv = buildChildEnv(options); - const result = runtime.spawnForeground( - process.execPath, - [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], - { - env: childEnv, - stdio: "inherit", - }, - ); - - if (result.error) { - throw result.error; - } - - return result.status ?? 1; -} - -async function requestLifecycleShutdown( - state: LocalDaemonState, - timeoutMs: number, -): Promise { - const host = resolveTcpHostFromListen(state.listen); - if (!host) { - return { - requested: false, - reason: "daemon listen target is not TCP, falling back to owner PID signal", - }; - } - - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); - const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) }); - if (!client) { - return { - requested: false, - reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`, - }; - } - - try { - await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) }); - return { requested: true }; - } catch (error) { - return { - requested: false, - reason: `daemon lifecycle shutdown request failed (${getErrorMessage( - error, - )}), falling back to owner PID signal`, - }; } finally { - await client.close().catch(() => undefined); + process.removeListener("SIGINT", cancel); + process.removeListener("SIGTERM", cancel); } } -export async function stopLocalDaemon( - options: StopLocalDaemonOptions = {}, -): Promise { - const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; - const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS; - const state = resolveLocalDaemonState({ home: options.home }); - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); +export function parseTimeoutMs(raw: unknown, fallback = 600_000): number { + if (raw === undefined) return fallback; + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) + throw { code: "INVALID_TIMEOUT", message: "Timeout must be a positive number of seconds." }; + return Math.ceil(seconds * 1000); +} - const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs()); - const lifecycleRequested = shutdownAttempt.requested; +const REMOVED_LAUNCH_FLAGS: Record = { + "--port ": "daemon.listen", + "--listen ": "daemon.listen", + "--relay": "daemon.relay.enabled", + "--no-relay": "daemon.relay.enabled", + "--relay-use-tls": "daemon.relay.useTls", + "--no-mcp": "daemon.mcp.enabled", + "--no-inject-mcp": "daemon.mcp.injectIntoAgents", + "--web-ui": "features.webUi.enabled", + "--no-web-ui": "features.webUi.enabled", + "--hostnames ": "daemon.hostnames", + "--allowed-hosts ": "daemon.hostnames", + "--foreground": "", +}; - if (!state.pidInfo || (!state.running && !lifecycleRequested)) { - const staleSuffix = - state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : ""; - return createNotRunningStopResult( - state, - state.pidInfo?.pid ?? null, - `Daemon is not running${staleSuffix}`, - ); - } - - const pid = state.pidInfo?.pid ?? null; - const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason; - if (!lifecycleRequested) { - const notRunningResult = await signalDaemonOwnerForStop(state, pid); - if (notRunningResult) return notRunningResult; - } - - const { stopped, forced } = await waitForStopAfterRequest({ - state, - pid, - timeoutMs: remainingTimeoutMs(), - killTimeoutMs, - force: options.force, +export function rejectRemovedLaunchFlags(command: Command): Command { + for (const flag of Object.keys(REMOVED_LAUNCH_FLAGS)) + command.addOption(new Option(flag).hideHelp()); + command.hook("preAction", () => { + for (const [flag, configPath] of Object.entries(REMOVED_LAUNCH_FLAGS)) { + const name = new Option(flag).attributeName(); + if (command.getOptionValueSource(name) !== "cli") continue; + throw { + code: "REMOVED_LAUNCH_OPTION", + message: `${flag.split(" ")[0]} was removed. ${configPath ? `Use paseo daemon config set ${configPath} --home , then start or restart.` : "Use paseo daemon run --home for foreground deployment."} Deployment environment overrides belong to paseo daemon run.`, + }; + } }); - if (!stopped) { - throw createStopTimeoutError(state, pid, timeoutMs); - } - - if (lifecycleRequested) { - removeStalePidFile(state); - } - - return { - action: "stopped", - home: state.home, - pid, - forced, - usedLifecycleRpc: lifecycleRequested, - reason: resolveStopReason(forced, lifecycleRequested), - message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage), - }; + return command; } diff --git a/packages/cli/src/commands/daemon/pair.test.ts b/packages/cli/src/commands/daemon/pair.test.ts index 79117ee16a..42be1a972a 100644 --- a/packages/cli/src/commands/daemon/pair.test.ts +++ b/packages/cli/src/commands/daemon/pair.test.ts @@ -1,139 +1,28 @@ -import { describe, expect, test, vi } from "vitest"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "vitest"; +import { resolveLocalPairingOffer } from "./pair.js"; -import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js"; - -const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null }; -const enabledOffer: PairingOffer = { - relayEnabled: true, - url: "https://app.paseo.sh/#offer=test", - qr: null, -}; - -interface RecordedPairCommandOutput extends PairCommandOutput { - stdout: string[]; - stderr: string[]; - successes: string[]; - exitCode: number | undefined; -} - -function createRecordedOutput(): RecordedPairCommandOutput { - return { - columns: 80, - stdout: [], - stderr: [], - successes: [], - exitCode: undefined, - writeStdout(message) { - this.stdout.push(message); - }, - writeStderr(message) { - this.stderr.push(message); - }, - setExitCode(code) { - this.exitCode = code; - }, - success(message) { - this.successes.push(message); - }, - }; -} - -describe("daemon pair workflow", () => { - test("interactive decline prints direct guidance and creates no pairing output", async () => { - const resolveOffer = vi.fn(async () => disabledOffer); - const confirmRelay = vi.fn(async () => false); - const printDirectGuidance = vi.fn(); - const output = createRecordedOutput(); - - await runPairCommand( - {}, - { resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output }, - ); - - expect(confirmRelay).toHaveBeenCalledOnce(); - expect(printDirectGuidance).toHaveBeenCalledOnce(); - expect(output.stderr.join("")).toContain("No pairing QR was created"); - expect(output.exitCode).toBe(1); - }); - - test("interactive consent enables relay and prints the refreshed offer", async () => { - const resolveOffer = vi - .fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise>() - .mockResolvedValueOnce(disabledOffer) - .mockResolvedValueOnce(enabledOffer); - const output = createRecordedOutput(); - - await runPairCommand( - {}, - { - resolveOffer, - confirmRelay: async () => true, - printDirectGuidance: vi.fn(), - isInteractive: () => true, - output, - }, - ); - - expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true })); - expect(output.stdout.join("")).toContain(enabledOffer.url ?? ""); - expect(output.successes).toEqual(["Relay enabled"]); - }); - - test("JSON mode never prompts and returns a structured relay-disabled error", async () => { - const confirmRelay = vi.fn(async () => true); - const output = createRecordedOutput(); - - await runPairCommand( - { json: true }, - { - resolveOffer: async () => disabledOffer, - confirmRelay, - printDirectGuidance: vi.fn(), - isInteractive: () => true, - output, - }, - ); - - expect(confirmRelay).not.toHaveBeenCalled(); - expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"'); - expect(output.exitCode).toBe(1); - }); - - test("explicit relay opts in without prompting", async () => { - const resolveOffer = vi.fn(async () => enabledOffer); - const confirmRelay = vi.fn(async () => false); - const output = createRecordedOutput(); - - await runPairCommand( - { relay: true, json: true }, - { - resolveOffer, - confirmRelay, - printDirectGuidance: vi.fn(), - isInteractive: () => false, - output, - }, - ); - - expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true })); - expect(confirmRelay).not.toHaveBeenCalled(); - expect(output.exitCode).toBeUndefined(); - }); - - test("surfaces launch-override rejection", async () => { - await expect( - runPairCommand( - { relay: true }, - { - resolveOffer: async () => { - throw new Error("Relay is controlled by a daemon launch override"); - }, - confirmRelay: vi.fn(), - printDirectGuidance: vi.fn(), - isInteractive: () => false, - output: createRecordedOutput(), - }, - ), - ).rejects.toThrow("launch override"); - }); +test("offline pairing requires relay consent and saves it in the selected home", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-offline-pair-")); + const home = path.join(root, "home"); + try { + expect(await resolveLocalPairingOffer({ paseoHome: home })).toMatchObject({ + relayEnabled: false, + url: null, + }); + expect(existsSync(home)).toBe(false); + const offer = await resolveLocalPairingOffer({ paseoHome: home, enableRelay: true }); + expect(offer.relayEnabled).toBe(true); + expect(offer.url).toContain("offer="); + expect( + JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).daemon.relay.enabled, + ).toBe(true); + expect(existsSync(path.join(home, "server-id"))).toBe(true); + expect(existsSync(path.join(home, "daemon-keypair.json"))).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } }); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index 366366b76c..1d9e090ee8 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -3,29 +3,23 @@ import { Command } from "commander"; import chalk from "chalk"; import { generateLocalPairingOffer, - getOrCreateServerId, - loadConfig, - resolvePaseoHome, + readDaemonInstance, + readPersistedConfig, + editPersistedConfig, + resolveConfigFromPersisted, } from "@getpaseo/server"; -import { tryConnectToDaemon } from "../../utils/client.js"; -import { resolveLocalDaemonState } from "./local-daemon.js"; -import { addJsonOption } from "../../utils/command-options.js"; +import { connectToDaemon } from "../../utils/client.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js"; import { formatPairingInstructions } from "../../output/pairing.js"; interface PairOptions { + daemonTarget: DaemonTarget; home?: string; json?: boolean; relay?: boolean; } -export interface PairCommandDependencies { - resolveOffer: typeof resolveLocalPairingOffer; - confirmRelay: typeof confirmRelayPairing; - printDirectGuidance: typeof printDirectConnectionGuidance; - isInteractive: () => boolean; - output: PairCommandOutput; -} - export interface PairCommandOutput { columns: number | undefined; writeStdout(message: string): void; @@ -62,33 +56,32 @@ function createProcessOutput(): PairCommandOutput { } export function pairCommand(): Command { - return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") + return addJsonAndDaemonHostOptions( + new Command("pair").description("Print the daemon pairing QR code and link"), + ) .option("--relay", "Enable relay without prompting") - .action(async (_options: PairOptions, command: Command) => { - await runPairCommand(command.optsWithGlobals()); - }); + .action( + withGlobalOptions((options: PairOptions, _command: Command) => runPairCommand(options)), + ); } export async function resolveLocalPairingOffer(options: { paseoHome: string; enableRelay?: boolean; }): Promise { - const state = resolveLocalDaemonState({ home: options.paseoHome }); - const serverId = getOrCreateServerId(state.home); - const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay); - if (daemonOffer) return daemonOffer; - - if (state.running) { - throw new Error( - "The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.", + const instance = await readDaemonInstance(options.paseoHome); + if (instance) + return resolveDaemonPairingOffer( + { kind: "instance", home: options.paseoHome }, + options.enableRelay, ); - } - - const config = loadConfig(options.paseoHome); - if (options.enableRelay && !config.relayEnabled) { - throw new Error("Start the daemon before enabling relay for pairing."); - } + if (options.enableRelay) + editPersistedConfig(options.paseoHome, "daemon.relay.enabled", { value: true }); + const config = resolveConfigFromPersisted( + options.paseoHome, + readPersistedConfig(options.paseoHome, { defaultsIfMissing: true }), + { env: {} }, + ); return generateLocalPairingOffer({ paseoHome: options.paseoHome, @@ -103,23 +96,16 @@ export async function resolveLocalPairingOffer(options: { } async function resolveDaemonPairingOffer( - listen: string, - expectedServerId: string, + target: DaemonTarget, enableRelay: boolean | undefined, -): Promise { - const client = await tryConnectToDaemon({ - host: listen, +): Promise { + const client = await connectToDaemon({ + target, timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, }); - if (!client) return null; try { const serverInfo = client.getLastServerInfoMessage(); - if (serverInfo?.serverId.trim() !== expectedServerId) { - throw new Error( - "The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.", - ); - } if (serverInfo?.features?.daemonStatusRpc !== true) { throw new Error("Update the Paseo daemon before pairing from this command."); } @@ -132,9 +118,14 @@ async function resolveDaemonPairingOffer( throw new Error("Update the Paseo daemon before enabling relay from this command."); } await client.patchDaemonConfig({ relay: { enabled: true } }); - offer = await client.getDaemonPairingOffer({ - timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, - }); + try { + offer = await client.getDaemonPairingOffer({ timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS }); + } catch (error) { + throw new Error( + `Relay configuration was saved, but fetching the pairing offer failed: ${String(error)}`, + { cause: error }, + ); + } } return { relayEnabled: offer.relayEnabled, @@ -164,40 +155,22 @@ export function printDirectConnectionGuidance(): void { console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`); } -export async function runPairCommand( - options: PairOptions, - dependencyOverrides: Partial = {}, -): Promise { - if (options.home) process.env.PASEO_HOME = options.home; - const dependencies: PairCommandDependencies = { - resolveOffer: resolveLocalPairingOffer, - confirmRelay: confirmRelayPairing, - printDirectGuidance: printDirectConnectionGuidance, - isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY), - output: createProcessOutput(), - ...dependencyOverrides, - }; +export async function runPairCommand(options: PairOptions): Promise { + const output = createProcessOutput(); + const target = options.daemonTarget; + const resolveOffer = (enableRelay: boolean) => + target.kind === "instance" + ? resolveLocalPairingOffer({ paseoHome: target.home, enableRelay }) + : resolveDaemonPairingOffer(target, enableRelay); + const offline = target.kind === "instance" && !(await readDaemonInstance(target.home)); + const pairing = await resolveOffer(options.relay === true); - const paseoHome = resolvePaseoHome(); - let pairing = await dependencies.resolveOffer({ - paseoHome, - enableRelay: options.relay === true, - }); + if (offline) + output.writeStderr( + `Offline pairing offer. Start with: paseo daemon start --home ${JSON.stringify(target.kind === "instance" ? target.home : "")}\n`, + ); - const canPrompt = dependencies.isInteractive() && options.json !== true; - if (!pairing.relayEnabled && canPrompt) { - const shouldEnable = await dependencies.confirmRelay(); - if (!shouldEnable) { - dependencies.printDirectGuidance(); - dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`); - dependencies.output.setExitCode(1); - return; - } - pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true }); - dependencies.output.success("Relay enabled"); - } - - outputPairingResult(pairing, options, dependencies.output); + outputPairingResult(pairing, options, output); } function outputPairingResult( diff --git a/packages/cli/src/commands/daemon/reload.test.ts b/packages/cli/src/commands/daemon/reload.test.ts index 518d02c799..a97400406e 100644 --- a/packages/cli/src/commands/daemon/reload.test.ts +++ b/packages/cli/src/commands/daemon/reload.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest"; import { render } from "../../output/render.js"; import { daemonReloadSchema, type DaemonReloadResult } from "./reload.js"; +const restartCommand = 'paseo daemon restart --home "/selected home"'; + function result(data: DaemonReloadResult) { return { type: "single" as const, data, schema: daemonReloadSchema }; } @@ -11,6 +13,7 @@ describe("daemon reload output", () => { expect( render( result({ + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: ["app.baseUrl"], @@ -23,7 +26,7 @@ describe("daemon reload output", () => { "Warning: These changes require a daemon restart:", " daemon.listen", "", - "Run: paseo daemon restart", + `Run: ${restartCommand}`, "", "Warning: These settings are controlled by daemon launch overrides:", " app.baseUrl", @@ -34,6 +37,7 @@ describe("daemon reload output", () => { test("human output omits restart guidance for a live-only reload", () => { const output = render( result({ + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -45,6 +49,7 @@ describe("daemon reload output", () => { test("structured output preserves the daemon result", () => { const data = { + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: [], diff --git a/packages/cli/src/commands/daemon/reload.ts b/packages/cli/src/commands/daemon/reload.ts index 4f523be26a..807bd26789 100644 --- a/packages/cli/src/commands/daemon/reload.ts +++ b/packages/cli/src/commands/daemon/reload.ts @@ -1,8 +1,12 @@ -import type { Command } from "commander"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { withOutput } from "../../output/index.js"; +import { Command } from "commander"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js"; export interface DaemonReloadResult { + restartCommand: string; appliedPaths: string[]; restartRequiredPaths: string[]; overrideControlledPaths: string[]; @@ -20,7 +24,7 @@ export const daemonReloadSchema: OutputSchema = { "Warning: These changes require a daemon restart:", ...result.data.restartRequiredPaths.map((path) => ` ${path}`), "", - "Run: paseo daemon restart", + `Run: ${result.data.restartCommand}`, ); } if (result.data.overrideControlledPaths.length > 0) { @@ -38,12 +42,13 @@ export async function runDaemonReloadCommand( options: CommandOptions, _command: Command, ): Promise> { - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const payload = await client.reloadDaemonConfig(); return { type: "single", data: { + restartCommand: `paseo daemon restart ${options.daemonTarget.kind === "instance" ? `--home ${JSON.stringify(options.daemonTarget.home)}` : `--host ${JSON.stringify(describeDaemonTarget(options.daemonTarget))}`}`, appliedPaths: payload.appliedPaths, restartRequiredPaths: payload.restartRequiredPaths, overrideControlledPaths: payload.overrideControlledPaths, @@ -54,3 +59,9 @@ export async function runDaemonReloadCommand( await client.close(); } } + +export function daemonReloadCommand(): Command { + return addJsonAndDaemonHostOptions( + new Command("reload").description("Reload config.json without restarting"), + ).action(withOutput(runDaemonReloadCommand)); +} diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 94bf1e0989..5394fc4b53 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -1,131 +1,122 @@ -import type { Command } from "commander"; -import { - startLocalDaemonDetached, - stopLocalDaemon, - DEFAULT_STOP_TIMEOUT_MS, - type DaemonStartOptions, -} from "./local-daemon.js"; -import type { - CommandOptions, - SingleResult, - OutputSchema, - CommandError, -} from "../../output/index.js"; +import { Command } from "commander"; +import { readDaemonInstance, isSameDaemonInstance, DaemonInstanceError } from "@getpaseo/server"; +import { setTimeout as delay } from "node:timers/promises"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; +import { parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js"; -interface RestartResult { - action: "restarted"; - home: string; - pid: string; - message: string; +export function daemonRestartCommand(): Command { + return rejectRemovedLaunchFlags( + addJsonAndDaemonHostOptions( + new Command("restart").description( + "Restart the selected daemon worker, retaining its supervisor launch", + ), + ), + ) + .option("--timeout ", "Replacement readiness deadline (default: 600)") + .action(withOutput(runRestartCommand)); } -const restartResultSchema: OutputSchema = { - idField: "action", - columns: [ - { - header: "STATUS", - field: "action", - color: () => "green", - }, - { header: "HOME", field: "home" }, - { header: "PID", field: "pid" }, - { header: "MESSAGE", field: "message" }, - ], -}; - -export type RestartCommandResult = SingleResult; - -function parseTimeoutMs(raw: unknown): number { - if (typeof raw !== "string" || raw.trim().length === 0) { - return DEFAULT_STOP_TIMEOUT_MS; +export async function runRestartCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const deadline = Date.now() + parseTimeoutMs(options.timeout); + const remaining = () => Math.max(1, deadline - Date.now()); + const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + async function checkSupervisor() { + if (target.kind !== "instance") return; + const current = await readDaemonInstance(target.home); + if (!current || !instance || !isSameDaemonInstance(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor exited or was replaced for ${target.home}.`, + ); } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - const error: CommandError = { - code: "INVALID_TIMEOUT", - message: `Invalid timeout value: ${raw}`, - details: "Timeout must be a positive number of seconds", - }; - throw error; - } - - return Math.ceil(seconds * 1000); -} - -function toStartOptions(options: CommandOptions): DaemonStartOptions { - const startOptions: DaemonStartOptions = { - home: typeof options.home === "string" ? options.home : undefined, - listen: typeof options.listen === "string" ? options.listen : undefined, - port: typeof options.port === "string" ? options.port : undefined, - relay: typeof options.relay === "boolean" ? options.relay : undefined, - mcp: typeof options.mcp === "boolean" ? options.mcp : undefined, - injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined, - webUi: typeof options.webUi === "boolean" ? options.webUi : undefined, - hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined, - }; - - if (startOptions.listen && startOptions.port) { - const error: CommandError = { - code: "INVALID_OPTIONS", - message: "Cannot use --listen and --port together", - }; - throw error; - } - - return startOptions; -} - -export async function runRestartCommand( - options: CommandOptions, - _command: Command, -): Promise { - const timeoutMs = parseTimeoutMs(options.timeout); - const force = options.force === true; - const startOptions = toStartOptions(options); - + if (target.kind === "instance" && !instance) + throw new DaemonInstanceError( + "DAEMON_NOT_RUNNING", + `Daemon is not running for ${target.home}.`, + ); + const client = await connectToDaemon({ + target, + instance: instance ?? undefined, + timeout: remaining(), + }); + let workerPid: number; + const serverId = client.getLastServerInfoMessage()?.serverId; + let acknowledged = false; try { - let stopResult: Awaited>; + workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid; + await checkSupervisor(); try { - stopResult = await stopLocalDaemon({ - home: startOptions.home, - timeoutMs, - force, - }); - } catch (err) { - const isTimeout = - err instanceof Error && err.message.includes("Timed out waiting for daemon PID"); - if (!force && isTimeout) { - stopResult = await stopLocalDaemon({ - home: startOptions.home, - timeoutMs, - force: true, - }); - } else { - throw err; - } + await client.restartServer("cli_restart", undefined, { timeout: remaining() }); + acknowledged = true; + } catch (error) { + if (!isReconnectFailure(error)) throw error; } - - const startup = await startLocalDaemonDetached(startOptions); - const before = stopResult.pid === null ? "not running" : `PID ${stopResult.pid}`; - const after = startup.pid === null ? "unknown PID" : `PID ${startup.pid}`; - - return { - type: "single", - data: { - action: "restarted", - home: stopResult.home, - pid: startup.pid === null ? "-" : String(startup.pid), - message: `Local daemon restarted (${before} -> ${after})`, - }, - schema: restartResultSchema, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "RESTART_FAILED", - message: `Failed to restart local daemon: ${message}`, - }; - throw error; + } finally { + await client.close(); } + let lastError: unknown = "No replacement worker observed"; + while (Date.now() < deadline) { + try { + const replacement = await connectToDaemon({ + target, + instance: instance ?? undefined, + timeout: Math.min(1_000, remaining()), + }); + try { + if (replacement.getLastServerInfoMessage()?.serverId !== serverId) + throw new Error("Connected peer identity changed"); + const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, remaining()) }); + if (status.pid !== workerPid) { + await checkSupervisor(); + return { + type: "single" as const, + data: { + action: "restarted", + target: describeDaemonTarget(target), + supervisorPid: instance?.pid ?? null, + previousWorkerPid: workerPid, + workerPid: status.pid, + acknowledged, + }, + schema: { + idField: "action" as const, + columns: [], + renderHuman: () => + `Restarted worker ${workerPid} → ${status.pid} at ${describeDaemonTarget(target)}. Supervisor launch retained.`, + }, + }; + } + } finally { + await replacement.close(); + } + } catch (error) { + lastError = error; + const code = (error as { code?: string } | null)?.code; + if (code === "DAEMON_REPLACED" || code === "DAEMON_NOT_RUNNING") break; + if (!isReconnectFailure(error)) throw error; + } + await delay(Math.min(100, remaining())); + } + throw { + code: "RESTART_NOT_CONFIRMED", + message: `Replacement was not confirmed for ${describeDaemonTarget(target)}. Restart acknowledged: ${acknowledged}. Last observation: ${String(lastError)}`, + }; +} + +function isReconnectFailure(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + [ + "DAEMON_CONNECTION_LOST", + "DAEMON_REQUEST_TIMEOUT", + "DAEMON_UNREACHABLE", + "DAEMON_NOT_READY", + ].includes(String(error.code)), + ); } diff --git a/packages/cli/src/commands/daemon/set-password.ts b/packages/cli/src/commands/daemon/set-password.ts index 3fd407f587..cf190327e0 100644 --- a/packages/cli/src/commands/daemon/set-password.ts +++ b/packages/cli/src/commands/daemon/set-password.ts @@ -3,7 +3,8 @@ import type { Command } from "commander"; import { isCancel, password as passwordPrompt } from "@clack/prompts"; import { hashDaemonPassword, - loadPersistedConfig, + readPersistedConfig, + resolvePaseoHome, savePersistedConfig, type PersistedConfig, } from "@getpaseo/server"; @@ -14,7 +15,6 @@ import type { OutputSchema, SingleResult, } from "../../output/index.js"; -import { resolveLocalPaseoHome } from "./local-daemon.js"; const CONFIG_FILENAME = "config.json"; @@ -81,9 +81,9 @@ export async function setDaemonPasswordInConfig( newPassword: string, options: SetPasswordOptions = {}, ): Promise { - const paseoHome = resolveLocalPaseoHome(options.home); + const paseoHome = resolvePaseoHome({ PASEO_HOME: options.home }); const configPath = path.join(paseoHome, CONFIG_FILENAME); - const persisted = loadPersistedConfig(paseoHome); + const persisted = readPersistedConfig(paseoHome); const nextConfig: PersistedConfig = { ...persisted, daemon: { @@ -100,8 +100,8 @@ export async function setDaemonPasswordInConfig( return { action: "password_set", configPath, - restartCommand: "paseo daemon restart", - message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart`, + restartCommand: `paseo daemon restart --home ${JSON.stringify(paseoHome)}`, + message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart --home ${JSON.stringify(paseoHome)}`, }; } @@ -115,7 +115,7 @@ export async function runSetPasswordCommand( : (message: string) => passwordPrompt({ message }); const newPassword = await promptForPassword(promptPassword); const result = await setDaemonPasswordInConfig(newPassword, { - home: typeof options.home === "string" ? options.home : undefined, + home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined, }); return { diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 3a4c3df44d..ae36e0eed0 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,71 +1,55 @@ -import { Command, Option } from "commander"; -import chalk from "chalk"; -import { - startLocalDaemonForeground, - startLocalDaemonDetached, - type DaemonStartOptions as StartOptions, -} from "./local-daemon.js"; -import { getErrorMessage } from "../../utils/errors.js"; - -export type { DaemonStartOptions as StartOptions } from "./local-daemon.js"; - -type RawStartCommandOptions = StartOptions & { - allowedHosts?: string; -}; +import { addLocalDaemonOptions } from "../../utils/command-options.js"; +import { Command } from "commander"; +import { daemonLogPath } from "@getpaseo/server"; +import { launchLocalDaemon, parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; export function startCommand(): Command { - return new Command("start") - .description("Start the local Paseo daemon") - .option("--listen ", "Listen target (host:port, port, or unix socket path)") - .option("--port ", "Port to listen on (default: 6767)") - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--foreground", "Run in foreground (don't daemonize)") - .option("--relay", "Enable relay connection") - .option("--no-relay", "Disable relay connection") - .option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers") - .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") - .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") - .option("--web-ui", "Enable the bundled daemon web UI") - .option("--no-web-ui", "Disable the bundled daemon web UI") - .option( - "--hostnames ", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts ").hideHelp()) - .action(async (options: RawStartCommandOptions) => { - await runStart({ - ...options, - hostnames: options.hostnames ?? options.allowedHosts, - }); - }); + return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("start"))) + .description("Start the local daemon from persistent configuration (local operation)") + .option("--timeout ", "Readiness deadline (default: 600)") + .action(withOutput(runStart)); } -export async function runStart(options: StartOptions): Promise { - if (options.listen && options.port) { - console.error(chalk.red("Cannot use --listen and --port together")); - process.exit(1); - } - - if (!options.foreground) { - try { - const startup = await startLocalDaemonDetached(options); - console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? "unknown"}).`)); - console.log(chalk.dim(`Logs: ${startup.logPath}`)); - } catch (err) { - exitWithError(getErrorMessage(err)); - } - return; - } - try { - const status = startLocalDaemonForeground(options); - process.exit(status); - } catch (err) { - const message = getErrorMessage(err); - exitWithError(`Failed to start daemon: ${message}`); - } +export async function runStart(options: CommandOptions, _command: Command) { + if (options.daemonTarget.kind !== "instance") throw new Error("Start requires a local home"); + const home = options.daemonTarget.home; + const result = await launchLocalDaemon({ home, timeoutMs: parseTimeoutMs(options.timeout) }); + const data = { + action: result.spawned ? "started" : "already_running", + home, + pid: result.instance.pid, + listen: result.instance.listen, + logPath: daemonLogPath(home), + }; + return { + type: "single" as const, + data, + schema: { + idField: "pid" as const, + columns: [], + renderHuman: () => + `${result.spawned ? "Started" : "Already running"}: PID ${data.pid}${data.listen ? `, listening on ${data.listen}` : ", not ready"}\nLogs: ${data.logPath}`, + }, + }; } -function exitWithError(message: string): never { - console.error(chalk.red(message)); - process.exit(1); +export function daemonRunCommand(): Command { + return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("run"))) + .description("Run a local daemon in the foreground with deployment environment overrides") + .action( + withOutput(async (options: CommandOptions, _command: Command) => { + if (options.daemonTarget.kind !== "instance") throw new Error("Run requires a local home"); + const result = await launchLocalDaemon({ + home: options.daemonTarget.home, + foreground: true, + }); + process.exitCode = result.exitCode ?? 0; + return { + type: "single" as const, + data: { pid: result.instance.pid, action: result.spawned ? "exited" : "already_running" }, + schema: { idField: "pid" as const, columns: [] }, + }; + }), + ); } diff --git a/packages/cli/src/commands/daemon/status.test.ts b/packages/cli/src/commands/daemon/status.test.ts deleted file mode 100644 index dd900f902f..0000000000 --- a/packages/cli/src/commands/daemon/status.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { selectRelayStatus } from "./status.js"; - -describe("selectRelayStatus", () => { - const persisted = { - enabled: false, - endpoint: "persisted.internal:443", - publicEndpoint: "persisted.example.com:443", - useTls: true, - publicUseTls: true, - }; - - test("uses the running daemon relay state over persisted config", () => { - expect( - selectRelayStatus({ - persisted, - live: { - enabled: true, - endpoint: "live.internal:443", - publicEndpoint: "live.example.com:443", - useTls: true, - publicUseTls: true, - }, - }), - ).toBe("wss://live.example.com:443"); - }); - - test("falls back to persisted config when the daemon cannot report live state", () => { - expect(selectRelayStatus({ persisted })).toBe("disabled"); - }); -}); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 87e34f0bcf..c44e01524e 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,464 +1,134 @@ -import type { Command } from "commander"; -import { createRequire } from "node:module"; -import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server"; -import { connectToDaemon } from "../../utils/client.js"; -import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; -import { resolveLocalDaemonState } from "./local-daemon.js"; -import { resolveNodePathFromPid } from "./runtime-toolchain.js"; +import { Command } from "commander"; +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; +import { + readDaemonInstance, + readPersistedConfig, + resolveConfigFromPersisted, + daemonLogPath, + isSameDaemonInstance, + DaemonInstanceError, +} from "@getpaseo/server"; +import { connectToDaemon, buildDaemonConnectionCommandError } from "../../utils/client.js"; +import { withOutput, toCommandError, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget, type DaemonTarget } from "../../utils/daemon-target.js"; -const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500; - -interface ProviderBinaryStatus { - label: string; - path: string | null; - version: string | null; - source?: "daemon" | "local"; +export function daemonStatusCommand(): Command { + return addJsonAndDaemonHostOptions( + new Command("status").description("Observe the selected daemon and its published endpoint"), + ).action(withOutput(runStatusCommand)); } -interface DaemonStatus { - serverId: string | null; - localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive"; - connectedDaemon: "reachable" | "unreachable" | "auth_required" | "auth_failed" | "not_probed"; - home: string; - listen: string; - relay: string; - hostname: string | null; - pid: number | null; - startedAt: string | null; - owner: string | null; - logPath: string; - daemonNode: string; - cliNode: string; - cliVersion: string; - daemonVersion: string | null; - desktopManaged: boolean; - providers: ProviderBinaryStatus[]; - note?: string; -} - -interface StatusRow { - key: string; - value: string; -} - -interface CliPackageJson { - version?: unknown; -} - -const require = createRequire(import.meta.url); - -function normalizeError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function shortenMessage(message: string, max = 120): string { - const normalized = message.replace(/\s+/g, " ").trim(); - if (normalized.length <= max) { - return normalized; - } - return `${normalized.slice(0, max - 3)}...`; -} - -function appendNote(current: string | undefined, next: string | undefined): string | undefined { - if (!next) return current; - if (!current) return next; - return `${current}; ${next}`; -} - -function resolveCliVersion(): string { - try { - const packageJson = require("../../../package.json") as CliPackageJson; - if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) { - return packageJson.version.trim(); - } - } catch { - // Fall through. - } - return "unknown"; -} - -function createStatusSchema(status: DaemonStatus): OutputSchema { +export async function runStatusCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + const local = + target.kind === "instance" + ? localStatus(target.home, instance) + : { host: describeDaemonTarget(target) }; + const observed = + target.kind === "endpoint" || instance?.listen + ? await probeDaemonStatus(target, instance, local) + : { connectedDaemon: "not_probed" }; + const data: Record = { ...local, ...observed }; return { - idField: "key", - columns: [ - { header: "KEY", field: "key" }, - { - header: "VALUE", - field: "value", - color: (_, item) => { - if (item.key === "Local Daemon") { - if (item.value === "running") return "green"; - if (item.value === "unresponsive") return "yellow"; - return "red"; - } - if (item.key === "Connected Daemon") { - if (item.value === "reachable") return "green"; - if (item.value === "not_probed" || item.value === "auth_required") return "yellow"; - return "red"; - } - if (item.key.startsWith(" ")) { - if (item.value === "not found" || item.value === "not found (daemon)") return "red"; - if (item.value.endsWith("(--version failed)")) return "yellow"; - return "green"; - } - return undefined; - }, - }, - ], - serialize: () => status, + type: "single" as const, + data, + schema: { + idField: () => "daemon", + columns: [], + renderHuman: () => + Object.entries(data) + .filter(([, value]) => value !== undefined) + .map( + ([key, value]) => + `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`, + ) + .join("\n"), + }, }; } -function toStatusRows(status: DaemonStatus): StatusRow[] { - const rows: StatusRow[] = [ - { key: "Server ID", value: status.serverId ?? "-" }, - { key: "Local Daemon", value: status.localDaemon }, - { key: "Connected Daemon", value: status.connectedDaemon }, - { key: "Home", value: status.home }, - { key: "Listen", value: status.listen }, - { key: "Relay", value: status.relay }, - { key: "Hostname", value: status.hostname ?? "-" }, - { key: "PID", value: status.pid === null ? "-" : String(status.pid) }, - { key: "Started", value: status.startedAt ?? "-" }, - { key: "Owner", value: status.owner ?? "-" }, - { key: "Logs", value: status.logPath }, - { key: "Daemon Node", value: status.daemonNode }, - { key: "CLI Node", value: status.cliNode }, - { key: "CLI", value: status.cliVersion }, - { key: "Daemon Version", value: status.daemonVersion ?? "-" }, - ]; - - if (status.note) { - rows.push({ key: "Note", value: status.note }); - } - - rows.push({ key: "", value: "" }); - rows.push({ key: "Providers", value: "" }); - for (const provider of status.providers) { - if (provider.source === "daemon") { - if (!provider.path) { - rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" }); - } else { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` }); - } - } else if (!provider.path) { - rows.push({ key: ` ${provider.label}`, value: "not found" }); - } else if (!provider.version) { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` }); - } else { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` }); - } - } - - return rows; -} - -const PROVIDER_BINARIES: { label: string; binary: string }[] = [ - { label: "Claude", binary: "claude" }, - { label: "Codex", binary: "codex" }, - { label: "OpenCode", binary: "opencode" }, -]; - -async function checkProviderBinary( - binary: string, -): Promise<{ path: string | null; version: string | null }> { - const binaryPath = await findExecutable(binary); - if (!binaryPath) { - return { path: null, version: null }; - } - try { - const { stdout } = await execCommand(binaryPath, ["--version"], { - timeout: 5000, - }); - return { path: binaryPath, version: stdout.trim() || null }; - } catch { - return { path: binaryPath, version: null }; - } -} - -async function checkProviderBinaries(): Promise { - const results = await Promise.all( - PROVIDER_BINARIES.map(async ({ label, binary }) => { - const result = await checkProviderBinary(binary); - return Object.assign({ label }, result); - }), - ); - return results; -} - -function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null { - if (uid === undefined && !hostname) { - return null; - } - const uidPart = uid === undefined ? "?" : String(uid); - const hostPart = hostname ?? "unknown-host"; - return `${uidPart}@${hostPart}`; -} - -interface DaemonProbeResult { - connectedDaemon: DaemonStatus["connectedDaemon"]; - localDaemonOverride?: DaemonStatus["localDaemon"]; - daemonVersion?: string | null; - daemonNodeOverride?: string; - daemonProviders?: ProviderBinaryStatus[]; - relayStatus?: string; - note?: string; -} - -type DaemonAuthProbeFailure = "auth_required" | "auth_failed"; - -function classifyDaemonAuthProbeFailure(error: unknown): DaemonAuthProbeFailure | null { - if (!(error instanceof Error)) return null; - if (error.message === "Password required") return "auth_required"; - if (error.message === "Incorrect password") return "auth_failed"; - return null; -} - -function describeDaemonAuthProbeFailure(host: string, failure: DaemonAuthProbeFailure): string { - if (failure === "auth_required") { - return `Daemon is reachable at ${host} but requires a password. Set PASEO_PASSWORD and retry.`; - } - return `Daemon is reachable at ${host} but the supplied password was rejected. Check PASEO_PASSWORD and retry.`; -} - -async function probeDaemonOverWebsocket(args: { - host: string; - state: ReturnType; -}): Promise { - const { host, state } = args; - let client: Awaited>; - try { - client = await connectToDaemon({ host, timeout: 1500 }); - } catch (error) { - const authFailure = classifyDaemonAuthProbeFailure(error); - if (authFailure) { - return { - connectedDaemon: authFailure, - note: describeDaemonAuthProbeFailure(host, authFailure), - }; - } - - if (state.running) { - return { - connectedDaemon: "unreachable", - localDaemonOverride: "unresponsive", - note: `Local daemon PID is running but websocket at ${host} is not reachable`, - }; - } - return { connectedDaemon: "unreachable" }; - } - - const daemonVersion = client.getLastServerInfoMessage()?.version ?? null; - try { - const statusPayload = await client.getDaemonStatus({ - timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS, - }); - const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label])); - const daemonProviders = statusPayload.providers.map((p) => ({ - label: labelMap.get(p.provider) ?? p.provider, - path: p.available ? "available" : null, - version: p.available ? null : (p.error ?? null), - source: "daemon" as const, - })); - const relayStatus = - statusPayload.relay == null - ? undefined - : selectRelayStatus({ - persisted: relayConfigFromLocalState(state), - live: statusPayload.relay, - }); - - if (!state.running) { - return { - connectedDaemon: "reachable", - daemonVersion: statusPayload.version ?? daemonVersion, - daemonNodeOverride: statusPayload.nodePath, - daemonProviders, - relayStatus, - note: state.pidInfo - ? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale` - : `Connected daemon is reachable at ${host} but no local daemon PID file was found`, - }; - } - - return { - connectedDaemon: "reachable", - daemonVersion: statusPayload.version ?? daemonVersion, - daemonNodeOverride: statusPayload.nodePath, - daemonProviders, - relayStatus, - }; - } catch { - return { - connectedDaemon: "reachable", - daemonVersion, - note: state.running - ? `Local daemon PID is running but daemon detail request to ${host} failed` - : `Connected daemon websocket is reachable at ${host} but daemon status request failed`, - }; - } finally { - await client.close().catch(() => {}); - } -} - -interface ProbeMergeState { - probe: DaemonProbeResult; - connectedDaemon: DaemonStatus["connectedDaemon"]; - localDaemon: DaemonStatus["localDaemon"]; - daemonNode: string; - daemonVersion: string | null; - daemonProviders: ProviderBinaryStatus[] | undefined; - relayStatus: string; - note: string | undefined; -} - -function applyProbeToStatus(input: ProbeMergeState): Omit { - const { probe } = input; - return { - connectedDaemon: probe.connectedDaemon, - localDaemon: probe.localDaemonOverride ?? input.localDaemon, - daemonNode: probe.daemonNodeOverride ?? input.daemonNode, - daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion, - daemonProviders: probe.daemonProviders ?? input.daemonProviders, - relayStatus: probe.relayStatus ?? input.relayStatus, - note: probe.note ? appendNote(input.note, probe.note) : input.note, - }; -} - -function resolveServerIdSafely(home: string): { serverId: string | null; error: string | null } { - try { - return { serverId: getOrCreateServerId(home), error: null }; - } catch (error) { - return { - serverId: null, - error: `serverId unavailable: ${shortenMessage(normalizeError(error))}`, - }; - } -} - -async function resolveDaemonNodeLabel( - state: ReturnType, -): Promise { - if (!state.running) return "-"; - if (!state.pidInfo?.pid) return "unknown (no PID available)"; - const fromPid = await resolveNodePathFromPid(state.pidInfo.pid); - return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; -} - -interface RelayStatusConfig { - enabled: boolean; - endpoint: string; - publicEndpoint: string; - useTls: boolean; - publicUseTls: boolean; -} - -function relayConfigFromLocalState( - state: ReturnType, -): RelayStatusConfig { - return { - enabled: state.relayEnabled, - endpoint: state.relayEndpoint, - publicEndpoint: state.relayEndpoint, - useTls: state.relayUseTls, - publicUseTls: state.relayPublicUseTls, - }; -} - -export function selectRelayStatus(input: { - persisted: RelayStatusConfig; - live?: RelayStatusConfig; -}): string { - const relay = input.live ?? input.persisted; - if (!relay.enabled) return "disabled"; - const scheme = relay.publicUseTls ? "wss" : "ws"; - return `${scheme}://${relay.publicEndpoint}`; -} - -export type StatusResult = ListResult; - -export async function runStatusCommand( - options: CommandOptions, - _command: Command, -): Promise { - const home = typeof options.home === "string" ? options.home : undefined; - const state = resolveLocalDaemonState({ home }); - const daemonTarget = state.listen.trim(); - - const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname); - let daemonNode = await resolveDaemonNodeLabel(state); - const cliNode = process.execPath; - let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped"; - let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed"; - let daemonVersion: string | null = null; - let daemonProviders: ProviderBinaryStatus[] | undefined; - let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) }); +async function probeDaemonStatus( + target: DaemonTarget, + instance: Awaited>, + local: Record, +) { + let connectedDaemon = "unreachable"; let note: string | undefined; - - if (!state.running && state.stalePidFile && state.pidInfo) { - localDaemon = "stale_pid"; - note = `Stale PID file found for PID ${state.pidInfo.pid}`; + let live: Record = {}; + let client: Awaited> | undefined; + try { + client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 }); + } catch (error) { + const failure = buildDaemonConnectionCommandError({ target, error }); + if (target.kind === "endpoint") throw failure; + connectedDaemon = "unreachable"; + if (failure.code === "AUTH_REQUIRED") connectedDaemon = "auth_required"; + if (failure.code === "AUTH_FAILED") connectedDaemon = "auth_failed"; + note = failure.message; } - - if (daemonTarget) { - const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state }); - ({ - connectedDaemon, - localDaemon, - daemonNode, - daemonVersion, - daemonProviders, - relayStatus, - note, - } = applyProbeToStatus({ - probe, - connectedDaemon, - localDaemon, - daemonNode, - daemonVersion, - daemonProviders, - relayStatus, - note, - })); + if (!client) return { connectedDaemon, note }; + try { + let requestError: unknown; + const status = await client.getDaemonStatus({ timeout: 1_500 }).catch((error: unknown) => { + requestError = error; + return null; + }); + // Recheck even when the RPC times out before combining local and live observations. + const current = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + if (instance && (!current || !isSameDaemonInstance(instance, current))) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + "Supervisor exited or was replaced during status observation.", + ); + const info = client.getLastServerInfoMessage(); + live = { serverId: info?.serverId, daemonVersion: info?.version }; + connectedDaemon = client.isConnected ? "reachable" : "unreachable"; + if (!status) { + const failure = toCommandError(requestError); + note = `Status details unavailable (${failure.code}): ${failure.message}`; + if ( + target.kind === "endpoint" || + !client.isConnected || + !(requestError instanceof DaemonConnectionError) || + requestError.code !== "DAEMON_REQUEST_TIMEOUT" + ) { + throw { ...failure, message: note, details: { ...local, ...live, connectedDaemon } }; + } + } else { + live = { + ...live, + daemonVersion: status.version, + workerPid: status.pid, + daemonNode: status.nodePath, + providers: status.providers, + relay: status.relay, + }; + } + } finally { + await client.close(); } + return { ...live, connectedDaemon, note }; +} - const cliVersion = resolveCliVersion(); - - const serverIdResult = resolveServerIdSafely(state.home); - const serverId = serverIdResult.serverId; - if (serverIdResult.error) { - note = appendNote(note, serverIdResult.error); - } - - const providers = daemonProviders ?? (await checkProviderBinaries()); - - const daemonStatus: DaemonStatus = { - serverId, - localDaemon, - connectedDaemon, - home: state.home, - listen: state.listen, - relay: relayStatus, - hostname: state.pidInfo?.hostname ?? null, - pid: state.pidInfo?.pid ?? null, - startedAt: state.pidInfo?.startedAt ?? null, - owner, - logPath: state.logPath, - daemonNode, - cliNode, - cliVersion, - daemonVersion, - desktopManaged: state.pidInfo?.desktopManaged === true, - providers, - note, - }; - +function localStatus(home: string, instance: Awaited>) { + const config = resolveConfigFromPersisted( + home, + readPersistedConfig(home, { defaultsIfMissing: true }), + { env: {} }, + ); + let localDaemon = "stopped"; + if (instance) localDaemon = instance.listen ? "running" : "not_ready"; return { - type: "list", - data: toStatusRows(daemonStatus), - schema: createStatusSchema(daemonStatus), + home, + pid: instance?.pid ?? null, + startedAt: instance?.startedAt ?? null, + listen: instance?.listen ?? null, + hostname: instance?.hostname ?? null, + configuredListen: config.listen, + localDaemon, + desktopManaged: instance?.desktopManaged === true, + logPath: daemonLogPath(home), }; } diff --git a/packages/cli/src/commands/daemon/stop.ts b/packages/cli/src/commands/daemon/stop.ts index 76351bb07b..a94eab24d4 100644 --- a/packages/cli/src/commands/daemon/stop.ts +++ b/packages/cli/src/commands/daemon/stop.ts @@ -1,94 +1,67 @@ -import type { Command } from "commander"; -import { - stopLocalDaemon, - DEFAULT_STOP_TIMEOUT_MS, - DEFAULT_KILL_TIMEOUT_MS, -} from "./local-daemon.js"; -import type { - CommandOptions, - SingleResult, - OutputSchema, - CommandError, -} from "../../output/index.js"; +import { Command } from "commander"; +import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; +import { parseTimeoutMs } from "./local-daemon.js"; -interface StopResult { - action: "stopped" | "not_running"; - home: string; - pid: string; - forced: boolean; - usedLifecycleRpc: boolean; - reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill"; - message: string; +export function daemonStopCommand(): Command { + return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon")) + .option("--timeout ", "Graceful exit deadline (default: 15)") + .option("--force", "Permit forced local process cleanup") + .option("--kill-timeout ", "Forced exit deadline (default: 3)") + .action(withOutput(runStopCommand)); } -const stopResultSchema: OutputSchema = { - idField: "action", - columns: [ - { - header: "STATUS", - field: "action", - color: (value) => (value === "stopped" ? "green" : "yellow"), +export async function runStopCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const timeoutMs = parseTimeoutMs(options.timeout, 15_000); + const deadline = Date.now() + timeoutMs; + const remaining = () => Math.max(1, deadline - Date.now()); + const requestShutdown = async (instance?: DaemonInstance) => { + const client = await connectToDaemon({ target, timeout: remaining(), instance }); + try { + await client.shutdownServer({ timeout: remaining() }); + } finally { + await client.close(); + } + }; + if (target.kind === "endpoint" && options.force) + throw { + code: "INVALID_OPTIONS", + message: "--force requires a local --home; an endpoint gives no remote process authority.", + }; + const result: { + action: string; + home?: string; + host?: string; + pid?: number | null; + forced?: boolean; + usedLifecycleRpc?: boolean; + } = + target.kind === "instance" + ? { + ...(await stopDaemonInstance(target.home, { + force: options.force === true, + timeoutMs, + killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000), + requestShutdown, + })), + home: target.home, + } + : (await requestShutdown(), + { action: "shutdown_requested", host: describeDaemonTarget(target) }); + return { + type: "single" as const, + data: result, + schema: { + idField: "action" as const, + columns: [], + renderHuman: () => + target.kind === "endpoint" + ? "Shutdown requested; remote process exit was not verified." + : `${result.action.replaceAll("_", " ")}: ${target.home}`, }, - { header: "HOME", field: "home" }, - { header: "PID", field: "pid" }, - { header: "MESSAGE", field: "message" }, - ], -}; - -export type StopCommandResult = SingleResult; - -function parseSecondsOption(raw: unknown, fallbackMs: number, label: string): number { - if (typeof raw !== "string" || raw.trim().length === 0) { - return fallbackMs; - } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - const error: CommandError = { - code: "INVALID_TIMEOUT", - message: `Invalid ${label} value: ${raw}`, - details: `${label} must be a positive number of seconds`, - }; - throw error; - } - - return Math.ceil(seconds * 1000); -} - -export async function runStopCommand( - options: CommandOptions, - _command: Command, -): Promise { - const home = typeof options.home === "string" ? options.home : undefined; - const force = options.force === true; - const timeoutMs = parseSecondsOption(options.timeout, DEFAULT_STOP_TIMEOUT_MS, "timeout"); - const killTimeoutMs = parseSecondsOption( - options.killTimeout, - DEFAULT_KILL_TIMEOUT_MS, - "kill-timeout", - ); - - try { - const result = await stopLocalDaemon({ home, force, timeoutMs, killTimeoutMs }); - return { - type: "single", - data: { - action: result.action, - home: result.home, - pid: result.pid === null ? "-" : String(result.pid), - forced: result.forced, - usedLifecycleRpc: result.usedLifecycleRpc, - reason: result.reason, - message: result.message, - }, - schema: stopResultSchema, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "STOP_FAILED", - message: `Failed to stop local daemon: ${message}`, - }; - throw error; - } + }; } diff --git a/packages/cli/src/commands/heartbeat/index.ts b/packages/cli/src/commands/heartbeat/index.ts index 7ded358e8f..812af3a847 100644 --- a/packages/cli/src/commands/heartbeat/index.ts +++ b/packages/cli/src/commands/heartbeat/index.ts @@ -64,7 +64,7 @@ async function runCreateHeartbeat( if (!cron) { throw new Error("--cron is required"); } - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined; if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) { @@ -105,7 +105,7 @@ async function runUpdateHeartbeat( if (!cron) { throw new Error("--cron is required"); } - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireOwnedHeartbeat(client, id, agentId); const payload = await client.scheduleUpdate({ @@ -133,7 +133,7 @@ async function runDeleteHeartbeat( _command: Command, ): Promise> { const agentId = requireCallerAgentId(); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireOwnedHeartbeat(client, id, agentId); const payload = await client.scheduleDelete({ id }); diff --git a/packages/cli/src/commands/hub/connect.ts b/packages/cli/src/commands/hub/connect.ts index 8f3fcf9bfc..90a4edca36 100644 --- a/packages/cli/src/commands/hub/connect.ts +++ b/packages/cli/src/commands/hub/connect.ts @@ -13,6 +13,7 @@ import { addHubResolutionHelp } from "./help.js"; interface HubConnectOptions { apiKey?: string; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; permission?: readonly string[]; permissions?: readonly string[]; @@ -41,7 +42,7 @@ export async function runHubConnect( const credential = resolveHubCredential({ ...resolution, origin }); const token = await dependencies.hub.issueEnrollmentToken(origin, credential); const permissions = options.permissions ?? options.permission ?? []; - return withHubDaemon(dependencies.daemon, options.host, async (daemon) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => { const response = await daemon.connectHub(origin, token, permissions); if ( response.status.hubOrigin !== null && diff --git a/packages/cli/src/commands/hub/daemon-client.ts b/packages/cli/src/commands/hub/daemon-client.ts index 3f53360ccd..21a2e7bde1 100644 --- a/packages/cli/src/commands/hub/daemon-client.ts +++ b/packages/cli/src/commands/hub/daemon-client.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import { connectToDaemon } from "../../utils/client.js"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; @@ -33,19 +34,19 @@ export interface HubDaemonClient { } export interface HubDaemonConnection { - connect(host: string | undefined): Promise; + connect(target: DaemonTarget): Promise; } export const productionHubDaemonConnection: HubDaemonConnection = { - connect: (host) => connectToDaemon({ host }), + connect: (target) => connectToDaemon({ target }), }; export async function withHubDaemon( connection: HubDaemonConnection, - host: string | undefined, + target: DaemonTarget, action: (client: HubDaemonClient) => Promise, ): Promise { - const client = await connection.connect(host); + const client = await connection.connect(target); try { return await action(client); } finally { diff --git a/packages/cli/src/commands/hub/disconnect.ts b/packages/cli/src/commands/hub/disconnect.ts index 867201376f..a7052d4e50 100644 --- a/packages/cli/src/commands/hub/disconnect.ts +++ b/packages/cli/src/commands/hub/disconnect.ts @@ -9,6 +9,7 @@ import { hubStatusResult } from "./status-output.js"; interface HubDisconnectOptions { force?: boolean; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -21,7 +22,7 @@ export function runHubDisconnect( options: HubDisconnectOptions, dependencies: HubDisconnectDependencies, ) { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const current = (await client.getHubStatus()).status; if (current.hubOrigin !== null) { reportHubProgress( diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index 6bddbd1e70..650c5a0d53 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -59,7 +59,8 @@ export function createHubCommand(overrides: Partial = {}) flow: environment.login, reporter: environment.reporter, isInteractive: environment.isInteractive, - continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment), + continueGuidedSetup: (origin, daemonTarget) => + continueHubGuidedSetup(origin, { ...environment, daemonTarget }), }); addHubInitCommand(hub, environment); addHubConnectCommand(hub, { @@ -71,8 +72,11 @@ export function createHubCommand(overrides: Partial = {}) }); addJsonAndDaemonHostOptions(hub.command("status")).action( withOutput(async (...args) => { - const options = args.at(-2) as { host?: string }; - return withHubDaemon(environment.daemon, options.host, async (client) => + const options = args.at(-2) as { + host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; + }; + return withHubDaemon(environment.daemon, options.daemonTarget, async (client) => hubStatusResult((await client.getHubStatus()).status), ); }), diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index 6a97558b34..acd6ef054b 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -1,3 +1,6 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { withGlobalOptions } from "../../utils/command-options.js"; +import type { CommandOptions } from "../../output/index.js"; import { cancel, confirm, @@ -49,6 +52,7 @@ const DAEMON_READY_POLL_MS = 250; const PROVIDER_READY_TIMEOUT_MS = 60_000; export interface HubGuidedSetupEnvironment { + daemonTarget: DaemonTarget; env: Readonly>; credentials: HubCredentialStore; hub: HubHttpClient; @@ -80,22 +84,27 @@ function initErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void { +export function addHubInitCommand( + parent: Command, + environment: Omit, +): void { parent .command("init") .description("Create and optionally deploy a safe starter Hub trigger") - .action(async () => { - try { - await runHubInit(environment); - } catch (error) { - if (error instanceof HubInitCancelledError) { - cancel(error.message); - return; + .action( + withGlobalOptions(async (options: CommandOptions) => { + try { + await runHubInit({ ...environment, daemonTarget: options.daemonTarget }); + } catch (error) { + if (error instanceof HubInitCancelledError) { + cancel(error.message); + return; + } + cancel(initErrorMessage(error)); + process.exitCode = 1; } - cancel(initErrorMessage(error)); - process.exitCode = 1; - } - }); + }), + ); } export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise { @@ -175,8 +184,10 @@ export async function continueHubGuidedSetup( origin: string, environment: HubGuidedSetupEnvironment, ): Promise { - const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) => - daemon.getHubStatus().then((response) => response.status), + const currentStatus = await withHubDaemon( + environment.daemon, + environment.daemonTarget, + async (daemon) => daemon.getHubStatus().then((response) => response.status), ); const current = resolveHubInitConnection(currentStatus, origin); if (current.kind === "connected") { @@ -187,7 +198,7 @@ export async function continueHubGuidedSetup( }.`, ); } else if (current.kind === "pending") { - await waitForDaemonReady(origin, environment.daemon); + await waitForDaemonReady(origin, environment); } else if (current.kind === "conflict") { reportMessage( environment, @@ -273,7 +284,7 @@ async function ensureDaemonConnection( confirmed = false, permissions: readonly string[] = ["hub.execute"], ): Promise { - const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => + const status = await withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => daemon.getHubStatus().then((response) => response.status), ); const connection = resolveHubInitConnection(status, origin); @@ -287,7 +298,7 @@ async function ensureDaemonConnection( return connection.daemonId; } if (connection.kind === "pending") { - return waitForDaemonReady(origin, environment.daemon); + return waitForDaemonReady(origin, environment); } if (connection.kind === "conflict") { throw new HubCommandError( @@ -315,7 +326,7 @@ async function connectDaemon( ): Promise { await runHubConnect( origin, - { permissions }, + { permissions, daemonTarget: environment.daemonTarget }, { env: environment.env, credentials: environment.credentials, @@ -324,15 +335,15 @@ async function connectDaemon( reporter: environment.reporter, }, ); - return waitForDaemonReady(origin, environment.daemon); + return waitForDaemonReady(origin, environment); } async function waitForDaemonReady( origin: string, - connection: HubDaemonConnection, + environment: HubGuidedSetupEnvironment, ): Promise { return withSpinner("Waiting for the daemon to connect", async (reporter) => - withHubDaemon(connection, undefined, async (daemon) => { + withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => { const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS; while (true) { const status = (await daemon.getHubStatus()).status; @@ -436,7 +447,7 @@ async function waitForStarterAgentProviders( cwd: string, ): Promise { return withSpinner("Discovering agent runtimes", async (reporter) => - withHubDaemon(environment.daemon, undefined, async (daemon) => { + withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => { const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS; while (true) { const snapshot = await daemon.getProvidersSnapshot({ cwd }); @@ -750,7 +761,9 @@ async function requiredSelect( return answer; } -function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void { +function requireInteractiveTerminal( + environment: Omit, +): void { if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) { throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY."); } diff --git a/packages/cli/src/commands/hub/login.ts b/packages/cli/src/commands/hub/login.ts index e77329094c..056561deb8 100644 --- a/packages/cli/src/commands/hub/login.ts +++ b/packages/cli/src/commands/hub/login.ts @@ -1,3 +1,5 @@ +import type { CommandOptions } from "../../output/index.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; import type { Command } from "commander"; import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js"; import { addJsonOption } from "../../utils/command-options.js"; @@ -26,12 +28,12 @@ interface HubLoginDependencies { flow: Pick; reporter: HubReporter; isInteractive?(): boolean; - continueGuidedSetup?(origin: string): Promise; + continueGuidedSetup?(origin: string, daemonTarget: DaemonTarget): Promise; } export async function runHubLogin( originInput: string | undefined, - options: { json?: boolean }, + options: Pick, dependencies: HubLoginDependencies, ): Promise> { const origin = resolveHubOrigin({ @@ -48,7 +50,7 @@ export async function runHubLogin( dependencies.isInteractive?.() && dependencies.continueGuidedSetup !== undefined ) { - await dependencies.continueGuidedSetup(origin); + await dependencies.continueGuidedSetup(origin, options.daemonTarget); } return { type: "single", data: { origin, status: "logged_in" }, schema }; } @@ -64,7 +66,7 @@ export function addHubLoginCommand(parent: Command, dependencies: HubLoginDepend ).action( withOutput(async (...args) => { const origin = args[0] as string | undefined; - const options = args.at(-2) as { json?: boolean }; + const options = args.at(-2) as CommandOptions; return runHubLogin(origin, options, dependencies); }), ); diff --git a/packages/cli/src/commands/hub/logout.ts b/packages/cli/src/commands/hub/logout.ts index 28f0837021..8fd6f2bb7c 100644 --- a/packages/cli/src/commands/hub/logout.ts +++ b/packages/cli/src/commands/hub/logout.ts @@ -28,6 +28,7 @@ interface HubLogoutOptions { disconnectDaemon?: boolean; force?: boolean; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -53,7 +54,7 @@ export async function runHubLogout( let daemonStatus: HubStatus | null = null; const shouldInspectDaemon = options.disconnectDaemon === true || mayPrompt; if (shouldInspectDaemon) { - daemonStatus = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => + daemonStatus = await withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => daemon.getHubStatus().then((response) => response.status), ); } @@ -72,9 +73,13 @@ export async function runHubLogout( options, `Disconnecting this daemon from ${active.origin}`, ); - const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => { - return daemon.disconnectHub(options.force ?? false); - }); + const disconnect = await withHubDaemon( + dependencies.daemon, + options.daemonTarget, + async (daemon) => { + return daemon.disconnectHub(options.force ?? false); + }, + ); dependencies.credentials.logoutActive(); return logoutResult({ origin: active.origin, diff --git a/packages/cli/src/commands/hub/permissions.ts b/packages/cli/src/commands/hub/permissions.ts index ca9214b3e3..86f0b4c307 100644 --- a/packages/cli/src/commands/hub/permissions.ts +++ b/packages/cli/src/commands/hub/permissions.ts @@ -8,6 +8,7 @@ import { hubStatusResult } from "./status-output.js"; interface HubPermissionsOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -33,7 +34,7 @@ export function runHubPermissionsList( options: HubPermissionsOptions, dependencies: HubPermissionsDependencies, ): Promise> { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const status = (await client.getHubStatus()).status; requireConnectedHub(status); return { @@ -53,7 +54,7 @@ export function runHubPermissionChange( options: HubPermissionsOptions, dependencies: HubPermissionsDependencies, ) { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const current = (await client.getHubStatus()).status; requireConnectedHub(current); const response = await client.updateHubPermissions( diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 1ce0ac4283..4719e2823f 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,17 +1,18 @@ -import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts"; +import { addLocalDaemonOptions } from "../utils/command-options.js"; +import { cancel, confirm, intro, isCancel, log, note, outro } from "@clack/prompts"; import { Command, Option } from "commander"; -import { writeFileSync } from "node:fs"; import path from "node:path"; -import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server"; import { - resolveLocalPaseoHome, - resolveLocalDaemonState, - resolveTcpHostFromListen, - startLocalDaemonDetached, - tailDaemonLog, - type DaemonStartOptions, -} from "./daemon/local-daemon.js"; -import { tryConnectToDaemon } from "../utils/client.js"; + readPersistedConfig as loadPersistedConfig, + savePersistedConfig, + readDaemonInstance, + waitForDaemonReady, + type PersistedConfig, +} from "@getpaseo/server"; +import { withGlobalOptions } from "../utils/command-options.js"; +import type { CommandOptions } from "../output/index.js"; +import { launchLocalDaemon, parseTimeoutMs } from "./daemon/local-daemon.js"; +import { connectToDaemon } from "../utils/client.js"; import { formatPairingInstructions } from "../output/pairing.js"; import { confirmRelayPairing, @@ -19,7 +20,12 @@ import { resolveLocalPairingOffer, } from "./daemon/pair.js"; -interface OnboardOptions extends DaemonStartOptions { +interface OnboardOptions extends CommandOptions { + port?: string; + listen?: string; + relay?: boolean; + mcp?: boolean; + hostnames?: string; timeout?: string; voice?: "ask" | "enable" | "disable"; } @@ -39,41 +45,12 @@ type OnboardPersistedConfig = PersistedConfig & { }; }; -const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000; -const READY_PROBE_TIMEOUT_MS = 1200; - class OnboardCancelledError extends Error {} - const plainNoteFormat = (line: string): string => line; - function renderNote(message: string, title: string): void { note(message, title, { format: plainNoteFormat }); } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function parseTimeoutMs(raw: string | undefined): number { - if (!raw || raw.trim().length === 0) { - return DEFAULT_READY_TIMEOUT_MS; - } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - throw new Error(`Invalid timeout value: ${raw}`); - } - - return Math.ceil(seconds * 1000); -} - -function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void { - const configPath = path.join(paseoHome, "config.json"); - writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); -} - function applyVoiceSelection( config: OnboardPersistedConfig, enabled: boolean, @@ -135,134 +112,6 @@ async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise= 0; index -= 1) { - const line = lines[index]; - if (!line || !line.includes("Downloading model artifact")) { - continue; - } - - const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/); - const modelMatch = line.match(/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/); - - return { - modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null, - pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null, - }; - } - - return null; -} - -function renderProgressLine(progress: DownloadProgress): string { - const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ""; - if (progress.pct === null) { - return `Downloading speech model${modelSuffix}...`; - } - return `Downloading speech model${modelSuffix}: ${progress.pct}%`; -} - -type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" }; - -async function probeDaemonReady(home: string, timeoutMs: number): Promise { - const state = resolveLocalDaemonState({ home }); - const host = resolveTcpHostFromListen(state.listen); - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); - - if (state.running && host) { - const client = await tryConnectToDaemon({ - host, - timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), - }); - if (client) { - try { - await client.fetchAgents({ - timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), - }); - return { kind: "ready", listen: state.listen, host }; - } catch { - // Daemon process is alive but not API-ready yet. - } finally { - await client.close().catch(() => {}); - } - } - } else if (state.running && !host) { - return { kind: "ready", listen: state.listen, host: null }; - } - - return { kind: "pending" }; -} - -interface ProgressState { - lastStatus: string; - lastPrintedAt: number; -} - -function announceProgress( - home: string, - state: ProgressState, - onStatus: ((message: string) => void) | undefined, -): ProgressState { - const progress = parseDownloadProgress(tailDaemonLog(home, 120) ?? ""); - const progressLine = progress ? renderProgressLine(progress) : null; - const statusMessage = progressLine ?? "Waiting for daemon to become ready..."; - - if (statusMessage !== state.lastStatus) { - onStatus?.(statusMessage); - return { lastStatus: statusMessage, lastPrintedAt: Date.now() }; - } - if (!onStatus && Date.now() - state.lastPrintedAt >= 3000) { - console.log(statusMessage); - return { lastStatus: state.lastStatus, lastPrintedAt: Date.now() }; - } - return state; -} - -async function waitForDaemonReady(args: { - home: string; - timeoutMs: number; - onStatus?: (message: string) => void; -}): Promise<{ listen: string; host: string | null }> { - const deadline = Date.now() + args.timeoutMs; - const createTimeoutError = () => { - const recentLogs = tailDaemonLog(args.home, 60); - return new Error( - [ - `Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join("\n\n"), - ); - }; - - async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> { - if (Date.now() >= deadline) { - throw createTimeoutError(); - } - const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now())); - if (probe.kind === "ready") { - return { listen: probe.listen, host: probe.host }; - } - const nextState = announceProgress(args.home, state, args.onStatus); - if (Date.now() >= deadline) { - throw createTimeoutError(); - } - await sleep(200); - return poll(nextState); - } - - return poll({ lastStatus: "", lastPrintedAt: 0 }); -} - function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void { const daemonLogPath = path.join(paseoHome, "daemon.log"); const nextStepsLines = [ @@ -272,13 +121,13 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo "2. Web app: https://app.paseo.sh", "3. Desktop app: https://github.com/getpaseo/paseo/releases/latest", "4. Docs: https://paseo.sh/docs", - '5. Example: paseo run --output-schema schema.json "extract fields"', + `5. Example: paseo run --home ${JSON.stringify(paseoHome)} --output-schema schema.json "extract fields"`, ]; const quickReferenceLines = [ "1. paseo --help", - "2. paseo ls", - '3. paseo run "your prompt"', - "4. paseo status", + `2. paseo ls --home ${JSON.stringify(paseoHome)}`, + `3. paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`, + `4. paseo status --home ${JSON.stringify(paseoHome)}`, `5. Daemon logs: ${daemonLogPath}`, ]; @@ -301,11 +150,10 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo } export function onboardCommand(): Command { - return new Command("onboard") + return addLocalDaemonOptions(new Command("onboard")) .description("Run first-time setup, start daemon, and print pairing instructions") .option("--listen ", "Listen target (host:port, port, or unix socket path)") .option("--port ", "Port to listen on (default: 6767)") - .option("--home ", "Paseo home directory (default: ~/.paseo)") .option("--relay", "Enable relay connection without prompting") .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") @@ -316,12 +164,15 @@ export function onboardCommand(): Command { .addOption(new Option("--allowed-hosts ").hideHelp()) .option("--timeout ", "Max time to wait for daemon readiness (default: 600)") .option("--voice ", "Voice setup mode: ask, enable, disable", "ask") - .action(async (options: RawOnboardOptions) => { - await runOnboard({ - ...options, - hostnames: options.hostnames ?? options.allowedHosts, - }); - }); + .action( + withGlobalOptions(async (options: RawOnboardOptions, command: Command) => { + await runOnboard({ + ...options, + mcp: command.getOptionValueSource("mcp") === "cli" ? options.mcp : undefined, + hostnames: options.hostnames ?? options.allowedHosts, + }); + }), + ); } async function resolveAndPersistVoice( @@ -354,70 +205,30 @@ async function resolveAndPersistVoice( return voiceEnabled; } -async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise { - const stateBeforeStart = resolveLocalDaemonState({ home: options.home }); - if (stateBeforeStart.running) { - log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`); - return; - } - - const startSpinner = richUi ? spinner() : null; - try { - if (startSpinner) { - startSpinner.start("Starting daemon..."); - } else { - log.message("Starting daemon..."); - } - const startup = await startLocalDaemonDetached(options); - if (startSpinner) { - startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`); - } else { - log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`); - } - log.message(`Logs: ${startup.logPath}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (startSpinner) { - startSpinner.error(message); - } else { - log.error(message); - } - process.exit(1); - } -} - -async function waitForDaemonReadyWithUi(args: { - home: string; - timeoutMs: number; - richUi: boolean; -}): Promise<{ listen: string; host: string | null }> { - const readySpinner = args.richUi ? spinner() : null; - try { - if (readySpinner) { - readySpinner.start("Waiting for daemon to become ready..."); - } else { - log.message("Waiting for daemon to become ready..."); - } - const readyState = await waitForDaemonReady({ - home: args.home, - timeoutMs: args.timeoutMs, - onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined, - }); - if (readySpinner) { - readySpinner.stop(`Daemon ready on ${readyState.listen}`); - } else { - log.message(`Daemon ready on ${readyState.listen}`); - } - return readyState; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (readySpinner) { - readySpinner.error(message); - } else { - log.error(message); - } - return process.exit(1); +function persistSetupChoices(paseoHome: string, options: OnboardOptions): void { + const persisted = loadPersistedConfig(paseoHome, { defaultsIfMissing: true }); + if (options.listen || options.port) { + persisted.daemon = { + ...persisted.daemon, + listen: options.listen ?? `127.0.0.1:${options.port}`, + }; } + if (options.relay !== undefined) + persisted.daemon = { + ...persisted.daemon, + relay: { ...persisted.daemon?.relay, enabled: options.relay }, + }; + if (options.mcp !== undefined) + persisted.daemon = { + ...persisted.daemon, + mcp: { ...persisted.daemon?.mcp, enabled: options.mcp }, + }; + if (options.hostnames) + persisted.daemon = { + ...persisted.daemon, + hostnames: options.hostnames === "true" ? true : options.hostnames.split(","), + }; + savePersistedConfig(paseoHome, persisted); } export async function runOnboard(options: OnboardOptions): Promise { @@ -431,16 +242,12 @@ export async function runOnboard(options: OnboardOptions): Promise { process.exit(1); } - let timeoutMs = DEFAULT_READY_TIMEOUT_MS; - try { - timeoutMs = parseTimeoutMs(options.timeout); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - cancel(message); - process.exit(1); - } + const timeoutMs = parseTimeoutMs(options.timeout); - const paseoHome = resolveLocalPaseoHome(options.home); + if (options.daemonTarget.kind !== "instance") throw new Error("Onboarding requires a local home"); + const paseoHome = options.daemonTarget.home; + const alreadyRunning = await readDaemonInstance(paseoHome); + persistSetupChoices(paseoHome, options); if (richUi) { renderNote(paseoHome, "Paseo home"); } @@ -452,12 +259,19 @@ export async function runOnboard(options: OnboardOptions): Promise { : "Voice features disabled. Local speech models will not be downloaded.", ); - await ensureDaemonStarted(options, richUi); - await waitForDaemonReadyWithUi({ - home: options.home ?? paseoHome, - timeoutMs, - richUi, - }); + if (alreadyRunning) { + log.message(`Daemon already running (PID ${alreadyRunning.pid}); retaining its supervisor.`); + const client = await connectToDaemon({ target: options.daemonTarget, timeout: timeoutMs }); + try { + log.message(JSON.stringify(await client.reloadDaemonConfig())); + } finally { + await client.close(); + } + } else { + await launchLocalDaemon({ home: paseoHome, timeoutMs }); + } + const ready = await waitForDaemonReady(paseoHome, { timeoutMs }); + log.message(`Daemon ready on ${ready.listen}`); if (options.relay === false) { log.message("Relay pairing skipped because --no-relay was provided."); diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index c815dd36f8..51656436e9 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -95,6 +95,7 @@ export async function openDesktopWithProject(projectPath: string): Promise try { launchDesktop([projectPath]); } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exitCode = 1; diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index 15234c0d01..d2338cffca 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Permission response item for display */ @@ -46,8 +46,6 @@ export async function runAllowCommand( options: PermitAllowOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // No validation needed - if no reqId provided, allow all by default // Parse input JSON if provided @@ -65,18 +63,7 @@ export async function runAllowCommand( } } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index 94ffe9d571..c0d4707f35 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, CommandError } from "../../output/index.js"; import { permitResponseSchema, type PermissionResponseItem } from "./allow.js"; @@ -19,8 +19,6 @@ export async function runDenyCommand( options: PermitDenyOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!options.all && !reqId) { const error: CommandError = { @@ -31,18 +29,7 @@ export async function runDenyCommand( throw error; } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); diff --git a/packages/cli/src/commands/permit/ls.ts b/packages/cli/src/commands/permit/ls.ts index adeaa70ff1..b04c8d9f58 100644 --- a/packages/cli/src/commands/permit/ls.ts +++ b/packages/cli/src/commands/permit/ls.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Permission list item for display */ @@ -48,20 +48,7 @@ export async function runLsCommand( options: PermitLsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index 02c6c846ce..957a0072ae 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + const listPlugins = vi.fn(async () => [ { id: "git-plugin", @@ -68,7 +70,9 @@ describe("plugin management commands", () => { }); it("requires host support before attempting a management RPC", async () => { - await expect(runPluginListCommand(undefined, {}, {} as never)).rejects.toMatchObject({ + await expect( + runPluginListCommand(undefined, { daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to use plugin management.", }); @@ -89,7 +93,7 @@ describe("plugin management commands", () => { it("lists runtime state and the installed commit without an upstream commit", async () => { features.pluginManagement = true; - const result = await runPluginListCommand(undefined, {}, {} as never); + const result = await runPluginListCommand(undefined, { daemonTarget }, {} as never); const output = render(result, { noColor: true }); expect(output).toContain("SOURCE"); @@ -102,13 +106,15 @@ describe("plugin management commands", () => { it("filters the shared ls and status command by plugin ID", async () => { features.pluginManagement = true; - const result = await runPluginListCommand("legacy-plugin", {}, {} as never); + const result = await runPluginListCommand("legacy-plugin", { daemonTarget }, {} as never); expect(result.data.map((plugin) => plugin.id)).toEqual(["legacy-plugin"]); }); it("requires plugin log support before attempting the RPC", async () => { - await expect(runPluginLogsCommand("example", {}, {} as never)).rejects.toMatchObject({ + await expect( + runPluginLogsCommand("example", { daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to view plugin logs.", }); @@ -117,7 +123,7 @@ describe("plugin management commands", () => { it("returns readable and JSON plugin log output", async () => { features.pluginLogs = true; - const result = await runPluginLogsCommand("example", {}, {} as never); + const result = await runPluginLogsCommand("example", { daemonTarget }, {} as never); expect(getPluginLogs).toHaveBeenCalledWith("example"); expect(render(result, { noColor: true })).toContain("ready"); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 608a57e5d1..e067d4aa39 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -90,7 +90,9 @@ export async function runPluginListCommand( options: PluginOptions, _command: Command, ): Promise> { - const plugins = await withPluginManagementClient(options.host, (client) => client.listPlugins()); + const plugins = await withPluginManagementClient(options.daemonTarget, (client) => + client.listPlugins(), + ); const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins; if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`); return { type: "list", data, schema: pluginSchema }; @@ -101,7 +103,9 @@ export async function runPluginLogsCommand( options: PluginOptions, _command: Command, ): Promise> { - const data = await withPluginLogsClient(options.host, (client) => client.getPluginLogs(pluginId)); + const data = await withPluginLogsClient(options.daemonTarget, (client) => + client.getPluginLogs(pluginId), + ); return { type: "list", data, schema: pluginLogsSchema }; } @@ -126,10 +130,10 @@ async function install( isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path; const sourceReference = formatPluginSourceReference(source, options.path); const data = canUseLegacyDirectoryInstall - ? await withPluginManagementClient(options.host, (client) => + ? await withPluginManagementClient(options.daemonTarget, (client) => client.installDirectoryPlugin(source, options.id), ) - : await withPluginSourceClient(options.host, (client) => + : await withPluginSourceClient(options.daemonTarget, (client) => client.installPluginSource({ source: sourceReference, ...(options.id ? { id: options.id } : {}), @@ -147,7 +151,7 @@ async function update( if ((pluginId === undefined) === (options.all !== true)) { throw new Error("Choose one plugin ID or pass --all"); } - const data = await withPluginSourceClient(options.host, (client) => + const data = await withPluginSourceClient(options.daemonTarget, (client) => client.updatePluginSources(pluginId), ); return { type: "list", data, schema: pluginUpdateSchema }; @@ -158,7 +162,7 @@ async function act( pluginId: string, options: PluginOptions, ): Promise> { - const data = await withPluginManagementClient(options.host, (client) => + const data = await withPluginManagementClient(options.daemonTarget, (client) => client[`${action}Plugin`](pluginId), ); return { type: "single", data, schema: pluginSchema }; @@ -169,7 +173,7 @@ async function remove( options: PluginOptions, _command: Command, ): Promise> { - const data = await withPluginManagementClient(options.host, async (client) => { + const data = await withPluginManagementClient(options.daemonTarget, async (client) => { const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId); if (!current) throw new Error(`Plugin is not configured: ${pluginId}`); await client.removePlugin(pluginId); diff --git a/packages/cli/src/commands/plugin/shared.ts b/packages/cli/src/commands/plugin/shared.ts index c85a8d7e32..80c3dcfafa 100644 --- a/packages/cli/src/commands/plugin/shared.ts +++ b/packages/cli/src/commands/plugin/shared.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { CommandError } from "../../output/index.js"; import { connectToDaemon } from "../../utils/client.js"; @@ -5,12 +6,12 @@ import { connectToDaemon } from "../../utils/client.js"; type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement"; async function withPluginClient( - host: string | undefined, + target: DaemonTarget, feature: PluginFeature, updateMessage: string, run: (client: DaemonClient) => Promise, ): Promise { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); if (client.getLastServerInfoMessage()?.features?.[feature] !== true) { await client.close().catch(() => undefined); throw { @@ -26,12 +27,12 @@ async function withPluginClient( } export async function withPluginManagementClient( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14. return withPluginClient( - host, + target, "pluginManagement", "Update the host to use plugin management.", run, @@ -39,20 +40,20 @@ export async function withPluginManagementClient( } export async function withPluginLogsClient( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16. - return withPluginClient(host, "pluginLogs", "Update the host to view plugin logs.", run); + return withPluginClient(target, "pluginLogs", "Update the host to view plugin logs.", run); } export async function withPluginSourceClient( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26. return withPluginClient( - host, + target, "pluginGitManagement", "Update the host to install and update Git plugins.", run, diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 2210e6c214..b141ea7f18 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -31,10 +31,10 @@ export async function runCreateCommand( const projectPath = resolveProjectPath({ pathArg, cwd: process.cwd(), - daemonTarget: options.host ?? process.env.PASEO_HOST, + daemonTarget: options.daemonTarget.kind === "endpoint" ? options.daemonTarget.host : undefined, }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -44,6 +44,7 @@ export async function runCreateCommand( } return { type: "single", data: toProjectRow(payload.project), schema: projectSchema }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_CREATE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index 05dd657ef1..2c4a20ddc2 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -24,8 +24,8 @@ export async function runDeleteCommand( options: CommandOptions, _command: Command, ): Promise> { - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -36,6 +36,7 @@ export async function runDeleteCommand( schema: projectDeleteSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_DELETE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/project/ls.ts b/packages/cli/src/commands/project/ls.ts index 750428881f..acd9a21935 100644 --- a/packages/cli/src/commands/project/ls.ts +++ b/packages/cli/src/commands/project/ls.ts @@ -7,8 +7,8 @@ export async function runLsCommand( options: CommandOptions, _command: Command, ): Promise> { - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { diff --git a/packages/cli/src/commands/project/project.test.ts b/packages/cli/src/commands/project/project.test.ts index 47ea1cecf5..301c7a8a65 100644 --- a/packages/cli/src/commands/project/project.test.ts +++ b/packages/cli/src/commands/project/project.test.ts @@ -47,7 +47,11 @@ describe("project commands", () => { }); it("creates a project from an absolute directory path", async () => { - const result = await runCreateCommand("relative/project", {}, {} as never); + const result = await runCreateCommand( + "relative/project", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(addProject).toHaveBeenCalledWith(path.resolve("relative/project")); expect(result.data).toEqual({ @@ -77,7 +81,10 @@ describe("project commands", () => { }); it("lists projects", async () => { - const result = await runLsCommand({}, {} as never); + const result = await runLsCommand( + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(listProjects).toHaveBeenCalled(); expect(result.data).toHaveLength(1); @@ -88,7 +95,12 @@ describe("project commands", () => { expect(resolveProjectName({ name: " New name " })).toBe("New name"); expect(resolveProjectName({ reset: true })).toBeNull(); - const result = await runRenameCommand("project-1", " New name ", {}, {} as never); + const result = await runRenameCommand( + "project-1", + " New name ", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(renameProject).toHaveBeenCalledWith("project-1", "New name"); expect(result.data).toEqual({ projectId: "project-1", name: "New name" }); }); @@ -98,7 +110,11 @@ describe("project commands", () => { }); it("deletes a project", async () => { - const result = await runDeleteCommand("project-1", {}, {} as never); + const result = await runDeleteCommand( + "project-1", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(removeProject).toHaveBeenCalledWith("project-1"); expect(result.data).toEqual({ diff --git a/packages/cli/src/commands/project/rename.ts b/packages/cli/src/commands/project/rename.ts index b00fdb06be..42bf21bd90 100644 --- a/packages/cli/src/commands/project/rename.ts +++ b/packages/cli/src/commands/project/rename.ts @@ -44,8 +44,8 @@ export async function runRenameCommand( _command: Command, ): Promise> { const name = resolveProjectName({ name: nameArg, reset: options.reset }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -56,6 +56,7 @@ export async function runRenameCommand( schema: projectRenameSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_RENAME_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/provider/diagnostic.ts b/packages/cli/src/commands/provider/diagnostic.ts index 0ff05556a1..cbc13bd670 100644 --- a/packages/cli/src/commands/provider/diagnostic.ts +++ b/packages/cli/src/commands/provider/diagnostic.ts @@ -28,7 +28,7 @@ export async function runDiagnosticCommand( options: ProviderDiagnosticOptions, _command: Command, ): Promise> { - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const result = await client.getProviderDiagnostic(provider.trim().toLowerCase()); return { diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 6b902383db..1e56b60f73 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -1,8 +1,7 @@ import type { Command } from "commander"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest"; -import { tryConnectToDaemon } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; export interface ProviderListItem { provider: ProviderSnapshotEntry["provider"]; @@ -13,20 +12,6 @@ export interface ProviderListItem { modes: string; } -/** Derive provider list from the manifest — single source of truth */ -const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({ - provider: def.id, - label: def.label, - status: "available", - enabled: def.enabledByDefault === false ? "Disabled" : "Enabled", - defaultMode: def.defaultModeId ?? "-", - modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-", -})); - -function getStaticProviders(): ProviderListItem[] { - return PROVIDERS; -} - /** Schema for provider ls output */ export const providerLsSchema: OutputSchema = { idField: "provider", @@ -59,15 +44,7 @@ export async function runLsCommand( options: ProviderLsOptions, _command: Command, ): Promise { - const client = await tryConnectToDaemon({ host: options.host }); - - if (!client) { - return { - type: "list", - data: getStaticProviders(), - schema: providerLsSchema, - }; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const snapshot = await client.getProvidersSnapshot(); @@ -83,12 +60,6 @@ export async function runLsCommand( })), schema: providerLsSchema, }; - } catch { - return { - type: "list", - data: getStaticProviders(), - schema: providerLsSchema, - }; } finally { await client.close().catch(() => {}); } diff --git a/packages/cli/src/commands/provider/models.ts b/packages/cli/src/commands/provider/models.ts index e5dde2d136..1d8fd3f7bb 100644 --- a/packages/cli/src/commands/provider/models.ts +++ b/packages/cli/src/commands/provider/models.ts @@ -62,7 +62,7 @@ export async function runModelsCommand( ): Promise { const normalizedProvider = provider.toLowerCase(); - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const result = await client.listProviderModels(normalizedProvider); diff --git a/packages/cli/src/commands/schedule/create.ts b/packages/cli/src/commands/schedule/create.ts index 471765876d..8b77ea392f 100644 --- a/packages/cli/src/commands/schedule/create.ts +++ b/packages/cli/src/commands/schedule/create.ts @@ -43,12 +43,12 @@ export async function runCreateCommand( mode: options.mode, thinking: options.thinking, cwd: options.cwd, - host: options.host, + daemonTarget: options.daemonTarget, maxRuns: options.maxRuns, expiresIn: options.expiresIn, runNow, }); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleCreate(input); if (payload.error || !payload.schedule) { diff --git a/packages/cli/src/commands/schedule/delete.ts b/packages/cli/src/commands/schedule/delete.ts index f1f4ba2cae..f46ecf5d1f 100644 --- a/packages/cli/src/commands/schedule/delete.ts +++ b/packages/cli/src/commands/schedule/delete.ts @@ -25,7 +25,7 @@ export async function runDeleteCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleDelete({ id }); if (payload.error) { diff --git a/packages/cli/src/commands/schedule/inspect.ts b/packages/cli/src/commands/schedule/inspect.ts index d020f24089..8a94a82e23 100644 --- a/packages/cli/src/commands/schedule/inspect.ts +++ b/packages/cli/src/commands/schedule/inspect.ts @@ -16,7 +16,7 @@ export async function runInspectCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleInspect({ id }); if (payload.error || !payload.schedule) { diff --git a/packages/cli/src/commands/schedule/logs.ts b/packages/cli/src/commands/schedule/logs.ts index c9565b5c0d..7609851766 100644 --- a/packages/cli/src/commands/schedule/logs.ts +++ b/packages/cli/src/commands/schedule/logs.ts @@ -13,7 +13,7 @@ export async function runLogsCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleLogs({ id }); diff --git a/packages/cli/src/commands/schedule/ls.ts b/packages/cli/src/commands/schedule/ls.ts index 501c86653e..c3dfb6db28 100644 --- a/packages/cli/src/commands/schedule/ls.ts +++ b/packages/cli/src/commands/schedule/ls.ts @@ -13,7 +13,7 @@ export async function runLsCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleList(); if (payload.error) { diff --git a/packages/cli/src/commands/schedule/pause.ts b/packages/cli/src/commands/schedule/pause.ts index ed8e12108d..07929516fc 100644 --- a/packages/cli/src/commands/schedule/pause.ts +++ b/packages/cli/src/commands/schedule/pause.ts @@ -15,7 +15,7 @@ export async function runPauseCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.schedulePause({ id }); diff --git a/packages/cli/src/commands/schedule/resume.ts b/packages/cli/src/commands/schedule/resume.ts index efac2d6340..8d2855b397 100644 --- a/packages/cli/src/commands/schedule/resume.ts +++ b/packages/cli/src/commands/schedule/resume.ts @@ -15,7 +15,7 @@ export async function runResumeCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleResume({ id }); diff --git a/packages/cli/src/commands/schedule/run-once.ts b/packages/cli/src/commands/schedule/run-once.ts index 2818afc5db..beb8f65b29 100644 --- a/packages/cli/src/commands/schedule/run-once.ts +++ b/packages/cli/src/commands/schedule/run-once.ts @@ -15,7 +15,7 @@ export async function runRunOnceCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleRunOnce({ id }); diff --git a/packages/cli/src/commands/schedule/shared.test.ts b/packages/cli/src/commands/schedule/shared.test.ts index 0777134b0e..bac537e4fe 100644 --- a/packages/cli/src/commands/schedule/shared.test.ts +++ b/packages/cli/src/commands/schedule/shared.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; + +import { selectDaemonTarget } from "../../utils/daemon-target.js"; import { compileEveryPresetToCron, @@ -7,32 +9,25 @@ import { } from "./shared.js"; const baseOptions = { + daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}), prompt: "do the thing", every: "5m", provider: "claude", }; const baseCron = { + daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}), prompt: "do the thing", cron: "0 9 * * *", provider: "claude", }; describe("parseScheduleCreateInput cwd/host validation", () => { - beforeEach(() => { - vi.spyOn(process, "cwd").mockReturnValue("/local/project"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - test("no host, no cwd → defaults to process.cwd()", () => { const input = parseScheduleCreateInput(baseOptions); expect(input.target).toEqual({ type: "new-agent", - config: { provider: "claude", cwd: "/local/project" }, + config: { provider: "claude", cwd: process.cwd() }, }); }); @@ -47,7 +42,7 @@ describe("parseScheduleCreateInput cwd/host validation", () => { test("host with cwd → uses provided cwd", () => { const input = parseScheduleCreateInput({ ...baseOptions, - host: "dev:6767", + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), cwd: "/remote/project", }); expect(input.target).toEqual({ @@ -57,7 +52,12 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); test("host without cwd → throws MISSING_CWD", () => { - expect(() => parseScheduleCreateInput({ ...baseOptions, host: "dev:6767" })).toThrow( + expect(() => + parseScheduleCreateInput({ + ...baseOptions, + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), + }), + ).toThrow( expect.objectContaining({ code: "MISSING_CWD", message: expect.stringContaining("--cwd is required when --host is specified"), @@ -66,9 +66,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); test("PASEO_HOST without cwd → throws MISSING_CWD", () => { - vi.stubEnv("PASEO_HOST", "dev:6767"); + const daemonTarget = selectDaemonTarget({}, { PASEO_HOST: "dev:12345" }); - expect(() => parseScheduleCreateInput(baseOptions)).toThrow( + expect(() => parseScheduleCreateInput({ ...baseOptions, daemonTarget })).toThrow( expect.objectContaining({ code: "MISSING_CWD", message: expect.stringContaining("--cwd is required"), @@ -76,9 +76,21 @@ describe("parseScheduleCreateInput cwd/host validation", () => { ); }); + test("explicit home beats PASEO_HOST and defaults cwd locally", () => { + const daemonTarget = selectDaemonTarget({ home: process.cwd() }, { PASEO_HOST: "dev:12345" }); + expect(parseScheduleCreateInput({ ...baseOptions, daemonTarget }).target).toEqual({ + type: "new-agent", + config: { provider: "claude", cwd: process.cwd() }, + }); + }); + test("host with whitespace-only cwd → throws MISSING_CWD", () => { expect(() => - parseScheduleCreateInput({ ...baseOptions, host: "dev:6767", cwd: " " }), + parseScheduleCreateInput({ + ...baseOptions, + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), + cwd: " ", + }), ).toThrow(expect.objectContaining({ code: "MISSING_CWD" })); }); }); diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index beae80acc0..7506596092 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -1,4 +1,5 @@ -import { connectToDaemon, getDaemonHost, getExplicitDaemonHost } from "../../utils/client.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; import type { CreateScheduleInput, @@ -19,15 +20,16 @@ export interface ScheduleCommandOptions extends CommandOptions { } export async function connectScheduleClient( - host: string | undefined, + target: DaemonTarget, ): Promise<{ client: ScheduleDaemonClient; host: string }> { - const resolvedHost = getDaemonHost({ host }); + const resolvedHost = getDaemonHost({ target }); try { const client = (await connectToDaemon({ - host, + target, })) as unknown as ScheduleDaemonClient; return { client, host: resolvedHost }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -147,6 +149,7 @@ export function parseScheduleCreateInput(options: { thinking?: string; cwd?: string; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; maxRuns?: string; expiresIn?: string; runNow?: boolean; @@ -168,7 +171,7 @@ export function parseScheduleCreateInput(options: { } const cwdInput = options.cwd?.trim(); - if (getExplicitDaemonHost(options.host) !== undefined && !cwdInput) { + if (options.daemonTarget.kind === "endpoint" && !cwdInput) { throw { code: "MISSING_CWD", message: diff --git a/packages/cli/src/commands/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts index 30873853c1..81f3762962 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -50,7 +50,7 @@ export async function runUpdateCommand( clearMaxRuns: options.noMaxRuns, clearExpires: options.noExpiresIn, }); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleUpdate(input); diff --git a/packages/cli/src/commands/script/ls.ts b/packages/cli/src/commands/script/ls.ts index cd7d98e9f8..e8271d0bef 100644 --- a/packages/cli/src/commands/script/ls.ts +++ b/packages/cli/src/commands/script/ls.ts @@ -12,7 +12,7 @@ export async function runLsCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.listWorkspaceScripts(workspaceId); diff --git a/packages/cli/src/commands/script/shared.ts b/packages/cli/src/commands/script/shared.ts index 4e3308bdfb..b5b2326b6c 100644 --- a/packages/cli/src/commands/script/shared.ts +++ b/packages/cli/src/commands/script/shared.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import { resolve } from "node:path"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; @@ -9,10 +10,10 @@ export interface WorkspaceScriptCommandOptions extends CommandOptions { workspace?: string; } -export async function connectWorkspaceScriptClient(host?: string): Promise { - const daemonHost = getDaemonHost({ host }); +export async function connectWorkspaceScriptClient(target: DaemonTarget): Promise { + const daemonHost = getDaemonHost({ target }); try { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. if (!client.getLastServerInfoMessage()?.features?.workspaceScriptManagement) { await client.close().catch(() => {}); diff --git a/packages/cli/src/commands/script/start.ts b/packages/cli/src/commands/script/start.ts index 1b377267fa..0883f52720 100644 --- a/packages/cli/src/commands/script/start.ts +++ b/packages/cli/src/commands/script/start.ts @@ -13,7 +13,7 @@ export async function runStartCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.startWorkspaceScriptWithStatus(workspaceId, scriptName); diff --git a/packages/cli/src/commands/script/stop.ts b/packages/cli/src/commands/script/stop.ts index 6855656057..21dbf21371 100644 --- a/packages/cli/src/commands/script/stop.ts +++ b/packages/cli/src/commands/script/stop.ts @@ -13,7 +13,7 @@ export async function runStopCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.stopWorkspaceScript(workspaceId, scriptName); diff --git a/packages/cli/src/commands/terminal/capture.ts b/packages/cli/src/commands/terminal/capture.ts index 98b9f2ca2e..94ff1726ee 100644 --- a/packages/cli/src/commands/terminal/capture.ts +++ b/packages/cli/src/commands/terminal/capture.ts @@ -16,11 +16,9 @@ export interface TerminalCaptureOptions extends TerminalCommandOptions { export async function runCaptureCommand( terminalId: string, - _options: TerminalCaptureOptions, - command: Command, + options: TerminalCaptureOptions, + _command: Command, ): Promise { - const options = command.optsWithGlobals() as TerminalCaptureOptions; - try { const payload = await executeCaptureCommand(terminalId, options); if (options.json) { @@ -55,7 +53,7 @@ async function executeCaptureCommand( terminalId: string, options: TerminalCaptureOptions, ): Promise<{ terminalId: string; lines: string[]; totalLines: number }> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await resolveTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/create.ts b/packages/cli/src/commands/terminal/create.ts index 9524be0223..be41d877e0 100644 --- a/packages/cli/src/commands/terminal/create.ts +++ b/packages/cli/src/commands/terminal/create.ts @@ -17,7 +17,7 @@ export async function runCreateCommand( options: TerminalCreateOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const cwd = options.cwd ?? (options.workspace ? undefined : process.cwd()); const workspaceId = diff --git a/packages/cli/src/commands/terminal/kill.ts b/packages/cli/src/commands/terminal/kill.ts index 30990d9c11..685c8ecaf3 100644 --- a/packages/cli/src/commands/terminal/kill.ts +++ b/packages/cli/src/commands/terminal/kill.ts @@ -13,7 +13,7 @@ export async function runKillCommand( options: TerminalCommandOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await requireTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts index bec0b0047b..23b1a182a6 100644 --- a/packages/cli/src/commands/terminal/ls.ts +++ b/packages/cli/src/commands/terminal/ls.ts @@ -17,7 +17,7 @@ export async function runLsCommand( options: TerminalLsOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); const cwd = options.all || options.workspace ? undefined : (options.cwd ?? process.cwd()); try { diff --git a/packages/cli/src/commands/terminal/send-keys.ts b/packages/cli/src/commands/terminal/send-keys.ts index 5097ea3908..8b1a274204 100644 --- a/packages/cli/src/commands/terminal/send-keys.ts +++ b/packages/cli/src/commands/terminal/send-keys.ts @@ -14,11 +14,9 @@ export interface TerminalSendKeysOptions extends TerminalCommandOptions { export async function runSendKeysCommand( terminalId: string, keys: string[], - _options: TerminalSendKeysOptions, - command: Command, + options: TerminalSendKeysOptions, + _command: Command, ): Promise { - const options = command.optsWithGlobals() as TerminalSendKeysOptions; - try { const payload = await executeSendKeysCommand(terminalId, keys, options); if (options.json) { @@ -39,7 +37,7 @@ async function executeSendKeysCommand( keys: string[], options: TerminalSendKeysOptions, ): Promise<{ terminalId: string; keysSent: number }> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await resolveTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/shared.ts b/packages/cli/src/commands/terminal/shared.ts index 101caf0879..0fcb9ef64d 100644 --- a/packages/cli/src/commands/terminal/shared.ts +++ b/packages/cli/src/commands/terminal/shared.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import { createPaseoApi } from "@getpaseo/client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; @@ -11,12 +12,13 @@ interface TerminalLike { name?: string | null; } -export async function connectTerminalClient(host?: string) { - const daemonHost = getDaemonHost({ host }); +export async function connectTerminalClient(target: DaemonTarget) { + const daemonHost = getDaemonHost({ target }); try { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); return { client: createPaseoApi(client), daemonHost, close: () => client.close() }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "DAEMON_NOT_RUNNING", diff --git a/packages/cli/src/commands/workspace/archive.ts b/packages/cli/src/commands/workspace/archive.ts index 958d0451d0..a9dad90e91 100644 --- a/packages/cli/src/commands/workspace/archive.ts +++ b/packages/cli/src/commands/workspace/archive.ts @@ -19,11 +19,11 @@ const workspaceArchiveSchema: OutputSchema = { export async function runArchiveCommand( workspaceId: string, - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -44,6 +44,7 @@ export async function runArchiveCommand( schema: workspaceArchiveSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_ARCHIVE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/create.ts b/packages/cli/src/commands/workspace/create.ts index 078eefb06e..5ce49278ef 100644 --- a/packages/cli/src/commands/workspace/create.ts +++ b/packages/cli/src/commands/workspace/create.ts @@ -134,8 +134,8 @@ export async function runCreateCommand( options: WorkspaceCreateOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -153,6 +153,7 @@ export async function runCreateCommand( } return { type: "single", data: toWorkspaceRow(payload.workspace), schema: workspaceSchema }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_CREATE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/ls.ts b/packages/cli/src/commands/workspace/ls.ts index 7ea9e8f56d..b9821e061a 100644 --- a/packages/cli/src/commands/workspace/ls.ts +++ b/packages/cli/src/commands/workspace/ls.ts @@ -4,11 +4,11 @@ import type { CommandError, ListResult } from "../../output/index.js"; import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; export async function runLsCommand( - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", diff --git a/packages/cli/src/commands/workspace/rename.ts b/packages/cli/src/commands/workspace/rename.ts index fd5857682b..85fa5f5ff4 100644 --- a/packages/cli/src/commands/workspace/rename.ts +++ b/packages/cli/src/commands/workspace/rename.ts @@ -18,6 +18,7 @@ const workspaceRenameSchema: OutputSchema = { export interface WorkspaceRenameOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; reset?: boolean; } @@ -55,8 +56,8 @@ export async function runRenameCommand( ): Promise> { const title = resolveWorkspaceTitle({ title: titleArg, reset: options.reset }); - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -72,6 +73,7 @@ export async function runRenameCommand( schema: workspaceRenameSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_RENAME_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/setup.ts b/packages/cli/src/commands/workspace/setup.ts index ce15d10f26..0d9e99821a 100644 --- a/packages/cli/src/commands/workspace/setup.ts +++ b/packages/cli/src/commands/workspace/setup.ts @@ -17,11 +17,11 @@ const workspaceSetupSchema: OutputSchema = { export async function runSetupCommand( workspaceId: string, - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { throw { code: "DAEMON_NOT_RUNNING", message: `Cannot connect to daemon at ${host}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/cli/src/commands/worktree/archive.test.ts b/packages/cli/src/commands/worktree/archive.test.ts index 5314be144d..f591aaa6a7 100644 --- a/packages/cli/src/commands/worktree/archive.test.ts +++ b/packages/cli/src/commands/worktree/archive.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { runArchiveCommandWithDeps } from "./archive.js"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + function createFakeDaemonClient( overrides: Partial< Pick @@ -60,9 +62,12 @@ describe("runArchiveCommand", () => { const result = await runArchiveCommandWithDeps( "feature", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ); @@ -111,9 +116,12 @@ describe("runArchiveCommand", () => { await runArchiveCommandWithDeps( "feature-x", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ); @@ -134,9 +142,12 @@ describe("runArchiveCommand", () => { await expect( runArchiveCommandWithDeps( "missing", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ), ).rejects.toMatchObject({ diff --git a/packages/cli/src/commands/worktree/archive.ts b/packages/cli/src/commands/worktree/archive.ts index 711711dced..eaf3857322 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -48,7 +48,7 @@ export async function runArchiveCommandWithDeps( options: WorktreeArchiveOptions, deps: { connectToDaemon: typeof connectToDaemon }, ): Promise { - const host = getDaemonHost({ host: options.host }); + const host = getDaemonHost({ target: options.daemonTarget }); // Validate arguments if (!nameArg || nameArg.trim().length === 0) { @@ -62,8 +62,9 @@ export async function runArchiveCommandWithDeps( let client: DaemonClient; try { - client = await deps.connectToDaemon({ host: options.host }); + client = await deps.connectToDaemon({ target: options.daemonTarget }); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "DAEMON_NOT_RUNNING", diff --git a/packages/cli/src/commands/worktree/create.ts b/packages/cli/src/commands/worktree/create.ts index 257fadc679..da7d873b5a 100644 --- a/packages/cli/src/commands/worktree/create.ts +++ b/packages/cli/src/commands/worktree/create.ts @@ -1,7 +1,6 @@ import path from "node:path"; import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js"; import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js"; @@ -31,18 +30,7 @@ export async function runCreateCommand( const cwd = options.cwd ?? process.cwd(); const request = buildCreateWorktreeRequest(options, cwd); - const host = getDaemonHost({ host: options.host }); - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw cmdError( - "DAEMON_NOT_RUNNING", - `Cannot connect to daemon at ${host}: ${message}`, - "Start the daemon with: paseo daemon start", - ); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const response = await client.createPaseoWorktree(request); diff --git a/packages/cli/src/commands/worktree/ls.ts b/packages/cli/src/commands/worktree/ls.ts index 10f1344e17..cd388b2b08 100644 --- a/packages/cli/src/commands/worktree/ls.ts +++ b/packages/cli/src/commands/worktree/ls.ts @@ -1,8 +1,6 @@ import type { Command } from "commander"; -import { homedir } from "node:os"; -import { basename, join, sep } from "node:path"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { basename } from "node:path"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Worktree list item for display */ @@ -27,19 +25,6 @@ function extractWorktreeName(path: string): string { return basename(path); } -export function resolvePaseoHomePath(): string { - return process.env.PASEO_HOME ?? join(homedir(), ".paseo"); -} - -export function resolvePaseoWorktreesDir(): string { - return join(resolvePaseoHomePath(), "worktrees"); -} - -function isAgentInManagedWorktree(agentCwd: string): boolean { - const worktreesDir = resolvePaseoWorktreesDir(); - return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep); -} - /** Schema for worktree ls output */ export const worktreeLsSchema: OutputSchema = { idField: "name", @@ -61,20 +46,7 @@ export async function runLsCommand( options: WorktreeLsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); @@ -96,9 +68,7 @@ export async function runLsCommand( // Build a map of worktree paths to agent IDs const worktreeAgentMap = new Map(); for (const agent of agents) { - if (isAgentInManagedWorktree(agent.cwd)) { - worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7)); - } + worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7)); } const items: WorktreeListItem[] = response.worktrees.map((wt) => ({ diff --git a/packages/cli/src/output/with-output.ts b/packages/cli/src/output/with-output.ts index ad0f631988..723b6c15d5 100644 --- a/packages/cli/src/output/with-output.ts +++ b/packages/cli/src/output/with-output.ts @@ -11,6 +11,8 @@ import { withGlobalOptions } from "../utils/command-options.js"; /** Options that include output settings from global options */ export interface CommandOptions extends Partial { + daemonTarget: import("../utils/daemon-target.js").DaemonTarget; + home?: string; /** Daemon host target from --host option */ host?: string; /** JSON output flag from --json option */ diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 4b679c5298..b95dd9b419 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -1,3 +1,4 @@ +import { renderError, toCommandError, defaultOutputOptions } from "./output/render.js"; import { createCli } from "./cli.js"; import { classifyInvocation } from "./classify.js"; import { openDesktopWithProject } from "./commands/open.js"; @@ -48,6 +49,16 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi } const program = createCli(); - await program.parseAsync(parseArgv, { from: "node" }); + try { + await program.parseAsync(parseArgv, { from: "node" }); + } catch (error) { + process.stderr.write( + renderError(toCommandError(error), { + ...defaultOutputOptions, + format: argv.includes("--json") ? "json" : "table", + }) + "\n", + ); + return 1; + } return typeof process.exitCode === "number" ? process.exitCode : 0; } diff --git a/packages/cli/src/utils/client-id.ts b/packages/cli/src/utils/client-id.ts index 13863e0945..145b698884 100644 --- a/packages/cli/src/utils/client-id.ts +++ b/packages/cli/src/utils/client-id.ts @@ -1,14 +1,7 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { ensurePrivateDirectory } from "@getpaseo/server"; +import { readFile, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; -import { dirname, join } from "node:path"; -import { homedir } from "node:os"; - -const CLIENT_SESSION_KEY_FILE = join( - process.env.PASEO_HOME ?? join(homedir(), ".paseo"), - "cli-client-id", -); - -let cachedClientId: string | null = null; +import { join } from "node:path"; function normalizeClientId(value: string): string | null { const trimmed = value.trim(); @@ -19,15 +12,12 @@ function generateClientId(): string { return `cid_${randomUUID().replace(/-/g, "")}`; } -export async function getOrCreateCliClientId(): Promise { - if (cachedClientId) { - return cachedClientId; - } +export async function getOrCreateCliClientId(home: string): Promise { + const clientSessionKeyFile = join(home, "cli-client-id"); try { - const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8")); + const existing = normalizeClientId(await readFile(clientSessionKeyFile, "utf8")); if (existing) { - cachedClientId = existing; return existing; } } catch (error) { @@ -38,8 +28,7 @@ export async function getOrCreateCliClientId(): Promise { } const nextValue = generateClientId(); - await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true }); - await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 }); - cachedClientId = nextValue; + ensurePrivateDirectory(home); + await writeFile(clientSessionKeyFile, nextValue, { mode: 0o600 }); return nextValue; } diff --git a/packages/cli/src/utils/client.ssh.test.ts b/packages/cli/src/utils/client.ssh.test.ts index 4311db71b5..19b720a893 100644 --- a/packages/cli/src/utils/client.ssh.test.ts +++ b/packages/cli/src/utils/client.ssh.test.ts @@ -47,7 +47,9 @@ describe("CLI SSH transport", () => { }); it("routes an SSH host through a local tunnel", async () => { - await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" }); + await connectToDaemon({ + target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" }, + }); expect(mocks.createSshTunnel).toHaveBeenCalledWith({ host: "deploy@build-box", diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index bd774ddaff..f2eae4b6a2 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import { waitForDaemonReady, resolvePaseoHome, type DaemonInstance } from "@getpaseo/server"; +import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js"; +export type { DaemonTarget } from "./daemon-target.js"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, @@ -13,57 +14,44 @@ import { } from "@getpaseo/protocol/connection-offer"; import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; -import path from "node:path"; import { WebSocket } from "ws"; import { getOrCreateCliClientId } from "./client-id.js"; import { resolveCliVersion } from "../version.js"; import { createSshTunnel } from "../ssh/ssh-tunnel.js"; export interface ConnectOptions { - host?: string; + target: DaemonTarget; timeout?: number; + instance?: DaemonInstance; } - -export interface DaemonConnectionCommandError { - code: "DAEMON_NOT_RUNNING"; - message: string; - details: string; -} - -const DEFAULT_HOST = "localhost:6767"; const DEFAULT_TIMEOUT = 15000; -const PID_FILENAME = "paseo.pid"; +type TransportTarget = + | { type: "tcp"; url: string } + | { type: "ipc"; url: string; socketPath: string }; -type DaemonTarget = - | { - type: "tcp"; - url: string; - } - | { - type: "ipc"; - url: string; - socketPath: string; - }; - -/** - * Get the daemon host from environment or options - */ -export function getDaemonHost(options?: ConnectOptions): string { - return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST; +export function getDaemonHost(options: ConnectOptions): string { + return describeDaemonTarget(options.target); } -export function buildDaemonConnectionCommandError(options: { - host?: string; - error: unknown; -}): DaemonConnectionCommandError { - const host = getDaemonHost({ host: options.host }); - const message = options.error instanceof Error ? options.error.message : String(options.error); +export function buildDaemonConnectionCommandError(options: ConnectOptions & { error: unknown }) { + const error = options.error; + let message = error instanceof Error ? error.message : String(error); + if (error && typeof error === "object" && "message" in error) message = String(error.message); + if (options.target.kind === "endpoint") + message = message.replaceAll(options.target.host, describeDaemonTarget(options.target)); + if (message.startsWith("Cannot connect to daemon at ")) + return error as { code: string; message: string; details: string }; + let code = "DAEMON_UNREACHABLE"; + if (typeof error === "object" && error !== null && "code" in error) code = String(error.code); + else if (message === "Password required") code = "AUTH_REQUIRED"; + else if (message === "Incorrect password") code = "AUTH_FAILED"; return { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: host.trim().startsWith("ssh://") - ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it." - : "Start the daemon with: paseo daemon start", + code, + message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`, + details: + options.target.kind === "instance" + ? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}` + : "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.", }; } @@ -118,100 +106,13 @@ export function normalizeDaemonHost(raw: string): string | null { return trimmed.includes(":") ? trimmed : null; } -export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string { - return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST; -} - -function isIpcDaemonHost(host: string | null): host is string { - return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://")); -} - -function isTcpDaemonHost(host: string | null): host is string { - return host !== null && !isIpcDaemonHost(host); -} - -function readPidSocketTarget(paseoHome: string): string | null { - const pidPath = path.join(paseoHome, PID_FILENAME); - if (!existsSync(pidPath)) { - return null; - } - - try { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as { - listen?: unknown; - sockPath?: unknown; - }; - if (typeof parsed.listen === "string") return parsed.listen; - if (typeof parsed.sockPath === "string") return parsed.sockPath; - return null; - } catch { - return null; - } -} - -function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null { - const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? ""); - if (isIpcDaemonHost(directEnvHost)) { - return directEnvHost; - } - - const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? ""); - if (isIpcDaemonHost(pidHost)) { - return pidHost; - } - - const config = loadConfig(paseoHome, { env }); - const configuredHost = normalizeDaemonHost(config.listen); - return isIpcDaemonHost(configuredHost) ? configuredHost : null; -} - -function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null { - const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen); - if (!isTcpDaemonHost(configuredHost)) { - return null; - } - return configuredHost === "127.0.0.1:6767" ? null : configuredHost; -} - -export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] { - const paseoHome = resolvePaseoHome(env); - const candidates: string[] = []; - const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome); - if (configuredIpcHost) { - candidates.push(configuredIpcHost); - } - const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome); - if (configuredTcpHost) { - candidates.push(configuredTcpHost); - } - candidates.push(DEFAULT_HOST); - return Array.from(new Set(candidates)); -} - -function resolveDaemonHostCandidates(options?: ConnectOptions): string[] { - const explicitHost = getExplicitDaemonHost(options?.host); - if (explicitHost) { - return [explicitHost]; - } - - return resolveDefaultDaemonHosts(); -} - -export function getExplicitDaemonHost( - host: string | undefined, - env: NodeJS.ProcessEnv = process.env, -): string | undefined { - const explicitHost = host ?? env.PASEO_HOST; - return explicitHost?.trim() ? explicitHost : undefined; -} - function stripIpcPrefix(trimmed: string): string { if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim(); if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim(); return trimmed; } -export function resolveDaemonTarget(host: string): DaemonTarget { +export function resolveDaemonTarget(host: string): TransportTarget { const trimmed = normalizeDaemonHost(host); if (!trimmed) { throw new Error(`Invalid daemon target: ${host}`); @@ -363,12 +264,21 @@ function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null } } -export async function connectToDaemon(options?: ConnectOptions): Promise { - const timeout = options?.timeout ?? DEFAULT_TIMEOUT; - const clientId = await getOrCreateCliClientId(); +async function connectSelectedDaemon(options: ConnectOptions): Promise { + const timeout = options.timeout ?? DEFAULT_TIMEOUT; + const deadline = Date.now() + timeout; + const explicitHost = + options.target.kind === "endpoint" + ? options.target.host + : ( + await waitForDaemonReady(options.target.home, { + timeoutMs: timeout, + instance: options.instance, + }) + ).listen; + const clientId = await getOrCreateCliClientId(resolvePaseoHome({})); const nodeWebSocketFactory = createNodeWebSocketFactory(); - const explicitHost = getExplicitDaemonHost(options?.host); if (explicitHost?.trim().startsWith("ssh://")) { const target = parseSshTransportUri(explicitHost.trim()); const tunnel = await createSshTunnel(target); @@ -377,10 +287,20 @@ export async function connectToDaemon(options?: ConnectOptions): Promise { + try { + await close(); + } finally { + tunnel.close(); + } + }; + return result.client; + } const failure = tunnel.failureDetail(); tunnel.close(); @@ -389,32 +309,37 @@ export async function connectToDaemon(options?: ConnectOptions): Promise { - if (index >= hosts.length) { - if (lastError instanceof Error) throw lastError; - throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`); - } - const host = hosts[index]; - const password = resolveDaemonPassword(host); - const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory); - if ("client" in result) { - return result.client; - } - return tryNext(index + 1, result.error); +export async function connectToDaemon(options: ConnectOptions): Promise { + try { + return await connectSelectedDaemon(options); + } catch (error) { + throw buildDaemonConnectionCommandError({ ...options, error }); } - - return tryNext(0, null); } /** * Try to connect to the daemon, returns null if connection fails */ -export async function tryConnectToDaemon(options?: ConnectOptions): Promise { +export async function tryConnectToDaemon(options: ConnectOptions): Promise { try { return await connectToDaemon(options); } catch { diff --git a/packages/cli/src/utils/command-options.test.ts b/packages/cli/src/utils/command-options.test.ts index 652fe9356b..f491c6db03 100644 --- a/packages/cli/src/utils/command-options.test.ts +++ b/packages/cli/src/utils/command-options.test.ts @@ -1,16 +1,19 @@ import { Command } from "commander"; import { describe, expect, it } from "vitest"; -import { addDaemonHostOption, withGlobalOptions } from "./command-options.js"; +import { createCli } from "../cli.js"; +import { withGlobalOptions } from "./command-options.js"; async function parseHost(argv: string[]): Promise { - const program = new Command().exitOverride().option("--host "); + const program = createCli().exitOverride(); let receivedHost: string | undefined; - addDaemonHostOption(program.command("ls")).action( - withGlobalOptions((options: { host?: string }) => { - receivedHost = options.host; - }), - ); + program.commands + .find((command) => command.name() === "ls")! + .action( + withGlobalOptions((options: { host?: string }) => { + receivedHost = options.host; + }), + ); await program.parseAsync(argv, { from: "user" }); return receivedHost; @@ -25,10 +28,10 @@ describe("global command options", () => { await expect(parseHost(["ls", "--host", "local:6767"])).resolves.toBe("local:6767"); }); - it("uses the last explicit host", async () => { - await expect(parseHost(["--host", "first:6767", "ls", "--host", "last:6767"])).resolves.toBe( - "last:6767", - ); + it("rejects conflicting parent and command selectors", async () => { + await expect( + parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]), + ).rejects.toMatchObject({ code: "TARGET_AMBIGUOUS" }); }); it("lets local-only commands ignore a global host", async () => { diff --git a/packages/cli/src/utils/command-options.ts b/packages/cli/src/utils/command-options.ts index b02b4abdb1..02fcd924a0 100644 --- a/packages/cli/src/utils/command-options.ts +++ b/packages/cli/src/utils/command-options.ts @@ -1,8 +1,17 @@ import type { Command } from "commander"; +import { selectDaemonTarget } from "./daemon-target.js"; const JSON_OPTION_DESCRIPTION = "Output in JSON format"; const DAEMON_HOST_OPTION_DESCRIPTION = - "Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)"; + "Explicit daemon endpoint: host:port, TCP, socket, SSH, or pairing URL (default: local home)"; + +const localCommands = new WeakSet(); + +export function addLocalDaemonOptions(command: T): T { + localCommands.add(command); + command.option("--home ", "Local daemon home (default: ~/.paseo)"); + return command; +} export function collectMultiple(value: string, previous: string[]): string[] { return previous.concat([value]); @@ -15,6 +24,8 @@ export function addJsonOption(command: T): T { export function addDaemonHostOption(command: T): T { command.option("--host ", DAEMON_HOST_OPTION_DESCRIPTION); + if (!command.options.some((option) => option.long === "--home")) + command.option("--home ", "Select a local daemon home (default: ~/.paseo)"); return command; } @@ -28,7 +39,19 @@ export function withGlobalOptions( return (...args) => { const command = args.at(-1) as Command; const mergedArgs = [...args]; - mergedArgs[mergedArgs.length - 2] = command.optsWithGlobals(); + const options = command.optsWithGlobals(); + const selectors = { home: new Set(), host: new Set() }; + for (let current: Command | null = command; current; current = current.parent) { + for (const key of ["home", "host"] as const) { + const value = current.opts()[key]; + if (typeof value === "string") selectors[key].add(value); + } + } + if (selectors.home.size > 1 || selectors.host.size > 1) + throw { code: "TARGET_AMBIGUOUS", message: "Conflicting duplicate daemon selectors." }; + const localOnly = localCommands.has(command); + options.daemonTarget = selectDaemonTarget(options, process.env, localOnly); + mergedArgs[mergedArgs.length - 2] = options; return handler(...(mergedArgs as Args)); }; } diff --git a/packages/cli/src/utils/daemon-target.test.ts b/packages/cli/src/utils/daemon-target.test.ts new file mode 100644 index 0000000000..f42b9e8704 --- /dev/null +++ b/packages/cli/src/utils/daemon-target.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "vitest"; +import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js"; + +test("explicit selectors win over both environment selectors", () => { + const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" }; + expect(selectDaemonTarget({ home: "/tmp/b" }, env)).toEqual({ kind: "instance", home: "/tmp/b" }); + expect(selectDaemonTarget({ host: "chosen:23456" }, env)).toEqual({ + kind: "endpoint", + host: "chosen:23456", + }); + expect(() => selectDaemonTarget({}, env)).toThrow(); + expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow(); +}); + +test("local operations ignore routing environment but reject an explicit endpoint", () => { + expect( + selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true), + ).toEqual({ kind: "instance", home: "/tmp/b" }); + expect(() => selectDaemonTarget({ host: "chosen:23456" }, {}, true)).toThrow(); +}); + +test("endpoint descriptions redact pairing material and credentials", () => { + expect( + describeDaemonTarget({ + kind: "endpoint", + host: "tcp://user:private@example.test:23456?password=secret", + }), + ).not.toMatch(/private|secret/); + expect( + describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }), + ).not.toContain("private"); +}); diff --git a/packages/cli/src/utils/daemon-target.ts b/packages/cli/src/utils/daemon-target.ts new file mode 100644 index 0000000000..2705168159 --- /dev/null +++ b/packages/cli/src/utils/daemon-target.ts @@ -0,0 +1,51 @@ +import { resolvePaseoHome } from "@getpaseo/server"; + +export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string }; + +export function selectDaemonTarget( + options: { home?: string; host?: string }, + env: NodeJS.ProcessEnv = process.env, + localOnly = false, +): DaemonTarget { + for (const key of ["home", "host"] as const) { + if (options[key] !== undefined && !options[key]!.trim()) + throw { code: "TARGET_INVALID", message: `--${key} requires a non-empty value.` }; + } + if (options.home !== undefined && options.host !== undefined) + throw { code: "TARGET_AMBIGUOUS", message: "Choose either --home or --host, not both." }; + if (localOnly) { + if (options.host !== undefined) + throw { + code: "LOCAL_OPERATION", + message: "This is a local operation; use --home. --host is not supported.", + }; + return { + kind: "instance", + home: resolvePaseoHome({ PASEO_HOME: options.home ?? env.PASEO_HOME }), + }; + } + if (options.home !== undefined) + return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: options.home }) }; + if (options.host !== undefined) return { kind: "endpoint", host: options.host }; + if (env.PASEO_HOME && env.PASEO_HOST) + throw { + code: "TARGET_AMBIGUOUS", + message: "PASEO_HOME and PASEO_HOST are both set. Choose --home or --host explicitly.", + }; + if (env.PASEO_HOST) return { kind: "endpoint", host: env.PASEO_HOST }; + return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: env.PASEO_HOME }) }; +} + +export function describeDaemonTarget(target: DaemonTarget): string { + if (target.kind === "instance") return `home ${target.home}`; + try { + const url = new URL(target.host); + if (url.password) url.password = "REDACTED"; + for (const key of url.searchParams.keys()) + if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED"); + if (url.hash) url.hash = "REDACTED"; + return url.toString(); + } catch { + return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED"); + } +} diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index aa84c4caae..d395bdc6fe 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -11,7 +11,7 @@ * - daemon status reports stopped when daemon not running * - daemon status --json outputs valid JSON * - daemon stop handles daemon not running gracefully - * - daemon restart starts the daemon and can be cleaned up + * - daemon restart refuses to start a stopped daemon * - daemon status probes the live relay state over local IPC */ @@ -140,7 +140,7 @@ try { const result = await daemonCommand(["status"]); assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped"); const output = result.stdout.toLowerCase(); - assert(output.includes("local daemon"), "status table should include Local Daemon row"); + assert(output.includes("localdaemon"), "status table should include Local Daemon row"); assert(output.includes("stopped"), "status should report stopped"); console.log("✓ daemon status reports stopped when not running\n"); } @@ -164,7 +164,7 @@ try { const result = await daemonCommand(["status", "--json"]); assert.strictEqual(result.exitCode, 0, "--json status should succeed"); const status = JSON.parse(result.stdout); - assert.strictEqual(typeof status.serverId, "string", "json status should include serverId"); + assert.strictEqual(status.serverId, undefined, "stopped observation must not invent identity"); assert.strictEqual(status.localDaemon, "stopped", "json status should report stopped"); assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home"); assert.strictEqual( @@ -175,20 +175,12 @@ try { console.log("✓ daemon status --json outputs valid JSON\n"); } - // Global hosts do not change local daemon lifecycle commands. { - console.log("Test 5b: daemon status ignores a global --host"); - const result = await runLocalPaseo(["--host", "localhost:1", "daemon", "status", "--json"], { + const result = await runLocalPaseo(["--host", "127.0.0.1:1", "daemon", "status", "--json"], { PASEO_HOME: paseoHome, }); - assert.strictEqual(result.exitCode, 0, `daemon status should stay local: ${result.stderr}`); - const parsed = JSON.parse(result.stdout); - assert.strictEqual( - parsed.localDaemon, - "stopped", - "daemon should report the local stopped state", - ); - console.log("✓ daemon status ignores a global --host\n"); + assert.strictEqual(result.exitCode, 1, "an explicit endpoint must not report local status"); + assert.match(result.stderr, /127.0.0.1:1/); } // Test 6: daemon stop handles daemon not running gracefully @@ -205,16 +197,12 @@ try { console.log("✓ daemon stop succeeds gracefully when daemon not running\n"); } - // Test 7: daemon restart starts daemon and can be stopped { - console.log("Test 7: daemon restart starts daemon and can be stopped"); - const result = await daemonCommand(["restart", "--port", String(port)]); - assert.strictEqual(result.exitCode, 0, "restart should succeed even when previously stopped"); - assert(result.stdout.toLowerCase().includes("restarted"), "output should report restart"); - - const cleanup = await daemonCommand(["stop", "--force"]); - assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart"); - console.log("✓ daemon restart starts and stop cleanup succeeds\n"); + const result = await daemonCommand(["restart", "--json"]); + assert.strictEqual(result.exitCode, 1, "restart must not start a stopped daemon"); + assert.match(result.stderr, /DAEMON_NOT_RUNNING/); + const configured = await daemonCommand(["config", "set", "daemon.listen", `127.0.0.1:${port}`]); + assert.strictEqual(configured.exitCode, 0, configured.stderr); } // Test 8: status uses the running daemon as relay authority over local IPC @@ -256,10 +244,10 @@ try { const relayProbe = await retryWhileWorkerRuns( worker, async () => { - const statusResult = await daemonCommand(["status", "--json"]); + const statusResult = await daemonCommand(["status", "--host", listen, "--json"]); const statusPayload = statusResult.exitCode === 0 ? JSON.parse(statusResult.stdout) : null; - const pairingResult = await daemonCommand(["pair", "--json"]); + const pairingResult = await daemonCommand(["pair", "--host", listen, "--json"]); return { statusResult, statusPayload, pairingResult }; }, (result) => @@ -280,8 +268,8 @@ try { assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable"); assert.strictEqual( payload.localDaemon, - "stopped", - "missing PID should report stopped locally", + undefined, + "endpoint status must not invent local PID state", ); assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state"); assert.strictEqual( @@ -303,6 +291,7 @@ try { const nestedReload = await daemonCommand(["reload", "--host", listen, "--json"]); assert.strictEqual(nestedReload.exitCode, 0, nestedReload.stderr); assert.deepStrictEqual(JSON.parse(nestedReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: ["daemon.listen"], @@ -315,6 +304,7 @@ try { }); assert.strictEqual(aliasReload.exitCode, 0, aliasReload.stderr); assert.deepStrictEqual(JSON.parse(aliasReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -323,6 +313,7 @@ try { const yamlReload = await daemonCommand(["reload", "--host", listen, "--format", "yaml"]); assert.strictEqual(yamlReload.exitCode, 0, yamlReload.stderr); assert.deepStrictEqual(YAML.parse(yamlReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], @@ -335,31 +326,16 @@ try { try { await writeFile( join(foreignHome, "config.json"), - `${JSON.stringify({ daemon: { listen } }, null, 2)}\n`, + `${JSON.stringify({ daemon: { listen, relay: { enabled: false } } }, null, 2)}\n`, "utf-8", ); - const foreignPairing = await retryWhileWorkerRuns( - worker, - () => - runLocalPaseo(["daemon", "pair", "--home", foreignHome, "--json"], { - PASEO_HOME: foreignHome, - }), - (result) => result.stderr.includes("different Paseo home"), - (result) => `Pairing did not report the daemon identity mismatch: ${result.stderr}`, - ); - assert.notStrictEqual( - foreignPairing.exitCode, - 0, - "pairing should reject a daemon owned by another home", - ); - assert( - foreignPairing.stderr.includes("different Paseo home"), - "pairing should explain the daemon identity mismatch", - ); - assert( - !foreignPairing.stdout.includes("#offer="), - "pairing should not expose another offer", + const foreignPairing = await runLocalPaseo( + ["daemon", "pair", "--home", foreignHome, "--json"], + { PASEO_HOME: foreignHome }, ); + assert.strictEqual(foreignPairing.exitCode, 1); + assert.match(foreignPairing.stderr, /RELAY_DISABLED/); + assert(!foreignPairing.stdout.includes("#offer=")); } finally { await rm(foreignHome, { recursive: true, force: true }); } @@ -369,14 +345,14 @@ try { console.log("✓ daemon status probes live relay state over local IPC\n"); } - // Test 9: --relay accepts an already-enabled persisted relay while stopped + // Test 9: explicit offline relay consent persists for subsequent pairing { - console.log("Test 9: daemon pair --relay accepts persisted relay while stopped"); + console.log("Test 9: daemon pair --relay persists offline relay consent"); const configPath = join(paseoHome, "config.json"); const config = JSON.parse(await readFile(configPath, "utf-8")); config.daemon = { ...config.daemon, - relay: { ...config.daemon?.relay, enabled: true }, + relay: { ...config.daemon?.relay, enabled: false }, }; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); @@ -390,7 +366,13 @@ try { const payload = JSON.parse(pairing.stdout); assert.strictEqual(payload.relayEnabled, true, "pairing should preserve persisted relay state"); assert.match(payload.url, /#offer=/, "pairing should include the offline offer"); - console.log("✓ daemon pair --relay accepts persisted relay while stopped\n"); + const persistedRelay = await daemonCommand(["config", "get", "daemon.relay.enabled", "--json"]); + assert.strictEqual(persistedRelay.exitCode, 0, persistedRelay.stderr); + assert.strictEqual(JSON.parse(persistedRelay.stdout).value, true); + const subsequentPairing = await daemonCommand(["pair", "--json"]); + assert.strictEqual(subsequentPairing.exitCode, 0, subsequentPairing.stderr); + assert.match(JSON.parse(subsequentPairing.stdout).url, /#offer=/); + console.log("✓ daemon pair --relay persists offline consent for subsequent pairing\n"); } } finally { // Best-effort daemon cleanup in case assertions fail before explicit stop. diff --git a/packages/cli/tests/04-agent-ls.test.ts b/packages/cli/tests/04-agent-ls.test.ts index 363eba5694..65b3bec0bc 100644 --- a/packages/cli/tests/04-agent-ls.test.ts +++ b/packages/cli/tests/04-agent-ls.test.ts @@ -23,12 +23,13 @@ import assert from "node:assert"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; +import { getAvailablePort } from "./helpers/network.ts"; import { runLocalPaseo } from "./helpers/local-cli.ts"; console.log("=== LS Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -62,7 +63,6 @@ try { console.log("Test 3: paseo ls handles daemon not running"); const result = await runLocalPaseo(["ls"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); @@ -74,8 +74,8 @@ try { assert(hasError, "error message should mention connection issue"); assert.match( output, - /--host .*PASEO_HOST/s, - "the recovery message should explain both remote connection inputs", + /Check the selected endpoint and credentials/, + "the recovery message should explain how to check the selected endpoint", ); console.log("✓ paseo ls handles daemon not running\n"); } @@ -85,7 +85,6 @@ try { console.log("Test 4: paseo ls --json handles errors"); const result = await runLocalPaseo(["ls", "--json"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); @@ -109,7 +108,6 @@ try { console.log("Test 5: paseo ls -a flag is accepted"); const result = await runLocalPaseo(["ls", "-a"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Will fail due to no daemon, but flag should be parsed without error // (no "unknown option" error) @@ -124,7 +122,6 @@ try { console.log("Test 6: paseo ls -g flag is accepted"); const result = await runLocalPaseo(["ls", "-g"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -g flag"); @@ -137,7 +134,6 @@ try { console.log("Test 7: paseo ls -ag combined flags are accepted"); const result = await runLocalPaseo(["ls", "-ag"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -ag flags"); @@ -150,7 +146,6 @@ try { console.log("Test 8: -q (quiet) flag is accepted"); const result = await runLocalPaseo(["-q", "ls"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); @@ -163,7 +158,6 @@ try { console.log("Test 9: paseo ls --ui is rejected"); const result = await runLocalPaseo(["ls", "--ui"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag"); const output = result.stdout + result.stderr; @@ -185,17 +179,23 @@ try { console.log("✓ global --host targets the requested daemon\n"); } - // Test 11: the last explicit --host wins + // Test 11: conflicting explicit --host selectors are rejected { - console.log("Test 11: the last explicit --host wins"); + console.log("Test 11: conflicting explicit --host selectors are rejected"); const firstHost = `localhost:${port}`; - const lastHost = `localhost:${port + 1}`; + const lastHost = `localhost:${await getAvailablePort()}`; const result = await runLocalPaseo(["--host", firstHost, "ls", "--host", lastHost]); const output = result.stdout + result.stderr; - assert.notStrictEqual(result.exitCode, 0, "should fail when the selected daemon is absent"); - assert(output.includes(lastHost), "connection error should name the last explicit host"); - assert(!output.includes(firstHost), "the earlier host should be fully overridden"); - console.log("✓ the last explicit --host wins\n"); + assert.notStrictEqual(result.exitCode, 0, "should reject conflicting explicit selectors"); + assert( + output.includes("Conflicting duplicate --host selectors."), + "should report the selector conflict", + ); + assert( + !output.includes("Cannot connect"), + "must reject the selectors before attempting a connection", + ); + console.log("✓ conflicting explicit --host selectors are rejected\n"); } } finally { // Clean up temp directory diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 0af2a6ec7c..5c56cadfbf 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -21,6 +21,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; @@ -30,8 +31,8 @@ $.verbose = false; console.log("=== Run Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); const schemaPath = join(paseoHome, "run-output-schema.json"); await writeFile( @@ -72,7 +73,7 @@ try { { console.log("Test 2: run requires prompt argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without prompt"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -88,7 +89,7 @@ try { { console.log("Test 3: run handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider claude "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider claude "test prompt"`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -104,7 +105,7 @@ try { { console.log("Test 4: run -d flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run -d "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -d flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -115,7 +116,7 @@ try { { console.log("Test 5: run --name flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --name "test-agent" "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --name flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -126,7 +127,7 @@ try { { console.log("Test 6: run --provider flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --provider flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -137,7 +138,7 @@ try { { console.log("Test 6b: run --provider provider/model syntax is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex/gpt-5.4 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept provider/model syntax"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -148,7 +149,7 @@ try { { console.log("Test 7: run --mode flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --mode bypass "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --mode flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -159,7 +160,7 @@ try { { console.log("Test 8: run --cwd flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --cwd /tmp "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --cwd flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -170,7 +171,7 @@ try { { console.log("Test 9: run --output-schema flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --output-schema ${schemaPath} "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --output-schema flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -181,7 +182,7 @@ try { { console.log("Test 10: run --output-schema cannot be used with --background"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema"); const output = result.stdout + result.stderr; assert( @@ -195,7 +196,7 @@ try { { console.log("Test 11: -q (quiet) flag is accepted with run"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q run -d "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -206,7 +207,7 @@ try { { console.log("Test 12: Combined flags work together"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept all combined flags"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -217,7 +218,7 @@ try { { console.log("Test 12b: conflicting provider/model syntax is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting model inputs"); const output = result.stdout + result.stderr; assert( @@ -240,7 +241,7 @@ try { { console.log("Test 14: run --ui is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --ui "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --ui "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag"); const output = result.stdout + result.stderr; assert(output.includes("unknown option"), "should report unknown option for --ui"); @@ -251,7 +252,7 @@ try { { console.log("Test 15: run --new-workspace is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --new-workspace local "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --new-workspace local "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --new-workspace"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -262,7 +263,7 @@ try { { console.log("Test 16: run --isolation is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --isolation local "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --isolation local "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --isolation flag"); const output = result.stdout + result.stderr; assert(output.includes("unknown option"), "should report unknown option for --isolation"); diff --git a/packages/cli/tests/06-agent-send.test.ts b/packages/cli/tests/06-agent-send.test.ts index 60fd9aac72..ca28e26f56 100644 --- a/packages/cli/tests/06-agent-send.test.ts +++ b/packages/cli/tests/06-agent-send.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Send Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); const promptFilePath = join(paseoHome, "send-prompt.txt"); await writeFile(promptFilePath, "prompt from file"); @@ -58,7 +59,7 @@ try { { console.log("Test 2: send requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -74,7 +75,7 @@ try { { console.log("Test 3: send requires prompt argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without prompt"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -90,7 +91,7 @@ try { { console.log("Test 4: send handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123 "test prompt"`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -106,7 +107,7 @@ try { { console.log("Test 5: send --no-wait flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --no-wait abc123 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --no-wait flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -117,7 +118,7 @@ try { { console.log("Test 5b: send --prompt flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt "test prompt" abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --prompt "test prompt" abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --prompt flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -128,7 +129,7 @@ try { { console.log("Test 5c: send --prompt-file flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt-file ${promptFilePath} abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --prompt-file ${promptFilePath} abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --prompt-file flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -150,7 +151,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with send"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q send --no-wait abc123 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -161,7 +162,7 @@ try { { console.log("Test 8: Combined flags work together"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q send --no-wait abc123 "Run the linter"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept all combined flags"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -172,7 +173,7 @@ try { { console.log("Test 8b: conflicting prompt sources are rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "positional prompt" --prompt "flag prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123 "positional prompt" --prompt "flag prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting prompt sources"); const output = result.stdout + result.stderr; assert( diff --git a/packages/cli/tests/07-agent-stop.test.ts b/packages/cli/tests/07-agent-stop.test.ts index 0b20028ec0..81b0cba66c 100644 --- a/packages/cli/tests/07-agent-stop.test.ts +++ b/packages/cli/tests/07-agent-stop.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Stop Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -48,7 +49,7 @@ try { { console.log("Test 2: stop requires ID, --all, or --cwd"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id, --all, or --cwd"); const output = result.stdout + result.stderr; const hasError = @@ -64,7 +65,7 @@ try { { console.log("Test 3: stop handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -80,7 +81,7 @@ try { { console.log("Test 4: stop --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -91,7 +92,7 @@ try { { console.log("Test 5: stop --cwd flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop --cwd /tmp`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --cwd flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -102,7 +103,7 @@ try { { console.log("Test 6: stop with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -122,7 +123,7 @@ try { { console.log("Test 8: -q (quiet) flag is accepted with stop"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q stop abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/08-agent-logs.test.ts b/packages/cli/tests/08-agent-logs.test.ts index f857bffdff..672f4a2dd0 100644 --- a/packages/cli/tests/08-agent-logs.test.ts +++ b/packages/cli/tests/08-agent-logs.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Logs Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -51,7 +52,7 @@ try { { console.log("Test 2: logs requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -67,7 +68,7 @@ try { { console.log("Test 3: logs handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -84,7 +85,7 @@ try { console.log("Test 4: logs -f (follow) flag is accepted"); // Use timeout to avoid hanging on follow mode const result = - await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow(); + await $`timeout 1 bash -c 'PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs -f abc123' || true`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -f flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -95,7 +96,7 @@ try { { console.log("Test 5: logs --follow flag is accepted"); const result = - await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow(); + await $`timeout 1 bash -c 'PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs --follow abc123' || true`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --follow flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -106,7 +107,7 @@ try { { console.log("Test 6: logs --tail flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs --tail 50 abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --tail flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -117,7 +118,7 @@ try { { console.log("Test 7: logs with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -137,7 +138,7 @@ try { { console.log("Test 9: -q (quiet) flag is accepted with logs"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q logs abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/09-agent-inspect.test.ts b/packages/cli/tests/09-agent-inspect.test.ts index 0ae33343d7..9c449971bd 100644 --- a/packages/cli/tests/09-agent-inspect.test.ts +++ b/packages/cli/tests/09-agent-inspect.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Inspect Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -48,7 +49,7 @@ try { { console.log("Test 2: inspect requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -64,7 +65,7 @@ try { { console.log("Test 3: inspect handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -91,7 +92,7 @@ try { { console.log("Test 5: -q (quiet) flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q inspect abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -102,7 +103,7 @@ try { { console.log("Test 6: --json flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123 --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect abc123 --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -113,7 +114,7 @@ try { { console.log("Test 7: --format yaml flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} --format yaml inspect abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --format yaml flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/10-agent-mode.test.ts b/packages/cli/tests/10-agent-mode.test.ts index 0aa492ea4f..e44898c03e 100644 --- a/packages/cli/tests/10-agent-mode.test.ts +++ b/packages/cli/tests/10-agent-mode.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Agent Mode Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -47,7 +48,7 @@ try { { console.log("Test 2: agent mode requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -63,7 +64,7 @@ try { { console.log("Test 3: agent mode handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123 bypass`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -79,7 +80,7 @@ try { { console.log("Test 4: agent mode --list flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode --list abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --list flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -90,7 +91,7 @@ try { { console.log("Test 5: agent mode with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123 plan --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -110,7 +111,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with agent mode"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q agent mode abc123 bypass`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -121,7 +122,7 @@ try { { console.log("Test 8: agent mode requires mode argument when not using --list"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123`.nothrow(); // Should fail because mode is required unless --list is specified assert.notStrictEqual(result.exitCode, 0, "should fail without mode argument"); const output = result.stdout + result.stderr; diff --git a/packages/cli/tests/11-agent-archive.test.ts b/packages/cli/tests/11-agent-archive.test.ts index 7493395a2b..0685e2948b 100644 --- a/packages/cli/tests/11-agent-archive.test.ts +++ b/packages/cli/tests/11-agent-archive.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Agent Archive Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -46,7 +47,7 @@ try { { console.log("Test 2: agent archive requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -61,7 +62,7 @@ try { { console.log("Test 3: agent archive handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -77,7 +78,7 @@ try { { console.log("Test 4: agent archive --force flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123 --force`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --force flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -88,7 +89,7 @@ try { { console.log("Test 5: agent archive with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -108,7 +109,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with agent archive"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q agent archive abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/11-agent-wait.test.ts b/packages/cli/tests/11-agent-wait.test.ts index 8984bc0c4e..198fa08910 100644 --- a/packages/cli/tests/11-agent-wait.test.ts +++ b/packages/cli/tests/11-agent-wait.test.ts @@ -19,6 +19,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -28,8 +29,8 @@ $.verbose = false; console.log("=== Wait Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -51,7 +52,7 @@ try { { console.log("Test 2: wait requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -67,7 +68,7 @@ try { { console.log("Test 3: wait handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -105,7 +106,7 @@ try { { console.log("Test 6: -q (quiet) flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q wait abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -116,7 +117,7 @@ try { { console.log("Test 7: --json flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123 --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait abc123 --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -127,7 +128,7 @@ try { { console.log("Test 8: --format yaml flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} --format yaml wait abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --format yaml flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/12-permit-ls.test.ts b/packages/cli/tests/12-permit-ls.test.ts index c4549ae6a7..44b9a720af 100644 --- a/packages/cli/tests/12-permit-ls.test.ts +++ b/packages/cli/tests/12-permit-ls.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Permit LS Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -55,7 +56,7 @@ try { { console.log("Test 3: permit ls handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit ls`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -71,7 +72,7 @@ try { { console.log("Test 4: permit ls --json handles errors"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit ls --json`.nothrow(); // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); // But output should be valid JSON if present @@ -93,7 +94,7 @@ try { { console.log("Test 5: -q (quiet) flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q permit ls`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/13-permit-allow-deny.test.ts b/packages/cli/tests/13-permit-allow-deny.test.ts index 51773da185..a44113726f 100644 --- a/packages/cli/tests/13-permit-allow-deny.test.ts +++ b/packages/cli/tests/13-permit-allow-deny.test.ts @@ -22,6 +22,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -31,8 +32,8 @@ $.verbose = false; console.log("=== Permit Allow/Deny Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -65,7 +66,7 @@ try { { console.log("Test 3: permit allow handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 req456`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -81,7 +82,7 @@ try { { console.log("Test 4: permit deny handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -97,7 +98,7 @@ try { { console.log("Test 5: permit allow --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -108,7 +109,7 @@ try { { console.log("Test 6: permit deny --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -119,7 +120,7 @@ try { { console.log("Test 7: permit deny --message flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456 --message "Not allowed"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --message flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -130,7 +131,7 @@ try { { console.log("Test 8: permit deny --interrupt flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456 --interrupt`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --interrupt flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -141,7 +142,7 @@ try { { console.log("Test 9: permit allow --input flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 req456 --input '{"key":"value"}'`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --input flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -152,7 +153,7 @@ try { { console.log("Test 10: permit allow requires req_id or --all"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123`.nothrow(); // This might fail due to daemon not running first, or due to missing argument // The important thing is it doesn't crash with an unhandled error assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all"); @@ -163,7 +164,7 @@ try { { console.log("Test 11: permit deny requires req_id or --all"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all"); console.log("✓ permit deny requires req_id or --all\n"); } diff --git a/packages/cli/tests/14-worktree.test.ts b/packages/cli/tests/14-worktree.test.ts index d1b30a541d..e0c4d7f9b5 100644 --- a/packages/cli/tests/14-worktree.test.ts +++ b/packages/cli/tests/14-worktree.test.ts @@ -19,6 +19,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -28,8 +29,8 @@ $.verbose = false; console.log("=== Worktree Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -56,7 +57,7 @@ try { { console.log("Test 3: worktree ls handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -72,7 +73,7 @@ try { { console.log("Test 4: worktree ls with --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -93,7 +94,7 @@ try { { console.log("Test 6: worktree archive requires name argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without name"); const output = result.stdout + result.stderr; const hasError = @@ -108,7 +109,7 @@ try { { console.log("Test 7: worktree archive handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive test-worktree`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -124,7 +125,7 @@ try { { console.log("Test 8: worktree archive with name and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive test-worktree --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -135,7 +136,7 @@ try { { console.log("Test 9: -q (quiet) flag is accepted with worktree ls"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q worktree ls`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -146,7 +147,7 @@ try { { console.log("Test 10: --json flag is accepted with worktree ls"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/16-agent-update.test.ts b/packages/cli/tests/16-agent-update.test.ts index 8e84ebf7b8..8ae54323bc 100644 --- a/packages/cli/tests/16-agent-update.test.ts +++ b/packages/cli/tests/16-agent-update.test.ts @@ -11,11 +11,11 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; -import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -43,7 +43,7 @@ try { { console.log("Test 2: agent update requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update --name "New Name"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -59,7 +59,7 @@ try { { console.log("Test 3: agent update requires update field"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without --name/--label"); const output = result.stdout + result.stderr; const hasError = @@ -74,7 +74,7 @@ try { { console.log("Test 4: agent update handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --name "Renamed Agent"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; const hasError = @@ -89,7 +89,7 @@ try { { console.log("Test 5: agent update accepts multi-label syntax"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --label flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -109,7 +109,7 @@ try { { console.log("Test 7: agent update accepts thinking as an update field"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --thinking high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --thinking high`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("Nothing to update"), "should treat --thinking as an update field"); console.log("✓ agent update accepts thinking as an update field\n"); @@ -119,7 +119,7 @@ try { { console.log("Test 8: thinking cannot be combined with metadata updates"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name renamed --thinking high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --name renamed --thinking high`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should reject a combined update"); const output = result.stdout + result.stderr; assert( diff --git a/packages/cli/tests/17-onboard.test.ts b/packages/cli/tests/17-onboard.test.ts index 3d8e641a26..a8b25148a2 100644 --- a/packages/cli/tests/17-onboard.test.ts +++ b/packages/cli/tests/17-onboard.test.ts @@ -5,6 +5,7 @@ import { readFile, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "zx"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -15,9 +16,19 @@ const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-")); const port = await getAvailablePort(); try { + const configured = await runLocalPaseo([ + "daemon", + "config", + "set", + "daemon.listen", + `127.0.0.1:${port}`, + "--home", + paseoHome, + ]); + assert.strictEqual(configured.exitCode, 0, configured.stderr); + console.log("Test 1: `paseo` runs blocking onboarding without implicit relay pairing"); - const onboard = - await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow(); + const onboard = await $`PASEO_HOME=${paseoHome} PASEO_PAIRING_QR=0 npx paseo`.nothrow(); assert.strictEqual( onboard.exitCode, @@ -37,8 +48,8 @@ try { assert(onboard.stdout.includes("paseo --help"), "onboard output should include --help shortcut"); assert(onboard.stdout.includes("paseo ls"), "onboard output should include ls shortcut"); assert( - onboard.stdout.includes('paseo run "your prompt"'), - "onboard output should include run shortcut", + onboard.stdout.includes(`paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`), + "onboard output should include a run shortcut for the selected home", ); assert(onboard.stdout.includes("paseo status"), "onboard output should include status shortcut"); assert( @@ -58,8 +69,7 @@ try { assert.strictEqual(enableRelay.exitCode, 0, `relay enable should succeed: ${enableRelay.stderr}`); assert(enableRelay.stdout.includes("#offer="), "relay enable should produce a pairing offer"); - const noRelayOnboard = - await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} npx paseo --no-relay`.nothrow(); + const noRelayOnboard = await $`PASEO_HOME=${paseoHome} npx paseo --no-relay`.nothrow(); assert.strictEqual( noRelayOnboard.exitCode, 0, diff --git a/packages/cli/tests/18-local-daemon-utils.test.ts b/packages/cli/tests/18-local-daemon-utils.test.ts deleted file mode 100644 index 89780a6731..0000000000 --- a/packages/cli/tests/18-local-daemon-utils.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * Phase 18: Local daemon utility tests. - * - * Tests pure helpers that do not require a running daemon. - */ - -import assert from "node:assert"; -import { resolveTcpHostFromListen } from "../src/commands/daemon/local-daemon.js"; - -console.log("=== Local Daemon Utility Helpers ===\n"); - -{ - console.log("Test 1: resolves numeric listen values to localhost host:port"); - assert.strictEqual(resolveTcpHostFromListen("6767"), "127.0.0.1:6767"); - assert.strictEqual(resolveTcpHostFromListen(" 7777 "), "127.0.0.1:7777"); - console.log("✓ resolves numeric listen values\n"); -} - -{ - console.log("Test 2: preserves explicit host:port listen values"); - assert.strictEqual(resolveTcpHostFromListen("localhost:6767"), "localhost:6767"); - assert.strictEqual(resolveTcpHostFromListen("0.0.0.0:8080"), "0.0.0.0:8080"); - console.log("✓ preserves explicit host:port values\n"); -} - -{ - console.log("Test 3: rejects unix socket listen values"); - assert.strictEqual(resolveTcpHostFromListen("/tmp/paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("unix:///tmp/paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("pipe://\\\\.\\pipe\\paseo-managed-test"), null); - assert.strictEqual(resolveTcpHostFromListen("\\\\.\\pipe\\paseo-managed-test"), null); - console.log("✓ rejects unix socket listen values\n"); -} - -{ - console.log("Test 4: rejects empty and non-host listen values"); - assert.strictEqual(resolveTcpHostFromListen(""), null); - assert.strictEqual(resolveTcpHostFromListen(" "), null); - assert.strictEqual(resolveTcpHostFromListen("localhost"), null); - console.log("✓ rejects empty and non-host listen values\n"); -} - -{ - console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)"); - assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null); - assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null); - console.log("✓ rejects Windows absolute paths\n"); -} - -console.log("=== All local daemon utility tests passed ==="); diff --git a/packages/cli/tests/20-worktree-ls-paths.test.ts b/packages/cli/tests/20-worktree-ls-paths.test.ts deleted file mode 100644 index d73cb5ca5e..0000000000 --- a/packages/cli/tests/20-worktree-ls-paths.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env npx tsx - -import assert from "node:assert"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from "../src/commands/worktree/ls.js"; - -console.log("=== Worktree LS Path Helper Tests ===\n"); - -const originalPaseoHome = process.env.PASEO_HOME; - -try { - { - console.log("Test 1: resolves explicit PASEO_HOME when set"); - process.env.PASEO_HOME = "/tmp/paseo-explicit-home"; - - assert.strictEqual(resolvePaseoHomePath(), "/tmp/paseo-explicit-home"); - assert.strictEqual(resolvePaseoWorktreesDir(), "/tmp/paseo-explicit-home/worktrees"); - console.log("\u2713 explicit PASEO_HOME is respected\n"); - } - - { - console.log("Test 2: falls back to homedir/.paseo when PASEO_HOME is unset"); - delete process.env.PASEO_HOME; - - assert.strictEqual(resolvePaseoHomePath(), join(homedir(), ".paseo")); - assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), ".paseo", "worktrees")); - console.log("\u2713 fallback home path is derived from os.homedir()\n"); - } -} finally { - if (originalPaseoHome === undefined) { - delete process.env.PASEO_HOME; - } else { - process.env.PASEO_HOME = originalPaseoHome; - } -} - -console.log("=== All worktree ls path helper tests passed ==="); diff --git a/packages/cli/tests/22-daemon-stop-supervisor.test.ts b/packages/cli/tests/22-daemon-stop-supervisor.test.ts index f6687703fa..979e866003 100644 --- a/packages/cli/tests/22-daemon-stop-supervisor.test.ts +++ b/packages/cli/tests/22-daemon-stop-supervisor.test.ts @@ -213,8 +213,9 @@ try { ); const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs); assert( - capturedSupervisorLogs.includes('"msg":"Worker requested shutdown"') && - capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"'), + process.platform === "win32" + ? capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"') + : capturedSupervisorLogs.includes("supervisor_received_SIGTERM"), `stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`, ); assert( diff --git a/packages/cli/tests/25-daemon-restart-supervisor.test.ts b/packages/cli/tests/25-daemon-restart-supervisor.test.ts index 040fba7f2d..d02371f722 100644 --- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts +++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts @@ -6,16 +6,20 @@ */ import assert from "node:assert"; -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { $ } from "zx"; -import { tryConnectToDaemon } from "../src/utils/client.ts"; +import { WebSocket } from "ws"; +import { + DaemonClient, + DaemonConnectionError, + type WebSocketLike, +} from "@getpaseo/client/internal/daemon-client"; +import { readDaemonInstance, isSameDaemonInstance } from "@getpaseo/server"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; -$.verbose = false; - const pollIntervalMs = 100; const testEnv = { PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0", @@ -40,60 +44,6 @@ function isProcessRunning(pid: number): boolean { } } -function readWorkerPid(supervisorPid: number): number | null { - if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) { - return null; - } - - const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" }); - if (result.status !== 0 || result.error) { - return null; - } - - for (const line of result.stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const [pidToken, ppidToken] = trimmed.split(/\s+/); - const pid = Number.parseInt(pidToken ?? "", 10); - const ppid = Number.parseInt(ppidToken ?? "", 10); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) { - continue; - } - if (ppid === supervisorPid && pid > 0) { - return pid; - } - } - - return null; -} - -interface DaemonStatus { - localDaemon: string | null; - pid: number | null; -} - -async function readDaemonStatus(paseoHome: string): Promise { - const result = - await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow(); - if (result.exitCode !== 0) { - return { localDaemon: null, pid: null }; - } - - try { - const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown }; - const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null; - const pid = - typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0 - ? parsed.pid - : null; - return { localDaemon, pid }; - } catch { - return { localDaemon: null, pid: null }; - } -} - async function readCapturedSupervisorLogs(paseoHome: string, recentLogs: string): Promise { const durableLogs = await readFile(join(paseoHome, "daemon.log"), "utf8").catch(() => ""); return `${recentLogs}\n${durableLogs}`; @@ -125,8 +75,30 @@ const host = `127.0.0.1:${port}`; let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; +let client: DaemonClient | undefined; +const availabilityLog = join(paseoHome, "availability.log"); try { + if (process.platform !== "win32") { + const provider = join(paseoHome, "slow-provider"); + await writeFile( + provider, + `#!${process.execPath} +import('node:fs').then(({appendFileSync}) => { + appendFileSync(${JSON.stringify(availabilityLog)}, JSON.stringify(process.argv.slice(2)) + "\\n"); + setTimeout(() => console.log('provider 1.0.0'), 1800); +}); +`, + { mode: 0o700 }, + ); + await writeFile( + join(paseoHome, "config.json"), + JSON.stringify({ + version: 1, + agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } }, + }), + ); + } console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); supervisorProcess = spawn( @@ -135,7 +107,11 @@ try { { cwd: cliRoot, env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + HOME: paseoHome, + USERPROFILE: paseoHome, ...testEnv, PASEO_HOME: paseoHome, PASEO_LISTEN: host, @@ -153,80 +129,90 @@ try { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); }); + let supervisor = await readDaemonInstance(paseoHome); await waitFor( async () => { - const status = await readDaemonStatus(paseoHome); - return ( - status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid) - ); + supervisor = await readDaemonInstance(paseoHome); + return supervisor?.pid === supervisorProcess?.pid && Boolean(supervisor?.listen); }, 120000, - "daemon did not become running in time", + "daemon did not publish its bound endpoint in time", ); - - const statusBeforeRestart = await readDaemonStatus(paseoHome); - const supervisorPid = statusBeforeRestart.pid; - assert.strictEqual( - statusBeforeRestart.localDaemon, - "running", - "daemon should be running before restart", - ); - assert(supervisorPid !== null, "supervisor pid should exist once daemon starts"); + assert(supervisor?.listen, "owned supervisor should publish its bound endpoint"); + const supervisorPid = supervisor.pid; + assert.strictEqual(supervisorPid, supervisorProcess.pid, "PID lock should identify this launch"); assert(isProcessRunning(supervisorPid), "supervisor process should be running"); - const workerPidBeforeRestart = readWorkerPid(supervisorPid); - assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart"); + + client = new DaemonClient({ + url: `ws://${supervisor.listen}/ws`, + clientId: "supervisor-restart-test", + connectTimeoutMs: 5000, + webSocketFactory: (url) => new WebSocket(url) as unknown as WebSocketLike, + }); + await client.connect(); + // The app reads worker identity through the full RPC, not the bounded CLI observation. + const statusBeforeRestart = await client.getDaemonStatus(); + const workerPidBeforeRestart = statusBeforeRestart.pid; assert( isProcessRunning(workerPidBeforeRestart), "worker process should be running before restart", ); + if (process.platform !== "win32") { + assert.match( + await readFile(availabilityLog, "utf8"), + /\["--version"\]/, + "the configured slow provider was actually probed", + ); + } console.log( `✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`, ); console.log("Test 2: app-style restart request should restart worker and keep daemon healthy"); - const client = await tryConnectToDaemon({ host, timeout: 5000 }); - assert(client, "daemon client should connect"); - 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 restartAck = await client.restartServer("settings_update"); + assert.strictEqual( + restartAck.status, + "restart_requested", + "restart request should be acknowledged", + ); + const deadline = Date.now() + 20000; + let statusAfterRestart = statusBeforeRestart; await waitFor( - () => { - const workerPid = readWorkerPid(supervisorPid); - return ( - workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid) - ); + async () => { + try { + const observed = await client!.getDaemonStatus({ + timeout: Math.max(1, deadline - Date.now()), + }); + if (observed.pid === workerPidBeforeRestart || !isProcessRunning(observed.pid)) + return false; + statusAfterRestart = observed; + return true; + } catch (error) { + if (error instanceof DaemonConnectionError) return false; + throw error; + } }, 20000, "worker pid did not change after restart request", ); - - const workerPidAfterRestart = readWorkerPid(supervisorPid); - assert(workerPidAfterRestart !== null, "worker process should exist after restart"); assert.notStrictEqual( - workerPidAfterRestart, + statusAfterRestart.pid, workerPidBeforeRestart, "worker pid should change after restart", ); - - const statusAfterRestart = await readDaemonStatus(paseoHome); + assert(isProcessRunning(statusAfterRestart.pid), "replacement worker should remain running"); + const current = await readDaemonInstance(paseoHome); + assert(current?.listen, "daemon should remain bound after restart"); assert.strictEqual( - statusAfterRestart.localDaemon, - "running", - "daemon should stay running after restart", - ); - assert.strictEqual( - statusAfterRestart.pid, + current.pid, supervisorPid, "supervisor pid should remain stable across restart", ); + assert( + isSameDaemonInstance(supervisor, current), + "supervisor start time should remain stable across restart", + ); const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs); assert( capturedSupervisorLogs.includes('"msg":"Worker requested restart"') && @@ -243,6 +229,7 @@ try { ); console.log("✓ app-style restart keeps daemon healthy and restarts worker\n"); } finally { + await client?.close(); if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) { supervisorProcess.kill("SIGTERM"); await waitFor( @@ -254,7 +241,7 @@ try { }); } - await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow(); + await runLocalPaseo(["daemon", "stop", "--home", paseoHome, "--force"]); await rm(paseoHome, { recursive: true, force: true }); } diff --git a/packages/cli/tests/27-agent-delete.test.ts b/packages/cli/tests/27-agent-delete.test.ts index 64ab4007bf..bec523f9ca 100644 --- a/packages/cli/tests/27-agent-delete.test.ts +++ b/packages/cli/tests/27-agent-delete.test.ts @@ -11,27 +11,23 @@ */ import assert from "node:assert"; -import { $ } from "zx"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; +import { getAvailablePort } from "./helpers/network.ts"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; - -$.verbose = false; +import { join } from "path"; console.log("=== Delete Command Tests ===\n"); -const cliRoot = dirname(fileURLToPath(import.meta.url)); -const repoRoot = join(cliRoot, "..", "..", ".."); -const port = 10000 + Math.floor(Math.random() * 50000); +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-delete-test-home-")); async function runCli(args: string[]) { - return $`npm --prefix ${repoRoot} run cli -- ${args}`.nothrow(); + return runLocalPaseo(["--host", `localhost:${port}`, ...args], { PASEO_HOME: paseoHome }); } async function runDelete(args: string[]) { - return $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- delete ${args}`.nothrow(); + return runCli(["delete", ...args]); } try { @@ -110,8 +106,7 @@ try { { console.log("Test 8: -q (quiet) flag is accepted with delete"); - const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- -q delete abc123`.nothrow(); + const result = await runCli(["-q", "delete", "abc123"]); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/28-client-ipc-targets.test.ts b/packages/cli/tests/28-client-ipc-targets.test.ts index 24ceb9a632..3072927df0 100644 --- a/packages/cli/tests/28-client-ipc-targets.test.ts +++ b/packages/cli/tests/28-client-ipc-targets.test.ts @@ -1,16 +1,13 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { getDaemonHost, normalizeDaemonHost, resolveDaemonPassword, resolveDaemonTarget, - resolveDefaultDaemonHosts, } from "../src/utils/client.js"; +import { selectDaemonTarget } from "../src/utils/daemon-target.js"; import { resolveCliVersion } from "../src/version.js"; console.log("=== CLI IPC Target Helpers ===\n"); @@ -81,48 +78,13 @@ console.log("=== CLI IPC Target Helpers ===\n"); } { - console.log("Test 6: default host resolution tries local IPC first, then localhost fallback"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-")); - try { - mkdirSync(paseoHome, { recursive: true }); - writeFileSync( - path.join(paseoHome, "paseo.pid"), - JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-from-pid.sock" }), - ); - assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [ - "unix:///tmp/paseo-from-pid.sock", - "localhost:6767", - ]); - const previousHome = process.env.PASEO_HOME; - const previousHost = process.env.PASEO_HOST; - process.env.PASEO_HOME = paseoHome; - delete process.env.PASEO_HOST; - assert.strictEqual(getDaemonHost(), "unix:///tmp/paseo-from-pid.sock"); - if (previousHome === undefined) delete process.env.PASEO_HOME; - else process.env.PASEO_HOME = previousHome; - if (previousHost === undefined) delete process.env.PASEO_HOST; - else process.env.PASEO_HOST = previousHost; - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ default host resolution tries local IPC first, then localhost fallback\n"); -} - -{ - console.log("Test 7: configured TCP host is preserved before the localhost fallback"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-")); - try { - assert.deepStrictEqual( - resolveDefaultDaemonHosts({ - PASEO_HOME: paseoHome, - PASEO_LISTEN: "127.0.0.1:7777", - }), - ["127.0.0.1:7777", "localhost:6767"], - ); - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ configured TCP host is preserved before the localhost fallback\n"); + const target = selectDaemonTarget( + { home: "/tmp/selected-home" }, + { PASEO_HOST: "ignored:12345", PASEO_LISTEN: "ignored:23456" }, + ); + assert.deepStrictEqual(target, { kind: "instance", home: "/tmp/selected-home" }); + assert.strictEqual(getDaemonHost({ target }), "home /tmp/selected-home"); + assert.throws(() => selectDaemonTarget({}, { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" })); } { @@ -131,28 +93,6 @@ console.log("=== CLI IPC Target Helpers ===\n"); console.log("✓ CLI app version resolves for daemon hello compatibility\n"); } -{ - console.log("Test 9: local IPC still takes priority over configured TCP hosts"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-")); - try { - mkdirSync(paseoHome, { recursive: true }); - writeFileSync( - path.join(paseoHome, "paseo.pid"), - JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-priority.sock" }), - ); - assert.deepStrictEqual( - resolveDefaultDaemonHosts({ - PASEO_HOME: paseoHome, - PASEO_LISTEN: "127.0.0.1:7777", - }), - ["unix:///tmp/paseo-priority.sock", "127.0.0.1:7777", "localhost:6767"], - ); - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ local IPC still takes priority over configured TCP hosts\n"); -} - { console.log("Test 10: daemon password resolution prefers TCP URI query, falls back to env"); const previousEnv = process.env.PASEO_PASSWORD; diff --git a/packages/cli/tests/32-daemon-set-password.test.ts b/packages/cli/tests/32-daemon-set-password.test.ts index 2dab1d9561..5756cfe0a0 100644 --- a/packages/cli/tests/32-daemon-set-password.test.ts +++ b/packages/cli/tests/32-daemon-set-password.test.ts @@ -51,7 +51,10 @@ try { const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8")); assert.strictEqual(result.configPath, join(paseoHome, "config.json")); - assert.strictEqual(result.restartCommand, "paseo daemon restart"); + assert.strictEqual( + result.restartCommand, + `paseo daemon restart --home ${JSON.stringify(paseoHome)}`, + ); assert.strictEqual(config.daemon.listen, "127.0.0.1:9999"); assert.strictEqual(config.daemon.relay.enabled, false); assert.notStrictEqual(config.daemon.auth.password, "shared-secret"); @@ -68,6 +71,7 @@ try { const result = await runSetPasswordCommand( { home: paseoHome, + daemonTarget: { kind: "instance", home: paseoHome }, promptPassword: promptSequence(["new-secret", "new-secret"]), }, {} as Command, @@ -88,6 +92,7 @@ try { runSetPasswordCommand( { home: paseoHome, + daemonTarget: { kind: "instance", home: paseoHome }, promptPassword: promptSequence(["first-secret", "second-secret"]), }, {} as Command, diff --git a/packages/cli/tests/33-daemon-stop-tree-kill.test.ts b/packages/cli/tests/33-daemon-stop-tree-kill.test.ts index 5ee8af7b61..3a290d91b2 100644 --- a/packages/cli/tests/33-daemon-stop-tree-kill.test.ts +++ b/packages/cli/tests/33-daemon-stop-tree-kill.test.ts @@ -118,6 +118,8 @@ try { join(paseoHome, "paseo.pid"), JSON.stringify({ pid: ownerProcess.pid, + hostname: "test", + uid: process.getuid?.() ?? -1, listen: "127.0.0.1:1", startedAt: new Date().toISOString(), }), @@ -153,15 +155,11 @@ try { action: parsed.action, forced: parsed.forced, usedLifecycleRpc: parsed.usedLifecycleRpc, - reason: parsed.reason, - message: parsed.message, }, { action: "stopped", forced: true, usedLifecycleRpc: false, - reason: "owner_pid_sigkill", - message: "Daemon owner process was force-stopped", }, `stop should report forced tree cleanup: ${stopResult.stdout}`, ); diff --git a/packages/cli/tests/34-daemon-status-auth.test.ts b/packages/cli/tests/34-daemon-status-auth.test.ts index bb683ed027..ebbad314a2 100644 --- a/packages/cli/tests/34-daemon-status-auth.test.ts +++ b/packages/cli/tests/34-daemon-status-auth.test.ts @@ -1,6 +1,9 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runLocalPaseo } from "./helpers/local-cli.ts"; import { startTestDaemon } from "./helpers/test-daemon.ts"; @@ -26,7 +29,7 @@ try { assert.strictEqual(status.connectedDaemon, "auth_required"); assert(!("runningAgents" in status), "status should not fetch agent counts"); assert(!("idleAgents" in status), "status should not fetch agent counts"); - assert.match(status.note, /requires a password/i); + assert.match(status.note, /Password required/i); assert.doesNotMatch(status.note, /not reachable/i); console.log("✓ missing password reports auth_required\n"); } @@ -44,7 +47,7 @@ try { assert.strictEqual(status.localDaemon, "running"); assert.strictEqual(status.connectedDaemon, "auth_failed"); - assert.match(status.note, /password was rejected/i); + assert.match(status.note, /Incorrect password/i); assert.doesNotMatch(status.note, /not reachable/i); console.log("✓ wrong password reports auth_failed\n"); } @@ -70,4 +73,78 @@ try { await daemon.stop(); } +// POSIX executable probing executes --version; Windows resolves executables differently. +if (process.platform !== "win32") { + const root = await mkdtemp(join(tmpdir(), "paseo status slow provider ")); + const home = join(root, "daemon"); + const workDir = join(root, "work"); + const provider = join(root, "slow-provider"); + const marker = join(root, "availability.log"); + let slowDaemon: Awaited> | undefined; + try { + await mkdir(home); + await mkdir(workDir); + await writeFile( + provider, + `#!${process.execPath} +import('node:fs').then(({appendFileSync}) => { + appendFileSync(${JSON.stringify(marker)}, JSON.stringify(process.argv.slice(2)) + "\\n"); + setTimeout(() => console.log('provider 1.0.0'), 1800); +}); +`, + { mode: 0o700 }, + ); + await writeFile( + join(home, "config.json"), + JSON.stringify({ + version: 1, + agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } }, + }), + ); + slowDaemon = await startTestDaemon({ + paseoHome: home, + workDir, + env: { PASEO_PASSWORD: "shared-secret" }, + }); + console.log("Test 4: local status separates authenticated reachability from slow details"); + const local = await runLocalPaseo(["daemon", "status", "--home", home, "--json"], { + PASEO_PASSWORD: "shared-secret", + }); + assert.strictEqual(local.exitCode, 0, local.stderr); + const status = JSON.parse(local.stdout); + assert.strictEqual(status.localDaemon, "running"); + assert.strictEqual(status.connectedDaemon, "reachable", JSON.stringify(status)); + assert.strictEqual(typeof status.serverId, "string"); + assert.match(status.note, /DAEMON_REQUEST_TIMEOUT/); + assert.match(status.note, /Status details unavailable/); + assert(!("workerPid" in status), "must not invent worker facts"); + assert(!("providers" in status), "must not invent provider facts"); + assert.match( + await readFile(marker, "utf8"), + /\["--version"\]/, + "the public provider executable was actually probed", + ); + console.log("✓ local authenticated connection remains reachable when details time out\n"); + + console.log("Test 5: explicit endpoint status remains an error when details time out"); + const remote = await runLocalPaseo( + ["daemon", "status", "--host", `127.0.0.1:${slowDaemon.port}`, "--json"], + { PASEO_PASSWORD: "shared-secret" }, + ); + assert.notStrictEqual(remote.exitCode, 0); + const { error } = JSON.parse(remote.stderr); + assert.strictEqual(error.code, "DAEMON_REQUEST_TIMEOUT"); + assert.match(error.message, /Status details unavailable/); + assert.strictEqual(error.details.connectedDaemon, "reachable"); + assert.strictEqual(error.details.serverId, status.serverId); + assert(!("home" in error.details), "endpoint observation must not invent local ownership"); + assert(!("workerPid" in error.details)); + assert(!("providers" in error.details)); + console.log("✓ failed remote query retains its error and authenticated connection fact\n"); + } finally { + await slowDaemon?.stop(); + await rm(root, { recursive: true, force: true }); + } +} + console.log("=== Daemon Status Auth Tests Passed ==="); diff --git a/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts b/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts index 91d789d1da..aaba355f67 100644 --- a/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts +++ b/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts @@ -1,8 +1,8 @@ #!/usr/bin/env npx tsx /** - * Regression: `paseo daemon stop` must stop a reachable daemon even when the - * local pid file points at a dead supervisor owner. + * Regression: `paseo daemon stop` must leave a reachable daemon alone when the + * selected home points at a dead supervisor owner. */ import assert from "node:assert"; @@ -12,6 +12,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "zx"; +import { connectToDaemon } from "../src/utils/client.js"; import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -146,21 +147,24 @@ try { await waitFor( async () => { - const status = await readDaemonStatus(paseoHome); - return status.localDaemon === "stale_pid" && status.connectedDaemon === "reachable"; + try { + const client = await connectToDaemon({ target: { kind: "endpoint", host }, timeout: 500 }); + await client.close(); + return true; + } catch { + return false; + } }, 120000, - "daemon did not enter stale_pid + reachable state in time", + "unowned worker did not become reachable in time", ); const statusBeforeStop = await readDaemonStatus(paseoHome); - assert.strictEqual(statusBeforeStop.pid, stalePid, "status should report the stale owner pid"); + assert.strictEqual(statusBeforeStop.pid, null, "status should not claim a dead owner is running"); assert(workerProcess.pid && isProcessRunning(workerProcess.pid), "worker should be running"); console.log(`✓ fixture has stale pid ${stalePid} and live worker ${workerProcess.pid}\n`); - console.log( - "Test 2: `paseo daemon stop` should stop reachable worker instead of saying not_running", - ); + console.log("Test 2: home-selected stop leaves the unowned reachable worker running"); const stopResult = await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow(); assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`); @@ -169,22 +173,11 @@ try { pid?: unknown; message?: unknown; }; - assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action"); - assert.strictEqual( - stopJson.pid, - String(stalePid), - "stop should report the stale pid it recovered from", - ); - assert.strictEqual( - stopJson.message, - "Daemon stopped gracefully", - "stop should route through lifecycle shutdown", - ); - - await waitFor( - () => !isProcessRunning(workerProcess?.pid ?? -1), - 15000, - "worker remained running after stop", + assert.strictEqual(stopJson.action, "not_running"); + assert.strictEqual(stopJson.pid, stalePid); + assert( + workerProcess.pid && isProcessRunning(workerProcess.pid), + "stop must not contact the stale endpoint", ); assert.strictEqual(existsSync(pidPath), false, "stale pid file should be removed after stop"); console.log("✓ stop recovered stale supervisor pid state\n"); diff --git a/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts b/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts index 313ec5c4c6..f7872b310b 100644 --- a/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts +++ b/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts @@ -1,43 +1,12 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; -import { spawn } from "node:child_process"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getAvailablePort } from "./helpers/network.ts"; -const CLI_ENTRY = join(import.meta.dirname, "..", "dist", "index.js"); - -interface CommandResult { - exitCode: number | null; - stdout: string; - stderr: string; -} - -function runLocalPaseo(args: string[], env: NodeJS.ProcessEnv): Promise { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [CLI_ENTRY, ...args], { - env: { ...process.env, ...env }, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on("error", reject); - child.on("close", (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); -} - if (process.platform !== "win32") { console.log("Skipping Windows daemon status process lookup regression on non-Windows."); process.exit(0); @@ -55,7 +24,16 @@ const env = { }; try { - const start = await runLocalPaseo(["daemon", "restart", "--port", String(port)], env); + for (const [field, value] of [ + ["daemon.listen", `127.0.0.1:${port}`], + ["daemon.relay.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ]) { + const saved = await runLocalPaseo(["daemon", "config", "set", field!, value!], env); + assert.equal(saved.exitCode, 0, saved.stderr); + } + const start = await runLocalPaseo(["daemon", "start"], env); assert.strictEqual( start.exitCode, 0, diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index 4cb407a787..02c5fab136 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -44,7 +44,9 @@ async function main(): Promise { assert.equal(install.exitCode, 0, install.stderr); assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); - const client = await connectToDaemon({ host: `127.0.0.1:${context.port}` }); + const client = await connectToDaemon({ + target: { kind: "endpoint", host: `127.0.0.1:${context.port}` }, + }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.close(); diff --git a/packages/cli/tests/helpers/local-cli.ts b/packages/cli/tests/helpers/local-cli.ts index ad7f3a4524..e6d22bb0ff 100644 --- a/packages/cli/tests/helpers/local-cli.ts +++ b/packages/cli/tests/helpers/local-cli.ts @@ -1,5 +1,10 @@ import { $, ProcessPromise } from "zx"; import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const cliHome = mkdtempSync(join(tmpdir(), "paseo-test-cli-os-home-")); +process.once("exit", () => rmSync(cliHome, { recursive: true, force: true })); const CLI_ENTRY = join(import.meta.dirname, "..", "..", "dist", "index.js"); @@ -11,6 +16,13 @@ export function runLocalPaseo( $.verbose = false; return $({ cwd, - env: { ...process.env, ...env }, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...env, + HOME: cliHome, + USERPROFILE: cliHome, + }, })`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow(); } diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 8607b1cfb5..f8be7e6ead 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -169,19 +169,23 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st * Wait for daemon to be ready by running `paseo agent ls` * This connects via WebSocket and ensures the daemon is responsive */ -async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise { +async function probeDaemonReady( + port: number, + paseoHome: string, + env?: NodeJS.ProcessEnv, +): Promise { try { const { exitCode } = await runPaseoCli( { port, wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`, - paseoHome: "", + paseoHome, workDir: "", process: null, isReady: false, stop: async () => {}, }, - ["agent", "ls"], + ["agent", "ls", "--host", `${TEST_DAEMON_HOST}:${port}`], { env }, ); return exitCode === 0; @@ -192,13 +196,14 @@ async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise< async function waitForDaemonReady( port: number, + paseoHome: string, timeout = 30000, env?: NodeJS.ProcessEnv, ): Promise { const deadline = Date.now() + timeout; async function poll(): Promise { - if (await probeDaemonReady(port, env)) return; + if (await probeDaemonReady(port, paseoHome, env)) return; if (Date.now() >= deadline) { throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); } @@ -240,23 +245,23 @@ export async function startTestDaemon(options?: { const cliSrcPath = join(cliDir, "src", "index.ts"); // Start daemon process using tsx to run TypeScript directly - const daemonProcess = spawn( - process.execPath, - [TSX_ENTRY, cliSrcPath, "daemon", "start", "--foreground"], - { - env: { - ...process.env, - ...TEST_DAEMON_ENV_DEFAULTS, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, - // Force no TTY to prevent QR code output - CI: "true", - ...options?.env, - }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", + const daemonProcess = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, "daemon", "run"], { + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...TEST_DAEMON_ENV_DEFAULTS, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, + // Force no TTY to prevent QR code output + CI: "true", + ...options?.env, + HOME: paseoHome, + USERPROFILE: paseoHome, }, - ); + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }); const stdout = createOutputCapture(); const stderr = createOutputCapture(); @@ -319,7 +324,7 @@ export async function startTestDaemon(options?: { // Wait for daemon to be ready try { - await waitForDaemonReady(port, timeout, options?.env); + await waitForDaemonReady(port, paseoHome, timeout, options?.env); ctx.isReady = true; } catch (err) { // Daemon failed to start - clean up and rethrow @@ -358,11 +363,14 @@ export async function runPaseoCli( return new Promise((resolve, reject) => { const proc = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], { env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), ...TEST_DAEMON_ENV_DEFAULTS, - PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`, PASEO_HOME: ctx.paseoHome, ...options?.env, + HOME: ctx.paseoHome, + USERPROFILE: ctx.paseoHome, }, cwd, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/cli/tests/run-all.ts b/packages/cli/tests/run-all.ts index d3d4cc8b2e..7303f5408e 100644 --- a/packages/cli/tests/run-all.ts +++ b/packages/cli/tests/run-all.ts @@ -196,12 +196,17 @@ async function runSingleTest(testFile: string): Promise { const testName = testFile.replace(/\.test\.ts$/, ""); const startedAt = Date.now(); const npmCache = await mkdtemp(join(tmpdir(), "paseo-cli-test-npm-cache-")); + const osHome = await mkdtemp(join(tmpdir(), "paseo-cli-test-os-home-")); try { return await new Promise((resolve) => { const proc = spawn("npx", ["tsx", testPath], { env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + HOME: osHome, + USERPROFILE: osHome, PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter), npm_config_cache: npmCache, PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD, @@ -249,6 +254,7 @@ async function runSingleTest(testFile: string): Promise { }); } finally { await rm(npmCache, { recursive: true, force: true }); + await rm(osHome, { recursive: true, force: true }); } } diff --git a/packages/cli/tests/setup.ts b/packages/cli/tests/setup.ts index 94f872300f..5b860cf6c2 100644 --- a/packages/cli/tests/setup.ts +++ b/packages/cli/tests/setup.ts @@ -20,6 +20,16 @@ const TEST_ENV_DEFAULTS = { PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0", }; +function testEnvironment(paseoHome: string): NodeJS.ProcessEnv { + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + ...TEST_ENV_DEFAULTS, + PASEO_HOME: paseoHome, + HOME: paseoHome, + USERPROFILE: paseoHome, + }; +} + function killPidTree(pid: number, signal: NodeJS.Signals): void { if (!Number.isInteger(pid) || pid <= 0) { return; @@ -83,19 +93,25 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st * Wait for daemon to be ready by testing WebSocket connection * Uses `paseo agent ls` which connects via WebSocket */ -async function probeDaemon(port: number): Promise { +async function probeDaemon(port: number, paseoHome: string): Promise { try { - const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow(); + const result = await $({ + env: testEnvironment(paseoHome), + })`paseo agent ls --host localhost:${port}`.nothrow(); return result.exitCode === 0; } catch { return false; } } -export async function waitForDaemon(port: number, timeout = 30000): Promise { +export async function waitForDaemon( + port: number, + paseoHome: string, + timeout = 30000, +): Promise { const deadline = Date.now() + timeout; async function poll(): Promise { - if (await probeDaemon(port)) return; + if (await probeDaemon(port, paseoHome)) return; if (Date.now() >= deadline) { throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`); } @@ -110,8 +126,14 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise { $.verbose = false; - const daemon = - $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow(); + const daemon = $({ + env: { + ...testEnvironment(paseoHome), + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + })`paseo daemon run`.nothrow(); return daemon; } @@ -125,7 +147,7 @@ export async function createTestContext(): Promise { // Helper to run CLI commands against test daemon const paseo = (args: string[]): ProcessPromise => { $.verbose = false; - return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow(); + return $({ env: testEnvironment(paseoHome) })`paseo --home ${paseoHome} ${args}`.nothrow(); }; // Cleanup function @@ -162,7 +184,7 @@ export async function createTestContext(): Promise { export async function createTestContextWithDaemon(): Promise { const ctx = await createTestContext(); ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome); - await waitForDaemon(ctx.port); + await waitForDaemon(ctx.port, ctx.paseoHome); return ctx; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 72d78b4fe0..bf41e06961 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1431,7 +1431,7 @@ test("honors explicit getDaemonStatus timeout below the session RPC default", as clients.push(client); const connectPromise = client.connect(); - mock.triggerOpen(); + mock.triggerOpen({ features: { daemonStatusRpc: true } }); await connectPromise; const responsePromise = client.getDaemonStatus({ diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9529b3179e..042541f5ca 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -884,6 +884,16 @@ type CorrelatedResponsePayloads = { type CorrelatedResponsePayload = CorrelatedResponsePayloads[TType]; +export class DaemonConnectionError extends Error { + constructor( + message: string, + readonly code: "DAEMON_CONNECTION_LOST" | "DAEMON_REQUEST_TIMEOUT" = "DAEMON_CONNECTION_LOST", + ) { + super(message); + this.name = "DaemonConnectionError"; + } +} + class DaemonRpcError extends Error { readonly requestId: string; readonly requestType?: string; @@ -1580,7 +1590,7 @@ export class DaemonClient { private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void { if (!this.transport) { - throw new Error("Transport not connected"); + throw new DaemonConnectionError("Transport not connected"); } const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", { kind: typeof frame === "string" ? "text" : "binary", @@ -1662,7 +1672,12 @@ export class DaemonClient { if (idx !== -1) { this.pendingSendQueue.splice(idx, 1); } - reject(new Error(`Timed out waiting for connection to send message`)); + reject( + new DaemonConnectionError( + "Timed out waiting for connection to send message", + "DAEMON_REQUEST_TIMEOUT", + ), + ); }, DEFAULT_SEND_QUEUE_TIMEOUT_MS); this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle }); @@ -1670,7 +1685,7 @@ export class DaemonClient { } // Not connected and not connecting - fail immediately - return Promise.reject(new Error(`Transport not connected (status: ${status})`)); + return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`)); } /** @@ -1688,7 +1703,7 @@ export class DaemonClient { this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); pending.resolve(); } else { - pending.reject(new Error("Connection lost before message could be sent")); + pending.reject(new DaemonConnectionError("Connection lost before message could be sent")); } } catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); @@ -1835,7 +1850,7 @@ export class DaemonClient { private sendSessionMessageStrict(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { - throw new Error("Transport not connected"); + throw new DaemonConnectionError("Transport not connected"); } const payload = SessionInboundMessageSchema.parse(message); try { @@ -3467,7 +3482,11 @@ export class DaemonClient { return payload.notice ?? null; } - async restartServer(reason?: string, requestId?: string): Promise { + async restartServer( + reason?: string, + requestId?: string, + options?: { timeout?: number }, + ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "restart_server_request", @@ -3477,6 +3496,7 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, + timeout: options?.timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -4801,6 +4821,10 @@ export class DaemonClient { } async getDaemonStatus(options?: DaemonStatusOptions): Promise { + if (!this.lastServerInfoMessage) throw new DaemonConnectionError("Transport not connected"); + if (this.lastServerInfoMessage?.features?.daemonStatusRpc !== true) { + throw new Error("Update the host to read daemon status."); + } return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { @@ -6122,9 +6146,9 @@ export class DaemonClient { // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. - this.clearWaiters(new Error(reason ?? "Connection lost")); - this.rejectPendingSendQueue(new Error(reason ?? "Connection lost")); - this.rejectPingProbe(new Error(reason ?? "Connection lost")); + this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost")); + this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost")); + this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost")); this.terminalStreams.clearSlots(); this.lastServerInfoMessage = null; @@ -6387,7 +6411,10 @@ export class DaemonClient { options?: WaitOptions, ): WaitHandle { // Capture stack trace at call site, not inside setTimeout - const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`); + const timeoutError = new DaemonConnectionError( + `Timeout waiting for message (${timeout}ms)`, + "DAEMON_REQUEST_TIMEOUT", + ); let waiter: Waiter | null = null; let settled = false; diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo index 05797ae6d1..957280d684 100755 --- a/packages/desktop/bin/paseo +++ b/packages/desktop/bin/paseo @@ -44,6 +44,4 @@ fi RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js" CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js" -# PASEO_DESKTOP_MANAGED marks daemons started through this bundled CLI as -# desktop-managed, so the desktop app restarts them when it upgrades. -exec env ELECTRON_RUN_AS_NODE=1 PASEO_NODE_ENV=production PASEO_DESKTOP_MANAGED=1 PASEO_CLI="${SCRIPT_PATH}" "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@" +exec env ELECTRON_RUN_AS_NODE=1 PASEO_NODE_ENV=production PASEO_CLI="${SCRIPT_PATH}" "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@" diff --git a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs new file mode 100644 index 0000000000..d514481992 --- /dev/null +++ b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs @@ -0,0 +1,164 @@ +import { spawn, execFileSync } from "node:child_process"; +import { once } from "node:events"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import net from "node:net"; +import { _electron as electron, expect } from "playwright/test"; +import { readDaemonInstance } from "@getpaseo/server"; + +// Run under the test's private Xvfb display; native dialog input never targets +// a user's desktop. All daemon operations still cross the real preload/IPC. +export async function verifyAttachedDaemonControls({ repo, root, env, home, port, instance }) { + if (!process.env.DISPLAY) + throw new Error("Renderer lifecycle QA requires a private Xvfb display"); + const listener = net.createServer(); + await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); + const metroPort = listener.address().port; + await new Promise((resolve) => listener.close(resolve)); + const metro = spawn( + process.execPath, + [ + path.join(repo, "node_modules/expo/bin/cli"), + "start", + "--web", + "--port", + String(metroPort), + "--offline", + ], + { + cwd: path.join(repo, "packages/app"), + detached: true, + stdio: "ignore", + env: { + ...env, + EXPO_NO_DOTENV: "1", + CI: "1", + PASEO_WEB_PLATFORM: "electron", + EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${port}`, + }, + }, + ); + let desktop; + let page; + try { + await expect + .poll( + async () => { + try { + return (await fetch(`http://127.0.0.1:${metroPort}/status`)).ok; + } catch { + return false; + } + }, + { timeout: 60_000 }, + ) + .toBe(true); + desktop = await electron.launch({ + args: [path.join(repo, "packages/desktop/dist/main.js"), "--no-sandbox"], + env: { + ...env, + EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`, + PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1", + PASEO_ELECTRON_USER_DATA_DIR: path.join(root, "renderer-user-data"), + }, + }); + page = await desktop.firstWindow(); + page.on("pageerror", (error) => console.log("Renderer error:", error.message)); + await page.route(/:(6767|6768)\b/, (route) => route.abort()); + await page.getByRole("button", { name: "Settings", exact: true }).click({ timeout: 90_000 }); + await page.getByRole("button", { name: "Enable built-in daemon", exact: true }).click(); + await expect(page.getByText("Attached to an existing daemon", { exact: true })).toBeVisible(); + await expect( + page.getByText("Removes localhost from this device and stops the built-in daemon", { + exact: true, + }), + ).toBeHidden(); + await page + .getByText("Attached to an existing daemon", { exact: true }) + .scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(root, "attached-daemon.png") }); + + async function respondToNativeDialog(title, artifact, key) { + let windowId; + await expect + .poll( + () => { + try { + windowId = execFileSync("xdotool", ["search", "--onlyvisible", "--name", title], { + encoding: "utf8", + }) + .trim() + .split("\n") + .at(-1); + return Boolean(windowId); + } catch { + return false; + } + }, + { timeout: 10_000 }, + ) + .toBe(true); + const screenshot = await desktop.evaluate(async ({ desktopCapturer }) => { + const sources = await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1280, height: 800 }, + }); + return sources[0].thumbnail.toPNG().toString("base64"); + }); + await writeFile(path.join(root, artifact), Buffer.from(screenshot, "base64")); + // Escape destroys the dialog on key-down. Send through the private display + // focus so key-up does not target an already-destroyed X11 window. + execFileSync("xdotool", ["windowfocus", "--sync", windowId, "key", key]); + } + + const management = page.getByRole("switch", { name: "Manage built-in daemon" }); + await management.click(); + await respondToNativeDialog( + "Pause built-in daemon", + "pause-attached-confirmation.png", + "Return", + ); + await expect(management).not.toBeChecked(); + expect((await readDaemonInstance(home)).pid).toBe(instance.pid); + + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await respondToNativeDialog("Stop local daemon", "stop-attached-cancelled.png", "Escape"); + await expect(page.getByRole("button", { name: "Stop daemon", exact: true })).toBeEnabled(); + expect(await readDaemonInstance(home)).toMatchObject({ + pid: instance.pid, + startedAt: instance.startedAt, + }); + + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await respondToNativeDialog("Stop local daemon", "stop-attached-confirmation.png", "Return"); + await expect.poll(() => readDaemonInstance(home), { timeout: 20_000 }).toBeNull(); + await expect(page.getByRole("button", { name: "Stop daemon", exact: true })).toBeHidden(); + await page.screenshot({ path: path.join(root, "attached-daemon-stopped.png") }); + console.log( + "PASS: real Desktop renderer pauses management without stopping an attached daemon, then confirms and stops that instance.", + ); + } catch (error) { + if (page) { + console.log( + "Renderer state:", + await page + .locator("body") + .innerText() + .catch(() => "closed"), + ); + await page.screenshot({ path: path.join(root, "renderer-failure.png") }).catch(() => {}); + } + throw error; + } finally { + if (desktop) { + const exited = once(desktop.process(), "exit"); + await desktop.evaluate(({ app }) => app.exit(0)).catch(() => {}); + await exited; + } + if (metro.exitCode === null) { + const exited = once(metro, "exit"); + process.kill(-metro.pid, "SIGTERM"); + await exited; + } + } +} diff --git a/packages/desktop/e2e/daemon-lifecycle.e2e.mjs b/packages/desktop/e2e/daemon-lifecycle.e2e.mjs new file mode 100644 index 0000000000..2ef29e1589 --- /dev/null +++ b/packages/desktop/e2e/daemon-lifecycle.e2e.mjs @@ -0,0 +1,212 @@ +import { verifyAttachedDaemonControls } from "./daemon-lifecycle-renderer.electron.mjs"; +import { once } from "node:events"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, rm, readdir, copyFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { _electron as electron, expect } from "playwright/test"; +import { + savePersistedConfig, + startDaemonInstance, + stopDaemonInstance, + readDaemonInstance, +} from "@getpaseo/server"; + +const repo = fileURLToPath(new URL("../../..", import.meta.url)); +const root = await mkdtemp(path.join(tmpdir(), "paseo desktop lifecycle ")); +const home = path.join(root, "daemon"); +const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith("PASEO_") && key !== "ELECTRON_RUN_AS_NODE", + ), +); +env.HOME = root; +env.USERPROFILE = root; +env.PASEO_HOME = home; +const server = net.createServer(); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const port = server.address().port; +await new Promise((resolve) => server.close(resolve)); +assert.ok(port !== 6767 && port !== 6768); +await mkdir(path.join(root, "web")); +await writeFile(path.join(root, "web/index.html"), "Lifecycle web UI"); +savePersistedConfig(home, { + daemon: { listen: `127.0.0.1:${port}`, relay: { enabled: false } }, + features: { + dictation: { enabled: false }, + voiceMode: { enabled: false }, + webUi: { enabled: true, distDir: path.join(root, "web") }, + }, +}); +const main = path.join(root, "main.cjs"); +const managerPath = path.join(repo, "packages/desktop/dist/daemon/daemon-manager.js"); +await writeFile( + main, + `const { app } = require("electron"); app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))}); app.whenReady().then(() => { global.lifecycle = require(${JSON.stringify(managerPath)}); });`, +); +let desktop; +let captured; +async function openDesktop() { + desktop = await electron.launch({ + args: ["--no-sandbox", "--ozone-platform=headless", main], + env: { + ...env, + PASEO_LISTEN: "127.0.0.1:1", + PASEO_WEB_UI_ENABLED: "false", + PASEO_HOST: "unused:1", + }, + }); + await expect + .poll(() => desktop.evaluate(() => Boolean(global.lifecycle)), { timeout: 10_000 }) + .toBe(true); +} +async function closeDesktop() { + if (!desktop) return; + const child = desktop.process(); + const exited = once(child, "exit"); + await desktop.evaluate(({ app }) => app.exit(0)).catch(() => {}); + await exited; + desktop = null; +} +async function command(name, args) { + console.log(`Desktop command: ${name}${args?.reason ? ` (${args.reason})` : ""}`); + return desktop.evaluate( + async (_, input) => global.lifecycle.createDaemonCommandHandlers()[input.name](input.args), + { name, args }, + ); +} +try { + // Simulate a legacy independent launch that carries the old Desktop flag. + const launch = await startDaemonInstance({ + home, + command: process.execPath, + args: [path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js")], + env, + mode: "deployment", + desktopManaged: true, + timeoutMs: 30_000, + }); + captured = launch.instance; + await openDesktop(); + const attached = await command("start_desktop_daemon"); + assert.equal(attached.pid, captured.pid); + assert.equal(attached.desktopManaged, true); + assert.equal(attached.ownedByDesktop, false); + for (const reason of ["quit", "settings", "app_update", "host_remove", "version_mismatch"]) { + const status = await command("stop_desktop_daemon", { reason }); + assert.equal(status.pid, captured.pid, `${reason} stopped an attached daemon`); + process.kill(captured.pid, 0); + } + const restarted = await command("restart_desktop_daemon"); + assert.equal(restarted.pid, captured.pid); + await assert.rejects( + command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: "outdated-confirmation", + }), + /changed since confirmation/, + ); + const stopped = await command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }); + assert.equal(stopped.status, "stopped"); + captured = null; + + const owned = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(owned.pid, captured.pid); + assert.equal(owned.ownedByDesktop, true); + assert.equal(owned.listen, `127.0.0.1:${port}`); + assert.equal( + JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).features.webUi.enabled, + true, + ); + const liveWebUi = await fetch(`http://127.0.0.1:${port}/`); + assert.equal(liveWebUi.status, 200); + assert.match(await liveWebUi.text(), /Lifecycle web UI/); + await command("patch_desktop_settings", { daemon: { keepRunningAfterQuit: true } }); + await closeDesktop(); + desktop = null; + process.kill(captured.pid, 0); + await openDesktop(); + assert.equal((await command("start_desktop_daemon")).ownedByDesktop, false); + await command("stop_desktop_daemon", { reason: "quit" }); + process.kill(captured.pid, 0); + await command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }); + captured = null; + const next = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(next.ownedByDesktop, true); + await command("stop_desktop_daemon", { reason: "quit" }); + assert.equal(await readDaemonInstance(home), null); + captured = null; + await closeDesktop(); + if (process.platform === "linux") { + const rendererLaunch = await startDaemonInstance({ + home, + command: process.execPath, + args: [path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js")], + env, + mode: "deployment", + desktopManaged: true, + timeoutMs: 30_000, + }); + captured = rendererLaunch.instance; + await verifyAttachedDaemonControls({ repo, root, env, home, port, instance: captured }); + captured = null; + } + if (process.platform !== "win32") { + const preload = path.join(root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 45000));', + ); + await openDesktop(); + // Playwright removes NODE_OPTIONS from electron.launch's environment. + // Restore this runtime input inside the isolated fixture before it launches the daemon. + await desktop.evaluate( + (_, value) => { + process.env.NODE_OPTIONS = value; + }, + `--import=${pathToFileURL(preload).href}`, + ); + const began = Date.now(); + const starting = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(starting.status, "starting"); + assert.equal(starting.ownedByDesktop, true); + assert.equal(starting.pid, captured.pid); + assert.ok(Date.now() - began < 40_000, "Desktop did not return its bounded starting state"); + process.kill(captured.pid, 0); + await command("stop_desktop_daemon", { reason: "quit" }); + captured = null; + await closeDesktop(); + console.log( + "PASS: Desktop readiness deadline returns starting and preserves its owned slow launch.", + ); + } + console.log( + "PASS: attached legacy daemon survives automatic actions; explicit captured stop, owned launch, managed settings, worker restart, and next-session attachment.", + ); +} finally { + await closeDesktop(); + if (captured) + await stopDaemonInstance(home, { instance: captured, force: true, timeoutMs: 2_000 }); + const artifacts = + process.env.PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR ?? + (await mkdtemp(path.join(tmpdir(), "paseo-desktop-lifecycle-artifacts-"))); + await mkdir(artifacts, { recursive: true }); + for (const name of await readdir(root)) + if (name.endsWith(".png")) await copyFile(path.join(root, name), path.join(artifacts, name)); + await rm(root, { recursive: true, force: true }); + console.log(`Lifecycle artifacts: ${artifacts}`); +} diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 6a77538952..8b121a1e7f 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -158,19 +158,15 @@ function getShellCommand(script) { } function createDefaultDaemonEnv(extraEnv) { - const env = { - ...process.env, + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), ...extraEnv, }; - - delete env.PASEO_HOME; - delete env.PASEO_LISTEN; - return env; } function createIsolatedDesktopEnv({ home, listen, userData, cdpPort }) { return { - ...process.env, + ...createDefaultDaemonEnv({ HOME: home, USERPROFILE: home }), PASEO_HOME: home, PASEO_LISTEN: listen, PASEO_ELECTRON_USER_DATA_DIR: userData, @@ -657,24 +653,15 @@ async function smokeColdCliDaemonStart({ appPath }) { const pidPath = path.join(home, "paseo.pid"); const port = await reserveLocalTcpPort(); const listen = `127.0.0.1:${port}`; - const env = createDefaultDaemonEnv(); + const env = createDefaultDaemonEnv({ HOME: home, USERPROFILE: home }); + configureIsolatedDaemonHome(home, listen); try { console.log("Packaged desktop smoke: cold-starting daemon through bundled CLI shim"); await runCliShimCommand({ appPath, env, - args: [ - "daemon", - "start", - "--home", - home, - "--listen", - listen, - "--no-relay", - "--no-mcp", - "--no-inject-mcp", - ], + args: ["daemon", "start", "--home", home], label: "Bundled CLI shim cold daemon start", }); diff --git a/packages/desktop/e2e/support/runtime.ts b/packages/desktop/e2e/support/runtime.ts index 14b770b2fd..9032e5c56e 100644 --- a/packages/desktop/e2e/support/runtime.ts +++ b/packages/desktop/e2e/support/runtime.ts @@ -62,6 +62,9 @@ export interface DesktopRuntimeConfig { slowInstall?: boolean; /** Initial PID reported by desktop_daemon_status. Defaults to null. */ daemonPid?: number | null; + daemonHome?: string; + /** Current-session ownership, independent of the legacy management flag. */ + ownedByDesktop?: boolean; daemonVersion?: string | null; daemonLogPath?: string; /** Initial manageBuiltInDaemon setting. Defaults to false. */ @@ -139,7 +142,7 @@ export async function installDesktopRuntime( let manageDaemon = cfg.manageBuiltInDaemon ?? false; let daemonRunning = true; let currentPid: number | null = cfg.daemonPid ?? null; - let startCount = 0; + let ownedByDesktop = cfg.ownedByDesktop ?? false; let manualUpdateAdmitted = false; window.__desktopDaemonStartRequested = false; @@ -150,7 +153,9 @@ export async function installDesktopRuntime( listen: cfg.daemonListen ?? "127.0.0.1:6767", hostname: null, pid: currentPid, - home: "", + home: cfg.daemonHome ?? "", + startedAt: currentPid === null ? null : "2026-09-01T00:00:00.000Z", + ownedByDesktop, version: cfg.daemonVersion ?? null, desktopManaged: manageDaemon, error: null, @@ -162,11 +167,11 @@ export async function installDesktopRuntime( if (cfg.hangDaemonStart) { return new Promise(() => undefined); } - startCount += 1; + if (!daemonRunning) { + currentPid = (cfg.daemonPid ?? 10000) + 1000; + ownedByDesktop = true; + } daemonRunning = true; - // First start (bootstrap) returns the configured PID; subsequent starts - // (after a stop) get a fresh PID so tests can observe the change. - currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000; return buildDaemonStatus(); } @@ -268,6 +273,7 @@ export async function installDesktopRuntime( } if (command === "stop_desktop_daemon") { + ownedByDesktop = false; daemonRunning = false; currentPid = null; return buildDaemonStatus(); @@ -396,6 +402,12 @@ export async function interceptDaemonManagementConfirmDialog( return page.evaluate(() => window.__capturedDialogCall!); } +export async function interceptDaemonStopConfirmDialog(page: Page): Promise { + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await page.waitForFunction(() => !!window.__capturedDialogCall); + return page.evaluate(() => window.__capturedDialogCall!); +} + export async function toggleDaemonManagement( page: Page, _action: "enable" | "disable", @@ -405,7 +417,9 @@ export async function toggleDaemonManagement( export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void { expect(args.title).toBe("Pause built-in daemon"); - expect(args.message).toContain("stop the built-in daemon immediately"); + expect(args.message).toBe( + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + ); } export async function expectDaemonManagementEnabled(page: Page): Promise { diff --git a/packages/desktop/e2e/updates.spec.ts b/packages/desktop/e2e/updates.spec.ts index 9a2153163e..361002edac 100644 --- a/packages/desktop/e2e/updates.spec.ts +++ b/packages/desktop/e2e/updates.spec.ts @@ -18,6 +18,7 @@ import { clickInstallUpdate, expectInstallInProgress, interceptDaemonManagementConfirmDialog, + interceptDaemonStopConfirmDialog, toggleDaemonManagement, expectDaemonManagementConfirmDialog, expectDaemonManagementEnabled, @@ -27,8 +28,8 @@ import { expectDaemonStatusVersion, } from "./support/runtime"; -// No Playwright Electron runner exists; we simulate the desktop bridge via -// addInitScript so Electron-gated UI activates without a real Electron process. +// These renderer cases use the Desktop bridge fixture. Actual Electron ownership +// and native confirmation journeys live in daemon-lifecycle.e2e.mjs. test.describe("Desktop updates", () => { test("a desktop-managed daemon explains why its update action is disabled", async ({ page, @@ -111,6 +112,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: false, }); await gotoAppShell(page); @@ -127,6 +129,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: false, }); await gotoAppShell(page); @@ -142,6 +145,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: true, }); await gotoAppShell(page); @@ -178,6 +182,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, daemonPid: realState.pid, daemonVersion: realState.version, daemonLogPath: realState.logPath, @@ -197,4 +202,67 @@ test.describe("Desktop daemon management", () => { const newPid = realState.pid !== null ? realState.pid + 1000 : 11000; await expectDaemonStatusPid(page, newPid); }); + + test("pausing management preserves an attached legacy desktop-managed daemon", async ({ + page, + }) => { + const serverId = getServerId(); + const realState = await loadRealDaemonState(); + await installDesktopRuntime(page, { + serverId, + manageBuiltInDaemon: true, + ownedByDesktop: false, + daemonPid: realState.pid, + confirmShouldAccept: true, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialog = await interceptDaemonManagementConfirmDialog(page); + expect(dialog).toEqual({ + title: "Pause built-in daemon", + message: "Pause automatic daemon management? The attached daemon will keep running.", + }); + await expectDaemonManagementDisabled(page); + await expectDaemonStatusPid(page, realState.pid); + + await toggleDaemonManagement(page, "enable"); + await expectDaemonManagementEnabled(page); + await expectDaemonStatusPid(page, realState.pid); + }); + + for (const ownedByDesktop of [false, true]) { + for (const confirmShouldAccept of [false, true]) { + test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the ${ownedByDesktop ? "owned" : "attached"} daemon`, async ({ + page, + }) => { + const serverId = getServerId(); + const realState = await loadRealDaemonState(); + const daemonHome = process.env.E2E_PASEO_HOME!; + await installDesktopRuntime(page, { + serverId, + daemonPid: realState.pid, + daemonHome, + ownedByDesktop, + confirmShouldAccept, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialog = await interceptDaemonStopConfirmDialog(page); + expect(dialog).toEqual({ + title: "Stop local daemon?", + message: [ + ownedByDesktop + ? "This daemon was launched by this Desktop session." + : "This daemon was not launched by this Desktop session.", + `Home: ${daemonHome}`, + `Supervisor PID: ${realState.pid}`, + "Running agent work will be interrupted.", + ].join("\n"), + }); + await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid); + }); + } + } }); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cae4c2b777..1346430760 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -24,7 +24,8 @@ "test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "vitest run --exclude \"e2e/**\"", - "typecheck": "tsgo --noEmit -p tsconfig.json" + "typecheck": "tsgo --noEmit -p tsconfig.json", + "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs" }, "dependencies": { "@getpaseo/cli": "*", diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 8ccd2d1062..1bd3beed33 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -1,13 +1,13 @@ -import { EventEmitter } from "node:events"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; -import { getBundledCliShimPath } from "../integrations/cli-install"; import { createDaemonCommandHandlers } from "./daemon-manager"; const mocks = vi.hoisted(() => ({ - paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home", + paseoHome: "", settings: { releaseChannel: "stable", daemon: { @@ -25,13 +25,13 @@ const mocks = vi.hoisted(() => ({ spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), - appLogPath: "/tmp/paseo-desktop-daemon-manager-test-main.log", + appLogPath: "", getElectronLogFile: vi.fn(), })); vi.mock("electron", () => ({ app: { - getPath: vi.fn(() => "/tmp/paseo-user-data"), + getPath: vi.fn(() => mocks.paseoHome), getVersion: vi.fn(() => "1.2.3"), isPackaged: true, }, @@ -67,7 +67,7 @@ vi.mock("../settings/desktop-settings-electron.js", () => ({ vi.mock("./runtime-paths.js", () => ({ createNodeEntrypointInvocation: mocks.createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint: vi.fn(() => ({ - entryPath: "/tmp/daemon.js", + entryPath: path.join(mocks.paseoHome, "daemon.js"), execArgv: [], })), })); @@ -77,40 +77,13 @@ vi.mock("./cli/external.js", () => ({ runExternalCliTextCommand: mocks.runExternalCliTextCommand, })); -function desktopSettingsWithManagement(enabled: boolean) { - return { - ...DEFAULT_DESKTOP_SETTINGS, - daemon: { - ...DEFAULT_DESKTOP_SETTINGS.daemon, - manageBuiltInDaemon: enabled, - }, - }; -} - -type MockChildProcess = EventEmitter & { - pid: number; - spawnfile: string; - spawnargs: string[]; - unref: ReturnType; -}; - -function createMockChildProcess(): MockChildProcess { - const child = new EventEmitter() as MockChildProcess; - child.pid = 1234; - child.spawnfile = "node"; - child.spawnargs = ["node", "daemon.js"]; - child.unref = vi.fn(); - return child; -} - -function scheduleFailedStartup(child: MockChildProcess): void { - setImmediate(() => { - child.emit("exit", 1, null); - }); -} - describe("daemon-manager commands", () => { + let fixtureRoot: string; + beforeEach(() => { + fixtureRoot = mkdtempSync(path.join(tmpdir(), "paseo daemon manager ")); + mocks.paseoHome = path.join(fixtureRoot, "home"); + mocks.appLogPath = path.join(fixtureRoot, "main.log"); mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); @@ -121,384 +94,10 @@ describe("daemon-manager commands", () => { mocks.logError.mockReset(); mocks.getElectronLogFile.mockReset(); mocks.getElectronLogFile.mockReturnValue({ path: mocks.appLogPath }); - rmSync(mocks.paseoHome, { recursive: true, force: true }); - rmSync(mocks.appLogPath, { force: true }); }); afterEach(() => { - rmSync(mocks.paseoHome, { recursive: true, force: true }); - rmSync(mocks.appLogPath, { force: true }); - }); - - it("refuses start and restart while built-in daemon management is disabled", async () => { - mocks.settings = desktopSettingsWithManagement(false); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).rejects.toThrow( - "Built-in daemon management is disabled.", - ); - await expect(handlers.restart_desktop_daemon()).rejects.toThrow( - "Built-in daemon management is disabled.", - ); - - expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled(); - expect(mocks.spawnProcess).not.toHaveBeenCalled(); - }); - - it("keeps stop callable while built-in daemon management is disabled", async () => { - mocks.settings = desktopSettingsWithManagement(false); - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]); - }); - - it("routes running desktop daemon stops through external CLI daemon stop", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "running", - serverId: "server-1", - pid: 4242, - listen: "127.0.0.1:6767", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(1, [ - "daemon", - "status", - "--json", - ]); - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [ - "daemon", - "status", - "--json", - ]); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop requested", - expect.objectContaining({ - reason: "manual_ipc", - statusBefore: expect.objectContaining({ - status: "running", - pid: 4242, - serverId: "server-1", - desktopManaged: true, - }), - }), - ); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop completed", - expect.objectContaining({ - reason: "manual_ipc", - cliResult: { action: "stopped" }, - statusAfter: expect.objectContaining({ - status: "stopped", - serverId: null, - }), - }), - ); - }); - - it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - }); - - it("records the renderer stop reason when stopping the desktop daemon", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "running", - serverId: "server-1", - pid: 4242, - listen: "127.0.0.1:6767", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await handlers.stop_desktop_daemon({ reason: "host_remove" }); - - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop requested", - expect.objectContaining({ reason: "host_remove" }), - ); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop completed", - expect.objectContaining({ - reason: "host_remove", - cliResult: { action: "stopped", reason: "lifecycle_shutdown_rpc" }, - }), - ); - }); - - it("uses a stale reachable desktop daemon when the version matches", async () => { - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.3", - desktopManaged: true, - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).resolves.toEqual({ - serverId: "server-1", - status: "running", - listen: "127.0.0.1:6767", - hostname: "dev-host", - pid: null, - home: mocks.paseoHome, - version: "1.2.3", - desktopManaged: true, - error: null, - }); - - expect(mocks.spawnProcess).not.toHaveBeenCalled(); - }); - - it("restarts a stale reachable desktop daemon when the version differs", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }) - .mockResolvedValueOnce({ - localDaemon: "running", - connectedDaemon: "reachable", - serverId: "server-2", - pid: 8888, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.3", - desktopManaged: true, - }); - mocks.spawnProcess.mockReturnValue(createMockChildProcess()); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).resolves.toEqual({ - serverId: "server-2", - status: "running", - listen: "127.0.0.1:6767", - hostname: "dev-host", - pid: 8888, - home: mocks.paseoHome, - version: "1.2.3", - desktopManaged: true, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - expect(mocks.spawnProcess).toHaveBeenCalled(); - }); - - it("starts the managed daemon detached from desktop stdio and reports daemon log failures", async () => { - mkdirSync(mocks.paseoHome, { recursive: true }); - writeFileSync( - `${mocks.paseoHome}/daemon.log`, - ["old log line", "recent daemon failure"].join("\n"), - ); - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - const handlers = createDaemonCommandHandlers(); - - let thrown: Error | null = null; - try { - await handlers.start_desktop_daemon(); - } catch (error) { - thrown = error instanceof Error ? error : new Error(String(error)); - } - - expect(thrown).toBeInstanceOf(Error); - const message = thrown?.message ?? ""; - const recentLogsLabel = message.match(/Recent logs \(([^)]*)\):/)?.[1]; - expect(message).toContain("Daemon failed to start: exit code 1"); - expect(recentLogsLabel?.split(/[\\/]/).at(-1)).toBe("daemon.log"); - expect(message).toContain("recent daemon failure"); - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: [] }), - ); - expect(mocks.spawnProcess).toHaveBeenCalledWith( - "node", - [], - expect.objectContaining({ - detached: true, - stdio: ["ignore", "ignore", "ignore"], - envOverlay: expect.objectContaining({ - PASEO_CLI: getBundledCliShimPath(), - PASEO_WEB_UI_ENABLED: "false", - }), - }), - ); - }); - - it("passes stale lock reclaim only after a live desktop daemon is confirmed unresponsive", async () => { - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "unresponsive", - connectedDaemon: "unreachable", - serverId: "", - pid: 7675, - listen: "127.0.0.1:6767", - desktopManaged: true, - }); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - - await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( - "Daemon failed to start: exit code 1", - ); - - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: ["--reclaim-stale-pid-lock"] }), - ); - }); - - it("does not pass stale lock reclaim when the status command fails", async () => { - mocks.runExternalCliJsonCommand.mockRejectedValue(new Error("status command failed")); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - - await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( - "Daemon failed to start: exit code 1", - ); - - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: [] }), - ); + rmSync(fixtureRoot, { recursive: true, force: true }); }); it("returns the Electron main-process log tail from electron-log", () => { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 4d3e5531eb..b2e19798c8 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,9 +1,16 @@ -import { type ChildProcess } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain, powerMonitor } from "electron"; import log from "electron-log/main"; -import { resolvePaseoHome, spawnProcess } from "@getpaseo/server"; +import { + resolvePaseoHome, + startDaemonInstance, + DaemonInstanceError, + stopDaemonInstance, + readDaemonInstance, + isSameDaemonInstance, + type DaemonInstance, +} from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -46,9 +53,7 @@ import { import { tailFile } from "../diagnostics/tail-file.js"; const DAEMON_LOG_FILENAME = "daemon.log"; -const STARTUP_POLL_INTERVAL_MS = 200; -const STARTUP_POLL_MAX_ATTEMPTS = 150; -const DETACHED_STARTUP_GRACE_MS = 1200; +let ownedLaunch: { home: string; instance: DaemonInstance } | null = null; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; const DESKTOP_DAEMON_STOP_REASON_VALUES = [ @@ -74,6 +79,8 @@ export interface DesktopDaemonStatus { home: string; version: string | null; desktopManaged: boolean; + ownedByDesktop: boolean; + startedAt: string | null; error: string | null; } @@ -123,73 +130,19 @@ function logFilePath(): string { } export function isDesktopManagedDaemonRunningSync(): boolean { + if (!ownedLaunch) return false; try { - const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8"); - const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown }; - if (lock.desktopManaged !== true) return false; - if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false; - return isProcessRunning(lock.pid); + const lock = JSON.parse(readFileSync(path.join(ownedLaunch.home, "paseo.pid"), "utf8")); + return isSameDaemonInstance(lock, ownedLaunch.instance) && isProcessRunning(lock.pid); } catch { return false; } } -function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record { - return { - status: status.status, - pid: status.pid, - listen: status.listen, - serverId: status.serverId || null, - version: status.version, - desktopManaged: status.desktopManaged, - error: status.error, - }; -} - -const DESKTOP_DAEMON_STOP_CLI_ARGS = [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", -]; - -async function runDesktopDaemonStopViaCli({ - reason, - statusBefore, - resolveStatusAfter = false, -}: { - reason: DesktopDaemonStopReason; - statusBefore?: DesktopDaemonStatus | null; - resolveStatusAfter?: boolean; -}): Promise<{ - cliResult: unknown; - statusAfter: DesktopDaemonStatus | null; -}> { - logDesktopDaemonLifecycle("desktop daemon stop requested", { - reason, - statusBefore: statusBefore ? summarizeDesktopDaemonStatus(statusBefore) : null, - }); - - const cliResult = await runExternalCliJsonCommand(DESKTOP_DAEMON_STOP_CLI_ARGS); - const statusAfter = resolveStatusAfter ? await resolveDesktopDaemonStatus() : null; - - logDesktopDaemonLifecycle("desktop daemon stop completed", { - reason, - cliResult, - statusAfter: statusAfter ? summarizeDesktopDaemonStatus(statusAfter) : null, - }); - - return { cliResult, statusAfter }; -} - export async function stopDesktopDaemonViaCli( reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON, ): Promise { - await runDesktopDaemonStopViaCli({ reason }); + await stopDesktopDaemon(reason); } function isProcessRunning(pid: number): boolean { @@ -204,12 +157,6 @@ function isProcessRunning(pid: number): boolean { } } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - function logDesktopDaemonLifecycle(message: string, details?: Record): void { log.info("[desktop daemon]", message, { pid: process.pid, @@ -222,25 +169,27 @@ function statusFromDaemonProbe( home: string, ): DesktopDaemonStatus { const local = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; - const reachable = payload.connectedDaemon === "reachable"; - const processAlive = local === "running"; - const stalledProcess = local === "unresponsive"; + const processAlive = local === "running" || local === "not_ready"; let status: DesktopDaemonState = "stopped"; - if (reachable || processAlive) { - status = "running"; - } else if (stalledProcess) { - status = "errored"; - } + if (local === "not_ready") status = "starting"; + if (local === "running") status = "running"; return { serverId: typeof payload.serverId === "string" ? payload.serverId : "", status, listen: typeof payload.listen === "string" ? payload.listen : null, hostname: status === "running" && typeof payload.hostname === "string" ? payload.hostname : null, - pid: (processAlive || stalledProcess) && typeof payload.pid === "number" ? payload.pid : null, + pid: processAlive && typeof payload.pid === "number" ? payload.pid : null, home, version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null, desktopManaged: payload.desktopManaged === true, + startedAt: typeof payload.startedAt === "string" ? payload.startedAt : null, + ownedByDesktop: Boolean( + ownedLaunch && + ownedLaunch.home === home && + payload.pid === ownedLaunch.instance.pid && + payload.startedAt === ownedLaunch.instance.startedAt, + ), error: null, }; } @@ -273,23 +222,28 @@ export async function resolveDesktopDaemonStatus(): Promise const home = getPaseoHome(); try { - const payload = (await runExternalCliJsonCommand(["daemon", "status", "--json"])) as Record< - string, - unknown - >; + const payload = (await runExternalCliJsonCommand([ + "daemon", + "status", + "--home", + home, + "--json", + ])) as Record; return statusFromDaemonProbe(payload, home); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage }); return { serverId: "", - status: "stopped", + status: "errored", listen: null, hostname: null, pid: null, home, version: null, desktopManaged: false, + ownedByDesktop: false, + startedAt: null, error: errorMessage, }; } @@ -302,7 +256,7 @@ function normalizeVersion(version: string | null): string | null { } function shouldRestartForVersion(current: DesktopDaemonStatus): boolean { - if (!current.desktopManaged) return false; + if (!current.ownedByDesktop) return false; const appVersion = normalizeVersion(resolveDesktopAppVersion()); const daemonVersion = normalizeVersion(current.version); return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion); @@ -314,40 +268,6 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void { } } -function buildStartupFailureError(result: { - code: number | null; - signal: string | null; - error?: Error; -}): Error { - const reason = result.error - ? result.error.message - : `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`; - const parts = [`Daemon failed to start: ${reason}`]; - const logs = tailFile(logFilePath(), 15); - if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`); - return new Error(parts.join("\n\n")); -} - -async function pollForRunningDaemon(): Promise { - async function poll(attempt: number): Promise { - if (attempt >= STARTUP_POLL_MAX_ATTEMPTS) return resolveDesktopDaemonStatus(); - const status = await resolveDesktopDaemonStatus(); - if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) { - logDesktopDaemonLifecycle("polling daemon status after detached start", { - attempt: attempt + 1, - status: status.status, - pid: status.pid, - listen: status.listen, - serverId: status.serverId || null, - }); - } - if (status.status === "running" && status.serverId && status.listen) return status; - await sleep(STARTUP_POLL_INTERVAL_MS); - return poll(attempt + 1); - } - return poll(0); -} - async function startDaemon(): Promise { assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); @@ -360,7 +280,7 @@ async function startDaemon(): Promise { error: current.error, desktopManaged: current.desktopManaged, }); - if (current.status === "running") { + if (current.status === "running" || current.status === "starting") { if (shouldRestartForVersion(current)) { logDesktopDaemonLifecycle("daemon version mismatch, restarting", { appVersion: normalizeVersion(resolveDesktopAppVersion()), @@ -372,118 +292,69 @@ async function startDaemon(): Promise { } } - const daemonRunner = resolveDaemonRunnerEntrypoint(); - const reclaimStalePidLock = - current.status === "errored" && current.desktopManaged && current.error === null; + const home = getPaseoHome(); const invocation = createNodeEntrypointInvocation({ - entrypoint: daemonRunner, + entrypoint: resolveDaemonRunnerEntrypoint(), argvMode: "node-script", - args: reclaimStalePidLock ? ["--reclaim-stale-pid-lock"] : [], + args: [], baseEnv: process.env, }); - - logDesktopDaemonLifecycle("starting detached daemon", { - appIsPackaged: app.isPackaged, - daemonRunnerEntry: daemonRunner.entryPath, - daemonRunnerExecArgv: daemonRunner.execArgv, - command: invocation.command, - args: invocation.args, - electronRunAsNode: invocation.env.ELECTRON_RUN_AS_NODE ?? null, - parentExecPath: process.execPath, - parentElectronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, - electronVersion: process.versions.electron ?? null, - nodeVersion: process.versions.node, - platform: process.platform, - arch: process.arch, - }); - - const child: ChildProcess = spawnProcess(invocation.command, invocation.args, { - detached: true, - envMode: "internal", - env: invocation.env, - envOverlay: { - PASEO_DESKTOP_MANAGED: "1", - PASEO_CLI: getBundledCliShimPath(), - PASEO_WEB_UI_ENABLED: "false", - }, - stdio: ["ignore", "ignore", "ignore"], - }); - - logDesktopDaemonLifecycle("detached spawn returned", { - childPid: child.pid ?? null, - spawnfile: child.spawnfile, - spawnargs: child.spawnargs, - }); - - child.unref(); - - type GraceResult = - | { exitedEarly: false } - | { exitedEarly: true; code: number | null; signal: string | null; error?: Error }; - - const result = await new Promise((resolve) => { - let settled = false; - const finish = (value: GraceResult) => { - if (settled) return; - settled = true; - resolve(value); - }; - - const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS); - - child.once("error", (error) => { - clearTimeout(timer); - finish({ exitedEarly: true, code: null, signal: null, error }); + try { + await startDaemonInstance({ + home, + timeoutMs: 30_000, + ...invocation, + env: { ...invocation.env, PASEO_CLI: getBundledCliShimPath() }, + mode: "managed", + desktopManaged: true, + onAcquired: (instance) => { + ownedLaunch = { home, instance }; + }, }); - child.once("exit", (code, signal) => { - clearTimeout(timer); - finish({ exitedEarly: true, code, signal }); - }); - }); - - logDesktopDaemonLifecycle("detached startup grace period completed", { - childPid: child.pid ?? null, - exitedEarly: result.exitedEarly, - ...(result.exitedEarly - ? { - exitCode: result.code, - signal: result.signal, - error: result.error?.message ?? null, - } - : {}), - }); - - if (result.exitedEarly) { - throw buildStartupFailureError(result); + } catch (error) { + if (!(error instanceof DaemonInstanceError && error.code === "DAEMON_NOT_READY")) throw error; } - - return pollForRunningDaemon(); + return resolveDesktopDaemonStatus(); } export async function stopDesktopDaemon( reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON, + confirmedInstance?: { pid: number; startedAt: string }, ): Promise { - const status = await resolveDesktopDaemonStatus(); - if (status.status !== "running") { - logDesktopDaemonLifecycle("desktop daemon stop skipped", { - reason, - statusBefore: summarizeDesktopDaemonStatus(status), - }); - return status; - } - - const { statusAfter } = await runDesktopDaemonStopViaCli({ - reason, - statusBefore: status, - resolveStatusAfter: true, + const home = getPaseoHome(); + const instance = await readDaemonInstance(home); + const owned = Boolean( + instance && + ownedLaunch && + ownedLaunch.home === home && + isSameDaemonInstance(instance, ownedLaunch.instance), + ); + const explicit = + reason === "manual_ipc" && + confirmedInstance && + instance && + instance.pid === confirmedInstance.pid && + instance.startedAt === confirmedInstance.startedAt; + if (confirmedInstance && !explicit) + throw new Error( + "Daemon changed since confirmation; inspect its current home and PID before stopping it.", + ); + if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus(); + logDesktopDaemonLifecycle("stopping captured supervisor", { reason, pid: instance.pid, owned }); + await stopDaemonInstance(home, { + instance, + timeoutMs: 15_000, + requestShutdown: async (ready) => { + await runExternalCliJsonCommand(["daemon", "stop", "--host", ready.listen, "--json"]); + }, }); - return statusAfter ?? (await resolveDesktopDaemonStatus()); + if (owned) ownedLaunch = null; + return resolveDesktopDaemonStatus(); } async function restartDaemon(): Promise { - assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); - await stopDesktopDaemon("restart"); - return startDaemon(); + await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]); + return resolveDesktopDaemonStatus(); } function getDaemonLogs(): DesktopDaemonLogs { @@ -495,7 +366,7 @@ function getDaemonLogs(): DesktopDaemonLogs { } async function getCliDaemonStatus(): Promise { - return await runExternalCliTextCommand(["daemon", "status"]); + return await runExternalCliTextCommand(["daemon", "status", "--home", getPaseoHome()]); } async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> { @@ -528,7 +399,13 @@ export function createDaemonCommandHandlers(): Record resolveDesktopDaemonStatus(), start_desktop_daemon: () => startDaemon(), - stop_desktop_daemon: (args) => stopDesktopDaemon(parseDesktopDaemonStopReason(args)), + stop_desktop_daemon: (args) => + stopDesktopDaemon( + parseDesktopDaemonStopReason(args), + typeof args?.pid === "number" && typeof args.startedAt === "string" + ? { pid: args.pid, startedAt: args.startedAt } + : undefined, + ), restart_desktop_daemon: () => restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), desktop_app_logs: () => getDesktopAppLogs(), diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index b379e6179f..c929281525 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -18,13 +18,11 @@ process.title = "Paseo Supervisor"; interface DaemonRunnerConfig { devMode: boolean; - reclaimStalePidLock: boolean; workerArgs: string[]; } function parseConfig(argv: string[]): DaemonRunnerConfig { let devMode = false; - let reclaimStalePidLock = false; const workerArgs: string[] = []; for (const arg of argv) { @@ -33,13 +31,14 @@ function parseConfig(argv: string[]): DaemonRunnerConfig { continue; } if (arg === "--reclaim-stale-pid-lock") { - reclaimStalePidLock = true; - continue; + throw new Error( + "--reclaim-stale-pid-lock was removed: stop the existing supervisor before starting another.", + ); } workerArgs.push(arg); } - return { devMode, reclaimStalePidLock, workerArgs }; + return { devMode, workerArgs }; } function resolveWorkerEntry(): string { @@ -116,7 +115,6 @@ async function main(): Promise { try { await acquirePidLock(paseoHome, null, { ownerPid: process.pid, - reclaimStaleDesktopLock: config.reclaimStalePidLock, }); } catch (error) { if (error instanceof PidLockError) { @@ -177,6 +175,7 @@ async function main(): Promise { onWorkerReady: async ({ listen }) => { await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); }, + onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }), onSupervisorExit: releaseLock, }); requestSupervisorShutdown = supervisor.requestShutdown; diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 9b4c622860..3eb5af9b32 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -36,7 +36,10 @@ async function runSupervisorFixture(options: { const workerPath = path.join(tempDir, "worker.mjs"); const runnerPath = path.join(tempDir, "runner.mjs"); - await writeFile(workerPath, options.workerSource); + await writeFile( + workerPath, + `process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`, + ); await writeFile( runnerPath, ` diff --git a/packages/server/scripts/supervisor.readiness.test.ts b/packages/server/scripts/supervisor.readiness.test.ts new file mode 100644 index 0000000000..297299d810 --- /dev/null +++ b/packages/server/scripts/supervisor.readiness.test.ts @@ -0,0 +1,122 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +async function runWorker(worker: string, publicationDelay = 0) { + const home = await mkdtemp(path.join(tmpdir(), "paseo-readiness-")); + const workerPath = path.join(home, "worker.mjs"); + const runnerPath = path.join(home, "runner.mjs"); + const eventsPath = path.join(home, "events.jsonl"); + const supervisor = new URL("./supervisor.ts", import.meta.url).href; + await writeFile(workerPath, worker); + await writeFile( + runnerPath, + ` + import { appendFile } from "node:fs/promises"; + import { runSupervisor } from ${JSON.stringify(supervisor)}; + const record = (event) => appendFile(${JSON.stringify(eventsPath)}, JSON.stringify(event) + "\\n"); + runSupervisor({ + name: "ReadinessTest", startupMessage: "starting", restartOnCrash: true, + resolveWorkerEntry: () => ${JSON.stringify(workerPath)}, workerArgs: [], workerExecArgv: [], + onWorkerReady: async ({ listen }) => { + await new Promise(resolve => setTimeout(resolve, ${publicationDelay})); + await record(listen); + }, + onWorkerExit: () => record(null), + }); + `, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ); + const child = spawn(process.execPath, ["--import", "tsx", runnerPath], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + env: { ...env, HOME: home, PASEO_HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + try { + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(output)); + }, 5_000); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); + const events = await readFile(eventsPath, "utf8").catch(() => ""); + return { + code, + output, + events: events + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + }; + } finally { + await rm(home, { recursive: true, force: true }); + } +} + +test("a worker that exits before ready fails the supervisor without respawning", async () => { + const result = await runWorker(` + import { existsSync, writeFileSync } from "node:fs"; + const marker = process.argv[1] + ".started"; + if (existsSync(marker)) process.exit(0); + writeFileSync(marker, "started"); + process.exit(1); + `); + expect(result.code).toBe(1); + expect(result.output).not.toContain("Restarting worker"); +}); + +test("a replacement worker must independently reach ready", async () => { + const result = await runWorker(` + import { existsSync, writeFileSync } from "node:fs"; + const marker = process.argv[1] + ".started"; + if (existsSync(marker)) process.exit(0); + writeFileSync(marker, "started"); + process.on("message", message => { + if (message.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.send({ type: "paseo:ready", listen: "test-endpoint" }); + process.send({ type: "paseo:restart" }); + `); + expect(result.code).toBe(1); + expect(result.events, result.output).toEqual(["test-endpoint", null, null]); +}); + +test("exit clears publication after an in-flight ready write", async () => { + const result = await runWorker( + ` + process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0)); + `, + 100, + ); + expect(result.code).toBe(0); + expect(result.events.at(-1)).toBeNull(); +}); + +test("requested shutdown before first readiness exits successfully", async () => { + const result = await runWorker(` + process.on("message", message => { + if (message.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.send({ type: "paseo:shutdown", reason: "cancelled_start" }); + `); + expect(result.code).toBe(0); + expect(result.events).toEqual([null]); +}); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index f2d9fc7665..5d48491f2a 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -51,6 +51,7 @@ interface SupervisorOptions { env?: NodeJS.ProcessEnv; } | null; onWorkerReady?: (message: { listen: string }) => Promise | void; + onWorkerExit?: () => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; logFile?: SupervisorLogFileOptions; @@ -129,6 +130,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController let restarting = false; let shuttingDown = false; let exiting = false; + let lifecycleFailed = false; + let publication = Promise.resolve(); let forceKillTimer: NodeJS.Timeout | null = null; const logStream = createSupervisorLogStream(options.logFile); @@ -244,6 +247,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController } const currentChild = child; + let reachedReady = false; + // Serialize endpoint writes with exit/clear before allowing another worker to spawn. const heartbeat = setInterval(() => { const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" }; if (currentChild.connected) { @@ -276,18 +281,21 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController child.on("message", (msg: unknown) => { const lifecycleMessage = parseLifecycleMessage(msg); - if (!lifecycleMessage) { + if (!lifecycleMessage || child !== currentChild) { return; } if (lifecycleMessage.type === "paseo:ready") { + reachedReady = true; writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen }); - Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch( - (error) => { + publication = publication + .then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen })) + .catch((error) => { + lifecycleFailed = true; const message = error instanceof Error ? error.message : String(error); log(`Worker ready callback failed: ${message}`); - }, - ); + requestShutdown("endpoint_publication_failed"); + }); return; } @@ -303,35 +311,57 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController requestRestart(reason); }); + child.on("error", (error) => { + lifecycleFailed = true; + log(`Worker spawn failed: ${error.message}`); + exitSupervisor(1); + }); + child.on("exit", (code, signal) => { clearInterval(heartbeat); clearForceKillTimer(); - const exitDescriptor = describeExit(code, signal); - writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor }); + child = null; + publication = publication + .then(() => options.onWorkerExit?.()) + .catch((error) => { + lifecycleFailed = true; + log(`Worker exit callback failed: ${String(error)}`); + }); + void publication.then(() => { + const exitDescriptor = describeExit(code, signal); + writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor }); - if (shuttingDown) { - log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); - exitSupervisor(0); + if (lifecycleFailed || (!reachedReady && !shuttingDown)) { + log(`Worker exited before successful readiness (${exitDescriptor}). Supervisor exiting.`); + exitSupervisor(1); + return; + } + + if (shuttingDown) { + log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); + exitSupervisor(0); + return; + } + + const crashed = + restartOnCrash && + ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); + + if (restarting || crashed) { + restarting = false; + log( + crashed + ? `Worker crashed (${exitDescriptor}). Restarting worker...` + : `Worker exited (${exitDescriptor}). Restarting worker...`, + ); + spawnWorker(); + return; + } + + log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); + exitSupervisor(typeof code === "number" ? code : 1); return; - } - - const crashed = - restartOnCrash && - ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); - - if (restarting || crashed) { - restarting = false; - log( - crashed - ? `Worker crashed (${exitDescriptor}). Restarting worker...` - : `Worker exited (${exitDescriptor}). Restarting worker...`, - ); - spawnWorker(); - return; - } - - log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); - exitSupervisor(typeof code === "number" ? code : 1); + }); }); }; @@ -389,7 +419,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController writeLifecycleLog("Supervisor shutdown requested", { reason }); log(`${reason}. Stopping worker...`); if (!child) { - exitSupervisor(0); + void publication.then(() => { + exitSupervisor(lifecycleFailed ? 1 : 0); + return; + }); return; } requestWorkerShutdown(reason); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 81aee8aa7f..380e3618e0 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -80,12 +80,12 @@ export function parseListenString(listen: string): ListenTarget { throw new Error(`Invalid listen string: ${listen}`); } -function formatListenTarget(listenTarget: ListenTarget | null): string | null { +export function formatListenTarget(listenTarget: ListenTarget | null): string | null { if (!listenTarget) { return null; } if (listenTarget.type === "tcp") { - return `${listenTarget.host}:${listenTarget.port}`; + return `${formatHostForHttpUrl(listenTarget.host)}:${listenTarget.port}`; } return listenTarget.path; } diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts new file mode 100644 index 0000000000..92bf9183ee --- /dev/null +++ b/packages/server/src/server/config-environment.ts @@ -0,0 +1,90 @@ +// Daemon configuration inputs. General provider credentials and executable/runtime +// controls remain available to managed launches and their agent processes. +export const DAEMON_SETTING_ENV_KEYS = [ + "MCP_DEBUG", + "OPENAI_STT_BASE_URL", + "OPENAI_TTS_BASE_URL", + "PASEO_ALLOWED_HOSTS", + "PASEO_APP_BASE_URL", + "PASEO_CORS_ORIGINS", + "PASEO_DICTATION_ENABLED", + "PASEO_DICTATION_LANGUAGE", + "PASEO_DICTATION_LOCAL_STT_MODEL", + "PASEO_DICTATION_STT_PROVIDER", + "PASEO_GIT_CONCURRENCY", + "PASEO_GIT_MAX_PROCESSES_PER_SECOND", + "PASEO_GIT_MAX_PROCESS_CONCURRENCY", + "PASEO_HOSTNAMES", + "PASEO_LISTEN", + "PASEO_LOCAL_MODELS_DIR", + "PASEO_LOG", + "PASEO_LOG_CONSOLE_FORMAT", + "PASEO_LOG_CONSOLE_LEVEL", + "PASEO_LOG_FILE_LEVEL", + "PASEO_LOG_FILE_PATH", + "PASEO_LOG_FILE_ROTATE_COUNT", + "PASEO_LOG_FILE_ROTATE_SIZE", + "PASEO_LOG_FORMAT", + "PASEO_LOG_LEVEL", + "PASEO_LOG_ROTATE_COUNT", + "PASEO_LOG_ROTATE_SIZE", + "PASEO_PASSWORD", + "PASEO_RELAY_ENABLED", + "PASEO_RELAY_ENDPOINT", + "PASEO_RELAY_PUBLIC_ENDPOINT", + "PASEO_RELAY_PUBLIC_USE_TLS", + "PASEO_RELAY_USE_TLS", + "PASEO_SERVICE_PROXY_ENABLED", + "PASEO_SERVICE_PROXY_LISTEN", + "PASEO_SERVICE_PROXY_PUBLIC_BASE_URL", + "PASEO_TRUSTED_PROXIES", + "PASEO_VOICE_LANGUAGE", + "PASEO_VOICE_LLM_PROVIDER", + "PASEO_VOICE_LOCAL_STT_MODEL", + "PASEO_VOICE_LOCAL_TTS_MODEL", + "PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", + "PASEO_VOICE_LOCAL_TTS_SPEED", + "PASEO_VOICE_MODE_ENABLED", + "PASEO_VOICE_STT_PROVIDER", + "PASEO_VOICE_TTS_PROVIDER", + "PASEO_VOICE_TURN_DETECTION_PROVIDER", + "PASEO_WEB_UI_DIST_DIR", + "PASEO_WEB_UI_ENABLED", + "PORT", + "STT_CONFIDENCE_THRESHOLD", + "STT_MODEL", + "TTS_MODEL", + "TTS_VOICE", +] as const; + +const CONFIG_CONTEXT_ENV_KEYS = [ + "PASEO_NODE_ENV", + "PASEO_DESKTOP_MANAGED", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_STT_API_KEY", + "OPENAI_TTS_API_KEY", +] as const; + +export function configurationEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries( + [...DAEMON_SETTING_ENV_KEYS, ...CONFIG_CONTEXT_ENV_KEYS].map((key) => [key, env[key]]), + ); +} + +export function daemonLaunchEnvironment(input: { + env: NodeJS.ProcessEnv; + home: string; + mode: "managed" | "deployment"; + desktopManaged?: boolean; +}): NodeJS.ProcessEnv { + const env = { ...input.env }; + if (input.mode === "managed") { + for (const key of DAEMON_SETTING_ENV_KEYS) delete env[key]; + } + delete env.PASEO_HOST; + delete env.PASEO_DESKTOP_MANAGED; + env.PASEO_HOME = input.home; + if (input.desktopManaged) env.PASEO_DESKTOP_MANAGED = "1"; + return env; +} diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7ed5f16519..8a8eb39a67 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -1,3 +1,4 @@ +import { configurationEnvironment } from "./config-environment.js"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -554,7 +555,7 @@ export function resolveConfigFromPersisted( options?: ResolveConfigFromPersistedOptions, ): PaseoDaemonConfig { const resolvedOptions = options ?? {}; - const env = resolvedOptions.env ?? process.env; + const env = configurationEnvironment(resolvedOptions.env ?? process.env); const cli = resolvedOptions.cli; const relayEnabledFallback = resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; diff --git a/packages/server/src/server/daemon-instance.ts b/packages/server/src/server/daemon-instance.ts new file mode 100644 index 0000000000..f3101f3b28 --- /dev/null +++ b/packages/server/src/server/daemon-instance.ts @@ -0,0 +1,305 @@ +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { + getPidLockInfo, + isPidRunning, + isSamePidLock, + releasePidLock, + type PidLockInfo, +} from "./pid-lock.js"; +import { daemonLaunchEnvironment } from "./config-environment.js"; +import { readPersistedConfig } from "./persisted-config.js"; +import treeKill from "tree-kill"; +const killTree = (pid: number, signal: string): Promise => + new Promise((resolve, reject) => + treeKill(pid, signal, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + +export { daemonLaunchEnvironment } from "./config-environment.js"; +export { + isSamePidLock as isSameDaemonInstance, + type PidLockInfo as DaemonInstance, +} from "./pid-lock.js"; + +export class DaemonInstanceError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + } +} + +export async function readDaemonInstance(home: string): Promise { + const lock = await getPidLockInfo(home); + return lock && isPidRunning(lock.pid) ? lock : null; +} + +export function daemonLogPath(home: string): string { + try { + return path.resolve(home, readPersistedConfig(home).log?.file?.path ?? "daemon.log"); + } catch { + return path.join(home, "daemon.log"); + } +} + +export async function waitForDaemonReady( + home: string, + options: { + timeoutMs?: number; + instance?: PidLockInfo; + signal?: AbortSignal; + } = {}, +): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 600_000); + while (true) { + options.signal?.throwIfAborted(); + const instance = await readDaemonInstance(home); + if (!instance) + throw new DaemonInstanceError( + "DAEMON_NOT_RUNNING", + `Daemon is not running for ${home}. Start with: paseo daemon start --home ${JSON.stringify(home)}`, + ); + if (options.instance && !isSamePidLock(instance, options.instance)) { + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing to follow PID ${instance.pid}.`, + ); + } + if (instance.listen) return { ...instance, listen: instance.listen }; + if (Date.now() >= deadline) throw notReady(home, instance); + await delay(100, undefined, { signal: options.signal }); + } +} + +function notReady(home: string, instance: PidLockInfo): DaemonInstanceError { + return new DaemonInstanceError( + "DAEMON_NOT_READY", + `Daemon PID ${instance.pid} remains running but is not ready for ${home}.\nLogs: ${daemonLogPath(home)}\nStatus: paseo daemon status --home ${JSON.stringify(home)}\nStop: paseo daemon stop --home ${JSON.stringify(home)}`, + ); +} + +async function requestInstanceStop( + home: string, + instance: PidLockInfo, + options: { + force?: boolean; + requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; + }, +) { + let forced = false; + let usedLifecycleRpc = false; + if (process.platform === "win32") { + if (instance.listen && options.requestShutdown) { + try { + await options.requestShutdown({ ...instance, listen: instance.listen }); + usedLifecycleRpc = true; + } catch (error) { + if (!options.force) throw error; + const current = await getPidLockInfo(home); + if (current && !isSamePidLock(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing forced cleanup.`, + ); + await killTree(instance.pid, "SIGKILL"); + forced = true; + } + } else if (!options.force) { + throw new DaemonInstanceError( + "STOP_NO_GRACEFUL_CHANNEL", + `PID ${instance.pid} for ${home} has no graceful shutdown channel. Use --force explicitly to terminate it.`, + ); + } else { + await killTree(instance.pid, "SIGKILL"); + forced = true; + } + } else { + try { + process.kill(instance.pid, "SIGTERM"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + return { forced, usedLifecycleRpc }; +} + +export async function stopDaemonInstance( + home: string, + options: { + instance?: PidLockInfo; + force?: boolean; + timeoutMs?: number; + killTimeoutMs?: number; + requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; + } = {}, +): Promise<{ + action: "stopped" | "not_running"; + pid: number | null; + forced: boolean; + usedLifecycleRpc: boolean; +}> { + const { timeoutMs = 15_000, killTimeoutMs = 3_000 } = options; + const deadline = Date.now() + timeoutMs; + const instance = await getPidLockInfo(home); + if (options.instance && instance && !isSamePidLock(instance, options.instance)) { + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing to stop PID ${instance.pid}.`, + ); + } + if (!instance || !isPidRunning(instance.pid)) { + if (instance) + await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + return { + action: "not_running", + pid: instance?.pid ?? null, + forced: false, + usedLifecycleRpc: false, + }; + } + if (instance.pid <= 1 || instance.pid === process.pid) + throw new Error("Refusing to stop invalid supervisor PID"); + let { forced, usedLifecycleRpc } = await requestInstanceStop(home, instance, options); + const waitForExit = async (waitMs: number) => { + const exitDeadline = Date.now() + waitMs; + while (isPidRunning(instance.pid)) { + const current = await getPidLockInfo(home); + if (current && !isSamePidLock(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; stop of PID ${instance.pid} was not confirmed.`, + ); + if (Date.now() >= exitDeadline) return false; + await delay(100); + } + return true; + }; + let stopped = await waitForExit(forced ? killTimeoutMs : Math.max(0, deadline - Date.now())); + if (!stopped && options.force && !forced) { + await killTree(instance.pid, "SIGKILL"); + forced = true; + stopped = await waitForExit(killTimeoutMs); + } + if (!stopped) + throw new DaemonInstanceError( + "STOP_NOT_CONFIRMED", + `Timed out waiting for supervisor PID ${instance.pid} in ${home} to exit${options.force ? "" : "; use --force to permit forced cleanup"}.`, + ); + await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + return { action: "stopped", pid: instance.pid, forced, usedLifecycleRpc }; +} + +export async function startDaemonInstance(input: { + home: string; + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + mode: "managed" | "deployment"; + desktopManaged?: boolean; + foreground?: boolean; + timeoutMs?: number; + signal?: AbortSignal; + onAcquired?: (instance: PidLockInfo) => void; + onReady?: (instance: PidLockInfo & { listen: string }) => void; +}): Promise<{ instance: PidLockInfo; spawned: boolean; exitCode?: number }> { + input.signal?.throwIfAborted(); + const existing = await readDaemonInstance(input.home); + if (existing) return { instance: existing, spawned: false }; + const { + foreground = false, + timeoutMs = 600_000, + signal, + onAcquired = () => {}, + onReady = () => {}, + } = input; + const child = spawn(input.command, input.args, { + env: daemonLaunchEnvironment(input), + detached: !foreground, + stdio: foreground ? "inherit" : "ignore", + windowsHide: true, + }); + let exit: { code: number; error?: Error } | undefined; + const exited = new Promise((resolve) => { + child.once("error", (error) => { + exit = { code: 1, error }; + resolve(1); + }); + child.once("exit", (code) => { + exit = { code: code ?? 1 }; + resolve(code ?? 1); + }); + }); + const cancel = () => { + if (child.pid && !exit) { + child.ref(); + child.kill("SIGTERM"); + } + }; + signal?.addEventListener("abort", cancel, { once: true }); + if (!foreground) child.unref(); + const deadline = foreground ? Infinity : Date.now() + timeoutMs; + let acquired: PidLockInfo | undefined; + async function waitUntilReady() { + while (true) { + signal?.throwIfAborted(); + const instance = await readDaemonInstance(input.home); + if (instance && instance.pid !== child.pid) return { instance, spawned: false }; + if (instance && !acquired) { + acquired = instance; + onAcquired(instance); + } + if (exit) { + const logPath = daemonLogPath(input.home); + const log = await readFile(logPath, "utf8").catch(() => ""); + throw new DaemonInstanceError( + "DAEMON_START_FAILED", + `Daemon failed to start (${exit.error?.message ?? `exit ${exit.code}`}). Logs: ${logPath}\n${log.split("\n").slice(-30).join("\n")}`, + ); + } + if (instance?.listen) { + const ready = { ...instance, listen: instance.listen }; + onReady(ready); + return { + instance: ready, + spawned: true, + ...(foreground ? { exitCode: await exited } : {}), + }; + } + if (Date.now() >= deadline) { + if (acquired) throw notReady(input.home, acquired); + throw new DaemonInstanceError( + "DAEMON_NOT_READY", + `Supervisor PID ${child.pid} remains running but has not published its lock for ${input.home}. Logs: ${daemonLogPath(input.home)}. Check paseo daemon status --home ${JSON.stringify(input.home)}. Stop with paseo daemon stop --home ${JSON.stringify(input.home)} once its lock is published, or signal this PID.`, + ); + } + await delay(100, undefined, { signal }); + } + } + try { + return await waitUntilReady(); + } catch (error) { + if (!(error instanceof DaemonInstanceError && error.code === "DAEMON_NOT_READY")) { + cancel(); + const stopped = await Promise.race([ + exited.then(() => true), + delay(15_000, false, { ref: false }), + ]); + if (!stopped && child.pid) { + await killTree(child.pid, "SIGKILL"); + await exited; + } + } + throw error; + } finally { + signal?.removeEventListener("abort", cancel); + } +} diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index f7a770d8be..2adc4ab982 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -1,6 +1,6 @@ import { appendFileSync, mkdirSync } from "node:fs"; import path from "node:path"; -import { createPaseoDaemon } from "./bootstrap.js"; +import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; import { createRootLogger } from "./logger.js"; @@ -323,10 +323,7 @@ async function main() { try { await daemon.start(); const listenTarget = daemon.getListenTarget(); - const listen = - listenTarget?.type === "tcp" - ? `${listenTarget.host}:${listenTarget.port}` - : listenTarget?.path; + const listen = formatListenTarget(listenTarget); if (!listen) { throw new Error("Daemon did not expose a listen target after startup"); } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 0961e08b11..72efe5d572 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -96,3 +96,23 @@ export type { AgentStreamEventPayload, AgentStreamMessage, } from "@getpaseo/protocol/messages"; + +export { + readDaemonInstance, + startDaemonInstance, + stopDaemonInstance, + waitForDaemonReady, + daemonLogPath, + daemonLaunchEnvironment, + isSameDaemonInstance, + DaemonInstanceError, + type DaemonInstance, +} from "./daemon-instance.js"; +export { + readPersistedConfig, + getPersistedConfigValue, + editPersistedConfig, +} from "./persisted-config.js"; +export { resolveConfigFromPersisted } from "./config.js"; + +export { ensurePrivateDirectory } from "./private-files.js"; diff --git a/packages/server/src/server/paseo-home.test.ts b/packages/server/src/server/paseo-home.test.ts index 99a6c30d25..d8623d230b 100644 --- a/packages/server/src/server/paseo-home.test.ts +++ b/packages/server/src/server/paseo-home.test.ts @@ -1,24 +1,16 @@ -import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import { resolvePaseoHome } from "./paseo-home.js"; -import { PRIVATE_DIRECTORY_MODE } from "./private-files.js"; - -const MODE_MASK = 0o777; - -function modeOf(filePath: string): number { - return statSync(filePath).mode & MODE_MASK; -} - -describe.skipIf(process.platform === "win32")("resolvePaseoHome permissions", () => { - test("creates PASEO_HOME with private permissions", () => { +describe("resolvePaseoHome", () => { + test("resolves PASEO_HOME without creating it", () => { const parent = mkdtempSync(path.join(tmpdir(), "paseo-home-parent-")); const paseoHome = path.join(parent, "home"); try { expect(resolvePaseoHome({ PASEO_HOME: paseoHome })).toBe(paseoHome); - expect(modeOf(paseoHome)).toBe(PRIVATE_DIRECTORY_MODE); + expect(existsSync(paseoHome)).toBe(false); } finally { rmSync(parent, { recursive: true, force: true }); } diff --git a/packages/server/src/server/paseo-home.ts b/packages/server/src/server/paseo-home.ts index f933653b8b..90b0410cbd 100644 --- a/packages/server/src/server/paseo-home.ts +++ b/packages/server/src/server/paseo-home.ts @@ -1,6 +1,5 @@ import os from "node:os"; import path from "node:path"; -import { ensurePrivateDirectory } from "./private-files.js"; function expandHomeDir(input: string): string { if (input.startsWith("~/")) { @@ -15,6 +14,5 @@ function expandHomeDir(input: string): string { export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string { const raw = env.PASEO_HOME ?? "~/.paseo"; const resolved = path.resolve(expandHomeDir(raw)); - ensurePrivateDirectory(resolved); return resolved; } diff --git a/packages/server/src/server/persisted-config-edit.test.ts b/packages/server/src/server/persisted-config-edit.test.ts new file mode 100644 index 0000000000..4b4bf4c0db --- /dev/null +++ b/packages/server/src/server/persisted-config-edit.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { + editPersistedConfig, + getPersistedConfigValue, + readPersistedConfig, +} from "./persisted-config.js"; + +test("configuration edits validate before writing and preserve unrelated settings", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-config-edit-")); + const home = path.join(root, "home"); + try { + expect(readPersistedConfig(home)).toEqual({}); + expect(existsSync(home)).toBe(false); + for (const field of [ + "unknown", + "daemon.missing", + "daemon.__proto__.listen", + "daemon.auth.password", + ]) { + expect(() => editPersistedConfig(home, field, { value: "value" })).toThrow(); + expect(existsSync(home)).toBe(false); + } + editPersistedConfig(home, "daemon.listen", { value: "127.0.0.1:12345" }); + editPersistedConfig(home, "features.webUi.enabled", { value: true }); + const before = await readFile(path.join(home, "config.json"), "utf8"); + expect(() => editPersistedConfig(home, "features.webUi.enabled", { value: "true" })).toThrow(); + expect(() => + editPersistedConfig(home, "daemon", { value: { auth: { password: "plaintext" } } }), + ).toThrow(/set-password/); + expect(await readFile(path.join(home, "config.json"), "utf8")).toBe(before); + editPersistedConfig(home, "features.webUi.enabled", { unset: true }); + const edited = readPersistedConfig(home); + expect(getPersistedConfigValue(edited, "features.webUi.enabled")).toBeUndefined(); + expect(edited.daemon?.listen).toBe("127.0.0.1:12345"); + await writeFile(path.join(home, "config.json"), "invalid json"); + expect(() => editPersistedConfig(home, "daemon.listen", { value: "12345" })).toThrow(); + expect(await readFile(path.join(home, "config.json"), "utf8")).toBe("invalid json"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 8ea1c5c25d..2ab987eebf 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -8,7 +8,7 @@ import { PersistedConfigSchema, savePersistedConfig, } from "./persisted-config.js"; -import { PRIVATE_FILE_MODE } from "./private-files.js"; +import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js"; const MODE_MASK = 0o777; const PERMISSIVE_FILE_MODE = 0o644; @@ -764,7 +764,8 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }); test("saves config.json with private permissions", () => { - const home = createTempHome(); + const parent = createTempHome(); + const home = path.join(parent, "home"); try { savePersistedConfig(home, { providers: { @@ -774,9 +775,10 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }, }); + expect(modeOf(home)).toBe(PRIVATE_DIRECTORY_MODE); expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); } finally { - rmSync(home, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); } }); }); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 1cdaf209d8..678e72370d 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -463,6 +463,84 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per return result.data as PersistedConfig; } +/** Observe the file without initializing a home, identity, or default configuration. */ +export function readPersistedConfig( + paseoHome: string, + options: { defaultsIfMissing?: boolean } = {}, +): PersistedConfig { + let raw: string; + try { + raw = readFileSync(getConfigPath(paseoHome), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {}; + throw error; + } + return PersistedConfigSchema.parse(stripRemovedConfigFields(JSON.parse(raw))) as PersistedConfig; +} + +function configPathParts(field: string): string[] { + const parts = field.split("."); + let schema: z.ZodType = PersistedConfigSchema; + for (const part of parts) { + if (!part || ["__proto__", "prototype", "constructor"].includes(part)) { + throw new Error(`Invalid configuration path: ${field}`); + } + while ( + schema instanceof z.ZodOptional || + schema instanceof z.ZodDefault || + schema instanceof z.ZodPipe + ) + schema = (schema instanceof z.ZodPipe ? schema.in : schema.unwrap()) as z.ZodType; + if (!(schema instanceof z.ZodObject) || !Object.hasOwn(schema.shape, part)) { + throw new Error( + `Unknown configuration path: ${field}. Replace its containing object with JSON for dynamic keys.`, + ); + } + schema = schema.shape[part]; + } + return parts; +} + +export function getPersistedConfigValue(config: PersistedConfig, field: string): unknown { + return configPathParts(field).reduce( + (value, part) => + value && typeof value === "object" ? (value as Record)[part] : undefined, + config, + ); +} + +export function editPersistedConfig( + paseoHome: string, + field: string, + edit: { value: unknown } | { unset: true }, +): PersistedConfig { + const parts = configPathParts(field); + if (field === "daemon.auth" || field.startsWith("daemon.auth.")) { + throw new Error("Use daemon set-password to change the daemon password."); + } + const config = readPersistedConfig(paseoHome, { defaultsIfMissing: true }); + let object = config as Record; + for (const part of parts.slice(0, -1)) { + object[part] ??= {}; + object = object[part] as Record; + } + const key = parts.at(-1)!; + if (field === "daemon") { + const previousAuth = config.daemon?.auth; + const nextAuth = + "value" in edit && edit.value && typeof edit.value === "object" + ? (edit.value as Record).auth + : undefined; + if (JSON.stringify(previousAuth) !== JSON.stringify(nextAuth)) + throw new Error("Use daemon set-password to change the daemon password."); + } + if ("unset" in edit) delete object[key]; + else object[key] = edit.value; + savePersistedConfig(paseoHome, config); + return config; +} + export function savePersistedConfig( paseoHome: string, config: PersistedConfig, diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 1dc6c260bb..457e1e7edc 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, open, rm, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, open, rm, stat, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -15,7 +15,8 @@ import { describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { - const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-")); + const parent = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-")); + const paseoHome = join(parent, "home"); const ownerPid = process.pid + 10_000; try { @@ -27,6 +28,9 @@ describe("pid-lock ownership", () => { ) => Promise )(paseoHome, null, { ownerPid }); + if (process.platform !== "win32") { + expect((await stat(paseoHome)).mode & 0o777).toBe(0o700); + } const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); expect(lock?.listen).toBeNull(); @@ -55,7 +59,7 @@ describe("pid-lock ownership", () => { const lockAfterOwnerRelease = await getPidLockInfo(paseoHome); expect(lockAfterOwnerRelease).toBeNull(); } finally { - await rm(paseoHome, { recursive: true, force: true }); + await rm(parent, { recursive: true, force: true }); } }); @@ -92,7 +96,7 @@ describe("pid-lock ownership", () => { } }); - test("reclaims a stale desktop heartbeat lock after desktop confirms the daemon is unreachable", async () => { + test("preserves a stale live desktop heartbeat lock", async () => { const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-desktop-heartbeat-")); const replacementOwnerPid = process.pid + 10_000; @@ -113,14 +117,13 @@ describe("pid-lock ownership", () => { const staleTime = new Date(Date.now() - 10 * 60_000); await utimes(pidPath, staleTime, staleTime); - await acquirePidLock(paseoHome, null, { - ownerPid: replacementOwnerPid, - reclaimStaleDesktopLock: true, - }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); const lock = await getPidLockInfo(paseoHome); - expect(lock?.pid).toBe(replacementOwnerPid); - expect(lock?.listen).toBeNull(); + expect(lock?.pid).toBe(process.pid); + expect(lock?.listen).toBe("127.0.0.1:6767"); } finally { await rm(paseoHome, { recursive: true, force: true }); } @@ -156,7 +159,7 @@ describe("pid-lock ownership", () => { } }); - test("reclaims a stale legacy desktop lock after desktop confirms the daemon is unreachable", async () => { + test("preserves a stale live legacy desktop lock", async () => { const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-desktop-")); const replacementOwnerPid = process.pid + 10_000; const pidPath = join(paseoHome, "paseo.pid"); @@ -176,14 +179,13 @@ describe("pid-lock ownership", () => { const staleTime = new Date(Date.now() - 10 * 60_000); await utimes(pidPath, staleTime, staleTime); - await acquirePidLock(paseoHome, null, { - ownerPid: replacementOwnerPid, - reclaimStaleDesktopLock: true, - }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); const lock = await getPidLockInfo(paseoHome); - expect(lock?.pid).toBe(replacementOwnerPid); - expect(lock?.heartbeat).toBe(true); + expect(lock?.pid).toBe(process.pid); + expect(lock?.heartbeat).toBeUndefined(); } finally { await rm(paseoHome, { recursive: true, force: true }); } diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 9202f49dfd..f7ad90bcd1 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,12 +1,12 @@ -import { open, readFile, stat, unlink, mkdir, utimes } from "node:fs/promises"; +import { open, readFile, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; -import { existsSync } from "node:fs"; +import { ensurePrivateDirectory } from "./private-files.js"; import { join } from "node:path"; import { hostname } from "node:os"; import { z } from "zod"; export const pidLockInfoSchema = z.object({ - pid: z.number(), + pid: z.number().int().positive(), startedAt: z.string(), hostname: z.string(), uid: z.number(), @@ -36,18 +36,16 @@ export class PidLockError extends Error { } } -// Stale recovery is for abandoned locks, so keep this well above ordinary event-loop stalls. -const PID_LOCK_STALE_MS = 5 * 60_000; const PID_LOCK_HEARTBEAT_INTERVAL_MS = 30_000; const PID_LOCK_READ_RETRY_ATTEMPTS = 10; const PID_LOCK_READ_RETRY_DELAY_MS = 50; -function isPidRunning(pid: number): boolean { +export function isPidRunning(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { - return false; + } catch (error) { + return isErrnoException(error) && error.code === "EPERM"; } } @@ -55,27 +53,29 @@ function getPidFilePath(paseoHome: string): string { return join(paseoHome, "paseo.pid"); } -async function isPidLockFresh(pidPath: string): Promise { - try { - const lockStat = await stat(pidPath); - return lockStat.mtimeMs >= Date.now() - PID_LOCK_STALE_MS; - } catch { - return false; - } -} - async function touchPidLockFile(pidPath: string): Promise { const now = new Date(); await utimes(pidPath, now, now); } async function readPidLock(pidPath: string): Promise { - try { - const content = await readFile(pidPath, "utf-8"); - return parsePidLockInfo(JSON.parse(content)); - } catch { - return null; + let lastError: unknown; + for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) { + try { + const content = await readFile(pidPath, "utf-8"); + const lock = parsePidLockInfo(JSON.parse(content)); + if (lock) return lock; + lastError = new Error("Invalid lock shape"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS)); } + throw Object.assign( + new PidLockError(`Cannot read daemon state at ${pidPath}: ${String(lastError)}`), + { code: "DAEMON_STATE_READ_FAILED" }, + ); } function resolveOwnerPid(ownerPid?: number): number { @@ -87,19 +87,9 @@ function resolveOwnerPid(ownerPid?: number): number { interface AcquirePidLockOptions { ownerPid?: number; - reclaimStaleDesktopLock?: boolean; } -function canReclaimLiveLock( - lock: PidLockInfo, - options: AcquirePidLockOptions | undefined, -): boolean { - // COMPAT(pidLockHeartbeat): v0.1.108 desktop startup has already confirmed the old daemon is - // unreachable before it launches the supervisor. Remove after 2027-01-15. - return options?.reclaimStaleDesktopLock === true && lock.desktopManaged === true; -} - -function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { +export function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { return left.pid === right.pid && left.startedAt === right.startedAt; } @@ -114,7 +104,6 @@ async function clearExistingPidLock( pidPath: string, existingLock: PidLockInfo, lockOwnerPid: number, - options: AcquirePidLockOptions | undefined, ): Promise<"already_owned" | "cleared"> { const lockOwnerRunning = isPidRunning(existingLock.pid); if (existingLock.pid === lockOwnerPid && lockOwnerRunning) { @@ -122,21 +111,14 @@ async function clearExistingPidLock( return "already_owned"; } - if (lockOwnerRunning) { - const reclaimable = canReclaimLiveLock(existingLock, options); - if (!reclaimable || (await isPidLockFresh(pidPath))) { - throw createLockHeldError(existingLock); - } - - // Re-read immediately before unlinking so a heartbeat at the stale boundary wins. - const confirmedLock = await readPidLock(pidPath); - if ( - !confirmedLock || - !isSamePidLock(existingLock, confirmedLock) || - (await isPidLockFresh(pidPath)) - ) { - throw new PidLockError("PID lock changed while checking whether it was abandoned"); - } + if (lockOwnerRunning) throw createLockHeldError(existingLock); + const confirmedLock = await readPidLock(pidPath); + if ( + !confirmedLock || + !isSamePidLock(existingLock, confirmedLock) || + isPidRunning(confirmedLock.pid) + ) { + throw new PidLockError("PID lock changed while checking whether it was abandoned"); } await unlink(pidPath).catch(() => {}); @@ -173,10 +155,7 @@ export async function acquirePidLock( ): Promise { const pidPath = getPidFilePath(paseoHome); - // Ensure paseoHome directory exists - if (!existsSync(paseoHome)) { - await mkdir(paseoHome, { recursive: true }); - } + ensurePrivateDirectory(paseoHome); // Try to read existing lock const existingLock = await readPidLock(pidPath); @@ -184,7 +163,7 @@ export async function acquirePidLock( // Check if existing lock is stale const lockOwnerPid = resolveOwnerPid(options?.ownerPid); if (existingLock) { - const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid, options); + const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid); if (result === "already_owned") { return; } @@ -298,7 +277,7 @@ export function startPidLockHeartbeat( export async function updatePidLock( paseoHome: string, - patch: { listen: string }, + patch: { listen: string | null }, options?: { ownerPid?: number }, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -329,7 +308,7 @@ export async function updatePidLock( export async function releasePidLock( paseoHome: string, - options?: { ownerPid?: number }, + options?: { ownerPid?: number; startedAt?: string }, ): Promise { const pidPath = getPidFilePath(paseoHome); const lockOwnerPid = resolveOwnerPid(options?.ownerPid); @@ -337,7 +316,10 @@ export async function releasePidLock( // Only remove if it's our lock const content = await readFile(pidPath, "utf-8"); const lock = parsePidLockInfo(JSON.parse(content)); - if (lock?.pid === lockOwnerPid) { + if ( + lock?.pid === lockOwnerPid && + (options?.startedAt === undefined || lock.startedAt === options.startedAt) + ) { await unlink(pidPath); } } catch { diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 36c50769c8..5952307db4 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { pathToFileURL } from "node:url"; import type pino from "pino"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; @@ -8,6 +9,8 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; const REPO_CWD = path.resolve("/tmp/paseo-observation-repo"); const GIT_DIR = path.join(REPO_CWD, ".git"); +// Checkout observation must not depend on installed forge CLIs or host-auth probes. +const REMOTE_URL = pathToFileURL(path.join(REPO_CWD, "remote.git")).href; const WORKTREE_A = path.resolve("/tmp/paseo-observation-worktree-a"); const WORKTREE_B = path.resolve("/tmp/paseo-observation-worktree-b"); @@ -529,7 +532,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -554,7 +557,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -598,7 +601,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -655,7 +658,7 @@ describe("WorkspaceGitService checkout observation", () => { const releaseFetch = createDeferred(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, })); const runGitFetch = vi.fn(async (_cwd, observer) => { if (phase === "before") { @@ -710,7 +713,7 @@ describe("WorkspaceGitService checkout observation", () => { const releaseFetch = createDeferred(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, })); const getCheckoutRefDerivedState = vi.fn(); const service = createService(watcher, { @@ -760,7 +763,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -820,7 +823,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -849,7 +852,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -893,7 +896,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -904,7 +907,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -952,7 +955,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "main", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: null, branchRemoteName: null, diff --git a/public-docs/cli.md b/public-docs/cli.md index ed626863f2..93b066e25b 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -276,18 +276,55 @@ Detaching is an explicit lifecycle action, not a creation flag. The agent keeps ## Daemon management +Define an instance once, then start its saved configuration: + ```bash -paseo daemon start # Start the daemon -paseo daemon start --web-ui # Start and serve the bundled web UI -paseo daemon status # Check status -paseo reload # Reload config.json (top-level alias) -paseo daemon reload # Reload config.json -paseo daemon stop # Stop the daemon +paseo daemon config set daemon.listen 127.0.0.1:6799 --home ~/paseo-test +paseo daemon config set daemon.relay.enabled false --home ~/paseo-test +paseo daemon start --home ~/paseo-test +paseo project ls --home ~/paseo-test +paseo daemon restart --home ~/paseo-test +paseo daemon stop --home ~/paseo-test ``` -Reload validates the whole file, applies runtime-safe changes, and reports `appliedPaths`, `restartRequiredPaths`, and `overrideControlledPaths`. Human output prints `paseo daemon restart` only when a changed setting needs it. Use `--json` or `--format yaml` for the structured result. Run `paseo --host reload` to reload a remote daemon's own configuration file. An older host that does not support reload returns an update-host error. +`start` runs in the background and reports the actual listening address and supervisor PID. It accepts only home selection and `--timeout ` (default 600). If waiting times out, the supervisor remains running: use the printed status, log, and stop instructions. A worker that exits before becoming ready makes startup fail. -Use `PASEO_HOME` to run multiple isolated daemon instances. +`restart` requests a replacement worker from the existing supervisor. It rereads the file and retains the supervisor's original environment and arguments. Success confirms a different ready worker, following a changed address for a home target. It never starts a stopped daemon or refreshes the supervisor binary. A timeout reports whether the request was acknowledged; it does not prove why reconnection failed. + +`stop --home` waits for that local supervisor to exit. On POSIX it signals the supervisor without contacting a TCP endpoint. On Windows it uses the ready daemon's shutdown RPC; an unbound instance requires explicit `--force`. `--force` permits forced process-tree cleanup after the graceful timeout (default 15 seconds). `stop --host` only reports **shutdown requested**; remote process exit is not verified. A service manager may start another instance after the captured supervisor exits. + +`status` separates local supervisor state, its published endpoint, the configured address, and RPC reachability. A stopped home is never probed at its configured address. An unbound live supervisor is **not ready**. If an authenticated local connection remains open but status details time out, the result stays `reachable` with a note explaining the unavailable details. Worker and provider fields are omitted. An explicit `--host` query still fails when its status request fails. + +`reload` validates the file, applies runtime-safe changes, and reports `appliedPaths`, `restartRequiredPaths`, and `overrideControlledPaths`. It never implicitly restarts. Use `--json` or `--format yaml` for structured results. An older host lacking the capability reports that it needs an update. + +The root aliases `start`, `status`, `restart`, `reload`, and `pair` use the same commands as `daemon`. Root `run` and `stop` remain agent operations. + +### Foreground deployments and migration + +Use environment overrides with the foreground deployment command: + +```bash +PASEO_LISTEN=127.0.0.1:6799 PASEO_RELAY_ENABLED=false paseo daemon run --home ~/paseo-test +``` + +It stays attached until the supervisor exits or you cancel, without a readiness timeout. Worker restart retains these launch inputs. Stop and relaunch the deployment to change them. If the home already has a live supervisor, `run` returns `already_running` without owning or launching a foreground process. + +Managed `start` ignores inherited daemon-setting overrides, including `PORT`, `PASEO_LISTEN`, relay, voice, and web UI settings. It preserves provider credentials and executable/runtime controls. `start --foreground` is removed; use `daemon run`. Former start/restart configuration flags such as `--port`, `--no-relay`, and `--web-ui` fail before side effects, with the corresponding `config set` migration. See [configuration edits](/docs/configuration#apply-changes). + +### Select one daemon + +Every daemon-connected CLI command accepts global `--home` or `--host`, before or after the command. A home selects a local supervisor's published endpoint; a host selects an explicit endpoint. There is no configured-address or default-port fallback. + +| Selectors | Result | +| ------------------------------------------ | ------------------------------------------------------ | +| `--home` | That local home, overriding both environment selectors | +| `--host` | That endpoint, overriding both environment selectors | +| Both flags, or conflicting duplicate flags | `TARGET_AMBIGUOUS` | +| Only `PASEO_HOME` or only `PASEO_HOST` | The corresponding target | +| Both environment selectors, without a flag | `TARGET_AMBIGUOUS` | +| Neither | Default local home, `~/.paseo` | + +Local-only `start`, `daemon run`, `config`, `onboard`, and `set-password` reject explicit `--host` and ignore `PASEO_HOST`. Endpoint operations retain TCP, Unix socket, Windows pipe, SSH, and pairing-offer transports. A host-side CLI controlling a container needs `--host` or `PASEO_HOST`. ## Hub @@ -329,12 +366,12 @@ The global `--host` option accepts either a local target (`host:port`, a unix so Get an offer URL from the daemon you want to control: ```bash -paseo daemon pair # asks before enabling relay, then prints the QR and link +paseo daemon pair # prints the QR and link when relay is enabled paseo daemon pair --relay # enables relay without prompting paseo daemon pair --json # structured output; never prompts ``` -Relay is off for new installations. In non-interactive or JSON mode, a disabled relay returns a `RELAY_DISABLED` error; pass `--relay` to provide explicit consent. Relay pairing is end-to-end encrypted. See [Security](/docs/security). +Relay is off for new installations. A disabled relay returns a `RELAY_DISABLED` error; pass `--relay` to provide explicit consent. For a stopped home, pairing is labelled offline; `--relay` saves relay enablement and the offer includes a start instruction. A live but unreachable home never falls back to an offline identity. Relay pairing is end-to-end encrypted. See [Security](/docs/security). Use it from anywhere: diff --git a/public-docs/configuration.md b/public-docs/configuration.md index a220420ee4..ca578a96ac 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -1,6 +1,6 @@ --- title: Configuration -description: Configure Paseo via config.json, environment variables, and CLI overrides. +description: Configure managed instances and foreground deployments. nav: Configuration order: 40 category: Configuration @@ -8,7 +8,7 @@ category: Configuration # Configuration -Paseo loads configuration from a single JSON file in your Paseo home directory, with optional environment variable and CLI overrides. +Managed instances load their configuration from the home’s `config.json` and defaults. Foreground deployments can override that file with environment variables. ## Where config lives @@ -18,18 +18,13 @@ By default, Paseo uses `~/.paseo` as its home directory. The configuration file ~/.paseo/config.json ``` -You can change the home directory by setting `PASEO_HOME` or passing `--home` to `paseo daemon start`. +You can change the home directory by setting `PASEO_HOME` or selecting `--home` on a CLI command. ## Precedence -Paseo merges configuration in this order: +Managed `start` and Desktop use defaults, then `config.json`. Inherited daemon-setting environment variables are removed from their launches. -1. Defaults -2. `config.json` -3. Environment variables -4. CLI flags - -Lists append across sources (for example, `hostnames` and `cors.allowedOrigins`). +Foreground `paseo daemon run`, Docker, and direct supervisor launches apply environment variables after the file. Existing legacy supervisor flags retain their precedence across worker restart. Changing deployment overrides requires stopping and relaunching that deployment. Lists append across sources, including hostnames and CORS origins. ## Example @@ -51,7 +46,19 @@ Minimal example that configures listening address, hostnames, and MCP: ## Apply changes -After saving `config.json`, reload it: +Edit the selected file through the CLI: + +```bash +paseo daemon config get daemon.listen --home ~/paseo-test +paseo daemon config set daemon.listen 127.0.0.1:6800 --home ~/paseo-test +paseo daemon config unset features.webUi.enabled --home ~/paseo-test +``` + +`get [path]` reports configured values and labels missing fields unset. It does not create a home. `set` parses JSON, otherwise treats the input as a string; `--string` forces a literal string. `--json` selects output format. Use whole-object JSON for dynamic keys containing dots. Unknown paths, invalid values, and invalid existing files are rejected without writing. Passwords use `set-password`. + +A successful edit saves the validated file, then reloads once if the instance is reachable. Output distinguishes applied changes, restart requirements, and deployment overrides. A stopped/unbound/unreachable instance reports **saved; not applied**. A failed reload reports **saved; reload failed** with a nonzero exit; the file remains saved. Edits never implicitly restart. + +After editing `config.json` directly, reload it: ```bash paseo reload @@ -69,7 +76,7 @@ New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon w Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths. -Environment variables and daemon start flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Remove the override and restart the daemon if you want the file value to become authoritative. +Deployment environment variables and legacy supervisor flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Stop and relaunch the deployment without the override to make the file value authoritative. ## Agent providers @@ -104,13 +111,14 @@ The daemon can serve the browser web client from the same HTTP server. This is e Enable it from the CLI: ```bash -paseo daemon start --web-ui +paseo daemon config set features.webUi.enabled true +paseo daemon start ``` Or set the environment variable: ```bash -PASEO_WEB_UI_ENABLED=true paseo daemon start +PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Or persist it in `config.json`: @@ -225,7 +233,7 @@ Set the persisted value in `config.json`: } ``` -`PASEO_RELAY_ENABLED=true|false` overrides the persisted value for that daemon launch. The matching `paseo daemon start --relay` and `--no-relay` flags have the same authority. Remove the launch override before changing relay from Paseo Desktop or `paseo daemon pair --relay`. +`PASEO_RELAY_ENABLED=true|false` overrides the file for a foreground deployment. Managed `start` uses the file. End and relaunch a deployment to remove its override before changing relay from the app or `paseo daemon pair --relay`. ## Common env vars diff --git a/public-docs/connectivity.md b/public-docs/connectivity.md index 425e605fd1..3263e61510 100644 --- a/public-docs/connectivity.md +++ b/public-docs/connectivity.md @@ -43,7 +43,7 @@ Set a different remote daemon port with `daemonPort`: paseo --host 'ssh://user@host?daemonPort=7777' ls -a ``` -Put `--host` before the command. `paseo daemon status` checks only the local daemon; use `paseo --host ssh://user@host ls` to verify a remote connection. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host. +Put `--host` before the command. `paseo daemon status` observes the default local home; use `paseo --host ssh://user@host daemon status` to query a remote daemon. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host. In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination. diff --git a/public-docs/docker.md b/public-docs/docker.md index be533e66a2..f6324a09f2 100644 --- a/public-docs/docker.md +++ b/public-docs/docker.md @@ -41,6 +41,8 @@ The image: The image does not bundle agent CLIs such as Claude Code, Codex, OpenCode, Copilot, or Pi. Add the agents you use with a small child image. +Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement. + ## Docker Compose ```yaml diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index fd2628ed9f..9e80e8355e 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -20,16 +20,17 @@ The web app ships inside the daemon package, so the UI you serve always matches ## Enable it -The bundled web UI is off by default. Turn it on when you start the daemon: +The bundled web UI is off by default. Save the setting, then start the daemon: ```bash -paseo daemon start --web-ui +paseo daemon config set features.webUi.enabled true +paseo daemon start ``` -Or with an environment variable: +Or run a foreground deployment with an environment variable: ```bash -PASEO_WEB_UI_ENABLED=true paseo daemon start +PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Or persist it in `config.json` so it survives restarts: @@ -73,7 +74,8 @@ The rest of this page builds from local to public. **Verify a direct connection By default the daemon listens on `127.0.0.1:6767`, reachable only from the same machine. To reach it from other devices, bind it to a network interface: ```bash -paseo daemon start --web-ui --listen 0.0.0.0:6767 +paseo daemon config set daemon.listen 0.0.0.0:6767 +paseo daemon start ``` > **Anyone who can reach the listening address can use your agents.** Before you bind beyond localhost, set a password and review your host allowlist. The relay pairing path avoids this entirely by keeping the daemon bound to localhost, see [Security](/docs/security). @@ -83,7 +85,7 @@ Two things to configure when you expose the daemon directly: 1. **Set a password** so only authorized clients can connect: ```bash - PASEO_PASSWORD=my-secret paseo daemon start --web-ui --listen 0.0.0.0:6767 + paseo daemon set-password ``` See [password authentication](/docs/configuration#password-authentication) for the persistent setup. Password auth controls access; it does not encrypt traffic, put TLS in front of it (below) on any untrusted network. @@ -91,7 +93,7 @@ Two things to configure when you expose the daemon directly: 2. **Allow your hostname** so the daemon's DNS-rebinding protection accepts requests for your domain: ```bash - paseo daemon start --web-ui --listen 0.0.0.0:6767 --hostnames ".example.com" + paseo daemon config set daemon.hostnames '[".example.com"]' ``` See [DNS rebinding protection](/docs/security#dns-rebinding-protection) for how the host allowlist works. @@ -179,7 +181,7 @@ If your proxy reaches the daemon from another address, as in some Docker, LAN, o `PASEO_TRUSTED_PROXIES` accepts the same comma-separated values: ```bash -PASEO_TRUSTED_PROXIES=loopback,172.16.0.0/12 paseo daemon start --web-ui +PASEO_TRUSTED_PROXIES=loopback,172.16.0.0/12 PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Only use `trustedProxies: true` when your final trusted proxy overwrites client-supplied `X-Forwarded-*` headers. Otherwise a client could spoof forwarded header values.