mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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 }>;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user