fix(omp): validate remote hooks before reviving retired panes

Co-authored-by: euclodius <244837876+euclodius@users.noreply.github.com>
This commit is contained in:
Neil
2026-09-19 06:00:24 -07:00
co-authored by euclodius
parent b0ec11f5b0
commit f0460762b8
4 changed files with 139 additions and 25 deletions
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { PANE } from './server.test-fixtures'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
afterEach(() => vi.restoreAllMocks())
const working = { agentType: 'omp', state: 'working', prompt: 'new turn' }
describe('OMP retired-pane remote ingress', () => {
it('recovers a legacy source-less OMP new turn using validated payload identity', () => {
const server = new AgentHookServer()
server.retirePaneAuthority(PANE)
server.ingestRemote(
{ paneKey: PANE, hookEventName: 'before_agent_start', payload: working },
'ssh'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
observation: expect.objectContaining({ boundary: true })
})
])
})
it.each([
{ payload: { ...working, state: 'invalid' } },
{ payload: { ...working, agentType: 'claude' } },
{ isReplay: 'true' },
{ isReplay: null },
{ launchToken: 42 },
{ providerSessionOnly: true }
])('rejected metadata leaves retirement intact: %j', (invalid) => {
const server = new AgentHookServer()
server.retirePaneAuthority(PANE)
const input = {
paneKey: PANE,
source: 'omp',
hookEventName: 'before_agent_start',
payload: working,
...invalid
}
// Exercise raw JSON ingress, including malformed fields a typed caller cannot create.
server.ingestRemote(JSON.parse(JSON.stringify(input)), 'ssh')
expect(server.getStatusSnapshot()).toEqual([])
server.ingestRemote(
{
paneKey: PANE,
source: 'omp',
hookEventName: 'agent_end',
payload: { ...working, state: 'done' }
},
'ssh'
)
expect(server.getStatusSnapshot()).toEqual([])
})
it('a replay cannot restore a retired OMP pane', () => {
const server = new AgentHookServer()
server.retirePaneAuthority(PANE)
server.ingestRemote(
{ paneKey: PANE, hookEventName: 'before_agent_start', isReplay: true, payload: working },
'ssh'
)
expect(server.getStatusSnapshot()).toEqual([])
})
})
@@ -91,6 +91,12 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) {
return
}
if (
(envelope.isReplay !== undefined && typeof envelope.isReplay !== 'boolean') ||
(envelope.launchToken !== undefined && typeof envelope.launchToken !== 'string')
) {
return
}
// Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state.
if (envelope.isReplay === true) {
const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
@@ -136,23 +142,6 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
(envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto')
? envelope.compactTrigger
: undefined
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
source,
rawSource: envelope.source,
hookEventName,
isReplay: envelope.isReplay === true,
hasExplicitPrompt: envelope.hasExplicitPrompt === true,
launchToken: envelope.launchToken
})
if (statusDisposition === 'suppress') {
return
}
if (statusDisposition === 'restart') {
// Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session.
// Why paneKey, not envelope.paneKey: alias resolution already mapped it to the
// stable pane, so the rebind cannot land on a legacy key.
this.observations.rebind(paneKey)
}
const worktreeId =
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
? envelope.worktreeId.trim()
@@ -184,12 +173,46 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
if (!validatedPayload) {
return
}
if (
envelope.source !== undefined &&
(source === 'omp' || validatedPayload.agentType === 'omp') &&
envelope.source !== validatedPayload.agentType
) {
return
}
// Why: restore a shed roster only when its digest and turn identity still match the cache.
let normalizedPayload = restoreShedStatusFields(
validatedPayload,
envelope.shedFields,
this.state.lastStatusByPaneKey.get(paneKey)?.payload
)
if (
envelope.providerSessionOnly === true &&
!isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType)
) {
return
}
// Older relays omit source; canonical OMP identity preserves boundary provenance.
const effectiveSource =
source ??
(envelope.source === undefined && validatedPayload.agentType === 'omp' ? 'omp' : undefined)
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
source: effectiveSource,
rawSource: envelope.source,
hookEventName,
isReplay: envelope.isReplay === true,
hasExplicitPrompt: envelope.hasExplicitPrompt === true,
launchToken: envelope.launchToken
})
if (statusDisposition === 'suppress') {
return
}
if (statusDisposition === 'restart') {
// Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session.
// Why paneKey, not envelope.paneKey: alias resolution already mapped it to the
// stable pane, so the rebind cannot land on a legacy key.
this.observations.rebind(paneKey)
}
const previousStatus = this.state.lastStatusByPaneKey.get(paneKey)
let acceptedCompactCompletion = false
if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') {
@@ -249,12 +272,6 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
) {
normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt }
}
if (
envelope.providerSessionOnly === true &&
!isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType)
) {
return
}
const applyClaudeBackgroundWork =
normalizedPayload.agentType === 'claude' &&
typeof envelope.claudeRunningNonAgentTask === 'boolean' &&
@@ -268,7 +285,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
})
const event: AgentHookEventPayload = {
paneKey,
source,
source: effectiveSource,
launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken,
tabId,
worktreeId,
@@ -684,6 +684,31 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything())
})
it.each([{ isReplay: 'true' }, { isReplay: null }, { launchToken: 42 }])(
'rejects malformed OMP authority metadata before forwarding: %j',
async (invalid) => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
serverBuildId: 'test-relay-build',
platform: 'linux-x64'
})
session = createSession('conn-omp-invalid')
await session.establish({} as SshConnection)
const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote')
const envelope = makeEnvelope({
source: 'omp',
hookEventName: 'before_agent_start',
payload: { agentType: 'omp', state: 'working', prompt: 'new turn' }
})
relay.notifyAgentHook(JSON.parse(JSON.stringify({ ...envelope, ...invalid })))
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
expect(ingestSpy).not.toHaveBeenCalled()
ingestSpy.mockRestore()
}
)
it('preserves replay metadata from remote hook notifications', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
+5 -1
View File
@@ -1574,7 +1574,11 @@ export class SshRelaySession {
return
}
const envelope = params
if (typeof envelope.paneKey !== 'string') {
if (
typeof envelope.paneKey !== 'string' ||
(envelope.isReplay !== undefined && typeof envelope.isReplay !== 'boolean') ||
(envelope.launchToken !== undefined && typeof envelope.launchToken !== 'string')
) {
return
}
// Why: forward the agent CLI's env/version verbatim (not the relay's) so warn-once protocol-mismatch diagnostics fire for remote events too.