diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 8357ac481..8e37e6a6f 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -118,6 +118,7 @@ export default async function globalSetup() { env: { ...process.env, PASEO_HOME: paseoHome, + PASEO_SERVER_ID: 'e2e-test-daemon', PASEO_LISTEN: `0.0.0.0:${port}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`, PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, diff --git a/packages/app/e2e/host-selection.spec.ts b/packages/app/e2e/host-selection.spec.ts index 20027d341..b024ed314 100644 --- a/packages/app/e2e/host-selection.spec.ts +++ b/packages/app/e2e/host-selection.spec.ts @@ -71,3 +71,119 @@ test('new agent respects serverId in the URL', async ({ page }) => { const input = page.getByRole('textbox', { name: 'Message agent...' }); await expect(input).toBeEditable({ timeout: 30000 }); }); + +test('new agent auto-selects first online host when no preference is stored', async ({ page }) => { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).'); + } + + const nowIso = new Date().toISOString(); + const testDaemon = { + id: 'e2e-test-daemon', + label: 'localhost', + endpoints: [`127.0.0.1:${daemonPort}`], + relay: null, + metadata: null, + createdAt: nowIso, + updatedAt: nowIso, + }; + + await page.goto('/'); + await page.evaluate( + ({ daemon }) => { + const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; + localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); + localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); + localStorage.removeItem('@paseo:create-agent-preferences'); + localStorage.removeItem('@paseo:settings'); + }, + { daemon: testDaemon } + ); + + await page.reload(); + await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 }); + + // Host should be auto-selected (no manual selection required). + await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible(); + const input = page.getByRole('textbox', { name: 'Message agent...' }); + await expect(input).toBeEditable({ timeout: 30000 }); +}); + +test('adopts daemon-provided serverId for legacy host ids', async ({ page }) => { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).'); + } + + const nowIso = new Date().toISOString(); + const legacyId = 'legacy-daemon-id'; + const daemon = { + id: legacyId, + label: 'localhost', + endpoints: [`127.0.0.1:${daemonPort}`], + relay: null, + metadata: null, + createdAt: nowIso, + updatedAt: nowIso, + }; + + await page.goto('/settings'); + await page.evaluate( + ({ daemon, legacyId }) => { + const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; + localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); + localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); + localStorage.setItem( + '@paseo:create-agent-preferences', + JSON.stringify({ + serverId: legacyId, + provider: 'claude', + providerPreferences: { + claude: { model: 'haiku' }, + codex: { model: 'gpt-5.1-codex-mini' }, + }, + }) + ); + localStorage.removeItem('@paseo:settings'); + }, + { daemon, legacyId } + ); + + await page.reload(); + await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 }); + + // Wait for server_info handshake to rekey registry + preferences. + await page.waitForFunction( + () => { + const raw = localStorage.getItem('@paseo:daemon-registry'); + const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); + if (!raw || !prefsRaw) return false; + const registry = JSON.parse(raw); + const prefs = JSON.parse(prefsRaw); + if (!Array.isArray(registry) || registry.length !== 1) return false; + if (registry[0]?.id !== 'e2e-test-daemon') return false; + if (prefs?.serverId !== 'e2e-test-daemon') return false; + const legacyIds = registry[0]?.metadata?.legacyIds; + return Array.isArray(legacyIds) && legacyIds.includes('legacy-daemon-id'); + }, + { timeout: 20000 } + ); + + const snapshot = await page.evaluate(() => { + const raw = localStorage.getItem('@paseo:daemon-registry'); + const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); + return { raw, prefsRaw }; + }); + if (!snapshot.raw || !snapshot.prefsRaw) { + throw new Error('Expected registry + preferences to be present after rekey.'); + } + + const registry = JSON.parse(snapshot.raw) as any[]; + expect(registry[0].id).toBe('e2e-test-daemon'); + expect(registry[0].metadata.legacyIds).toContain('legacy-daemon-id'); + + const prefs = JSON.parse(snapshot.prefsRaw) as any; + expect(prefs.serverId).toBe('e2e-test-daemon'); +}); diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 0fe2f7282..489a6c228 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -1,12 +1,23 @@ -import { useLocalSearchParams } from "expo-router"; +import { useEffect } from "react"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; +import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context"; export default function AgentReadyRoute() { + const router = useRouter(); + const { daemons } = useDaemonRegistry(); const { serverId, agentId } = useLocalSearchParams<{ serverId?: string; agentId?: string; }>(); + useEffect(() => { + if (typeof serverId !== "string" || typeof agentId !== "string") return; + const canonical = resolveCanonicalDaemonId(daemons, serverId); + if (!canonical || canonical === serverId) return; + router.replace(`/agent/${canonical}/${agentId}` as any); + }, [agentId, daemons, router, serverId]); + return ( ); } - diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index f07d1be23..9ec0ccc05 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -210,7 +210,7 @@ export function DropdownMenuTrigger({ onPress={handlePress} style={({ pressed, hovered }) => { if (typeof style === "function") { - return style({ pressed, hovered, open: ctx.open }); + return style({ pressed, hovered: Boolean(hovered), open: ctx.open }); } return style; }} diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index d26e1d09a..bae50952c 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -59,6 +59,7 @@ interface DaemonRegistryContextValue { removeDaemon: (id: string) => Promise; upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise; upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise; + adoptDaemonServerId: (currentId: string, serverId: string) => Promise; } const DaemonRegistryContext = createContext(null); @@ -129,6 +130,72 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { await persist(remaining); }, [persist, readDaemons]); + const adoptDaemonServerId = useCallback( + async (currentId: string, serverId: string) => { + const trimmed = serverId.trim(); + if (!trimmed) { + throw new Error("serverId is required"); + } + if (currentId === trimmed) { + return trimmed; + } + + const existing = readDaemons(); + const now = new Date().toISOString(); + const idx = existing.findIndex((daemon) => daemon.id === currentId); + if (idx === -1) { + return trimmed; + } + + const current = existing[idx]; + const otherIdx = existing.findIndex((daemon) => daemon.id === trimmed); + + const addLegacyId = (metadata: Record | null | undefined, legacyId: string) => { + const prev = (metadata ?? {}) as Record; + const legacyIdsRaw = prev.legacyIds; + const legacyIds = Array.isArray(legacyIdsRaw) + ? legacyIdsRaw.filter((value) => typeof value === "string") + : []; + if (!legacyIds.includes(legacyId)) { + legacyIds.push(legacyId); + } + return { ...prev, legacyIds }; + }; + + // Merge into existing canonical entry if it exists. + if (otherIdx !== -1) { + const canonical = existing[otherIdx]; + const merged: HostProfile = { + ...canonical, + label: canonical.label?.trim() ? canonical.label : current.label, + endpoints: Array.from(new Set([...(canonical.endpoints ?? []), ...(current.endpoints ?? [])])), + daemonPublicKeyB64: canonical.daemonPublicKeyB64 ?? current.daemonPublicKeyB64, + relay: canonical.relay ?? current.relay ?? null, + createdAt: canonical.createdAt ?? current.createdAt, + updatedAt: now, + metadata: addLegacyId(canonical.metadata, currentId), + }; + const next = existing.filter((daemon) => daemon.id !== currentId); + next[otherIdx > idx ? otherIdx - 1 : otherIdx] = merged; + await persist(next); + return trimmed; + } + + const updated: HostProfile = { + ...current, + id: trimmed, + updatedAt: now, + metadata: addLegacyId(current.metadata, currentId), + }; + + const next = [...existing]; + next[idx] = updated; + await persist(next); + return trimmed; + }, + [persist, readDaemons] + ); + const upsertDaemonFromOffer = useCallback( async (offer: ConnectionOfferV1) => { const existing = readDaemons(); @@ -194,6 +261,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { addDaemon, updateDaemon, removeDaemon, + adoptDaemonServerId, upsertDaemonFromOffer, upsertDaemonFromOfferUrl, }; @@ -389,3 +457,16 @@ export function buildDirectDaemonWsUrl(profile: HostProfile): string { } return buildDaemonWebSocketUrl(endpoint); } + +export function resolveCanonicalDaemonId(daemons: HostProfile[], id: string): string | null { + const direct = daemons.find((daemon) => daemon.id === id); + if (direct) return direct.id; + + for (const daemon of daemons) { + const legacyIds = (daemon.metadata as any)?.legacyIds; + if (Array.isArray(legacyIds) && legacyIds.some((value) => value === id)) { + return daemon.id; + } + } + return null; +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index dc6c6d82f..520cdf21e 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,7 +1,7 @@ import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { AppState, Platform } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useDaemonClient } from "@/hooks/use-daemon-client"; import { useAudioPlayer } from "@/hooks/use-audio-player"; import { useClientActivity } from "@/hooks/use-client-activity"; @@ -23,6 +23,7 @@ import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-type import type { DaemonClient, ConnectionState } from "@server/client/daemon-client"; import { File } from "expo-file-system"; import { useDaemonConnections } from "./daemon-connections-context"; +import { useDaemonRegistry } from "./daemon-registry-context"; import { useSessionStore, type SessionState, @@ -259,18 +260,23 @@ interface SessionProviderProps { serverId: string; } +const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; +const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"]; + // SessionProvider: Daemon client message handler that updates Zustand store export function SessionProvider({ children, serverUrl, serverId, }: SessionProviderProps) { + const queryClient = useQueryClient(); const client = useDaemonClient(serverUrl); const [connectionSnapshot, setConnectionSnapshot] = useState(() => mapConnectionState(client.getConnectionState(), client.lastError) ); const { updateConnectionStatus } = useDaemonConnections(); + const { adoptDaemonServerId } = useDaemonRegistry(); // Zustand store actions const initializeSession = useSessionStore((state) => state.initializeSession); @@ -349,6 +355,7 @@ export function SessionProvider({ ); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); + const hasAdoptedServerIdRef = useRef(false); useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { @@ -443,6 +450,47 @@ export function SessionProvider({ return unsubscribe; }, [client]); + // Daemon identity handshake: adopt stable serverId from the daemon. + useEffect(() => { + return client.on("status", (message) => { + if (message.type !== "status") return; + const payload = message.payload as { status?: unknown; serverId?: unknown }; + if (payload?.status !== "server_info") return; + + const remoteServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : ""; + if (!remoteServerId) return; + + // Avoid loops if multiple server_info messages arrive. + if (hasAdoptedServerIdRef.current) return; + hasAdoptedServerIdRef.current = true; + + if (remoteServerId === serverId) { + return; + } + + void (async () => { + const canonicalId = await adoptDaemonServerId(serverId, remoteServerId); + + // Migrate persisted "create agent" preferences if they reference the old id. + try { + const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as { serverId?: unknown } & Record; + if (typeof parsed.serverId === "string" && parsed.serverId === serverId) { + const next = { ...parsed, serverId: canonicalId }; + await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); + queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); + } + } + } catch (error) { + console.warn("[Session] Failed to migrate form preferences serverId", { error }); + } + })().catch((error) => { + console.warn("[Session] Failed to adopt daemon serverId", { error }); + }); + }); + }, [adoptDaemonServerId, client, queryClient, serverId]); + useEffect(() => { updateSessionConnection(serverId, connectionSnapshot); }, [serverId, connectionSnapshot, updateSessionConnection]); diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index df54256f7..5aca1d05f 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -54,6 +54,7 @@ type UseAgentFormStateOptions = { isVisible?: boolean; isCreateFlow?: boolean; isTargetDaemonReady?: boolean; + onlineServerIds?: string[]; }; type UseAgentFormStateResult = { @@ -230,6 +231,7 @@ export function useAgentFormState( isVisible = true, isCreateFlow = true, isTargetDaemonReady = true, + onlineServerIds = [], } = options; const { @@ -357,6 +359,32 @@ export function useAgentFormState( validServerIds, ]); + // Auto-select the first online host when: + // - no URL override + // - no stored preference applied + // - user hasn't manually picked a host in this session + useEffect(() => { + if (!isVisible || !isCreateFlow) return; + if (isPreferencesLoading) return; + if (!hasResolvedRef.current) return; + if (userModified.serverId) return; + if (combinedInitialValues?.serverId !== undefined) return; + if (formStateRef.current.serverId) return; + + const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null; + if (!candidate) return; + + setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate })); + }, [ + combinedInitialValues?.serverId, + isCreateFlow, + isPreferencesLoading, + isVisible, + onlineServerIds.join("|"), + userModified.serverId, + validServerIds, + ]); + // Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load). useEffect(() => { if (!isVisible || !isCreateFlow) return; diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 1f40cd61d..64bc9c630 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -32,7 +32,7 @@ import { checkoutStatusQueryKey, } from "@/hooks/use-checkout-status-query"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { formatConnectionStatus } from "@/utils/daemons"; import { useSessionStore } from "@/stores/session-store"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -147,6 +147,33 @@ export function DraftAgentScreen({ const resolvedModel = getParamValue(params.model); const resolvedWorkingDir = getParamValue(params.workingDir); + const onlineServerIds = useMemo(() => { + if (daemons.length === 0) return []; + const out: string[] = []; + for (const daemon of daemons) { + const status = connectionStates.get(daemon.id)?.status ?? "idle"; + if (status === "online") out.push(daemon.id); + } + return out; + }, [connectionStates, daemons]); + + // If the URL contains a legacy host id, redirect to the canonical daemon-provided id. + useEffect(() => { + if (!resolvedServerId) return; + const canonical = resolveCanonicalDaemonId(daemons, resolvedServerId); + if (!canonical || canonical === resolvedServerId) return; + + const nextParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (key === "serverId") continue; + if (typeof value === "string") nextParams[key] = value; + if (Array.isArray(value) && typeof value[0] === "string") nextParams[key] = value[0] as string; + } + nextParams.serverId = canonical; + + router.replace({ pathname: "/", params: nextParams as any }); + }, [daemons, params, resolvedServerId, router]); + const initialValues = useMemo((): CreateAgentInitialValues => { const values: CreateAgentInitialValues = {}; if (resolvedWorkingDir) { @@ -187,6 +214,7 @@ export function DraftAgentScreen({ initialValues, isVisible, isCreateFlow: true, + onlineServerIds, }); const hostEntry = selectedServerId ? connectionStates.get(selectedServerId) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0bf218bdb..0b8796bbd 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -55,6 +55,7 @@ import { } from "./connection-offer.js"; import { printPairingQrIfEnabled } from "./pairing-qr.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; +import { getOrCreateServerId } from "./server-id.js"; import type { AgentClient, AgentProvider, @@ -104,7 +105,8 @@ export async function createPaseoDaemon( rootLogger: Logger ): Promise { const logger = rootLogger.child({ module: "bootstrap" }); - const connectionSessionId = randomUUID(); + const serverId = getOrCreateServerId(config.paseoHome, { logger }); + const connectionSessionId = serverId; let relayTransport: RelayTransportController | null = null; const staticDir = config.staticDir; @@ -416,6 +418,7 @@ export async function createPaseoDaemon( const wsServer = new VoiceAssistantWebSocketServer( httpServer, logger, + serverId, agentManager, agentStorage, downloadTokenStore, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index d8d93550a..22221e356 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -7,6 +7,7 @@ import { execSync } from "node:child_process"; import { createDaemonTestContext, type DaemonTestContext, + DaemonClient, } from "./test-utils/index.js"; import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js"; import { @@ -88,6 +89,29 @@ describe("daemon client E2E", () => { expect(deleteResult.error).toBeTruthy(); }, 30000); + test("emits server_info on websocket connect", async () => { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + }); + + const infoPromise = waitForSignal<{ serverId: string }>(5000, (resolve) => { + const unsubscribe = client.on("status", (message) => { + if (message.type !== "status") return; + const payload = message.payload as { status?: unknown; serverId?: unknown }; + if (payload.status !== "server_info") return; + if (typeof payload.serverId !== "string" || payload.serverId.trim().length === 0) return; + resolve({ serverId: payload.serverId.trim() }); + }); + return unsubscribe; + }); + + await client.connect(); + const info = await infoPromise; + expect(info.serverId.length).toBeGreaterThan(0); + + await client.close(); + }, 15000); + test("matches request IDs for concurrent session requests", async () => { const firstRequestId = `list-${Date.now()}-a`; const secondRequestId = `list-${Date.now()}-b`; diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index cb68bba9b..7199053b7 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -101,13 +101,16 @@ describe("Relay transport (plaintext) - daemon E2E", () => { }); ws.on("message", (data) => { - clearTimeout(timeout); try { - resolve(JSON.parse(data.toString())); + const parsed = JSON.parse(data.toString()); + if (parsed && typeof parsed === "object" && "type" in parsed && (parsed as any).type === "pong") { + clearTimeout(timeout); + resolve(parsed); + ws.close(); + } } catch (err) { + clearTimeout(timeout); reject(err); - } finally { - ws.close(); } }); diff --git a/packages/server/src/server/server-id.test.ts b/packages/server/src/server/server-id.test.ts new file mode 100644 index 000000000..c97124056 --- /dev/null +++ b/packages/server/src/server/server-id.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { getOrCreateServerId } from "./server-id.js"; + +function tmpHome(): string { + return mkdtempSync(path.join(tmpdir(), "paseo-server-id-")); +} + +describe("getOrCreateServerId", () => { + let home: string; + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.PASEO_SERVER_ID; + home = tmpHome(); + }); + + afterEach(() => { + process.env = originalEnv; + rmSync(home, { recursive: true, force: true }); + }); + + it("creates and persists a stable id per PASEO_HOME", () => { + const first = getOrCreateServerId(home); + const second = getOrCreateServerId(home); + expect(first).toBe(second); + expect(first.startsWith("srv_")).toBe(true); + + const idPath = path.join(home, "server-id"); + expect(existsSync(idPath)).toBe(true); + expect(readFileSync(idPath, "utf8").trim()).toBe(first); + }); + + it("respects and persists PASEO_SERVER_ID override", () => { + process.env.PASEO_SERVER_ID = "test-daemon-id"; + const id = getOrCreateServerId(home); + expect(id).toBe("test-daemon-id"); + + const idPath = path.join(home, "server-id"); + expect(existsSync(idPath)).toBe(true); + expect(readFileSync(idPath, "utf8").trim()).toBe("test-daemon-id"); + }); +}); + diff --git a/packages/server/src/server/server-id.ts b/packages/server/src/server/server-id.ts new file mode 100644 index 000000000..b36a1e8ca --- /dev/null +++ b/packages/server/src/server/server-id.ts @@ -0,0 +1,79 @@ +import path from "node:path"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; + +type LoggerLike = { + child(bindings: Record): LoggerLike; + info(...args: any[]): void; + warn(...args: any[]): void; +}; + +const SERVER_ID_FILENAME = "server-id"; + +function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { + return logger?.child({ module: "server-id" }); +} + +function getServerIdPath(paseoHome: string): string { + return path.join(paseoHome, SERVER_ID_FILENAME); +} + +function generateServerId(): string { + // 9 bytes -> 12 base64url chars; keep it short + URL-safe. + const rand = randomBytes(9).toString("base64url"); + return `srv_${rand}`; +} + +/** + * Stable daemon identifier scoped to a given $PASEO_HOME. + * + * - Persisted to `$PASEO_HOME/server-id` + * - Can be overridden via `PASEO_SERVER_ID` (useful for tests) + */ +export function getOrCreateServerId( + paseoHome: string, + options?: { env?: NodeJS.ProcessEnv; logger?: LoggerLike } +): string { + const env = options?.env ?? process.env; + const log = getLogger(options?.logger); + const serverIdPath = getServerIdPath(paseoHome); + + const envOverride = + typeof env.PASEO_SERVER_ID === "string" && env.PASEO_SERVER_ID.trim().length > 0 + ? env.PASEO_SERVER_ID.trim() + : null; + + if (envOverride) { + // Persist the override for consistent identity across restarts. + if (!existsSync(serverIdPath)) { + try { + writeFileSync(serverIdPath, `${envOverride}\n`, "utf8"); + log?.info({ serverId: envOverride }, "Persisted PASEO_SERVER_ID override"); + } catch (error) { + log?.warn({ error }, "Failed to persist PASEO_SERVER_ID override"); + } + } + return envOverride; + } + + if (existsSync(serverIdPath)) { + try { + const raw = readFileSync(serverIdPath, "utf8"); + const parsed = raw.trim(); + if (parsed.length > 0) { + return parsed; + } + } catch (error) { + log?.warn({ error }, "Failed to read server-id file, regenerating"); + } + } + + const created = generateServerId(); + try { + writeFileSync(serverIdPath, `${created}\n`, "utf8"); + } catch (error) { + log?.warn({ error }, "Failed to persist serverId (continuing with in-memory id)"); + } + return created; +} + diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index f830e5403..faadf882a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -31,6 +31,7 @@ export class VoiceAssistantWebSocketServer { constructor( server: HTTPServer, logger: pino.Logger, + serverId: string, agentManager: AgentManager, agentStorage: AgentStorage, downloadTokenStore: DownloadTokenStore, @@ -51,6 +52,7 @@ export class VoiceAssistantWebSocketServer { this.logger = logger.child({ module: "websocket-server" }); this.bridge = new WebSocketSessionBridge( this.logger, + serverId, agentManager, agentStorage, downloadTokenStore, diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts index f9eae2489..06cd515ca 100644 --- a/packages/server/src/server/websocket-session-bridge.ts +++ b/packages/server/src/server/websocket-session-bridge.ts @@ -26,6 +26,7 @@ export class WebSocketSessionBridge { private readonly logger: pino.Logger; private readonly sessions: Map = new Map(); private clientIdCounter = 0; + private readonly serverId: string; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly downloadTokenStore: DownloadTokenStore; @@ -48,6 +49,7 @@ export class WebSocketSessionBridge { constructor( logger: pino.Logger, + serverId: string, agentManager: AgentManager, agentStorage: AgentStorage, downloadTokenStore: DownloadTokenStore, @@ -65,6 +67,7 @@ export class WebSocketSessionBridge { } ) { this.logger = logger.child({ module: "websocket-session-bridge" }); + this.serverId = serverId; this.agentManager = agentManager; this.agentStorage = agentStorage; this.downloadTokenStore = downloadTokenStore; @@ -121,6 +124,18 @@ export class WebSocketSessionBridge { this.sessions.set(ws, session); + // Advertise stable server identity immediately on connect (used for URL/shareable IDs). + this.sendToClient( + ws, + wrapSessionMessage({ + type: "status", + payload: { + status: "server_info", + serverId: this.serverId, + }, + }) + ); + connectionLogger.info( { clientId, totalSessions: this.sessions.size }, "Client connected"