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.
This commit is contained in:
Mohamed Boudra
2026-07-16 21:04:31 +08:00
committed by GitHub
parent f4509fe044
commit 6aba0370ae
33 changed files with 531 additions and 92 deletions
+2
View File
@@ -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
+12
View File
@@ -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:
+13
View File
@@ -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;
+93
View File
@@ -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<void>;
}
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<OutdatedDaemon> {
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<OutdatedDaemonReadyMessage> {
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);
});
});
}
+45 -1
View File
@@ -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();
@@ -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 } : {}),
});
@@ -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 = {
@@ -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" },
]);
}
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -1880,6 +1880,8 @@ export const ja: TranslationResources = {
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
},
update: {
desktopManagedHint:
"このデーモンはPaseo Desktopによって管理されています。ホスト上のPaseo Desktopを更新してください。",
title: "デーモンを更新",
hint: "デーモンを最新バージョンに更新して再起動します",
confirm: "更新",
+2
View File
@@ -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",
+2
View File
@@ -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",
+1
View File
@@ -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",
@@ -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);
});
});
@@ -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
);
}
+108 -59
View File
@@ -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 ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
{!isLocalDaemon ? <UpdateDaemonCard key={host.serverId} host={host} /> : null}
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
@@ -755,14 +757,19 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
</View>
);
}
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<string | null>(null);
const [updateState, setUpdateState] = useState<DaemonUpdateState>({ 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 (
<View style={settingsStyles.card} testID="host-page-update-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.update.hint")}</Text>
<Text style={settingsStyles.rowHint}>
{desktopManaged
? t("settings.host.daemon.update.desktopManagedHint")
: t("settings.host.daemon.update.hint")}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={updateIcon}
onPress={handleUpdate}
disabled={isUpdating || !daemonClient || !isConnected}
disabled={desktopManaged || isUpdating || !daemonClient || !isConnected}
testID="host-page-update-button"
>
{buttonLabel}
</Button>
</View>
{updateState.status === "failed" ? (
<View style={styles.updateFailure}>
<InlineAlert
variant="error"
title={updateState.title}
description={updateState.message}
testID="host-page-update-error"
/>
</View>
) : null}
</View>
);
}
@@ -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,
+16 -1
View File
@@ -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<SessionStore>()(
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<SessionStore>()(
currentServerInfo: session.serverInfo,
nextHostname,
nextVersion,
nextDesktopManaged,
nextCapabilities,
nextFeatures,
nextServerId: info.serverId,
@@ -747,6 +759,9 @@ export const useSessionStore = create<SessionStore>()(
serverId: info.serverId,
hostname: nextHostname,
version: nextVersion,
...(nextDesktopManaged !== undefined
? { desktopManaged: nextDesktopManaged }
: {}),
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
...(nextFeatures ? { features: nextFeatures } : {}),
},
+2
View File
@@ -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
+4 -1
View File
@@ -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,
+14 -1
View File
@@ -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(
+1
View File
@@ -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,
@@ -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: {
@@ -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",
@@ -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,
});
@@ -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,
}),
@@ -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<DaemonSelfUpdateResult> {
if (input.desktopManaged) {
return { success: false, error: DESKTOP_MANAGED_UPDATE_ERROR, newVersion: null };
}
if (this.inProgress) {
throw new DaemonSelfUpdateInProgressError();
}
@@ -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,
@@ -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<void> {
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);
});
@@ -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,
+6 -26
View File
@@ -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<string, SessionConnection> = 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.