From 6aba0370aeea9973260a9dd594097d127e7dc834 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 15:04:31 +0200 Subject: [PATCH] Make remote daemon update failures actionable (#2120) * fix(settings): show daemon update failures React Native web ignores Alert.alert, so failed remote updates reset to an enabled button without any user-visible explanation. Keep updater progress and failure as explicit rendered state, including the daemon's error, and cover the real browser-to-daemon failure path. * test(settings): harden daemon update regression * fix(settings): disable Desktop-managed daemon updates Desktop-bundled daemons advertised npm self-update even though the install-origin checks always rejected it. Report Desktop ownership to clients, render actionable guidance, and refuse stale requests before touching npm. --- docs/coding-standards.md | 2 + docs/testing.md | 12 ++ packages/app/e2e/fixtures.ts | 13 ++ packages/app/e2e/helpers/daemon-update.ts | 93 ++++++++++ packages/app/e2e/settings-host-page.spec.ts | 46 ++++- packages/app/src/contexts/session-context.tsx | 6 + .../diagnostics/app-diagnostic-report.test.ts | 18 +- .../src/diagnostics/app-diagnostic-report.ts | 9 + packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../screens/settings/daemon-reconnect.test.ts | 39 ++++ .../src/screens/settings/daemon-reconnect.ts | 20 +++ .../app/src/screens/settings/host-page.tsx | 167 +++++++++++------- packages/app/src/stores/session-store.ts | 17 +- packages/protocol/src/messages.ts | 2 + packages/server/src/server/bootstrap.ts | 5 +- packages/server/src/server/config.test.ts | 15 +- packages/server/src/server/config.ts | 1 + .../src/server/daemon-client.e2e.test.ts | 22 +++ ...mon-self-update-session-controller.test.ts | 13 +- .../daemon-self-update-session-controller.ts | 4 + .../daemon/daemon-self-updater.test.ts | 19 ++ .../session/daemon/daemon-self-updater.ts | 8 + .../server/session/daemon/daemon-session.ts | 3 + .../test-utils/outdated-daemon-process.ts | 38 ++++ .../src/server/test-utils/paseo-daemon.ts | 4 + .../server/src/server/websocket-server.ts | 32 +--- 33 files changed, 531 insertions(+), 92 deletions(-) create mode 100644 packages/app/e2e/helpers/daemon-update.ts create mode 100644 packages/app/src/screens/settings/daemon-reconnect.test.ts create mode 100644 packages/app/src/screens/settings/daemon-reconnect.ts create mode 100644 packages/server/src/server/test-utils/outdated-daemon-process.ts diff --git a/docs/coding-standards.md b/docs/coding-standards.md index f33f77f15..31fb96bc4 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -47,6 +47,8 @@ For testing rules, see [testing.md](testing.md). - Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`. - Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI. - Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y. +- Every fallible user action owns explicit pending, success, and failure UI. Console logs and unverified platform alerts do not satisfy this contract. See [testing.md](testing.md#fallible-user-actions). +- A capability advertised to a client means the current runtime can perform the action, not merely that its RPC handler exists. If an unavailable action needs explanatory UI, send the runtime fact or reason separately and keep the server-side refusal fail-closed. ## Density diff --git a/docs/testing.md b/docs/testing.md index bea99d889..98e41d635 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -21,6 +21,18 @@ WRONG (horizontal): Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior. +## Fallible user actions + +Every user action that can fail must expose the complete operation state in the UI: + +- **Pending:** show immediate progress and prevent accidental duplicate submissions. +- **Success:** show the requested result, or a clear success acknowledgement when the result is not otherwise visible. +- **Failure:** keep an actionable error visible in the same context until the user retries or dismisses it. + +Logs, console output, and a reset button are not user feedback. Neither is a platform API unless it is verified on every supported platform: React Native Web's `Alert.alert()` is a no-op, so browser and Electron failures must use rendered app UI such as the shared alert component. + +Every fallible action needs behavioral coverage for success and failure. RPC-backed UI should use an app Playwright test with a real browser, network, and daemon whenever feasible. The failure test must assert what the user can see and do after the failure, not an internal response, state field, or log line. Add distinct timeout or disconnect cases when they produce distinct recovery behavior. + ## Determinism first Tests must produce the same result every run: diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index a0abe9129..35390c9f1 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,4 +1,5 @@ import { test as base, expect, type Page } from "@playwright/test"; +import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; import { @@ -22,6 +23,8 @@ interface TrackedProjectPickerFixture extends ProjectPickerFixture { // reliably for every test that uses this `test` object. const test = base.extend<{ paseoE2ESetup: void; + outdatedDaemon: OutdatedDaemon; + desktopManagedOutdatedDaemon: OutdatedDaemon; projectPickerFixture: TrackedProjectPickerFixture; withWorkspace: WithWorkspace; }>({ @@ -116,6 +119,16 @@ const test = base.extend<{ }, { auto: true }, ], + outdatedDaemon: async ({}, provide) => { + const daemon = await startOutdatedDaemon(); + await provide(daemon); + await daemon.close(); + }, + desktopManagedOutdatedDaemon: async ({}, provide) => { + const daemon = await startOutdatedDaemon({ desktopManaged: true }); + await provide(daemon); + await daemon.close(); + }, projectPickerFixture: async ({}, provide) => { const resource = await createProjectPickerFixture(); const { fixture } = resource; diff --git a/packages/app/e2e/helpers/daemon-update.ts b/packages/app/e2e/helpers/daemon-update.ts new file mode 100644 index 000000000..11e4d12a0 --- /dev/null +++ b/packages/app/e2e/helpers/daemon-update.ts @@ -0,0 +1,93 @@ +import { fork, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import path from "node:path"; + +export interface OutdatedDaemon { + endpoint: string; + label: string; + serverId: string; + close(): Promise; +} + +interface OutdatedDaemonReadyMessage { + type: "ready"; + endpoint: string; + serverId: string; +} + +interface OutdatedDaemonErrorMessage { + type: "error"; + error: string; +} + +type OutdatedDaemonMessage = OutdatedDaemonReadyMessage | OutdatedDaemonErrorMessage; + +export async function startOutdatedDaemon(options?: { + desktopManaged?: boolean; +}): Promise { + const metroPort = process.env.E2E_METRO_PORT; + if (!metroPort) { + throw new Error("E2E_METRO_PORT is not set - globalSetup must run first"); + } + + const child = fork( + path.resolve(__dirname, "../../../server/src/server/test-utils/outdated-daemon-process.ts"), + { + env: { + ...process.env, + E2E_METRO_PORT: metroPort, + E2E_DESKTOP_MANAGED: options?.desktopManaged === true ? "1" : "0", + }, + execArgv: ["--import", "tsx"], + stdio: ["ignore", "pipe", "pipe", "ipc"], + }, + ); + const stderr: string[] = []; + child.stderr?.on("data", (data: Buffer) => stderr.push(data.toString("utf8"))); + + try { + const ready = await waitForDaemon(child, stderr); + return { + endpoint: ready.endpoint, + label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host", + serverId: ready.serverId, + async close() { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + await once(child, "exit"); + }, + }; + } catch (error) { + child.kill("SIGTERM"); + throw error; + } +} + +async function waitForDaemon( + child: ChildProcess, + stderr: string[], +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out starting outdated daemon. ${stderr.join("")}`)); + }, 20_000); + + child.once("exit", (code, signal) => { + clearTimeout(timeout); + reject( + new Error( + `Outdated daemon exited before startup (code ${String(code)}, signal ${String(signal)}). ${stderr.join("")}`, + ), + ); + }); + child.once("message", (message: OutdatedDaemonMessage) => { + if (message.type === "error") { + clearTimeout(timeout); + reject(new Error(message.error)); + return; + } + clearTimeout(timeout); + resolve(message); + }); + }); +} diff --git a/packages/app/e2e/settings-host-page.spec.ts b/packages/app/e2e/settings-host-page.spec.ts index efbbdc3ae..7a16a3566 100644 --- a/packages/app/e2e/settings-host-page.spec.ts +++ b/packages/app/e2e/settings-host-page.spec.ts @@ -1,4 +1,4 @@ -import { test } from "./fixtures"; +import { expect, test } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { TEST_HOST_LABEL } from "./helpers/daemon-registry"; @@ -18,6 +18,7 @@ import { expectRetiredSidebarSectionsAbsent, expectHostPageVisible, expectLocalHostEntryFirst, + seedSavedSettingsHosts, } from "./helpers/settings"; test.describe("Settings host page", () => { @@ -69,6 +70,49 @@ test.describe("Settings host page", () => { await expectHostActionCards(page, serverId); }); + test("a failed remote daemon update remains visible in the host UI", async ({ + page, + outdatedDaemon, + }) => { + await seedSavedSettingsHosts(page, [outdatedDaemon]); + await page.reload(); + await openSettings(page); + await openSettingsHost(page, outdatedDaemon.serverId); + await openHostSection(page, outdatedDaemon.serverId, "host"); + + page.once("dialog", (dialog) => dialog.accept()); + const updateButton = page.getByTestId("host-page-update-button"); + await updateButton.click(); + + await expect( + updateButton.filter({ hasText: /Preparing update|Downloading packages|Installing/ }), + ).toBeDisabled(); + + const updateFailure = page.getByTestId("host-page-update-error"); + await expect(updateFailure).toBeVisible(); + await expect(updateFailure).toContainText("Update failed"); + await expect(updateFailure).toContainText("Failed to update the daemon:"); + await expect(updateButton).toBeEnabled(); + }); + + test("a Desktop-managed daemon explains why its update action is disabled", async ({ + page, + desktopManagedOutdatedDaemon, + }) => { + await seedSavedSettingsHosts(page, [desktopManagedOutdatedDaemon]); + await page.reload(); + await openSettings(page); + await openSettingsHost(page, desktopManagedOutdatedDaemon.serverId); + await openHostSection(page, desktopManagedOutdatedDaemon.serverId, "host"); + + const updateCard = page.getByTestId("host-page-update-card"); + await expect(updateCard).toBeVisible(); + await expect(updateCard).toContainText( + "This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.", + ); + await expect(page.getByTestId("host-page-update-button")).toBeDisabled(); + }); + test("clicking the label pencil reveals the inline editor", async ({ page }) => { const serverId = getServerId(); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 7d2ab8074..f919e41da 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -951,6 +951,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId: serverInfo.serverId, hostname: serverInfo.hostname, version: serverInfo.version, + ...(serverInfo.desktopManaged !== undefined + ? { desktopManaged: serverInfo.desktopManaged } + : {}), ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), ...(serverInfo.features ? { features: serverInfo.features } : {}), }); @@ -1422,6 +1425,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId: serverInfo.serverId, hostname: serverInfo.hostname, version: serverInfo.version, + ...(serverInfo.desktopManaged !== undefined + ? { desktopManaged: serverInfo.desktopManaged } + : {}), ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), ...(serverInfo.features ? { features: serverInfo.features } : {}), }); diff --git a/packages/app/src/diagnostics/app-diagnostic-report.test.ts b/packages/app/src/diagnostics/app-diagnostic-report.test.ts index 44512e3ee..c01244d96 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; -import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report"; +import { + formatHostRuntimeSection, + formatServerInfoSection, + redactAppDiagnosticReport, +} from "./app-diagnostic-report"; import type { HostRuntimeSnapshot } from "@/runtime/host-runtime"; import type { HostProfile } from "@/types/host-connection"; @@ -41,6 +45,18 @@ function makeHost(): HostProfile { } describe("app diagnostics report", () => { + test("reports whether the connected daemon is managed by Paseo Desktop", () => { + const report = formatServerInfoSection({ + status: "server_info", + serverId: "srv-desktop-managed", + hostname: "desktop-host.local", + version: "0.1.108", + desktopManaged: true, + }); + + expect(report).toContain("Desktop managed: yes"); + }); + test("formats connection rows without raw connection details", () => { const host = makeHost(); const snapshot: HostRuntimeSnapshot = { diff --git a/packages/app/src/diagnostics/app-diagnostic-report.ts b/packages/app/src/diagnostics/app-diagnostic-report.ts index 33aaad9e3..6a5764ff9 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.ts @@ -7,6 +7,11 @@ interface DiagnosticEntry { value: string; } +function formatDiagnosticBoolean(value: boolean | undefined): string { + if (value === undefined) return "unknown"; + return value ? "yes" : "no"; +} + export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string { return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n"); } @@ -77,6 +82,10 @@ export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | nu { label: "Server ID", value: serverInfo.serverId }, { label: "Hostname", value: serverInfo.hostname ?? "unknown" }, { label: "Version", value: serverInfo.version ?? "unknown" }, + { + label: "Desktop managed", + value: formatDiagnosticBoolean(serverInfo.desktopManaged), + }, { label: "Features", value: features.length > 0 ? features.join(", ") : "none" }, ]); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2fa57725a..e909e6eeb 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1859,6 +1859,8 @@ export const ar: TranslationResources = { dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.", }, update: { + desktopManagedHint: + "يدير Paseo Desktop هذا البرنامج الخفي. حدّث Paseo Desktop على المضيف.", title: "Update daemon", hint: "Update the daemon to the latest version and restart it", confirm: "Update", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c85d45fd6..e379d4367 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1868,6 +1868,8 @@ export const en = { dialogFailedMessage: "Unable to open the restart confirmation dialog.", }, update: { + desktopManagedHint: + "This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.", title: "Update daemon", hint: "Update the daemon to the latest version and restart it", confirm: "Update", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 69529eea1..902cbd0b6 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1906,6 +1906,8 @@ export const es: TranslationResources = { "No se puede abrir el cuadro de diálogo de confirmación de reinicio.", }, update: { + desktopManagedHint: + "Este daemon está administrado por Paseo Desktop. Actualiza Paseo Desktop en el host.", title: "Update daemon", hint: "Update the daemon to the latest version and restart it", confirm: "Update", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index eb27e6c1a..6919a443a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1909,6 +1909,8 @@ export const fr: TranslationResources = { "Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.", }, update: { + desktopManagedHint: + "Ce daemon est géré par Paseo Desktop. Mettez à jour Paseo Desktop sur l’hôte.", title: "Update daemon", hint: "Update the daemon to the latest version and restart it", confirm: "Update", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1ed9eabed..54cfefebe 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1880,6 +1880,8 @@ export const ja: TranslationResources = { dialogFailedMessage: "再起動確認ダイアログを開けませんでした。", }, update: { + desktopManagedHint: + "このデーモンはPaseo Desktopによって管理されています。ホスト上のPaseo Desktopを更新してください。", title: "デーモンを更新", hint: "デーモンを最新バージョンに更新して再起動します", confirm: "更新", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5d292c9f2..22995e17d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1890,6 +1890,8 @@ export const ptBR: TranslationResources = { dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.", }, update: { + desktopManagedHint: + "Este daemon é gerenciado pelo Paseo Desktop. Atualize o Paseo Desktop no host.", title: "Atualizar daemon", hint: "Atualiza o daemon para a versão mais recente e o reinicia", confirm: "Atualizar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 268dd2a87..f7496e749 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1896,6 +1896,8 @@ export const ru: TranslationResources = { dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.", }, update: { + desktopManagedHint: + "Этот демон управляется Paseo Desktop. Обновите Paseo Desktop на хосте.", title: "Update daemon", hint: "Update the daemon to the latest version and restart it", confirm: "Update", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index ceb49d858..bfc8fbb29 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1837,6 +1837,7 @@ export const zhCN: TranslationResources = { dialogFailedMessage: "无法打开重启确认对话框。", }, update: { + desktopManagedHint: "此 Daemon 由 Paseo Desktop 管理。请在 Host 上更新 Paseo Desktop。", title: "Update daemon", hint: "Update the daemon to the latest version and restart it", confirm: "Update", diff --git a/packages/app/src/screens/settings/daemon-reconnect.test.ts b/packages/app/src/screens/settings/daemon-reconnect.test.ts new file mode 100644 index 000000000..d83e3e0bb --- /dev/null +++ b/packages/app/src/screens/settings/daemon-reconnect.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { hasDaemonReconnectedAfter } from "./daemon-reconnect"; + +describe("daemon update reconnect detection", () => { + it("accepts a reconnect performed by the existing daemon client", () => { + const start = { + clientGeneration: 4, + lastOnlineAt: "2026-07-16T10:00:00.000Z", + }; + + const reconnected = hasDaemonReconnectedAfter( + { + connectionStatus: "online", + clientGeneration: 4, + lastOnlineAt: "2026-07-16T10:00:05.000Z", + }, + start, + ); + + expect(reconnected).toBe(true); + }); + + it("does not accept the original connection before the daemon restarts", () => { + const start = { + clientGeneration: 4, + lastOnlineAt: "2026-07-16T10:00:00.000Z", + }; + + const reconnected = hasDaemonReconnectedAfter( + { + connectionStatus: "online", + ...start, + }, + start, + ); + + expect(reconnected).toBe(false); + }); +}); diff --git a/packages/app/src/screens/settings/daemon-reconnect.ts b/packages/app/src/screens/settings/daemon-reconnect.ts new file mode 100644 index 000000000..1095d8974 --- /dev/null +++ b/packages/app/src/screens/settings/daemon-reconnect.ts @@ -0,0 +1,20 @@ +export interface DaemonConnectionMarker { + clientGeneration: number; + lastOnlineAt: string | null; +} + +interface DaemonConnectionSnapshot extends DaemonConnectionMarker { + connectionStatus: "idle" | "connecting" | "online" | "offline" | "error"; +} + +export function hasDaemonReconnectedAfter( + snapshot: DaemonConnectionSnapshot | null, + start: DaemonConnectionMarker | null, +): boolean { + if (snapshot?.connectionStatus !== "online") return false; + if (!start) return true; + return ( + snapshot.clientGeneration !== start.clientGeneration || + snapshot.lastOnlineAt !== start.lastOnlineAt + ); +} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 9fdb7fbcd..e2a62970f 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -24,6 +24,7 @@ import { import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; +import { Alert as InlineAlert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { @@ -68,6 +69,7 @@ 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"; const ThemedArrowUp = withUnistyles(ArrowUp); @@ -365,7 +367,7 @@ export function HostSettingsPage({ {isLocalDaemon ? : null} - {!isLocalDaemon ? : null} + {!isLocalDaemon ? : null} @@ -755,14 +757,19 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { ); } + +type DaemonUpdateState = + | { status: "idle" } + | { 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(); - const [isUpdating, setIsUpdating] = useState(false); - const [progressPhase, setProgressPhase] = useState(null); + const [updateState, setUpdateState] = useState({ status: "idle" }); const isMountedRef = useRef(true); const unsubscribeRef = useRef<(() => void) | null>(null); @@ -772,6 +779,9 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { const supportsSelfUpdate = useSessionStore( (state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true, ); + const desktopManaged = useSessionStore( + (state) => state.sessions[host.serverId]?.serverInfo?.desktopManaged === true, + ); const appVersion = resolveAppVersion(); const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion); @@ -788,11 +798,8 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { [host.serverId, runtime], ); const hasReconnectedAfter = useCallback( - (startGeneration: number | null) => { - const snapshot = runtime.getSnapshot(host.serverId); - if (!snapshot || !isHostRuntimeConnected(snapshot)) return false; - return startGeneration === null || snapshot.clientGeneration !== startGeneration; - }, + (startMarker: DaemonConnectionMarker | null) => + hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker), [host.serverId, runtime], ); @@ -810,27 +817,30 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { ); const waitForDaemonRestart = useCallback( - async (startGeneration: number | null) => { + async (startMarker: DaemonConnectionMarker | null) => { const disconnectTimeoutMs = 15000; const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while - if (!hasReconnectedAfter(startGeneration) && isHostConnected()) { + if (!hasReconnectedAfter(startMarker) && isHostConnected()) { await waitForCondition( - () => !isHostConnected() || hasReconnectedAfter(startGeneration), + () => !isHostConnected() || hasReconnectedAfter(startMarker), disconnectTimeoutMs, ); } const reconnected = - hasReconnectedAfter(startGeneration) || - (await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs)); + hasReconnectedAfter(startMarker) || + (await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs)); if (isMountedRef.current) { - setIsUpdating(false); - setProgressPhase(null); if (!reconnected) { - Alert.alert( - t("settings.host.daemon.update.unableToReconnectTitle"), - t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }), - ); + 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], @@ -838,17 +848,19 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { const handleUpdate = useCallback(() => { if (!daemonClient) { - Alert.alert( - t("settings.host.daemon.update.unavailableTitle"), - t("settings.host.daemon.update.unavailableMessage"), - ); + setUpdateState({ + status: "failed", + title: t("settings.host.daemon.update.unavailableTitle"), + message: t("settings.host.daemon.update.unavailableMessage"), + }); return; } if (!isHostConnected()) { - Alert.alert( - t("settings.host.daemon.update.offlineTitle"), - t("settings.host.daemon.update.offlineMessage"), - ); + setUpdateState({ + status: "failed", + title: t("settings.host.daemon.update.offlineTitle"), + message: t("settings.host.daemon.update.offlineMessage"), + }); return; } @@ -860,10 +872,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { destructive: false, }) .then((confirmed) => { - if (!confirmed) return; - const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null; - setIsUpdating(true); - setProgressPhase(t("settings.host.daemon.update.phaseStarting")); + 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"), + }); const requestId = `settings_daemon_update_${host.serverId}`; const unsubscribe = daemonClient.on("daemon.update.progress", (message) => { @@ -871,13 +891,25 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { if (!isMountedRef.current) return; const { phase } = message.payload; if (phase === "starting") - setProgressPhase(t("settings.host.daemon.update.phaseStarting")); + setUpdateState({ + status: "updating", + phase: t("settings.host.daemon.update.phaseStarting"), + }); else if (phase === "downloading") - setProgressPhase(t("settings.host.daemon.update.phaseDownloading")); + setUpdateState({ + status: "updating", + phase: t("settings.host.daemon.update.phaseDownloading"), + }); else if (phase === "installing") - setProgressPhase(t("settings.host.daemon.update.phaseInstalling")); + setUpdateState({ + status: "updating", + phase: t("settings.host.daemon.update.phaseInstalling"), + }); else if (phase === "complete") - setProgressPhase(t("settings.host.daemon.update.phaseComplete")); + setUpdateState({ + status: "updating", + phase: t("settings.host.daemon.update.phaseComplete"), + }); }); unsubscribeRef.current = unsubscribe; @@ -888,18 +920,17 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { unsubscribe(); if (!response.success) { if (!isMountedRef.current) return undefined; - setIsUpdating(false); - setProgressPhase(null); - Alert.alert( - t("settings.host.daemon.update.requestFailedTitle"), - t("settings.host.daemon.update.requestFailedMessage", { + 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(startGeneration); + void waitForDaemonRestart(startMarker); return undefined; }) .catch((error) => { @@ -907,23 +938,24 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { unsubscribe(); console.error(`[HostPage] Failed to update daemon ${host.label}`, error); if (!isMountedRef.current) return; - setIsUpdating(false); - setProgressPhase(null); - Alert.alert( - t("settings.host.daemon.update.requestFailedTitle"), - t("settings.host.daemon.update.requestFailedMessage", { + setUpdateState({ + status: "failed", + title: t("settings.host.daemon.update.requestFailedTitle"), + message: t("settings.host.daemon.update.requestFailedMessage", { error: error instanceof Error ? error.message : "Unknown error", }), - ); + }); }); return; }) .catch((error) => { console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error); - Alert.alert( - t("settings.host.daemon.update.requestFailedTitle"), - t("settings.host.daemon.update.dialogFailedMessage"), - ); + if (!isMountedRef.current) return; + setUpdateState({ + status: "failed", + title: t("settings.host.daemon.update.requestFailedTitle"), + message: t("settings.host.daemon.update.dialogFailedMessage"), + }); }); }, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]); @@ -932,33 +964,46 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { [theme.iconSize.sm, theme.colors.foreground], ); - // Don't show if the daemon doesn't support self-update or versions match - if (!supportsSelfUpdate || !hasVersionMismatch) { + const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged); + if (!shouldShowUpdate) { return null; } - const buttonLabel = isUpdating - ? (progressPhase ?? t("settings.host.daemon.update.updating")) - : t("settings.host.daemon.update.confirm"); + const isUpdating = updateState.status === "updating"; + const buttonLabel = isUpdating ? updateState.phase : t("settings.host.daemon.update.confirm"); return ( {t("settings.host.daemon.update.title")} - {t("settings.host.daemon.update.hint")} + + {desktopManaged + ? t("settings.host.daemon.update.desktopManagedHint") + : t("settings.host.daemon.update.hint")} + + {updateState.status === "failed" ? ( + + + + ) : null} ); } @@ -1798,6 +1843,10 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({ })); const styles = StyleSheet.create((theme) => ({ + updateFailure: { + marginHorizontal: theme.spacing[4], + marginBottom: theme.spacing[4], + }, identityEditButton: { padding: theme.spacing[1], borderRadius: theme.borderRadius.md, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index bea9f1c75..a19e43e43 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -306,6 +306,7 @@ export interface DaemonServerInfo { serverId: string; hostname: string | null; version: string | null; + desktopManaged?: boolean; capabilities?: ServerCapabilities; features?: ServerInfoStatusPayload["features"]; } @@ -591,17 +592,26 @@ function isSessionServerInfoUnchanged(input: { currentServerInfo: SessionState["serverInfo"] | undefined; nextHostname: string | null; nextVersion: string | null; + nextDesktopManaged: boolean | undefined; nextCapabilities: ServerCapabilities | undefined; nextFeatures: ServerInfoStatusPayload["features"] | undefined; nextServerId: string; }): boolean { - const { currentServerInfo, nextHostname, nextVersion, nextCapabilities, nextFeatures } = input; + const { + currentServerInfo, + nextHostname, + nextVersion, + nextDesktopManaged, + nextCapabilities, + nextFeatures, + } = input; const prevHostname = currentServerInfo?.hostname?.trim() || null; const prevVersion = currentServerInfo?.version?.trim() || null; return ( currentServerInfo?.serverId === input.nextServerId && prevHostname === nextHostname && prevVersion === nextVersion && + currentServerInfo?.desktopManaged === nextDesktopManaged && areServerCapabilitiesEqual(currentServerInfo?.capabilities, nextCapabilities) && areServerInfoFeaturesEqual(currentServerInfo?.features, nextFeatures) ); @@ -721,6 +731,7 @@ export const useSessionStore = create()( const nextHostname = info.hostname?.trim() || null; const nextVersion = info.version?.trim() || null; + const nextDesktopManaged = info.desktopManaged; const nextCapabilities = info.capabilities; const nextFeatures = info.features; @@ -729,6 +740,7 @@ export const useSessionStore = create()( currentServerInfo: session.serverInfo, nextHostname, nextVersion, + nextDesktopManaged, nextCapabilities, nextFeatures, nextServerId: info.serverId, @@ -747,6 +759,9 @@ export const useSessionStore = create()( serverId: info.serverId, hostname: nextHostname, version: nextVersion, + ...(nextDesktopManaged !== undefined + ? { desktopManaged: nextDesktopManaged } + : {}), ...(nextCapabilities ? { capabilities: nextCapabilities } : {}), ...(nextFeatures ? { features: nextFeatures } : {}), }, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ad8167c94..75533f13e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2423,6 +2423,8 @@ export const ServerInfoStatusPayloadSchema = z serverId: z.string().trim().min(1), hostname: ServerInfoHostnameSchema.optional(), version: ServerInfoVersionSchema.optional(), + // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. + desktopManaged: z.boolean().optional(), capabilities: ServerCapabilitiesFromUnknownSchema.optional(), // COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot features: z diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2c0b2885f..da61823e7 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -335,6 +335,8 @@ export type DaemonLifecycleIntent = export interface PaseoDaemonConfig { listen: string; paseoHome: string; + daemonVersion?: string; + desktopManaged?: boolean; worktreesRoot?: string; corsAllowedOrigins: string[]; allowedHosts?: HostnamesConfig; @@ -483,7 +485,7 @@ export async function createPaseoDaemon( const logger = rootLogger.child({ module: "bootstrap" }); const bootstrapStart = performance.now(); const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; - const daemonVersion = resolveDaemonVersion(import.meta.url); + const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url); const daemonConfigStore = new DaemonConfigStore( config.paseoHome, createInitialMutableDaemonConfig(config), @@ -1371,6 +1373,7 @@ export async function createPaseoDaemon( listen: formatListenTarget(boundListenTarget ?? listenTarget), worktreesRoot: config.worktreesRoot, appBaseUrl: config.appBaseUrl, + desktopManaged: config.desktopManaged === true, relay: { enabled: relayEnabled, endpoint: relayEndpoint, diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 3ae640a99..f9d4929c6 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; -import { resolveBundledWebUiDistDir } from "./config.js"; +import { loadConfig, resolveBundledWebUiDistDir } from "./config.js"; const roots: string[] = []; @@ -13,6 +13,19 @@ describe("server config", () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + test("records when the daemon is managed by Paseo Desktop", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-")); + roots.push(paseoHome); + + const desktopConfig = loadConfig(paseoHome, { + env: { PASEO_DESKTOP_MANAGED: "1" }, + }); + const standaloneConfig = loadConfig(paseoHome, { env: {} }); + + expect(desktopConfig.desktopManaged).toBe(true); + expect(standaloneConfig.desktopManaged).toBe(false); + }); + test("resolves bundled web UI path from source-tree modules", () => { const root = path.parse(process.cwd()).root; expect( diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 83ee56267..7d9b71d7d 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -488,6 +488,7 @@ export function loadConfig( return { listen, paseoHome, + desktopManaged: env.PASEO_DESKTOP_MANAGED === "1", worktreesRoot: resolveWorktreesRoot(paseoHome, persisted), corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), hostnames, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 944ca4465..5c411e993 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1006,10 +1006,32 @@ test("receives server_info on websocket connect", async () => { expect(serverInfo).not.toBeNull(); expect(serverInfo?.serverId.length).toBeGreaterThan(0); expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true); + expect(serverInfo?.desktopManaged).toBe(false); + expect(serverInfo?.features?.daemonSelfUpdate).toBe(true); await client.close(); }, 15000); +test("a Desktop-managed daemon does not advertise npm self-update", async () => { + const daemon = await createTestPaseoDaemon({ desktopManaged: true }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: `cid-desktop-managed-${randomUUID()}`, + clientType: "cli", + }); + + try { + await client.connect(); + const serverInfo = client.getLastServerInfoMessage(); + + expect(serverInfo?.desktopManaged).toBe(true); + expect(serverInfo?.features?.daemonSelfUpdate).toBe(false); + } finally { + await client.close(); + await daemon.close(); + } +}, 15000); + test("emits disabled voice capability reasons on fresh daemon startup", async () => { const isolatedCtx = await createDaemonTestContext({ speech: { diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts index d6c57f14c..ff274d901 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts @@ -23,12 +23,14 @@ interface ControllerHarness { function createController(input: { updater: TestUpdater; daemonVersion?: string | null; + desktopManaged?: boolean; }): ControllerHarness { const emitted: SessionOutboundMessage[] = []; const restartIntents: RestartIntent[] = []; const controller = new DaemonSelfUpdateSessionController({ clientId: "client-1", daemonVersion: input.daemonVersion ?? "0.1.15", + desktopManaged: input.desktopManaged, emit: (msg) => { emitted.push(msg); }, @@ -82,6 +84,7 @@ describe("DaemonSelfUpdateSessionController", () => { await controller.dispatch(updateRequest); expect(updateInput?.daemonVersion).toBe("0.1.15"); + expect(updateInput?.desktopManaged).toBe(false); expect(emitted).toEqual([ { type: "daemon.update.progress", @@ -119,15 +122,21 @@ describe("DaemonSelfUpdateSessionController", () => { }); test("emits a failed response without restart lifecycle intent", async () => { + let updateInput: DaemonSelfUpdateInput | null = null; const updater: TestUpdater = { - async update() { + async update(input) { + updateInput = input; return { success: false, error: "not an npm global install", newVersion: null }; }, }; - const { controller, emitted, restartIntents } = createController({ updater }); + const { controller, emitted, restartIntents } = createController({ + updater, + desktopManaged: true, + }); await controller.dispatch(updateRequest); + expect(updateInput?.desktopManaged).toBe(true); expect(emitted).toEqual([ { type: "daemon.update.response", diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts index 0cfb6a43d..461a97277 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts @@ -24,6 +24,7 @@ interface DaemonSelfUpdateRestartIntent { export interface DaemonSelfUpdateSessionControllerOptions { clientId: string; daemonVersion: string | null; + desktopManaged?: boolean; emit: (msg: SessionOutboundMessage) => void; emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void; sessionLogger: pino.Logger; @@ -37,6 +38,7 @@ function isDaemonSelfUpdateMessage(msg: SessionInboundMessage): msg is DaemonUpd export class DaemonSelfUpdateSessionController { private readonly clientId: string; private readonly daemonVersion: string | null; + private readonly desktopManaged: boolean; private readonly emit: (msg: SessionOutboundMessage) => void; private readonly emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void; private readonly sessionLogger: pino.Logger; @@ -45,6 +47,7 @@ export class DaemonSelfUpdateSessionController { constructor(options: DaemonSelfUpdateSessionControllerOptions) { this.clientId = options.clientId; this.daemonVersion = options.daemonVersion; + this.desktopManaged = options.desktopManaged === true; this.emit = options.emit; this.emitLifecycleIntent = options.emitLifecycleIntent; this.sessionLogger = options.sessionLogger; @@ -64,6 +67,7 @@ export class DaemonSelfUpdateSessionController { try { const result = await this.updater.update({ daemonVersion: previousVersion, + desktopManaged: this.desktopManaged, onProgress: (phase) => this.emitProgress(msg.requestId, phase), logger: this.sessionLogger, }); diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts index 2736e3834..faf9491f5 100644 --- a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts @@ -84,6 +84,7 @@ function createRuntime(input: { async function runUpdate(input: { runtime: DaemonSelfUpdateRuntime; daemonVersion?: string | null; + desktopManaged?: boolean; phases?: DaemonSelfUpdatePhase[]; }) { const logger = createLogger(); @@ -91,6 +92,7 @@ async function runUpdate(input: { const phases = input.phases ?? []; const result = await updater.update({ daemonVersion: input.daemonVersion ?? "0.1.15", + desktopManaged: input.desktopManaged ?? false, onProgress: (phase) => phases.push(phase), logger, }); @@ -98,6 +100,21 @@ async function runUpdate(input: { } describe("DaemonSelfUpdater", () => { + test("refuses a Desktop-managed daemon without touching npm", async () => { + const calls: RuntimeCall[] = []; + const runtime = createRuntime({ calls, inspections: [] }); + + const { result, phases } = await runUpdate({ runtime, desktopManaged: true }); + + expect(result).toEqual({ + success: false, + error: "This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.", + newVersion: null, + }); + expect(phases).toEqual([]); + expect(calls).toEqual([]); + }); + test("updates a daemon that is running from the npm global cli install", async () => { const calls: RuntimeCall[] = []; const runtime = createRuntime({ @@ -214,6 +231,7 @@ describe("DaemonSelfUpdater", () => { const firstUpdate = updater.update({ daemonVersion: "0.1.15", + desktopManaged: false, onProgress: () => {}, logger, }); @@ -222,6 +240,7 @@ describe("DaemonSelfUpdater", () => { await expect( updater.update({ daemonVersion: "0.1.15", + desktopManaged: false, onProgress: () => {}, logger, }), diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.ts b/packages/server/src/server/session/daemon/daemon-self-updater.ts index cdb958935..b7ca60eda 100644 --- a/packages/server/src/server/session/daemon/daemon-self-updater.ts +++ b/packages/server/src/server/session/daemon/daemon-self-updater.ts @@ -16,6 +16,7 @@ export interface DaemonSelfUpdateResult { export interface DaemonSelfUpdateInput { daemonVersion: string | null; + desktopManaged: boolean; onProgress: (phase: DaemonSelfUpdatePhase) => void; logger: DaemonSelfUpdateLogger; } @@ -42,12 +43,19 @@ const defaultRuntime: DaemonSelfUpdateRuntime = { installOrigin: daemonInstallOriginRuntime, }; +const DESKTOP_MANAGED_UPDATE_ERROR = + "This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host."; + export class DaemonSelfUpdater { private inProgress = false; constructor(private readonly runtime: DaemonSelfUpdateRuntime = defaultRuntime) {} async update(input: DaemonSelfUpdateInput): Promise { + if (input.desktopManaged) { + return { success: false, error: DESKTOP_MANAGED_UPDATE_ERROR, newVersion: null }; + } + if (this.inProgress) { throw new DaemonSelfUpdateInProgressError(); } diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index bc9e89dec..beb525118 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -13,7 +13,9 @@ import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../wor export interface DaemonRuntimeConfig { listen: string | null; + worktreesRoot?: string; appBaseUrl?: string; + desktopManaged?: boolean; relay: { enabled: boolean; endpoint: string; @@ -86,6 +88,7 @@ export class DaemonSession { this.selfUpdate = new DaemonSelfUpdateSessionController({ clientId: this.clientId, daemonVersion: this.daemonVersion ?? null, + desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, emit: (msg) => this.host.emit(msg), emitLifecycleIntent: (intent) => this.host.emitLifecycleIntent(intent), sessionLogger: this.logger, diff --git a/packages/server/src/server/test-utils/outdated-daemon-process.ts b/packages/server/src/server/test-utils/outdated-daemon-process.ts new file mode 100644 index 000000000..b09aa4bf5 --- /dev/null +++ b/packages/server/src/server/test-utils/outdated-daemon-process.ts @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { createTestPaseoDaemon } from "./paseo-daemon.js"; + +async function main(): Promise { + const metroPort = process.env.E2E_METRO_PORT; + if (!metroPort) { + throw new Error("E2E_METRO_PORT is not set"); + } + + const daemon = await createTestPaseoDaemon({ + corsAllowedOrigins: [`http://localhost:${metroPort}`], + daemonVersion: "0.0.0", + desktopManaged: process.env.E2E_DESKTOP_MANAGED === "1", + }); + const serverId = (await readFile(path.join(daemon.paseoHome, "server-id"), "utf8")).trim(); + + process.send?.({ + type: "ready", + endpoint: `127.0.0.1:${daemon.port}`, + serverId, + }); + + const shutdown = async () => { + await daemon.close(); + process.exit(0); + }; + process.once("SIGINT", () => void shutdown()); + process.once("SIGTERM", () => void shutdown()); +} + +void main().catch((error) => { + process.send?.({ + type: "error", + error: error instanceof Error ? (error.stack ?? error.message) : String(error), + }); + process.exit(1); +}); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 8d4006560..284059b66 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -14,6 +14,8 @@ import { createTestAgentClients } from "./fake-agent-client.js"; import type { PushNotificationSender } from "../push/notifications.js"; interface TestPaseoDaemonOptions { + daemonVersion?: string; + desktopManaged?: boolean; downloadTokenTtlMs?: number; corsAllowedOrigins?: string[]; listen?: string; @@ -157,6 +159,8 @@ async function prepareTestDaemonConfig( const config: PaseoDaemonConfig = { listen: `${listenHost}:0`, paseoHome, + daemonVersion: options.daemonVersion, + desktopManaged: options.desktopManaged, corsAllowedOrigins: options.corsAllowedOrigins ?? [], hostnames: true, mcpEnabled: options.mcpEnabled ?? true, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 948d14d3b..90cb496cd 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -77,6 +77,7 @@ import { type BrowserAutomationHostCapability, } from "@getpaseo/protocol/browser-automation/capabilities"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; +import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; @@ -404,19 +405,7 @@ export class VoiceAssistantWebSocketServer { private readonly externalSessionsByKey: Map = new Map(); private readonly serverId: string; private readonly daemonVersion: string; - private readonly daemonRuntimeConfig: - | { - listen: string | null; - appBaseUrl?: string; - relay: { - enabled: boolean; - endpoint: string; - publicEndpoint: string; - useTls: boolean; - publicUseTls: boolean; - }; - } - | undefined; + private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; @@ -507,18 +496,7 @@ export class VoiceAssistantWebSocketServer { github?: GitHubService, pushNotificationSender?: PushNotificationSender, providerSnapshotManager?: ProviderSnapshotManager, - daemonRuntimeConfig?: { - listen: string | null; - worktreesRoot?: string; - appBaseUrl?: string; - relay: { - enabled: boolean; - endpoint: string; - publicEndpoint: string; - useTls: boolean; - publicUseTls: boolean; - }; - }, + daemonRuntimeConfig?: DaemonRuntimeConfig, serviceProxyPublicBaseUrl?: string | null, browserToolsBroker?: BrowserToolsBroker | null, ) { @@ -1227,6 +1205,8 @@ export class VoiceAssistantWebSocketServer { serverId: this.serverId, hostname: getHostname(), version: this.daemonVersion, + // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. + desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. @@ -1257,7 +1237,7 @@ export class VoiceAssistantWebSocketServer { // COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100. daemonDiagnostics: true, // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. - daemonSelfUpdate: true, + daemonSelfUpdate: this.daemonRuntimeConfig?.desktopManaged !== true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14.