From fa21d29fcd79204681c6d547e3b6506582921b64 Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Fri, 19 Jun 2026 21:32:28 +0000 Subject: [PATCH] Extract local IPC request handler seam --- apps/desktop/src/check-packaging-contract.ts | 7 +- apps/desktop/src/codemap.md | 6 +- .../desktop/src/local-ipc-request-handlers.ts | 320 ++++++++++++++++++ apps/desktop/src/local-ipc.ts | 202 ++--------- .../tests/local-ipc-request-handlers.test.ts | 53 +++ apps/desktop/tests/local-ipc-socket.test.ts | 3 + 6 files changed, 408 insertions(+), 183 deletions(-) create mode 100644 apps/desktop/src/local-ipc-request-handlers.ts create mode 100644 apps/desktop/tests/local-ipc-request-handlers.test.ts diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 8936d208..e894217a 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -236,6 +236,7 @@ const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8"); const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8"); const localIpcSourceForLogging = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8"); const localIpcSocketSource = readFileSync(join(appDir, "src", "local-ipc-socket.ts"), "utf8"); +const localIpcRequestHandlersSource = readFileSync(join(appDir, "src", "local-ipc-request-handlers.ts"), "utf8"); const localIpcPathsSource = readFileSync(join(appDir, "src", "local-ipc-paths.ts"), "utf8"); const leaseManagerSource = readFileSync(join(appDir, "src", "lease-manager.ts"), "utf8"); const defaultPetControllerSource = readFileSync(join(appDir, "src", "default-familiar-controller.ts"), "utf8"); @@ -447,11 +448,15 @@ assert.match(agentPetControllerSource, /dismissedAgentPets\.has\(petId\)/, "dism assert.match(agentPetControllerSource, /function clearAgentPetLeaseState/, "agent familiar lease cleanup must clear dismissal, timers, and hidden transient state."); assert.match(localIpcSource, /handleLastExplicitLease/, "agent familiar dismissal must clear when the explicit lease group ends."); assert.match(localIpcSource, /from "\.\/local-ipc-socket(?:\.js)?"/, "local-ipc must compose the extracted socket helper seam."); +assert.match(localIpcSource, /from "\.\/local-ipc-request-handlers(?:\.js)?"/, "local-ipc must compose the extracted request-handler seam."); assert.match(localIpcSocketSource, /export function handleLocalIpcSocket/, "local-ipc socket seam must export the client socket handler."); assert.match(localIpcSocketSource, /export function getLocalIpcListeningEndpoint/, "local-ipc socket seam must export advertised endpoint resolution."); assert.match(localIpcSocketSource, /export function isAllowedRemoteAddress/, "local-ipc socket seam must export remote-address filtering."); +assert.match(localIpcRequestHandlersSource, /export async function handleLocalIpcRequest/, "local-ipc request-handler seam must export the async dispatcher."); +assert.match(localIpcRequestHandlersSource, /export function buildHelloResponse/, "local-ipc request-handler seam must export the hello response builder."); +assert.match(localIpcRequestHandlersSource, /export function buildFamiliarsListResponse/, "local-ipc request-handler seam must export familiar list response shaping."); assert.match(localIpcSource, /clearAgentPetLeaseState\(petId\)/, "last explicit lease cleanup must reset dismissed agent familiar state."); -assert.match(localIpcSource, /reason: applied\.reason/, "IPC responses must report dismissed explicit familiar events as not shown."); +assert.match(localIpcRequestHandlersSource, /reason: applied\.reason/, "IPC responses must report dismissed explicit familiar events as not shown."); assert.match(updateCheckerSource, /alvinunreal\/familiaros/, "GitHub release notice must check the public FamiliarOS repository."); assert.match(updateCheckerSource, /api\.github\.com\/repos\/\$\{githubRepository\}\/releases\/latest/, "update checker must use GitHub latest release API."); assert.match(updateCheckerSource, /shell\.openExternal\(url\)/, "update action must open the GitHub release page externally."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 468f5a59..e8e111fa 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -27,13 +27,14 @@ main.ts ├── plugin-service.ts (plugin state/runtime init, JS host wiring) ├── tray.ts (tray creation) ├── local-ipc.ts (IPC server start) +│ ├── local-ipc-request-handlers.ts (protocol request dispatch, familiar actions, lease routes, and memory CRUD shaping) │ └── local-ipc-socket.ts (socket accept loop, advertised endpoint resolution, and remote address filtering) └── windows.ts (UI handlers) ``` **IPC Request Flow**: ``` -local-ipc.ts → parseIpcRequest() → handleRequest() +local-ipc.ts → parseIpcRequest() → handleLocalIpcRequest() ├── hello/status/familiars.list/familiars.install └── lease.acquire/heartbeat/release └── lease-manager.ts @@ -280,7 +281,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `reaction-animation-mapping.ts`: Reaction-to-animation state mapping, user-configurable overrides, sprite state definitions **IPC**: -- `local-ipc.ts`: net.Server lifecycle, request routing, discovery file management, and logging +- `local-ipc.ts`: net.Server lifecycle, discovery file management, lease wiring, and request-entry logging +- `local-ipc-request-handlers.ts`: Extracted local IPC protocol request dispatch, status shaping, familiar actions, and memory CRUD handlers - `local-ipc-socket.ts`: Extracted socket accept loop, advertised endpoint resolution, and loopback/private address filtering - `local-ipc-protocol.ts`: Protocol constants, request/response types, validation functions - `local-ipc-paths.ts`: Platform-specific socket paths and discovery file locations diff --git a/apps/desktop/src/local-ipc-request-handlers.ts b/apps/desktop/src/local-ipc-request-handlers.ts new file mode 100644 index 00000000..893866b1 --- /dev/null +++ b/apps/desktop/src/local-ipc-request-handlers.ts @@ -0,0 +1,320 @@ +import { applyAgentPetReaction, applyAgentPetSay } from "./agent-familiar-controller.js"; +import type { FamiliarOSActivityRecord } from "./app-state.js"; +import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-familiar-controller.js"; +import { forgetFamiliarOSMemory, listFamiliarOSMemories, searchFamiliarOSMemories, storeFamiliarOSMemory } from "./familiaros-memory.js"; +import { installPet } from "./familiar-installation.js"; +import { createStaleLeaseStatus, type LeaseManager, type LeaseSnapshot } from "./lease-manager.js"; +import { + IpcProtocolError, + isRecord, + validateInstallPetId, + validateMemoryId, + validateMemoryImportance, + validateMemoryLimit, + validateMemoryQuery, + validateMemoryTags, + validateMemoryText, + validateOptionalMemoryKind, + validateOptionalLeaseId, + validateReaction, + validateRequestedPetId, + validateSayMessage, + type FamiliarOSIpcRequest, +} from "./local-ipc-protocol.js"; + +interface InstalledFamiliarLike { + readonly id: string; + readonly displayName: string; + readonly builtIn: boolean; + readonly broken?: boolean; +} + +interface AppStateSnapshotLike { + readonly familiars: { + readonly installed: readonly InstalledFamiliarLike[]; + }; + readonly preferences: { + readonly defaultPetId: string; + readonly openDefaultPetOnLaunch: boolean; + readonly speechBubblesEnabled: boolean; + }; +} + +interface DefaultPetLike extends InstalledFamiliarLike {} + +export interface LocalIpcRequestDependencies { + readonly appVersion: string; + readonly getAppStateSnapshot: () => AppStateSnapshotLike; + readonly getCurrentDefaultPet: () => { readonly id: string; readonly displayName: string }; + readonly getCurrentDefaultPetWithFallback: () => DefaultPetLike; + readonly getLeaseTarget: (value: unknown) => LeaseSnapshot | null; + readonly leaseManager: Pick; + readonly safeRecordFamiliarOSActivity: (activity: FamiliarOSActivityRecord) => void; + readonly validateRequiredLeaseId: (value: unknown) => string; + readonly onDebug: (message: string, fields: Record) => void; +} + +export function buildHelloResponse(appVersion: string) { + return { + ok: true, + protocol: "familiaros-ipc", + protocolVersion: 1, + appVersion, + } as const; +} + +export function buildFamiliarsListResponse(state: AppStateSnapshotLike) { + return { + ok: true, + familiars: state.familiars.installed.map((familiar) => ({ + id: familiar.id, + displayName: familiar.displayName, + builtIn: familiar.builtIn, + broken: familiar.broken === true, + })), + defaultPetId: state.preferences.defaultPetId, + } as const; +} + +export async function handleLocalIpcRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +): Promise { + if (request.method === "hello") { + return buildHelloResponse(dependencies.appVersion); + } + if (request.method === "status") { + return handleStatusRequest(request, dependencies); + } + if (request.method === "familiars.list") { + return buildFamiliarsListResponse(dependencies.getAppStateSnapshot()); + } + if (request.method === "familiars.install") { + return handleFamiliarInstallRequest(request); + } + if (request.method === "lease.acquire") { + return handleLeaseAcquireRequest(request, dependencies); + } + if (request.method === "lease.heartbeat") { + return handleLeaseHeartbeatRequest(request, dependencies); + } + if (request.method === "lease.release") { + return handleLeaseReleaseRequest(request, dependencies); + } + if (request.method === "memory.list") { + return handleMemoryListRequest(request, dependencies); + } + if (request.method === "memory.search") { + return handleMemorySearchRequest(request, dependencies); + } + if (request.method === "memory.store") { + return handleMemoryStoreRequest(request, dependencies); + } + if (request.method === "memory.delete") { + return handleMemoryDeleteRequest(request, dependencies); + } + if (request.method === "familiar.react") { + return handleFamiliarReactionRequest(request, dependencies); + } + return handleFamiliarSayRequest(request, dependencies); +} + +function handleStatusRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const leaseId = validateOptionalLeaseId(params.leaseId); + if (leaseId) { + const lease = dependencies.leaseManager.get(leaseId); + if (!lease) return createStaleLeaseStatus(leaseId); + return { ok: true, appRunning: true, ...lease }; + } + + const state = dependencies.getAppStateSnapshot(); + const defaultPet = dependencies.getCurrentDefaultPetWithFallback(); + return { + ok: true, + appRunning: true, + protocolVersion: 1, + appVersion: dependencies.appVersion, + defaultPet: { + id: defaultPet.id, + displayName: defaultPet.displayName, + builtIn: defaultPet.builtIn, + broken: defaultPet.broken === true, + }, + paused: getDefaultPetPaused(), + defaultPetVisible: isDefaultPetVisible(), + openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch, + speechBubblesEnabled: state.preferences.speechBubblesEnabled, + }; +} + +async function handleFamiliarInstallRequest( + request: FamiliarOSIpcRequest, +): Promise<{ readonly ok: true; readonly petId: string; readonly displayName: string; readonly installed: true }> { + const params = isRecord(request.params) ? request.params : {}; + const petId = validateInstallPetId(params.petId); + const state = await installPet(petId); + const installed = state.familiars.installed.find((familiar) => familiar.id === petId); + if (!installed) throw new IpcProtocolError("install_failed", "Familiar install did not complete."); + return { ok: true, petId: installed.id, displayName: installed.displayName, installed: true }; +} + +function handleLeaseAcquireRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const requestedPetId = validateRequestedPetId(params.requestedPetId); + dependencies.onDebug("lease acquire requested", { requestId: request.id, requestedPetId }); + return dependencies.leaseManager.acquire(requestedPetId); +} + +function handleLeaseHeartbeatRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const leaseId = dependencies.validateRequiredLeaseId(params.leaseId); + dependencies.onDebug("lease heartbeat requested", { requestId: request.id, leaseId }); + try { + return dependencies.leaseManager.heartbeat(leaseId); + } catch { + throw new IpcProtocolError("unknown_lease", "Unknown or expired lease."); + } +} + +function handleLeaseReleaseRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const leaseId = dependencies.validateRequiredLeaseId(params.leaseId); + dependencies.onDebug("lease release requested", { requestId: request.id, leaseId }); + return dependencies.leaseManager.release(leaseId); +} + +function handleMemoryListRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const limit = validateMemoryLimit(params.limit, 12, 25); + dependencies.onDebug("memory list requested", { requestId: request.id, limit }); + return { + ok: true, + memories: listFamiliarOSMemories(limit), + }; +} + +function handleMemorySearchRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const query = validateMemoryQuery(params.query); + const limit = validateMemoryLimit(params.limit, 8, 12); + dependencies.onDebug("memory search requested", { requestId: request.id, limit }); + return { + ok: true, + query, + memories: searchFamiliarOSMemories(query, limit), + }; +} + +function handleMemoryStoreRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const text = validateMemoryText(params.text); + const kind = validateOptionalMemoryKind(params.kind); + const tags = validateMemoryTags(params.tags); + const importance = validateMemoryImportance(params.importance); + dependencies.onDebug("memory store requested", { + requestId: request.id, + kind, + hasTags: Array.isArray(tags) && tags.length > 0, + importance, + }); + return { + ok: true, + memory: storeFamiliarOSMemory({ + text, + kind, + tags, + importance, + source: "mcp", + }), + }; +} + +function handleMemoryDeleteRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const id = validateMemoryId(params.id); + dependencies.onDebug("memory delete requested", { requestId: request.id, id }); + return { + ok: true, + deleted: forgetFamiliarOSMemory(id), + id, + }; +} + +function handleFamiliarReactionRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const reaction = validateReaction(params.reaction); + const lease = dependencies.getLeaseTarget(params.leaseId); + const petId = lease?.actualTargetPetId ?? dependencies.getCurrentDefaultPet().id; + dependencies.onDebug("familiar react requested", { + requestId: request.id, + reaction, + leaseId: lease?.leaseId, + targetKind: lease?.targetKind, + actualPetId: lease?.actualTargetPetId, + }); + if (lease?.targetKind === "explicit") { + if (getDefaultPetPaused()) return { ok: true, reaction, shown: false, reason: "paused", leaseId: lease.leaseId }; + const applied = applyAgentPetReaction(lease.actualTargetPetId, reaction); + dependencies.safeRecordFamiliarOSActivity({ kind: "react", reaction, petId }); + return { ok: true, reaction, shown: applied.shown, reason: applied.reason, leaseId: lease.leaseId }; + } + const applied = applyExternalPetReaction(reaction); + dependencies.safeRecordFamiliarOSActivity({ kind: "react", reaction, petId }); + return { ok: true, reaction, shown: applied.shown, reason: applied.reason }; +} + +function handleFamiliarSayRequest( + request: FamiliarOSIpcRequest, + dependencies: LocalIpcRequestDependencies, +) { + const params = isRecord(request.params) ? request.params : {}; + const message = validateSayMessage(params.message); + const reaction = params.reaction === undefined ? undefined : validateReaction(params.reaction); + const lease = dependencies.getLeaseTarget(params.leaseId); + const petId = lease?.actualTargetPetId ?? dependencies.getCurrentDefaultPet().id; + dependencies.onDebug("familiar say requested", { + requestId: request.id, + reaction, + messageLength: message.length, + leaseId: lease?.leaseId, + targetKind: lease?.targetKind, + actualPetId: lease?.actualTargetPetId, + }); + if (lease?.targetKind === "explicit") { + if (getDefaultPetPaused()) return { ok: true, shown: false, reason: "paused", reaction, leaseId: lease.leaseId }; + const applied = applyAgentPetSay(lease.actualTargetPetId, message, reaction); + dependencies.safeRecordFamiliarOSActivity({ kind: "say", reaction, petId }); + return { ok: true, shown: applied.shown, reason: applied.reason, reaction, leaseId: lease.leaseId }; + } + const applied = applyExternalPetSay(message, reaction); + dependencies.safeRecordFamiliarOSActivity({ kind: "say", reaction, petId }); + return { ok: true, shown: applied.shown, reason: applied.reason, reaction }; +} diff --git a/apps/desktop/src/local-ipc.ts b/apps/desktop/src/local-ipc.ts index b7d52ba7..b65ac6bb 100644 --- a/apps/desktop/src/local-ipc.ts +++ b/apps/desktop/src/local-ipc.ts @@ -1,17 +1,15 @@ import { randomBytes } from "node:crypto"; import net from "node:net"; -import { applyAgentPetReaction, applyAgentPetSay, clearAgentPetLeaseState, showAgentPet } from "./agent-familiar-controller.js"; +import { clearAgentPetLeaseState, showAgentPet } from "./agent-familiar-controller.js"; import { getAppStateSnapshot, recordFamiliarOSActivity } from "./app-state.js"; import { builtInPet } from "./built-in-familiar.js"; -import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-familiar-controller.js"; -import { createStaleLeaseStatus, LeaseManager } from "./lease-manager.js"; +import { LeaseManager } from "./lease-manager.js"; import { debug, error as logError, info } from "./logger.js"; import { formatIpcEndpoint, getLocalIpcListeningEndpoint, handleLocalIpcSocket, listenOnIpcEndpoint } from "./local-ipc-socket.js"; import { cleanupUnixSocket, getDiscoveryFilePath, getIpcEndpointConfig, protectUnixSocket, removeDiscoveryFile, writeDiscoveryFile, type FamiliarOSDiscoveryFile } from "./local-ipc-paths.js"; -import { errorResponse, IpcProtocolError, isRecord, okResponse, parseIpcRequest, validateInstallPetId, validateMemoryId, validateMemoryImportance, validateMemoryLimit, validateMemoryQuery, validateMemoryTags, validateMemoryText, validateOptionalLeaseId, validateOptionalMemoryKind, validateReaction, validateRequestedPetId, validateSayMessage, type FamiliarOSIpcRequest } from "./local-ipc-protocol.js"; -import { forgetFamiliarOSMemory, listFamiliarOSMemories, searchFamiliarOSMemories, storeFamiliarOSMemory } from "./familiaros-memory.js"; -import { installPet } from "./familiar-installation.js"; +import { errorResponse, IpcProtocolError, okResponse, parseIpcRequest, validateOptionalLeaseId } from "./local-ipc-protocol.js"; +import { handleLocalIpcRequest } from "./local-ipc-request-handlers.js"; let ipcServer: net.Server | null = null; let ipcDiscovery: FamiliarOSDiscoveryFile | null = null; @@ -98,185 +96,23 @@ async function handleRawRequest(raw: string, token: string) { const request = parseIpcRequest(raw, token); requestId = request.id; debug("ipc", "request received", { requestId, method: request.method }); - return okResponse(request.id, await handleRequest(request)); + return okResponse(request.id, await handleLocalIpcRequest(request, { + appVersion: ipcDiscovery?.appVersion ?? "0.0.0", + getAppStateSnapshot, + getCurrentDefaultPet, + getCurrentDefaultPetWithFallback, + getLeaseTarget, + leaseManager, + safeRecordFamiliarOSActivity, + validateRequiredLeaseId, + onDebug: (message, fields) => debug("ipc", message, fields), + })); } catch (error) { logError("ipc", "request failed", error instanceof Error ? error : { requestId, error }); return errorResponse(requestId, error); } } -async function handleRequest(request: FamiliarOSIpcRequest): Promise { - if (request.method === "hello") { - return { - ok: true, - protocol: "familiaros-ipc", - protocolVersion: 1, - appVersion: ipcDiscovery?.appVersion ?? "0.0.0", - }; - } - - if (request.method === "status") { - const params = isRecord(request.params) ? request.params : {}; - const leaseId = validateOptionalLeaseId(params.leaseId); - if (leaseId) { - const lease = leaseManager.get(leaseId); - if (!lease) return createStaleLeaseStatus(leaseId); - return { ok: true, appRunning: true, ...lease }; - } - const state = getAppStateSnapshot(); - const defaultPet = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId) ?? builtInPet; - return { - ok: true, - appRunning: true, - protocolVersion: 1, - appVersion: ipcDiscovery?.appVersion ?? "0.0.0", - defaultPet: { - id: defaultPet.id, - displayName: defaultPet.displayName, - builtIn: defaultPet.builtIn, - broken: "broken" in defaultPet && defaultPet.broken === true, - }, - paused: getDefaultPetPaused(), - defaultPetVisible: isDefaultPetVisible(), - openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch, - speechBubblesEnabled: state.preferences.speechBubblesEnabled, - }; - } - - if (request.method === "familiars.list") { - const state = getAppStateSnapshot(); - return { - ok: true, - familiars: state.familiars.installed.map((familiar) => ({ - id: familiar.id, - displayName: familiar.displayName, - builtIn: familiar.builtIn, - broken: familiar.broken === true, - })), - defaultPetId: state.preferences.defaultPetId, - }; - } - - if (request.method === "familiars.install") { - const params = isRecord(request.params) ? request.params : {}; - const petId = validateInstallPetId(params.petId); - const state = await installPet(petId); - const installed = state.familiars.installed.find((familiar) => familiar.id === petId); - if (!installed) throw new IpcProtocolError("install_failed", "Familiar install did not complete."); - return { ok: true, petId: installed.id, displayName: installed.displayName, installed: true }; - } - - if (request.method === "lease.acquire") { - const params = isRecord(request.params) ? request.params : {}; - const requestedPetId = validateRequestedPetId(params.requestedPetId); - debug("ipc", "lease acquire requested", { requestId: request.id, requestedPetId }); - return leaseManager.acquire(requestedPetId); - } - - if (request.method === "lease.heartbeat") { - const params = isRecord(request.params) ? request.params : {}; - const leaseId = validateRequiredLeaseId(params.leaseId); - debug("ipc", "lease heartbeat requested", { requestId: request.id, leaseId }); - try { - return leaseManager.heartbeat(leaseId); - } catch { - throw new IpcProtocolError("unknown_lease", "Unknown or expired lease."); - } - } - - if (request.method === "lease.release") { - const params = isRecord(request.params) ? request.params : {}; - const leaseId = validateRequiredLeaseId(params.leaseId); - debug("ipc", "lease release requested", { requestId: request.id, leaseId }); - return leaseManager.release(leaseId); - } - - if (request.method === "memory.list") { - const params = isRecord(request.params) ? request.params : {}; - const limit = validateMemoryLimit(params.limit, 12, 25); - debug("ipc", "memory list requested", { requestId: request.id, limit }); - return { - ok: true, - memories: listFamiliarOSMemories(limit), - }; - } - - if (request.method === "memory.search") { - const params = isRecord(request.params) ? request.params : {}; - const query = validateMemoryQuery(params.query); - const limit = validateMemoryLimit(params.limit, 8, 12); - debug("ipc", "memory search requested", { requestId: request.id, limit }); - return { - ok: true, - query, - memories: searchFamiliarOSMemories(query, limit), - }; - } - - if (request.method === "memory.store") { - const params = isRecord(request.params) ? request.params : {}; - const text = validateMemoryText(params.text); - const kind = validateOptionalMemoryKind(params.kind); - const tags = validateMemoryTags(params.tags); - const importance = validateMemoryImportance(params.importance); - debug("ipc", "memory store requested", { requestId: request.id, kind, hasTags: Array.isArray(tags) && tags.length > 0, importance }); - return { - ok: true, - memory: storeFamiliarOSMemory({ - text, - kind, - tags, - importance, - source: "mcp", - }), - }; - } - - if (request.method === "memory.delete") { - const params = isRecord(request.params) ? request.params : {}; - const id = validateMemoryId(params.id); - debug("ipc", "memory delete requested", { requestId: request.id, id }); - return { - ok: true, - deleted: forgetFamiliarOSMemory(id), - id, - }; - } - - if (request.method === "familiar.react") { - const params = isRecord(request.params) ? request.params : {}; - const reaction = validateReaction(params.reaction); - const lease = getLeaseTarget(params.leaseId); - const petId = lease?.actualTargetPetId ?? getCurrentDefaultPet().id; - debug("ipc", "familiar react requested", { requestId: request.id, reaction, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId }); - if (lease?.targetKind === "explicit") { - if (getDefaultPetPaused()) return { ok: true, reaction, shown: false, reason: "paused", leaseId: lease.leaseId }; - const applied = applyAgentPetReaction(lease.actualTargetPetId, reaction); - safeRecordFamiliarOSActivity({ kind: "react", reaction, petId }); - return { ok: true, reaction, shown: applied.shown, reason: applied.reason, leaseId: lease.leaseId }; - } - const applied = applyExternalPetReaction(reaction); - safeRecordFamiliarOSActivity({ kind: "react", reaction, petId }); - return { ok: true, reaction, shown: applied.shown, reason: applied.reason }; - } - - const params = isRecord(request.params) ? request.params : {}; - const message = validateSayMessage(params.message); - const reaction = params.reaction === undefined ? undefined : validateReaction(params.reaction); - const lease = getLeaseTarget(params.leaseId); - const petId = lease?.actualTargetPetId ?? getCurrentDefaultPet().id; - debug("ipc", "familiar say requested", { requestId: request.id, reaction, messageLength: message.length, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId }); - if (lease?.targetKind === "explicit") { - if (getDefaultPetPaused()) return { ok: true, shown: false, reason: "paused", reaction, leaseId: lease.leaseId }; - const applied = applyAgentPetSay(lease.actualTargetPetId, message, reaction); - safeRecordFamiliarOSActivity({ kind: "say", reaction, petId }); - return { ok: true, shown: applied.shown, reason: applied.reason, reaction, leaseId: lease.leaseId }; - } - const applied = applyExternalPetSay(message, reaction); - safeRecordFamiliarOSActivity({ kind: "say", reaction, petId }); - return { ok: true, shown: applied.shown, reason: applied.reason, reaction }; -} - function safeRecordFamiliarOSActivity(activity: Parameters[0]): void { try { recordFamiliarOSActivity(activity); @@ -323,7 +159,13 @@ function getCurrentDefaultPet(): { readonly id: string; readonly displayName: st return { id: familiar.id, displayName: familiar.displayName }; } -function getCurrentDefaultPetWithFallback(): { readonly id: string; readonly displayName: string; readonly fallbackReason?: "default_broken_fallback_builtin" } { +function getCurrentDefaultPetWithFallback(): { + readonly id: string; + readonly displayName: string; + readonly builtIn: boolean; + readonly broken?: boolean; + readonly fallbackReason?: "default_broken_fallback_builtin"; +} { const state = getAppStateSnapshot(); const configuredDefault = state.familiars.installed.find((familiar) => familiar.id === state.preferences.defaultPetId); if (configuredDefault && !configuredDefault.broken) return configuredDefault; diff --git a/apps/desktop/tests/local-ipc-request-handlers.test.ts b/apps/desktop/tests/local-ipc-request-handlers.test.ts new file mode 100644 index 00000000..55dce9a8 --- /dev/null +++ b/apps/desktop/tests/local-ipc-request-handlers.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildFamiliarsListResponse, buildHelloResponse } from "../src/local-ipc-request-handlers.js"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const localIpcSource = readFileSync(resolve(desktopRoot, "src/local-ipc.ts"), "utf8"); +const localIpcRequestHandlersSource = readFileSync(resolve(desktopRoot, "src/local-ipc-request-handlers.ts"), "utf8"); + +assert.match(localIpcSource, /from "\.\/local-ipc-request-handlers(?:\.js)?"/, "local-ipc must compose the extracted request-handler seam."); +assert.match(localIpcRequestHandlersSource, /export async function handleLocalIpcRequest/, "local-ipc request-handler seam must export the async dispatcher."); +assert.match(localIpcRequestHandlersSource, /export function buildHelloResponse/, "local-ipc request-handler seam must export the hello response builder."); +assert.match(localIpcRequestHandlersSource, /export function buildFamiliarsListResponse/, "local-ipc request-handler seam must export familiar list response shaping."); + +assert.deepEqual( + buildHelloResponse("1.2.3"), + { + ok: true, + protocol: "familiaros-ipc", + protocolVersion: 1, + appVersion: "1.2.3", + }, + "local-ipc hello responses must preserve protocol and app version metadata.", +); + +assert.deepEqual( + buildFamiliarsListResponse({ + familiars: { + installed: [ + { id: "built-in", displayName: "Built In", builtIn: true }, + { id: "fox", displayName: "Fox", builtIn: false, broken: true }, + ], + }, + preferences: { + defaultPetId: "fox", + openDefaultPetOnLaunch: true, + speechBubblesEnabled: true, + }, + }), + { + ok: true, + familiars: [ + { id: "built-in", displayName: "Built In", builtIn: true, broken: false }, + { id: "fox", displayName: "Fox", builtIn: false, broken: true }, + ], + defaultPetId: "fox", + }, + "local-ipc familiar list responses must normalize broken flags for each installed familiar.", +); + +console.error("Local IPC request-handler seam validation passed."); diff --git a/apps/desktop/tests/local-ipc-socket.test.ts b/apps/desktop/tests/local-ipc-socket.test.ts index 88aa9d77..0dca01e3 100644 --- a/apps/desktop/tests/local-ipc-socket.test.ts +++ b/apps/desktop/tests/local-ipc-socket.test.ts @@ -7,12 +7,15 @@ import { getLocalIpcListeningEndpoint, isAllowedRemoteAddress } from "../src/loc const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); const localIpcSource = readFileSync(resolve(desktopRoot, "src/local-ipc.ts"), "utf8"); +const localIpcRequestHandlersSource = readFileSync(resolve(desktopRoot, "src/local-ipc-request-handlers.ts"), "utf8"); const localIpcSocketSource = readFileSync(resolve(desktopRoot, "src/local-ipc-socket.ts"), "utf8"); assert.match(localIpcSource, /from "\.\/local-ipc-socket(?:\.js)?"/, "local-ipc must compose the extracted socket helper seam."); +assert.match(localIpcSource, /from "\.\/local-ipc-request-handlers(?:\.js)?"/, "local-ipc must compose the extracted request-handler seam."); assert.match(localIpcSocketSource, /export function handleLocalIpcSocket/, "local-ipc socket seam must export the client socket handler."); assert.match(localIpcSocketSource, /export function getLocalIpcListeningEndpoint/, "local-ipc socket seam must export advertised endpoint resolution."); assert.match(localIpcSocketSource, /export function isAllowedRemoteAddress/, "local-ipc socket seam must export remote-address filtering."); +assert.match(localIpcRequestHandlersSource, /export async function handleLocalIpcRequest/, "local-ipc request-handler seam must export the async dispatcher."); assert.equal(isAllowedRemoteAddress("127.0.0.1", "127.0.0.1"), true, "loopback clients must always be allowed."); assert.equal(isAllowedRemoteAddress("::ffff:127.0.0.1", "127.0.0.1"), true, "IPv4-mapped loopback clients must be allowed.");