fix(codex): preserve child hooks across turns (#128485)

* fix(codex): preserve native hook relays across turns

* fix(codex): fail closed on relay renewal loss
This commit is contained in:
Bek
2026-08-24 00:31:42 -04:00
committed by GitHub
parent 4f8f050f5b
commit a5c8e00cb6
27 changed files with 2180 additions and 687 deletions
-1
View File
@@ -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
+7 -6
View File
@@ -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`
@@ -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,
});
@@ -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,
})
) {
@@ -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[];
@@ -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 {
@@ -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?.();
}
@@ -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,
@@ -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,
@@ -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 };
}
@@ -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<typeof turnStartResult>) => void;
const deferredTurnStart = new Promise<ReturnType<typeof turnStartResult>>((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<ReturnType<typeof createAdmittedHostCapabilityTestFixture>> | 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<void>((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:
`<subagent_notification>{"agent_path":${JSON.stringify(childThreadId)},` +
'"status":{"completed":null}}</subagent_notification>',
trigger_turn: false,
}),
},
],
},
},
} as unknown as CodexServerNotification);
nativeHookRelayUnregisterQueue.flush();
let childDenied: Awaited<ReturnType<typeof invokeNativeHookRelay>> | 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",
@@ -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" },
@@ -99,6 +99,7 @@ type ResumeThreadContext = ThreadRequestContext & {
clearCurrentBinding: (operation: string) => Promise<void>;
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,
@@ -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,
@@ -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;
};
@@ -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,
@@ -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" }),
@@ -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 (
@@ -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,
@@ -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<NativeHookRelayProcessResponse>;
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<string>;
retained?: ReturnType<typeof retainBeforeToolCallForNativeHookRelay>;
retention?: NativeHookRelayRetention;
removeAbortListener?: () => void;
};
type RelayLifetime = {
bindings: Set<RelayBinding>;
childBindings: Map<string, RelayBinding>;
foreground?: RelayBinding;
expiryTimer?: ReturnType<typeof setTimeout>;
};
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<ActiveNativeHookRelayRegistration, RelayLifetime>;
};
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<RelayBinding>(),
childBindings: new Map<string, RelayBinding>(),
};
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<ActiveNativeHookRelayRegistration> {
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)];
}
@@ -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<NativeHookRelayPermissionApprovalResult> {
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<never>((_, 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, unknown>): 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;
}
@@ -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<NativeHookRelayDeferredApprovalOutcome | undefined> {
@@ -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, unknown>,
): string {
return permissionRequestToolInputKeyFingerprint(toolInput);
}
function permissionRequestToolInputKeyFingerprint(toolInput: Record<string, unknown>): 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<string, unkn
return fingerprint || "none";
}
export function permissionRequestContentFingerprintForTests(
request: NativeHookRelayPermissionApprovalRequest,
): string {
return permissionRequestContentFingerprint(request);
}
function permissionRequestContentFingerprint(
export function permissionRequestContentFingerprint(
request: NativeHookRelayPermissionApprovalRequest,
): string {
const hash = createHash("sha256");
@@ -554,168 +600,11 @@ export function removeNativeHookRelayPermissionState(relayId: string): void {
for (const key of pendingPermissionApprovals.keys()) {
if (key.startsWith(`${relayId}:`)) {
pendingPermissionApprovals.delete(key);
pendingPermissionApprovalOwners.delete(key);
}
}
}
async function requestNativeHookRelayPermissionApproval(
request: NativeHookRelayPermissionApprovalRequest,
): Promise<NativeHookRelayPermissionApprovalResult> {
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<never>((_, 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, unknown>): 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;
@@ -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(),
};
@@ -229,6 +229,7 @@ export type NativeHookRelayPermissionApprovalRequester = (
export type NativeHookRelayPreToolUseApproval = {
deferredApproval: DeferredPluginToolApproval;
originalParamsFingerprint: string;
assertActive?: () => void;
resolutionPromise?: Promise<NativeHookRelayDeferredApprovalOutcome>;
};
@@ -255,8 +256,11 @@ export type NativeHookRelaySharedState = {
relays: Map<string, ActiveNativeHookRelayRegistration>;
relayBridges: Map<string, NativeHookRelayBridgeRegistration>;
invocations: NativeHookRelayInvocation[];
approvalOwners: WeakMap<NativeHookRelayRegistration, symbol>;
pendingPermissionApprovals: Map<string, Promise<NativeHookRelayPermissionApprovalResult>>;
pendingPermissionApprovalOwners: Map<string, symbol>;
pendingPreToolUseApprovals: Map<string, NativeHookRelayPreToolUseApproval>;
pendingPreToolUseApprovalOwners: Map<string, symbol>;
permissionApprovalWindows: Map<string, number[]>;
permissionAllowAlwaysApprovals: Map<string, { expiresAtMs: number }>;
};
+599 -5
View File
@@ -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<string, unknown>;
invocations: unknown[];
pendingPermissionApprovals: Map<string, unknown>;
pendingPreToolUseApprovals: Map<string, unknown>;
permissionApprovalWindows: Map<string, unknown[]>;
permissionAllowAlwaysApprovals: Map<string, unknown>;
};
@@ -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<string, (decision: "allow") => 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<string, unknown>) =>
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<undefined>((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({
+60 -472
View File
@@ -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<typeof retainBeforeToolCallForNativeHookRelay>;
retention?: NativeHookRelayRetention;
removeAbortListener?: () => void;
expiryTimer?: ReturnType<typeof setTimeout>;
};
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<NativeHookRelayRegistration> {
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 {
@@ -6,6 +6,7 @@ import {
} from "../agents/harness/native-hook-relay.js";
export type RetainedNativeHookRelayParams = RegisterNativeHookRelayParams & {
composeWithExistingRoute?: boolean;
retention: NativeHookRelayRetention;
};