From 63a5fbc398a2d80d025215afe0afe2c7c1e1576d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:19:11 -0700 Subject: [PATCH] fix: harden relay hook cache hydration --- src/relay/agent-hook-server.test.ts | 69 ++++++++++++++++++++++++++++ src/relay/agent-hook-status-cache.ts | 57 +++++++++++++++++++++-- 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index e247706b5dc..0a2be085a68 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -366,6 +366,75 @@ describe('RelayAgentHookServer', () => { } }) + it('skips malformed durable entries while hydrating valid status and diagnostics', async () => { + writeFileSync( + join(dir, 'hook-status-cache.json'), + JSON.stringify({ + version: 1, + entries: [ + { event: { paneKey: PANE_KEY }, meta: { source: 'claude' } }, + { + event: { + paneKey: PANE_KEY, + payload: { state: 'working', prompt: 'missing agent type' } + }, + meta: { source: 'claude' } + }, + { + event: { + paneKey: PANE_KEY, + payload: null + }, + meta: { source: 'claude' } + }, + { + event: { + paneKey: PANE_KEY, + payload: { + state: 'working', + prompt: 'keep me after restart', + agentType: 'codex' + }, + reconcileDiagnostic: { + kind: 'unverifiable', + reason: 'transcript-unreadable', + observedAt: 123 + } + }, + meta: { source: 'codex', env: 'remote', version: '1' } + } + ] + }), + 'utf8' + ) + + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await expect(server.start()).resolves.toBeUndefined() + try { + expect(server.replayCachedPayloadsForPanes()).toBe(1) + expect(forward).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'codex', + env: 'remote', + version: '1', + payload: expect.objectContaining({ + prompt: 'keep me after restart', + agentType: 'codex' + }), + reconcileDiagnostic: { + kind: 'unverifiable', + reason: 'transcript-unreadable', + observedAt: 123 + }, + isReplay: true + }) + ) + } finally { + server.stop() + } + }) + it('forwards and replays Pi session identity as metadata-only', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-status-cache.ts b/src/relay/agent-hook-status-cache.ts index 0182dfd91d5..42fcfd82aa2 100644 --- a/src/relay/agent-hook-status-cache.ts +++ b/src/relay/agent-hook-status-cache.ts @@ -6,10 +6,16 @@ import { type AgentHookEventPayload, type HookListenerState } from '../shared/agent-hook-listener' -import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' +import { normalizeAgentStatusPayload } from '../shared/agent-status-types' +import { + isAgentHookSource, + type AgentHookRelayEnvelope, + type AgentHookSource +} from '../shared/agent-hook-relay' import { codexRosterToSnapshots } from '../shared/codex-subagent-roster' import { reconcileCodexSubagentTranscript } from '../shared/codex-subagent-transcript' import { buildRelayHookEnvelope } from './agent-hook-envelope-build' +import { parsePaneKey } from '../shared/stable-pane-id' export type RelayHookStatusMeta = { source: AgentHookSource; env?: string; version?: string } type PersistedCache = { @@ -22,6 +28,50 @@ const MAX_CACHED_PANES = 256 const MAX_RECONCILE_ATTEMPTS = 5 const RECONCILE_INTERVAL_MS = 1_000 +function sanitizeHydratedEntry( + rawEntry: unknown +): { event: AgentHookEventPayload; meta: RelayHookStatusMeta } | null { + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const entry = rawEntry as Record + if (typeof entry.event !== 'object' || entry.event === null) { + return null + } + if (typeof entry.meta !== 'object' || entry.meta === null) { + return null + } + const rawEvent = entry.event as Record + const paneKey = rawEvent.paneKey + if (typeof paneKey !== 'string' || !parsePaneKey(paneKey)) { + return null + } + const payload = normalizeAgentStatusPayload(rawEvent.payload) + // Every normalized hook event carries its source agent type. Without it, + // restart reconciliation and replay would operate on an untrusted shape. + if (!payload || typeof payload.agentType !== 'string' || payload.agentType.length === 0) { + return null + } + const rawMeta = entry.meta as Record + if (!isAgentHookSource(rawMeta.source)) { + return null + } + if ( + (rawMeta.env !== undefined && typeof rawMeta.env !== 'string') || + (rawMeta.version !== undefined && typeof rawMeta.version !== 'string') + ) { + return null + } + return { + event: { ...rawEvent, paneKey, payload } as AgentHookEventPayload, + meta: { + source: rawMeta.source, + env: typeof rawMeta.env === 'string' ? rawMeta.env : undefined, + version: typeof rawMeta.version === 'string' ? rawMeta.version : undefined + } + } +} + export function applyRelayHookEvent(options: { state: HookListenerState event: AgentHookEventPayload @@ -107,8 +157,9 @@ export function hydrateRelayHookStatusCache( if (cache.version !== CACHE_VERSION || !Array.isArray(cache.entries)) { return metadata } - for (const entry of cache.entries.slice(-MAX_CACHED_PANES)) { - if (!entry?.event || !entry.meta || typeof entry.event.paneKey !== 'string') { + for (const rawEntry of cache.entries.slice(-MAX_CACHED_PANES)) { + const entry = sanitizeHydratedEntry(rawEntry) + if (!entry) { continue } const event =