diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 04915dec1626..d0af49b020e6 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1928,7 +1928,6 @@ src/agents/harness/lifecycle-hook-helpers.ts 1 src/agents/harness/native-hook-relay-client.ts 2 src/agents/harness/native-hook-relay-codec.ts 2 src/agents/harness/native-hook-relay-state.ts 1 -src/agents/harness/native-hook-relay.ts 3 src/agents/harness/selection.ts 6 src/agents/harness/support.ts 1 src/agents/harness/tool-result-middleware.ts 1 diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index d8ab79825be3..615ed8f4acf8 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -1561,12 +1561,13 @@ and unsupported; prefer managed stdio or the local Unix control socket. unavailable`:** the Codex thread is still trying to use a native hook relay id that OpenClaw no longer has registered. This is a native Codex hook transport problem, not an ACP backend, provider, GitHub, or shell-command -failure. Start a fresh session in the affected chat with `/new` or `/reset`, -then retry a harmless command. If that works once but the next native tool -call fails again, treat `/new` as a temporary workaround only: copy the -prompt into a fresh session after restarting the Codex app-server or -OpenClaw Gateway so old threads are dropped and native hook registrations -are recreated. +failure. Same-process child agents stay bound to the policy of the turn that +spawned them, including after that parent turn yields. Restarting the Codex +app-server or OpenClaw Gateway intentionally drops this process-local authority; +those child tasks must be dispatched again. For an unavailable relay, start a +fresh session in the affected chat with `/new` or `/reset`, then retry a harmless +command. If it fails again without a restart, inspect the gateway logs for the +specific relay transport error rather than assuming the process restarted. **Codex tool calls create too many short-lived hook processes:** set `plugins.entries.codex.config.appServer.loopDetectionPreToolUseRelay: false` diff --git a/extensions/codex/src/app-server/approval-bridge.test.ts b/extensions/codex/src/app-server/approval-bridge.test.ts index a00d3e3d4e52..7b8c174d5d3c 100644 --- a/extensions/codex/src/app-server/approval-bridge.test.ts +++ b/extensions/codex/src/app-server/approval-bridge.test.ts @@ -1730,11 +1730,13 @@ describe("Codex app-server approval bridge", () => { expect(mockHasNativeHookRelayInvocation).toHaveBeenNthCalledWith(1, { relayId: "relay-1", event: "pre_tool_use", + turnId: "turn-1", toolUseId: "execve-approval-1", }); expect(mockHasNativeHookRelayInvocation).toHaveBeenNthCalledWith(2, { relayId: "relay-1", event: "pre_tool_use", + turnId: "turn-1", toolUseId: "execve-approval-2", }); }); @@ -1772,6 +1774,7 @@ describe("Codex app-server approval bridge", () => { expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1); expect(mockResolveNativeHookRelayDeferredToolApproval).toHaveBeenCalledWith({ relayId: "relay-1", + turnId: "turn-1", toolUseId: "cmd-native-relay-noop", signal: undefined, }); @@ -1818,10 +1821,12 @@ describe("Codex app-server approval bridge", () => { expect(mockHasNativeHookRelayInvocation).toHaveBeenCalledWith({ relayId: "relay-1", event: "pre_tool_use", + turnId: "turn-1", toolUseId: "cmd-native-relay-observed", }); expect(mockResolveNativeHookRelayDeferredToolApproval).toHaveBeenCalledWith({ relayId: "relay-1", + turnId: "turn-1", toolUseId: "cmd-native-relay-observed", signal: undefined, }); diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index 6b54cef4889c..5fd06d0d0650 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -587,9 +587,11 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: { if (!payload) { return undefined; } + const turnId = readString(params.requestParams, "turnId"); const resolveDeferredApproval = async () => { const approvalOutcome = await resolveNativeHookRelayDeferredToolApproval({ relayId: nativeHookRelay.relayId, + turnId, toolUseId: params.context.approvalId, signal: params.signal, }); @@ -612,6 +614,7 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: { hasNativeHookRelayInvocation({ relayId: nativeHookRelay.relayId, event: "pre_tool_use", + turnId, toolUseId: params.context.approvalId, }) ) { @@ -644,6 +647,7 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: { !hasNativeHookRelayInvocation({ relayId: nativeHookRelay.relayId, event: "pre_tool_use", + turnId, toolUseId: params.context.approvalId, }) ) { diff --git a/extensions/codex/src/app-server/native-hook-relay.ts b/extensions/codex/src/app-server/native-hook-relay.ts index 5799272b76df..553b8bcdb5c1 100644 --- a/extensions/codex/src/app-server/native-hook-relay.ts +++ b/extensions/codex/src/app-server/native-hook-relay.ts @@ -61,9 +61,12 @@ export type CodexNativePreToolUseFailure = { }; export type CodexNativeHookRelay = NativeHookRelayRegistrationHandle & { + activateForegroundBinding: () => void; authorizeRetentionAfterSuccessfulYield: () => void; + bindForegroundTurn: (turnId: string) => void; hasClaimedDirectChild: () => boolean; claimDirectChild: (threadId: string) => () => void; + renewDirectChild: (threadId: string) => void; rejectPendingDirectChild: (threadId: string, reason: string) => void; }; @@ -146,6 +149,7 @@ export function createCodexNativeHookRelay(params: { | undefined; generation?: string; generationMismatchGraceMs?: number; + composeWithExistingRoute?: boolean; events: readonly NativeHookRelayEvent[]; agentId: string | undefined; sessionId: string; @@ -185,6 +189,12 @@ export function createCodexNativeHookRelay(params: { } pendingDirectChildAdmissions.clear(); }; + const ttlMs = resolveCodexNativeHookRelayTtlMs({ + explicitTtlMs: params.options?.ttlMs, + attemptTimeoutMs: params.attemptTimeoutMs, + startupTimeoutMs: params.startupTimeoutMs, + turnStartTimeoutMs: params.turnStartTimeoutMs, + }); const relay = registerRetainedNativeHookRelayForBundledRuntime({ provider: "codex", relayId: buildCodexNativeHookRelayId({ @@ -206,17 +216,14 @@ export function createCodexNativeHookRelay(params: { ...(params.approvalContext ? { approvalContext: params.approvalContext } : {}), allowedEvents: params.events, preToolUseLoopDetection: params.loopDetectionPreToolUseRelay, - ttlMs: resolveCodexNativeHookRelayTtlMs({ - explicitTtlMs: params.options?.ttlMs, - attemptTimeoutMs: params.attemptTimeoutMs, - startupTimeoutMs: params.startupTimeoutMs, - turnStartTimeoutMs: params.turnStartTimeoutMs, - }), + ttlMs, signal: params.signal, runBeforeToolCall: params.hostCapabilities.runBeforeToolCall, assertActive: params.hostCapabilities.assertActive, + composeWithExistingRoute: params.composeWithExistingRoute, retention: { readClaim: readCodexNativeChildThreadId, + readForegroundSubject: readCodexNativeTurnId, // A child claim identifies the subject; successful parent finalization // separately authorizes its lifetime beyond foreground closure. shouldRetainAfterForegroundClose: () => @@ -268,10 +275,17 @@ export function createCodexNativeHookRelay(params: { return { ...relay, unregister, + activateForegroundBinding: relay.activateForegroundBinding, authorizeRetentionAfterSuccessfulYield: () => { successfulYieldRetentionAuthorized = true; }, + bindForegroundTurn: relay.bindForegroundSubject, hasClaimedDirectChild: () => directChildClaims.size > 0, + renewDirectChild: (threadId) => { + if (params.options?.ttlMs === undefined && directChildClaims.has(threadId)) { + relay.renewRetainedSubject(threadId, ttlMs); + } + }, rejectPendingDirectChild: (threadIdInput, reason) => { const threadId = threadIdInput.trim(); const pending = threadId ? pendingDirectChildAdmissions.get(threadId) : undefined; @@ -291,6 +305,7 @@ export function createCodexNativeHookRelay(params: { return () => undefined; } const claim = Symbol(threadId); + const releaseRetainedSubject = relay.bindRetainedSubject(threadId); directChildClaims.set(threadId, claim); const pending = pendingDirectChildAdmissions.get(threadId); pendingDirectChildAdmissions.delete(threadId); @@ -305,6 +320,7 @@ export function createCodexNativeHookRelay(params: { return; } directChildClaims.delete(threadId); + releaseRetainedSubject(); if (foregroundClosed && directChildClaims.size === 0) { relay.unregister(); } @@ -321,6 +337,14 @@ function readCodexNativeChildThreadId(rawPayload: unknown): string | undefined { return threadId || undefined; } +function readCodexNativeTurnId(rawPayload: unknown): string | undefined { + if (!isJsonObject(rawPayload) || typeof rawPayload.turn_id !== "string") { + return undefined; + } + const turnId = rawPayload.turn_id.trim(); + return turnId || undefined; +} + /** Selects the native hook events Codex should install for the current approval mode. */ export function resolveCodexNativeHookRelayEvents(params: { configuredEvents?: readonly NativeHookRelayEvent[]; diff --git a/extensions/codex/src/app-server/native-subagent-monitor.test.ts b/extensions/codex/src/app-server/native-subagent-monitor.test.ts index 8431b4396035..a843fa513d1a 100644 --- a/extensions/codex/src/app-server/native-subagent-monitor.test.ts +++ b/extensions/codex/src/app-server/native-subagent-monitor.test.ts @@ -1645,6 +1645,68 @@ describe("CodexNativeSubagentMonitor", () => { client.close(); }); + it.each([ + { + name: "the child remains actively claimed", + claim: true, + threadStatus: "active", + status: "inProgress", + expectedRenewals: 1, + expectedReleases: 0, + }, + { + name: "the active child was never claimed", + claim: false, + threadStatus: "active", + status: "inProgress", + expectedRenewals: 0, + expectedReleases: 0, + }, + { + name: "the claimed child is resumable", + claim: true, + threadStatus: "idle", + status: "interrupted", + expectedRenewals: 0, + expectedReleases: 1, + }, + ] as const)("renews only when $name after an authoritative read", async (testCase) => { + const client = createClient(); + client.setThreadRead( + "child-thread", + threadRead({ threadStatus: testCase.threadStatus, status: testCase.status }), + ); + const releaseDirectChild = vi.fn(); + const renewDirectChild = vi.fn(); + const monitor = new CodexNativeSubagentMonitor(client as never, createRuntime()); + const owner = monitor.registerParent({ + parentThreadId: "parent-thread", + claimDirectChild: () => releaseDirectChild, + renewDirectChild, + }); + owner.bindTurn("turn-1"); + await notifyChildStarted(client); + if (testCase.claim) { + await client.notify({ + method: "item/completed", + params: { + threadId: "parent-thread", + turnId: "turn-1", + item: directSpawnItem("v1", "parent-thread", "child-thread"), + }, + }); + } + + await expect(monitor.reconcileChildThread("child-thread")).resolves.toBe(false); + + expect(renewDirectChild).toHaveBeenCalledTimes(testCase.expectedRenewals); + expect(releaseDirectChild).toHaveBeenCalledTimes(testCase.expectedReleases); + if (testCase.expectedRenewals === 1) { + expect(renewDirectChild).toHaveBeenCalledWith("child-thread"); + } + client.close(); + }); + it("does not replay stale history while a system-error child still has an active turn", async () => { vi.useFakeTimers(); try { diff --git a/extensions/codex/src/app-server/native-subagent-monitor.ts b/extensions/codex/src/app-server/native-subagent-monitor.ts index 38fff64711be..51f2a6f4ca97 100644 --- a/extensions/codex/src/app-server/native-subagent-monitor.ts +++ b/extensions/codex/src/app-server/native-subagent-monitor.ts @@ -59,6 +59,7 @@ type NativeSubagentMonitorClient = Pick< type ParentOwner = { turnId?: string; claimDirectChild?: (threadId: string) => (() => void) | undefined; + renewDirectChild?: (threadId: string) => void; rejectPendingDirectChild?: (threadId: string, reason: string) => void; onDirectChildAccepted?: () => void; }; @@ -99,6 +100,7 @@ type ChildState = { deliveryOwnerKey?: string; settledWithoutCompletion: boolean; releaseDirectChild?: () => void; + renewDirectChild?: () => void; }; type ChildAssistantMessages = { @@ -196,6 +198,7 @@ function registerMonitor(params: { retainClient?: () => (() => void) | undefined; retainParentThread?: (threadId: string) => (() => void) | undefined; claimDirectChild?: (threadId: string) => (() => void) | undefined; + renewDirectChild?: (threadId: string) => void; rejectPendingDirectChild?: (threadId: string, reason: string) => void; onDirectChildAccepted?: () => void; }): { bindTurn: (turnId: string) => void; unregister: () => void } { @@ -262,6 +265,7 @@ function registerMonitor(params: { taskRuntimeScope: params.taskRuntimeScope, agentId: params.agentId, claimDirectChild: params.claimDirectChild, + renewDirectChild: params.renewDirectChild, rejectPendingDirectChild: params.rejectPendingDirectChild, onDirectChildAccepted: params.onDirectChildAccepted, }); @@ -365,6 +369,7 @@ class Monitor { taskRuntimeScope?: AgentHarnessTaskRuntimeScope; agentId?: string; claimDirectChild?: (threadId: string) => (() => void) | undefined; + renewDirectChild?: (threadId: string) => void; rejectPendingDirectChild?: (threadId: string, reason: string) => void; onDirectChildAccepted?: () => void; }): { bindTurn: (turnId: string) => void; unregister: () => void } { @@ -393,6 +398,7 @@ class Monitor { const owner = Symbol("codex-native-subagent-owner"); state.owners.set(owner, { claimDirectChild: params.claimDirectChild, + renewDirectChild: params.renewDirectChild, rejectPendingDirectChild: params.rejectPendingDirectChild, onDirectChildAccepted: params.onDirectChildAccepted, }); @@ -1024,12 +1030,15 @@ class Monitor { if (!state) { return false; } + const renewDirectChild = childState.renewDirectChild; const statusRead = this.retainThreadStatusRevision(childState.childThreadId); try { const recovery = await this.readThreadRecovery(childState.childThreadId); // Notification handlers run concurrently. A later status transition wins // over this read so stale history cannot complete or re-arm the child. if ( + this.disposed || + this.parentStates.get(childState.parentThreadId) !== state || !statusRead.isCurrent() || this.childStates.get(childState.childThreadId) !== childState ) { @@ -1044,7 +1053,16 @@ class Monitor { this.unregisterChild(childState); return false; } - if (recovery.threadState === "active") { + if (recovery.threadState === "active" && !recovery.resumable) { + if ( + renewDirectChild && + !childState.terminal && + !childState.settledWithoutCompletion && + childState.releaseDirectChild && + childState.renewDirectChild === renewDirectChild + ) { + renewDirectChild(); + } this.observeActiveChild(childState); return false; } @@ -1319,6 +1337,7 @@ class Monitor { options: { agentPath?: string; claimDirectChild?: (threadId: string) => (() => void) | undefined; + renewDirectChild?: (threadId: string) => void; } = {}, ): ChildState | undefined { const parentThreadId = state.parentThreadId; @@ -1379,7 +1398,13 @@ class Monitor { !childState.settledWithoutCompletion && !childState.releaseDirectChild ) { - childState.releaseDirectChild = options.claimDirectChild(childThreadId); + const releaseDirectChild = options.claimDirectChild(childThreadId); + if (releaseDirectChild) { + childState.releaseDirectChild = releaseDirectChild; + childState.renewDirectChild = options.renewDirectChild + ? () => options.renewDirectChild?.(childThreadId) + : undefined; + } } this.registerAgentPath(childState, childThreadId); state.mirror?.markAuthoritativeCompletionExpected(childThreadId); @@ -1412,6 +1437,7 @@ class Monitor { const childState = this.registerChildThread(state, evidence.childThreadId, { ...(evidence.agentPath === undefined ? {} : { agentPath: evidence.agentPath }), ...(owner?.claimDirectChild ? { claimDirectChild: owner.claimDirectChild } : {}), + ...(owner?.renewDirectChild ? { renewDirectChild: owner.renewDirectChild } : {}), }); if (!owner) { this.bufferPendingDirectSpawnEvidence(turnIdInput, evidence); @@ -1463,6 +1489,7 @@ class Monitor { const childState = this.registerChildThread(state, evidence.childThreadId, { ...(evidence.agentPath === undefined ? {} : { agentPath: evidence.agentPath }), claimDirectChild: owner.claimDirectChild, + ...(owner.renewDirectChild ? { renewDirectChild: owner.renewDirectChild } : {}), }); if (childState) { owner.onDirectChildAccepted?.(); @@ -1570,6 +1597,7 @@ class Monitor { private releaseDirectChild(childState: ChildState): void { const release = childState.releaseDirectChild; childState.releaseDirectChild = undefined; + childState.renewDirectChild = undefined; release?.(); } diff --git a/extensions/codex/src/app-server/run-attempt-resources.ts b/extensions/codex/src/app-server/run-attempt-resources.ts index f22dd9b86a1d..f60f84c7c2ed 100644 --- a/extensions/codex/src/app-server/run-attempt-resources.ts +++ b/extensions/codex/src/app-server/run-attempt-resources.ts @@ -194,6 +194,10 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { }; const registerNativeSubagentMonitor = (parentThreadId: string) => { unregisterNativeSubagentMonitor(); + // Child recovery outlives this attempt's mutable state. Capture the relay + // that admitted the child so a successor foreground turn cannot renew or + // release the child under the successor's policy. + const nativeHookRelay = state.nativeHookRelay; state.nativeSubagentMonitor = codexNativeSubagentMonitorRuntime.register({ client: state.client, parentThreadId, @@ -203,9 +207,16 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { retainClient: () => retainSharedCodexAppServerClientIfCurrent(state.client), retainParentThread: (protectedThreadId) => protectCodexAppServerLiveThread(state.client, protectedThreadId), - claimDirectChild: (childThreadId) => state.nativeHookRelay?.claimDirectChild(childThreadId), - rejectPendingDirectChild: (childThreadId, reason) => - state.nativeHookRelay?.rejectPendingDirectChild(childThreadId, reason), + ...(nativeHookRelay + ? { + claimDirectChild: (childThreadId: string) => + nativeHookRelay.claimDirectChild(childThreadId), + renewDirectChild: (childThreadId: string) => + nativeHookRelay.renewDirectChild(childThreadId), + rejectPendingDirectChild: (childThreadId: string, reason: string) => + nativeHookRelay.rejectPendingDirectChild(childThreadId, reason), + } + : {}), ...(params.sessionKey && params.agentHarnessTaskRuntimeScope ? { onDirectChildAccepted: () => { @@ -215,6 +226,10 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { : {}), }); }; + const bindNativeTurn = (turnId: string) => { + state.nativeHookRelay?.bindForegroundTurn(turnId); + state.nativeSubagentMonitor?.bindTurn(turnId); + }; const releaseCurrentRoute = () => { state.detachRouteAbort(); state.detachRouteAbort = () => undefined; @@ -230,8 +245,12 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { const requesterChannel = params.messageChannel ?? params.messageProvider; const requester = buildCodexHookRequester(params); const buildNativeHookRelayFinalConfigPatch = ( - decision: { action: "resume"; binding: CodexAppServerThreadBinding } | { action: "start" }, + decision: + | { action: "resume"; binding: CodexAppServerThreadBinding } + | { action: "start"; preserveExistingBinding: boolean }, ) => { + const composeWithExistingRoute = + decision.action === "resume" || decision.preserveExistingBinding; state.nativeHookRelay?.unregister(); if (params.pluginHarnessToolPolicyRestricted === true) { state.nativeHookRelay = undefined; @@ -242,6 +261,7 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { } state.nativeHookRelay = createCodexNativeHookRelay({ options: options.nativeHookRelay, + composeWithExistingRoute, generation: decision.action === "resume" ? decision.binding.nativeHookRelayGeneration : undefined, generationMismatchGraceMs: @@ -282,6 +302,9 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { }, }); return { + ...(composeWithExistingRoute && state.nativeHookRelay + ? { activateThreadBinding: state.nativeHookRelay.activateForegroundBinding } + : {}), configPatch: state.nativeHookRelay ? buildCodexNativeHookRelayConfig({ relay: state.nativeHookRelay, @@ -309,6 +332,7 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { releaseSandboxExecEnvironment, runCleanupStep, registerNativeSubagentMonitor, + bindNativeTurn, releaseCurrentRoute, startupTimeoutMs, buildNativeHookRelayFinalConfigPatch, diff --git a/extensions/codex/src/app-server/run-attempt-route.ts b/extensions/codex/src/app-server/run-attempt-route.ts index 847cf925a7af..f8921e9c6224 100644 --- a/extensions/codex/src/app-server/run-attempt-route.ts +++ b/extensions/codex/src/app-server/run-attempt-route.ts @@ -19,6 +19,7 @@ export async function prepareCodexAttemptRoute( trajectoryRecorder, releaseCurrentRoute, registerNativeSubagentMonitor, + bindNativeTurn, activateNativePreToolUseFailureFallback, releaseSandboxExecEnvironment, releaseSharedClientLeaseOnce, @@ -85,6 +86,7 @@ export async function prepareCodexAttemptRoute( } resourceState.detachRouteAbort = attachRouteAbort(resourceState.turnRoute); await resourceState.turnRoute.activate({ + onTurnStarted: bindNativeTurn, onNotificationReceived: noteNotificationReceived, onNotification: enqueueNotification, onRequest: handleServerRequest, diff --git a/extensions/codex/src/app-server/run-attempt-turn-start.ts b/extensions/codex/src/app-server/run-attempt-turn-start.ts index c4924b92d140..49f2a216191d 100644 --- a/extensions/codex/src/app-server/run-attempt-turn-start.ts +++ b/extensions/codex/src/app-server/run-attempt-turn-start.ts @@ -49,6 +49,7 @@ export async function startCodexAttemptTurn( markTrajectoryEndRecorded, activateNativePreToolUseFailureFallback, releaseCurrentRoute, + bindNativeTurn, releaseSandboxExecEnvironment, releaseSharedClientLeaseAndRetireOneShotClient, } = resources; @@ -323,6 +324,6 @@ export async function startCodexAttemptTurn( }; } turnIdRef.current = turn.turn.id; - resourceState.nativeSubagentMonitor?.bindTurn(turn.turn.id); + bindNativeTurn(turn.turn.id); return { turn }; } diff --git a/extensions/codex/src/app-server/run-attempt.native-hook-relay-retention.test.ts b/extensions/codex/src/app-server/run-attempt.native-hook-relay-retention.test.ts index 8b5f4967afa7..2b9f15a842f8 100644 --- a/extensions/codex/src/app-server/run-attempt.native-hook-relay-retention.test.ts +++ b/extensions/codex/src/app-server/run-attempt.native-hook-relay-retention.test.ts @@ -17,16 +17,359 @@ import { createParams, createCodexRuntimePlanFixture, createStartedThreadHarness, + extractGenerationFromThreadRequest, extractRelayIdFromThreadRequest, runCodexAppServerAttempt, setCodexTestModelSupportsTools, setupRunAttemptTestHooks, tempDir, + threadStartResult, + turnStartResult, } from "./run-attempt-test-harness.js"; setupRunAttemptTestHooks(); describe("runCodexAppServerAttempt native hook relay retention", () => { + it("binds foreground policy from turn/started before turn/start responds", async () => { + const sessionFile = path.join(tempDir, "early-turn-start-session.jsonl"); + const workspaceDir = path.join(tempDir, "early-turn-start-workspace"); + let resolveTurnStart!: (value: ReturnType) => void; + const deferredTurnStart = new Promise>((resolve) => { + resolveTurnStart = resolve; + }); + const harness = createStartedThreadHarness(async (method) => { + if (method === "turn/start") { + return await deferredTurnStart; + } + return undefined; + }); + const params = createParams(sessionFile, workspaceDir); + params.disableTools = false; + params.runtimePlan = createCodexRuntimePlanFixture(); + setCodexTestModelSupportsTools(params, true); + const fixture = await createAdmittedHostCapabilityTestFixture(params); + params.hostCapabilities = fixture.hostCapabilities; + const beforeToolCall = vi.fn(async () => ({ + block: true, + blockReason: "early turn policy denied", + })); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]), + ); + + const run = runCodexAppServerAttempt(params, { + nativeHookRelay: { enabled: true, events: ["pre_tool_use"] }, + }); + try { + await harness.waitForMethod("turn/start"); + const startRequest = harness.requests.find((request) => request.method === "thread/start"); + const relayId = extractRelayIdFromThreadRequest(startRequest?.params); + await harness.notify({ + method: "turn/started", + params: { + threadId: "thread-1", + turnId: "turn-early", + turn: { id: "turn-early", status: "inProgress" }, + }, + } as CodexServerNotification); + + const earlyInvocation = await invokeNativeHookRelay({ + provider: "codex", + relayId, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + turn_id: "turn-early", + cwd: workspaceDir, + tool_name: "Bash", + tool_use_id: "early-tool", + tool_input: { command: "echo early" }, + }, + }).then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + resolveTurnStart(turnStartResult("turn-early")); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-early" }); + await run; + if ("error" in earlyInvocation) { + throw earlyInvocation.error; + } + expect(JSON.parse(earlyInvocation.value.stdout)).toMatchObject({ + hookSpecificOutput: { + permissionDecision: "deny", + permissionDecisionReason: "early turn policy denied", + }, + }); + expect(beforeToolCall).toHaveBeenCalledOnce(); + } finally { + resolveTurnStart(turnStartResult("turn-early")); + fixture.closeHost(); + fixture.closeAdmission(); + } + }); + + it("keeps child A on its origin policy when turn B starts a transient thread", async () => { + const childThreadId = "child-from-turn-a"; + const sessionFile = path.join(tempDir, "two-turn-yield-session.jsonl"); + const workspaceDir = path.join(tempDir, "two-turn-yield-workspace"); + let turnStartCount = 0; + let threadStartCount = 0; + const harness = createStartedThreadHarness(async (method, requestParams) => { + if (method === "thread/start") { + threadStartCount += 1; + return threadStartResult(threadStartCount === 1 ? "thread-1" : "thread-transient"); + } + if (method === "thread/read") { + return { + thread: { + id: childThreadId, + parentThreadId: "thread-1", + status: { type: "active" }, + turns: [], + }, + }; + } + if (method === "thread/resume") { + return threadStartResult((requestParams as { threadId?: string })?.threadId ?? "thread-1"); + } + if (method === "turn/start") { + turnStartCount += 1; + return turnStartResult(turnStartCount === 1 ? "turn-a" : "turn-b"); + } + return undefined; + }); + const beforeToolCall = vi.fn(async (event: unknown, context: unknown) => { + const command = (event as { params?: { command?: string } }).params?.command; + const runId = (context as { runId?: string }).runId; + return command === `deny-${runId}` + ? { block: true, blockReason: `${runId} policy denied` } + : undefined; + }); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]), + ); + + const paramsA = createParams(sessionFile, workspaceDir, { runId: "run-a" }); + paramsA.disableTools = false; + paramsA.runtimePlan = createCodexRuntimePlanFixture(); + setCodexTestModelSupportsTools(paramsA, true); + const fixtureA = await createAdmittedHostCapabilityTestFixture(paramsA); + paramsA.hostCapabilities = fixtureA.hostCapabilities; + paramsA.agentHarnessTaskRuntimeScope = fixtureA.agentHarnessTaskRuntimeScope; + + const runA = runCodexAppServerAttempt(paramsA, { + nativeHookRelay: { enabled: true, events: ["pre_tool_use"] }, + }); + let fixtureB: Awaited> | undefined; + try { + await harness.waitForMethod("turn/start"); + const startRequest = harness.requests.find((request) => request.method === "thread/start"); + const relayId = extractRelayIdFromThreadRequest(startRequest?.params); + const generationA = extractGenerationFromThreadRequest(startRequest?.params); + await harness.notify({ + method: "thread/started", + params: { + thread: { + id: childThreadId, + parentThreadId: "thread-1", + source: { + subAgent: { thread_spawn: { parent_thread_id: "thread-1", depth: 1 } }, + }, + }, + }, + } as CodexServerNotification); + await harness.notify({ + method: "item/completed", + params: { + threadId: "thread-1", + turnId: "turn-a", + item: { + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "completed", + senderThreadId: "thread-1", + receiverThreadIds: [childThreadId], + }, + }, + } as unknown as CodexServerNotification); + await expect( + harness.handleServerRequest({ + id: "request-sessions-yield-a", + method: "item/tool/call", + params: { + threadId: "thread-1", + turnId: "turn-a", + callId: "yield-a", + namespace: null, + tool: "sessions_yield", + arguments: { message: "Waiting for child A" }, + }, + }), + ).resolves.toMatchObject({ success: true }); + await new Promise((resolve) => { + setImmediate(resolve); + }); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-a" }); + await runA; + fixtureA.closeHost(); + fixtureA.closeAdmission(); + + const paramsB = createParams(sessionFile, workspaceDir, { runId: "run-b" }); + paramsB.disableTools = true; + paramsB.runtimePlan = createCodexRuntimePlanFixture(); + setCodexTestModelSupportsTools(paramsB, true); + fixtureB = await createAdmittedHostCapabilityTestFixture(paramsB); + paramsB.hostCapabilities = fixtureB.hostCapabilities; + paramsB.agentHarnessTaskRuntimeScope = fixtureB.agentHarnessTaskRuntimeScope; + const runB = runCodexAppServerAttempt(paramsB, { + nativeHookRelay: { enabled: true, events: ["pre_tool_use"] }, + }); + await vi.waitFor( + () => + expect( + harness.requests.filter((request) => request.method === "turn/start"), + ).toHaveLength(2), + { interval: 1 }, + ); + expect(harness.requests.filter((request) => request.method === "thread/start")).toHaveLength( + 2, + ); + await harness.notify({ + method: "rawResponseItem/completed", + params: { + threadId: "thread-1", + item: { + type: "message", + role: "assistant", + phase: "commentary", + content: [ + { + type: "output_text", + text: JSON.stringify({ + author: childThreadId, + recipient: "/root", + other_recipients: [], + content: + `{"agent_path":${JSON.stringify(childThreadId)},` + + '"status":{"completed":null}}', + trigger_turn: false, + }), + }, + ], + }, + }, + } as unknown as CodexServerNotification); + + nativeHookRelayUnregisterQueue.flush(); + let childDenied: Awaited> | undefined; + let childError: unknown; + void invokeNativeHookRelay({ + provider: "codex", + relayId, + generation: generationA, + requireGeneration: true, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + turn_id: "child-a-turn", + agent_id: childThreadId, + cwd: workspaceDir, + tool_name: "Bash", + tool_use_id: "child-a-tool", + tool_input: { command: "deny-run-a" }, + }, + }).then( + (response) => { + childDenied = response; + }, + (error: unknown) => { + childError = error; + }, + ); + await vi.waitFor(() => { + expect(childDenied !== undefined || childError !== undefined).toBe(true); + }); + if (childError !== undefined) { + throw childError instanceof Error + ? childError + : new Error("Child A hook failed", { cause: childError }); + } + if (!childDenied) { + throw new Error("Expected child A hook response"); + } + expect(childDenied.stdout).toContain("run-a policy denied"); + expect(JSON.parse(childDenied.stdout)).toMatchObject({ + hookSpecificOutput: { + permissionDecision: "deny", + permissionDecisionReason: "run-a policy denied", + }, + }); + const rootDenied = await invokeNativeHookRelay({ + provider: "codex", + relayId, + generation: generationA, + requireGeneration: true, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + turn_id: "turn-b", + cwd: workspaceDir, + tool_name: "Bash", + tool_use_id: "root-b-tool", + tool_input: { command: "deny-run-b" }, + }, + }); + expect(JSON.parse(rootDenied.stdout)).toMatchObject({ + hookSpecificOutput: { + permissionDecision: "deny", + permissionDecisionReason: "run-b policy denied", + }, + }); + expect(beforeToolCall.mock.calls.slice(-2).map(([, context]) => context)).toEqual([ + expect.objectContaining({ runId: "run-a" }), + expect.objectContaining({ runId: "run-b" }), + ]); + + await harness.notify({ + method: "turn/completed", + params: { + threadId: childThreadId, + turn: { id: "child-a-turn", status: "completed" }, + }, + } as CodexServerNotification); + nativeHookRelayUnregisterQueue.flush(); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + generation: generationA, + requireGeneration: true, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + turn_id: "turn-b", + tool_name: "Bash", + tool_use_id: "root-b-after-child", + tool_input: { command: "allow-run-b" }, + }, + }), + ).resolves.toMatchObject({ exitCode: 0 }); + + await harness.completeTurn({ threadId: "thread-transient", turnId: "turn-b" }); + await runB; + nativeHookRelayUnregisterQueue.flush(); + expect( + nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId), + ).toBeUndefined(); + } finally { + fixtureA.closeHost(); + fixtureA.closeAdmission(); + fixtureB?.closeHost(); + fixtureB?.closeAdmission(); + } + }); + it.each([ { name: "Codex multi-agent V1", diff --git a/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts b/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts index cb9a7f513462..f442ad0f01b7 100644 --- a/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts +++ b/extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts @@ -109,6 +109,7 @@ describe("runCodexAppServerAttempt native hook relay", () => { event: "post_tool_use", rawPayload: { hook_event_name: "PostToolUse", + turn_id: "turn-1", tool_name: "Bash", tool_use_id: "native-call-1", tool_input: { command: "pnpm test" }, @@ -682,6 +683,7 @@ describe("runCodexAppServerAttempt native hook relay", () => { event: "pre_tool_use", rawPayload: { hook_event_name: "PreToolUse", + turn_id: "turn-1", tool_name: "Bash", tool_use_id: "late-call-1", tool_input: { command: "python3 -c 'print(\"x\")'" }, @@ -805,6 +807,7 @@ describe("runCodexAppServerAttempt native hook relay", () => { requireGeneration: true, rawPayload: { hook_event_name: "PreToolUse", + turn_id: "turn-1", tool_name: "Bash", tool_use_id: "first-tool-after-restart", tool_input: { command: "pwd" }, diff --git a/extensions/codex/src/app-server/thread-lifecycle-io.ts b/extensions/codex/src/app-server/thread-lifecycle-io.ts index 6ab6c2a58d23..ec2b848155e0 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-io.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-io.ts @@ -99,6 +99,7 @@ type ResumeThreadContext = ThreadRequestContext & { clearCurrentBinding: (operation: string) => Promise; prebuiltPluginThreadConfig?: CodexPluginThreadConfig; prebuiltFinalConfigPatch?: { + activateThreadBinding?: () => void; configPatch?: JsonObject; nativeHookRelayGeneration?: string; }; @@ -330,6 +331,7 @@ export async function resumeExistingCodexThread( "committing a resumed thread", ); } + finalConfigPatch.activateThreadBinding?.(); if (contextEngineBinding) { embeddedAgentLog.info("codex app-server wrote context-engine thread binding", { sessionId: params.params.sessionId, @@ -467,7 +469,10 @@ export async function startFreshCodexThread( params.pluginThreadConfig?.build(), ))) : undefined; - const finalConfigPatch = params.buildFinalConfigPatch?.({ action: "start" }) ?? { + const finalConfigPatch = params.buildFinalConfigPatch?.({ + action: "start", + preserveExistingBinding, + }) ?? { configPatch: params.finalConfigPatch, nativeHookRelayGeneration: params.nativeHookRelayGeneration, }; @@ -691,6 +696,7 @@ export async function startFreshCodexThread( }); } } + finalConfigPatch.activateThreadBinding?.(); lifecycleTiming.mark("thread-ready"); lifecycleTiming.logSummary({ runId: params.params.runId, diff --git a/extensions/codex/src/app-server/thread-lifecycle-run.ts b/extensions/codex/src/app-server/thread-lifecycle-run.ts index f95b3c965927..47ebe44740f9 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-run.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-run.ts @@ -152,7 +152,10 @@ export async function startOrResumeThread( params.pluginThreadConfig?.build(), ) : undefined; - const finalConfigPatch = params.buildFinalConfigPatch?.({ action: "start" }) ?? { + const finalConfigPatch = params.buildFinalConfigPatch?.({ + action: "start", + preserveExistingBinding: false, + }) ?? { configPatch: params.finalConfigPatch, nativeHookRelayGeneration: params.nativeHookRelayGeneration, }; @@ -598,8 +601,9 @@ export async function startOrResumeThread( } else if (incognito) { if (binding.clientId && binding.clientId === clientId) { // Ephemeral threads have no cold-resume source; reuse only the live client that started it. - params.buildFinalConfigPatch?.({ action: "resume", binding }); + const finalConfigPatch = params.buildFinalConfigPatch?.({ action: "resume", binding }); throwIfAborted(); + finalConfigPatch?.activateThreadBinding?.(); lifecycleTiming.mark("thread-ready"); lifecycleTiming.logSummary({ runId: params.params.runId, diff --git a/extensions/codex/src/app-server/thread-lifecycle-types.ts b/extensions/codex/src/app-server/thread-lifecycle-types.ts index 15fedef40095..2d9fbeef027d 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-types.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-types.ts @@ -25,9 +25,11 @@ export type CodexAppServerThreadLifecycleBinding = CodexAppServerThreadBinding & type CodexThreadFinalConfigPatchDecision = | { action: "resume"; binding: CodexAppServerThreadBinding } - | { action: "start" }; + | { action: "start"; preserveExistingBinding: boolean }; type CodexThreadFinalConfigPatchResult = { + /** Activates attempt policy only after the canonical thread claim succeeds. */ + activateThreadBinding?: () => void; configPatch?: JsonObject; nativeHookRelayGeneration?: string; }; diff --git a/extensions/codex/src/app-server/thread-lifecycle-warm.ts b/extensions/codex/src/app-server/thread-lifecycle-warm.ts index ead06c83626a..7551ab0aff4b 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-warm.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-warm.ts @@ -27,6 +27,7 @@ import type { resolveCodexAppServerThreadModelSelection } from "./thread-model-s import { buildThreadResumeParams } from "./thread-requests.js"; type CodexWarmThreadFinalConfigPatch = { + activateThreadBinding?: () => void; configPatch?: JsonObject; nativeHookRelayGeneration?: string; }; @@ -243,6 +244,7 @@ export async function tryReuseCodexLiveThread( throw new CodexThreadBindingConflictError(binding.threadId, "committing a reused thread"); } throwIfAborted(); + prebuiltFinalConfigPatch.activateThreadBinding?.(); lifecycleTiming.mark("thread-ready"); lifecycleTiming.logSummary({ runId: params.params.runId, diff --git a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts index abe6838dcbb2..ca61cc92efb1 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts @@ -405,7 +405,10 @@ describe("Codex app-server thread lifecycle bindings", () => { nativeHookRelayGeneration: "generation-warm-next", }); expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]); - expect(buildFinalConfigPatch).toHaveBeenNthCalledWith(1, { action: "start" }); + expect(buildFinalConfigPatch).toHaveBeenNthCalledWith(1, { + action: "start", + preserveExistingBinding: false, + }); expect(buildFinalConfigPatch).toHaveBeenNthCalledWith(2, { action: "resume", binding: expect.objectContaining({ threadId: "thread-warm" }), diff --git a/extensions/codex/src/app-server/turn-router.ts b/extensions/codex/src/app-server/turn-router.ts index 9dfa4369fe0c..f5446cefd545 100644 --- a/extensions/codex/src/app-server/turn-router.ts +++ b/extensions/codex/src/app-server/turn-router.ts @@ -39,6 +39,7 @@ type CodexThreadNotificationReceivedHandler = ( receivedAtMs: number, ) => void; type CodexThreadRouteHandlers = { + onTurnStarted?: (turnId: string) => void; onNotificationReceived?: CodexThreadNotificationReceivedHandler; onNotification?: CodexThreadNotificationHandler; onRequest?: CodexThreadRequestHandler; @@ -343,6 +344,9 @@ class ClientTurnRouter implements CodexAppServerTurnRouter { if (route.gate !== "bound" && scope.turnId) { if (notification.method === "turn/started") { route.observedNativeTurn = { id: scope.turnId, completed: false }; + if (route.gate === "armed") { + route.handlers?.onTurnStarted?.(scope.turnId); + } } else if (notification.method === "turn/completed") { route.completedNativeTurnIds.add(scope.turnId); if ( diff --git a/src/agents/harness/native-hook-relay-events.ts b/src/agents/harness/native-hook-relay-events.ts index f5f8bd8a312a..5f7094417857 100644 --- a/src/agents/harness/native-hook-relay-events.ts +++ b/src/agents/harness/native-hook-relay-events.ts @@ -164,7 +164,8 @@ async function runNativeHookRelayPreToolUse(params: { if (outcome.deferredApproval) { if ( !setNativeHookRelayPreToolUseApproval({ - relayId: params.registration.relayId, + registration: params.registration, + turnId: params.invocation.turnId, toolUseId: params.invocation.toolUseId, deferredApproval: outcome.deferredApproval, originalParamsFingerprint: originalToolInputFingerprint, diff --git a/src/agents/harness/native-hook-relay-lifecycle.ts b/src/agents/harness/native-hook-relay-lifecycle.ts new file mode 100644 index 000000000000..62cc00780c63 --- /dev/null +++ b/src/agents/harness/native-hook-relay-lifecycle.ts @@ -0,0 +1,727 @@ +import { randomUUID } from "node:crypto"; +import { + MAX_TIMER_TIMEOUT_MS, + resolveExpiresAtMsFromDurationMs, +} from "@openclaw/normalization-core/number-coercion"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; +import { retainBeforeToolCallForNativeHookRelay } from "./host-capability.js"; +import { + NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS, + registerNativeHookRelayBridge, + renewNativeHookRelayBridgeRecord, + unregisterNativeHookRelayBridge, +} from "./native-hook-relay-bridge.js"; +import { + buildNativeHookRelayCommandWithStateDatabase, + resolveNativeHookRelayCommandTimeoutMs, +} from "./native-hook-relay-command.js"; +import { + nativeHookRelayEventHasLocalWork, + nativeHookRelayEventToolMatcher, +} from "./native-hook-relay-events.js"; +import { + inheritNativeHookRelayApprovalOwner, + pruneNativeHookRelayPermissionAllowAlways, + registerNativeHookRelayApprovalOwner, + removeNativeHookRelayPendingApprovalsForOwner, + removeNativeHookRelayPermissionState, + removeNativeHookRelayPreToolUseApprovals, +} from "./native-hook-relay-permissions.js"; +import { nativeHookRelayState } from "./native-hook-relay-state.js"; +import type { + ActiveNativeHookRelayRegistration, + ActiveNativeHookRelayRegistrationHandle, + InvokeNativeHookRelayParams, + NativeHookRelayEvent, + NativeHookRelayProcessResponse, + NativeHookRelayRegistration, + RegisterNativeHookRelayParams, +} from "./native-hook-relay-types.js"; +import { NATIVE_HOOK_RELAY_EVENTS } from "./native-hook-relay-types.js"; +import { normalizePositiveInteger } from "./native-hook-relay-utils.js"; + +type NativeHookRelayInvoker = ( + params: InvokeNativeHookRelayParams, +) => Promise; +const DEFAULT_RELAY_TTL_MS = 30 * 60 * 1000; +const log = createSubsystemLogger("agents/harness/native-hook-relay"); + +const { relays, relayBridges, invocations } = nativeHookRelayState; +type RelayBinding = { + registration: ActiveNativeHookRelayRegistration; + token: symbol; + foregroundOpen: boolean; + foregroundSubject?: string; + childSubjects: Set; + retained?: ReturnType; + retention?: NativeHookRelayRetention; + removeAbortListener?: () => void; +}; + +type RelayLifetime = { + bindings: Set; + childBindings: Map; + foreground?: RelayBinding; + expiryTimer?: ReturnType; +}; + +const RELAY_LIFETIMES = Symbol.for("openclaw.nativeHookRelay.lifetimes"); +// SAFETY: this module alone writes this symbol slot with the declared lifetime-map type. +const nativeHookRelayGlobals = globalThis as typeof globalThis & { + [RELAY_LIFETIMES]?: WeakMap; +}; +const relayLifetimes = (nativeHookRelayGlobals[RELAY_LIFETIMES] ??= new WeakMap()); + +/** Private bundled-runtime callbacks for retained direct-child hook policy. */ +export type NativeHookRelayRetention = Readonly<{ + readClaim: (rawPayload: unknown) => string | undefined; + readForegroundSubject?: (rawPayload: unknown) => string | undefined; + shouldRetainAfterForegroundClose: () => boolean; + allowPreToolUse: (claim: string) => boolean; + awaitForegroundAdmission?: (claim: string) => Promise<(() => boolean) | undefined>; + onDispose: () => void; +}>; + +type RetainedNativeHookRelayParams = RegisterNativeHookRelayParams & { + composeWithExistingRoute?: boolean; + retention: NativeHookRelayRetention; +}; + +type RetainedNativeHookRelayHandle = ActiveNativeHookRelayRegistrationHandle & { + activateForegroundBinding: () => void; + bindForegroundSubject: (subject: string) => void; + bindRetainedSubject: (subject: string) => () => void; + renewRetainedSubject: (subject: string, ttlMs?: number) => void; +}; + +function readRelayLifetime( + registration: ActiveNativeHookRelayRegistration, +): RelayLifetime | undefined { + return relayLifetimes.get(registration); +} + +function setRelayLifetime( + registration: ActiveNativeHookRelayRegistration, + lifetime: RelayLifetime, +): void { + relayLifetimes.set(registration, lifetime); +} + +function scheduleNativeHookRelayExpiry( + relayId: string, + route: ActiveNativeHookRelayRegistration, +): void { + const lifetime = readRelayLifetime(route); + if (!lifetime) { + return; + } + if (lifetime.expiryTimer) { + clearTimeout(lifetime.expiryTimer); + } + const rearm = () => { + if (relays.get(relayId) !== route) { + return; + } + lifetime.expiryTimer = undefined; + const now = Date.now(); + for (const binding of lifetime.bindings) { + if (now > binding.registration.expiresAtMs) { + removeNativeHookRelayBinding(relayId, route, binding); + } + } + if (relays.get(relayId) !== route) { + return; + } + if (lifetime.expiryTimer) { + return; + } + const earliestExpiry = Math.min( + ...[...lifetime.bindings].map((binding) => binding.registration.expiresAtMs), + ); + const remainingMs = earliestExpiry - now; + if (remainingMs < 0) { + rearm(); + return; + } + lifetime.expiryTimer = setTimeout(rearm, Math.min(remainingMs + 1, MAX_TIMER_TIMEOUT_MS)); + lifetime.expiryTimer.unref(); + }; + rearm(); +} + +function updateNativeHookRelayRouteExpiry( + relayId: string, + route: ActiveNativeHookRelayRegistration, +): boolean { + const lifetime = readRelayLifetime(route); + if (!lifetime || lifetime.bindings.size === 0) { + return false; + } + const expiresAtMs = Math.max( + ...[...lifetime.bindings].map((binding) => binding.registration.expiresAtMs), + ); + const bridge = relayBridges.get(relayId); + if (bridge?.server.listening) { + try { + const renewal = renewNativeHookRelayBridgeRecord(route, bridge, expiresAtMs); + if (renewal === "unavailable") { + return false; + } + if (renewal === "ownership-changed") { + log.debug("native hook relay bridge record ownership changed", { relayId }); + unregisterNativeHookRelay(relayId, route); + return false; + } + } catch (error) { + log.debug("failed to renew native hook relay bridge record", { error, relayId }); + return false; + } + } + route.expiresAtMs = expiresAtMs; + scheduleNativeHookRelayExpiry(relayId, route); + return true; +} + +function resolveNativeHookRelayExpiresAtMs(ttlMs: number | undefined): number | undefined { + return resolveExpiresAtMsFromDurationMs(normalizePositiveInteger(ttlMs, DEFAULT_RELAY_TTL_MS)); +} + +export function registerNativeHookRelayLifecycle( + params: RegisterNativeHookRelayParams, + invokeRelay: NativeHookRelayInvoker, +): ActiveNativeHookRelayRegistrationHandle { + return registerNativeHookRelayInternal(params, undefined, false, invokeRelay); +} + +/** Private-local bundled runtime entrypoint; not exported through the public SDK. */ +export function registerRetainedNativeHookRelayLifecycle( + params: RetainedNativeHookRelayParams, + invokeRelay: NativeHookRelayInvoker, +): RetainedNativeHookRelayHandle { + const { composeWithExistingRoute = false, retention, ...registrationParams } = params; + return registerNativeHookRelayInternal( + registrationParams, + retention, + composeWithExistingRoute, + invokeRelay, + ); +} + +function registerNativeHookRelayInternal( + params: RegisterNativeHookRelayParams, + retention: NativeHookRelayRetention | undefined, + composeWithExistingRoute: boolean, + invokeRelay: NativeHookRelayInvoker, +): RetainedNativeHookRelayHandle { + pruneExpiredNativeHookRelays(); + pruneNativeHookRelayPermissionAllowAlways(); + const relayId = normalizeRelayKey(params.relayId, "id") ?? randomUUID(); + const requestedGeneration = normalizeRelayKey(params.generation, "generation"); + const existingRoute = composeWithExistingRoute ? relays.get(relayId) : undefined; + if ( + existingRoute && + (existingRoute.provider !== params.provider || + existingRoute.agentId !== params.agentId || + existingRoute.sessionId !== params.sessionId || + existingRoute.sessionKey !== params.sessionKey || + (requestedGeneration !== undefined && requestedGeneration !== existingRoute.generation)) + ) { + throw new Error("native hook relay successor route identity mismatch"); + } + const generation = existingRoute?.generation ?? requestedGeneration ?? randomUUID(); + const generationMismatchGraceMs = normalizePositiveInteger(params.generationMismatchGraceMs, 0); + const now = Date.now(); + const expiresAtMs = resolveNativeHookRelayExpiresAtMs(params.ttlMs); + if (expiresAtMs === undefined) { + throw new Error("Native hook relay expiry is outside the supported Date range"); + } + const allowedEvents = normalizeAllowedEvents(params.allowedEvents); + const stateDbPath = resolveOpenClawStateSqlitePath(); + const deliverReplacedRegistrationUnregister = existingRoute + ? undefined + : unregisterNativeHookRelay(relayId, undefined, { + deferBridgeRecordRemovalMs: NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS, + deferOnUnregister: true, + }); + let preparedRoute: ActiveNativeHookRelayRegistration | undefined; + let preparedBinding: RelayBinding | undefined; + try { + const registration: ActiveNativeHookRelayRegistration = { + relayId, + provider: params.provider, + generation, + ...(generationMismatchGraceMs > 0 + ? { generationMismatchGraceExpiresAtMs: now + generationMismatchGraceMs } + : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), + sessionId: params.sessionId, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.config ? { config: params.config } : {}), + runId: params.runId, + ...(params.channelId ? { channelId: params.channelId } : {}), + ...(params.requester ? { requester: params.requester } : {}), + ...(params.approvalContext ? { approvalContext: params.approvalContext } : {}), + allowedEvents, + preToolUseLoopDetection: params.preToolUseLoopDetection !== false, + expiresAtMs, + preToolUseFailureProjections: new Map(), + ...(params.signal ? { signal: params.signal } : {}), + ...(params.runBeforeToolCall ? { runBeforeToolCall: params.runBeforeToolCall } : {}), + ...(params.assertActive ? { assertActive: params.assertActive } : {}), + ...(params.onPreToolUseFailure ? { onPreToolUseFailure: params.onPreToolUseFailure } : {}), + }; + const binding: RelayBinding = { + registration, + token: Symbol("native-hook-relay-binding"), + foregroundOpen: false, + childSubjects: new Set(), + ...(retention ? { retention } : {}), + }; + registerNativeHookRelayApprovalOwner(registration, binding.token); + const route = existingRoute ?? registration; + preparedRoute = route; + preparedBinding = binding; + const lifetime = existingRoute + ? readRelayLifetime(existingRoute) + : { + bindings: new Set(), + childBindings: new Map(), + }; + if (!lifetime) { + throw new Error("native hook relay successor route is inactive"); + } + lifetime.bindings.add(binding); + if (!existingRoute) { + relays.set(relayId, route); + setRelayLifetime(route, lifetime); + } + if (params.signal) { + const abort = () => removeNativeHookRelayBinding(relayId, route, binding); + params.signal.addEventListener("abort", abort, { once: true }); + binding.removeAbortListener = () => params.signal?.removeEventListener("abort", abort); + if (params.signal.aborted) { + removeNativeHookRelayBinding(relayId, route, binding); + throw new Error("native hook relay registration aborted"); + } + } + if (!existingRoute) { + registerNativeHookRelayBridge(route, stateDbPath, invokeRelay); + } + const activateForegroundBinding = () => { + if (relays.get(relayId) !== route || !lifetime.bindings.has(binding)) { + throw new Error("native hook relay binding is inactive"); + } + // Renew before changing foreground ownership so a transient store failure + // leaves the last working binding authoritative. + if (!updateNativeHookRelayRouteExpiry(relayId, route)) { + throw new Error("native hook relay route renewal failed"); + } + for (const previous of lifetime.bindings) { + if (previous === binding) { + continue; + } + previous.foregroundOpen = false; + if (!shouldRetainNativeHookRelayBinding(previous)) { + removeNativeHookRelayBinding(relayId, route, previous); + } + } + if (relays.get(relayId) !== route || !lifetime.bindings.has(binding)) { + throw new Error("native hook relay binding is inactive"); + } + if (!binding.retained && params.runBeforeToolCall && retention) { + binding.retained = retainBeforeToolCallForNativeHookRelay(params.runBeforeToolCall); + } + binding.foregroundOpen = true; + lifetime.foreground = binding; + }; + const handle: RetainedNativeHookRelayHandle = { + ...registration, + shouldRelayEvent: (event) => nativeHookRelayEventHasLocalWork(registration, event), + toolMatcherForEvent: (event) => nativeHookRelayEventToolMatcher(registration, event), + commandForEvent: (event, options) => + buildNativeHookRelayCommandWithStateDatabase({ + provider: params.provider, + relayId, + stateDbPath, + generation: registration.generation, + event, + nice: params.command?.nice, + timeoutMs: resolveNativeHookRelayCommandTimeoutMs( + params.command?.timeoutMs, + options?.timeoutMs, + ), + executable: params.command?.executable, + nodeExecutable: params.command?.nodeExecutable, + }), + renew: (ttlMs) => { + if (relays.get(relayId) !== route || !lifetime.bindings.has(binding)) { + return; + } + const renewedExpiresAtMs = resolveNativeHookRelayExpiresAtMs(ttlMs); + if (renewedExpiresAtMs === undefined) { + return; + } + const previousExpiresAtMs = registration.expiresAtMs; + registration.expiresAtMs = renewedExpiresAtMs; + handle.expiresAtMs = renewedExpiresAtMs; + if (!updateNativeHookRelayRouteExpiry(relayId, route)) { + registration.expiresAtMs = previousExpiresAtMs; + handle.expiresAtMs = previousExpiresAtMs; + } + }, + unregister: () => deactivateNativeHookRelayForeground(relayId, route, binding), + activateForegroundBinding, + bindForegroundSubject: (subjectInput) => { + const subject = subjectInput.trim(); + if (!subject || relays.get(relayId) !== route || !lifetime.bindings.has(binding)) { + throw new Error("native hook relay foreground subject is invalid"); + } + if (binding.foregroundSubject && binding.foregroundSubject !== subject) { + throw new Error("native hook relay foreground subject already bound"); + } + binding.foregroundSubject = subject; + }, + bindRetainedSubject: (subjectInput) => { + const subject = subjectInput.trim(); + if (!subject || relays.get(relayId) !== route || !lifetime.bindings.has(binding)) { + throw new Error("native hook relay retained subject is invalid"); + } + const owner = lifetime.childBindings.get(subject); + if (owner && owner !== binding) { + throw new Error("native hook relay retained subject already claimed"); + } + lifetime.childBindings.set(subject, binding); + binding.childSubjects.add(subject); + let released = false; + return () => { + if (released) { + return; + } + released = true; + if (lifetime.childBindings.get(subject) === binding) { + lifetime.childBindings.delete(subject); + } + binding.childSubjects.delete(subject); + if (!binding.foregroundOpen && binding.childSubjects.size === 0) { + removeNativeHookRelayBinding(relayId, route, binding); + } + }; + }, + renewRetainedSubject: (subjectInput, ttlMs) => { + const subject = subjectInput.trim(); + if (subject && lifetime.childBindings.get(subject) === binding) { + handle.renew(ttlMs); + } + }, + }; + if (!composeWithExistingRoute) { + activateForegroundBinding(); + } else if (!updateNativeHookRelayRouteExpiry(relayId, route)) { + throw new Error("native hook relay route renewal failed"); + } + return handle; + } catch (error) { + if (preparedRoute && preparedBinding) { + removeNativeHookRelayBinding(relayId, preparedRoute, preparedBinding); + } + throw error; + } finally { + // The successor is authoritative before the old callback runs. A reentrant + // callback can therefore replace this registration normally instead of + // being overwritten by the outer replacement path. Finally also preserves + // the old callback if successor setup aborts partway through. + deliverReplacedRegistrationUnregister?.(); + } +} + +export function unregisterNativeHookRelay( + relayId: string, + expectedRegistration?: ActiveNativeHookRelayRegistration, + options?: { deferBridgeRecordRemovalMs?: number; deferOnUnregister?: boolean }, +): (() => void) | undefined { + if (expectedRegistration && relays.get(relayId) !== expectedRegistration) { + return undefined; + } + const route = expectedRegistration ?? relays.get(relayId); + if (!route) { + return undefined; + } + const lifetime = readRelayLifetime(route); + const bridge = relayBridges.get(relayId); + // Detach first: owner cleanup may register a same-id successor, which must + // never be removed by this registration's later resource cleanup. + if (relays.get(relayId) === route) { + relays.delete(relayId); + } + if (lifetime?.expiryTimer) { + clearTimeout(lifetime.expiryTimer); + } + const bindings = lifetime ? [...lifetime.bindings] : []; + lifetime?.bindings.clear(); + lifetime?.childBindings.clear(); + if (lifetime) { + lifetime.foreground = undefined; + } + for (const binding of bindings) { + binding.removeAbortListener?.(); + binding.retained?.release(); + } + relayLifetimes.delete(route); + unregisterNativeHookRelayBridge(relayId, { + ...options, + ...(bridge ? { expectedBridge: bridge } : {}), + }); + removeNativeHookRelayInvocations(relayId); + removeNativeHookRelayPreToolUseApprovals(relayId); + removeNativeHookRelayPermissionState(relayId); + const deliverOnUnregister = () => { + for (const binding of bindings) { + deliverNativeHookRelayBindingDispose(relayId, binding); + } + }; + if (options?.deferOnUnregister) { + return deliverOnUnregister; + } + deliverOnUnregister(); + return undefined; +} + +function deliverNativeHookRelayBindingDispose(relayId: string, binding: RelayBinding): void { + try { + binding.retention?.onDispose(); + } catch (error) { + try { + log.warn("native hook relay unregister callback failed", { error, relayId }); + } catch { + // Teardown has already detached every identity-bound resource. Logging + // must not turn an observer callback failure into a cleanup failure. + } + } +} + +function removeNativeHookRelayBinding( + relayId: string, + route: ActiveNativeHookRelayRegistration, + binding: RelayBinding, +): void { + const lifetime = readRelayLifetime(route); + if (relays.get(relayId) !== route || !lifetime?.bindings.delete(binding)) { + return; + } + if (lifetime.foreground === binding) { + lifetime.foreground = undefined; + } + binding.foregroundOpen = false; + for (const subject of binding.childSubjects) { + if (lifetime.childBindings.get(subject) === binding) { + lifetime.childBindings.delete(subject); + } + } + binding.childSubjects.clear(); + binding.removeAbortListener?.(); + binding.retained?.release(); + removeNativeHookRelayPendingApprovalsForOwner(relayId, binding.registration); + deliverNativeHookRelayBindingDispose(relayId, binding); + if (lifetime.bindings.size === 0) { + unregisterNativeHookRelay(relayId, route); + return; + } + updateNativeHookRelayRouteExpiry(relayId, route); +} + +function shouldRetainNativeHookRelayBinding(binding: RelayBinding): boolean { + if (!binding.retained || !binding.retention || binding.childSubjects.size === 0) { + return false; + } + try { + return binding.retention.shouldRetainAfterForegroundClose(); + } catch (error) { + try { + log.warn("native hook relay retention predicate failed", { + error, + relayId: binding.registration.relayId, + }); + } catch { + // A logging failure cannot make a throwing retention predicate retain authority. + } + return false; + } +} + +function deactivateNativeHookRelayForeground( + relayId: string, + route: ActiveNativeHookRelayRegistration, + binding: RelayBinding, +): void { + if (relays.get(relayId) !== route) { + return; + } + const lifetime = readRelayLifetime(route); + if (!lifetime?.bindings.has(binding)) { + return; + } + binding.foregroundOpen = false; + if (lifetime.foreground === binding) { + lifetime.foreground = undefined; + } + if (shouldRetainNativeHookRelayBinding(binding)) { + return; + } + removeNativeHookRelayBinding(relayId, route, binding); +} + +export async function resolveNativeHookRelayInvocationBinding( + route: ActiveNativeHookRelayRegistration, + event: NativeHookRelayEvent, + rawPayload: unknown, +): Promise { + const lifetime = readRelayLifetime(route); + if (!lifetime) { + throw new Error("native hook relay registration is inactive"); + } + const subjectReader = + lifetime.foreground?.retention ?? + [...lifetime.bindings].find((binding) => binding.retention)?.retention; + const claim = subjectReader?.readClaim(rawPayload); + if (claim) { + let binding = lifetime.childBindings.get(claim); + let assertAdmission: (() => boolean) | undefined; + if (!binding && event === "pre_tool_use") { + const foreground = lifetime.foreground; + const retention = foreground?.retention; + if (!foreground?.foregroundOpen || !retention?.awaitForegroundAdmission) { + throw new Error("native hook relay retained invocation not allowed"); + } + if (!foreground.registration.allowedEvents.includes(event)) { + throw new Error("native hook relay event not allowed"); + } + assertAdmission = await retention.awaitForegroundAdmission(claim); + if (!assertAdmission) { + throw new Error("native hook relay retained invocation not allowed"); + } + binding = lifetime.childBindings.get(claim); + } + if (!binding) { + throw new Error("native hook relay retained invocation not allowed"); + } + const selected = binding; + const retained = selected.retained; + const retention = selected.retention; + if (!retained || !retention) { + throw new Error("native hook relay retained invocation not allowed"); + } + const assertRetainedAuthority = () => { + if ( + relays.get(route.relayId) !== route || + !lifetime.bindings.has(selected) || + Date.now() > selected.registration.expiresAtMs + ) { + throw new Error("native hook relay registration is inactive"); + } + selected.registration.signal?.throwIfAborted(); + retained.assertActive(); + if (assertAdmission && !assertAdmission()) { + throw new Error("native hook relay retained invocation not allowed"); + } + if (lifetime.childBindings.get(claim) !== selected || !retention.allowPreToolUse(claim)) { + throw new Error("native hook relay retained invocation not allowed"); + } + }; + const effectiveRegistration = { + ...selected.registration, + assertActive: assertRetainedAuthority, + runBeforeToolCall: retained.runBeforeToolCall, + }; + inheritNativeHookRelayApprovalOwner(effectiveRegistration, selected.registration); + return effectiveRegistration; + } + const foreground = lifetime.foreground; + const foregroundSubject = subjectReader?.readForegroundSubject?.(rawPayload); + if (!foreground?.foregroundOpen || foreground.foregroundSubject !== foregroundSubject) { + throw new Error("native hook relay foreground invocation not allowed"); + } + const foregroundToken = foreground.token; + const assertActive = () => { + if ( + relays.get(route.relayId) !== route || + !lifetime.bindings.has(foreground) || + Date.now() > foreground.registration.expiresAtMs + ) { + throw new Error("native hook relay registration is inactive"); + } + foreground.registration.signal?.throwIfAborted(); + foreground.registration.assertActive?.(); + if ( + lifetime.foreground !== foreground || + !foreground.foregroundOpen || + foreground.token !== foregroundToken + ) { + throw new Error("native hook relay foreground invocation not allowed"); + } + }; + const effectiveRegistration = { ...foreground.registration, assertActive }; + inheritNativeHookRelayApprovalOwner(effectiveRegistration, foreground.registration); + return effectiveRegistration; +} + +function normalizeRelayKey( + value: string | undefined, + kind: "id" | "generation", +): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.length > 160 || !/^[A-Za-z0-9._:-]+$/u.test(trimmed)) { + throw new Error(`native hook relay ${kind} must be non-empty, compact, and URL-safe`); + } + return trimmed; +} + +export function getNativeHookRelayRoute( + relayId: string, +): ActiveNativeHookRelayRegistration | undefined { + return relays.get(relayId); +} + +export function listNativeHookRelayRoutes(): Iterable< + readonly [string, ActiveNativeHookRelayRegistration] +> { + return relays; +} + +export function getNativeHookRelayRegistrationForTests( + relayId: string, +): NativeHookRelayRegistration | undefined { + const route = relays.get(relayId); + return route ? (readRelayLifetime(route)?.foreground?.registration ?? route) : undefined; +} + +function removeNativeHookRelayInvocations(relayId: string): void { + for (let index = invocations.length - 1; index >= 0; index -= 1) { + if (invocations[index]?.relayId === relayId) { + invocations.splice(index, 1); + } + } +} + +export function pruneExpiredNativeHookRelays(now = Date.now()): void { + for (const [relayId, route] of relays) { + const lifetime = readRelayLifetime(route); + for (const binding of lifetime?.bindings ?? []) { + if (now > binding.registration.expiresAtMs) { + removeNativeHookRelayBinding(relayId, route, binding); + } + } + } +} + +function normalizeAllowedEvents( + events: readonly NativeHookRelayEvent[] | undefined, +): readonly NativeHookRelayEvent[] { + if (!events?.length) { + return NATIVE_HOOK_RELAY_EVENTS; + } + return [...new Set(events)]; +} diff --git a/src/agents/harness/native-hook-relay-permission-gateway.ts b/src/agents/harness/native-hook-relay-permission-gateway.ts new file mode 100644 index 000000000000..d017c011308f --- /dev/null +++ b/src/agents/harness/native-hook-relay-permission-gateway.ts @@ -0,0 +1,167 @@ +import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; +import { isApprovalNotFoundError } from "../../infra/approval-errors.js"; +import { toErrorObject } from "../../infra/errors.js"; +import { PluginApprovalResolutions } from "../../plugins/types.js"; +import { callGatewayTool } from "../tools/gateway.js"; +import type { + NativeHookRelayPermissionApprovalRequest, + NativeHookRelayPermissionApprovalResult, + NativeHookRelayProvider, +} from "./native-hook-relay-types.js"; +import { readOptionalNonEmptyString, truncateRelayText } from "./native-hook-relay-utils.js"; + +const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; +const MAX_APPROVAL_TITLE_LENGTH = 80; +const MAX_APPROVAL_DESCRIPTION_LENGTH = 700; + +export async function requestNativeHookRelayPermissionApproval( + request: NativeHookRelayPermissionApprovalRequest, +): Promise { + const timeoutMs = DEFAULT_PERMISSION_TIMEOUT_MS; + const requestResult: { id?: string; decision?: string | null } = await callGatewayTool( + "plugin.approval.request", + { timeoutMs: timeoutMs + 10_000 }, + { + pluginId: `openclaw-native-hook-relay-${request.provider}`, + title: truncateRelayText( + `${nativeHookRelayProviderDisplayName(request.provider)} permission request`, + MAX_APPROVAL_TITLE_LENGTH, + ), + description: truncateRelayText( + formatPermissionApprovalDescription(request), + MAX_APPROVAL_DESCRIPTION_LENGTH, + ), + severity: "warning", + toolName: request.toolName, + toolCallId: request.toolCallId, + allowedDecisions: [ + PluginApprovalResolutions.ALLOW_ONCE, + PluginApprovalResolutions.ALLOW_ALWAYS, + PluginApprovalResolutions.DENY, + ], + agentId: request.agentId, + sessionKey: request.sessionKey, + timeoutMs, + twoPhase: true, + }, + { expectFinal: false }, + ); + const approvalId = requestResult?.id; + if (!approvalId) { + return "defer"; + } + let decision: string | null | undefined; + if (Object.hasOwn(requestResult ?? {}, "decision")) { + decision = requestResult.decision; + } else { + const waitResult = await waitForNativeHookRelayApprovalDecision({ + approvalId, + signal: request.signal, + timeoutMs, + }); + // Bind the verdict to the request that parked this call. A stale or + // misrouted reply must never release a different tool gate. + if (!waitResult || waitResult.id !== approvalId) { + return "defer"; + } + decision = waitResult.decision; + } + if (decision === PluginApprovalResolutions.ALLOW_ONCE) { + return "allow"; + } + if (decision === PluginApprovalResolutions.ALLOW_ALWAYS) { + return "allow-always"; + } + if (decision === PluginApprovalResolutions.DENY) { + return "deny"; + } + return decision == null ? "timed-out" : "defer"; +} + +async function waitForNativeHookRelayApprovalDecision(params: { + approvalId: string; + signal?: AbortSignal; + timeoutMs: number; +}): Promise<{ id?: string; decision?: string | null } | undefined> { + const waitPromise: Promise<{ id?: string; decision?: string | null } | undefined> = + callGatewayTool( + "plugin.approval.waitDecision", + { timeoutMs: params.timeoutMs + 10_000 }, + { id: params.approvalId }, + ).catch((error: unknown) => { + if (isApprovalNotFoundError(error)) { + return undefined; + } + throw error; + }); + if (!params.signal) { + return waitPromise; + } + let onAbort: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + if (params.signal!.aborted) { + reject(toErrorObject(params.signal!.reason, "Non-Error rejection")); + return; + } + onAbort = () => reject(toErrorObject(params.signal!.reason, "Non-Error rejection")); + params.signal!.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([waitPromise, abortPromise]); + } finally { + if (onAbort) { + params.signal.removeEventListener("abort", onAbort); + } + } +} + +export function formatPermissionApprovalDescription( + request: NativeHookRelayPermissionApprovalRequest, +): string { + const lines = [ + `Tool: ${sanitizeApprovalText(request.toolName)}`, + request.cwd ? `Cwd: ${sanitizeApprovalText(request.cwd)}` : undefined, + request.model ? `Model: ${sanitizeApprovalText(request.model)}` : undefined, + formatToolInputPreview(request.toolInput), + ].filter((line): line is string => Boolean(line)); + return lines.join("\n"); +} + +function formatToolInputPreview(toolInput: Record): string | undefined { + const command = readOptionalNonEmptyString(toolInput.command); + if (command) { + return `Command: ${truncateRelayText(sanitizeApprovalText(command), 240)}`; + } + const keys = Object.keys(toolInput).map(sanitizeApprovalText).filter(Boolean).toSorted(); + if (!keys.length) { + return undefined; + } + const shownKeys = keys.slice(0, 12).join(", "); + const omitted = keys.length > 12 ? ` (${keys.length - 12} omitted)` : ""; + return `Input keys: ${shownKeys}${omitted}`; +} + +function sanitizeApprovalText(value: string): string { + let sanitized = ""; + for (const char of stripAnsi(value)) { + const codePoint = char.codePointAt(0); + sanitized += codePoint != null && isUnsafeApprovalCodePoint(codePoint) ? " " : char; + } + return sanitized.replace(/\s+/g, " ").trim(); +} + +function isUnsafeApprovalCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0 && codePoint <= 8) || + codePoint === 11 || + codePoint === 12 || + (codePoint >= 14 && codePoint <= 31) || + (codePoint >= 127 && codePoint <= 159) || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + ); +} + +function nativeHookRelayProviderDisplayName(provider: NativeHookRelayProvider): string { + return provider === "codex" ? "Codex" : provider; +} diff --git a/src/agents/harness/native-hook-relay-permissions.ts b/src/agents/harness/native-hook-relay-permissions.ts index e81c48d0fd6d..80a9200cac3c 100644 --- a/src/agents/harness/native-hook-relay-permissions.ts +++ b/src/agents/harness/native-hook-relay-permissions.ts @@ -3,9 +3,6 @@ import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, } from "@openclaw/normalization-core/number-coercion"; -import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; -import { isApprovalNotFoundError } from "../../infra/approval-errors.js"; -import { toErrorObject } from "../../infra/errors.js"; import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { prepareSystemRunMutableFileBinding, @@ -13,17 +10,16 @@ import { type SystemRunMutableFileBinding, } from "../../infra/system-run-approval-binding.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { PluginApprovalResolutions } from "../../plugins/types.js"; import { cancelDeferredPluginToolApproval, requestDeferredPluginToolApproval, type DeferredPluginToolApproval, } from "../agent-tools.before-tool-call.js"; -import { callGatewayTool } from "../tools/gateway.js"; import { nativeHookRelayParamsWereRewritten, normalizeNativeHookToolName, } from "./native-hook-relay-codec.js"; +import { requestNativeHookRelayPermissionApproval } from "./native-hook-relay-permission-gateway.js"; import { MAX_NATIVE_HOOK_RELAY_INVOCATIONS, nativeHookRelayState, @@ -37,7 +33,6 @@ import type { NativeHookRelayPermissionApprovalResult, NativeHookRelayPreToolUseApproval, NativeHookRelayProcessResponse, - NativeHookRelayProvider, NativeHookRelayProviderAdapter, NativeHookRelayRegistration, } from "./native-hook-relay-types.js"; @@ -45,13 +40,10 @@ import { readOptionalNonEmptyString, truncateRelayText } from "./native-hook-rel export type NativeHookRelayDeferredToolApprovalRequester = typeof requestDeferredPluginToolApproval; -const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; const PERMISSION_ALLOW_ALWAYS_TTL_MS = 30 * 60 * 1000; const MAX_PERMISSION_FALLBACK_KEYS = 200; const MAX_PERMISSION_FALLBACK_KEY_CHARS = 240; const MAX_PERMISSION_FINGERPRINT_SORT_KEYS = 200; -const MAX_APPROVAL_TITLE_LENGTH = 80; -const MAX_APPROVAL_DESCRIPTION_LENGTH = 700; const MAX_PERMISSION_APPROVALS_PER_WINDOW = 12; const PERMISSION_APPROVAL_WINDOW_MS = 60_000; const MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES = 512; @@ -67,12 +59,32 @@ const NATIVE_SHELL_APPROVAL_TOOLS = new Set([ ]); const { + approvalOwners, pendingPermissionApprovals, + pendingPermissionApprovalOwners, pendingPreToolUseApprovals, + pendingPreToolUseApprovalOwners, permissionApprovalWindows, permissionAllowAlwaysApprovals, } = nativeHookRelayState; +export function registerNativeHookRelayApprovalOwner( + registration: NativeHookRelayRegistration, + owner: symbol, +): void { + approvalOwners.set(registration, owner); +} + +export function inheritNativeHookRelayApprovalOwner( + registration: NativeHookRelayRegistration, + ownerRegistration: NativeHookRelayRegistration, +): void { + const owner = approvalOwners.get(ownerRegistration); + if (owner) { + approvalOwners.set(registration, owner); + } +} + let nativeHookRelayPermissionApprovalRequester: NativeHookRelayPermissionApprovalRequester = requestNativeHookRelayPermissionApproval; let nativeHookRelayDeferredToolApprovalRequester: NativeHookRelayDeferredToolApprovalRequester = @@ -80,30 +92,41 @@ let nativeHookRelayDeferredToolApprovalRequester: NativeHookRelayDeferredToolApp function nativeHookRelayPreToolUseApprovalKey(params: { relayId: string; + turnId?: string; toolUseId?: string; }): string | undefined { + const turnId = params.turnId?.trim(); const toolUseId = params.toolUseId?.trim(); - return toolUseId ? `${params.relayId}:${toolUseId}` : undefined; + return turnId && toolUseId ? `${params.relayId}:${turnId}:${toolUseId}` : undefined; } export function setNativeHookRelayPreToolUseApproval(params: { - relayId: string; + registration: NativeHookRelayRegistration; + turnId?: string; toolUseId?: string; deferredApproval: DeferredPluginToolApproval; originalParamsFingerprint: string; }): boolean { - const key = nativeHookRelayPreToolUseApprovalKey(params); - if (!key) { + const key = nativeHookRelayPreToolUseApprovalKey({ + relayId: params.registration.relayId, + turnId: params.turnId, + toolUseId: params.toolUseId, + }); + const owner = approvalOwners.get(params.registration); + if (!key || !owner) { return false; } const previousApproval = pendingPreToolUseApprovals.get(key); if (previousApproval) { cancelDeferredPluginToolApproval(previousApproval.deferredApproval); + pendingPreToolUseApprovalOwners.delete(key); } pendingPreToolUseApprovals.set(key, { deferredApproval: params.deferredApproval, originalParamsFingerprint: params.originalParamsFingerprint, + ...(params.registration.assertActive ? { assertActive: params.registration.assertActive } : {}), }); + pendingPreToolUseApprovalOwners.set(key, owner); if (pendingPreToolUseApprovals.size > MAX_NATIVE_HOOK_RELAY_INVOCATIONS) { const oldestKey = pendingPreToolUseApprovals.keys().next().value; if (oldestKey) { @@ -112,6 +135,7 @@ export function setNativeHookRelayPreToolUseApproval(params: { cancelDeferredPluginToolApproval(oldestApproval.deferredApproval); } pendingPreToolUseApprovals.delete(oldestKey); + pendingPreToolUseApprovalOwners.delete(oldestKey); } } return true; @@ -123,12 +147,14 @@ export function removeNativeHookRelayPreToolUseApprovals(relayId: string): void if (key.startsWith(prefix)) { cancelDeferredPluginToolApproval(pendingApproval.deferredApproval); pendingPreToolUseApprovals.delete(key); + pendingPreToolUseApprovalOwners.delete(key); } } } export async function resolveNativeHookRelayDeferredToolApproval(params: { relayId: string; + turnId?: string; toolUseId?: string; signal?: AbortSignal; }): Promise { @@ -146,6 +172,7 @@ export async function resolveNativeHookRelayDeferredToolApproval(params: { ).finally(() => { if (pendingPreToolUseApprovals.get(pendingApprovalKey) === pendingApproval) { pendingPreToolUseApprovals.delete(pendingApprovalKey); + pendingPreToolUseApprovalOwners.delete(pendingApprovalKey); } }); return pendingApproval.resolutionPromise; @@ -159,6 +186,7 @@ async function resolveNativeHookRelayPreToolUseApproval( deferredApproval: pendingApproval.deferredApproval, signal, }); + pendingApproval.assertActive?.(); if (outcome.blocked) { return { handled: true, @@ -290,12 +318,41 @@ async function startNativeHookRelayPermissionApprovalWithBudget(params: { nativeHookRelayPermissionApprovalRequester(params.request).finally(() => { if (pendingPermissionApprovals.get(params.approvalKey) === approval) { pendingPermissionApprovals.delete(params.approvalKey); + pendingPermissionApprovalOwners.delete(params.approvalKey); } }); pendingPermissionApprovals.set(params.approvalKey, approval); + const owner = approvalOwners.get(params.registration); + if (owner) { + pendingPermissionApprovalOwners.set(params.approvalKey, owner); + } return approval; } +export function removeNativeHookRelayPendingApprovalsForOwner( + relayId: string, + registration: NativeHookRelayRegistration, +): void { + const owner = approvalOwners.get(registration); + if (!owner) { + return; + } + const prefix = `${relayId}:`; + for (const [key, pendingApproval] of pendingPreToolUseApprovals) { + if (key.startsWith(prefix) && pendingPreToolUseApprovalOwners.get(key) === owner) { + cancelDeferredPluginToolApproval(pendingApproval.deferredApproval); + pendingPreToolUseApprovals.delete(key); + pendingPreToolUseApprovalOwners.delete(key); + } + } + for (const key of pendingPermissionApprovals.keys()) { + if (key.startsWith(prefix) && pendingPermissionApprovalOwners.get(key) === owner) { + pendingPermissionApprovals.delete(key); + pendingPermissionApprovalOwners.delete(key); + } + } +} + function nativeHookRelayPermissionApprovalKey(params: { registration: NativeHookRelayRegistration; request: NativeHookRelayPermissionApprovalRequest; @@ -371,14 +428,9 @@ function permissionRequestFallbackKey(request: NativeHookRelayPermissionApproval } return `${request.toolName}:keys:${permissionRequestToolInputKeyFingerprint(request.toolInput)}`; } - -export function permissionRequestToolInputKeyFingerprintForTests( +export function permissionRequestToolInputKeyFingerprint( toolInput: Record, ): string { - return permissionRequestToolInputKeyFingerprint(toolInput); -} - -function permissionRequestToolInputKeyFingerprint(toolInput: Record): string { let fingerprint = ""; const { keys, truncated } = readBoundedOwnKeys(toolInput, MAX_PERMISSION_FALLBACK_KEYS); for (const key of keys) { @@ -396,13 +448,7 @@ function permissionRequestToolInputKeyFingerprint(toolInput: Record { - const timeoutMs = DEFAULT_PERMISSION_TIMEOUT_MS; - const requestResult: { id?: string; decision?: string | null } = await callGatewayTool( - "plugin.approval.request", - { timeoutMs: timeoutMs + 10_000 }, - { - pluginId: `openclaw-native-hook-relay-${request.provider}`, - title: truncateRelayText( - `${nativeHookRelayProviderDisplayName(request.provider)} permission request`, - MAX_APPROVAL_TITLE_LENGTH, - ), - description: truncateRelayText( - formatPermissionApprovalDescription(request), - MAX_APPROVAL_DESCRIPTION_LENGTH, - ), - severity: "warning", - toolName: request.toolName, - toolCallId: request.toolCallId, - allowedDecisions: [ - PluginApprovalResolutions.ALLOW_ONCE, - PluginApprovalResolutions.ALLOW_ALWAYS, - PluginApprovalResolutions.DENY, - ], - agentId: request.agentId, - sessionKey: request.sessionKey, - timeoutMs, - twoPhase: true, - }, - { expectFinal: false }, - ); - const approvalId = requestResult?.id; - if (!approvalId) { - return "defer"; - } - let decision: string | null | undefined; - if (Object.hasOwn(requestResult ?? {}, "decision")) { - decision = requestResult.decision; - } else { - const waitResult = await waitForNativeHookRelayApprovalDecision({ - approvalId, - signal: request.signal, - timeoutMs, - }); - // Bind the verdict to the request that parked this call. A stale or - // misrouted reply must never release a different tool gate. - if (!waitResult || waitResult.id !== approvalId) { - return "defer"; - } - decision = waitResult.decision; - } - if (decision === PluginApprovalResolutions.ALLOW_ONCE) { - return "allow"; - } - if (decision === PluginApprovalResolutions.ALLOW_ALWAYS) { - return "allow-always"; - } - if (decision === PluginApprovalResolutions.DENY) { - return "deny"; - } - return decision == null ? "timed-out" : "defer"; -} - -async function waitForNativeHookRelayApprovalDecision(params: { - approvalId: string; - signal?: AbortSignal; - timeoutMs: number; -}): Promise<{ id?: string; decision?: string | null } | undefined> { - const waitPromise: Promise<{ id?: string; decision?: string | null } | undefined> = - callGatewayTool( - "plugin.approval.waitDecision", - { timeoutMs: params.timeoutMs + 10_000 }, - { id: params.approvalId }, - ).catch((error: unknown) => { - if (isApprovalNotFoundError(error)) { - return undefined; - } - throw error; - }); - if (!params.signal) { - return waitPromise; - } - let onAbort: (() => void) | undefined; - const abortPromise = new Promise((_, reject) => { - if (params.signal!.aborted) { - reject(toErrorObject(params.signal!.reason, "Non-Error rejection")); - return; - } - onAbort = () => reject(toErrorObject(params.signal!.reason, "Non-Error rejection")); - params.signal!.addEventListener("abort", onAbort, { once: true }); - }); - try { - return await Promise.race([waitPromise, abortPromise]); - } finally { - if (onAbort) { - params.signal.removeEventListener("abort", onAbort); - } - } -} - -export function formatPermissionApprovalDescriptionForTests( - request: NativeHookRelayPermissionApprovalRequest, -): string { - return formatPermissionApprovalDescription(request); -} - -function formatPermissionApprovalDescription( - request: NativeHookRelayPermissionApprovalRequest, -): string { - const lines = [ - `Tool: ${sanitizeApprovalText(request.toolName)}`, - request.cwd ? `Cwd: ${sanitizeApprovalText(request.cwd)}` : undefined, - request.model ? `Model: ${sanitizeApprovalText(request.model)}` : undefined, - formatToolInputPreview(request.toolInput), - ].filter((line): line is string => Boolean(line)); - return lines.join("\n"); -} - -function formatToolInputPreview(toolInput: Record): string | undefined { - const command = readOptionalNonEmptyString(toolInput.command); - if (command) { - return `Command: ${truncateRelayText(sanitizeApprovalText(command), 240)}`; - } - const keys = Object.keys(toolInput).map(sanitizeApprovalText).filter(Boolean).toSorted(); - if (!keys.length) { - return undefined; - } - const shownKeys = keys.slice(0, 12).join(", "); - const omitted = keys.length > 12 ? ` (${keys.length - 12} omitted)` : ""; - return `Input keys: ${shownKeys}${omitted}`; -} - -function sanitizeApprovalText(value: string): string { - let sanitized = ""; - for (const char of stripAnsi(value)) { - const codePoint = char.codePointAt(0); - sanitized += codePoint != null && isUnsafeApprovalCodePoint(codePoint) ? " " : char; - } - return sanitized.replace(/\s+/g, " ").trim(); -} - -function isUnsafeApprovalCodePoint(codePoint: number): boolean { - return ( - (codePoint >= 0 && codePoint <= 8) || - codePoint === 11 || - codePoint === 12 || - (codePoint >= 14 && codePoint <= 31) || - (codePoint >= 127 && codePoint <= 159) || - (codePoint >= 0x202a && codePoint <= 0x202e) || - (codePoint >= 0x2066 && codePoint <= 0x2069) - ); -} - -function nativeHookRelayProviderDisplayName(provider: NativeHookRelayProvider): string { - return provider === "codex" ? "Codex" : provider; -} - export function setNativeHookRelayPermissionApprovalRequesterForTests( requester: NativeHookRelayPermissionApprovalRequester, ): void { @@ -730,10 +619,12 @@ export function setNativeHookRelayDeferredToolApprovalRequesterForTests( export function clearNativeHookRelayPermissionsForTests(): void { pendingPermissionApprovals.clear(); + pendingPermissionApprovalOwners.clear(); for (const pendingApproval of pendingPreToolUseApprovals.values()) { cancelDeferredPluginToolApproval(pendingApproval.deferredApproval); } pendingPreToolUseApprovals.clear(); + pendingPreToolUseApprovalOwners.clear(); permissionApprovalWindows.clear(); permissionAllowAlwaysApprovals.clear(); nativeHookRelayPermissionApprovalRequester = requestNativeHookRelayPermissionApproval; diff --git a/src/agents/harness/native-hook-relay-state.ts b/src/agents/harness/native-hook-relay-state.ts index 5d0f0a5f22ca..21bf8a4e3b74 100644 --- a/src/agents/harness/native-hook-relay-state.ts +++ b/src/agents/harness/native-hook-relay-state.ts @@ -11,8 +11,11 @@ function getNativeHookRelaySharedState(): NativeHookRelaySharedState { relays: new Map(), relayBridges: new Map(), invocations: [], + approvalOwners: new WeakMap(), pendingPermissionApprovals: new Map(), + pendingPermissionApprovalOwners: new Map(), pendingPreToolUseApprovals: new Map(), + pendingPreToolUseApprovalOwners: new Map(), permissionApprovalWindows: new Map(), permissionAllowAlwaysApprovals: new Map(), }; diff --git a/src/agents/harness/native-hook-relay-types.ts b/src/agents/harness/native-hook-relay-types.ts index 9bb90b958adc..e9ee2d99daf7 100644 --- a/src/agents/harness/native-hook-relay-types.ts +++ b/src/agents/harness/native-hook-relay-types.ts @@ -229,6 +229,7 @@ export type NativeHookRelayPermissionApprovalRequester = ( export type NativeHookRelayPreToolUseApproval = { deferredApproval: DeferredPluginToolApproval; originalParamsFingerprint: string; + assertActive?: () => void; resolutionPromise?: Promise; }; @@ -255,8 +256,11 @@ export type NativeHookRelaySharedState = { relays: Map; relayBridges: Map; invocations: NativeHookRelayInvocation[]; + approvalOwners: WeakMap; pendingPermissionApprovals: Map>; + pendingPermissionApprovalOwners: Map; pendingPreToolUseApprovals: Map; + pendingPreToolUseApprovalOwners: Map; permissionApprovalWindows: Map; permissionAllowAlwaysApprovals: Map; }; diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index 91ea8ba62c02..0121681cab6d 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -56,6 +56,13 @@ function readTestNativeAgentId(rawPayload: unknown): string | undefined { return rawPayload.agent_id.trim() || undefined; } +function readTestNativeTurnId(rawPayload: unknown): string | undefined { + if (!isRecord(rawPayload) || typeof rawPayload.turn_id !== "string") { + return undefined; + } + return rawPayload.turn_id.trim() || undefined; +} + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); @@ -210,6 +217,7 @@ type NativeHookRelaySharedStateForTests = { relayBridges: Map; invocations: unknown[]; pendingPermissionApprovals: Map; + pendingPreToolUseApprovals: Map; permissionApprovalWindows: Map; permissionAllowAlwaysApprovals: Map; }; @@ -436,6 +444,7 @@ describe("native hook relay registry", () => { onDispose: () => {}, }, }); + relay.bindRetainedSubject("child-thread"); const invocation = invokeNativeHookRelay({ provider: "codex", relayId: relay.relayId, @@ -468,8 +477,12 @@ describe("native hook relay registry", () => { throw new Error("Expected admitted delegated authority"); } const afterToolCall = vi.fn(); + const beforeAgentFinalize = vi.fn(); initializeGlobalHookRunner( - createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]), + createMockPluginRegistry([ + { hookName: "after_tool_call", handler: afterToolCall }, + { hookName: "before_agent_finalize", handler: beforeAgentFinalize }, + ]), ); const approvalRequester = vi.fn(async () => "allow" as const); testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester); @@ -479,7 +492,12 @@ describe("native hook relay registry", () => { relayId: "codex-retained-direct-child", sessionId: "session-1", runId: "run-retained-child", - allowedEvents: ["pre_tool_use", "permission_request", "post_tool_use"], + allowedEvents: [ + "pre_tool_use", + "permission_request", + "post_tool_use", + "before_agent_finalize", + ], runBeforeToolCall: hostCapabilities.runBeforeToolCall, assertActive: hostCapabilities.assertActive, retention: { @@ -489,6 +507,7 @@ describe("native hook relay registry", () => { onDispose: () => {}, }, }); + relay.bindRetainedSubject("child-thread"); await expect( invokeNativeHookRelay({ @@ -574,11 +593,11 @@ describe("native hook relay registry", () => { rawPayload: { agent_id: "child-thread", hook_event_name: "PermissionRequest", - tool_name: "Bash", + tool_name: "mcp__test__tool", tool_input: { command: "true" }, }, }), - ).rejects.toThrow("foreground invocation not allowed"); + ).resolves.toMatchObject({ exitCode: 0 }); await expect( invokeNativeHookRelay({ provider: "codex", @@ -593,7 +612,22 @@ describe("native hook relay registry", () => { tool_use_id: "child-post-tool-after-close", }, }), - ).rejects.toThrow("foreground invocation not allowed"); + ).resolves.toMatchObject({ exitCode: 0 }); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "before_agent_finalize", + rawPayload: { + agent_id: "child-thread", + hook_event_name: "SubagentStop", + turn_id: "child-turn", + }, + }), + ).resolves.toMatchObject({ exitCode: 0 }); + expect(approvalRequester).toHaveBeenCalledTimes(2); + expect(afterToolCall).toHaveBeenCalledTimes(2); + expect(beforeAgentFinalize).toHaveBeenCalledOnce(); await expect( invokeNativeHookRelay({ provider: "codex", @@ -623,6 +657,535 @@ describe("native hook relay registry", () => { relay.unregister(); }); + it("routes every retained event by child id before exact foreground turn", async () => { + const relayId = uniqueNativeHookRelayIdForTests("retained-event-subjects"); + testing.setNativeHookRelayPermissionApprovalRequesterForTests(async () => "allow"); + const fixtureA = await createAdmittedHostCapabilityTestFixture({ runId: "run-a" }); + const fixtureB = await createAdmittedHostCapabilityTestFixture({ runId: "run-b" }); + const retention = { + readClaim: readTestNativeAgentId, + readForegroundSubject: readTestNativeTurnId, + shouldRetainAfterForegroundClose: () => true, + allowPreToolUse: (claim: string) => claim === "child-a", + onDispose: () => {}, + }; + const relayA = registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + sessionId: "session-1", + runId: "run-a", + runBeforeToolCall: fixtureA.hostCapabilities.runBeforeToolCall, + assertActive: fixtureA.hostCapabilities.assertActive, + retention, + }); + relayA.bindForegroundSubject("turn-a"); + const releaseChildA = relayA.bindRetainedSubject("child-a"); + const relayB = registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + generation: relayA.generation, + sessionId: "session-1", + runId: "run-b", + runBeforeToolCall: fixtureB.hostCapabilities.runBeforeToolCall, + assertActive: fixtureB.hostCapabilities.assertActive, + composeWithExistingRoute: true, + retention, + }); + relayB.bindForegroundSubject("turn-b"); + relayB.activateForegroundBinding(); + + const events = [ + { + event: "pre_tool_use", + payload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: {}, + tool_use_id: "tool-pre", + }, + }, + { + event: "permission_request", + payload: { hook_event_name: "PermissionRequest", tool_name: "Bash", tool_input: {} }, + }, + { + event: "post_tool_use", + payload: { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: {}, + tool_response: {}, + tool_use_id: "tool-post", + }, + }, + { + event: "before_agent_finalize", + payload: { hook_event_name: "SubagentStop" }, + }, + ] as const; + + for (const { event, payload } of events) { + await invokeNativeHookRelay({ + provider: "codex", + relayId, + event, + rawPayload: { ...payload, agent_id: "child-a", turn_id: "turn-b" }, + }); + await invokeNativeHookRelay({ + provider: "codex", + relayId, + event, + rawPayload: { ...payload, turn_id: "turn-b" }, + }); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + event, + rawPayload: { ...payload, agent_id: "unknown-child", turn_id: "turn-b" }, + }), + ).rejects.toThrow("retained invocation not allowed"); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + event, + rawPayload: { ...payload, turn_id: "turn-a" }, + }), + ).rejects.toThrow("foreground invocation not allowed"); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + event, + rawPayload: payload, + }), + ).rejects.toThrow("foreground invocation not allowed"); + } + + expect( + testing + .getNativeHookRelayInvocationsForTests() + .slice(-8) + .map(({ event, runId }) => [event, runId]), + ).toEqual( + events.flatMap(({ event }) => [ + [event, "run-a"], + [event, "run-b"], + ]), + ); + + relayB.unregister(); + releaseChildA(); + closeAdmittedRunDelegatedAuthority(fixtureA.admittedRunContext); + closeAdmittedRunDelegatedAuthority(fixtureB.admittedRunContext); + }); + + it("keeps a composed cold-resume binding inactive until its thread claim commits", async () => { + const relay = registerRetainedNativeHookRelay({ + provider: "codex", + relayId: uniqueNativeHookRelayIdForTests("cold-resume-activation"), + sessionId: "session-1", + runId: "run-resume", + allowedEvents: ["post_tool_use"], + composeWithExistingRoute: true, + retention: { + readClaim: readTestNativeAgentId, + readForegroundSubject: readTestNativeTurnId, + shouldRetainAfterForegroundClose: () => false, + allowPreToolUse: () => false, + onDispose: () => {}, + }, + }); + relay.bindForegroundSubject("turn-resume"); + const invoke = () => + invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "post_tool_use", + rawPayload: { + hook_event_name: "PostToolUse", + turn_id: "turn-resume", + tool_name: "Bash", + tool_input: {}, + tool_response: {}, + tool_use_id: "tool-resume", + }, + }); + + await expect(invoke()).rejects.toThrow("foreground invocation not allowed"); + relay.activateForegroundBinding(); + await expect(invoke()).resolves.toMatchObject({ exitCode: 0 }); + + relay.unregister(); + }); + + it("keeps the previous foreground when composed route renewal fails", async () => { + const relayId = uniqueNativeHookRelayIdForTests("composed-renewal-failure"); + const retention = { + readClaim: readTestNativeAgentId, + readForegroundSubject: readTestNativeTurnId, + shouldRetainAfterForegroundClose: () => false, + allowPreToolUse: () => false, + onDispose: () => {}, + }; + const relayA = registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + sessionId: "session-1", + runId: "run-a", + allowedEvents: ["post_tool_use"], + retention, + }); + relayA.bindForegroundSubject("turn-a"); + await waitForNativeHookRelayBridgeRecord(relayId); + const renewRoute = vi + .spyOn(nativeHookRelayBridge, "renewNativeHookRelayBridgeRecord") + .mockReturnValue("unavailable"); + const invoke = (turnId: string) => + invokeNativeHookRelay({ + provider: "codex", + relayId, + event: "post_tool_use", + rawPayload: { + hook_event_name: "PostToolUse", + turn_id: turnId, + tool_name: "Bash", + tool_response: {}, + }, + }); + + expect(() => + registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + generation: relayA.generation, + sessionId: "session-1", + runId: "run-b", + allowedEvents: ["post_tool_use"], + composeWithExistingRoute: true, + retention, + }), + ).toThrow("native hook relay route renewal failed"); + renewRoute.mockReset().mockReturnValueOnce("renewed").mockReturnValue("unavailable"); + const relayB = registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + generation: relayA.generation, + sessionId: "session-1", + runId: "run-b", + allowedEvents: ["post_tool_use"], + composeWithExistingRoute: true, + retention, + }); + relayB.bindForegroundSubject("turn-b"); + + expect(() => relayB.activateForegroundBinding()).toThrow( + "native hook relay route renewal failed", + ); + await expect(invoke("turn-a")).resolves.toMatchObject({ exitCode: 0 }); + await expect(invoke("turn-b")).rejects.toThrow( + "native hook relay foreground invocation not allowed", + ); + + relayB.unregister(); + relayA.unregister(); + }); + + it("fails closed when foreground replacement loses route ownership", async () => { + const relayId = uniqueNativeHookRelayIdForTests("activation-ownership-loss"); + const disposedA = vi.fn(); + const disposedB = vi.fn(); + const register = (runId: string, onDispose: () => void, compose = false) => + registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + sessionId: "session-1", + runId, + allowedEvents: ["post_tool_use"], + ...(compose ? { composeWithExistingRoute: true } : {}), + retention: { + readClaim: readTestNativeAgentId, + readForegroundSubject: readTestNativeTurnId, + shouldRetainAfterForegroundClose: () => false, + allowPreToolUse: () => false, + onDispose, + }, + }); + register("run-a", disposedA); + await waitForNativeHookRelayBridgeRecord(relayId); + vi.spyOn(nativeHookRelayBridge, "renewNativeHookRelayBridgeRecord") + .mockReturnValueOnce("renewed") + .mockReturnValueOnce("renewed") + .mockReturnValue("ownership-changed"); + const relayB = register("run-b", disposedB, true); + + expect(() => relayB.activateForegroundBinding()).toThrow( + "native hook relay binding is inactive", + ); + expect(testing.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined(); + expect(disposedA).toHaveBeenCalledOnce(); + expect(disposedB).toHaveBeenCalledOnce(); + }); + + it("uses each binding's event set before admitting a new child", async () => { + const relayId = uniqueNativeHookRelayIdForTests("binding-event-owner"); + const fixtureA = await createAdmittedHostCapabilityTestFixture({ runId: "run-a" }); + const fixtureB = await createAdmittedHostCapabilityTestFixture({ runId: "run-b" }); + const awaitForegroundAdmission = vi.fn(async () => () => true); + const relayA = registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + sessionId: "session-1", + runId: "run-a", + allowedEvents: ["pre_tool_use"], + runBeforeToolCall: fixtureA.hostCapabilities.runBeforeToolCall, + assertActive: fixtureA.hostCapabilities.assertActive, + retention: { + readClaim: readTestNativeAgentId, + readForegroundSubject: readTestNativeTurnId, + shouldRetainAfterForegroundClose: () => true, + allowPreToolUse: (claim) => claim === "child-a", + onDispose: () => {}, + }, + }); + const releaseChildA = relayA.bindRetainedSubject("child-a"); + const relayB = registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + generation: relayA.generation, + sessionId: "session-1", + runId: "run-b", + allowedEvents: ["post_tool_use"], + runBeforeToolCall: fixtureB.hostCapabilities.runBeforeToolCall, + assertActive: fixtureB.hostCapabilities.assertActive, + composeWithExistingRoute: true, + retention: { + readClaim: readTestNativeAgentId, + readForegroundSubject: readTestNativeTurnId, + shouldRetainAfterForegroundClose: () => true, + allowPreToolUse: () => false, + awaitForegroundAdmission, + onDispose: () => {}, + }, + }); + relayB.bindForegroundSubject("turn-b"); + relayB.activateForegroundBinding(); + + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + agent_id: "child-a", + tool_name: "Bash", + tool_input: {}, + tool_use_id: "child-a-tool", + }, + }), + ).resolves.toMatchObject({ exitCode: 0 }); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + event: "post_tool_use", + rawPayload: { + hook_event_name: "PostToolUse", + turn_id: "turn-b", + tool_name: "Bash", + tool_input: {}, + tool_response: {}, + tool_use_id: "foreground-b-tool", + }, + }), + ).resolves.toMatchObject({ exitCode: 0 }); + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + agent_id: "unknown-child", + tool_name: "Bash", + tool_input: {}, + tool_use_id: "unknown-child-tool", + }, + }), + ).rejects.toThrow("native hook relay event not allowed"); + expect(awaitForegroundAdmission).not.toHaveBeenCalled(); + + relayB.unregister(); + releaseChildA(); + closeAdmittedRunDelegatedAuthority(fixtureA.admittedRunContext); + closeAdmittedRunDelegatedAuthority(fixtureB.admittedRunContext); + }); + + it("validates route generation before reading binding claims", async () => { + const readClaim = vi.fn(readTestNativeAgentId); + const relay = registerRetainedNativeHookRelay({ + provider: "codex", + relayId: uniqueNativeHookRelayIdForTests("binding-validation-order"), + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + retention: { + readClaim, + shouldRetainAfterForegroundClose: () => true, + allowPreToolUse: () => true, + onDispose: () => {}, + }, + }); + const rawPayload = { + hook_event_name: "PreToolUse", + agent_id: "unclaimed-child", + tool_name: "Bash", + tool_input: {}, + }; + + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + generation: "stale-generation", + requireGeneration: true, + event: "pre_tool_use", + rawPayload, + }), + ).rejects.toThrow("native hook relay bridge stale registration"); + expect(readClaim).not.toHaveBeenCalled(); + }); + + it("removes only the released binding's pending permission decision", async () => { + const relayId = uniqueNativeHookRelayIdForTests("binding-permission-owner"); + const fixtureA = await createAdmittedHostCapabilityTestFixture({ runId: "run-a" }); + const fixtureB = await createAdmittedHostCapabilityTestFixture({ runId: "run-b" }); + const decisions = new Map void>(); + testing.setNativeHookRelayPermissionApprovalRequesterForTests( + (request) => + new Promise<"allow">((resolve) => { + decisions.set(request.runId, resolve); + }), + ); + const retention = { + readClaim: readTestNativeAgentId, + readForegroundSubject: readTestNativeTurnId, + shouldRetainAfterForegroundClose: () => true, + allowPreToolUse: (claim: string) => claim === "child-a", + onDispose: () => {}, + }; + const relayA = registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + sessionId: "session-1", + runId: "run-a", + runBeforeToolCall: fixtureA.hostCapabilities.runBeforeToolCall, + assertActive: fixtureA.hostCapabilities.assertActive, + retention, + }); + relayA.bindForegroundSubject("turn-a"); + const releaseChildA = relayA.bindRetainedSubject("child-a"); + const relayB = registerRetainedNativeHookRelay({ + provider: "codex", + relayId, + generation: relayA.generation, + sessionId: "session-1", + runId: "run-b", + runBeforeToolCall: fixtureB.hostCapabilities.runBeforeToolCall, + assertActive: fixtureB.hostCapabilities.assertActive, + composeWithExistingRoute: true, + retention, + }); + relayB.bindForegroundSubject("turn-b"); + relayB.activateForegroundBinding(); + + const invokePermission = (rawPayload: Record) => + invokeNativeHookRelay({ + provider: "codex", + relayId, + event: "permission_request", + rawPayload: { + hook_event_name: "PermissionRequest", + tool_name: "mcp__test__tool", + tool_input: {}, + ...rawPayload, + }, + }); + const childDecision = invokePermission({ agent_id: "child-a", turn_id: "turn-a" }); + const rootDecision = invokePermission({ turn_id: "turn-b" }); + await vi.waitFor(() => expect(decisions.size).toBe(2)); + + releaseChildA(); + + decisions.get("run-a")?.("allow"); + decisions.get("run-b")?.("allow"); + await expect(childDecision).rejects.toThrow("native hook relay registration is inactive"); + await expect(rootDecision).resolves.toMatchObject({ exitCode: 0 }); + + relayB.unregister(); + closeAdmittedRunDelegatedAuthority(fixtureA.admittedRunContext); + closeAdmittedRunDelegatedAuthority(fixtureB.admittedRunContext); + }); + + it.each(["permission_request", "before_agent_finalize"] as const)( + "rejects a late %s result after its selected binding closes", + async (event) => { + let active = true; + let settle: (() => void) | undefined; + if (event === "permission_request") { + testing.setNativeHookRelayPermissionApprovalRequesterForTests( + () => + new Promise<"allow">((resolve) => { + settle = () => resolve("allow"); + }), + ); + } else { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_agent_finalize", + handler: () => + new Promise((resolve) => { + settle = () => resolve(undefined); + }), + }, + ]), + ); + } + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: `run-late-${event}`, + allowedEvents: [event], + assertActive: () => { + if (!active) { + throw new Error("selected binding closed"); + } + }, + }); + const invocation = invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event, + rawPayload: + event === "permission_request" + ? { + hook_event_name: "PermissionRequest", + tool_name: "mcp__test__tool", + tool_input: {}, + } + : { hook_event_name: "Stop", turn_id: "turn-1" }, + }); + await vi.waitFor(() => expect(settle).toBeTypeOf("function")); + active = false; + settle?.(); + + await expect(invocation).rejects.toThrow("selected binding closed"); + }, + ); + it.each(["abort", "expiry"] as const)( "physically releases active retained child authority on %s", async (cause) => { @@ -652,6 +1215,7 @@ describe("native hook relay registry", () => { }, ...(cause === "abort" ? { signal: controller.signal } : { ttlMs: 5 }), }); + relay.bindRetainedSubject("child-thread"); closeAdmittedRunDelegatedAuthority(admittedRunContext); expect(validateAgentRunDelegatedAuthority(delegatedAuthority)).toBe(false); @@ -711,6 +1275,7 @@ describe("native hook relay registry", () => { onDispose: () => {}, }, }); + retaining.bindRetainedSubject("child-thread"); closeAdmittedRunDelegatedAuthority(admittedRunContext); ordinary.unregister(); @@ -2161,6 +2726,7 @@ describe("native hook relay registry", () => { hasNativeHookRelayInvocation({ relayId: relay.relayId, event: "pre_tool_use", + turnId: "turn-a", toolUseId: "call-1", }), ).toBe(false); @@ -2171,6 +2737,7 @@ describe("native hook relay registry", () => { event: "pre_tool_use", rawPayload: { hook_event_name: "PreToolUse", + turn_id: "turn-a", tool_name: "Bash", tool_use_id: "call-1", tool_input: { command: "pnpm test" }, @@ -2181,13 +2748,23 @@ describe("native hook relay registry", () => { hasNativeHookRelayInvocation({ relayId: relay.relayId, event: "pre_tool_use", + turnId: "turn-a", toolUseId: "call-1", }), ).toBe(true); + expect( + hasNativeHookRelayInvocation({ + relayId: relay.relayId, + event: "pre_tool_use", + turnId: "turn-b", + toolUseId: "call-1", + }), + ).toBe(false); expect( hasNativeHookRelayInvocation({ relayId: relay.relayId, event: "post_tool_use", + turnId: "turn-a", toolUseId: "call-1", }), ).toBe(false); @@ -2195,6 +2772,7 @@ describe("native hook relay registry", () => { hasNativeHookRelayInvocation({ relayId: relay.relayId, event: "pre_tool_use", + turnId: "turn-a", }), ).toBe(false); }); @@ -3036,6 +3614,7 @@ describe("native hook relay registry", () => { rawPayload: { hook_event_name: "PreToolUse", openclaw_approval_mode: "report", + turn_id: "turn-a", cwd: "/repo", tool_name: "exec_command", tool_use_id: "native-report-rewrite-1", @@ -3162,6 +3741,7 @@ describe("native hook relay registry", () => { rawPayload: { hook_event_name: "PreToolUse", openclaw_approval_mode: "report", + turn_id: "turn-a", cwd: "/repo", tool_name: "exec_command", tool_use_id: "native-approval-report-1", @@ -3198,6 +3778,7 @@ describe("native hook relay registry", () => { rawPayload: { hook_event_name: "PreToolUse", openclaw_approval_mode: "report", + turn_id: "turn-a", cwd: "/repo", tool_name: "exec_command", tool_use_id: "native-approval-report-duplicate", @@ -3218,12 +3799,22 @@ describe("native hook relay registry", () => { ); testing.setNativeHookRelayDeferredToolApprovalRequesterForTests(approvalRequester); + await expect( + resolveNativeHookRelayDeferredToolApproval({ + relayId: relay.relayId, + turnId: "turn-b", + toolUseId: "native-approval-report-duplicate", + }), + ).resolves.toBeUndefined(); + const firstApproval = resolveNativeHookRelayDeferredToolApproval({ relayId: relay.relayId, + turnId: "turn-a", toolUseId: "native-approval-report-duplicate", }); const duplicateApproval = resolveNativeHookRelayDeferredToolApproval({ relayId: relay.relayId, + turnId: "turn-a", toolUseId: "native-approval-report-duplicate", }); @@ -3241,6 +3832,7 @@ describe("native hook relay registry", () => { await expect( resolveNativeHookRelayDeferredToolApproval({ relayId: relay.relayId, + turnId: "turn-a", toolUseId: "native-approval-report-duplicate", }), ).resolves.toBeUndefined(); @@ -3270,6 +3862,7 @@ describe("native hook relay registry", () => { rawPayload: { hook_event_name: "PreToolUse", openclaw_approval_mode: "report", + turn_id: "turn-a", cwd: "/repo", tool_name: "exec_command", tool_use_id: "native-approval-cancelled", @@ -3287,6 +3880,7 @@ describe("native hook relay registry", () => { await expect( resolveNativeHookRelayDeferredToolApproval({ relayId: relay.relayId, + turnId: "turn-a", toolUseId: "native-approval-cancelled", }), ).resolves.toEqual({ diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index 4f4211793df9..4c91409e2e37 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -1,20 +1,9 @@ /** Native harness hook event relay and public Plugin SDK facade. */ -import { randomUUID } from "node:crypto"; -import { - MAX_TIMER_TIMEOUT_MS, - resolveExpiresAtMsFromDurationMs, -} from "@openclaw/normalization-core/number-coercion"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; -import { retainBeforeToolCallForNativeHookRelay } from "./host-capability.js"; import { clearNativeHookRelayBridgesForTests, - NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS, NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR, readNativeHookRelayBridgeRecordIfExists, - registerNativeHookRelayBridge, - renewNativeHookRelayBridgeRecord, - unregisterNativeHookRelayBridge, isRetryableNativeHookRelayBridgeLookupError, } from "./native-hook-relay-bridge.js"; import { @@ -23,23 +12,23 @@ import { normalizeNativeHookToolName, readNativeHookRelayApprovalMode, } from "./native-hook-relay-codec.js"; +import { processNativeHookRelayInvocation } from "./native-hook-relay-events.js"; import { - buildNativeHookRelayCommandWithStateDatabase, - resolveNativeHookRelayCommandTimeoutMs, -} from "./native-hook-relay-command.js"; -import { - nativeHookRelayEventHasLocalWork, - nativeHookRelayEventToolMatcher, - processNativeHookRelayInvocation, -} from "./native-hook-relay-events.js"; + getNativeHookRelayRegistrationForTests, + getNativeHookRelayRoute, + listNativeHookRelayRoutes, + pruneExpiredNativeHookRelays, + registerNativeHookRelayLifecycle, + registerRetainedNativeHookRelayLifecycle, + resolveNativeHookRelayInvocationBinding, + unregisterNativeHookRelay, +} from "./native-hook-relay-lifecycle.js"; +import type { NativeHookRelayRetention } from "./native-hook-relay-lifecycle.js"; +import { formatPermissionApprovalDescription } from "./native-hook-relay-permission-gateway.js"; import { + permissionRequestContentFingerprint, + permissionRequestToolInputKeyFingerprint, clearNativeHookRelayPermissionsForTests, - formatPermissionApprovalDescriptionForTests as formatPermissionApprovalDescriptionForTestsImpl, - permissionRequestContentFingerprintForTests as permissionRequestContentFingerprintForTestsImpl, - permissionRequestToolInputKeyFingerprintForTests as permissionRequestToolInputKeyFingerprintForTestsImpl, - pruneNativeHookRelayPermissionAllowAlways, - removeNativeHookRelayPermissionState, - removeNativeHookRelayPreToolUseApprovals, setNativeHookRelayDeferredToolApprovalRequesterForTests as setNativeHookRelayDeferredToolApprovalRequesterForTestsImpl, setNativeHookRelayPermissionApprovalRequesterForTests as setNativeHookRelayPermissionApprovalRequesterForTestsImpl, } from "./native-hook-relay-permissions.js"; @@ -54,16 +43,13 @@ import type { InvokeNativeHookRelayParams, NativeHookRelayEvent, NativeHookRelayInvocation, - NativeHookRelayPermissionApprovalRequest, NativeHookRelayPermissionApprovalRequester, NativeHookRelayProcessResponse, NativeHookRelayRegistration, RegisterNativeHookRelayParams, } from "./native-hook-relay-types.js"; -import { NATIVE_HOOK_RELAY_EVENTS } from "./native-hook-relay-types.js"; import { isJsonValue, - normalizePositiveInteger, readNativeHookRelayEvent, readNativeHookRelayProvider, readNonEmptyString, @@ -71,6 +57,7 @@ import { } from "./native-hook-relay-utils.js"; export { buildNativeHookRelayCommand } from "./native-hook-relay-command.js"; export { resolveNativeHookRelayDeferredToolApproval } from "./native-hook-relay-permissions.js"; +export type { NativeHookRelayRetention } from "./native-hook-relay-lifecycle.js"; export type { NativeHookRelayEvent, NativeHookRelayProcessResponse, @@ -78,392 +65,22 @@ export type { NativeHookRelayRegistrationHandle, } from "./native-hook-relay-types.js"; -const DEFAULT_RELAY_TTL_MS = 30 * 60 * 1000; const log = createSubsystemLogger("agents/harness/native-hook-relay"); - -const { relays, relayBridges, invocations } = nativeHookRelayState; -type RelayLifetime = { - foregroundOpen: boolean; - foregroundToken: symbol; - retained?: ReturnType; - retention?: NativeHookRelayRetention; - removeAbortListener?: () => void; - expiryTimer?: ReturnType; -}; - -const RELAY_LIFETIME = "__openclawNativeHookRelayLifetimeV1"; - -/** Private bundled-runtime callbacks for retained direct-child hook policy. */ -export type NativeHookRelayRetention = Readonly<{ - readClaim: (rawPayload: unknown) => string | undefined; - shouldRetainAfterForegroundClose: () => boolean; - allowPreToolUse: (claim: string) => boolean; - awaitForegroundAdmission?: (claim: string) => Promise<(() => boolean) | undefined>; - onDispose: () => void; -}>; - -type RetainedNativeHookRelayParams = RegisterNativeHookRelayParams & { - retention: NativeHookRelayRetention; -}; - -function readRelayLifetime( - registration: ActiveNativeHookRelayRegistration, -): RelayLifetime | undefined { - return (registration as ActiveNativeHookRelayRegistration & { [RELAY_LIFETIME]?: RelayLifetime })[ - RELAY_LIFETIME - ]; -} - -function setRelayLifetime( - registration: ActiveNativeHookRelayRegistration, - lifetime: RelayLifetime, -): void { - Object.defineProperty(registration, RELAY_LIFETIME, { - configurable: true, - value: lifetime, - }); -} - -function scheduleNativeHookRelayExpiry( - relayId: string, - registration: ActiveNativeHookRelayRegistration, -): void { - const lifetime = readRelayLifetime(registration); - if (!lifetime) { - return; - } - if (lifetime.expiryTimer) { - clearTimeout(lifetime.expiryTimer); - } - const rearm = () => { - if (relays.get(relayId) !== registration) { - return; - } - const remainingMs = registration.expiresAtMs - Date.now(); - if (remainingMs < 0) { - unregisterNativeHookRelay(relayId, registration); - return; - } - lifetime.expiryTimer = setTimeout(rearm, Math.min(remainingMs + 1, MAX_TIMER_TIMEOUT_MS)); - lifetime.expiryTimer.unref(); - }; - rearm(); -} - -function resolveNativeHookRelayExpiresAtMs(ttlMs: number | undefined): number | undefined { - return resolveExpiresAtMsFromDurationMs(normalizePositiveInteger(ttlMs, DEFAULT_RELAY_TTL_MS)); -} +const { invocations } = nativeHookRelayState; export function registerNativeHookRelay( params: RegisterNativeHookRelayParams, ): ActiveNativeHookRelayRegistrationHandle { - return registerNativeHookRelayInternal(params, undefined); + return registerNativeHookRelayLifecycle(params, invokeNativeHookRelay); } -/** Private-local bundled runtime entrypoint; not exported through the public SDK. */ -export function registerRetainedNativeHookRelay( - params: RetainedNativeHookRelayParams, -): ActiveNativeHookRelayRegistrationHandle { - const { retention, ...registrationParams } = params; - return registerNativeHookRelayInternal(registrationParams, retention); -} +type RetainedNativeHookRelayParams = RegisterNativeHookRelayParams & { + composeWithExistingRoute?: boolean; + retention: NativeHookRelayRetention; +}; -function registerNativeHookRelayInternal( - params: RegisterNativeHookRelayParams, - retention: NativeHookRelayRetention | undefined, -): ActiveNativeHookRelayRegistrationHandle { - pruneExpiredNativeHookRelays(); - pruneNativeHookRelayPermissionAllowAlways(); - const relayId = normalizeRelayKey(params.relayId, "id") ?? randomUUID(); - const generation = normalizeRelayKey(params.generation, "generation") ?? randomUUID(); - const generationMismatchGraceMs = normalizePositiveInteger(params.generationMismatchGraceMs, 0); - const now = Date.now(); - const expiresAtMs = resolveNativeHookRelayExpiresAtMs(params.ttlMs); - if (expiresAtMs === undefined) { - throw new Error("Native hook relay expiry is outside the supported Date range"); - } - const allowedEvents = normalizeAllowedEvents(params.allowedEvents); - const stateDbPath = resolveOpenClawStateSqlitePath(); - const deliverReplacedRegistrationUnregister = unregisterNativeHookRelay(relayId, undefined, { - deferBridgeRecordRemovalMs: NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS, - deferOnUnregister: true, - }); - let partialRegistration: ActiveNativeHookRelayRegistration | undefined; - try { - const retained = - params.runBeforeToolCall && retention - ? retainBeforeToolCallForNativeHookRelay(params.runBeforeToolCall) - : undefined; - const registration = { - relayId, - provider: params.provider, - generation, - ...(generationMismatchGraceMs > 0 - ? { generationMismatchGraceExpiresAtMs: now + generationMismatchGraceMs } - : {}), - ...(params.agentId ? { agentId: params.agentId } : {}), - sessionId: params.sessionId, - ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), - ...(params.config ? { config: params.config } : {}), - runId: params.runId, - ...(params.channelId ? { channelId: params.channelId } : {}), - ...(params.requester ? { requester: params.requester } : {}), - ...(params.approvalContext ? { approvalContext: params.approvalContext } : {}), - allowedEvents, - preToolUseLoopDetection: params.preToolUseLoopDetection !== false, - expiresAtMs, - preToolUseFailureProjections: new Map(), - ...(params.signal ? { signal: params.signal } : {}), - ...(params.runBeforeToolCall ? { runBeforeToolCall: params.runBeforeToolCall } : {}), - ...(params.assertActive ? { assertActive: params.assertActive } : {}), - ...(params.onPreToolUseFailure ? { onPreToolUseFailure: params.onPreToolUseFailure } : {}), - } as ActiveNativeHookRelayRegistration; - partialRegistration = registration; - relays.set(relayId, registration); - setRelayLifetime(registration, { - foregroundOpen: true, - foregroundToken: Symbol("native-hook-relay-foreground"), - ...(retained ? { retained } : {}), - ...(retention ? { retention } : {}), - }); - if (params.signal) { - const abort = () => unregisterNativeHookRelay(relayId, registration); - params.signal.addEventListener("abort", abort, { once: true }); - readRelayLifetime(registration)!.removeAbortListener = () => - params.signal?.removeEventListener("abort", abort); - if (params.signal.aborted) { - unregisterNativeHookRelay(relayId, registration); - throw new Error("native hook relay registration aborted"); - } - } - registerNativeHookRelayBridge(registration, stateDbPath, invokeNativeHookRelay); - scheduleNativeHookRelayExpiry(relayId, registration); - const handle: ActiveNativeHookRelayRegistrationHandle = { - ...registration, - shouldRelayEvent: (event) => nativeHookRelayEventHasLocalWork(registration, event), - toolMatcherForEvent: (event) => nativeHookRelayEventToolMatcher(registration, event), - commandForEvent: (event, options) => - buildNativeHookRelayCommandWithStateDatabase({ - provider: params.provider, - relayId, - stateDbPath, - generation: registration.generation, - event, - nice: params.command?.nice, - timeoutMs: resolveNativeHookRelayCommandTimeoutMs( - params.command?.timeoutMs, - options?.timeoutMs, - ), - executable: params.command?.executable, - nodeExecutable: params.command?.nodeExecutable, - }), - renew: (ttlMs) => { - const current = relays.get(relayId); - if (current !== registration) { - return; - } - const renewedExpiresAtMs = resolveNativeHookRelayExpiresAtMs(ttlMs); - if (renewedExpiresAtMs === undefined) { - return; - } - const bridge = relayBridges.get(relayId); - if (bridge && bridge.server.listening) { - try { - const renewal = renewNativeHookRelayBridgeRecord(current, bridge, renewedExpiresAtMs); - if (renewal === "unavailable") { - return; - } - if (renewal === "ownership-changed") { - log.debug("native hook relay bridge record ownership changed", { relayId }); - unregisterNativeHookRelay(relayId, current); - return; - } - } catch (error) { - log.debug("failed to renew native hook relay bridge record", { error, relayId }); - return; - } - } - current.expiresAtMs = renewedExpiresAtMs; - handle.expiresAtMs = renewedExpiresAtMs; - scheduleNativeHookRelayExpiry(relayId, current); - }, - unregister: () => deactivateNativeHookRelayForeground(relayId, registration), - }; - return handle; - } catch (error) { - if (partialRegistration) { - unregisterNativeHookRelay(relayId, partialRegistration); - } - throw error; - } finally { - // The successor is authoritative before the old callback runs. A reentrant - // callback can therefore replace this registration normally instead of - // being overwritten by the outer replacement path. Finally also preserves - // the old callback if successor setup aborts partway through. - deliverReplacedRegistrationUnregister?.(); - } -} - -function unregisterNativeHookRelay( - relayId: string, - expectedRegistration?: ActiveNativeHookRelayRegistration, - options?: { deferBridgeRecordRemovalMs?: number; deferOnUnregister?: boolean }, -): (() => void) | undefined { - if (expectedRegistration && relays.get(relayId) !== expectedRegistration) { - return undefined; - } - const registration = expectedRegistration ?? relays.get(relayId); - if (!registration) { - return undefined; - } - const lifetime = readRelayLifetime(registration); - const bridge = relayBridges.get(relayId); - // Detach first: owner cleanup may register a same-id successor, which must - // never be removed by this registration's later resource cleanup. - if (relays.get(relayId) === registration) { - relays.delete(relayId); - } - if (lifetime?.expiryTimer) { - clearTimeout(lifetime.expiryTimer); - } - lifetime?.removeAbortListener?.(); - lifetime?.retained?.release(); - delete (registration as ActiveNativeHookRelayRegistration & { [RELAY_LIFETIME]?: RelayLifetime })[ - RELAY_LIFETIME - ]; - unregisterNativeHookRelayBridge(relayId, { - ...options, - ...(bridge ? { expectedBridge: bridge } : {}), - }); - removeNativeHookRelayInvocations(relayId); - removeNativeHookRelayPreToolUseApprovals(relayId); - removeNativeHookRelayPermissionState(relayId); - const deliverOnUnregister = () => { - try { - lifetime?.retention?.onDispose(); - } catch (error) { - try { - log.warn("native hook relay unregister callback failed", { error, relayId }); - } catch { - // Teardown has already detached every identity-bound resource. Logging - // must not turn an observer callback failure into a cleanup failure. - } - } - }; - if (options?.deferOnUnregister) { - return deliverOnUnregister; - } - deliverOnUnregister(); - return undefined; -} - -function deactivateNativeHookRelayForeground( - relayId: string, - registration: ActiveNativeHookRelayRegistration, -): void { - if (relays.get(relayId) !== registration) { - return; - } - const lifetime = readRelayLifetime(registration); - if (!lifetime) { - return; - } - lifetime.foregroundOpen = false; - let shouldRetain = false; - if (lifetime.retained && lifetime.retention) { - try { - shouldRetain = lifetime.retention.shouldRetainAfterForegroundClose(); - } catch (error) { - try { - log.warn("native hook relay retention predicate failed", { error, relayId }); - } catch { - // A logging failure cannot make a throwing retention predicate retain authority. - } - } - } - if (shouldRetain) { - return; - } - unregisterNativeHookRelay(relayId, registration); -} - -async function resolveNativeHookRelayInvocationBinding( - registration: ActiveNativeHookRelayRegistration, - event: NativeHookRelayEvent, - rawPayload: unknown, -): Promise { - const lifetime = readRelayLifetime(registration); - if (!lifetime) { - throw new Error("native hook relay registration is inactive"); - } - const claim = lifetime.retention?.readClaim(rawPayload); - if (claim && event === "pre_tool_use" && lifetime.retained && lifetime.retention) { - const retained = lifetime.retained; - const retention = lifetime.retention; - let assertAdmission: (() => boolean) | undefined; - const assertRetainedAuthority = () => { - if ( - relays.get(registration.relayId) !== registration || - Date.now() > registration.expiresAtMs - ) { - throw new Error("native hook relay registration is inactive"); - } - registration.signal?.throwIfAborted(); - retained.assertActive(); - if (assertAdmission && !assertAdmission()) { - throw new Error("native hook relay retained invocation not allowed"); - } - if (!retention.allowPreToolUse(claim)) { - throw new Error("native hook relay retained invocation not allowed"); - } - }; - if (lifetime.foregroundOpen && retention.awaitForegroundAdmission) { - assertAdmission = await retention.awaitForegroundAdmission(claim); - if (!assertAdmission) { - throw new Error("native hook relay retained invocation not allowed"); - } - assertRetainedAuthority(); - } else if (!retention.allowPreToolUse(claim)) { - throw new Error("native hook relay retained invocation not allowed"); - } - return { - ...registration, - assertActive: assertRetainedAuthority, - runBeforeToolCall: retained.runBeforeToolCall, - }; - } - if (!lifetime.foregroundOpen) { - throw new Error("native hook relay foreground invocation not allowed"); - } - const foregroundToken = lifetime.foregroundToken; - const assertActive = () => { - if ( - relays.get(registration.relayId) !== registration || - Date.now() > registration.expiresAtMs - ) { - throw new Error("native hook relay registration is inactive"); - } - registration.signal?.throwIfAborted(); - registration.assertActive?.(); - if (!lifetime.foregroundOpen || lifetime.foregroundToken !== foregroundToken) { - throw new Error("native hook relay foreground invocation not allowed"); - } - }; - return { ...registration, assertActive }; -} - -function normalizeRelayKey( - value: string | undefined, - kind: "id" | "generation", -): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - if (trimmed.length > 160 || !/^[A-Za-z0-9._:-]+$/u.test(trimmed)) { - throw new Error(`native hook relay ${kind} must be non-empty, compact, and URL-safe`); - } - return trimmed; +export function registerRetainedNativeHookRelay(params: RetainedNativeHookRelayParams) { + return registerRetainedNativeHookRelayLifecycle(params, invokeNativeHookRelay); } export async function invokeNativeHookRelay( @@ -472,51 +89,49 @@ export async function invokeNativeHookRelay( const provider = readNativeHookRelayProvider(params.provider); const relayId = readNonEmptyString(params.relayId, "relayId"); const event = readNativeHookRelayEvent(params.event); - const registration = relays.get(relayId); - if (!registration) { + const route = getNativeHookRelayRoute(relayId); + if (!route) { pruneExpiredNativeHookRelays(); throw new Error("native hook relay not found"); } - if (Date.now() > registration.expiresAtMs) { - unregisterNativeHookRelay(relayId, registration); + if (route.provider !== provider) { + throw new Error("native hook relay provider mismatch"); + } + if (Date.now() > route.expiresAtMs) { + unregisterNativeHookRelay(relayId, route); throw new Error("native hook relay expired"); } - if (registration.provider !== provider) { - throw new Error("native hook relay provider mismatch"); + if (!isJsonValue(params.rawPayload)) { + throw new Error("native hook relay payload must be JSON-compatible"); } if (params.requireGeneration) { const generation = readNonEmptyString(params.generation, "generation"); - if (generation !== registration.generation) { - if (!canAcceptNativeHookRelayGenerationMismatch(registration, generation)) { + if (generation !== route.generation) { + if (!canAcceptNativeHookRelayGenerationMismatch(route, generation)) { throw new Error(NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR); } log.debug("native hook relay accepted bootstrap generation mismatch", { relayId, event, - runId: registration.runId, + runId: route.runId, }); } } - if (!registration.allowedEvents.includes(event)) { - throw new Error("native hook relay event not allowed"); - } - if (!isJsonValue(params.rawPayload)) { - throw new Error("native hook relay payload must be JSON-compatible"); - } - - const normalized = normalizeNativeHookInvocation({ - registration, - event, - rawPayload: params.rawPayload, - }); const effectiveRegistration = await resolveNativeHookRelayInvocationBinding( - registration, + route, event, params.rawPayload, ); - if (event === "pre_tool_use" || event === "permission_request") { - effectiveRegistration.assertActive?.(); + if (!effectiveRegistration.allowedEvents.includes(event)) { + throw new Error("native hook relay event not allowed"); } + + const normalized = normalizeNativeHookInvocation({ + registration: effectiveRegistration, + event, + rawPayload: params.rawPayload, + }); + effectiveRegistration.assertActive?.(); recordNativeHookRelayInvocation(normalized); const startedAt = Date.now(); const response = await processNativeHookRelayInvocation({ @@ -526,7 +141,11 @@ export async function invokeNativeHookRelay( }); // Policy and approval callbacks may yield while their admitted run closes. // Never let a late allow cross back into the native runtime. - if (event === "pre_tool_use" || event === "permission_request") { + if ( + event === "pre_tool_use" || + event === "permission_request" || + event === "before_agent_finalize" + ) { effectiveRegistration.assertActive?.(); } if ( @@ -534,7 +153,7 @@ export async function invokeNativeHookRelay( response.failureDisposition && readNativeHookRelayApprovalMode(normalized.rawPayload) !== "report" ) { - projectNativeHookRelayPreToolUseFailure(registration, { + projectNativeHookRelayPreToolUseFailure(effectiveRegistration, { toolName: normalizeNativeHookToolName(normalized.toolName), toolCallId: normalized.toolUseId, disposition: response.failureDisposition, @@ -591,16 +210,19 @@ function projectNativeHookRelayPreToolUseFailure( export function hasNativeHookRelayInvocation(params: { relayId: string; event: NativeHookRelayEvent; + turnId?: string; toolUseId?: string; }): boolean { + const turnId = params.turnId?.trim(); const toolUseId = params.toolUseId?.trim(); - if (!toolUseId) { + if (!turnId || !toolUseId) { return false; } return invocations.some( (invocation) => invocation.relayId === params.relayId && invocation.event === params.event && + invocation.turnId === turnId && invocation.toolUseId === toolUseId, ); } @@ -615,14 +237,6 @@ function recordNativeHookRelayInvocation(invocation: NativeHookRelayInvocation): } } -function removeNativeHookRelayInvocations(relayId: string): void { - for (let index = invocations.length - 1; index >= 0; index -= 1) { - if (invocations[index]?.relayId === relayId) { - invocations.splice(index, 1); - } - } -} - function canAcceptNativeHookRelayGenerationMismatch( registration: NativeHookRelayRegistration, generation: string, @@ -638,26 +252,9 @@ function canAcceptNativeHookRelayGenerationMismatch( return true; } -function pruneExpiredNativeHookRelays(now = Date.now()): void { - for (const [relayId, registration] of relays) { - if (now > registration.expiresAtMs) { - unregisterNativeHookRelay(relayId, registration); - } - } -} - -function normalizeAllowedEvents( - events: readonly NativeHookRelayEvent[] | undefined, -): readonly NativeHookRelayEvent[] { - if (!events?.length) { - return NATIVE_HOOK_RELAY_EVENTS; - } - return [...new Set(events)]; -} - export const testing = { clearNativeHookRelaysForTests(): void { - for (const [relayId, registration] of relays) { + for (const [relayId, registration] of listNativeHookRelayRoutes()) { unregisterNativeHookRelay(relayId, registration); } clearNativeHookRelayBridgesForTests(); @@ -668,7 +265,7 @@ export const testing = { return [...invocations]; }, getNativeHookRelayRegistrationForTests(relayId: string): NativeHookRelayRegistration | undefined { - return relays.get(relayId); + return getNativeHookRelayRegistrationForTests(relayId); }, getNativeHookRelayBridgeDirForTests(): string { throw new Error("native hook relay bridge files were retired"); @@ -684,18 +281,9 @@ export const testing = { isNativeHookRelayBridgeLookupRetryableForTests(error: unknown, elapsedMs = 0): boolean { return isRetryableNativeHookRelayBridgeLookupError({ error, elapsedMs }); }, - formatPermissionApprovalDescriptionForTests( - request: NativeHookRelayPermissionApprovalRequest, - ): string { - return formatPermissionApprovalDescriptionForTestsImpl(request); - }, - permissionRequestContentFingerprintForTests( - request: NativeHookRelayPermissionApprovalRequest, - ): string { - return permissionRequestContentFingerprintForTestsImpl(request); - }, - permissionRequestToolInputKeyFingerprintForTests: - permissionRequestToolInputKeyFingerprintForTestsImpl, + formatPermissionApprovalDescriptionForTests: formatPermissionApprovalDescription, + permissionRequestContentFingerprintForTests: permissionRequestContentFingerprint, + permissionRequestToolInputKeyFingerprintForTests: permissionRequestToolInputKeyFingerprint, setNativeHookRelayPermissionApprovalRequesterForTests( requester: NativeHookRelayPermissionApprovalRequester, ): void { diff --git a/src/plugin-sdk/native-hook-relay-runtime.ts b/src/plugin-sdk/native-hook-relay-runtime.ts index 8133dd41a106..c83c020db155 100644 --- a/src/plugin-sdk/native-hook-relay-runtime.ts +++ b/src/plugin-sdk/native-hook-relay-runtime.ts @@ -6,6 +6,7 @@ import { } from "../agents/harness/native-hook-relay.js"; export type RetainedNativeHookRelayParams = RegisterNativeHookRelayParams & { + composeWithExistingRoute?: boolean; retention: NativeHookRelayRetention; };