fix: harden relay hook cache hydration

This commit is contained in:
Brennan Benson
2026-08-27 06:25:13 -07:00
parent a93bfd06c3
commit 63a5fbc398
2 changed files with 123 additions and 3 deletions
+69
View File
@@ -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 })
+54 -3
View File
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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 =