diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 826a93294..2319a64f6 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "إزالة المضيف", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bdaa7f86c..18125001b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index a8fbf7a64..c40f29c43 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5be82675c..1217f752a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f6f7cbf42..7c2f520e3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "ホストを削除", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e00c314f1..a4613aecb 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e54dd030d..79aa70306 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Удалить хост", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7cb3cb50b..abb29848e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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", diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 7a976316b..8f39fd0be 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -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 ? : null} + {!isLocalDaemon ? : null} + ); @@ -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(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( + () => , + [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 ( + + + + {t("settings.host.daemon.update.title")} + {t("settings.host.daemon.update.hint")} + + + + + ); +} + function InjectPaseoToolsCard({ serverId }: { serverId: string }) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index f072d6a10..8857b0418 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -10,6 +10,7 @@ import { RenameTerminalResponseSchema, RestartRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, + DaemonUpdateResponseSchema, SessionInboundMessageSchema, type ServerInfoStatusPayload, WSOutboundMessageSchema, @@ -468,6 +469,7 @@ export interface FetchAgentTimelineOptions { type AgentRefreshedStatusPayload = z.infer; type RestartRequestedStatusPayload = z.infer; type ShutdownRequestedStatusPayload = z.infer; +type DaemonUpdateResponse = z.infer; type FetchAgentsPayload = Extract< SessionOutboundMessage, { type: "fetch_agents_response" } @@ -2636,6 +2638,30 @@ export class DaemonClient { }); } + async updateDaemon(requestId?: string): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "daemon.update.request", + requestId: resolvedRequestId, + }); + return this.sendRequest({ + requestId: resolvedRequestId, + message, + timeout: 300_000, // 5 minutes — npm update can be slow on remote machines + options: { skipQueue: true }, + select: (msg) => { + const parsed = DaemonUpdateResponseSchema.safeParse(msg); + if (!parsed.success) { + return null; + } + if (parsed.data.payload.requestId !== resolvedRequestId) { + return null; + } + return parsed.data.payload; + }, + }); + } + // ============================================================================ // Audio / Voice // ============================================================================ diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index f2247823f..9cd91e40d 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -402,3 +402,23 @@ describe("paseo worktree archive request compatibility", () => { expect(parsed.scope).toBe("workspace"); }); }); + +describe("daemon update messages", () => { + test("daemon update progress is a scoped outbound message", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "daemon.update.progress", + payload: { + requestId: "update-1", + phase: "installing", + }, + }); + + expect(parsed).toEqual({ + type: "daemon.update.progress", + payload: { + requestId: "update-1", + phase: "installing", + }, + }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index db2f9d65b..499de202a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1252,6 +1252,11 @@ export const ShutdownServerRequestMessageSchema = z.object({ requestId: z.string(), }); +export const DaemonUpdateRequestMessageSchema = z.object({ + type: z.literal("daemon.update.request"), + requestId: z.string(), +}); + export const AgentTimelineCursorSchema = z.object({ epoch: z.string(), seq: z.number().int().nonnegative(), @@ -2053,6 +2058,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentRequestMessageSchema, ShutdownServerRequestMessageSchema, RestartServerRequestMessageSchema, + DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, SetAgentModeRequestMessageSchema, SetAgentModelRequestMessageSchema, @@ -2327,6 +2333,8 @@ export const ServerInfoStatusPayloadSchema = z agentDetach: z.boolean().optional(), // COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100. daemonDiagnostics: z.boolean().optional(), + // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. + daemonSelfUpdate: z.boolean().optional(), }) .optional(), }) @@ -4093,6 +4101,29 @@ export const TerminalAttentionRequiredSchema = z.object({ }), }); +export const DaemonUpdateResponseSchema = z.object({ + type: z.literal("daemon.update.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + error: z.string().nullable(), + previousVersion: z.string().nullable(), + newVersion: z.string().nullable(), + }), +}); + +export type DaemonUpdateResponse = z.infer; + +export const DaemonUpdateProgressMessageSchema = z.object({ + type: z.literal("daemon.update.progress"), + payload: z.object({ + requestId: z.string(), + phase: z.enum(["starting", "downloading", "installing", "complete"]), + }), +}); + +export type DaemonUpdateProgressMessage = z.infer; + export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ActivityLogMessageSchema, AssistantChunkMessageSchema, @@ -4228,6 +4259,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ LoopInspectResponseSchema, LoopLogsResponseSchema, LoopStopResponseSchema, + DaemonUpdateProgressMessageSchema, + DaemonUpdateResponseSchema, ]); export type SessionOutboundMessage = z.infer; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5b495a548..75999482d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -777,7 +777,9 @@ export class Session { this.daemonSession = new DaemonSession({ host: { emit: (msg) => this.emit(msg), + emitLifecycleIntent: (intent) => this.emitLifecycleIntent(intent), }, + clientId: this.clientId, paseoHome: this.paseoHome, serverId, daemonVersion, @@ -1510,6 +1512,8 @@ export class Session { return this.daemonSession.handleGetPairingOfferRequest(msg); case "diagnostics.request": return this.daemonSession.handleDiagnosticsRequest(msg); + case "daemon.update.request": + return this.daemonSession.handleUpdateRequest(msg); case "set_daemon_config_request": this.emit({ type: "set_daemon_config_response", diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts new file mode 100644 index 000000000..d6c57f14c --- /dev/null +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "vitest"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import { + DaemonSelfUpdateInProgressError, + type DaemonSelfUpdateInput, + type DaemonSelfUpdater, +} from "./daemon-self-updater.js"; +import { + DaemonSelfUpdateSessionController, + type DaemonSelfUpdateSessionControllerOptions, +} from "./daemon-self-update-session-controller.js"; + +type TestUpdater = Pick; +type RestartIntent = Parameters[0]; + +interface ControllerHarness { + controller: DaemonSelfUpdateSessionController; + emitted: SessionOutboundMessage[]; + restartIntents: RestartIntent[]; +} + +function createController(input: { + updater: TestUpdater; + daemonVersion?: string | null; +}): ControllerHarness { + const emitted: SessionOutboundMessage[] = []; + const restartIntents: RestartIntent[] = []; + const controller = new DaemonSelfUpdateSessionController({ + clientId: "client-1", + daemonVersion: input.daemonVersion ?? "0.1.15", + emit: (msg) => { + emitted.push(msg); + }, + emitLifecycleIntent: (intent) => { + restartIntents.push(intent); + }, + sessionLogger: createTestLogger(), + updater: input.updater, + }); + + return { controller, emitted, restartIntents }; +} + +const updateRequest: SessionInboundMessage = { + type: "daemon.update.request", + requestId: "update-1", +}; + +describe("DaemonSelfUpdateSessionController", () => { + test("returns undefined synchronously for messages owned by another subsystem", () => { + const updater: TestUpdater = { + async update() { + throw new Error("update should not run"); + }, + }; + const { controller, emitted, restartIntents } = createController({ updater }); + const message: SessionInboundMessage = { + type: "daemon.get_status.request", + requestId: "status-1", + }; + + const result = controller.dispatch(message); + + expect(result).toBeUndefined(); + expect(emitted).toEqual([]); + expect(restartIntents).toEqual([]); + }); + + test("emits progress, response, and restart lifecycle intent after a successful update", async () => { + let updateInput: DaemonSelfUpdateInput | null = null; + const updater: TestUpdater = { + async update(input) { + updateInput = input; + input.onProgress("starting"); + input.onProgress("installing"); + return { success: true, error: null, newVersion: "0.1.96" }; + }, + }; + const { controller, emitted, restartIntents } = createController({ updater }); + + await controller.dispatch(updateRequest); + + expect(updateInput?.daemonVersion).toBe("0.1.15"); + expect(emitted).toEqual([ + { + type: "daemon.update.progress", + payload: { + requestId: "update-1", + phase: "starting", + }, + }, + { + type: "daemon.update.progress", + payload: { + requestId: "update-1", + phase: "installing", + }, + }, + { + type: "daemon.update.response", + payload: { + requestId: "update-1", + success: true, + error: null, + previousVersion: "0.1.15", + newVersion: "0.1.96", + }, + }, + ]); + expect(restartIntents).toEqual([ + { + type: "restart", + clientId: "client-1", + requestId: "update-1", + reason: "daemon_update", + }, + ]); + }); + + test("emits a failed response without restart lifecycle intent", async () => { + const updater: TestUpdater = { + async update() { + return { success: false, error: "not an npm global install", newVersion: null }; + }, + }; + const { controller, emitted, restartIntents } = createController({ updater }); + + await controller.dispatch(updateRequest); + + expect(emitted).toEqual([ + { + type: "daemon.update.response", + payload: { + requestId: "update-1", + success: false, + error: "not an npm global install", + previousVersion: "0.1.15", + newVersion: null, + }, + }, + ]); + expect(restartIntents).toEqual([]); + }); + + test("maps concurrent updates to rpc_error", async () => { + const updater: TestUpdater = { + async update() { + throw new DaemonSelfUpdateInProgressError(); + }, + }; + const { controller, emitted, restartIntents } = createController({ updater }); + + await controller.dispatch(updateRequest); + + expect(emitted).toEqual([ + { + type: "rpc_error", + payload: { + requestId: "update-1", + requestType: "daemon.update.request", + error: "An update is already in progress", + code: "already_updating", + }, + }, + ]); + expect(restartIntents).toEqual([]); + }); +}); diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts new file mode 100644 index 000000000..9f7975e1c --- /dev/null +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts @@ -0,0 +1,134 @@ +import type pino from "pino"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import { + daemonSelfUpdater, + DaemonSelfUpdateInProgressError, + type DaemonSelfUpdatePhase, + type DaemonSelfUpdater, +} from "./daemon-self-updater.js"; +import { getErrorMessage } from "@getpaseo/protocol/error-utils"; + +type DaemonUpdateRequest = Extract; + +const DAEMON_SELF_UPDATE_MESSAGE_TYPES: ReadonlySet = new Set([ + "daemon.update.request", +]); + +interface DaemonSelfUpdateRestartIntent { + type: "restart"; + clientId: string; + requestId: string; + reason?: string; +} + +export interface DaemonSelfUpdateSessionControllerOptions { + clientId: string; + daemonVersion: string | null; + emit: (msg: SessionOutboundMessage) => void; + emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void; + sessionLogger: pino.Logger; + updater?: Pick; +} + +function isDaemonSelfUpdateMessage(msg: SessionInboundMessage): msg is DaemonUpdateRequest { + return DAEMON_SELF_UPDATE_MESSAGE_TYPES.has(msg.type); +} + +export class DaemonSelfUpdateSessionController { + private readonly clientId: string; + private readonly daemonVersion: string | null; + private readonly emit: (msg: SessionOutboundMessage) => void; + private readonly emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void; + private readonly sessionLogger: pino.Logger; + private readonly updater: Pick; + + constructor(options: DaemonSelfUpdateSessionControllerOptions) { + this.clientId = options.clientId; + this.daemonVersion = options.daemonVersion; + this.emit = options.emit; + this.emitLifecycleIntent = options.emitLifecycleIntent; + this.sessionLogger = options.sessionLogger; + this.updater = options.updater ?? daemonSelfUpdater; + } + + dispatch(msg: SessionInboundMessage): Promise | undefined { + if (!isDaemonSelfUpdateMessage(msg)) { + return undefined; + } + return this.handleDaemonUpdateRequest(msg); + } + + private async handleDaemonUpdateRequest(msg: DaemonUpdateRequest): Promise { + const previousVersion = this.daemonVersion; + + try { + const result = await this.updater.update({ + daemonVersion: previousVersion, + onProgress: (phase) => this.emitProgress(msg.requestId, phase), + logger: this.sessionLogger, + }); + + this.emitResponse({ + requestId: msg.requestId, + success: result.success, + error: result.error, + previousVersion, + newVersion: result.newVersion, + }); + if (!result.success) { + return; + } + + this.emitLifecycleIntent({ + type: "restart", + clientId: this.clientId, + requestId: msg.requestId, + reason: "daemon_update", + }); + } catch (error) { + if (error instanceof DaemonSelfUpdateInProgressError) { + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: "daemon.update.request", + error: error.message, + code: "already_updating", + }, + }); + return; + } + this.sessionLogger.error({ err: error }, "Daemon update failed with exception"); + this.emitResponse({ + requestId: msg.requestId, + success: false, + error: getErrorMessage(error), + previousVersion, + newVersion: null, + }); + } + } + + private emitProgress(requestId: string, phase: DaemonSelfUpdatePhase): void { + this.emit({ + type: "daemon.update.progress", + payload: { + requestId, + phase, + }, + }); + } + + private emitResponse(payload: { + requestId: string; + success: boolean; + error: string | null; + previousVersion: string | null; + newVersion: string | null; + }): void { + this.emit({ + type: "daemon.update.response", + payload, + }); + } +} diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts new file mode 100644 index 000000000..2736e3834 --- /dev/null +++ b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from "vitest"; +import { + DaemonSelfUpdateInProgressError, + DaemonSelfUpdater, + type DaemonSelfUpdateRuntime, + type DaemonSelfUpdatePhase, +} from "./daemon-self-updater.js"; +import type { CommandResult, NpmGlobalPaseoInstall } from "./npm-global-cli.js"; + +interface TestLogger { + errors: Array<{ obj: object; msg?: string }>; + warnings: Array<{ obj: object; msg?: string }>; + error(obj: object, msg?: string): void; + warn(obj: object, msg?: string): void; +} + +type Inspection = NpmGlobalPaseoInstall | Error; +type RuntimeCall = "inspect" | "installLatest"; + +const globalRoot = "/global/lib"; +const globalNodeModules = `${globalRoot}/node_modules`; +const cliPackagePath = `${globalNodeModules}/@getpaseo/cli`; +const npmServerPackageRoot = `${cliPackagePath}/node_modules/@getpaseo/server`; +const sourceServerPackageRoot = "/repo/packages/server"; + +function npmGlobalPaseoInstall( + version: string, + options?: { linked?: boolean }, +): NpmGlobalPaseoInstall { + return { + version, + packagePath: cliPackagePath, + globalRootPath: globalRoot, + isLinked: options?.linked === true, + }; +} + +function createLogger(): TestLogger { + return { + errors: [], + warnings: [], + error(obj, msg) { + this.errors.push({ obj, msg }); + }, + warn(obj, msg) { + this.warnings.push({ obj, msg }); + }, + }; +} + +function createRuntime(input: { + inspections: Inspection[]; + currentServerPackageRoot?: string | null; + installResult?: CommandResult; + calls?: RuntimeCall[]; +}): DaemonSelfUpdateRuntime { + const calls = input.calls ?? []; + return { + npm: { + async inspect() { + calls.push("inspect"); + const inspection = input.inspections.shift(); + if (!inspection) { + throw new Error("Unexpected npm global install inspection"); + } + if (inspection instanceof Error) { + throw inspection; + } + return inspection; + }, + async installLatest() { + calls.push("installLatest"); + return input.installResult ?? { exitCode: 0, stdout: "changed 42 packages", stderr: "" }; + }, + }, + installOrigin: { + resolveCurrentServerPackageRoot() { + return input.currentServerPackageRoot ?? npmServerPackageRoot; + }, + }, + }; +} + +async function runUpdate(input: { + runtime: DaemonSelfUpdateRuntime; + daemonVersion?: string | null; + phases?: DaemonSelfUpdatePhase[]; +}) { + const logger = createLogger(); + const updater = new DaemonSelfUpdater(input.runtime); + const phases = input.phases ?? []; + const result = await updater.update({ + daemonVersion: input.daemonVersion ?? "0.1.15", + onProgress: (phase) => phases.push(phase), + logger, + }); + return { result, logger, phases }; +} + +describe("DaemonSelfUpdater", () => { + test("updates a daemon that is running from the npm global cli install", async () => { + const calls: RuntimeCall[] = []; + const runtime = createRuntime({ + calls, + inspections: [npmGlobalPaseoInstall("0.1.15"), npmGlobalPaseoInstall("0.1.96")], + }); + + const { result, phases } = await runUpdate({ runtime }); + + expect(result).toEqual({ + success: true, + error: null, + newVersion: "0.1.96", + }); + expect(phases).toEqual(["starting", "downloading", "installing", "complete"]); + expect(calls).toEqual(["inspect", "installLatest", "inspect"]); + }); + + test("does not run install when npm global cli is missing", async () => { + const calls: RuntimeCall[] = []; + const runtime = createRuntime({ + calls, + inspections: [new Error("@getpaseo/cli is not installed with npm -g on this host")], + }); + + const { result, phases } = await runUpdate({ runtime }); + + expect(result.success).toBe(false); + expect(result.error).toBe("@getpaseo/cli is not installed with npm -g on this host"); + expect(phases).toEqual(["starting"]); + expect(calls).toEqual(["inspect"]); + }); + + test("does not update a daemon whose version does not match the npm global cli", async () => { + const calls: RuntimeCall[] = []; + const runtime = createRuntime({ + calls, + inspections: [npmGlobalPaseoInstall("0.1.15")], + }); + + const { result } = await runUpdate({ runtime, daemonVersion: "0.1.96" }); + + expect(result).toEqual({ + success: false, + error: + "This daemon is not running from the npm global @getpaseo/cli install (global npm has 0.1.15, daemon is 0.1.96).", + newVersion: null, + }); + expect(calls).toEqual(["inspect"]); + }); + + test("does not update a daemon running outside the npm global package tree", async () => { + const calls: RuntimeCall[] = []; + const runtime = createRuntime({ + calls, + currentServerPackageRoot: sourceServerPackageRoot, + inspections: [npmGlobalPaseoInstall("0.1.15")], + }); + + const { result } = await runUpdate({ runtime }); + + expect(result).toEqual({ + success: false, + error: "This daemon is not running from the npm global @getpaseo/cli install.", + newVersion: null, + }); + expect(calls).toEqual(["inspect"]); + }); + + test("does not update linked global installs", async () => { + const runtime = createRuntime({ + inspections: [npmGlobalPaseoInstall("0.1.15", { linked: true })], + }); + + const { result } = await runUpdate({ runtime }); + + expect(result).toEqual({ + success: false, + error: + "The global @getpaseo/cli install is linked; self-update only supports normal npm global installs.", + newVersion: null, + }); + }); + + test("rejects concurrent update requests", async () => { + const calls: RuntimeCall[] = []; + let resolveInstall: ((result: CommandResult) => void) | null = null; + let installStartedResolve: (() => void) | null = null; + const installStarted = new Promise((resolve) => { + installStartedResolve = resolve; + }); + const runtime: DaemonSelfUpdateRuntime = { + npm: { + async inspect() { + calls.push("inspect"); + return npmGlobalPaseoInstall("0.1.15"); + }, + async installLatest() { + calls.push("installLatest"); + installStartedResolve?.(); + return new Promise((resolve) => { + resolveInstall = resolve; + }); + }, + }, + installOrigin: { + resolveCurrentServerPackageRoot() { + return npmServerPackageRoot; + }, + }, + }; + const logger = createLogger(); + const updater = new DaemonSelfUpdater(runtime); + + const firstUpdate = updater.update({ + daemonVersion: "0.1.15", + onProgress: () => {}, + logger, + }); + await installStarted; + + await expect( + updater.update({ + daemonVersion: "0.1.15", + onProgress: () => {}, + logger, + }), + ).rejects.toBeInstanceOf(DaemonSelfUpdateInProgressError); + + resolveInstall?.({ exitCode: 0, stdout: "updated", stderr: "" }); + await expect(firstUpdate).resolves.toMatchObject({ success: true }); + expect(calls).toEqual(["inspect", "installLatest", "inspect"]); + }); +}); diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.ts b/packages/server/src/server/session/daemon/daemon-self-updater.ts new file mode 100644 index 000000000..cdb958935 --- /dev/null +++ b/packages/server/src/server/session/daemon/daemon-self-updater.ts @@ -0,0 +1,98 @@ +import { getErrorMessage } from "@getpaseo/protocol/error-utils"; +import { + daemonInstallOriginRuntime, + validateDaemonInstallOrigin, + type DaemonInstallOriginRuntime, +} from "./install-origin.js"; +import { npmGlobalPaseoCli, type NpmGlobalPaseoCli } from "./npm-global-cli.js"; + +export type DaemonSelfUpdatePhase = "starting" | "downloading" | "installing" | "complete"; + +export interface DaemonSelfUpdateResult { + success: boolean; + error: string | null; + newVersion: string | null; +} + +export interface DaemonSelfUpdateInput { + daemonVersion: string | null; + onProgress: (phase: DaemonSelfUpdatePhase) => void; + logger: DaemonSelfUpdateLogger; +} + +export interface DaemonSelfUpdateLogger { + error(obj: object, msg?: string): void; + warn(obj: object, msg?: string): void; +} + +export interface DaemonSelfUpdateRuntime { + npm: NpmGlobalPaseoCli; + installOrigin: DaemonInstallOriginRuntime; +} + +export class DaemonSelfUpdateInProgressError extends Error { + constructor() { + super("An update is already in progress"); + this.name = "DaemonSelfUpdateInProgressError"; + } +} + +const defaultRuntime: DaemonSelfUpdateRuntime = { + npm: npmGlobalPaseoCli, + installOrigin: daemonInstallOriginRuntime, +}; + +export class DaemonSelfUpdater { + private inProgress = false; + + constructor(private readonly runtime: DaemonSelfUpdateRuntime = defaultRuntime) {} + + async update(input: DaemonSelfUpdateInput): Promise { + if (this.inProgress) { + throw new DaemonSelfUpdateInProgressError(); + } + + this.inProgress = true; + try { + input.onProgress("starting"); + const install = await this.runtime.npm.inspect(); + const unsupportedReason = validateDaemonInstallOrigin( + install, + input.daemonVersion, + this.runtime.installOrigin, + ); + if (unsupportedReason) { + return { success: false, error: unsupportedReason, newVersion: null }; + } + + input.onProgress("downloading"); + input.onProgress("installing"); + + const result = await this.runtime.npm.installLatest(); + if (result.exitCode !== 0) { + const error = + result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`; + input.logger.error( + { exitCode: result.exitCode, stderr: result.stderr }, + "Daemon self-update failed", + ); + return { success: false, error, newVersion: null }; + } + + const updatedInstall = await this.runtime.npm.inspect().catch((error: unknown) => { + input.logger.warn({ err: error }, "Unable to read updated npm package version"); + return null; + }); + + input.onProgress("complete"); + return { success: true, error: null, newVersion: updatedInstall?.version ?? null }; + } catch (error) { + input.logger.error({ err: error }, "Daemon self-update failed with exception"); + return { success: false, error: getErrorMessage(error), newVersion: null }; + } finally { + this.inProgress = false; + } + } +} + +export const daemonSelfUpdater = new DaemonSelfUpdater(); diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index 856091d9e..22ca4c43d 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -42,10 +42,15 @@ function makeSubsystem(overrides: { getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; }) { const emitted: SessionOutboundMessage[] = []; - const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) }; + const restartIntents: Parameters[0][] = []; + const host: DaemonSessionHost = { + emit: (msg) => emitted.push(msg), + emitLifecycleIntent: (intent) => restartIntents.push(intent), + }; const paseoHome = makeHome(); const subsystem = new DaemonSession({ host, + clientId: "client-1", paseoHome, serverId: overrides.serverId, daemonVersion: overrides.daemonVersion, @@ -57,7 +62,7 @@ function makeSubsystem(overrides: { getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics, logger: pino({ level: "silent" }), }); - return { subsystem, emitted, paseoHome }; + return { subsystem, emitted, paseoHome, restartIntents }; } describe("DaemonSession", () => { diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 0c6781d29..2669afdc0 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -7,6 +7,7 @@ import { collectDaemonDiagnostics, type DaemonWebSocketRuntimeDiagnosticSnapshot, } from "./diagnostics.js"; +import { DaemonSelfUpdateSessionController } from "./daemon-self-update-session-controller.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js"; @@ -24,10 +25,17 @@ export interface DaemonRuntimeConfig { export interface DaemonSessionHost { emit(msg: SessionOutboundMessage): void; + emitLifecycleIntent(intent: { + type: "restart"; + clientId: string; + requestId: string; + reason?: string; + }): void; } export interface DaemonSessionOptions { host: DaemonSessionHost; + clientId: string; paseoHome: string; serverId: string | undefined; daemonVersion: string | undefined; @@ -49,6 +57,7 @@ export interface DaemonSessionOptions { */ export class DaemonSession { private readonly host: DaemonSessionHost; + private readonly clientId: string; private readonly paseoHome: string; private readonly serverId: string | undefined; private readonly daemonVersion: string | undefined; @@ -59,9 +68,11 @@ export class DaemonSession { private readonly listProviderAvailability: () => Promise; private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; private readonly logger: pino.Logger; + private readonly selfUpdate: DaemonSelfUpdateSessionController; constructor(options: DaemonSessionOptions) { this.host = options.host; + this.clientId = options.clientId; this.paseoHome = options.paseoHome; this.serverId = options.serverId; this.daemonVersion = options.daemonVersion; @@ -72,6 +83,13 @@ export class DaemonSession { this.listProviderAvailability = options.listProviderAvailability; this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); this.logger = options.logger; + this.selfUpdate = new DaemonSelfUpdateSessionController({ + clientId: this.clientId, + daemonVersion: this.daemonVersion ?? null, + emit: (msg) => this.host.emit(msg), + emitLifecycleIntent: (intent) => this.host.emitLifecycleIntent(intent), + sessionLogger: this.logger, + }); } async handleGetStatusRequest( @@ -191,4 +209,10 @@ export class DaemonSession { }); } } + + async handleUpdateRequest( + msg: Extract, + ): Promise { + await this.selfUpdate.dispatch(msg); + } } diff --git a/packages/server/src/server/session/daemon/install-origin.ts b/packages/server/src/server/session/daemon/install-origin.ts new file mode 100644 index 000000000..3f305ab3a --- /dev/null +++ b/packages/server/src/server/session/daemon/install-origin.ts @@ -0,0 +1,89 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; +import { isRealpathInsideRoot } from "../../../utils/path.js"; +import { PASEO_CLI_PACKAGE, type NpmGlobalPaseoInstall } from "./npm-global-cli.js"; + +const PackageJsonSchema = z.object({ name: z.string().optional() }).passthrough(); + +export interface DaemonInstallOriginRuntime { + resolveCurrentServerPackageRoot(): string | null; +} + +export const daemonInstallOriginRuntime: DaemonInstallOriginRuntime = { + resolveCurrentServerPackageRoot, +}; + +export function validateDaemonInstallOrigin( + install: NpmGlobalPaseoInstall, + daemonVersion: string | null, + runtime: DaemonInstallOriginRuntime = daemonInstallOriginRuntime, +): string | null { + if (install.isLinked) { + return `The global ${PASEO_CLI_PACKAGE} install is linked; self-update only supports normal npm global installs.`; + } + + if (daemonVersion && install.version !== daemonVersion) { + return `This daemon is not running from the npm global ${PASEO_CLI_PACKAGE} install (global npm has ${install.version}, daemon is ${daemonVersion}).`; + } + + const currentServerPackageRoot = runtime.resolveCurrentServerPackageRoot(); + if (!currentServerPackageRoot) { + return "Unable to verify that this daemon is running from an npm global install."; + } + + if (!isCurrentServerUnderNpmInstall(currentServerPackageRoot, install)) { + return `This daemon is not running from the npm global ${PASEO_CLI_PACKAGE} install.`; + } + + return null; +} + +function isCurrentServerUnderNpmInstall( + currentServerPackageRoot: string, + install: NpmGlobalPaseoInstall, +): boolean { + const roots = install.globalRootPath + ? [install.packagePath, globalNodeModulesPath(install.globalRootPath)] + : [install.packagePath]; + + return roots.some((root) => isRealpathInsideRoot(root, currentServerPackageRoot)); +} + +function globalNodeModulesPath(globalRootPath: string): string { + const normalized = path.normalize(globalRootPath); + return path.basename(normalized) === "node_modules" + ? normalized + : path.join(normalized, "node_modules"); +} + +function resolveCurrentServerPackageRoot(): string | null { + return resolvePackageRootFrom(fileURLToPath(import.meta.url), "@getpaseo/server"); +} + +function resolvePackageRootFrom(startPath: string, packageName: string): string | null { + let currentDir = path.dirname(startPath); + + while (true) { + const packageJsonPath = path.join(currentDir, "package.json"); + if (existsSync(packageJsonPath)) { + try { + const packageJson = PackageJsonSchema.parse( + JSON.parse(readFileSync(packageJsonPath, "utf8")), + ); + if (packageJson.name === packageName) { + return currentDir; + } + } catch { + return null; + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} diff --git a/packages/server/src/server/session/daemon/npm-global-cli.test.ts b/packages/server/src/server/session/daemon/npm-global-cli.test.ts new file mode 100644 index 000000000..aece7eb09 --- /dev/null +++ b/packages/server/src/server/session/daemon/npm-global-cli.test.ts @@ -0,0 +1,107 @@ +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { DefaultNpmGlobalPaseoCli } from "./npm-global-cli.js"; + +interface CommandCall { + command: string; + args: string[]; + timeout?: number; + maxBuffer?: number; +} + +const globalRoot = path.join(path.sep, "global", "lib"); +const globalNodeModules = path.join(globalRoot, "node_modules"); +const cliPackagePath = path.join(globalNodeModules, "@getpaseo", "cli"); + +function npmGlobalPaseoCliJson(version: string, options?: { linked?: boolean }): string { + return JSON.stringify({ + name: "lib", + path: globalRoot, + dependencies: { + "@getpaseo/cli": { + version, + path: cliPackagePath, + link: options?.linked === true, + }, + }, + }); +} + +describe("DefaultNpmGlobalPaseoCli", () => { + test("inspects the npm global cli install with npm -g ls", async () => { + const calls: CommandCall[] = []; + const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => { + calls.push({ + command, + args, + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + }); + return { exitCode: 0, stdout: npmGlobalPaseoCliJson("0.1.15"), stderr: "" }; + }); + + await expect(cli.inspect()).resolves.toEqual({ + version: "0.1.15", + packagePath: cliPackagePath, + globalRootPath: globalRoot, + isLinked: false, + }); + expect(calls).toEqual([ + { + command: "npm", + args: ["-g", "ls", "@getpaseo/cli", "--json", "--depth=0", "--long"], + timeout: 10_000, + maxBuffer: 10 * 1024 * 1024, + }, + ]); + }); + + test("runs the global install command for the latest cli", async () => { + const calls: CommandCall[] = []; + const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => { + calls.push({ + command, + args, + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + }); + return { exitCode: 0, stdout: "changed 42 packages", stderr: "" }; + }); + + await expect(cli.installLatest()).resolves.toEqual({ + exitCode: 0, + stdout: "changed 42 packages", + stderr: "", + }); + expect(calls).toEqual([ + { + command: "npm", + args: ["install", "-g", "@getpaseo/cli@latest"], + timeout: 300_000, + maxBuffer: 10 * 1024 * 1024, + }, + ]); + }); + + test("reports missing npm when npm exits without JSON", async () => { + const cli = new DefaultNpmGlobalPaseoCli(async () => ({ + exitCode: 127, + stdout: "", + stderr: "npm: command not found", + })); + + await expect(cli.inspect()).rejects.toThrow("npm: command not found"); + }); + + test("reports missing global cli when npm output has no cli dependency", async () => { + const cli = new DefaultNpmGlobalPaseoCli(async () => ({ + exitCode: 1, + stdout: JSON.stringify({ name: "lib", path: globalRoot, dependencies: {} }), + stderr: "missing", + })); + + await expect(cli.inspect()).rejects.toThrow( + "@getpaseo/cli is not installed with npm -g on this host", + ); + }); +}); diff --git a/packages/server/src/server/session/daemon/npm-global-cli.ts b/packages/server/src/server/session/daemon/npm-global-cli.ts new file mode 100644 index 000000000..d1e044405 --- /dev/null +++ b/packages/server/src/server/session/daemon/npm-global-cli.ts @@ -0,0 +1,147 @@ +import { getErrorMessage } from "@getpaseo/protocol/error-utils"; +import { z } from "zod"; +import { execCommand } from "../../../utils/spawn.js"; + +export const PASEO_CLI_PACKAGE = "@getpaseo/cli"; + +const NPM_PROBE_TIMEOUT_MS = 10_000; +const NPM_INSTALL_TIMEOUT_MS = 300_000; +const NPM_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +const NpmGlobalListSchema = z + .object({ + path: z.string().optional(), + dependencies: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); + +const NpmGlobalCliPackageSchema = z + .object({ + version: z.string(), + path: z.string(), + link: z.boolean().optional(), + }) + .passthrough(); + +const CommandErrorSchema = z + .object({ + code: z.union([z.number(), z.string()]).optional(), + stdout: z.string().optional(), + stderr: z.string().optional(), + }) + .passthrough(); + +export interface CommandOptions { + timeout?: number; + maxBuffer?: number; +} + +export interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface NpmGlobalPaseoInstall { + version: string; + packagePath: string; + globalRootPath: string | null; + isLinked: boolean; +} + +export interface NpmGlobalPaseoCli { + inspect(): Promise; + installLatest(): Promise; +} + +export type CommandRunner = ( + command: string, + args: string[], + options?: CommandOptions, +) => Promise; + +async function runExternalCommand( + command: string, + args: string[], + options?: CommandOptions, +): Promise { + try { + const { stdout, stderr } = await execCommand(command, args, { + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + }); + return { exitCode: 0, stdout, stderr }; + } catch (error) { + const parsed = CommandErrorSchema.safeParse(error); + if (!parsed.success) { + return { exitCode: 1, stdout: "", stderr: getErrorMessage(error) }; + } + + return { + exitCode: typeof parsed.data.code === "number" ? parsed.data.code : 1, + stdout: parsed.data.stdout ?? "", + stderr: parsed.data.stderr || getErrorMessage(error), + }; + } +} + +function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | null { + let parsedJson: unknown; + try { + parsedJson = JSON.parse(stdout); + } catch { + return null; + } + + const list = NpmGlobalListSchema.safeParse(parsedJson); + if (!list.success) { + return null; + } + + const rawCliPackage = list.data.dependencies?.[PASEO_CLI_PACKAGE]; + const cliPackage = NpmGlobalCliPackageSchema.safeParse(rawCliPackage); + if (!cliPackage.success) { + return null; + } + + return { + version: cliPackage.data.version, + packagePath: cliPackage.data.path, + globalRootPath: list.data.path ?? null, + isLinked: cliPackage.data.link === true, + }; +} + +export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli { + constructor(private readonly runCommand: CommandRunner = runExternalCommand) {} + + async inspect(): Promise { + const result = await this.runCommand( + "npm", + ["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long"], + { + timeout: NPM_PROBE_TIMEOUT_MS, + maxBuffer: NPM_MAX_BUFFER_BYTES, + }, + ); + + if (result.exitCode !== 0 && result.stdout.trim().length === 0) { + throw new Error(result.stderr.trim() || "npm is not available on this host"); + } + + const install = parseNpmGlobalPaseoInstall(result.stdout); + if (!install) { + throw new Error(`${PASEO_CLI_PACKAGE} is not installed with npm -g on this host`); + } + return install; + } + + installLatest(): Promise { + return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`], { + timeout: NPM_INSTALL_TIMEOUT_MS, + maxBuffer: NPM_MAX_BUFFER_BYTES, + }); + } +} + +export const npmGlobalPaseoCli = new DefaultNpmGlobalPaseoCli(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 01e0b260f..c2eb412b2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1183,6 +1183,8 @@ export class VoiceAssistantWebSocketServer { agentDetach: true, // 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, }, }; } diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 0554af692..36cfa7ef8 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -1,6 +1,7 @@ import type { Dirent } from "node:fs"; import { readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; +import { isPathInsideRoot } from "./path.js"; export interface SearchHomeDirectoriesOptions { homeDir: string; @@ -786,11 +787,6 @@ function normalizeRelativePath(homeRoot: string, absolutePath: string): string { return relative.split(path.sep).join("/"); } -function isPathInsideRoot(root: string, target: string): boolean { - const relative = path.relative(root, target); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null { const typedQuery = query.trim().replace(/\\/g, "/"); let normalized = typedQuery; diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts index 644b436cc..d84a1bf6a 100644 --- a/packages/server/src/utils/path.test.ts +++ b/packages/server/src/utils/path.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { areEquivalentPaths, createPathEquivalenceMatcher } from "./path.js"; +import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js"; describe("path equivalence", () => { test.each([ @@ -19,4 +19,18 @@ describe("path equivalence", () => { areEquivalentPaths("/Users/Administrator/GhostFactory", "/users/administrator/ghostfactory"), ).toBe(false); }); + + test("checks POSIX root containment without prefix false positives", () => { + expect(isPathInsideRoot("/opt/paseo", "/opt/paseo/node_modules/@getpaseo/server")).toBe(true); + expect(isPathInsideRoot("/opt/paseo", "/opt/paseo-other")).toBe(false); + }); + + test("checks Windows root containment case-insensitively", () => { + expect( + isPathInsideRoot("C:\\Paseo\\node_modules", "c:/paseo/node_modules/@getpaseo/server"), + ).toBe(true); + expect(isPathInsideRoot("C:\\Paseo\\node_modules", "C:\\Paseo\\node_modules-other")).toBe( + false, + ); + }); }); diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 2ee49fa95..f4d87a92a 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -58,6 +58,25 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri }; } +export function isPathInsideRoot(root: string, candidate: string): boolean { + const compareAsWindows = shouldCompareAsWindows(root, candidate); + const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; + const normalizedRoot = normalizePathForComparison(root, compareAsWindows); + const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows); + const relative = platformPath.relative(normalizedRoot, normalizedCandidate); + + return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative)); +} + +export function isRealpathInsideRoot(root: string, candidate: string): boolean { + const rootVariants = collectPathVariants(root); + const candidateVariants = collectPathVariants(candidate); + + return rootVariants.some((rootVariant) => + candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)), + ); +} + function collectPathVariants(value: string): string[] { const variants = new Set([value]); for (const realpath of resolveRealpathVariants(value)) {