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 }`. - 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. - 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. - 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 ## 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. 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 ## Determinism first
Tests must produce the same result every run: 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 { test as base, expect, type Page } from "@playwright/test";
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
import { getE2EDaemonPort } from "./helpers/daemon-port"; import { getE2EDaemonPort } from "./helpers/daemon-port";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { import {
@@ -22,6 +23,8 @@ interface TrackedProjectPickerFixture extends ProjectPickerFixture {
// reliably for every test that uses this `test` object. // reliably for every test that uses this `test` object.
const test = base.extend<{ const test = base.extend<{
paseoE2ESetup: void; paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture; projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace; withWorkspace: WithWorkspace;
}>({ }>({
@@ -116,6 +119,16 @@ const test = base.extend<{
}, },
{ auto: true }, { 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) => { projectPickerFixture: async ({}, provide) => {
const resource = await createProjectPickerFixture(); const resource = await createProjectPickerFixture();
const { fixture } = resource; 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 { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port"; import { getE2EDaemonPort } from "./helpers/daemon-port";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry"; import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
@@ -18,6 +18,7 @@ import {
expectRetiredSidebarSectionsAbsent, expectRetiredSidebarSectionsAbsent,
expectHostPageVisible, expectHostPageVisible,
expectLocalHostEntryFirst, expectLocalHostEntryFirst,
seedSavedSettingsHosts,
} from "./helpers/settings"; } from "./helpers/settings";
test.describe("Settings host page", () => { test.describe("Settings host page", () => {
@@ -69,6 +70,49 @@ test.describe("Settings host page", () => {
await expectHostActionCards(page, serverId); 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 }) => { test("clicking the label pencil reveals the inline editor", async ({ page }) => {
const serverId = getServerId(); const serverId = getServerId();
@@ -951,6 +951,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId: serverInfo.serverId, serverId: serverInfo.serverId,
hostname: serverInfo.hostname, hostname: serverInfo.hostname,
version: serverInfo.version, version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}), ...(serverInfo.features ? { features: serverInfo.features } : {}),
}); });
@@ -1422,6 +1425,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId: serverInfo.serverId, serverId: serverInfo.serverId,
hostname: serverInfo.hostname, hostname: serverInfo.hostname,
version: serverInfo.version, version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}), ...(serverInfo.features ? { features: serverInfo.features } : {}),
}); });
@@ -1,5 +1,9 @@
import { describe, expect, test } from "vitest"; 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 { HostRuntimeSnapshot } from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
@@ -41,6 +45,18 @@ function makeHost(): HostProfile {
} }
describe("app diagnostics report", () => { 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", () => { test("formats connection rows without raw connection details", () => {
const host = makeHost(); const host = makeHost();
const snapshot: HostRuntimeSnapshot = { const snapshot: HostRuntimeSnapshot = {
@@ -7,6 +7,11 @@ interface DiagnosticEntry {
value: string; 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 { export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string {
return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n"); 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: "Server ID", value: serverInfo.serverId },
{ label: "Hostname", value: serverInfo.hostname ?? "unknown" }, { label: "Hostname", value: serverInfo.hostname ?? "unknown" },
{ label: "Version", value: serverInfo.version ?? "unknown" }, { label: "Version", value: serverInfo.version ?? "unknown" },
{
label: "Desktop managed",
value: formatDiagnosticBoolean(serverInfo.desktopManaged),
},
{ label: "Features", value: features.length > 0 ? features.join(", ") : "none" }, { label: "Features", value: features.length > 0 ? features.join(", ") : "none" },
]); ]);
} }
+2
View File
@@ -1859,6 +1859,8 @@ export const ar: TranslationResources = {
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.", dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
}, },
update: { update: {
desktopManagedHint:
"يدير Paseo Desktop هذا البرنامج الخفي. حدّث Paseo Desktop على المضيف.",
title: "Update daemon", title: "Update daemon",
hint: "Update the daemon to the latest version and restart it", hint: "Update the daemon to the latest version and restart it",
confirm: "Update", confirm: "Update",
+2
View File
@@ -1868,6 +1868,8 @@ export const en = {
dialogFailedMessage: "Unable to open the restart confirmation dialog.", dialogFailedMessage: "Unable to open the restart confirmation dialog.",
}, },
update: { update: {
desktopManagedHint:
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
title: "Update daemon", title: "Update daemon",
hint: "Update the daemon to the latest version and restart it", hint: "Update the daemon to the latest version and restart it",
confirm: "Update", 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.", "No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
}, },
update: { update: {
desktopManagedHint:
"Este daemon está administrado por Paseo Desktop. Actualiza Paseo Desktop en el host.",
title: "Update daemon", title: "Update daemon",
hint: "Update the daemon to the latest version and restart it", hint: "Update the daemon to the latest version and restart it",
confirm: "Update", 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.", "Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
}, },
update: { update: {
desktopManagedHint:
"Ce daemon est géré par Paseo Desktop. Mettez à jour Paseo Desktop sur l’hôte.",
title: "Update daemon", title: "Update daemon",
hint: "Update the daemon to the latest version and restart it", hint: "Update the daemon to the latest version and restart it",
confirm: "Update", confirm: "Update",
+2
View File
@@ -1880,6 +1880,8 @@ export const ja: TranslationResources = {
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。", dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
}, },
update: { update: {
desktopManagedHint:
"このデーモンはPaseo Desktopによって管理されています。ホスト上のPaseo Desktopを更新してください。",
title: "デーモンを更新", title: "デーモンを更新",
hint: "デーモンを最新バージョンに更新して再起動します", hint: "デーモンを最新バージョンに更新して再起動します",
confirm: "更新", 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.", dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
}, },
update: { update: {
desktopManagedHint:
"Este daemon é gerenciado pelo Paseo Desktop. Atualize o Paseo Desktop no host.",
title: "Atualizar daemon", title: "Atualizar daemon",
hint: "Atualiza o daemon para a versão mais recente e o reinicia", hint: "Atualiza o daemon para a versão mais recente e o reinicia",
confirm: "Atualizar", confirm: "Atualizar",
+2
View File
@@ -1896,6 +1896,8 @@ export const ru: TranslationResources = {
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.", dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
}, },
update: { update: {
desktopManagedHint:
"Этот демон управляется Paseo Desktop. Обновите Paseo Desktop на хосте.",
title: "Update daemon", title: "Update daemon",
hint: "Update the daemon to the latest version and restart it", hint: "Update the daemon to the latest version and restart it",
confirm: "Update", confirm: "Update",
+1
View File
@@ -1837,6 +1837,7 @@ export const zhCN: TranslationResources = {
dialogFailedMessage: "无法打开重启确认对话框。", dialogFailedMessage: "无法打开重启确认对话框。",
}, },
update: { update: {
desktopManagedHint: "此 Daemon 由 Paseo Desktop 管理。请在 Host 上更新 Paseo Desktop。",
title: "Update daemon", title: "Update daemon",
hint: "Update the daemon to the latest version and restart it", hint: "Update the daemon to the latest version and restart it",
confirm: "Update", 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 { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal"; import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Alert as InlineAlert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
@@ -68,6 +69,7 @@ import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card"; import { BrowserToolsOptInCard } from "./browser-tools-card";
import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect";
import { restartDaemonFromSettings } from "./daemon-restart"; import { restartDaemonFromSettings } from "./daemon-restart";
const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedArrowUp = withUnistyles(ArrowUp);
@@ -365,7 +367,7 @@ export function HostSettingsPage({
{isLocalDaemon ? <LocalDaemonSection /> : null} {isLocalDaemon ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null} {!isLocalDaemon ? <UpdateDaemonCard key={host.serverId} host={host} /> : null}
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} /> <RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View> </View>
@@ -755,14 +757,19 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
</View> </View>
); );
} }
type DaemonUpdateState =
| { status: "idle" }
| { status: "updating"; phase: string }
| { status: "failed"; title: string; message: string };
function UpdateDaemonCard({ host }: { host: HostProfile }) { function UpdateDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId); const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore(); const runtime = getHostRuntimeStore();
const [isUpdating, setIsUpdating] = useState(false); const [updateState, setUpdateState] = useState<DaemonUpdateState>({ status: "idle" });
const [progressPhase, setProgressPhase] = useState<string | null>(null);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const unsubscribeRef = useRef<(() => void) | null>(null); const unsubscribeRef = useRef<(() => void) | null>(null);
@@ -772,6 +779,9 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
const supportsSelfUpdate = useSessionStore( const supportsSelfUpdate = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true, (state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
); );
const desktopManaged = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.desktopManaged === true,
);
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
@@ -788,11 +798,8 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
[host.serverId, runtime], [host.serverId, runtime],
); );
const hasReconnectedAfter = useCallback( const hasReconnectedAfter = useCallback(
(startGeneration: number | null) => { (startMarker: DaemonConnectionMarker | null) =>
const snapshot = runtime.getSnapshot(host.serverId); hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker),
if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
return startGeneration === null || snapshot.clientGeneration !== startGeneration;
},
[host.serverId, runtime], [host.serverId, runtime],
); );
@@ -810,27 +817,30 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
); );
const waitForDaemonRestart = useCallback( const waitForDaemonRestart = useCallback(
async (startGeneration: number | null) => { async (startMarker: DaemonConnectionMarker | null) => {
const disconnectTimeoutMs = 15000; const disconnectTimeoutMs = 15000;
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) { if (!hasReconnectedAfter(startMarker) && isHostConnected()) {
await waitForCondition( await waitForCondition(
() => !isHostConnected() || hasReconnectedAfter(startGeneration), () => !isHostConnected() || hasReconnectedAfter(startMarker),
disconnectTimeoutMs, disconnectTimeoutMs,
); );
} }
const reconnected = const reconnected =
hasReconnectedAfter(startGeneration) || hasReconnectedAfter(startMarker) ||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs)); (await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs));
if (isMountedRef.current) { if (isMountedRef.current) {
setIsUpdating(false);
setProgressPhase(null);
if (!reconnected) { if (!reconnected) {
Alert.alert( setUpdateState({
t("settings.host.daemon.update.unableToReconnectTitle"), status: "failed",
t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }), 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], [hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
@@ -838,17 +848,19 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
const handleUpdate = useCallback(() => { const handleUpdate = useCallback(() => {
if (!daemonClient) { if (!daemonClient) {
Alert.alert( setUpdateState({
t("settings.host.daemon.update.unavailableTitle"), status: "failed",
t("settings.host.daemon.update.unavailableMessage"), title: t("settings.host.daemon.update.unavailableTitle"),
); message: t("settings.host.daemon.update.unavailableMessage"),
});
return; return;
} }
if (!isHostConnected()) { if (!isHostConnected()) {
Alert.alert( setUpdateState({
t("settings.host.daemon.update.offlineTitle"), status: "failed",
t("settings.host.daemon.update.offlineMessage"), title: t("settings.host.daemon.update.offlineTitle"),
); message: t("settings.host.daemon.update.offlineMessage"),
});
return; return;
} }
@@ -860,10 +872,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
destructive: false, destructive: false,
}) })
.then((confirmed) => { .then((confirmed) => {
if (!confirmed) return; if (!confirmed || !isMountedRef.current) return;
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null; const startSnapshot = runtime.getSnapshot(host.serverId);
setIsUpdating(true); const startMarker = startSnapshot
setProgressPhase(t("settings.host.daemon.update.phaseStarting")); ? {
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 requestId = `settings_daemon_update_${host.serverId}`;
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => { const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
@@ -871,13 +891,25 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
const { phase } = message.payload; const { phase } = message.payload;
if (phase === "starting") if (phase === "starting")
setProgressPhase(t("settings.host.daemon.update.phaseStarting")); setUpdateState({
status: "updating",
phase: t("settings.host.daemon.update.phaseStarting"),
});
else if (phase === "downloading") 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") 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") else if (phase === "complete")
setProgressPhase(t("settings.host.daemon.update.phaseComplete")); setUpdateState({
status: "updating",
phase: t("settings.host.daemon.update.phaseComplete"),
});
}); });
unsubscribeRef.current = unsubscribe; unsubscribeRef.current = unsubscribe;
@@ -888,18 +920,17 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
unsubscribe(); unsubscribe();
if (!response.success) { if (!response.success) {
if (!isMountedRef.current) return undefined; if (!isMountedRef.current) return undefined;
setIsUpdating(false); setUpdateState({
setProgressPhase(null); status: "failed",
Alert.alert( title: t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedTitle"), message: t("settings.host.daemon.update.requestFailedMessage", {
t("settings.host.daemon.update.requestFailedMessage", {
error: response.error ?? "Unknown error", error: response.error ?? "Unknown error",
}), }),
); });
return undefined; return undefined;
} }
// Update succeeded — wait for daemon to restart and reconnect // Update succeeded — wait for daemon to restart and reconnect
void waitForDaemonRestart(startGeneration); void waitForDaemonRestart(startMarker);
return undefined; return undefined;
}) })
.catch((error) => { .catch((error) => {
@@ -907,23 +938,24 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
unsubscribe(); unsubscribe();
console.error(`[HostPage] Failed to update daemon ${host.label}`, error); console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setIsUpdating(false); setUpdateState({
setProgressPhase(null); status: "failed",
Alert.alert( title: t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedTitle"), message: t("settings.host.daemon.update.requestFailedMessage", {
t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : "Unknown error",
}), }),
); });
}); });
return; return;
}) })
.catch((error) => { .catch((error) => {
console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error); console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
Alert.alert( if (!isMountedRef.current) return;
t("settings.host.daemon.update.requestFailedTitle"), setUpdateState({
t("settings.host.daemon.update.dialogFailedMessage"), 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]); }, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
@@ -932,33 +964,46 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
[theme.iconSize.sm, theme.colors.foreground], [theme.iconSize.sm, theme.colors.foreground],
); );
// Don't show if the daemon doesn't support self-update or versions match const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged);
if (!supportsSelfUpdate || !hasVersionMismatch) { if (!shouldShowUpdate) {
return null; return null;
} }
const buttonLabel = isUpdating const isUpdating = updateState.status === "updating";
? (progressPhase ?? t("settings.host.daemon.update.updating")) const buttonLabel = isUpdating ? updateState.phase : t("settings.host.daemon.update.confirm");
: t("settings.host.daemon.update.confirm");
return ( return (
<View style={settingsStyles.card} testID="host-page-update-card"> <View style={settingsStyles.card} testID="host-page-update-card">
<View style={settingsStyles.row}> <View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text> <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> </View>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
leftIcon={updateIcon} leftIcon={updateIcon}
onPress={handleUpdate} onPress={handleUpdate}
disabled={isUpdating || !daemonClient || !isConnected} disabled={desktopManaged || isUpdating || !daemonClient || !isConnected}
testID="host-page-update-button" testID="host-page-update-button"
> >
{buttonLabel} {buttonLabel}
</Button> </Button>
</View> </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> </View>
); );
} }
@@ -1798,6 +1843,10 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
})); }));
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
updateFailure: {
marginHorizontal: theme.spacing[4],
marginBottom: theme.spacing[4],
},
identityEditButton: { identityEditButton: {
padding: theme.spacing[1], padding: theme.spacing[1],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
+16 -1
View File
@@ -306,6 +306,7 @@ export interface DaemonServerInfo {
serverId: string; serverId: string;
hostname: string | null; hostname: string | null;
version: string | null; version: string | null;
desktopManaged?: boolean;
capabilities?: ServerCapabilities; capabilities?: ServerCapabilities;
features?: ServerInfoStatusPayload["features"]; features?: ServerInfoStatusPayload["features"];
} }
@@ -591,17 +592,26 @@ function isSessionServerInfoUnchanged(input: {
currentServerInfo: SessionState["serverInfo"] | undefined; currentServerInfo: SessionState["serverInfo"] | undefined;
nextHostname: string | null; nextHostname: string | null;
nextVersion: string | null; nextVersion: string | null;
nextDesktopManaged: boolean | undefined;
nextCapabilities: ServerCapabilities | undefined; nextCapabilities: ServerCapabilities | undefined;
nextFeatures: ServerInfoStatusPayload["features"] | undefined; nextFeatures: ServerInfoStatusPayload["features"] | undefined;
nextServerId: string; nextServerId: string;
}): boolean { }): boolean {
const { currentServerInfo, nextHostname, nextVersion, nextCapabilities, nextFeatures } = input; const {
currentServerInfo,
nextHostname,
nextVersion,
nextDesktopManaged,
nextCapabilities,
nextFeatures,
} = input;
const prevHostname = currentServerInfo?.hostname?.trim() || null; const prevHostname = currentServerInfo?.hostname?.trim() || null;
const prevVersion = currentServerInfo?.version?.trim() || null; const prevVersion = currentServerInfo?.version?.trim() || null;
return ( return (
currentServerInfo?.serverId === input.nextServerId && currentServerInfo?.serverId === input.nextServerId &&
prevHostname === nextHostname && prevHostname === nextHostname &&
prevVersion === nextVersion && prevVersion === nextVersion &&
currentServerInfo?.desktopManaged === nextDesktopManaged &&
areServerCapabilitiesEqual(currentServerInfo?.capabilities, nextCapabilities) && areServerCapabilitiesEqual(currentServerInfo?.capabilities, nextCapabilities) &&
areServerInfoFeaturesEqual(currentServerInfo?.features, nextFeatures) areServerInfoFeaturesEqual(currentServerInfo?.features, nextFeatures)
); );
@@ -721,6 +731,7 @@ export const useSessionStore = create<SessionStore>()(
const nextHostname = info.hostname?.trim() || null; const nextHostname = info.hostname?.trim() || null;
const nextVersion = info.version?.trim() || null; const nextVersion = info.version?.trim() || null;
const nextDesktopManaged = info.desktopManaged;
const nextCapabilities = info.capabilities; const nextCapabilities = info.capabilities;
const nextFeatures = info.features; const nextFeatures = info.features;
@@ -729,6 +740,7 @@ export const useSessionStore = create<SessionStore>()(
currentServerInfo: session.serverInfo, currentServerInfo: session.serverInfo,
nextHostname, nextHostname,
nextVersion, nextVersion,
nextDesktopManaged,
nextCapabilities, nextCapabilities,
nextFeatures, nextFeatures,
nextServerId: info.serverId, nextServerId: info.serverId,
@@ -747,6 +759,9 @@ export const useSessionStore = create<SessionStore>()(
serverId: info.serverId, serverId: info.serverId,
hostname: nextHostname, hostname: nextHostname,
version: nextVersion, version: nextVersion,
...(nextDesktopManaged !== undefined
? { desktopManaged: nextDesktopManaged }
: {}),
...(nextCapabilities ? { capabilities: nextCapabilities } : {}), ...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
...(nextFeatures ? { features: nextFeatures } : {}), ...(nextFeatures ? { features: nextFeatures } : {}),
}, },
+2
View File
@@ -2423,6 +2423,8 @@ export const ServerInfoStatusPayloadSchema = z
serverId: z.string().trim().min(1), serverId: z.string().trim().min(1),
hostname: ServerInfoHostnameSchema.optional(), hostname: ServerInfoHostnameSchema.optional(),
version: ServerInfoVersionSchema.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(), capabilities: ServerCapabilitiesFromUnknownSchema.optional(),
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot // COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
features: z features: z
+4 -1
View File
@@ -335,6 +335,8 @@ export type DaemonLifecycleIntent =
export interface PaseoDaemonConfig { export interface PaseoDaemonConfig {
listen: string; listen: string;
paseoHome: string; paseoHome: string;
daemonVersion?: string;
desktopManaged?: boolean;
worktreesRoot?: string; worktreesRoot?: string;
corsAllowedOrigins: string[]; corsAllowedOrigins: string[];
allowedHosts?: HostnamesConfig; allowedHosts?: HostnamesConfig;
@@ -483,7 +485,7 @@ export async function createPaseoDaemon(
const logger = rootLogger.child({ module: "bootstrap" }); const logger = rootLogger.child({ module: "bootstrap" });
const bootstrapStart = performance.now(); const bootstrapStart = performance.now();
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; 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( const daemonConfigStore = new DaemonConfigStore(
config.paseoHome, config.paseoHome,
createInitialMutableDaemonConfig(config), createInitialMutableDaemonConfig(config),
@@ -1371,6 +1373,7 @@ export async function createPaseoDaemon(
listen: formatListenTarget(boundListenTarget ?? listenTarget), listen: formatListenTarget(boundListenTarget ?? listenTarget),
worktreesRoot: config.worktreesRoot, worktreesRoot: config.worktreesRoot,
appBaseUrl: config.appBaseUrl, appBaseUrl: config.appBaseUrl,
desktopManaged: config.desktopManaged === true,
relay: { relay: {
enabled: relayEnabled, enabled: relayEnabled,
endpoint: relayEndpoint, endpoint: relayEndpoint,
+14 -1
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { resolveBundledWebUiDistDir } from "./config.js"; import { loadConfig, resolveBundledWebUiDistDir } from "./config.js";
const roots: string[] = []; const roots: string[] = [];
@@ -13,6 +13,19 @@ describe("server config", () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); 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", () => { test("resolves bundled web UI path from source-tree modules", () => {
const root = path.parse(process.cwd()).root; const root = path.parse(process.cwd()).root;
expect( expect(
+1
View File
@@ -488,6 +488,7 @@ export function loadConfig(
return { return {
listen, listen,
paseoHome, paseoHome,
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted), worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
hostnames, hostnames,
@@ -1006,10 +1006,32 @@ test("receives server_info on websocket connect", async () => {
expect(serverInfo).not.toBeNull(); expect(serverInfo).not.toBeNull();
expect(serverInfo?.serverId.length).toBeGreaterThan(0); expect(serverInfo?.serverId.length).toBeGreaterThan(0);
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true); expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
expect(serverInfo?.desktopManaged).toBe(false);
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
await client.close(); await client.close();
}, 15000); }, 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 () => { test("emits disabled voice capability reasons on fresh daemon startup", async () => {
const isolatedCtx = await createDaemonTestContext({ const isolatedCtx = await createDaemonTestContext({
speech: { speech: {
@@ -23,12 +23,14 @@ interface ControllerHarness {
function createController(input: { function createController(input: {
updater: TestUpdater; updater: TestUpdater;
daemonVersion?: string | null; daemonVersion?: string | null;
desktopManaged?: boolean;
}): ControllerHarness { }): ControllerHarness {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const restartIntents: RestartIntent[] = []; const restartIntents: RestartIntent[] = [];
const controller = new DaemonSelfUpdateSessionController({ const controller = new DaemonSelfUpdateSessionController({
clientId: "client-1", clientId: "client-1",
daemonVersion: input.daemonVersion ?? "0.1.15", daemonVersion: input.daemonVersion ?? "0.1.15",
desktopManaged: input.desktopManaged,
emit: (msg) => { emit: (msg) => {
emitted.push(msg); emitted.push(msg);
}, },
@@ -82,6 +84,7 @@ describe("DaemonSelfUpdateSessionController", () => {
await controller.dispatch(updateRequest); await controller.dispatch(updateRequest);
expect(updateInput?.daemonVersion).toBe("0.1.15"); expect(updateInput?.daemonVersion).toBe("0.1.15");
expect(updateInput?.desktopManaged).toBe(false);
expect(emitted).toEqual([ expect(emitted).toEqual([
{ {
type: "daemon.update.progress", type: "daemon.update.progress",
@@ -119,15 +122,21 @@ describe("DaemonSelfUpdateSessionController", () => {
}); });
test("emits a failed response without restart lifecycle intent", async () => { test("emits a failed response without restart lifecycle intent", async () => {
let updateInput: DaemonSelfUpdateInput | null = null;
const updater: TestUpdater = { const updater: TestUpdater = {
async update() { async update(input) {
updateInput = input;
return { success: false, error: "not an npm global install", newVersion: null }; 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); await controller.dispatch(updateRequest);
expect(updateInput?.desktopManaged).toBe(true);
expect(emitted).toEqual([ expect(emitted).toEqual([
{ {
type: "daemon.update.response", type: "daemon.update.response",
@@ -24,6 +24,7 @@ interface DaemonSelfUpdateRestartIntent {
export interface DaemonSelfUpdateSessionControllerOptions { export interface DaemonSelfUpdateSessionControllerOptions {
clientId: string; clientId: string;
daemonVersion: string | null; daemonVersion: string | null;
desktopManaged?: boolean;
emit: (msg: SessionOutboundMessage) => void; emit: (msg: SessionOutboundMessage) => void;
emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void; emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
sessionLogger: pino.Logger; sessionLogger: pino.Logger;
@@ -37,6 +38,7 @@ function isDaemonSelfUpdateMessage(msg: SessionInboundMessage): msg is DaemonUpd
export class DaemonSelfUpdateSessionController { export class DaemonSelfUpdateSessionController {
private readonly clientId: string; private readonly clientId: string;
private readonly daemonVersion: string | null; private readonly daemonVersion: string | null;
private readonly desktopManaged: boolean;
private readonly emit: (msg: SessionOutboundMessage) => void; private readonly emit: (msg: SessionOutboundMessage) => void;
private readonly emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void; private readonly emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
private readonly sessionLogger: pino.Logger; private readonly sessionLogger: pino.Logger;
@@ -45,6 +47,7 @@ export class DaemonSelfUpdateSessionController {
constructor(options: DaemonSelfUpdateSessionControllerOptions) { constructor(options: DaemonSelfUpdateSessionControllerOptions) {
this.clientId = options.clientId; this.clientId = options.clientId;
this.daemonVersion = options.daemonVersion; this.daemonVersion = options.daemonVersion;
this.desktopManaged = options.desktopManaged === true;
this.emit = options.emit; this.emit = options.emit;
this.emitLifecycleIntent = options.emitLifecycleIntent; this.emitLifecycleIntent = options.emitLifecycleIntent;
this.sessionLogger = options.sessionLogger; this.sessionLogger = options.sessionLogger;
@@ -64,6 +67,7 @@ export class DaemonSelfUpdateSessionController {
try { try {
const result = await this.updater.update({ const result = await this.updater.update({
daemonVersion: previousVersion, daemonVersion: previousVersion,
desktopManaged: this.desktopManaged,
onProgress: (phase) => this.emitProgress(msg.requestId, phase), onProgress: (phase) => this.emitProgress(msg.requestId, phase),
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
@@ -84,6 +84,7 @@ function createRuntime(input: {
async function runUpdate(input: { async function runUpdate(input: {
runtime: DaemonSelfUpdateRuntime; runtime: DaemonSelfUpdateRuntime;
daemonVersion?: string | null; daemonVersion?: string | null;
desktopManaged?: boolean;
phases?: DaemonSelfUpdatePhase[]; phases?: DaemonSelfUpdatePhase[];
}) { }) {
const logger = createLogger(); const logger = createLogger();
@@ -91,6 +92,7 @@ async function runUpdate(input: {
const phases = input.phases ?? []; const phases = input.phases ?? [];
const result = await updater.update({ const result = await updater.update({
daemonVersion: input.daemonVersion ?? "0.1.15", daemonVersion: input.daemonVersion ?? "0.1.15",
desktopManaged: input.desktopManaged ?? false,
onProgress: (phase) => phases.push(phase), onProgress: (phase) => phases.push(phase),
logger, logger,
}); });
@@ -98,6 +100,21 @@ async function runUpdate(input: {
} }
describe("DaemonSelfUpdater", () => { 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 () => { test("updates a daemon that is running from the npm global cli install", async () => {
const calls: RuntimeCall[] = []; const calls: RuntimeCall[] = [];
const runtime = createRuntime({ const runtime = createRuntime({
@@ -214,6 +231,7 @@ describe("DaemonSelfUpdater", () => {
const firstUpdate = updater.update({ const firstUpdate = updater.update({
daemonVersion: "0.1.15", daemonVersion: "0.1.15",
desktopManaged: false,
onProgress: () => {}, onProgress: () => {},
logger, logger,
}); });
@@ -222,6 +240,7 @@ describe("DaemonSelfUpdater", () => {
await expect( await expect(
updater.update({ updater.update({
daemonVersion: "0.1.15", daemonVersion: "0.1.15",
desktopManaged: false,
onProgress: () => {}, onProgress: () => {},
logger, logger,
}), }),
@@ -16,6 +16,7 @@ export interface DaemonSelfUpdateResult {
export interface DaemonSelfUpdateInput { export interface DaemonSelfUpdateInput {
daemonVersion: string | null; daemonVersion: string | null;
desktopManaged: boolean;
onProgress: (phase: DaemonSelfUpdatePhase) => void; onProgress: (phase: DaemonSelfUpdatePhase) => void;
logger: DaemonSelfUpdateLogger; logger: DaemonSelfUpdateLogger;
} }
@@ -42,12 +43,19 @@ const defaultRuntime: DaemonSelfUpdateRuntime = {
installOrigin: daemonInstallOriginRuntime, installOrigin: daemonInstallOriginRuntime,
}; };
const DESKTOP_MANAGED_UPDATE_ERROR =
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.";
export class DaemonSelfUpdater { export class DaemonSelfUpdater {
private inProgress = false; private inProgress = false;
constructor(private readonly runtime: DaemonSelfUpdateRuntime = defaultRuntime) {} constructor(private readonly runtime: DaemonSelfUpdateRuntime = defaultRuntime) {}
async update(input: DaemonSelfUpdateInput): Promise<DaemonSelfUpdateResult> { async update(input: DaemonSelfUpdateInput): Promise<DaemonSelfUpdateResult> {
if (input.desktopManaged) {
return { success: false, error: DESKTOP_MANAGED_UPDATE_ERROR, newVersion: null };
}
if (this.inProgress) { if (this.inProgress) {
throw new DaemonSelfUpdateInProgressError(); throw new DaemonSelfUpdateInProgressError();
} }
@@ -13,7 +13,9 @@ import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../wor
export interface DaemonRuntimeConfig { export interface DaemonRuntimeConfig {
listen: string | null; listen: string | null;
worktreesRoot?: string;
appBaseUrl?: string; appBaseUrl?: string;
desktopManaged?: boolean;
relay: { relay: {
enabled: boolean; enabled: boolean;
endpoint: string; endpoint: string;
@@ -86,6 +88,7 @@ export class DaemonSession {
this.selfUpdate = new DaemonSelfUpdateSessionController({ this.selfUpdate = new DaemonSelfUpdateSessionController({
clientId: this.clientId, clientId: this.clientId,
daemonVersion: this.daemonVersion ?? null, daemonVersion: this.daemonVersion ?? null,
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
emit: (msg) => this.host.emit(msg), emit: (msg) => this.host.emit(msg),
emitLifecycleIntent: (intent) => this.host.emitLifecycleIntent(intent), emitLifecycleIntent: (intent) => this.host.emitLifecycleIntent(intent),
sessionLogger: this.logger, 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"; import type { PushNotificationSender } from "../push/notifications.js";
interface TestPaseoDaemonOptions { interface TestPaseoDaemonOptions {
daemonVersion?: string;
desktopManaged?: boolean;
downloadTokenTtlMs?: number; downloadTokenTtlMs?: number;
corsAllowedOrigins?: string[]; corsAllowedOrigins?: string[];
listen?: string; listen?: string;
@@ -157,6 +159,8 @@ async function prepareTestDaemonConfig(
const config: PaseoDaemonConfig = { const config: PaseoDaemonConfig = {
listen: `${listenHost}:0`, listen: `${listenHost}:0`,
paseoHome, paseoHome,
daemonVersion: options.daemonVersion,
desktopManaged: options.desktopManaged,
corsAllowedOrigins: options.corsAllowedOrigins ?? [], corsAllowedOrigins: options.corsAllowedOrigins ?? [],
hostnames: true, hostnames: true,
mcpEnabled: options.mcpEnabled ?? true, mcpEnabled: options.mcpEnabled ?? true,
+6 -26
View File
@@ -77,6 +77,7 @@ import {
type BrowserAutomationHostCapability, type BrowserAutomationHostCapability,
} from "@getpaseo/protocol/browser-automation/capabilities"; } from "@getpaseo/protocol/browser-automation/capabilities";
import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
@@ -404,19 +405,7 @@ export class VoiceAssistantWebSocketServer {
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map(); private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
private readonly serverId: string; private readonly serverId: string;
private readonly daemonVersion: string; private readonly daemonVersion: string;
private readonly daemonRuntimeConfig: private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
| {
listen: string | null;
appBaseUrl?: string;
relay: {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
};
}
| undefined;
private readonly agentManager: AgentManager; private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage; private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry; private readonly projectRegistry: ProjectRegistry;
@@ -507,18 +496,7 @@ export class VoiceAssistantWebSocketServer {
github?: GitHubService, github?: GitHubService,
pushNotificationSender?: PushNotificationSender, pushNotificationSender?: PushNotificationSender,
providerSnapshotManager?: ProviderSnapshotManager, providerSnapshotManager?: ProviderSnapshotManager,
daemonRuntimeConfig?: { daemonRuntimeConfig?: DaemonRuntimeConfig,
listen: string | null;
worktreesRoot?: string;
appBaseUrl?: string;
relay: {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
};
},
serviceProxyPublicBaseUrl?: string | null, serviceProxyPublicBaseUrl?: string | null,
browserToolsBroker?: BrowserToolsBroker | null, browserToolsBroker?: BrowserToolsBroker | null,
) { ) {
@@ -1227,6 +1205,8 @@ export class VoiceAssistantWebSocketServer {
serverId: this.serverId, serverId: this.serverId,
hostname: getHostname(), hostname: getHostname(),
version: this.daemonVersion, 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 } : {}), ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
features: { features: {
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. // 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. // COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
daemonDiagnostics: true, daemonDiagnostics: true,
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. // 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. // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: true, agentForkContext: true,
// COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14.