Make remote daemon update failures actionable (#2120)
* fix(settings): show daemon update failures React Native web ignores Alert.alert, so failed remote updates reset to an enabled button without any user-visible explanation. Keep updater progress and failure as explicit rendered state, including the daemon's error, and cover the real browser-to-daemon failure path. * test(settings): harden daemon update regression * fix(settings): disable Desktop-managed daemon updates Desktop-bundled daemons advertised npm self-update even though the install-origin checks always rejected it. Report Desktop ownership to clients, render actionable guidance, and refuse stale requests before touching npm.
This commit is contained in:
@@ -335,6 +335,8 @@ export type DaemonLifecycleIntent =
|
||||
export interface PaseoDaemonConfig {
|
||||
listen: string;
|
||||
paseoHome: string;
|
||||
daemonVersion?: string;
|
||||
desktopManaged?: boolean;
|
||||
worktreesRoot?: string;
|
||||
corsAllowedOrigins: string[];
|
||||
allowedHosts?: HostnamesConfig;
|
||||
@@ -483,7 +485,7 @@ export async function createPaseoDaemon(
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
const bootstrapStart = performance.now();
|
||||
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
|
||||
const daemonVersion = resolveDaemonVersion(import.meta.url);
|
||||
const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url);
|
||||
const daemonConfigStore = new DaemonConfigStore(
|
||||
config.paseoHome,
|
||||
createInitialMutableDaemonConfig(config),
|
||||
@@ -1371,6 +1373,7 @@ export async function createPaseoDaemon(
|
||||
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
appBaseUrl: config.appBaseUrl,
|
||||
desktopManaged: config.desktopManaged === true,
|
||||
relay: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveBundledWebUiDistDir } from "./config.js";
|
||||
import { loadConfig, resolveBundledWebUiDistDir } from "./config.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
@@ -13,6 +13,19 @@ describe("server config", () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("records when the daemon is managed by Paseo Desktop", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
|
||||
roots.push(paseoHome);
|
||||
|
||||
const desktopConfig = loadConfig(paseoHome, {
|
||||
env: { PASEO_DESKTOP_MANAGED: "1" },
|
||||
});
|
||||
const standaloneConfig = loadConfig(paseoHome, { env: {} });
|
||||
|
||||
expect(desktopConfig.desktopManaged).toBe(true);
|
||||
expect(standaloneConfig.desktopManaged).toBe(false);
|
||||
});
|
||||
|
||||
test("resolves bundled web UI path from source-tree modules", () => {
|
||||
const root = path.parse(process.cwd()).root;
|
||||
expect(
|
||||
|
||||
@@ -488,6 +488,7 @@ export function loadConfig(
|
||||
return {
|
||||
listen,
|
||||
paseoHome,
|
||||
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
|
||||
@@ -1006,10 +1006,32 @@ test("receives server_info on websocket connect", async () => {
|
||||
expect(serverInfo).not.toBeNull();
|
||||
expect(serverInfo?.serverId.length).toBeGreaterThan(0);
|
||||
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
|
||||
expect(serverInfo?.desktopManaged).toBe(false);
|
||||
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
|
||||
|
||||
await client.close();
|
||||
}, 15000);
|
||||
|
||||
test("a Desktop-managed daemon does not advertise npm self-update", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ desktopManaged: true });
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
clientId: `cid-desktop-managed-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
|
||||
expect(serverInfo?.desktopManaged).toBe(true);
|
||||
expect(serverInfo?.features?.daemonSelfUpdate).toBe(false);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
test("emits disabled voice capability reasons on fresh daemon startup", async () => {
|
||||
const isolatedCtx = await createDaemonTestContext({
|
||||
speech: {
|
||||
|
||||
+11
-2
@@ -23,12 +23,14 @@ interface ControllerHarness {
|
||||
function createController(input: {
|
||||
updater: TestUpdater;
|
||||
daemonVersion?: string | null;
|
||||
desktopManaged?: boolean;
|
||||
}): ControllerHarness {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const restartIntents: RestartIntent[] = [];
|
||||
const controller = new DaemonSelfUpdateSessionController({
|
||||
clientId: "client-1",
|
||||
daemonVersion: input.daemonVersion ?? "0.1.15",
|
||||
desktopManaged: input.desktopManaged,
|
||||
emit: (msg) => {
|
||||
emitted.push(msg);
|
||||
},
|
||||
@@ -82,6 +84,7 @@ describe("DaemonSelfUpdateSessionController", () => {
|
||||
await controller.dispatch(updateRequest);
|
||||
|
||||
expect(updateInput?.daemonVersion).toBe("0.1.15");
|
||||
expect(updateInput?.desktopManaged).toBe(false);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "daemon.update.progress",
|
||||
@@ -119,15 +122,21 @@ describe("DaemonSelfUpdateSessionController", () => {
|
||||
});
|
||||
|
||||
test("emits a failed response without restart lifecycle intent", async () => {
|
||||
let updateInput: DaemonSelfUpdateInput | null = null;
|
||||
const updater: TestUpdater = {
|
||||
async update() {
|
||||
async update(input) {
|
||||
updateInput = input;
|
||||
return { success: false, error: "not an npm global install", newVersion: null };
|
||||
},
|
||||
};
|
||||
const { controller, emitted, restartIntents } = createController({ updater });
|
||||
const { controller, emitted, restartIntents } = createController({
|
||||
updater,
|
||||
desktopManaged: true,
|
||||
});
|
||||
|
||||
await controller.dispatch(updateRequest);
|
||||
|
||||
expect(updateInput?.desktopManaged).toBe(true);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "daemon.update.response",
|
||||
|
||||
@@ -24,6 +24,7 @@ interface DaemonSelfUpdateRestartIntent {
|
||||
export interface DaemonSelfUpdateSessionControllerOptions {
|
||||
clientId: string;
|
||||
daemonVersion: string | null;
|
||||
desktopManaged?: boolean;
|
||||
emit: (msg: SessionOutboundMessage) => void;
|
||||
emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
|
||||
sessionLogger: pino.Logger;
|
||||
@@ -37,6 +38,7 @@ function isDaemonSelfUpdateMessage(msg: SessionInboundMessage): msg is DaemonUpd
|
||||
export class DaemonSelfUpdateSessionController {
|
||||
private readonly clientId: string;
|
||||
private readonly daemonVersion: string | null;
|
||||
private readonly desktopManaged: boolean;
|
||||
private readonly emit: (msg: SessionOutboundMessage) => void;
|
||||
private readonly emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
|
||||
private readonly sessionLogger: pino.Logger;
|
||||
@@ -45,6 +47,7 @@ export class DaemonSelfUpdateSessionController {
|
||||
constructor(options: DaemonSelfUpdateSessionControllerOptions) {
|
||||
this.clientId = options.clientId;
|
||||
this.daemonVersion = options.daemonVersion;
|
||||
this.desktopManaged = options.desktopManaged === true;
|
||||
this.emit = options.emit;
|
||||
this.emitLifecycleIntent = options.emitLifecycleIntent;
|
||||
this.sessionLogger = options.sessionLogger;
|
||||
@@ -64,6 +67,7 @@ export class DaemonSelfUpdateSessionController {
|
||||
try {
|
||||
const result = await this.updater.update({
|
||||
daemonVersion: previousVersion,
|
||||
desktopManaged: this.desktopManaged,
|
||||
onProgress: (phase) => this.emitProgress(msg.requestId, phase),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ function createRuntime(input: {
|
||||
async function runUpdate(input: {
|
||||
runtime: DaemonSelfUpdateRuntime;
|
||||
daemonVersion?: string | null;
|
||||
desktopManaged?: boolean;
|
||||
phases?: DaemonSelfUpdatePhase[];
|
||||
}) {
|
||||
const logger = createLogger();
|
||||
@@ -91,6 +92,7 @@ async function runUpdate(input: {
|
||||
const phases = input.phases ?? [];
|
||||
const result = await updater.update({
|
||||
daemonVersion: input.daemonVersion ?? "0.1.15",
|
||||
desktopManaged: input.desktopManaged ?? false,
|
||||
onProgress: (phase) => phases.push(phase),
|
||||
logger,
|
||||
});
|
||||
@@ -98,6 +100,21 @@ async function runUpdate(input: {
|
||||
}
|
||||
|
||||
describe("DaemonSelfUpdater", () => {
|
||||
test("refuses a Desktop-managed daemon without touching npm", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
const runtime = createRuntime({ calls, inspections: [] });
|
||||
|
||||
const { result, phases } = await runUpdate({ runtime, desktopManaged: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
|
||||
newVersion: null,
|
||||
});
|
||||
expect(phases).toEqual([]);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test("updates a daemon that is running from the npm global cli install", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
const runtime = createRuntime({
|
||||
@@ -214,6 +231,7 @@ describe("DaemonSelfUpdater", () => {
|
||||
|
||||
const firstUpdate = updater.update({
|
||||
daemonVersion: "0.1.15",
|
||||
desktopManaged: false,
|
||||
onProgress: () => {},
|
||||
logger,
|
||||
});
|
||||
@@ -222,6 +240,7 @@ describe("DaemonSelfUpdater", () => {
|
||||
await expect(
|
||||
updater.update({
|
||||
daemonVersion: "0.1.15",
|
||||
desktopManaged: false,
|
||||
onProgress: () => {},
|
||||
logger,
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface DaemonSelfUpdateResult {
|
||||
|
||||
export interface DaemonSelfUpdateInput {
|
||||
daemonVersion: string | null;
|
||||
desktopManaged: boolean;
|
||||
onProgress: (phase: DaemonSelfUpdatePhase) => void;
|
||||
logger: DaemonSelfUpdateLogger;
|
||||
}
|
||||
@@ -42,12 +43,19 @@ const defaultRuntime: DaemonSelfUpdateRuntime = {
|
||||
installOrigin: daemonInstallOriginRuntime,
|
||||
};
|
||||
|
||||
const DESKTOP_MANAGED_UPDATE_ERROR =
|
||||
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.";
|
||||
|
||||
export class DaemonSelfUpdater {
|
||||
private inProgress = false;
|
||||
|
||||
constructor(private readonly runtime: DaemonSelfUpdateRuntime = defaultRuntime) {}
|
||||
|
||||
async update(input: DaemonSelfUpdateInput): Promise<DaemonSelfUpdateResult> {
|
||||
if (input.desktopManaged) {
|
||||
return { success: false, error: DESKTOP_MANAGED_UPDATE_ERROR, newVersion: null };
|
||||
}
|
||||
|
||||
if (this.inProgress) {
|
||||
throw new DaemonSelfUpdateInProgressError();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../wor
|
||||
|
||||
export interface DaemonRuntimeConfig {
|
||||
listen: string | null;
|
||||
worktreesRoot?: string;
|
||||
appBaseUrl?: string;
|
||||
desktopManaged?: boolean;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
@@ -86,6 +88,7 @@ export class DaemonSession {
|
||||
this.selfUpdate = new DaemonSelfUpdateSessionController({
|
||||
clientId: this.clientId,
|
||||
daemonVersion: this.daemonVersion ?? null,
|
||||
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
|
||||
emit: (msg) => this.host.emit(msg),
|
||||
emitLifecycleIntent: (intent) => this.host.emitLifecycleIntent(intent),
|
||||
sessionLogger: this.logger,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createTestPaseoDaemon } from "./paseo-daemon.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error("E2E_METRO_PORT is not set");
|
||||
}
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
corsAllowedOrigins: [`http://localhost:${metroPort}`],
|
||||
daemonVersion: "0.0.0",
|
||||
desktopManaged: process.env.E2E_DESKTOP_MANAGED === "1",
|
||||
});
|
||||
const serverId = (await readFile(path.join(daemon.paseoHome, "server-id"), "utf8")).trim();
|
||||
|
||||
process.send?.({
|
||||
type: "ready",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
await daemon.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", () => void shutdown());
|
||||
process.once("SIGTERM", () => void shutdown());
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
process.send?.({
|
||||
type: "error",
|
||||
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import { createTestAgentClients } from "./fake-agent-client.js";
|
||||
import type { PushNotificationSender } from "../push/notifications.js";
|
||||
|
||||
interface TestPaseoDaemonOptions {
|
||||
daemonVersion?: string;
|
||||
desktopManaged?: boolean;
|
||||
downloadTokenTtlMs?: number;
|
||||
corsAllowedOrigins?: string[];
|
||||
listen?: string;
|
||||
@@ -157,6 +159,8 @@ async function prepareTestDaemonConfig(
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: `${listenHost}:0`,
|
||||
paseoHome,
|
||||
daemonVersion: options.daemonVersion,
|
||||
desktopManaged: options.desktopManaged,
|
||||
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||
hostnames: true,
|
||||
mcpEnabled: options.mcpEnabled ?? true,
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
type BrowserAutomationHostCapability,
|
||||
} from "@getpaseo/protocol/browser-automation/capabilities";
|
||||
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
|
||||
|
||||
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
|
||||
|
||||
@@ -404,19 +405,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
||||
private readonly serverId: string;
|
||||
private readonly daemonVersion: string;
|
||||
private readonly daemonRuntimeConfig:
|
||||
| {
|
||||
listen: string | null;
|
||||
appBaseUrl?: string;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
@@ -507,18 +496,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
github?: GitHubService,
|
||||
pushNotificationSender?: PushNotificationSender,
|
||||
providerSnapshotManager?: ProviderSnapshotManager,
|
||||
daemonRuntimeConfig?: {
|
||||
listen: string | null;
|
||||
worktreesRoot?: string;
|
||||
appBaseUrl?: string;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
};
|
||||
},
|
||||
daemonRuntimeConfig?: DaemonRuntimeConfig,
|
||||
serviceProxyPublicBaseUrl?: string | null,
|
||||
browserToolsBroker?: BrowserToolsBroker | null,
|
||||
) {
|
||||
@@ -1227,6 +1205,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
serverId: this.serverId,
|
||||
hostname: getHostname(),
|
||||
version: this.daemonVersion,
|
||||
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
|
||||
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
|
||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||
features: {
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
@@ -1257,7 +1237,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: true,
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
daemonSelfUpdate: true,
|
||||
daemonSelfUpdate: this.daemonRuntimeConfig?.desktopManaged !== true,
|
||||
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||
agentForkContext: true,
|
||||
// COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14.
|
||||
|
||||
Reference in New Issue
Block a user