fix(opencode): harden session continuity fencing

This commit is contained in:
Brennan Benson
2026-08-19 16:57:54 -07:00
parent c9105ba4ba
commit 0eb19ada09
10 changed files with 276 additions and 10 deletions
@@ -609,6 +609,40 @@ describe('AgentHookServer ingestRemote', () => {
expect(listener).not.toHaveBeenCalled()
})
it('rejects non-string launchToken', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
launchToken: 123 as unknown as string,
payload: { state: 'working', prompt: 'p', agentType: 'opencode' }
},
'conn-1'
)
expect(listener).not.toHaveBeenCalled()
})
it('rejects malformed hookProcessId', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookProcessId: 'not-a-process-id',
payload: { state: 'working', prompt: 'p', agentType: 'opencode' }
},
'conn-1'
)
expect(listener).not.toHaveBeenCalled()
})
it('rejects empty paneKey after trim', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
@@ -4,6 +4,8 @@ import { AgentHookServer } from './server'
const PANE = makePaneKey('tab-opencode', '11111111-1111-4111-8111-111111111111')
const TARGET_PANE = makePaneKey('tab-opencode', '22222222-2222-4222-8222-222222222222')
const OLD_HOOK_PROCESS_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const FRESH_HOOK_PROCESS_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
describe('AgentHookServer OpenCode lifecycle', () => {
const servers: AgentHookServer[] = []
@@ -21,7 +23,8 @@ describe('AgentHookServer OpenCode lifecycle', () => {
payload: Record<string, unknown>,
launchToken: string,
paneKey?: string,
source?: 'opencode' | 'mimo-code'
source?: 'opencode' | 'mimo-code',
hookProcessId?: string
) => Promise<Response>
}> {
const server = new AgentHookServer()
@@ -30,7 +33,7 @@ describe('AgentHookServer OpenCode lifecycle', () => {
const env = server.buildPtyEnv()
return {
server,
post: (payload, launchToken, paneKey = PANE, source = 'opencode') =>
post: (payload, launchToken, paneKey = PANE, source = 'opencode', hookProcessId) =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, {
method: 'POST',
headers: {
@@ -40,6 +43,7 @@ describe('AgentHookServer OpenCode lifecycle', () => {
body: JSON.stringify({
paneKey,
launchToken,
hookProcessId,
tabId: 'tab-opencode',
worktreeId: 'wt-opencode',
env: 'production',
@@ -128,6 +132,59 @@ describe('AgentHookServer OpenCode lifecycle', () => {
])
})
it('fences a stale process without blocking an existing-session resume', async () => {
const { server, post } = await setup()
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale process' },
'shared-token',
PANE,
'opencode',
OLD_HOOK_PROCESS_ID
)
server.retirePaneAuthority(PANE)
await post(
{ hook_event_name: 'SessionStart', sessionID: 'fresh' },
'shared-token',
PANE,
'opencode',
FRESH_HOOK_PROCESS_ID
)
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'fresh' },
'shared-token',
PANE,
'opencode',
FRESH_HOOK_PROCESS_ID
)
await post(
{
hook_event_name: 'MessagePart',
role: 'user',
text: 'resume old',
messageID: 'resume',
sessionID: 'old'
},
'shared-token',
PANE,
'opencode',
FRESH_HOOK_PROCESS_ID
)
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale process' },
'shared-token',
PANE,
'opencode',
OLD_HOOK_PROCESS_ID
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
prompt: 'resume old',
providerSession: { key: 'session_id', id: 'old' }
})
])
})
it('clears the destination token fence when pane authority transfers', async () => {
const { server, post } = await setup()
await post(
+51 -4
View File
@@ -55,6 +55,7 @@ import {
} from '../../shared/claude-subagent-roster'
import {
isAgentHookSource,
normalizeAgentHookProcessId,
restoreShedStatusFields,
type AgentHookSource
} from '../../shared/agent-hook-relay'
@@ -123,6 +124,7 @@ type NormalizedLocalHook = {
type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
| 'claudeRunningNonAgentTask'
| 'hookProcessId'
| 'launchToken'
| 'promptInteractionKey'
| 'restoredUnconfirmed'
@@ -742,6 +744,7 @@ export class AgentHookServer {
private closedAgentStatusTabIds = new Set<string>()
private closedAgentStatusPaneKeys = new Set<string>()
private restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
private restartedStatusHookProcessIdByPaneKey = new Map<string, string>()
private connectionTimestampWatermarkById = new Map<string, number>()
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
private lastWrittenJson: string | null = null
@@ -1130,6 +1133,7 @@ export class AgentHookServer {
source?: AgentHookSource
hasExplicitPrompt?: boolean
launchToken?: string
hookProcessId?: string
}
): 'accept' | 'restart' | 'suppress' {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
@@ -1142,8 +1146,9 @@ export class AgentHookServer {
}
if (!paneRetired) {
const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey)
// Why: deferred retirement lets a new process start in a still-authorized pane, so
// its tokened SessionStart re-fences; prompts recur, so a stale process would win.
const processFence = this.restartedStatusHookProcessIdByPaneKey.get(ownerPaneKey)
// Why: SessionStart is the only process boundary safe to re-fence while the pane is live;
// prompts recur within one OpenCode process and cannot distinguish a resume from stale work.
if (
event?.hookEventName === 'SessionStart' &&
event.isReplay !== true &&
@@ -1155,9 +1160,21 @@ export class AgentHookServer {
ownerPaneKey,
createHash('sha256').update(startedLaunchToken).digest('hex')
)
const startedHookProcessId = normalizeAgentHookProcessId(event.hookProcessId)
if (startedHookProcessId) {
this.restartedStatusHookProcessIdByPaneKey.set(ownerPaneKey, startedHookProcessId)
} else {
this.restartedStatusHookProcessIdByPaneKey.delete(ownerPaneKey)
}
return 'accept'
}
}
if (event && processFence) {
const hookProcessId = normalizeAgentHookProcessId(event.hookProcessId)
if (hookProcessId !== processFence) {
return 'suppress'
}
}
if (event && tokenFence) {
const launchToken = event.launchToken?.trim()
if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) {
@@ -1190,6 +1207,12 @@ export class AgentHookServer {
} else {
this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey)
}
const hookProcessId = normalizeAgentHookProcessId(event.hookProcessId)
if (hookProcessId) {
this.restartedStatusHookProcessIdByPaneKey.set(ownerPaneKey, hookProcessId)
} else {
this.restartedStatusHookProcessIdByPaneKey.delete(ownerPaneKey)
}
return 'restart'
}
return 'suppress'
@@ -1854,6 +1877,13 @@ export class AgentHookServer {
if (restartedTokenHash) {
this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash)
}
const restartedHookProcessId =
this.restartedStatusHookProcessIdByPaneKey.get(previousOwnerPaneKey)
this.restartedStatusHookProcessIdByPaneKey.delete(previousOwnerPaneKey)
this.restartedStatusHookProcessIdByPaneKey.delete(toPaneKey)
if (restartedHookProcessId) {
this.restartedStatusHookProcessIdByPaneKey.set(toPaneKey, restartedHookProcessId)
}
const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey)
if (activeTurnCompletedAt !== undefined) {
this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey)
@@ -1915,6 +1945,7 @@ export class AgentHookServer {
for (const key of paneKeys) {
this.markPaneClosedForAgentStatus(key)
this.restartedStatusLaunchTokenHashByPaneKey.delete(key)
this.restartedStatusHookProcessIdByPaneKey.delete(key)
this.clearAssistantMessageRetry(key)
this.clearCodexSubagentPoll(key)
clearPaneCacheState(this.state, key)
@@ -2225,6 +2256,7 @@ export class AgentHookServer {
env?: string
version?: string
launchToken?: string
hookProcessId?: unknown
hasExplicitPrompt?: boolean
promptInteractionKey?: string
hookEventName?: string
@@ -2276,6 +2308,9 @@ export class AgentHookServer {
if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') {
return
}
if (envelope.launchToken !== undefined && typeof envelope.launchToken !== 'string') {
return
}
// Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined.
const reportedTabId =
envelope.tabId !== undefined && envelope.tabId.trim().length > 0
@@ -2301,12 +2336,17 @@ export class AgentHookServer {
(envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto')
? envelope.compactTrigger
: undefined
const hookProcessId = normalizeAgentHookProcessId(envelope.hookProcessId)
if (envelope.hookProcessId !== undefined && !hookProcessId) {
return
}
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
hookEventName,
isReplay: envelope.isReplay === true,
source,
hasExplicitPrompt: envelope.hasExplicitPrompt === true,
launchToken: envelope.launchToken
launchToken: envelope.launchToken,
hookProcessId
})
if (statusDisposition === 'suppress') {
return
@@ -2414,6 +2454,7 @@ export class AgentHookServer {
paneKey,
source,
launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken,
hookProcessId,
tabId,
worktreeId,
connectionId: trimmedConnectionId,
@@ -2527,7 +2568,8 @@ export class AgentHookServer {
isReplay: normalized.event.isReplay,
source: normalized.event.source,
hasExplicitPrompt: normalized.event.hasExplicitPrompt,
launchToken: normalized.event.launchToken
launchToken: normalized.event.launchToken,
hookProcessId: normalized.event.hookProcessId
})
: 'suppress'
if (normalized.event && statusDisposition !== 'suppress') {
@@ -2622,6 +2664,7 @@ export class AgentHookServer {
this.closedAgentStatusTabIds.clear()
this.closedAgentStatusPaneKeys.clear()
this.restartedStatusLaunchTokenHashByPaneKey.clear()
this.restartedStatusHookProcessIdByPaneKey.clear()
this.retiredPaneFencesByKey.clear()
this.connectionTimestampWatermarkById.clear()
this.legacyPaneKeyAliases.clear()
@@ -2800,6 +2843,7 @@ export class AgentHookServer {
this.currentAuthorityObservations.delete(paneKey)
this.promptSentDedupeByPaneKey.delete(paneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey)
this.restartedStatusHookProcessIdByPaneKey.delete(paneKey)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
@@ -2822,6 +2866,7 @@ export class AgentHookServer {
this.currentAuthorityObservations.delete(resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
this.restartedStatusHookProcessIdByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
@@ -2833,6 +2878,7 @@ export class AgentHookServer {
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
this.restartedStatusHookProcessIdByPaneKey.delete(legacyPaneKey)
clearedAlias = true
}
}
@@ -3182,6 +3228,7 @@ export class AgentHookServer {
const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true
const {
claudeRunningNonAgentTask: _claudeRunningNonAgentTask,
hookProcessId: _hookProcessId,
promptInteractionKey: _promptInteractionKey,
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
restoredUnconfirmed: _restoredUnconfirmed,
@@ -147,6 +147,31 @@ describe('OpenCode plugin lifecycle delivery', () => {
expect(posts).toEqual([{ hook_event_name: 'SessionStart', sessionID: 'root' }])
})
it('uses one hook process id across lifecycle posts', async () => {
const hookProcessIds: string[] = []
globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as {
hookProcessId: string
payload: RecordedPost
}
hookProcessIds.push(body.hookProcessId)
posts.push(body.payload)
return new Response(null, { status: 204 })
}) as typeof globalThis.fetch
const handler = await loadHandler()
await handler({
event: { type: 'session.created', properties: { info: { id: 'root' } } }
})
await handler({ event: status('busy') })
expect(hookProcessIds).toHaveLength(2)
expect(new Set(hookProcessIds).size).toBe(1)
expect(hookProcessIds[0]).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)
})
it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => {
let releaseFirstLookup: (() => void) | undefined
const firstLookup = new Promise<void>((resolve) => {
@@ -741,6 +766,56 @@ describe('OpenCode plugin lifecycle delivery', () => {
expect(names().at(-1)).toBe('SessionIdle')
})
it('keeps an active root busy when another root session is created', async () => {
const sessions = [{ id: 'root-a' }, { id: 'root-b' }]
let releaseLookup: (() => void) | undefined
const blockedLookup = new Promise<void>((resolve) => {
releaseLookup = resolve
})
const list = vi.fn(async () => {
await blockedLookup
return { data: sessions }
})
const handler = await loadHandler(list)
const busy = handler({ event: status('busy', 'root-a') })
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(1))
const created = handler({
event: { type: 'session.created', properties: { info: { id: 'root-b' } } }
})
releaseLookup?.()
await Promise.all([busy, created])
expect(names()).toEqual(['SessionBusy'])
})
it('drops an old queued preview before a new root boundary', async () => {
vi.useFakeTimers()
const hooks = await loadHooks(async () => ({ data: [{ id: 'root-a' }, { id: 'root-b' }] }))
await hooks.event({
event: {
type: 'message.updated',
properties: { sessionID: 'root-a', info: { id: 'assistant-message', role: 'assistant' } }
}
})
const part = (text: string): PluginEvent => ({
type: 'message.part.updated',
properties: {
sessionID: 'root-a',
part: { type: 'text', text, messageID: 'assistant-message' }
}
})
await hooks.event({ event: part('first preview') })
await hooks.event({ event: part('queued preview') })
await hooks.event({
event: { type: 'session.created', properties: { info: { id: 'root-b' } } }
})
await vi.advanceTimersByTimeAsync(1_000)
expect(names()).toEqual(['MessagePart', 'SessionStart'])
})
it('reasserts Busy through the refreshed endpoint after a live text delta', async () => {
const deliveries: { url: string; token: string | null }[] = []
globalThis.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
+8 -3
View File
@@ -51,6 +51,7 @@ export function getOpenCodeFamilyPluginSource(
"// OpenCode's Node process (not Orca's) and has no access to server.ts's",
'// equivalent warnedVersions / warnedEnvs Sets.',
'let warnedBadEndpoint = false;',
'const hookProcessId = globalThis.crypto.randomUUID();',
'',
'// Why: message.part.updated can fire many times per second during a',
'// streaming assistant reply, and each post() calls resolveHookCoords()',
@@ -390,6 +391,7 @@ export function getOpenCodeFamilyPluginSource(
' const body = JSON.stringify({',
' paneKey,',
' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",',
' hookProcessId,',
' tabId: process.env.ORCA_TAB_ID || "",',
' worktreeId: process.env.ORCA_WORKTREE_ID || "",',
' env: coords.env,',
@@ -884,9 +886,12 @@ export function getOpenCodeFamilyPluginSource(
' const info = event.properties?.info;',
' if (!info?.id || info.parentID) return;',
' rememberSessionRoot(info.id, info.id);',
' await enqueueLifecycle(() =>',
' disposed ? undefined : post("SessionStart", { sessionID: info.id })',
' );',
' await enqueueLifecycle(async () => {',
' if (disposed || currentAttention() || latestBusyOwner()) return;',
' stateArrivalRevision += 1;',
' await flushPendingAssistantPart();',
' return post("SessionStart", { sessionID: info.id });',
' });',
' return;',
' }',
''
+2
View File
@@ -1490,6 +1490,7 @@ export class SshRelaySession {
const envelope = params as {
paneKey?: unknown
launchToken?: unknown
hookProcessId?: unknown
tabId?: unknown
worktreeId?: unknown
env?: unknown
@@ -1519,6 +1520,7 @@ export class SshRelaySession {
{
paneKey: envelope.paneKey,
launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined,
hookProcessId: envelope.hookProcessId,
tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined,
worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined,
env: typeof envelope.env === 'string' ? envelope.env : undefined,
+1
View File
@@ -18,6 +18,7 @@ export function buildRelayHookEnvelope(
source,
paneKey: event.paneKey,
...(event.launchToken ? { launchToken: event.launchToken } : {}),
...(event.hookProcessId ? { hookProcessId: event.hookProcessId } : {}),
tabId: event.tabId,
worktreeId: event.worktreeId,
connectionId: null,
+25
View File
@@ -106,6 +106,31 @@ describe('RelayAgentHookServer', () => {
}
})
it('forwards a validated hook process id', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.getCoordinates()
await fetch(`http://127.0.0.1:${port}/hook/opencode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: PANE_KEY,
hookProcessId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
payload: { hook_event_name: 'SessionBusy', sessionID: 'session-1' }
})
})
expect(forward.mock.calls[0][0].hookProcessId).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
} finally {
server.stop()
}
})
it('rejects requests with the wrong bearer token (403)', async () => {
const forward = vi.fn()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
+9 -1
View File
@@ -55,7 +55,11 @@ import {
type CodexSubagentTranscriptState
} from './codex-subagent-transcript'
import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types'
import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay'
import {
normalizeAgentHookProcessId,
REMOTE_AGENT_HOOK_ENV,
type AgentHookSource
} from './agent-hook-relay'
import {
agentProviderSessionsEqual,
extractAgentProviderSession,
@@ -332,6 +336,8 @@ export type AgentHookEventPayload = {
source?: AgentHookSource
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
launchToken?: string
/** Process-lifetime identity generated by the managed hook plugin. */
hookProcessId?: string
tabId?: string
worktreeId?: string
/** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop
@@ -4406,6 +4412,7 @@ export function normalizeHookPayload(
}
const worktreeId = readStringField(record, 'worktreeId')
const launchToken = readStringField(record, 'launchToken')
const hookProcessId = normalizeAgentHookProcessId(record.hookProcessId)
const hookPayloadRecord = hookPayload as Record<string, unknown>
if (source === 'claude') {
@@ -4609,6 +4616,7 @@ export function normalizeHookPayload(
paneKey,
source,
launchToken,
hookProcessId,
tabId,
worktreeId,
// Why: normalization is transport-agnostic — only ingestRemote knows the mux identity to stamp here.
+12
View File
@@ -63,6 +63,16 @@ export function isAgentHookSource(value: unknown): value is AgentHookSource {
return typeof value === 'string' && AGENT_HOOK_SOURCE_SET.has(value)
}
export function normalizeAgentHookProcessId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(trimmed)
? trimmed
: undefined
}
/** Env marker used by the remote relay. It is a transport/location marker, not
* a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */
export const REMOTE_AGENT_HOOK_ENV = 'remote' as const
@@ -73,6 +83,8 @@ export type AgentHookRelayEnvelope = {
paneKey: string
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
launchToken?: string
/** Process-lifetime identity generated by the managed hook plugin. */
hookProcessId?: string
tabId?: string
worktreeId?: string
/** Always `null` on the wire — relay does not know Orca's local connectionId. */