From 444214cbe28dd3f1d08bd7358cc8d6190df7b415 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 26 Jun 2026 12:50:14 +0700 Subject: [PATCH] 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. --- packages/app/src/i18n/resources/ja.ts | 25 ++ packages/app/src/i18n/resources/pt-BR.ts | 25 ++ .../app/src/screens/settings/host-page.tsx | 65 +++-- packages/protocol/src/messages.test.ts | 20 ++ packages/protocol/src/messages.ts | 22 +- packages/server/src/server/session.ts | 136 +--------- ...mon-self-update-session-controller.test.ts | 169 +++++++++++++ .../daemon-self-update-session-controller.ts | 134 ++++++++++ .../daemon/daemon-self-updater.test.ts | 235 ++++++++++++++++++ .../session/daemon/daemon-self-updater.ts | 98 ++++++++ .../session/daemon/daemon-session.test.ts | 9 +- .../server/session/daemon/daemon-session.ts | 24 ++ .../server/session/daemon/install-origin.ts | 89 +++++++ .../session/daemon/npm-global-cli.test.ts | 107 ++++++++ .../server/session/daemon/npm-global-cli.ts | 147 +++++++++++ .../server/src/utils/directory-suggestions.ts | 6 +- packages/server/src/utils/path.test.ts | 16 +- packages/server/src/utils/path.ts | 19 ++ 18 files changed, 1169 insertions(+), 177 deletions(-) create mode 100644 packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts create mode 100644 packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts create mode 100644 packages/server/src/server/session/daemon/daemon-self-updater.test.ts create mode 100644 packages/server/src/server/session/daemon/daemon-self-updater.ts create mode 100644 packages/server/src/server/session/daemon/install-origin.ts create mode 100644 packages/server/src/server/session/daemon/npm-global-cli.test.ts create mode 100644 packages/server/src/server/session/daemon/npm-global-cli.ts diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f6f7cbf42e..7c2f520e35 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 e00c314f16..a4613aecbd 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/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 662a4f01ba..8f39fd0beb 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -54,7 +54,6 @@ 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 type { DaemonUpdateProgressStatusPayload } from "@getpaseo/protocol/messages"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { resolveAppVersion } from "@/utils/app-version"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; @@ -747,6 +746,14 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { () => 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) => { @@ -761,24 +768,32 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { [], ); - const waitForDaemonRestart = useCallback(async () => { - const disconnectTimeoutMs = 15000; - const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while - if (isHostConnected()) { - await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs); - } - const reconnected = await waitForCondition(() => isHostConnected(), 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 }), + 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, ); } - } - }, [host.label, isHostConnected, t, waitForCondition]); + 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) { @@ -805,15 +820,15 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }) .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}`; - // Subscribe to progress events - const unsubscribe = daemonClient.subscribe((event) => { - if (event.type !== "status") return; - if (event.payload.status !== "daemon_update_progress") return; + const unsubscribe = daemonClient.on("daemon.update.progress", (message) => { + if (message.payload.requestId !== requestId) return; if (!isMountedRef.current) return; - const phase = (event.payload as DaemonUpdateProgressStatusPayload).phase; + const { phase } = message.payload; if (phase === "starting") setProgressPhase(t("settings.host.daemon.update.phaseStarting")); else if (phase === "downloading") @@ -826,7 +841,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { unsubscribeRef.current = unsubscribe; void daemonClient - .updateDaemon(`settings_daemon_update_${host.serverId}`) + .updateDaemon(requestId) .then((response) => { unsubscribeRef.current = null; unsubscribe(); @@ -843,7 +858,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { return undefined; } // Update succeeded — wait for daemon to restart and reconnect - void waitForDaemonRestart(); + void waitForDaemonRestart(startGeneration); return undefined; }) .catch((error) => { @@ -869,7 +884,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { t("settings.host.daemon.update.dialogFailedMessage"), ); }); - }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]); + }, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]); const updateIcon = useMemo( () => , diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index f2247823f5..9cd91e40d0 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 99e3b55651..499de202a9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2423,16 +2423,6 @@ export const ShutdownRequestedStatusPayloadSchema = z.object({ requestId: z.string(), }); -export const DaemonUpdateProgressStatusPayloadSchema = z.object({ - status: z.literal("daemon_update_progress"), - requestId: z.string(), - phase: z.enum(["starting", "downloading", "installing", "complete"]), -}); - -export type DaemonUpdateProgressStatusPayload = z.infer< - typeof DaemonUpdateProgressStatusPayloadSchema ->; - export const DaemonConfigChangedStatusPayloadSchema = z .object({ status: z.literal("daemon_config_changed"), @@ -2447,7 +2437,6 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ AgentRefreshedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema, - DaemonUpdateProgressStatusPayloadSchema, DaemonConfigChangedStatusPayloadSchema, ]); @@ -4125,6 +4114,16 @@ export const DaemonUpdateResponseSchema = z.object({ 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, @@ -4260,6 +4259,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ LoopInspectResponseSchema, LoopLogsResponseSchema, LoopStopResponseSchema, + DaemonUpdateProgressMessageSchema, DaemonUpdateResponseSchema, ]); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 121fe1bb0c..75999482d5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,6 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { stat } from "node:fs/promises"; -import { spawn } from "node:child_process"; import { basename, normalize, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; @@ -382,9 +381,6 @@ class SessionRequestError extends Error { } } -// Global guard — only one daemon update can run at a time across all sessions. -let daemonUpdateInProgress = false; - export interface SessionFileSystem { isDirectory(path: string): Promise; } @@ -781,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, @@ -1515,7 +1513,7 @@ export class Session { case "diagnostics.request": return this.daemonSession.handleDiagnosticsRequest(msg); case "daemon.update.request": - return this.handleDaemonUpdateRequest(msg); + return this.daemonSession.handleUpdateRequest(msg); case "set_daemon_config_request": this.emit({ type: "set_daemon_config_response", @@ -1783,134 +1781,6 @@ export class Session { }); } - private async handleDaemonUpdateRequest( - msg: Extract, - ): Promise { - if (daemonUpdateInProgress) { - this.emit({ - type: "rpc_error", - payload: { - requestId: msg.requestId, - requestType: "daemon.update.request", - error: "An update is already in progress", - code: "already_updating", - }, - }); - return; - } - - daemonUpdateInProgress = true; - const previousVersion = this.daemonVersion ?? null; - - const emitProgress = (phase: "starting" | "downloading" | "installing" | "complete") => { - this.emit({ - type: "status", - payload: { - status: "daemon_update_progress", - requestId: msg.requestId, - phase, - }, - }); - }; - - const emitResponse = (success: boolean, error: string | null, newVersion: string | null) => { - this.emit({ - type: "daemon.update.response", - payload: { - requestId: msg.requestId, - success, - error, - previousVersion, - newVersion, - }, - }); - }; - - try { - emitProgress("starting"); - - // Check if npm is available - const npmVersionOk = await new Promise((fulfill) => { - const proc = spawn("npm", ["--version"], { - stdio: ["ignore", "pipe", "pipe"], - timeout: 10000, - }); - proc.on("close", (code: number | null) => fulfill(code === 0)); - proc.on("error", () => fulfill(false)); - }); - - if (!npmVersionOk) { - emitResponse(false, "npm is not available on this system", null); - daemonUpdateInProgress = false; - return; - } - - emitProgress("downloading"); - - // Install latest version globally — npm install -g @getpaseo/cli@latest - // is more reliable than npm update -g, especially across major versions. - const result = await new Promise<{ exitCode: number; stdout: string; stderr: string }>( - (fulfill) => { - const proc = spawn("npm", ["install", "-g", "@getpaseo/cli@latest"], { - stdio: ["ignore", "pipe", "pipe"], - timeout: 300_000, // 5 minutes - }); - let stdout = ""; - let stderr = ""; - proc.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - proc.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - proc.on("close", (code: number | null) => { - fulfill({ exitCode: code ?? 1, stdout, stderr }); - }); - proc.on("error", (err: Error) => { - fulfill({ exitCode: 1, stdout, stderr: err.message }); - }); - }, - ); - - if (result.exitCode !== 0) { - const errorMsg = - result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`; - this.sessionLogger.error( - { exitCode: result.exitCode, stderr: result.stderr }, - "Daemon update failed", - ); - emitResponse(false, errorMsg, null); - daemonUpdateInProgress = false; - return; - } - - emitProgress("installing"); - - // Try to parse the new version from npm output - let newVersion: string | null = null; - const versionMatch = result.stdout.match(/@getpaseo\/cli@(\S+)/); - if (versionMatch) { - newVersion = versionMatch[1]; - } - - emitProgress("complete"); - emitResponse(true, null, newVersion); - - // Trigger restart to load the new code - this.emitLifecycleIntent({ - type: "restart", - clientId: this.clientId, - requestId: msg.requestId, - reason: "daemon_update", - }); - } catch (error) { - this.sessionLogger.error({ err: error }, "Daemon update failed with exception"); - emitResponse(false, error instanceof Error ? error.message : "Unknown error", null); - } finally { - daemonUpdateInProgress = false; - } - } - private emitLifecycleIntent(intent: SessionLifecycleIntent): void { if (!this.onLifecycleIntent) { return; 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 0000000000..d6c57f14c3 --- /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 0000000000..9f7975e1cd --- /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 0000000000..f1868f5a58 --- /dev/null +++ b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts @@ -0,0 +1,235 @@ +import path from "node:path"; +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 = path.join(path.sep, "global", "lib"); +const globalNodeModules = path.join(globalRoot, "node_modules"); +const cliPackagePath = path.join(globalNodeModules, "@getpaseo", "cli"); +const npmServerPackageRoot = path.join(cliPackagePath, "node_modules", "@getpaseo", "server"); +const sourceServerPackageRoot = path.join(path.sep, "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 0000000000..cdb9589355 --- /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 856091d9e7..22ca4c43d8 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 0c6781d299..2669afdc0f 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 0000000000..3f305ab3ad --- /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 0000000000..aece7eb09a --- /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 0000000000..d1e0444058 --- /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/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 0554af6921..36cfa7ef8f 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 644b436cc7..d84a1bf6a4 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 2ee49fa95c..f4d87a92a5 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)) {