Compare commits

...

5 Commits

Author SHA1 Message Date
Mohamed Boudra
98c0033f22 test(daemon): make self-update paths platform-stable 2026-06-26 13:25:17 +07:00
Mohamed Boudra
444214cbe2 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.
2026-06-26 13:02:35 +07:00
Davy
7f6403f517 fix: use npm install@latest and global concurrency guard
Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard
2026-06-26 12:54:17 +07:00
Davy
be8c2a54ce fix: clean up progress subscription on unmount, use typed phase cast
Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }
2026-06-26 12:53:23 +07:00
Davy
c6c030ab26 feat: add remote daemon self-update from client
Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.
2026-06-26 12:53:23 +07:00
26 changed files with 1536 additions and 8 deletions

View File

@@ -1745,6 +1745,30 @@ export const ar: TranslationResources = {
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "منطقة الخطر",
remove: {
title: "إزالة المضيف",

View File

@@ -1753,6 +1753,30 @@ export const en = {
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Danger zone",
remove: {
title: "Remove host",

View File

@@ -1786,6 +1786,30 @@ export const es: TranslationResources = {
dialogFailedMessage:
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Zona de peligro",
remove: {
title: "Eliminar host",

View File

@@ -1791,6 +1791,30 @@ export const fr: TranslationResources = {
dialogFailedMessage:
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Zone dangereuse",
remove: {
title: "Supprimer l'hôte",

View File

@@ -1765,6 +1765,31 @@ export const ja: TranslationResources = {
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
},
update: {
title: "デーモンを更新",
hint: "デーモンを最新バージョンに更新して再起動します",
confirm: "更新",
confirmTitle: "{{name}}を更新",
confirmMessage:
"これによりデーモンが最新バージョンに更新され、再起動されます。実行中のエージェントは一時的に中断されます。",
updating: "更新中...",
phaseStarting: "更新を準備中...",
phaseDownloading: "パッケージをダウンロード中...",
phaseInstalling: "インストール中...",
phaseComplete: "更新が完了しました。再起動中...",
unableToReconnectTitle: "再接続できません",
unableToReconnectMessage:
"{{name}}が更新後にオンラインに戻りませんでした。デーモンが再起動されたことを確認してください。",
unavailableTitle: "ホストが利用できません",
unavailableMessage:
"このホストは接続されていません。更新する前にオンラインになるまでお待ちください。",
offlineTitle: "ホストオフライン",
offlineMessage:
"このホストはオフラインです。オンラインに戻るまで待ってから更新してください。",
requestFailedTitle: "更新に失敗しました",
requestFailedMessage: "デーモンの更新に失敗しました: {{error}}",
dialogFailedMessage: "更新確認ダイアログを開けませんでした。",
},
dangerZone: "危険ゾーン",
remove: {
title: "ホストを削除",

View File

@@ -1771,6 +1771,31 @@ export const ptBR: TranslationResources = {
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
},
update: {
title: "Atualizar daemon",
hint: "Atualiza o daemon para a versão mais recente e o reinicia",
confirm: "Atualizar",
confirmTitle: "Atualizar {{name}}",
confirmMessage:
"Isso vai atualizar o daemon para a versão mais recente e reiniciá-lo. Agentes em execução serão interrompidos brevemente.",
updating: "Atualizando...",
phaseStarting: "Preparando atualização...",
phaseDownloading: "Baixando pacotes...",
phaseInstalling: "Instalando...",
phaseComplete: "Atualização concluída, reiniciando...",
unableToReconnectTitle: "Não foi possível reconectar",
unableToReconnectMessage:
"{{name}} não voltou a ficar online após a atualização. Verifique se o daemon reiniciou.",
unavailableTitle: "Host indisponível",
unavailableMessage:
"Este host não está conectado. Aguarde até ele ficar online antes de atualizar.",
offlineTitle: "Host offline",
offlineMessage:
"Este host está offline. Aguarde até ele voltar a ficar online antes de atualizar.",
requestFailedTitle: "Falha na atualização",
requestFailedMessage: "Falha ao atualizar o daemon: {{error}}",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação da atualização.",
},
dangerZone: "Zona de perigo",
remove: {
title: "Remover host",

View File

@@ -1777,6 +1777,30 @@ export const ru: TranslationResources = {
"Не удалось отправить запрос на перезапуск.Paseo автоматически повторно подключается. Повторите попытку, как только хост окажется в сети.",
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Опасная зона",
remove: {
title: "Удалить хост",

View File

@@ -1722,6 +1722,30 @@ export const zhCN: TranslationResources = {
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
dialogFailedMessage: "无法打开重启确认对话框。",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "危险区域",
remove: {
title: "移除 Host",

View File

@@ -1,6 +1,7 @@
import {
ArrowDown,
ArrowUp,
ArrowUpToLine,
ChevronRight,
Globe,
Monitor,
@@ -53,6 +54,8 @@ import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { formatLatency } from "@/utils/latency";
import { ICON_SIZE } from "@/styles/theme";
@@ -353,6 +356,8 @@ export function HostSettingsPage({
{isLocalDaemon ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
);
@@ -709,6 +714,214 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
);
}
function UpdateDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore();
const [isUpdating, setIsUpdating] = useState(false);
const [progressPhase, setProgressPhase] = useState<string | null>(null);
const isMountedRef = useRef(true);
const unsubscribeRef = useRef<(() => void) | null>(null);
const daemonVersion = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
);
const supportsSelfUpdate = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
);
const appVersion = resolveAppVersion();
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
useEffect(() => {
return () => {
isMountedRef.current = false;
unsubscribeRef.current?.();
};
}, []);
const isHostConnected = useCallback(
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
[host.serverId, runtime],
);
const hasReconnectedAfter = useCallback(
(startGeneration: number | null) => {
const snapshot = runtime.getSnapshot(host.serverId);
if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
return startGeneration === null || snapshot.clientGeneration !== startGeneration;
},
[host.serverId, runtime],
);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) return false;
if (predicate()) return true;
await delay(intervalMs);
}
return predicate();
},
[],
);
const waitForDaemonRestart = useCallback(
async (startGeneration: number | null) => {
const disconnectTimeoutMs = 15000;
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
await waitForCondition(
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
disconnectTimeoutMs,
);
}
const reconnected =
hasReconnectedAfter(startGeneration) ||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs));
if (isMountedRef.current) {
setIsUpdating(false);
setProgressPhase(null);
if (!reconnected) {
Alert.alert(
t("settings.host.daemon.update.unableToReconnectTitle"),
t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }),
);
}
}
},
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
);
const handleUpdate = useCallback(() => {
if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.update.unavailableTitle"),
t("settings.host.daemon.update.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.update.offlineTitle"),
t("settings.host.daemon.update.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.update.confirmTitle", { name: host.label }),
message: t("settings.host.daemon.update.confirmMessage"),
confirmLabel: t("settings.host.daemon.update.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: false,
})
.then((confirmed) => {
if (!confirmed) return;
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
setIsUpdating(true);
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
const requestId = `settings_daemon_update_${host.serverId}`;
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
if (message.payload.requestId !== requestId) return;
if (!isMountedRef.current) return;
const { phase } = message.payload;
if (phase === "starting")
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
else if (phase === "downloading")
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
else if (phase === "installing")
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
else if (phase === "complete")
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
});
unsubscribeRef.current = unsubscribe;
void daemonClient
.updateDaemon(requestId)
.then((response) => {
unsubscribeRef.current = null;
unsubscribe();
if (!response.success) {
if (!isMountedRef.current) return undefined;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
error: response.error ?? "Unknown error",
}),
);
return undefined;
}
// Update succeeded — wait for daemon to restart and reconnect
void waitForDaemonRestart(startGeneration);
return undefined;
})
.catch((error) => {
unsubscribeRef.current = null;
unsubscribe();
console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
if (!isMountedRef.current) return;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
});
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.dialogFailedMessage"),
);
});
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
const updateIcon = useMemo(
() => <ArrowUpToLine size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
// Don't show if the daemon doesn't support self-update or versions match
if (!supportsSelfUpdate || !hasVersionMismatch) {
return null;
}
const buttonLabel = isUpdating
? (progressPhase ?? t("settings.host.daemon.update.updating"))
: t("settings.host.daemon.update.confirm");
return (
<View style={settingsStyles.card} testID="host-page-update-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.update.hint")}</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={updateIcon}
onPress={handleUpdate}
disabled={isUpdating || !daemonClient || !isConnected}
testID="host-page-update-button"
>
{buttonLabel}
</Button>
</View>
</View>
);
}
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);

