mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
fix(opencode): harden session continuity fencing
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;',
|
||||
' }',
|
||||
''
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user