feat: add remote daemon self-update from client (#1513)

* feat: add remote daemon self-update from client

Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.

* fix: clean up progress subscription on unmount, use typed phase cast

Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }

* fix: use npm install@latest and global concurrency guard

Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard

* feat(daemon): harden remote self-update

Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.

* test(daemon): make self-update paths platform-stable

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Davy
2026-06-26 16:13:29 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 9ad49fca92
commit 40265782d4
26 changed files with 1536 additions and 8 deletions
+24
View File
@@ -1745,6 +1745,30 @@ export const ar: TranslationResources = {
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "منطقة الخطر",
remove: {
title: "إزالة المضيف",
+24
View File
@@ -1753,6 +1753,30 @@ export const en = {
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Danger zone",
remove: {
title: "Remove host",
+24
View File
@@ -1786,6 +1786,30 @@ export const es: TranslationResources = {
dialogFailedMessage:
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Zona de peligro",
remove: {
title: "Eliminar host",
+24
View File
@@ -1791,6 +1791,30 @@ export const fr: TranslationResources = {
dialogFailedMessage:
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Zone dangereuse",
remove: {
title: "Supprimer l'hôte",
+25
View File
@@ -1765,6 +1765,31 @@ export const ja: TranslationResources = {
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
},
update: {
title: "デーモンを更新",
hint: "デーモンを最新バージョンに更新して再起動します",
confirm: "更新",
confirmTitle: "{{name}}を更新",
confirmMessage:
"これによりデーモンが最新バージョンに更新され、再起動されます。実行中のエージェントは一時的に中断されます。",
updating: "更新中...",
phaseStarting: "更新を準備中...",
phaseDownloading: "パッケージをダウンロード中...",
phaseInstalling: "インストール中...",
phaseComplete: "更新が完了しました。再起動中...",
unableToReconnectTitle: "再接続できません",
unableToReconnectMessage:
"{{name}}が更新後にオンラインに戻りませんでした。デーモンが再起動されたことを確認してください。",
unavailableTitle: "ホストが利用できません",
unavailableMessage:
"このホストは接続されていません。更新する前にオンラインになるまでお待ちください。",
offlineTitle: "ホストオフライン",
offlineMessage:
"このホストはオフラインです。オンラインに戻るまで待ってから更新してください。",
requestFailedTitle: "更新に失敗しました",
requestFailedMessage: "デーモンの更新に失敗しました: {{error}}",
dialogFailedMessage: "更新確認ダイアログを開けませんでした。",
},
dangerZone: "危険ゾーン",
remove: {
title: "ホストを削除",
+25
View File
@@ -1771,6 +1771,31 @@ export const ptBR: TranslationResources = {
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
},
update: {
title: "Atualizar daemon",
hint: "Atualiza o daemon para a versão mais recente e o reinicia",
confirm: "Atualizar",
confirmTitle: "Atualizar {{name}}",
confirmMessage:
"Isso vai atualizar o daemon para a versão mais recente e reiniciá-lo. Agentes em execução serão interrompidos brevemente.",
updating: "Atualizando...",
phaseStarting: "Preparando atualização...",
phaseDownloading: "Baixando pacotes...",
phaseInstalling: "Instalando...",
phaseComplete: "Atualização concluída, reiniciando...",
unableToReconnectTitle: "Não foi possível reconectar",
unableToReconnectMessage:
"{{name}} não voltou a ficar online após a atualização. Verifique se o daemon reiniciou.",
unavailableTitle: "Host indisponível",
unavailableMessage:
"Este host não está conectado. Aguarde até ele ficar online antes de atualizar.",
offlineTitle: "Host offline",
offlineMessage:
"Este host está offline. Aguarde até ele voltar a ficar online antes de atualizar.",
requestFailedTitle: "Falha na atualização",
requestFailedMessage: "Falha ao atualizar o daemon: {{error}}",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação da atualização.",
},
dangerZone: "Zona de perigo",
remove: {
title: "Remover host",
+24
View File
@@ -1777,6 +1777,30 @@ export const ru: TranslationResources = {
"Не удалось отправить запрос на перезапуск.Paseo автоматически повторно подключается. Повторите попытку, как только хост окажется в сети.",
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Опасная зона",
remove: {
title: "Удалить хост",
+24
View File
@@ -1722,6 +1722,30 @@ export const zhCN: TranslationResources = {
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
dialogFailedMessage: "无法打开重启确认对话框。",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "危险区域",
remove: {
title: "移除 Host",
@@ -1,6 +1,7 @@
import {
ArrowDown,
ArrowUp,
ArrowUpToLine,
ChevronRight,
Globe,
Monitor,
@@ -53,6 +54,8 @@ import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { formatLatency } from "@/utils/latency";
import { ICON_SIZE } from "@/styles/theme";
@@ -353,6 +356,8 @@ export function HostSettingsPage({
{isLocalDaemon ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
);
@@ -709,6 +714,214 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
);
}
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 isMountedRef = useRef(true);
const unsubscribeRef = useRef<(() => void) | null>(null);
const daemonVersion = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
);
const supportsSelfUpdate = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
);
const appVersion = resolveAppVersion();
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
useEffect(() => {
return () => {
isMountedRef.current = false;
unsubscribeRef.current?.();
};
}, []);
const isHostConnected = useCallback(
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
[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;
},
[host.serverId, runtime],
);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) return false;
if (predicate()) return true;
await delay(intervalMs);
}
return predicate();
},
[],
);
const waitForDaemonRestart = useCallback(
async (startGeneration: number | null) => {
const disconnectTimeoutMs = 15000;
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
await waitForCondition(
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
disconnectTimeoutMs,
);
}
const reconnected =
hasReconnectedAfter(startGeneration) ||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), 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 }),
);
}
}
},
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
);
const handleUpdate = useCallback(() => {
if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.update.unavailableTitle"),
t("settings.host.daemon.update.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.update.offlineTitle"),
t("settings.host.daemon.update.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.update.confirmTitle", { name: host.label }),
message: t("settings.host.daemon.update.confirmMessage"),
confirmLabel: t("settings.host.daemon.update.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: false,
})
.then((confirmed) => {
if (!confirmed) return;
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
setIsUpdating(true);
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
const requestId = `settings_daemon_update_${host.serverId}`;
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
if (message.payload.requestId !== requestId) return;
if (!isMountedRef.current) return;
const { phase } = message.payload;
if (phase === "starting")
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
else if (phase === "downloading")
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
else if (phase === "installing")
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
else if (phase === "complete")
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
});
unsubscribeRef.current = unsubscribe;
void daemonClient
.updateDaemon(requestId)
.then((response) => {
unsubscribeRef.current = null;
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", {
error: response.error ?? "Unknown error",
}),
);
return undefined;
}
// Update succeeded — wait for daemon to restart and reconnect
void waitForDaemonRestart(startGeneration);
return undefined;
})
.catch((error) => {
unsubscribeRef.current = null;
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", {
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"),
);
});
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
const updateIcon = useMemo(
() => <ArrowUpToLine size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
// Don't show if the daemon doesn't support self-update or versions match
if (!supportsSelfUpdate || !hasVersionMismatch) {
return null;
}
const buttonLabel = isUpdating
? (progressPhase ?? t("settings.host.daemon.update.updating"))
: 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>
</View>
<Button
variant="outline"
size="sm"
leftIcon={updateIcon}
onPress={handleUpdate}
disabled={isUpdating || !daemonClient || !isConnected}
testID="host-page-update-button"
>
{buttonLabel}
</Button>
</View>
</View>
);
}
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);