View File

@@ -10,6 +10,7 @@ import {
RenameTerminalResponseSchema,
RestartRequestedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema,
DaemonUpdateResponseSchema,
SessionInboundMessageSchema,
type ServerInfoStatusPayload,
WSOutboundMessageSchema,
@@ -468,6 +469,7 @@ export interface FetchAgentTimelineOptions {
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>;
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayloadSchema>;
type DaemonUpdateResponse = z.infer<typeof DaemonUpdateResponseSchema>;
type FetchAgentsPayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agents_response" }
@@ -2636,6 +2638,30 @@ export class DaemonClient {
});
}
async updateDaemon(requestId?: string): Promise<DaemonUpdateResponse["payload"]> {
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
// ============================================================================

View File

@@ -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",
},
});
});
});

View File

@@ -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<typeof DaemonUpdateResponseSchema>;
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<typeof DaemonUpdateProgressMessageSchema>;
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<typeof SessionOutboundMessageSchema>;

View File

@@ -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",

View File

@@ -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<DaemonSelfUpdater, "update">;
type RestartIntent = Parameters<DaemonSelfUpdateSessionControllerOptions["emitLifecycleIntent"]>[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([]);
});
});

View File

@@ -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<SessionInboundMessage, { type: "daemon.update.request" }>;
const DAEMON_SELF_UPDATE_MESSAGE_TYPES: ReadonlySet<SessionInboundMessage["type"]> = 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<DaemonSelfUpdater, "update">;
}
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<DaemonSelfUpdater, "update">;
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<void> | undefined {
if (!isDaemonSelfUpdateMessage(msg)) {
return undefined;
}
return this.handleDaemonUpdateRequest(msg);
}
private async handleDaemonUpdateRequest(msg: DaemonUpdateRequest): Promise<void> {
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,
});
}
}

View File

@@ -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<void>((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<CommandResult>((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"]);
});
});

View File

@@ -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<DaemonSelfUpdateResult> {
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();

View File

@@ -42,10 +42,15 @@ function makeSubsystem(overrides: {
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
}) {
const emitted: SessionOutboundMessage[] = [];
const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) };
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[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", () => {

View File

@@ -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<ProviderAvailability[]>;
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<SessionInboundMessage, { type: "daemon.update.request" }>,
): Promise<void> {
await this.selfUpdate.dispatch(msg);
}
}

View File

@@ -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;
}
}

View File

@@ -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",
);
});
});

View File

@@ -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<NpmGlobalPaseoInstall>;
installLatest(): Promise<CommandResult>;
}
export type CommandRunner = (
command: string,
args: string[],
options?: CommandOptions,
) => Promise<CommandResult>;
async function runExternalCommand(
command: string,
args: string[],
options?: CommandOptions,
): Promise<CommandResult> {
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<NpmGlobalPaseoInstall> {
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<CommandResult> {
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();

View File

@@ -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,
},
};
}

View File

@@ -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;

View File

@@ -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,
);
});
});

View File

@@ -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<string>([value]);
for (const realpath of resolveRealpathVariants(value)) {