From 43c608e5fd2bd19c96841b4f27feb5bad884b7c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 05:37:54 -0700 Subject: [PATCH] refactor(agents): split native hook relay (#113626) * refactor(agents): split native hook relay * refactor(agents): preserve native relay API * refactor(agents): keep relay state private * refactor(agents): break relay type cycles --- config/max-lines-baseline.txt | 1 - .../harness/native-hook-relay-bridge.ts | 519 ++++ .../harness/native-hook-relay-contracts.ts | 241 ++ .../harness/native-hook-relay-events.ts | 283 ++ .../harness/native-hook-relay-permissions.ts | 602 +++++ .../harness/native-hook-relay-provider.ts | 485 ++++ .../harness/native-hook-relay-runtime.ts | 525 ++++ src/agents/harness/native-hook-relay.ts | 2392 ++--------------- 8 files changed, 2816 insertions(+), 2232 deletions(-) create mode 100644 src/agents/harness/native-hook-relay-bridge.ts create mode 100644 src/agents/harness/native-hook-relay-contracts.ts create mode 100644 src/agents/harness/native-hook-relay-events.ts create mode 100644 src/agents/harness/native-hook-relay-permissions.ts create mode 100644 src/agents/harness/native-hook-relay-provider.ts create mode 100644 src/agents/harness/native-hook-relay-runtime.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index a014d3d1ead1..f8798cc4d5e2 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -462,7 +462,6 @@ src/agents/embedded-agent-subscribe.ts src/agents/failover-error.test.ts src/agents/failover-error.ts src/agents/harness/native-hook-relay.test.ts -src/agents/harness/native-hook-relay.ts src/agents/harness/selection.test.ts src/agents/harness/selection.ts src/agents/main-session-restart-recovery.test.ts diff --git a/src/agents/harness/native-hook-relay-bridge.ts b/src/agents/harness/native-hook-relay-bridge.ts new file mode 100644 index 000000000000..e7c85f8a8650 --- /dev/null +++ b/src/agents/harness/native-hook-relay-bridge.ts @@ -0,0 +1,519 @@ +import { randomUUID } from "node:crypto"; +import { + createServer, + request as httpRequest, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { toErrorObject } from "../../infra/errors.js"; +import type { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { + ActiveNativeHookRelayRegistration, + InvokeNativeHookRelayBridgeParams, + InvokeNativeHookRelayParams, + NativeHookRelayBridgeRegistration, + NativeHookRelayBridgeRequestAuth, + NativeHookRelayProcessResponse, +} from "./native-hook-relay-contracts.js"; +import { + getNativeHookRelayProviderAdapter, + isJsonObject, + normalizePositiveInteger, + readNativeHookRelayEvent, + readNativeHookRelayProvider, + readNonEmptyString, +} from "./native-hook-relay-provider.js"; +import { + deleteNativeHookRelayBridgeRecordIfOwned, + pruneNativeHookRelayBridgeRecords, + readNativeHookRelayBridgeRecord as readNativeHookRelayBridgeRecordFromStore, + writeNativeHookRelayBridgeRecord, + type NativeHookRelayBridgeRecord, +} from "./native-hook-relay-store.js"; + +const DEFAULT_RELAY_TIMEOUT_MS = 5_000; +const MAX_NATIVE_HOOK_BRIDGE_BODY_BYTES = 5_000_000; +const MAX_NATIVE_HOOK_BRIDGE_RESPONSE_BYTES = 5_000_000; +const NATIVE_HOOK_BRIDGE_RETRY_INTERVAL_MS = 25; +const NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS = 250; +export const NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR = + "native hook relay bridge stale registration"; + +export function createNativeHookRelayBridgeRuntime(context: { + relays: Map; + relayBridges: Map; + invokeNativeHookRelay: ( + params: InvokeNativeHookRelayParams & { requireGeneration?: boolean }, + ) => Promise; + log: Pick, "debug">; +}) { + const { relays, relayBridges, invokeNativeHookRelay, log } = context; + async function invokeNativeHookRelayBridge( + params: InvokeNativeHookRelayBridgeParams, + ): Promise { + const provider = readNativeHookRelayProvider(params.provider); + const relayId = readNonEmptyString(params.relayId, "relayId"); + const event = readNativeHookRelayEvent(params.event); + const timeoutMs = normalizePositiveInteger(params.timeoutMs, DEFAULT_RELAY_TIMEOUT_MS); + const registrationTimeoutMs = normalizePositiveInteger(params.registrationTimeoutMs, timeoutMs); + const startedAt = Date.now(); + let lastError: unknown = new Error("native hook relay bridge not found"); + while (Date.now() - startedAt < timeoutMs) { + try { + const record = readNativeHookRelayBridgeRecord(relayId, params.stateDbPath); + if (Date.now() > record.expiresAtMs) { + throw new Error("native hook relay bridge expired"); + } + return await invokeNativeHookRelayBridgeRecord({ + record, + timeoutMs: Math.max(1, timeoutMs - (Date.now() - startedAt)), + payload: { + provider, + relayId, + event, + generation: params.generation, + rawPayload: params.rawPayload, + }, + }); + } catch (error) { + lastError = error; + if ( + error instanceof Error && + error.message === "native hook relay bridge not found" && + Date.now() - startedAt >= registrationTimeoutMs + ) { + break; + } + if ( + !isRetryableNativeHookRelayBridgeLookupError({ + error, + elapsedMs: Date.now() - startedAt, + }) + ) { + break; + } + await delay( + Math.min(NATIVE_HOOK_BRIDGE_RETRY_INTERVAL_MS, timeoutMs - (Date.now() - startedAt)), + ); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + + function renderNativeHookRelayUnavailableResponse(params: { + provider: unknown; + event: unknown; + preToolUseUnavailable?: unknown; + message?: string; + }): NativeHookRelayProcessResponse { + const provider = readNativeHookRelayProvider(params.provider); + const event = readNativeHookRelayEvent(params.event); + const adapter = getNativeHookRelayProviderAdapter(provider); + const message = params.message?.trim() || "Native hook relay unavailable"; + if (event === "pre_tool_use") { + // The standalone CLI cannot reconstruct the originating registration after + // relay lookup fails, so unavailable PreToolUse must fail closed unless the + // generated command explicitly recorded that no before-tool policy existed. + if (params.preToolUseUnavailable === "noop") { + return adapter.renderNoopResponse(event); + } + return adapter.renderPreToolUseBlockResponse(message); + } + if (event === "permission_request") { + return adapter.renderPermissionDecisionResponse("deny", message); + } + return adapter.renderNoopResponse(event); + } + + function isNativeHookRelayBridgeStaleRegistrationError(error: unknown): boolean { + return ( + error instanceof Error && error.message === NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR + ); + } + + function isNativeHookRelayBridgePidDead(pid: number): boolean { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return ( + typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH" + ); + } + } + + function registerNativeHookRelayBridge( + registration: ActiveNativeHookRelayRegistration, + stateDbPath: string, + ): void { + // Liveness checks stay outside the write transaction. The store rereads each + // authoritative row before deletion so renewal or replacement wins the race. + try { + const pruned = pruneNativeHookRelayBridgeRecords({ + currentPid: process.pid, + isPidDead: isNativeHookRelayBridgePidDead, + stateDbPath, + }); + for (const row of pruned) { + log.debug("pruned stale native hook relay bridge record", { + relayId: row.relayId, + stalePid: row.pid, + currentPid: process.pid, + reason: row.reason, + }); + } + } catch (error) { + log.debug("native hook relay bridge record prune skipped", { error }); + } + unregisterNativeHookRelayBridge(registration.relayId); + const token = randomUUID(); + const server = createServer(); + const bridge: NativeHookRelayBridgeRegistration = { + relayId: registration.relayId, + stateDbPath, + token, + server, + }; + server.on("request", (req, res) => { + void handleNativeHookRelayBridgeRequest(req, res, { + provider: registration.provider, + relayId: registration.relayId, + token, + registration, + bridge, + }); + }); + relayBridges.set(registration.relayId, bridge); + server.on("error", (error) => { + log.debug("native hook relay bridge server error", { error, relayId: registration.relayId }); + }); + server.listen(0, "127.0.0.1", () => { + if (relayBridges.get(registration.relayId) !== bridge) { + return; + } + try { + writeNativeHookRelayBridgeRecordForRegistration(registration, bridge); + } catch (error) { + log.debug("failed to publish native hook relay bridge record", { + error, + relayId: registration.relayId, + }); + } + }); + server.unref(); + } + + function writeNativeHookRelayBridgeRecordForRegistration( + registration: ActiveNativeHookRelayRegistration, + bridge: NativeHookRelayBridgeRegistration, + ): void { + const record = resolveNativeHookRelayBridgeRecord(registration, bridge); + if (!record) { + return; + } + writeNativeHookRelayBridgeRecord({ + record, + stateDbPath: bridge.stateDbPath, + }); + } + + function resolveNativeHookRelayBridgeRecord( + registration: ActiveNativeHookRelayRegistration, + bridge: NativeHookRelayBridgeRegistration, + expiresAtMs = registration.expiresAtMs, + ): NativeHookRelayBridgeRecord | undefined { + const address = bridge.server.address(); + if (!address || typeof address === "string") { + log.debug("native hook relay bridge server address unavailable", { + relayId: registration.relayId, + }); + return undefined; + } + const { token } = bridge; + const record: NativeHookRelayBridgeRecord = { + relayId: registration.relayId, + pid: process.pid, + hostname: "127.0.0.1", + port: address.port, + token, + expiresAtMs, + }; + return record; + } + + function unregisterNativeHookRelayBridge( + relayId: string, + options?: { deferBridgeRecordRemovalMs?: number }, + ): void { + const bridge = relayBridges.get(relayId); + if (!bridge) { + return; + } + relayBridges.delete(relayId); + bridge.server.close(); + const removeRecord = () => { + try { + deleteNativeHookRelayBridgeRecordIfOwned({ + ...bridge, + pid: process.pid, + }); + } catch (error) { + log.debug("failed to remove native hook relay bridge record", { error, relayId }); + } + }; + const deferBridgeRecordRemovalMs = normalizePositiveInteger( + options?.deferBridgeRecordRemovalMs, + 0, + ); + if (deferBridgeRecordRemovalMs > 0) { + // During stable-id replacement, retain the old locator until the successor + // upserts. The token-scoped timer cannot delete that successor. + const timeout = setTimeout(removeRecord, deferBridgeRecordRemovalMs); + timeout.unref(); + return; + } + removeRecord(); + } + + async function handleNativeHookRelayBridgeRequest( + req: IncomingMessage, + res: ServerResponse, + auth: NativeHookRelayBridgeRequestAuth, + ): Promise { + try { + if (req.method !== "POST" || req.url !== "/invoke") { + writeNativeHookRelayBridgeJson(res, 404, { ok: false, error: "not found" }); + return; + } + if (req.headers.authorization !== `Bearer ${auth.token}`) { + writeNativeHookRelayBridgeJson(res, 403, { ok: false, error: "forbidden" }); + return; + } + if (!isCurrentNativeHookRelayBridgeRequest(auth)) { + writeNativeHookRelayBridgeJson(res, 410, { + ok: false, + error: NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR, + }); + return; + } + const body = await readNativeHookRelayBridgeBody(req); + const payload = readNativeHookRelayBridgePayload(JSON.parse(body)); + if (payload.provider !== auth.provider || payload.relayId !== auth.relayId) { + writeNativeHookRelayBridgeJson(res, 403, { + ok: false, + error: "native hook relay bridge target mismatch", + }); + return; + } + if (!isCurrentNativeHookRelayBridgeRequest(auth)) { + writeNativeHookRelayBridgeJson(res, 410, { + ok: false, + error: NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR, + }); + return; + } + const result = await invokeNativeHookRelay({ ...payload, requireGeneration: true }); + writeNativeHookRelayBridgeJson(res, 200, { ok: true, result }); + } catch (error) { + writeNativeHookRelayBridgeJson( + res, + isNativeHookRelayBridgeStaleRegistrationError(error) ? 410 : 500, + { + ok: false, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + } + + function isCurrentNativeHookRelayBridgeRequest(auth: NativeHookRelayBridgeRequestAuth): boolean { + return ( + relays.get(auth.relayId) === auth.registration && + relayBridges.get(auth.relayId) === auth.bridge + ); + } + + async function readNativeHookRelayBridgeBody(req: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.byteLength; + if (total > MAX_NATIVE_HOOK_BRIDGE_BODY_BYTES) { + throw new Error("native hook relay bridge payload too large"); + } + chunks.push(buffer); + } + return Buffer.concat(chunks, total).toString("utf8"); + } + + function readNativeHookRelayBridgePayload(value: unknown): InvokeNativeHookRelayParams { + if (!isJsonObject(value)) { + throw new Error("native hook relay bridge payload must be an object"); + } + return { + provider: value.provider, + relayId: value.relayId, + generation: readNonEmptyString(value.generation, "generation"), + event: value.event, + rawPayload: value.rawPayload, + }; + } + + function writeNativeHookRelayBridgeJson( + res: ServerResponse, + statusCode: number, + payload: unknown, + ): void { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }); + res.end(body); + } + + function readNativeHookRelayBridgeRecord( + relayId: string, + stateDbPath?: string, + ): NativeHookRelayBridgeRecord { + const record = readNativeHookRelayBridgeRecordIfExists(relayId, stateDbPath); + if (!record) { + throw new Error("native hook relay bridge not found"); + } + return record; + } + + function readNativeHookRelayBridgeRecordIfExists( + relayId: string, + stateDbPath?: string, + ): NativeHookRelayBridgeRecord | undefined { + try { + return readNativeHookRelayBridgeRecordFromStore({ relayId, stateDbPath }); + } catch (error) { + log.debug("failed to read native hook relay bridge record", { error, relayId }); + } + return undefined; + } + + async function invokeNativeHookRelayBridgeRecord(params: { + record: NativeHookRelayBridgeRecord; + timeoutMs: number; + payload: InvokeNativeHookRelayParams; + }): Promise { + return postNativeHookRelayBridgeRecord(params); + } + + function postNativeHookRelayBridgeRecord(params: { + record: NativeHookRelayBridgeRecord; + timeoutMs: number; + payload: InvokeNativeHookRelayParams; + }): Promise { + const body = JSON.stringify(params.payload); + return new Promise((resolve, reject) => { + let settled = false; + const resolveOnce = (value: NativeHookRelayProcessResponse) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + const rejectOnce = (error: unknown) => { + if (!settled) { + settled = true; + reject(toErrorObject(error, "Non-Error rejection")); + } + }; + const req = httpRequest( + { + hostname: params.record.hostname, + method: "POST", + path: "/invoke", + port: params.record.port, + timeout: params.timeoutMs, + headers: { + authorization: `Bearer ${params.record.token}`, + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }, + }, + (res) => { + let responseText = ""; + let responseBytes = 0; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + const chunkText = typeof chunk === "string" ? chunk : String(chunk); + responseBytes += Buffer.byteLength(chunkText); + if (responseBytes > MAX_NATIVE_HOOK_BRIDGE_RESPONSE_BYTES) { + rejectOnce(new Error("native hook relay bridge response too large")); + res.destroy(); + return; + } + responseText += chunkText; + }); + res.on("error", rejectOnce); + res.on("end", () => { + if (settled) { + return; + } + try { + const parsed = JSON.parse(responseText) as + | { ok: true; result: NativeHookRelayProcessResponse } + | { ok: false; error?: string }; + if (parsed.ok) { + resolveOnce(parsed.result); + return; + } + rejectOnce(new Error(parsed.error || "native hook relay bridge failed")); + } catch (error) { + rejectOnce(error); + } + }); + }, + ); + req.on("timeout", () => { + req.destroy(new Error("native hook relay bridge timed out")); + }); + req.on("error", rejectOnce); + req.end(body); + }); + } + + function isRetryableNativeHookRelayBridgeError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return ( + code === "ENOENT" || + code === "ECONNREFUSED" || + code === "EAGAIN" || + (error instanceof Error && error.message === "native hook relay bridge not found") + ); + } + + function isRetryableNativeHookRelayBridgeLookupError(params: { + error: unknown; + elapsedMs: number; + }): boolean { + return ( + isRetryableNativeHookRelayBridgeError(params.error) || + (params.elapsedMs < NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS && + isNativeHookRelayBridgeStaleRegistrationError(params.error)) + ); + } + + function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, Math.max(0, ms)); + }); + } + + return { + registerNativeHookRelayBridge, + resolveNativeHookRelayBridgeRecord, + unregisterNativeHookRelayBridge, + invokeNativeHookRelayBridge, + renderNativeHookRelayUnavailableResponse, + isNativeHookRelayBridgeStaleRegistrationError, + readNativeHookRelayBridgeRecordIfExists, + isRetryableNativeHookRelayBridgeLookupError, + }; +} diff --git a/src/agents/harness/native-hook-relay-contracts.ts b/src/agents/harness/native-hook-relay-contracts.ts new file mode 100644 index 000000000000..50138241dfb7 --- /dev/null +++ b/src/agents/harness/native-hook-relay-contracts.ts @@ -0,0 +1,241 @@ +import type { Server } from "node:http"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { PluginHookToolRequesterContext } from "../../plugins/hook-types.js"; +import type { + BeforeToolCallFailureDisposition, + DeferredPluginToolApproval, + requestDeferredPluginToolApproval, +} from "../agent-tools.before-tool-call.js"; + +const NATIVE_HOOK_RELAY_EVENTS = [ + "pre_tool_use", + "post_tool_use", + "permission_request", + "before_agent_finalize", +] as const; + +const NATIVE_HOOK_RELAY_PROVIDERS = ["codex"] as const; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export type NativeHookRelayEvent = (typeof NATIVE_HOOK_RELAY_EVENTS)[number]; +export type NativeHookRelayProvider = (typeof NATIVE_HOOK_RELAY_PROVIDERS)[number]; + +export type NativeHookRelayInvocation = { + provider: NativeHookRelayProvider; + relayId: string; + event: NativeHookRelayEvent; + nativeEventName?: string; + agentId?: string; + sessionId: string; + sessionKey?: string; + runId: string; + cwd?: string; + model?: string; + turnId?: string; + transcriptPath?: string; + permissionMode?: string; + stopHookActive?: boolean; + lastAssistantMessage?: string; + toolName?: string; + toolUseId?: string; + rawPayload: JsonValue; + receivedAt: string; +}; + +export type NativeHookRelayProcessResponse = { + stdout: string; + stderr: string; + exitCode: number; + failureDisposition?: Exclude; +}; + +export type NativeHookRelayRegistration = { + relayId: string; + provider: NativeHookRelayProvider; + generationMismatchGraceExpiresAtMs?: number; + generationMismatchGraceAcceptedGeneration?: string; + agentId?: string; + sessionId: string; + sessionKey?: string; + config?: OpenClawConfig; + runId: string; + channelId?: string; + requester?: PluginHookToolRequesterContext; + allowedEvents: readonly NativeHookRelayEvent[]; + expiresAtMs: number; + signal?: AbortSignal; + onPreToolUseFailure?: (failure: { + toolName: string; + toolCallId: string; + disposition: Exclude; + durationMs: number; + }) => void | Promise; +}; + +type NativeHookRelayRegistrationHandle = NativeHookRelayRegistration & { + generation?: string; + shouldRelayEvent: (event: NativeHookRelayEvent) => boolean; + commandForEvent: ( + event: NativeHookRelayEvent, + options?: NativeHookRelayCommandForEventOptions, + ) => string; + renew: (ttlMs?: number) => void; + unregister: () => void; +}; + +export type RegisterNativeHookRelayParams = { + provider: NativeHookRelayProvider; + relayId?: string; + generation?: string; + generationMismatchGraceMs?: number; + agentId?: string; + sessionId: string; + sessionKey?: string; + config?: OpenClawConfig; + runId: string; + channelId?: string; + requester?: PluginHookToolRequesterContext; + allowedEvents?: readonly NativeHookRelayEvent[]; + /** Whether this relay should run OpenClaw loop detection from native PreToolUse hooks. */ + preToolUseLoopDetection?: boolean; + ttlMs?: number; + command?: NativeHookRelayCommandOptions; + signal?: AbortSignal; + onPreToolUseFailure?: NativeHookRelayRegistration["onPreToolUseFailure"]; +}; + +type NativeHookRelayCommandOptions = { + executable?: string; + nice?: number | false; + nodeExecutable?: string; + timeoutMs?: number; +}; + +type NativeHookRelayCommandForEventOptions = { + timeoutMs?: number; +}; + +export type InvokeNativeHookRelayParams = { + provider: unknown; + relayId: unknown; + generation?: unknown; + event: unknown; + rawPayload: unknown; + requireGeneration?: boolean; +}; + +export type InvokeNativeHookRelayBridgeParams = InvokeNativeHookRelayParams & { + registrationTimeoutMs?: number; + stateDbPath?: string; + timeoutMs?: number; +}; + +export type NativeHookRelayInvocationMetadata = Partial< + Pick< + NativeHookRelayInvocation, + | "nativeEventName" + | "cwd" + | "model" + | "turnId" + | "transcriptPath" + | "permissionMode" + | "stopHookActive" + | "lastAssistantMessage" + | "toolName" + | "toolUseId" + > +>; + +export type NativeHookRelayProviderAdapter = { + normalizeMetadata: (rawPayload: JsonValue) => NativeHookRelayInvocationMetadata; + readToolInput: (rawPayload: JsonValue) => Record; + readToolResponse: (rawPayload: JsonValue) => unknown; + renderNoopResponse: (event: NativeHookRelayEvent) => NativeHookRelayProcessResponse; + renderPreToolUseBlockResponse: ( + reason: string, + failureDisposition?: Exclude, + ) => NativeHookRelayProcessResponse; + renderBeforeAgentFinalizeReviseResponse: (reason: string) => NativeHookRelayProcessResponse; + renderBeforeAgentFinalizeStopResponse: (reason?: string) => NativeHookRelayProcessResponse; + renderPermissionDecisionResponse: ( + decision: NativeHookRelayPermissionDecision, + message?: string, + ) => NativeHookRelayProcessResponse; +}; + +type NativeHookRelayPermissionDecision = "allow" | "deny"; + +export type NativeHookRelayPermissionApprovalResult = + | NativeHookRelayPermissionDecision + | "allow-always" + | "defer"; + +export type ActiveNativeHookRelayRegistration = NativeHookRelayRegistration & { + generation: string; + preToolUseLoopDetection: boolean; + preToolUseFailureProjections: Map; settled: boolean }>; +}; + +export type ActiveNativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle & { + generation: string; +}; + +export type NativeHookRelayPermissionApprovalRequest = { + provider: NativeHookRelayProvider; + agentId?: string; + sessionId: string; + sessionKey?: string; + runId: string; + toolName: string; + toolCallId?: string; + cwd?: string; + model?: string; + toolInput: Record; + signal?: AbortSignal; +}; + +export type NativeHookRelayPermissionApprovalRequester = ( + request: NativeHookRelayPermissionApprovalRequest, +) => Promise; + +export type NativeHookRelayDeferredToolApprovalRequester = typeof requestDeferredPluginToolApproval; + +export type NativeHookRelayPreToolUseApproval = { + deferredApproval: DeferredPluginToolApproval; + originalParamsFingerprint: string; + resolutionPromise?: Promise; +}; + +export type NativeHookRelayDeferredApprovalOutcome = + | { + handled: true; + outcome: "approved-once"; + } + | { + handled: true; + outcome: "denied"; + reason: string; + failureDisposition?: Exclude; + }; + +export type NativeHookRelayBridgeRegistration = { + relayId: string; + stateDbPath: string; + token: string; + server: Server; +}; + +export type NativeHookRelayBridgeRequestAuth = { + provider: NativeHookRelayProvider; + relayId: string; + token: string; + registration: ActiveNativeHookRelayRegistration; + bridge: NativeHookRelayBridgeRegistration; +}; diff --git a/src/agents/harness/native-hook-relay-events.ts b/src/agents/harness/native-hook-relay-events.ts new file mode 100644 index 000000000000..5adc2b574d31 --- /dev/null +++ b/src/agents/harness/native-hook-relay-events.ts @@ -0,0 +1,283 @@ +import type { createSubsystemLogger } from "../../logging/subsystem.js"; +import { listAgentToolResultMiddlewares } from "../../plugins/agent-tool-result-middleware.js"; +import { + cancelDeferredPluginToolApproval, + runBeforeToolCallHook, +} from "../agent-tools.before-tool-call.js"; +import { stableStringify } from "../stable-stringify.js"; +import { payloadTextResult } from "../tools/common.js"; +import { runAgentHarnessAfterToolCallHook } from "./hook-helpers.js"; +import { runAgentHarnessBeforeAgentFinalizeHook } from "./lifecycle-hook-helpers.js"; +import type { + NativeHookRelayInvocation, + NativeHookRelayPermissionApprovalRequest, + NativeHookRelayPermissionApprovalResult, + NativeHookRelayProcessResponse, + NativeHookRelayProviderAdapter, + NativeHookRelayRegistration, +} from "./native-hook-relay-contracts.js"; +import { + nativeHookRelayParamsWereRewritten, + normalizeNativeHookToolName, + readNativeHookRelayApprovalMode, +} from "./native-hook-relay-provider.js"; +import { createAgentToolResultMiddlewareRunner } from "./tool-result-middleware.js"; + +export function createNativeHookRelayEventRuntime(context: { + pendingPermissionApprovals: Map>; + setNativeHookRelayPreToolUseApproval: (params: { + relayId: string; + toolUseId?: string; + deferredApproval: Parameters[0]; + originalParamsFingerprint: string; + }) => boolean; + startNativeHookRelayPermissionApprovalWithBudget: (params: { + registration: NativeHookRelayRegistration; + approvalKey: string; + request: NativeHookRelayPermissionApprovalRequest; + }) => Promise; + nativeHookRelayPermissionApprovalKey: (params: { + registration: NativeHookRelayRegistration; + request: NativeHookRelayPermissionApprovalRequest; + }) => string; + nativeHookRelayPermissionAllowAlwaysKey: (params: { + registration: NativeHookRelayRegistration; + request: NativeHookRelayPermissionApprovalRequest; + }) => string; + hasNativeHookRelayPermissionAllowAlways: (key: string, now?: number) => boolean; + rememberNativeHookRelayPermissionAllowAlways: (key: string, now?: number) => void; + log: Pick, "warn">; +}) { + const { + pendingPermissionApprovals, + setNativeHookRelayPreToolUseApproval, + startNativeHookRelayPermissionApprovalWithBudget, + nativeHookRelayPermissionApprovalKey, + nativeHookRelayPermissionAllowAlwaysKey, + hasNativeHookRelayPermissionAllowAlways, + rememberNativeHookRelayPermissionAllowAlways, + log, + } = context; + async function processNativeHookRelayInvocation(params: { + registration: NativeHookRelayRegistration; + invocation: NativeHookRelayInvocation; + adapter: NativeHookRelayProviderAdapter; + }): Promise { + if (params.invocation.event === "pre_tool_use") { + return runNativeHookRelayPreToolUse(params); + } + if (params.invocation.event === "post_tool_use") { + return runNativeHookRelayPostToolUse(params); + } + if (params.invocation.event === "before_agent_finalize") { + return runNativeHookRelayBeforeAgentFinalize(params); + } + return runNativeHookRelayPermissionRequest(params); + } + + async function runNativeHookRelayPreToolUse(params: { + registration: NativeHookRelayRegistration; + invocation: NativeHookRelayInvocation; + adapter: NativeHookRelayProviderAdapter; + }): Promise { + const toolName = normalizeNativeHookToolName(params.invocation.toolName); + const toolInput = params.adapter.readToolInput(params.invocation.rawPayload); + const originalToolInputFingerprint = stableStringify(toolInput); + const approvalMode = readNativeHookRelayApprovalMode(params.invocation.rawPayload); + const outcome = await runBeforeToolCallHook({ + toolName, + params: toolInput, + ...(params.invocation.toolUseId ? { toolCallId: params.invocation.toolUseId } : {}), + ...(approvalMode === "report" ? { approvalMode: "defer" } : {}), + signal: params.registration.signal, + ctx: { + ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + ...(params.registration.config ? { config: params.registration.config } : {}), + runId: params.registration.runId, + ...(params.registration.channelId ? { channelId: params.registration.channelId } : {}), + ...(params.registration.requester ? { requester: params.registration.requester } : {}), + ...(params.invocation.cwd + ? { cwd: params.invocation.cwd, workspaceDir: params.invocation.cwd } + : {}), + }, + }); + if (outcome.blocked) { + return params.adapter.renderPreToolUseBlockResponse( + outcome.reason, + outcome.kind === "failure" && outcome.disposition !== "blocked" + ? outcome.disposition + : undefined, + ); + } + if (outcome.deferredApproval) { + if ( + !setNativeHookRelayPreToolUseApproval({ + relayId: params.registration.relayId, + toolUseId: params.invocation.toolUseId, + deferredApproval: outcome.deferredApproval, + originalParamsFingerprint: originalToolInputFingerprint, + }) + ) { + cancelDeferredPluginToolApproval(outcome.deferredApproval); + return params.adapter.renderPreToolUseBlockResponse( + "Plugin approval required but Codex tool id unavailable.", + ); + } + return params.adapter.renderNoopResponse(params.invocation.event); + } + if (nativeHookRelayParamsWereRewritten(originalToolInputFingerprint, outcome.params)) { + // Codex app-server may continue with the original params when updatedInput + // is unsupported, so rewrites must fail closed here. + return params.adapter.renderPreToolUseBlockResponse( + "OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.", + ); + } + return params.adapter.renderNoopResponse(params.invocation.event); + } + + async function runNativeHookRelayPostToolUse(params: { + registration: NativeHookRelayRegistration; + invocation: NativeHookRelayInvocation; + adapter: NativeHookRelayProviderAdapter; + }): Promise { + const toolName = normalizeNativeHookToolName(params.invocation.toolName); + const toolCallId = + params.invocation.toolUseId ?? `${params.invocation.event}:${params.invocation.receivedAt}`; + const startArgs = params.adapter.readToolInput(params.invocation.rawPayload); + const rawResult = params.adapter.readToolResponse(params.invocation.rawPayload); + // Native results are observe-only for middleware: codex-rs PostToolUse hooks + // cannot replace tool_response (PostToolUseOutcome has no result field), so a + // transformed result reaches only after_tool_call observers, never the model. + const hasToolResultMiddleware = listAgentToolResultMiddlewares("codex").length > 0; + const result = !hasToolResultMiddleware + ? rawResult + : await createAgentToolResultMiddlewareRunner({ + runtime: "codex", + ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + runId: params.registration.runId, + }).applyToolResultMiddleware({ + turnId: params.invocation.turnId, + toolCallId, + toolName, + args: startArgs, + ...(params.invocation.cwd ? { cwd: params.invocation.cwd } : {}), + result: payloadTextResult(rawResult), + }); + await runAgentHarnessAfterToolCallHook({ + toolName, + toolCallId, + runId: params.registration.runId, + ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + ...(params.registration.channelId ? { channelId: params.registration.channelId } : {}), + startArgs, + result, + }); + return params.adapter.renderNoopResponse(params.invocation.event); + } + + async function runNativeHookRelayPermissionRequest(params: { + registration: NativeHookRelayRegistration; + invocation: NativeHookRelayInvocation; + adapter: NativeHookRelayProviderAdapter; + }): Promise { + const request: NativeHookRelayPermissionApprovalRequest = { + provider: params.registration.provider, + ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + runId: params.registration.runId, + toolName: normalizeNativeHookToolName(params.invocation.toolName), + ...(params.invocation.toolUseId ? { toolCallId: params.invocation.toolUseId } : {}), + ...(params.invocation.cwd ? { cwd: params.invocation.cwd } : {}), + ...(params.invocation.model ? { model: params.invocation.model } : {}), + toolInput: params.adapter.readToolInput(params.invocation.rawPayload), + ...(params.registration.signal ? { signal: params.registration.signal } : {}), + }; + const approvalKey = nativeHookRelayPermissionApprovalKey({ + registration: params.registration, + request, + }); + const allowAlwaysKey = nativeHookRelayPermissionAllowAlwaysKey({ + registration: params.registration, + request, + }); + if (hasNativeHookRelayPermissionAllowAlways(allowAlwaysKey)) { + return params.adapter.renderPermissionDecisionResponse("allow"); + } + const pendingApproval = pendingPermissionApprovals.get(approvalKey); + try { + const decision = await (pendingApproval ?? + startNativeHookRelayPermissionApprovalWithBudget({ + registration: params.registration, + approvalKey, + request, + })); + if (decision === "allow") { + return params.adapter.renderPermissionDecisionResponse("allow"); + } + if (decision === "allow-always") { + rememberNativeHookRelayPermissionAllowAlways(allowAlwaysKey); + return params.adapter.renderPermissionDecisionResponse("allow"); + } + if (decision === "deny") { + return params.adapter.renderPermissionDecisionResponse("deny", "Denied by user"); + } + } catch (error) { + log.warn( + `native hook permission approval failed; deferring to provider approval path: ${String(error)}`, + ); + } + // A PermissionRequest no-op is not an allow decision. Codex interprets it as + // "no hook decision" and falls through to its normal guardian/user approval path. + return params.adapter.renderNoopResponse(params.invocation.event); + } + + async function runNativeHookRelayBeforeAgentFinalize(params: { + registration: NativeHookRelayRegistration; + invocation: NativeHookRelayInvocation; + adapter: NativeHookRelayProviderAdapter; + }): Promise { + const outcome = await runAgentHarnessBeforeAgentFinalizeHook({ + event: { + runId: params.registration.runId, + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + ...(params.invocation.turnId ? { turnId: params.invocation.turnId } : {}), + provider: params.registration.provider, + ...(params.invocation.model ? { model: params.invocation.model } : {}), + ...(params.invocation.cwd ? { cwd: params.invocation.cwd } : {}), + ...(params.invocation.transcriptPath + ? { transcriptPath: params.invocation.transcriptPath } + : {}), + stopHookActive: params.invocation.stopHookActive === true, + ...(params.invocation.lastAssistantMessage + ? { lastAssistantMessage: params.invocation.lastAssistantMessage } + : {}), + }, + ctx: { + ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + runId: params.registration.runId, + ...(params.registration.channelId ? { channelId: params.registration.channelId } : {}), + ...(params.invocation.cwd ? { workspaceDir: params.invocation.cwd } : {}), + ...(params.invocation.model ? { modelId: params.invocation.model } : {}), + }, + }); + if (outcome.action === "revise") { + return params.adapter.renderBeforeAgentFinalizeReviseResponse(outcome.reason); + } + if (outcome.action === "finalize") { + return params.adapter.renderBeforeAgentFinalizeStopResponse(outcome.reason); + } + return params.adapter.renderNoopResponse(params.invocation.event); + } + + return { processNativeHookRelayInvocation }; +} diff --git a/src/agents/harness/native-hook-relay-permissions.ts b/src/agents/harness/native-hook-relay-permissions.ts new file mode 100644 index 000000000000..852bdde43f64 --- /dev/null +++ b/src/agents/harness/native-hook-relay-permissions.ts @@ -0,0 +1,602 @@ +import { createHash } from "node:crypto"; +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 type { 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 type { + JsonValue, + NativeHookRelayDeferredApprovalOutcome, + NativeHookRelayDeferredToolApprovalRequester, + NativeHookRelayPermissionApprovalRequest, + NativeHookRelayPermissionApprovalRequester, + NativeHookRelayPermissionApprovalResult, + NativeHookRelayPreToolUseApproval, + NativeHookRelayRegistration, +} from "./native-hook-relay-contracts.js"; +import { + nativeHookRelayParamsWereRewritten, + nativeHookRelayProviderDisplayName, + readOptionalString, + truncateText, +} from "./native-hook-relay-provider.js"; + +const PERMISSION_ALLOW_ALWAYS_TTL_MS = 30 * 60 * 1000; +const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; +const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200; +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; + +export function createNativeHookRelayPermissionRuntime(context: { + pendingPermissionApprovals: Map>; + pendingPreToolUseApprovals: Map; + permissionApprovalWindows: Map; + permissionAllowAlwaysApprovals: Map; + log: Pick, "warn">; +}) { + const { + pendingPermissionApprovals, + pendingPreToolUseApprovals, + permissionApprovalWindows, + permissionAllowAlwaysApprovals, + log, + } = context; + let nativeHookRelayPermissionApprovalRequester: NativeHookRelayPermissionApprovalRequester = + requestNativeHookRelayPermissionApproval; + let nativeHookRelayDeferredToolApprovalRequester: NativeHookRelayDeferredToolApprovalRequester = + requestDeferredPluginToolApproval; + async function resolveNativeHookRelayDeferredToolApproval(params: { + relayId: string; + toolUseId?: string; + signal?: AbortSignal; + }): Promise { + const pendingApprovalKey = nativeHookRelayPreToolUseApprovalKey({ + relayId: params.relayId, + toolUseId: params.toolUseId, + }); + if (!pendingApprovalKey) { + return undefined; + } + const pendingApproval = pendingPreToolUseApprovals.get(pendingApprovalKey); + if (!pendingApproval) { + return undefined; + } + pendingApproval.resolutionPromise ??= resolveNativeHookRelayPreToolUseApproval( + pendingApproval, + params.signal, + ).finally(() => { + if (pendingPreToolUseApprovals.get(pendingApprovalKey) === pendingApproval) { + pendingPreToolUseApprovals.delete(pendingApprovalKey); + } + }); + return pendingApproval.resolutionPromise; + } + + async function resolveNativeHookRelayPreToolUseApproval( + pendingApproval: NativeHookRelayPreToolUseApproval, + signal?: AbortSignal, + ): Promise { + const outcome = await nativeHookRelayDeferredToolApprovalRequester({ + deferredApproval: pendingApproval.deferredApproval, + signal, + }); + if (outcome.blocked) { + return { + handled: true, + outcome: "denied", + reason: outcome.reason, + ...(outcome.kind === "failure" && outcome.disposition !== "blocked" + ? { failureDisposition: outcome.disposition } + : {}), + }; + } + if ( + nativeHookRelayParamsWereRewritten(pendingApproval.originalParamsFingerprint, outcome.params) + ) { + return { + handled: true, + outcome: "denied", + reason: + "OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.", + }; + } + return { + handled: true, + outcome: "approved-once", + }; + } + + function nativeHookRelayPreToolUseApprovalKey(params: { + relayId: string; + toolUseId?: string; + }): string | undefined { + const toolUseId = params.toolUseId?.trim(); + return toolUseId ? `${params.relayId}:${toolUseId}` : undefined; + } + + function setNativeHookRelayPreToolUseApproval(params: { + relayId: string; + toolUseId?: string; + deferredApproval: DeferredPluginToolApproval; + originalParamsFingerprint: string; + }): boolean { + const key = nativeHookRelayPreToolUseApprovalKey(params); + if (!key) { + return false; + } + const previousApproval = pendingPreToolUseApprovals.get(key); + if (previousApproval) { + cancelDeferredPluginToolApproval(previousApproval.deferredApproval); + } + pendingPreToolUseApprovals.set(key, { + deferredApproval: params.deferredApproval, + originalParamsFingerprint: params.originalParamsFingerprint, + }); + if (pendingPreToolUseApprovals.size > MAX_NATIVE_HOOK_RELAY_INVOCATIONS) { + const oldestKey = pendingPreToolUseApprovals.keys().next().value; + if (oldestKey) { + const oldestApproval = pendingPreToolUseApprovals.get(oldestKey); + if (oldestApproval) { + cancelDeferredPluginToolApproval(oldestApproval.deferredApproval); + } + pendingPreToolUseApprovals.delete(oldestKey); + } + } + return true; + } + + function removeNativeHookRelayPreToolUseApprovals(relayId: string): void { + const prefix = `${relayId}:`; + for (const [key, pendingApproval] of pendingPreToolUseApprovals) { + if (key.startsWith(prefix)) { + cancelDeferredPluginToolApproval(pendingApproval.deferredApproval); + pendingPreToolUseApprovals.delete(key); + } + } + } + + async function startNativeHookRelayPermissionApprovalWithBudget(params: { + registration: NativeHookRelayRegistration; + approvalKey: string; + request: NativeHookRelayPermissionApprovalRequest; + }): Promise { + if (!consumeNativeHookRelayPermissionBudget(params.registration.relayId)) { + log.warn( + `native hook permission approval rate limit exceeded; deferring to provider approval path: relay=${params.registration.relayId} run=${params.registration.runId}`, + ); + return "defer"; + } + const approval: Promise = + nativeHookRelayPermissionApprovalRequester(params.request).finally(() => { + if (pendingPermissionApprovals.get(params.approvalKey) === approval) { + pendingPermissionApprovals.delete(params.approvalKey); + } + }); + pendingPermissionApprovals.set(params.approvalKey, approval); + return approval; + } + + function nativeHookRelayPermissionApprovalKey(params: { + registration: NativeHookRelayRegistration; + request: NativeHookRelayPermissionApprovalRequest; + }): string { + return [ + params.registration.relayId, + params.registration.runId, + params.request.toolCallId + ? `call:${params.request.toolCallId}` + : permissionRequestFallbackKey(params.request), + permissionRequestContentFingerprint(params.request), + ].join(":"); + } + + function nativeHookRelayPermissionAllowAlwaysKey(params: { + registration: NativeHookRelayRegistration; + request: NativeHookRelayPermissionApprovalRequest; + }): string { + const hash = createHash("sha256"); + hash.update("openclaw:native-hook-relay:permission-allow-always:v2"); + hash.update("\0"); + hash.update(params.registration.relayId); + hash.update("\0"); + hash.update(params.request.provider); + hash.update("\0"); + hash.update(params.request.agentId ?? ""); + hash.update("\0"); + hash.update(params.request.sessionKey ?? params.request.sessionId); + hash.update("\0"); + hash.update(permissionRequestContentFingerprint(params.request)); + return hash.digest("hex"); + } + + function permissionRequestFallbackKey(request: NativeHookRelayPermissionApprovalRequest): string { + const command = readOptionalString(request.toolInput.command); + if (command) { + return `${request.toolName}:command:${truncateText(command, 240)}`; + } + return `${request.toolName}:keys:${permissionRequestToolInputKeyFingerprint(request.toolInput)}`; + } + + function permissionRequestToolInputKeyFingerprint(toolInput: Record): string { + let fingerprint = ""; + const { keys, truncated } = readBoundedOwnKeys(toolInput, MAX_PERMISSION_FALLBACK_KEYS); + for (const key of keys) { + const separator = fingerprint ? "," : ""; + const remaining = MAX_PERMISSION_FALLBACK_KEY_CHARS - fingerprint.length - separator.length; + if (remaining <= 0) { + break; + } + fingerprint += `${separator}${key.slice(0, remaining)}`; + } + if (truncated && fingerprint.length < MAX_PERMISSION_FALLBACK_KEY_CHARS) { + const marker = `${fingerprint ? "," : ""}...`; + fingerprint += marker.slice(0, MAX_PERMISSION_FALLBACK_KEY_CHARS - fingerprint.length); + } + return fingerprint || "none"; + } + + function permissionRequestContentFingerprint( + request: NativeHookRelayPermissionApprovalRequest, + ): string { + const hash = createHash("sha256"); + hash.update(request.toolName); + hash.update("\0"); + hash.update(request.cwd ?? ""); + hash.update("\0"); + updateJsonHash(hash, request.toolInput); + return hash.digest("hex"); + } + + function updateJsonHash(hash: ReturnType, value: JsonValue): void { + if (value === null) { + hash.update("null"); + return; + } + if (typeof value === "string") { + hash.update("string:"); + hash.update(JSON.stringify(value)); + return; + } + if (typeof value === "number") { + hash.update(`number:${String(value)}`); + return; + } + if (typeof value === "boolean") { + hash.update(`boolean:${String(value)}`); + return; + } + if (Array.isArray(value)) { + hash.update("["); + for (const item of value) { + updateJsonHash(hash, item); + hash.update(","); + } + hash.update("]"); + return; + } + hash.update("{"); + const { keys, truncated } = readBoundedOwnKeys(value, MAX_PERMISSION_FINGERPRINT_SORT_KEYS); + for (const key of keys) { + hash.update(JSON.stringify(key)); + hash.update(":"); + const item = value[key]; + if (item !== undefined) { + updateJsonHash(hash, item); + } + hash.update(","); + } + if (truncated) { + // Keep ordinary objects order-independent without sorting a broad native + // hook payload. The tail remains content-sensitive in traversal order. + const sortedKeySet = new Set(keys); + hash.update("#object-tail:"); + for (const key in value) { + if (!Object.hasOwn(value, key) || sortedKeySet.has(key)) { + continue; + } + hash.update(JSON.stringify(key)); + hash.update(":"); + const item = value[key]; + if (item !== undefined) { + updateJsonHash(hash, item); + } + hash.update(","); + } + } + hash.update("}"); + } + + function readBoundedOwnKeys( + value: Record, + maxKeys: number, + ): { keys: string[]; truncated: boolean } { + const keys: string[] = []; + let truncated = false; + for (const key in value) { + if (!Object.hasOwn(value, key)) { + continue; + } + if (keys.length >= maxKeys) { + truncated = true; + break; + } + keys.push(key); + } + keys.sort(); + return { keys, truncated }; + } + + function consumeNativeHookRelayPermissionBudget(relayId: string, now = Date.now()): boolean { + const windowStart = now - PERMISSION_APPROVAL_WINDOW_MS; + const timestamps = (permissionApprovalWindows.get(relayId) ?? []).filter( + (timestamp) => timestamp >= windowStart, + ); + if (timestamps.length >= MAX_PERMISSION_APPROVALS_PER_WINDOW) { + permissionApprovalWindows.set(relayId, timestamps); + return false; + } + timestamps.push(now); + permissionApprovalWindows.set(relayId, timestamps); + return true; + } + + function hasNativeHookRelayPermissionAllowAlways(key: string, now = Date.now()): boolean { + const validNow = asDateTimestampMs(now); + if (validNow === undefined) { + return false; + } + const entry = permissionAllowAlwaysApprovals.get(key); + if (!entry) { + return false; + } + const expiresAtMs = asDateTimestampMs(entry.expiresAtMs); + if (expiresAtMs === undefined || expiresAtMs <= validNow) { + permissionAllowAlwaysApprovals.delete(key); + return false; + } + return true; + } + + function rememberNativeHookRelayPermissionAllowAlways(key: string, now = Date.now()): void { + pruneNativeHookRelayPermissionAllowAlways(now); + const expiresAtMs = resolveExpiresAtMsFromDurationMs(PERMISSION_ALLOW_ALWAYS_TTL_MS, { + nowMs: now, + }); + if (expiresAtMs === undefined) { + return; + } + permissionAllowAlwaysApprovals.set(key, { + expiresAtMs, + }); + while (permissionAllowAlwaysApprovals.size > MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES) { + const oldestKey = permissionAllowAlwaysApprovals.keys().next().value; + if (typeof oldestKey !== "string") { + break; + } + permissionAllowAlwaysApprovals.delete(oldestKey); + } + } + + function pruneNativeHookRelayPermissionAllowAlways(now = Date.now()): void { + const validNow = asDateTimestampMs(now); + if (validNow === undefined) { + return; + } + for (const [key, entry] of permissionAllowAlwaysApprovals) { + const expiresAtMs = asDateTimestampMs(entry.expiresAtMs); + if (expiresAtMs === undefined || expiresAtMs <= validNow) { + permissionAllowAlwaysApprovals.delete(key); + } + } + } + + function removeNativeHookRelayPermissionState(relayId: string): void { + permissionApprovalWindows.delete(relayId); + for (const key of pendingPermissionApprovals.keys()) { + if (key.startsWith(`${relayId}:`)) { + pendingPermissionApprovals.delete(key); + } + } + } + + async function requestNativeHookRelayPermissionApproval( + request: NativeHookRelayPermissionApprovalRequest, + ): Promise { + const timeoutMs = DEFAULT_PERMISSION_TIMEOUT_MS; + const requestResult: { + id?: string; + decision?: string | null; + } = await callGatewayTool( + "plugin.approval.request", + { timeoutMs: timeoutMs + 10_000 }, + { + pluginId: `openclaw-native-hook-relay-${request.provider}`, + title: truncateText( + `${nativeHookRelayProviderDisplayName(request.provider)} permission request`, + MAX_APPROVAL_TITLE_LENGTH, + ), + description: truncateText( + 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. + decision = waitResult?.id === approvalId ? waitResult.decision : undefined; + } + if (decision === PluginApprovalResolutions.ALLOW_ONCE) { + return "allow"; + } + if (decision === PluginApprovalResolutions.ALLOW_ALWAYS) { + return "allow-always"; + } + if (decision === PluginApprovalResolutions.DENY) { + return "deny"; + } + return "defer"; + } + + async function waitForNativeHookRelayApprovalDecision(params: { + approvalId: string; + signal?: AbortSignal; + timeoutMs: number; + }): Promise<{ id?: string; decision?: string | null } | undefined> { + const waitPromise: Promise<{ id?: string; decision?: string | null } | undefined> = + callGatewayTool( + "plugin.approval.waitDecision", + { timeoutMs: params.timeoutMs + 10_000 }, + { id: params.approvalId }, + ).catch((error: unknown) => { + if (isApprovalNotFoundError(error)) { + return undefined; + } + throw error; + }); + if (!params.signal) { + return waitPromise; + } + let onAbort: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + if (params.signal!.aborted) { + reject(toErrorObject(params.signal!.reason, "Non-Error rejection")); + return; + } + onAbort = () => reject(toErrorObject(params.signal!.reason, "Non-Error rejection")); + params.signal!.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([waitPromise, abortPromise]); + } finally { + if (onAbort) { + params.signal.removeEventListener("abort", onAbort); + } + } + } + + function formatPermissionApprovalDescription( + request: NativeHookRelayPermissionApprovalRequest, + ): string { + const lines = [ + `Tool: ${sanitizeApprovalText(request.toolName)}`, + request.cwd ? `Cwd: ${sanitizeApprovalText(request.cwd)}` : undefined, + request.model ? `Model: ${sanitizeApprovalText(request.model)}` : undefined, + formatToolInputPreview(request.toolInput), + ].filter((line): line is string => Boolean(line)); + return lines.join("\n"); + } + + function formatToolInputPreview(toolInput: Record): string | undefined { + const command = readOptionalString(toolInput.command); + if (command) { + return `Command: ${truncateText(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) + ); + } + + return { + resolveNativeHookRelayDeferredToolApproval, + setNativeHookRelayPreToolUseApproval, + removeNativeHookRelayPreToolUseApprovals, + startNativeHookRelayPermissionApprovalWithBudget, + nativeHookRelayPermissionApprovalKey, + nativeHookRelayPermissionAllowAlwaysKey, + hasNativeHookRelayPermissionAllowAlways, + rememberNativeHookRelayPermissionAllowAlways, + pruneNativeHookRelayPermissionAllowAlways, + removeNativeHookRelayPermissionState, + formatPermissionApprovalDescription, + permissionRequestContentFingerprint, + permissionRequestToolInputKeyFingerprint, + resetForTests(): void { + pendingPermissionApprovals.clear(); + for (const pendingApproval of pendingPreToolUseApprovals.values()) { + cancelDeferredPluginToolApproval(pendingApproval.deferredApproval); + } + pendingPreToolUseApprovals.clear(); + permissionApprovalWindows.clear(); + permissionAllowAlwaysApprovals.clear(); + nativeHookRelayPermissionApprovalRequester = requestNativeHookRelayPermissionApproval; + nativeHookRelayDeferredToolApprovalRequester = requestDeferredPluginToolApproval; + }, + setPermissionApprovalRequesterForTests( + requester: NativeHookRelayPermissionApprovalRequester, + ): void { + nativeHookRelayPermissionApprovalRequester = requester; + }, + setDeferredToolApprovalRequesterForTests( + requester: NativeHookRelayDeferredToolApprovalRequester, + ): void { + nativeHookRelayDeferredToolApprovalRequester = requester; + }, + }; +} diff --git a/src/agents/harness/native-hook-relay-provider.ts b/src/agents/harness/native-hook-relay-provider.ts new file mode 100644 index 000000000000..a52528f7721b --- /dev/null +++ b/src/agents/harness/native-hook-relay-provider.ts @@ -0,0 +1,485 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; +import { stableStringify } from "../stable-stringify.js"; +import { normalizeToolName } from "../tool-policy.js"; +import type { + JsonValue, + NativeHookRelayEvent, + NativeHookRelayInvocation, + NativeHookRelayInvocationMetadata, + NativeHookRelayProvider, + NativeHookRelayProviderAdapter, + NativeHookRelayRegistration, +} from "./native-hook-relay-contracts.js"; + +const NATIVE_HOOK_RELAY_EVENTS = [ + "pre_tool_use", + "post_tool_use", + "permission_request", + "before_agent_finalize", +] as const; + +const MAX_NATIVE_HOOK_RELAY_JSON_DEPTH = 64; +const MAX_NATIVE_HOOK_RELAY_JSON_NODES = 20_000; +const MAX_NATIVE_HOOK_RELAY_STRING_LENGTH = 1_000_000; +const MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH = 4_000_000; +const MAX_NATIVE_HOOK_RELAY_HISTORY_STRING_LENGTH = 4_000; +const MAX_NATIVE_HOOK_RELAY_HISTORY_TOTAL_STRING_LENGTH = 20_000; +const MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS = 50; +const MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS = 50; + +const NATIVE_HOOK_TOOL_NAME_ALIASES: Record = { + exec_command: "exec", +}; + +const nativeHookRelayProviderAdapters: Record< + NativeHookRelayProvider, + NativeHookRelayProviderAdapter +> = { + codex: { + normalizeMetadata: normalizeCodexHookMetadata, + readToolInput: readCodexToolInput, + readToolResponse: readCodexToolResponse, + renderNoopResponse: () => { + // Codex treats empty stdout plus exit 0 as no decision/no additional context. + return { stdout: "", stderr: "", exitCode: 0 }; + }, + renderPreToolUseBlockResponse: (reason, failureDisposition) => ({ + stdout: `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + })}\n`, + stderr: "", + exitCode: 0, + ...(failureDisposition ? { failureDisposition } : {}), + }), + renderBeforeAgentFinalizeReviseResponse: (reason) => ({ + stdout: `${JSON.stringify({ + decision: "block", + reason, + })}\n`, + stderr: "", + exitCode: 0, + }), + renderBeforeAgentFinalizeStopResponse: (reason) => ({ + stdout: `${JSON.stringify({ + continue: false, + ...(reason?.trim() ? { stopReason: reason.trim() } : {}), + })}\n`, + stderr: "", + exitCode: 0, + }), + renderPermissionDecisionResponse: (decision, message) => ({ + stdout: `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: + decision === "allow" + ? { behavior: "allow" } + : { + behavior: "deny", + message: message?.trim() || "Denied by OpenClaw", + }, + }, + })}\n`, + stderr: "", + exitCode: 0, + }), + }, +}; + +export function snapshotNativeHookRelayPayload(payload: JsonValue): JsonValue { + return snapshotJsonValue(payload, { + remainingStringLength: MAX_NATIVE_HOOK_RELAY_HISTORY_TOTAL_STRING_LENGTH, + }); +} + +function snapshotJsonValue(value: JsonValue, state: { remainingStringLength: number }): JsonValue { + if (value === null || typeof value === "number" || typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + return snapshotString(value, state); + } + if (Array.isArray(value)) { + const items = value + .slice(0, MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS) + .map((item) => snapshotJsonValue(item, state)); + if (value.length > MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS) { + items.push("[truncated]"); + } + return items; + } + const snapshot: Record = {}; + const keys = Object.keys(value); + for (const key of keys.slice(0, MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS)) { + const item = value[key]; + if (item !== undefined) { + snapshot[snapshotString(key, state)] = snapshotJsonValue(item, state); + } + } + if (keys.length > MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS) { + snapshot["[truncated]"] = keys.length - MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS; + } + return snapshot; +} + +function snapshotString(value: string, state: { remainingStringLength: number }): string { + if (state.remainingStringLength <= 0) { + return "[truncated]"; + } + const limit = Math.min( + value.length, + MAX_NATIVE_HOOK_RELAY_HISTORY_STRING_LENGTH, + state.remainingStringLength, + ); + if (limit >= value.length) { + state.remainingStringLength -= limit; + return value; + } + const prefix = truncateUtf16Safe(value, limit); + // Charge the retained prefix; a safe boundary may back up one code unit. + state.remainingStringLength -= prefix.length; + return `${prefix}...[truncated]`; +} + +export function normalizeNativeHookInvocation(params: { + registration: NativeHookRelayRegistration; + event: NativeHookRelayEvent; + rawPayload: JsonValue; +}): NativeHookRelayInvocation { + const metadata = getNativeHookRelayProviderAdapter( + params.registration.provider, + ).normalizeMetadata(params.rawPayload); + return { + provider: params.registration.provider, + relayId: params.registration.relayId, + event: params.event, + ...metadata, + ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + runId: params.registration.runId, + rawPayload: params.rawPayload, + receivedAt: new Date().toISOString(), + }; +} + +export function getNativeHookRelayProviderAdapter( + provider: NativeHookRelayProvider, +): NativeHookRelayProviderAdapter { + return nativeHookRelayProviderAdapters[provider]; +} + +function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvocationMetadata { + const payload = isJsonObject(rawPayload) ? rawPayload : {}; + const metadata: NativeHookRelayInvocationMetadata = {}; + const nativeEventName = readOptionalString(payload.hook_event_name); + if (nativeEventName) { + metadata.nativeEventName = nativeEventName; + } + const cwd = readOptionalString(payload.cwd); + if (cwd) { + metadata.cwd = cwd; + } + const model = readOptionalString(payload.model); + if (model) { + metadata.model = model; + } + const turnId = readOptionalString(payload.turn_id); + if (turnId) { + metadata.turnId = turnId; + } + const transcriptPath = readOptionalString(payload.transcript_path); + if (transcriptPath) { + metadata.transcriptPath = transcriptPath; + } + const permissionMode = readOptionalString(payload.permission_mode); + if (permissionMode) { + metadata.permissionMode = permissionMode; + } + const stopHookActive = readOptionalBoolean(payload.stop_hook_active); + if (stopHookActive !== undefined) { + metadata.stopHookActive = stopHookActive; + } + const lastAssistantMessage = readOptionalString(payload.last_assistant_message); + if (lastAssistantMessage) { + metadata.lastAssistantMessage = lastAssistantMessage; + } + const toolName = readOptionalString(payload.tool_name); + if (toolName) { + metadata.toolName = toolName; + } + const toolUseId = readOptionalString(payload.tool_use_id); + if (toolUseId) { + metadata.toolUseId = toolUseId; + } + return metadata; +} + +function readCodexToolInput(rawPayload: JsonValue): Record { + const payload = isJsonObject(rawPayload) ? rawPayload : {}; + const toolInput = payload.tool_input; + if (isJsonObject(toolInput)) { + const toolName = readOptionalString(payload.tool_name); + return normalizeCodexToolInput( + normalizeNativeHookToolName(toolName), + toolInput as Record, + ); + } + if (toolInput === undefined) { + return {}; + } + return { value: toolInput as JsonValue }; +} + +function normalizeCodexToolInput( + toolName: string, + toolInput: Record, +): Record { + const command = normalizeCodexCommand(toolInput.cmd); + if (toolName !== "exec" || command === undefined) { + return toolInput; + } + return { + ...toolInput, + command, + }; +} + +function normalizeCodexCommand(value: JsonValue | undefined): string | undefined { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value) && value.every((part): part is string => typeof part === "string")) { + return shellQuoteArgs(value); + } + return undefined; +} + +export function nativeHookRelayParamsWereRewritten( + originalFingerprint: string, + candidate: unknown, +): boolean { + if (candidate === undefined) { + return false; + } + return stableStringify(candidate) !== originalFingerprint; +} + +function readCodexToolResponse(rawPayload: JsonValue): unknown { + const payload = isJsonObject(rawPayload) ? rawPayload : {}; + return payload.tool_response; +} + +export function readNativeHookRelayApprovalMode(rawPayload: JsonValue): "report" | undefined { + const payload = isJsonObject(rawPayload) ? rawPayload : {}; + return payload.openclaw_approval_mode === "report" ? "report" : undefined; +} + +export function normalizeNativeHookToolName(toolName: string | undefined): string { + const normalized = normalizeToolName(toolName ?? "tool"); + return NATIVE_HOOK_TOOL_NAME_ALIASES[normalized] ?? normalized; +} + +export function nativeHookRelayProviderDisplayName(provider: NativeHookRelayProvider): string { + if (provider === "codex") { + return "Codex"; + } + return provider; +} + +export function truncateText(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${truncateUtf16Safe(value, Math.max(0, maxLength - 3))}...`; +} + +export function resolveOpenClawCliExecutable(): string { + const envPath = process.env.OPENCLAW_CLI_PATH?.trim(); + if (envPath && existsSync(envPath)) { + return envPath; + } + const packageRoot = resolveOpenClawPackageRootSync({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + if (packageRoot) { + for (const candidate of [ + path.join(packageRoot, "openclaw.mjs"), + path.join(packageRoot, "dist", "entry.js"), + path.join(packageRoot, "scripts", "run-node.mjs"), + ]) { + if (existsSync(candidate)) { + return candidate; + } + } + } + const argvEntry = process.argv[1]; + if (argvEntry) { + const resolved = path.resolve(argvEntry); + if (existsSync(resolved)) { + return resolved; + } + } + throw new Error("Cannot resolve OpenClaw CLI executable path for native hook relay"); +} + +export function normalizeAllowedEvents( + events: readonly NativeHookRelayEvent[] | undefined, +): readonly NativeHookRelayEvent[] { + if (!events?.length) { + return NATIVE_HOOK_RELAY_EVENTS; + } + return [...new Set(events)]; +} + +export function normalizePositiveInteger(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : fallback; +} + +export function normalizeOptionalPositiveInteger(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +export function shellQuoteArgs(args: readonly string[]): string { + return args.map((arg) => shellQuoteArg(arg, process.platform)).join(" "); +} + +function shellQuoteArg(value: string, platform: NodeJS.Platform): string { + if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) { + return value; + } + if (platform === "win32") { + return `"${value.replaceAll('"', '\\"')}"`; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +export function readNativeHookRelayProvider(value: unknown): NativeHookRelayProvider { + if (value === "codex") { + return value; + } + throw new Error("unsupported native hook relay provider"); +} + +export function readNativeHookRelayEvent(value: unknown): NativeHookRelayEvent { + if ( + value === "pre_tool_use" || + value === "post_tool_use" || + value === "permission_request" || + value === "before_agent_finalize" + ) { + return value; + } + throw new Error("unsupported native hook relay event"); +} + +export function readNonEmptyString(value: unknown, name: string): string { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + throw new Error(`native hook relay ${name} is required`); +} + +export function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +export function isJsonValue(value: unknown): value is JsonValue { + const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; + let nodes = 0; + let totalStringLength = 0; + while (stack.length) { + const current = stack.pop()!; + nodes += 1; + if (nodes > MAX_NATIVE_HOOK_RELAY_JSON_NODES) { + return false; + } + if (current.depth > MAX_NATIVE_HOOK_RELAY_JSON_DEPTH) { + return false; + } + if (current.value === null) { + continue; + } + if (typeof current.value === "string") { + if (current.value.length > MAX_NATIVE_HOOK_RELAY_STRING_LENGTH) { + return false; + } + totalStringLength += current.value.length; + if (totalStringLength > MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH) { + return false; + } + continue; + } + if (typeof current.value === "number") { + if (!Number.isFinite(current.value)) { + return false; + } + continue; + } + if (typeof current.value === "boolean") { + continue; + } + if (Array.isArray(current.value)) { + for (const valueLocal of current.value) { + if (nodes + stack.length + 1 > MAX_NATIVE_HOOK_RELAY_JSON_NODES) { + return false; + } + stack.push({ value: valueLocal, depth: current.depth + 1 }); + } + continue; + } + if (!isJsonObject(current.value)) { + return false; + } + try { + for (const key in current.value) { + if (!Object.hasOwn(current.value, key)) { + continue; + } + if (key.length > MAX_NATIVE_HOOK_RELAY_STRING_LENGTH) { + return false; + } + totalStringLength += key.length; + if (totalStringLength > MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH) { + return false; + } + if (nodes + stack.length + 1 > MAX_NATIVE_HOOK_RELAY_JSON_NODES) { + return false; + } + stack.push({ value: current.value[key], depth: current.depth + 1 }); + } + } catch { + return false; + } + } + return true; +} + +export function isJsonObject(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + try { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +} diff --git a/src/agents/harness/native-hook-relay-runtime.ts b/src/agents/harness/native-hook-relay-runtime.ts new file mode 100644 index 000000000000..0f9b9f088352 --- /dev/null +++ b/src/agents/harness/native-hook-relay-runtime.ts @@ -0,0 +1,525 @@ +import { randomUUID } from "node:crypto"; +import { resolveExpiresAtMsFromDurationMs } from "@openclaw/normalization-core/number-coercion"; +import type { createSubsystemLogger } from "../../logging/subsystem.js"; +import { listAgentToolResultMiddlewares } from "../../plugins/agent-tool-result-middleware.js"; +import { hasGlobalHooks } from "../../plugins/hook-runner-global.js"; +import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; +import { hasBeforeToolCallPolicy } from "../agent-tools.before-tool-call.js"; +import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js"; +import { NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR } from "./native-hook-relay-bridge.js"; +import type { + ActiveNativeHookRelayRegistration, + ActiveNativeHookRelayRegistrationHandle, + InvokeNativeHookRelayParams, + NativeHookRelayBridgeRegistration, + NativeHookRelayEvent, + NativeHookRelayInvocation, + NativeHookRelayProcessResponse, + NativeHookRelayProvider, + NativeHookRelayProviderAdapter, + NativeHookRelayRegistration, + RegisterNativeHookRelayParams, +} from "./native-hook-relay-contracts.js"; +import { + getNativeHookRelayProviderAdapter, + isJsonValue, + normalizeAllowedEvents, + normalizeNativeHookInvocation, + normalizeNativeHookToolName, + normalizeOptionalPositiveInteger, + normalizePositiveInteger, + readNativeHookRelayApprovalMode, + readNativeHookRelayEvent, + readNativeHookRelayProvider, + readNonEmptyString, + resolveOpenClawCliExecutable, + shellQuoteArgs, + snapshotNativeHookRelayPayload, +} from "./native-hook-relay-provider.js"; +import { renewOrRestoreNativeHookRelayBridgeRecord } from "./native-hook-relay-store.js"; + +const DEFAULT_RELAY_TTL_MS = 30 * 60 * 1000; +const DEFAULT_RELAY_TIMEOUT_MS = 5_000; +const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200; +const NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS = 250; + +export function createNativeHookRelayRuntime(context: { + relays: Map; + relayBridges: Map; + invocations: NativeHookRelayInvocation[]; + registerNativeHookRelayBridge: ( + registration: ActiveNativeHookRelayRegistration, + stateDbPath: string, + ) => void; + resolveNativeHookRelayBridgeRecord: ( + registration: ActiveNativeHookRelayRegistration, + bridge: NativeHookRelayBridgeRegistration, + expiresAtMs?: number, + ) => import("./native-hook-relay-store.js").NativeHookRelayBridgeRecord | undefined; + unregisterNativeHookRelayBridge: ( + relayId: string, + options?: { deferBridgeRecordRemovalMs?: number }, + ) => void; + processNativeHookRelayInvocation: (params: { + registration: NativeHookRelayRegistration; + invocation: NativeHookRelayInvocation; + adapter: NativeHookRelayProviderAdapter; + }) => Promise; + removeNativeHookRelayPreToolUseApprovals: (relayId: string) => void; + removeNativeHookRelayPermissionState: (relayId: string) => void; + pruneNativeHookRelayPermissionAllowAlways: (now?: number) => void; + log: Pick, "debug">; +}) { + const { + relays, + relayBridges, + invocations, + registerNativeHookRelayBridge, + resolveNativeHookRelayBridgeRecord, + unregisterNativeHookRelayBridge, + processNativeHookRelayInvocation, + removeNativeHookRelayPreToolUseApprovals, + removeNativeHookRelayPermissionState, + pruneNativeHookRelayPermissionAllowAlways, + log, + } = context; + + function resolveNativeHookRelayExpiresAtMs(ttlMs: number | undefined): number | undefined { + return resolveExpiresAtMsFromDurationMs(normalizePositiveInteger(ttlMs, DEFAULT_RELAY_TTL_MS)); + } + function registerNativeHookRelay( + params: RegisterNativeHookRelayParams, + ): ActiveNativeHookRelayRegistrationHandle { + pruneExpiredNativeHookRelays(); + pruneNativeHookRelayPermissionAllowAlways(); + const relayId = normalizeRelayId(params.relayId) ?? randomUUID(); + const generation = normalizeRelayGeneration(params.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(); + unregisterNativeHookRelay(relayId, undefined, { + deferBridgeRecordRemovalMs: NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS, + }); + 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 } : {}), + allowedEvents, + preToolUseLoopDetection: params.preToolUseLoopDetection !== false, + expiresAtMs, + preToolUseFailureProjections: new Map(), + ...(params.signal ? { signal: params.signal } : {}), + ...(params.onPreToolUseFailure ? { onPreToolUseFailure: params.onPreToolUseFailure } : {}), + }; + relays.set(relayId, registration); + registerNativeHookRelayBridge(registration, stateDbPath); + const handle: ActiveNativeHookRelayRegistrationHandle = { + ...registration, + shouldRelayEvent: (event) => nativeHookRelayEventHasLocalWork(registration, event), + commandForEvent: (event, options) => + buildNativeHookRelayCommandWithStateDatabase({ + provider: params.provider, + relayId, + stateDbPath, + generation: registration.generation, + event, + preToolUseUnavailable: + event === "pre_tool_use" && !nativeHookRelayEventHasLocalWork(registration, event) + ? "noop" + : undefined, + 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) { + const record = resolveNativeHookRelayBridgeRecord(current, bridge, renewedExpiresAtMs); + if (!record) { + return; + } + try { + if ( + !renewOrRestoreNativeHookRelayBridgeRecord({ + record, + stateDbPath: bridge.stateDbPath, + }) + ) { + 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; + }, + unregister: () => unregisterNativeHookRelay(relayId, registration), + }; + return handle; + } + + function unregisterNativeHookRelay( + relayId: string, + expectedRegistration?: ActiveNativeHookRelayRegistration, + options?: { deferBridgeRecordRemovalMs?: number }, + ): void { + if (expectedRegistration && relays.get(relayId) !== expectedRegistration) { + return; + } + unregisterNativeHookRelayBridge(relayId, options); + relays.delete(relayId); + removeNativeHookRelayInvocations(relayId); + removeNativeHookRelayPreToolUseApprovals(relayId); + removeNativeHookRelayPermissionState(relayId); + } + + function normalizeRelayId(value: string | undefined): 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 id must be non-empty, compact, and URL-safe"); + } + return trimmed; + } + + function normalizeRelayGeneration(value: string | undefined): 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 generation must be non-empty, compact, and URL-safe"); + } + return trimmed; + } + + function resolveNativeHookRelayNicePrefix(value: number | false | undefined): string[] { + if (process.platform === "win32" || value === false || value === undefined) { + return []; + } + const nice = normalizePositiveInteger(value, 0); + if (nice <= 0) { + return []; + } + return ["nice", "-n", String(nice)]; + } + + function resolveNativeHookRelayCommandTimeoutMs( + configuredTimeoutMs: number | undefined, + overrideTimeoutMs: number | undefined, + ): number | undefined { + const configured = normalizeOptionalPositiveInteger(configuredTimeoutMs); + const override = normalizeOptionalPositiveInteger(overrideTimeoutMs); + if (configured === undefined) { + return override; + } + if (override === undefined) { + return configured; + } + return Math.min(configured, override); + } + + function buildNativeHookRelayCommand(params: { + provider: NativeHookRelayProvider; + relayId: string; + generation?: string; + event: NativeHookRelayEvent; + preToolUseUnavailable?: "noop"; + timeoutMs?: number; + executable?: string; + nice?: number | false; + nodeExecutable?: string; + }): string { + return buildNativeHookRelayCommandWithStateDatabase(params); + } + + function buildNativeHookRelayCommandWithStateDatabase(params: { + provider: NativeHookRelayProvider; + relayId: string; + stateDbPath?: string; + generation?: string; + event: NativeHookRelayEvent; + preToolUseUnavailable?: "noop"; + timeoutMs?: number; + executable?: string; + nice?: number | false; + nodeExecutable?: string; + }): string { + const timeoutMs = normalizePositiveInteger(params.timeoutMs, DEFAULT_RELAY_TIMEOUT_MS); + const executable = params.executable ?? resolveOpenClawCliExecutable(); + const argv = + executable === "openclaw" + ? ["openclaw"] + : [params.nodeExecutable ?? process.execPath, executable]; + const nicePrefix = resolveNativeHookRelayNicePrefix(params.nice); + const command = shellQuoteArgs([ + ...nicePrefix, + ...argv, + "hooks", + "relay", + "--provider", + params.provider, + "--relay-id", + params.relayId, + ...(params.stateDbPath ? ["--state-db", params.stateDbPath] : []), + ...(params.generation ? ["--generation", params.generation] : []), + "--event", + params.event, + ...(params.event === "pre_tool_use" && params.preToolUseUnavailable + ? ["--pre-tool-use-unavailable", params.preToolUseUnavailable] + : []), + "--timeout", + String(timeoutMs), + ]); + // Codex kills the shell process when a hook times out. Replace that shell so + // the timeout targets this relay instead of leaving its Node child behind. + return process.platform === "win32" ? command : `exec ${command}`; + } + + function nativePreToolUseMayRunLoopDetection( + registration: ActiveNativeHookRelayRegistration, + ): boolean { + if (!registration.preToolUseLoopDetection || !registration.sessionKey) { + return false; + } + const loopDetection = resolveToolLoopDetectionConfig({ + cfg: registration.config, + agentId: registration.agentId, + }); + return loopDetection?.enabled !== false; + } + + function nativeHookRelayEventHasLocalWork( + registration: ActiveNativeHookRelayRegistration, + event: NativeHookRelayEvent, + ): boolean { + if (event === "pre_tool_use") { + // Avoid spawning a native hook relay for every Codex tool call when there + // is no before_tool_call hook, trusted-tool policy, or loop detector work. + return hasBeforeToolCallPolicy() || nativePreToolUseMayRunLoopDetection(registration); + } + if (event === "post_tool_use") { + return ( + hasGlobalHooks("after_tool_call") || listAgentToolResultMiddlewares("codex").length > 0 + ); + } + if (event === "before_agent_finalize") { + return hasGlobalHooks("before_agent_finalize"); + } + return true; + } + + async function invokeNativeHookRelay( + params: InvokeNativeHookRelayParams, + ): Promise { + const provider = readNativeHookRelayProvider(params.provider); + const relayId = readNonEmptyString(params.relayId, "relayId"); + const event = readNativeHookRelayEvent(params.event); + const registration = relays.get(relayId); + if (!registration) { + pruneExpiredNativeHookRelays(); + throw new Error("native hook relay not found"); + } + if (Date.now() > registration.expiresAtMs) { + unregisterNativeHookRelay(relayId, registration); + throw new Error("native hook relay expired"); + } + if (registration.provider !== provider) { + throw new Error("native hook relay provider mismatch"); + } + if (params.requireGeneration) { + const generation = readNonEmptyString(params.generation, "generation"); + if (generation !== registration.generation) { + if (!canAcceptNativeHookRelayGenerationMismatch(registration, 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, + }); + } + } + 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, + }); + recordNativeHookRelayInvocation(normalized); + const startedAt = Date.now(); + const response = await processNativeHookRelayInvocation({ + registration, + invocation: normalized, + adapter: getNativeHookRelayProviderAdapter(provider), + }); + if ( + normalized.toolUseId && + response.failureDisposition && + readNativeHookRelayApprovalMode(normalized.rawPayload) !== "report" + ) { + projectNativeHookRelayPreToolUseFailure(registration, { + toolName: normalizeNativeHookToolName(normalized.toolName), + toolCallId: normalized.toolUseId, + disposition: response.failureDisposition, + durationMs: Date.now() - startedAt, + }); + } + return response; + } + + function projectNativeHookRelayPreToolUseFailure( + registration: ActiveNativeHookRelayRegistration, + failure: Parameters>[0], + ): void { + const callback = registration.onPreToolUseFailure; + if (!callback) { + return; + } + if (registration.preToolUseFailureProjections.has(failure.toolCallId)) { + return; + } + const record = { + promise: Promise.resolve().then(() => callback(failure)), + settled: false, + }; + registration.preToolUseFailureProjections.set(failure.toolCallId, record); + void record.promise.then( + () => { + record.settled = true; + }, + (error: unknown) => { + record.settled = true; + if (registration.preToolUseFailureProjections.get(failure.toolCallId) === record) { + registration.preToolUseFailureProjections.delete(failure.toolCallId); + } + log.debug("native pre-tool failure projection failed", { + error, + relayId: registration.relayId, + toolCallId: failure.toolCallId, + }); + }, + ); + if (registration.preToolUseFailureProjections.size > MAX_NATIVE_HOOK_RELAY_INVOCATIONS) { + let oldestToolCallId: string | undefined; + for (const [toolCallId, candidate] of registration.preToolUseFailureProjections) { + oldestToolCallId ??= toolCallId; + if (candidate.settled) { + registration.preToolUseFailureProjections.delete(toolCallId); + return; + } + } + if (oldestToolCallId) { + registration.preToolUseFailureProjections.delete(oldestToolCallId); + } + } + } + + function hasNativeHookRelayInvocation(params: { + relayId: string; + event: NativeHookRelayEvent; + toolUseId?: string; + }): boolean { + const toolUseId = params.toolUseId?.trim(); + if (!toolUseId) { + return false; + } + return invocations.some( + (invocation) => + invocation.relayId === params.relayId && + invocation.event === params.event && + invocation.toolUseId === toolUseId, + ); + } + + function recordNativeHookRelayInvocation(invocation: NativeHookRelayInvocation): void { + invocations.push({ + ...invocation, + rawPayload: snapshotNativeHookRelayPayload(invocation.rawPayload), + }); + if (invocations.length > MAX_NATIVE_HOOK_RELAY_INVOCATIONS) { + invocations.splice(0, invocations.length - MAX_NATIVE_HOOK_RELAY_INVOCATIONS); + } + } + + 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, + ): boolean { + const expiresAtMs = registration.generationMismatchGraceExpiresAtMs; + if (typeof expiresAtMs !== "number" || Date.now() > expiresAtMs) { + return false; + } + if (registration.generationMismatchGraceAcceptedGeneration) { + return registration.generationMismatchGraceAcceptedGeneration === generation; + } + registration.generationMismatchGraceAcceptedGeneration = generation; + return true; + } + + function pruneExpiredNativeHookRelays(now = Date.now()): void { + for (const [relayId, registration] of relays) { + if (now > registration.expiresAtMs) { + unregisterNativeHookRelay(relayId, registration); + } + } + } + + return { + registerNativeHookRelay, + buildNativeHookRelayCommand, + invokeNativeHookRelay, + hasNativeHookRelayInvocation, + unregisterNativeHookRelay, + pruneExpiredNativeHookRelays, + getNativeHookRelayInvocationsForTests: () => [...invocations], + getNativeHookRelayRegistrationForTests: (relayId: string) => relays.get(relayId), + }; +} diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index 80584c78c570..751e26e992d0 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -1,59 +1,20 @@ +import type { Server } from "node:http"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; /** * Bridges native harness hook events through registered relay processes. */ -import { createHash, randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; -import { - createServer, - request as httpRequest, - type IncomingMessage, - type Server, - type ServerResponse, -} from "node:http"; -import path from "node:path"; -import { - asDateTimestampMs, - resolveExpiresAtMsFromDurationMs, -} from "@openclaw/normalization-core/number-coercion"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { isApprovalNotFoundError } from "../../infra/approval-errors.js"; -import { toErrorObject } from "../../infra/errors.js"; -import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { listAgentToolResultMiddlewares } from "../../plugins/agent-tool-result-middleware.js"; -import { hasGlobalHooks } from "../../plugins/hook-runner-global.js"; import type { PluginHookToolRequesterContext } from "../../plugins/hook-types.js"; -import { PluginApprovalResolutions } from "../../plugins/types.js"; -import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; -import { - cancelDeferredPluginToolApproval, - hasBeforeToolCallPolicy, +import type { + BeforeToolCallFailureDisposition, + DeferredPluginToolApproval, requestDeferredPluginToolApproval, - runBeforeToolCallHook, - type BeforeToolCallFailureDisposition, - type DeferredPluginToolApproval, } from "../agent-tools.before-tool-call.js"; -import { stableStringify } from "../stable-stringify.js"; -import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js"; -import { normalizeToolName } from "../tool-policy.js"; -import { payloadTextResult } from "../tools/common.js"; -import { callGatewayTool } from "../tools/gateway.js"; -import { runAgentHarnessAfterToolCallHook } from "./hook-helpers.js"; -import { runAgentHarnessBeforeAgentFinalizeHook } from "./lifecycle-hook-helpers.js"; -import { - clearNativeHookRelayBridgeRecordsForTests, - deleteNativeHookRelayBridgeRecordIfOwned, - pruneNativeHookRelayBridgeRecords, - readNativeHookRelayBridgeRecord as readNativeHookRelayBridgeRecordFromStore, - renewOrRestoreNativeHookRelayBridgeRecord, - writeNativeHookRelayBridgeRecord, - type NativeHookRelayBridgeRecord, -} from "./native-hook-relay-store.js"; -import { createAgentToolResultMiddlewareRunner } from "./tool-result-middleware.js"; - -type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; +import { createNativeHookRelayBridgeRuntime } from "./native-hook-relay-bridge.js"; +import { createNativeHookRelayEventRuntime } from "./native-hook-relay-events.js"; +import { createNativeHookRelayPermissionRuntime } from "./native-hook-relay-permissions.js"; +import { createNativeHookRelayRuntime } from "./native-hook-relay-runtime.js"; +import { clearNativeHookRelayBridgeRecordsForTests } from "./native-hook-relay-store.js"; const NATIVE_HOOK_RELAY_EVENTS = [ "pre_tool_use", @@ -61,34 +22,11 @@ const NATIVE_HOOK_RELAY_EVENTS = [ "permission_request", "before_agent_finalize", ] as const; - const NATIVE_HOOK_RELAY_PROVIDERS = ["codex"] as const; export type NativeHookRelayEvent = (typeof NATIVE_HOOK_RELAY_EVENTS)[number]; export type NativeHookRelayProvider = (typeof NATIVE_HOOK_RELAY_PROVIDERS)[number]; -type NativeHookRelayInvocation = { - provider: NativeHookRelayProvider; - relayId: string; - event: NativeHookRelayEvent; - nativeEventName?: string; - agentId?: string; - sessionId: string; - sessionKey?: string; - runId: string; - cwd?: string; - model?: string; - turnId?: string; - transcriptPath?: string; - permissionMode?: string; - stopHookActive?: boolean; - lastAssistantMessage?: string; - toolName?: string; - toolUseId?: string; - rawPayload: JsonValue; - receivedAt: string; -}; - export type NativeHookRelayProcessResponse = { stdout: string; stderr: string; @@ -119,6 +57,17 @@ type NativeHookRelayRegistration = { }) => void | Promise; }; +type NativeHookRelayCommandOptions = { + executable?: string; + nice?: number | false; + nodeExecutable?: string; + timeoutMs?: number; +}; + +type NativeHookRelayCommandForEventOptions = { + timeoutMs?: number; +}; + export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistration & { generation?: string; shouldRelayEvent: (event: NativeHookRelayEvent) => boolean; @@ -130,6 +79,10 @@ export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistration & { unregister: () => void; }; +type ActiveNativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle & { + generation: string; +}; + type RegisterNativeHookRelayParams = { provider: NativeHookRelayProvider; relayId?: string; @@ -151,17 +104,6 @@ type RegisterNativeHookRelayParams = { onPreToolUseFailure?: NativeHookRelayRegistration["onPreToolUseFailure"]; }; -type NativeHookRelayCommandOptions = { - executable?: string; - nice?: number | false; - nodeExecutable?: string; - timeoutMs?: number; -}; - -type NativeHookRelayCommandForEventOptions = { - timeoutMs?: number; -}; - type InvokeNativeHookRelayParams = { provider: unknown; relayId: unknown; @@ -177,72 +119,39 @@ type InvokeNativeHookRelayBridgeParams = InvokeNativeHookRelayParams & { timeoutMs?: number; }; -type NativeHookRelayInvocationMetadata = Partial< - Pick< - NativeHookRelayInvocation, - | "nativeEventName" - | "cwd" - | "model" - | "turnId" - | "transcriptPath" - | "permissionMode" - | "stopHookActive" - | "lastAssistantMessage" - | "toolName" - | "toolUseId" - > ->; +type NativeHookRelayDeferredApprovalOutcome = + | { handled: true; outcome: "approved-once" } + | { + handled: true; + outcome: "denied"; + reason: string; + failureDisposition?: Exclude; + }; -type NativeHookRelayProviderAdapter = { - normalizeMetadata: (rawPayload: JsonValue) => NativeHookRelayInvocationMetadata; - readToolInput: (rawPayload: JsonValue) => Record; - readToolResponse: (rawPayload: JsonValue) => unknown; - renderNoopResponse: (event: NativeHookRelayEvent) => NativeHookRelayProcessResponse; - renderPreToolUseBlockResponse: ( - reason: string, - failureDisposition?: Exclude, - ) => NativeHookRelayProcessResponse; - renderBeforeAgentFinalizeReviseResponse: (reason: string) => NativeHookRelayProcessResponse; - renderBeforeAgentFinalizeStopResponse: (reason?: string) => NativeHookRelayProcessResponse; - renderPermissionDecisionResponse: ( - decision: NativeHookRelayPermissionDecision, - message?: string, - ) => NativeHookRelayProcessResponse; +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +type NativeHookRelayInvocation = { + provider: NativeHookRelayProvider; + relayId: string; + event: NativeHookRelayEvent; + nativeEventName?: string; + agentId?: string; + sessionId: string; + sessionKey?: string; + runId: string; + cwd?: string; + model?: string; + turnId?: string; + transcriptPath?: string; + permissionMode?: string; + stopHookActive?: boolean; + lastAssistantMessage?: string; + toolName?: string; + toolUseId?: string; + rawPayload: JsonValue; + receivedAt: string; }; -const DEFAULT_RELAY_TTL_MS = 30 * 60 * 1000; -const DEFAULT_RELAY_TIMEOUT_MS = 5_000; -const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; -const PERMISSION_ALLOW_ALWAYS_TTL_MS = 30 * 60 * 1000; -const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200; -const MAX_NATIVE_HOOK_RELAY_JSON_DEPTH = 64; -const MAX_NATIVE_HOOK_RELAY_JSON_NODES = 20_000; -const MAX_NATIVE_HOOK_RELAY_STRING_LENGTH = 1_000_000; -const MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH = 4_000_000; -const MAX_NATIVE_HOOK_RELAY_HISTORY_STRING_LENGTH = 4_000; -const MAX_NATIVE_HOOK_RELAY_HISTORY_TOTAL_STRING_LENGTH = 20_000; -const MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS = 50; -const MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS = 50; -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; -const MAX_NATIVE_HOOK_BRIDGE_BODY_BYTES = 5_000_000; -const MAX_NATIVE_HOOK_BRIDGE_RESPONSE_BYTES = 5_000_000; -const NATIVE_HOOK_BRIDGE_RETRY_INTERVAL_MS = 25; -const NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS = 250; -const NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR = - "native hook relay bridge stale registration"; -const log = createSubsystemLogger("agents/harness/native-hook-relay"); - -function resolveNativeHookRelayExpiresAtMs(ttlMs: number | undefined): number | undefined { - return resolveExpiresAtMsFromDurationMs(normalizePositiveInteger(ttlMs, DEFAULT_RELAY_TTL_MS)); -} - type NativeHookRelayPermissionDecision = "allow" | "deny"; type NativeHookRelayPermissionApprovalResult = @@ -250,6 +159,45 @@ type NativeHookRelayPermissionApprovalResult = | "allow-always" | "defer"; +type ActiveNativeHookRelayRegistration = NativeHookRelayRegistration & { + generation: string; + preToolUseLoopDetection: boolean; + preToolUseFailureProjections: Map; settled: boolean }>; +}; + +type NativeHookRelayPermissionApprovalRequest = { + provider: NativeHookRelayProvider; + agentId?: string; + sessionId: string; + sessionKey?: string; + runId: string; + toolName: string; + toolCallId?: string; + cwd?: string; + model?: string; + toolInput: Record; + signal?: AbortSignal; +}; + +type NativeHookRelayPermissionApprovalRequester = ( + request: NativeHookRelayPermissionApprovalRequest, +) => Promise; + +type NativeHookRelayDeferredToolApprovalRequester = typeof requestDeferredPluginToolApproval; + +type NativeHookRelayPreToolUseApproval = { + deferredApproval: DeferredPluginToolApproval; + originalParamsFingerprint: string; + resolutionPromise?: Promise; +}; + +type NativeHookRelayBridgeRegistration = { + relayId: string; + stateDbPath: string; + token: string; + server: Server; +}; + type NativeHookRelaySharedState = { relays: Map; relayBridges: Map; @@ -260,16 +208,6 @@ type NativeHookRelaySharedState = { permissionAllowAlwaysApprovals: Map; }; -type ActiveNativeHookRelayRegistration = NativeHookRelayRegistration & { - generation: string; - preToolUseLoopDetection: boolean; - preToolUseFailureProjections: Map; settled: boolean }>; -}; - -type ActiveNativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle & { - generation: string; -}; - const NATIVE_HOOK_RELAY_STATE_SYMBOL = Symbol.for("openclaw.nativeHookRelay.state"); function getNativeHookRelaySharedState(): NativeHookRelaySharedState { @@ -297,290 +235,63 @@ const pendingPreToolUseApprovals = nativeHookRelayState.pendingPreToolUseApprova const permissionApprovalWindows = nativeHookRelayState.permissionApprovalWindows; const permissionAllowAlwaysApprovals = nativeHookRelayState.permissionAllowAlwaysApprovals; -type NativeHookRelayPermissionApprovalRequest = { - provider: NativeHookRelayProvider; - agentId?: string; - sessionId: string; - sessionKey?: string; - runId: string; - toolName: string; - toolCallId?: string; - cwd?: string; - model?: string; - toolInput: Record; - signal?: AbortSignal; -}; +const log = createSubsystemLogger("agents/harness/native-hook-relay"); -type NativeHookRelayPermissionApprovalRequester = ( - request: NativeHookRelayPermissionApprovalRequest, -) => Promise; +const permissionRuntime = createNativeHookRelayPermissionRuntime({ + pendingPermissionApprovals, + pendingPreToolUseApprovals, + permissionApprovalWindows, + permissionAllowAlwaysApprovals, + log, +}); -type NativeHookRelayDeferredToolApprovalRequester = typeof requestDeferredPluginToolApproval; - -type NativeHookRelayPreToolUseApproval = { - deferredApproval: DeferredPluginToolApproval; - originalParamsFingerprint: string; - resolutionPromise?: Promise; -}; - -type NativeHookRelayDeferredApprovalOutcome = - | { - handled: true; - outcome: "approved-once"; +const runtimeHolder: { current?: ReturnType } = {}; +const bridgeRuntime = createNativeHookRelayBridgeRuntime({ + relays, + relayBridges, + invokeNativeHookRelay: (params) => { + if (!runtimeHolder.current) { + throw new Error("native hook relay runtime unavailable"); } - | { - handled: true; - outcome: "denied"; - reason: string; - failureDisposition?: Exclude; - }; - -type NativeHookRelayBridgeRegistration = { - relayId: string; - stateDbPath: string; - token: string; - server: Server; -}; - -type NativeHookRelayBridgeRequestAuth = { - provider: NativeHookRelayProvider; - relayId: string; - token: string; - registration: ActiveNativeHookRelayRegistration; - bridge: NativeHookRelayBridgeRegistration; -}; - -let nativeHookRelayPermissionApprovalRequester: NativeHookRelayPermissionApprovalRequester = - requestNativeHookRelayPermissionApproval; -let nativeHookRelayDeferredToolApprovalRequester: NativeHookRelayDeferredToolApprovalRequester = - requestDeferredPluginToolApproval; - -const NATIVE_HOOK_TOOL_NAME_ALIASES: Record = { - exec_command: "exec", -}; - -const nativeHookRelayProviderAdapters: Record< - NativeHookRelayProvider, - NativeHookRelayProviderAdapter -> = { - codex: { - normalizeMetadata: normalizeCodexHookMetadata, - readToolInput: readCodexToolInput, - readToolResponse: readCodexToolResponse, - renderNoopResponse: () => { - // Codex treats empty stdout plus exit 0 as no decision/no additional context. - return { stdout: "", stderr: "", exitCode: 0 }; - }, - renderPreToolUseBlockResponse: (reason, failureDisposition) => ({ - stdout: `${JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: reason, - }, - })}\n`, - stderr: "", - exitCode: 0, - ...(failureDisposition ? { failureDisposition } : {}), - }), - renderBeforeAgentFinalizeReviseResponse: (reason) => ({ - stdout: `${JSON.stringify({ - decision: "block", - reason, - })}\n`, - stderr: "", - exitCode: 0, - }), - renderBeforeAgentFinalizeStopResponse: (reason) => ({ - stdout: `${JSON.stringify({ - continue: false, - ...(reason?.trim() ? { stopReason: reason.trim() } : {}), - })}\n`, - stderr: "", - exitCode: 0, - }), - renderPermissionDecisionResponse: (decision, message) => ({ - stdout: `${JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PermissionRequest", - decision: - decision === "allow" - ? { behavior: "allow" } - : { - behavior: "deny", - message: message?.trim() || "Denied by OpenClaw", - }, - }, - })}\n`, - stderr: "", - exitCode: 0, - }), + return runtimeHolder.current.invokeNativeHookRelay(params); }, -}; + log, +}); +const eventRuntime = createNativeHookRelayEventRuntime({ + pendingPermissionApprovals, + setNativeHookRelayPreToolUseApproval: permissionRuntime.setNativeHookRelayPreToolUseApproval, + startNativeHookRelayPermissionApprovalWithBudget: + permissionRuntime.startNativeHookRelayPermissionApprovalWithBudget, + nativeHookRelayPermissionApprovalKey: permissionRuntime.nativeHookRelayPermissionApprovalKey, + nativeHookRelayPermissionAllowAlwaysKey: + permissionRuntime.nativeHookRelayPermissionAllowAlwaysKey, + hasNativeHookRelayPermissionAllowAlways: + permissionRuntime.hasNativeHookRelayPermissionAllowAlways, + rememberNativeHookRelayPermissionAllowAlways: + permissionRuntime.rememberNativeHookRelayPermissionAllowAlways, + log, +}); +const runtime = createNativeHookRelayRuntime({ + relays, + relayBridges, + invocations, + registerNativeHookRelayBridge: bridgeRuntime.registerNativeHookRelayBridge, + resolveNativeHookRelayBridgeRecord: bridgeRuntime.resolveNativeHookRelayBridgeRecord, + unregisterNativeHookRelayBridge: bridgeRuntime.unregisterNativeHookRelayBridge, + processNativeHookRelayInvocation: eventRuntime.processNativeHookRelayInvocation, + removeNativeHookRelayPreToolUseApprovals: + permissionRuntime.removeNativeHookRelayPreToolUseApprovals, + removeNativeHookRelayPermissionState: permissionRuntime.removeNativeHookRelayPermissionState, + pruneNativeHookRelayPermissionAllowAlways: + permissionRuntime.pruneNativeHookRelayPermissionAllowAlways, + log, +}); +runtimeHolder.current = runtime; export function registerNativeHookRelay( params: RegisterNativeHookRelayParams, ): ActiveNativeHookRelayRegistrationHandle { - pruneExpiredNativeHookRelays(); - pruneNativeHookRelayPermissionAllowAlways(); - const relayId = normalizeRelayId(params.relayId) ?? randomUUID(); - const generation = normalizeRelayGeneration(params.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(); - unregisterNativeHookRelay(relayId, undefined, { - deferBridgeRecordRemovalMs: NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS, - }); - 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 } : {}), - allowedEvents, - preToolUseLoopDetection: params.preToolUseLoopDetection !== false, - expiresAtMs, - preToolUseFailureProjections: new Map(), - ...(params.signal ? { signal: params.signal } : {}), - ...(params.onPreToolUseFailure ? { onPreToolUseFailure: params.onPreToolUseFailure } : {}), - }; - relays.set(relayId, registration); - registerNativeHookRelayBridge(registration, stateDbPath); - const handle: ActiveNativeHookRelayRegistrationHandle = { - ...registration, - shouldRelayEvent: (event) => nativeHookRelayEventHasLocalWork(registration, event), - commandForEvent: (event, options) => - buildNativeHookRelayCommandWithStateDatabase({ - provider: params.provider, - relayId, - stateDbPath, - generation: registration.generation, - event, - preToolUseUnavailable: - event === "pre_tool_use" && !nativeHookRelayEventHasLocalWork(registration, event) - ? "noop" - : undefined, - 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) { - const record = resolveNativeHookRelayBridgeRecord(current, bridge, renewedExpiresAtMs); - if (!record) { - return; - } - try { - if ( - !renewOrRestoreNativeHookRelayBridgeRecord({ - record, - stateDbPath: bridge.stateDbPath, - }) - ) { - 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; - }, - unregister: () => unregisterNativeHookRelay(relayId, registration), - }; - return handle; -} - -function unregisterNativeHookRelay( - relayId: string, - expectedRegistration?: ActiveNativeHookRelayRegistration, - options?: { deferBridgeRecordRemovalMs?: number }, -): void { - if (expectedRegistration && relays.get(relayId) !== expectedRegistration) { - return; - } - unregisterNativeHookRelayBridge(relayId, options); - relays.delete(relayId); - removeNativeHookRelayInvocations(relayId); - removeNativeHookRelayPreToolUseApprovals(relayId); - removeNativeHookRelayPermissionState(relayId); -} - -function normalizeRelayId(value: string | undefined): 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 id must be non-empty, compact, and URL-safe"); - } - return trimmed; -} - -function normalizeRelayGeneration(value: string | undefined): 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 generation must be non-empty, compact, and URL-safe"); - } - return trimmed; -} - -function resolveNativeHookRelayNicePrefix(value: number | false | undefined): string[] { - if (process.platform === "win32" || value === false || value === undefined) { - return []; - } - const nice = normalizePositiveInteger(value, 0); - if (nice <= 0) { - return []; - } - return ["nice", "-n", String(nice)]; -} - -function resolveNativeHookRelayCommandTimeoutMs( - configuredTimeoutMs: number | undefined, - overrideTimeoutMs: number | undefined, -): number | undefined { - const configured = normalizeOptionalPositiveInteger(configuredTimeoutMs); - const override = normalizeOptionalPositiveInteger(overrideTimeoutMs); - if (configured === undefined) { - return override; - } - if (override === undefined) { - return configured; - } - return Math.min(configured, override); + return runtime.registerNativeHookRelay(params); } export function buildNativeHookRelayCommand(params: { @@ -594,193 +305,13 @@ export function buildNativeHookRelayCommand(params: { nice?: number | false; nodeExecutable?: string; }): string { - return buildNativeHookRelayCommandWithStateDatabase(params); -} - -function buildNativeHookRelayCommandWithStateDatabase(params: { - provider: NativeHookRelayProvider; - relayId: string; - stateDbPath?: string; - generation?: string; - event: NativeHookRelayEvent; - preToolUseUnavailable?: "noop"; - timeoutMs?: number; - executable?: string; - nice?: number | false; - nodeExecutable?: string; -}): string { - const timeoutMs = normalizePositiveInteger(params.timeoutMs, DEFAULT_RELAY_TIMEOUT_MS); - const executable = params.executable ?? resolveOpenClawCliExecutable(); - const argv = - executable === "openclaw" - ? ["openclaw"] - : [params.nodeExecutable ?? process.execPath, executable]; - const nicePrefix = resolveNativeHookRelayNicePrefix(params.nice); - const command = shellQuoteArgs([ - ...nicePrefix, - ...argv, - "hooks", - "relay", - "--provider", - params.provider, - "--relay-id", - params.relayId, - ...(params.stateDbPath ? ["--state-db", params.stateDbPath] : []), - ...(params.generation ? ["--generation", params.generation] : []), - "--event", - params.event, - ...(params.event === "pre_tool_use" && params.preToolUseUnavailable - ? ["--pre-tool-use-unavailable", params.preToolUseUnavailable] - : []), - "--timeout", - String(timeoutMs), - ]); - // Codex kills the shell process when a hook times out. Replace that shell so - // the timeout targets this relay instead of leaving its Node child behind. - return process.platform === "win32" ? command : `exec ${command}`; -} - -function nativePreToolUseMayRunLoopDetection( - registration: ActiveNativeHookRelayRegistration, -): boolean { - if (!registration.preToolUseLoopDetection || !registration.sessionKey) { - return false; - } - const loopDetection = resolveToolLoopDetectionConfig({ - cfg: registration.config, - agentId: registration.agentId, - }); - return loopDetection?.enabled !== false; -} - -function nativeHookRelayEventHasLocalWork( - registration: ActiveNativeHookRelayRegistration, - event: NativeHookRelayEvent, -): boolean { - if (event === "pre_tool_use") { - // Avoid spawning a native hook relay for every Codex tool call when there - // is no before_tool_call hook, trusted-tool policy, or loop detector work. - return hasBeforeToolCallPolicy() || nativePreToolUseMayRunLoopDetection(registration); - } - if (event === "post_tool_use") { - return hasGlobalHooks("after_tool_call") || listAgentToolResultMiddlewares("codex").length > 0; - } - if (event === "before_agent_finalize") { - return hasGlobalHooks("before_agent_finalize"); - } - return true; + return runtime.buildNativeHookRelayCommand(params); } export async function invokeNativeHookRelay( params: InvokeNativeHookRelayParams, ): Promise { - const provider = readNativeHookRelayProvider(params.provider); - const relayId = readNonEmptyString(params.relayId, "relayId"); - const event = readNativeHookRelayEvent(params.event); - const registration = relays.get(relayId); - if (!registration) { - pruneExpiredNativeHookRelays(); - throw new Error("native hook relay not found"); - } - if (Date.now() > registration.expiresAtMs) { - unregisterNativeHookRelay(relayId, registration); - throw new Error("native hook relay expired"); - } - if (registration.provider !== provider) { - throw new Error("native hook relay provider mismatch"); - } - if (params.requireGeneration) { - const generation = readNonEmptyString(params.generation, "generation"); - if (generation !== registration.generation) { - if (!canAcceptNativeHookRelayGenerationMismatch(registration, 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, - }); - } - } - 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, - }); - recordNativeHookRelayInvocation(normalized); - const startedAt = Date.now(); - const response = await processNativeHookRelayInvocation({ - registration, - invocation: normalized, - adapter: getNativeHookRelayProviderAdapter(provider), - }); - if ( - normalized.toolUseId && - response.failureDisposition && - readNativeHookRelayApprovalMode(normalized.rawPayload) !== "report" - ) { - projectNativeHookRelayPreToolUseFailure(registration, { - toolName: normalizeNativeHookToolName(normalized.toolName), - toolCallId: normalized.toolUseId, - disposition: response.failureDisposition, - durationMs: Date.now() - startedAt, - }); - } - return response; -} - -function projectNativeHookRelayPreToolUseFailure( - registration: ActiveNativeHookRelayRegistration, - failure: Parameters>[0], -): void { - const callback = registration.onPreToolUseFailure; - if (!callback) { - return; - } - if (registration.preToolUseFailureProjections.has(failure.toolCallId)) { - return; - } - const record = { - promise: Promise.resolve().then(() => callback(failure)), - settled: false, - }; - registration.preToolUseFailureProjections.set(failure.toolCallId, record); - void record.promise.then( - () => { - record.settled = true; - }, - (error: unknown) => { - record.settled = true; - if (registration.preToolUseFailureProjections.get(failure.toolCallId) === record) { - registration.preToolUseFailureProjections.delete(failure.toolCallId); - } - log.debug("native pre-tool failure projection failed", { - error, - relayId: registration.relayId, - toolCallId: failure.toolCallId, - }); - }, - ); - if (registration.preToolUseFailureProjections.size > MAX_NATIVE_HOOK_RELAY_INVOCATIONS) { - let oldestToolCallId: string | undefined; - for (const [toolCallId, candidate] of registration.preToolUseFailureProjections) { - oldestToolCallId ??= toolCallId; - if (candidate.settled) { - registration.preToolUseFailureProjections.delete(toolCallId); - return; - } - } - if (oldestToolCallId) { - registration.preToolUseFailureProjections.delete(oldestToolCallId); - } - } + return runtime.invokeNativeHookRelay(params); } export function hasNativeHookRelayInvocation(params: { @@ -788,16 +319,7 @@ export function hasNativeHookRelayInvocation(params: { event: NativeHookRelayEvent; toolUseId?: string; }): boolean { - const toolUseId = params.toolUseId?.trim(); - if (!toolUseId) { - return false; - } - return invocations.some( - (invocation) => - invocation.relayId === params.relayId && - invocation.event === params.event && - invocation.toolUseId === toolUseId, - ); + return runtime.hasNativeHookRelayInvocation(params); } export async function resolveNativeHookRelayDeferredToolApproval(params: { @@ -805,112 +327,13 @@ export async function resolveNativeHookRelayDeferredToolApproval(params: { toolUseId?: string; signal?: AbortSignal; }): Promise { - const pendingApprovalKey = nativeHookRelayPreToolUseApprovalKey({ - relayId: params.relayId, - toolUseId: params.toolUseId, - }); - if (!pendingApprovalKey) { - return undefined; - } - const pendingApproval = pendingPreToolUseApprovals.get(pendingApprovalKey); - if (!pendingApproval) { - return undefined; - } - pendingApproval.resolutionPromise ??= resolveNativeHookRelayPreToolUseApproval( - pendingApproval, - params.signal, - ).finally(() => { - if (pendingPreToolUseApprovals.get(pendingApprovalKey) === pendingApproval) { - pendingPreToolUseApprovals.delete(pendingApprovalKey); - } - }); - return pendingApproval.resolutionPromise; -} - -async function resolveNativeHookRelayPreToolUseApproval( - pendingApproval: NativeHookRelayPreToolUseApproval, - signal?: AbortSignal, -): Promise { - const outcome = await nativeHookRelayDeferredToolApprovalRequester({ - deferredApproval: pendingApproval.deferredApproval, - signal, - }); - if (outcome.blocked) { - return { - handled: true, - outcome: "denied", - reason: outcome.reason, - ...(outcome.kind === "failure" && outcome.disposition !== "blocked" - ? { failureDisposition: outcome.disposition } - : {}), - }; - } - if ( - nativeHookRelayParamsWereRewritten(pendingApproval.originalParamsFingerprint, outcome.params) - ) { - return { - handled: true, - outcome: "denied", - reason: - "OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.", - }; - } - return { - handled: true, - outcome: "approved-once", - }; + return permissionRuntime.resolveNativeHookRelayDeferredToolApproval(params); } export async function invokeNativeHookRelayBridge( params: InvokeNativeHookRelayBridgeParams, ): Promise { - const provider = readNativeHookRelayProvider(params.provider); - const relayId = readNonEmptyString(params.relayId, "relayId"); - const event = readNativeHookRelayEvent(params.event); - const timeoutMs = normalizePositiveInteger(params.timeoutMs, DEFAULT_RELAY_TIMEOUT_MS); - const registrationTimeoutMs = normalizePositiveInteger(params.registrationTimeoutMs, timeoutMs); - const startedAt = Date.now(); - let lastError: unknown = new Error("native hook relay bridge not found"); - while (Date.now() - startedAt < timeoutMs) { - try { - const record = readNativeHookRelayBridgeRecord(relayId, params.stateDbPath); - if (Date.now() > record.expiresAtMs) { - throw new Error("native hook relay bridge expired"); - } - return await invokeNativeHookRelayBridgeRecord({ - record, - timeoutMs: Math.max(1, timeoutMs - (Date.now() - startedAt)), - payload: { - provider, - relayId, - event, - generation: params.generation, - rawPayload: params.rawPayload, - }, - }); - } catch (error) { - lastError = error; - if ( - error instanceof Error && - error.message === "native hook relay bridge not found" && - Date.now() - startedAt >= registrationTimeoutMs - ) { - break; - } - if ( - !isRetryableNativeHookRelayBridgeLookupError({ - error, - elapsedMs: Date.now() - startedAt, - }) - ) { - break; - } - await delay( - Math.min(NATIVE_HOOK_BRIDGE_RETRY_INTERVAL_MS, timeoutMs - (Date.now() - startedAt)), - ); - } - } - throw lastError instanceof Error ? lastError : new Error(String(lastError)); + return bridgeRuntime.invokeNativeHookRelayBridge(params); } export function renderNativeHookRelayUnavailableResponse(params: { @@ -919,1520 +342,28 @@ export function renderNativeHookRelayUnavailableResponse(params: { preToolUseUnavailable?: unknown; message?: string; }): NativeHookRelayProcessResponse { - const provider = readNativeHookRelayProvider(params.provider); - const event = readNativeHookRelayEvent(params.event); - const adapter = getNativeHookRelayProviderAdapter(provider); - const message = params.message?.trim() || "Native hook relay unavailable"; - if (event === "pre_tool_use") { - // The standalone CLI cannot reconstruct the originating registration after - // relay lookup fails, so unavailable PreToolUse must fail closed unless the - // generated command explicitly recorded that no before-tool policy existed. - if (params.preToolUseUnavailable === "noop") { - return adapter.renderNoopResponse(event); - } - return adapter.renderPreToolUseBlockResponse(message); - } - if (event === "permission_request") { - return adapter.renderPermissionDecisionResponse("deny", message); - } - return adapter.renderNoopResponse(event); + return bridgeRuntime.renderNativeHookRelayUnavailableResponse(params); } export function isNativeHookRelayBridgeStaleRegistrationError(error: unknown): boolean { - return ( - error instanceof Error && error.message === NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR - ); -} - -function recordNativeHookRelayInvocation(invocation: NativeHookRelayInvocation): void { - invocations.push({ - ...invocation, - rawPayload: snapshotNativeHookRelayPayload(invocation.rawPayload), - }); - if (invocations.length > MAX_NATIVE_HOOK_RELAY_INVOCATIONS) { - invocations.splice(0, invocations.length - MAX_NATIVE_HOOK_RELAY_INVOCATIONS); - } -} - -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, -): boolean { - const expiresAtMs = registration.generationMismatchGraceExpiresAtMs; - if (typeof expiresAtMs !== "number" || Date.now() > expiresAtMs) { - return false; - } - if (registration.generationMismatchGraceAcceptedGeneration) { - return registration.generationMismatchGraceAcceptedGeneration === generation; - } - registration.generationMismatchGraceAcceptedGeneration = generation; - return true; -} - -function nativeHookRelayPreToolUseApprovalKey(params: { - relayId: string; - toolUseId?: string; -}): string | undefined { - const toolUseId = params.toolUseId?.trim(); - return toolUseId ? `${params.relayId}:${toolUseId}` : undefined; -} - -function setNativeHookRelayPreToolUseApproval(params: { - relayId: string; - toolUseId?: string; - deferredApproval: DeferredPluginToolApproval; - originalParamsFingerprint: string; -}): boolean { - const key = nativeHookRelayPreToolUseApprovalKey(params); - if (!key) { - return false; - } - const previousApproval = pendingPreToolUseApprovals.get(key); - if (previousApproval) { - cancelDeferredPluginToolApproval(previousApproval.deferredApproval); - } - pendingPreToolUseApprovals.set(key, { - deferredApproval: params.deferredApproval, - originalParamsFingerprint: params.originalParamsFingerprint, - }); - if (pendingPreToolUseApprovals.size > MAX_NATIVE_HOOK_RELAY_INVOCATIONS) { - const oldestKey = pendingPreToolUseApprovals.keys().next().value; - if (oldestKey) { - const oldestApproval = pendingPreToolUseApprovals.get(oldestKey); - if (oldestApproval) { - cancelDeferredPluginToolApproval(oldestApproval.deferredApproval); - } - pendingPreToolUseApprovals.delete(oldestKey); - } - } - return true; -} - -function removeNativeHookRelayPreToolUseApprovals(relayId: string): void { - const prefix = `${relayId}:`; - for (const [key, pendingApproval] of pendingPreToolUseApprovals) { - if (key.startsWith(prefix)) { - cancelDeferredPluginToolApproval(pendingApproval.deferredApproval); - pendingPreToolUseApprovals.delete(key); - } - } -} - -function pruneExpiredNativeHookRelays(now = Date.now()): void { - for (const [relayId, registration] of relays) { - if (now > registration.expiresAtMs) { - unregisterNativeHookRelay(relayId, registration); - } - } -} - -function isNativeHookRelayBridgePidDead(pid: number): boolean { - try { - process.kill(pid, 0); - return false; - } catch (error) { - return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH"; - } -} - -function registerNativeHookRelayBridge( - registration: ActiveNativeHookRelayRegistration, - stateDbPath: string, -): void { - // Liveness checks stay outside the write transaction. The store rereads each - // authoritative row before deletion so renewal or replacement wins the race. - try { - const pruned = pruneNativeHookRelayBridgeRecords({ - currentPid: process.pid, - isPidDead: isNativeHookRelayBridgePidDead, - stateDbPath, - }); - for (const row of pruned) { - log.debug("pruned stale native hook relay bridge record", { - relayId: row.relayId, - stalePid: row.pid, - currentPid: process.pid, - reason: row.reason, - }); - } - } catch (error) { - log.debug("native hook relay bridge record prune skipped", { error }); - } - unregisterNativeHookRelayBridge(registration.relayId); - const token = randomUUID(); - const server = createServer(); - const bridge: NativeHookRelayBridgeRegistration = { - relayId: registration.relayId, - stateDbPath, - token, - server, - }; - server.on("request", (req, res) => { - void handleNativeHookRelayBridgeRequest(req, res, { - provider: registration.provider, - relayId: registration.relayId, - token, - registration, - bridge, - }); - }); - relayBridges.set(registration.relayId, bridge); - server.on("error", (error) => { - log.debug("native hook relay bridge server error", { error, relayId: registration.relayId }); - }); - server.listen(0, "127.0.0.1", () => { - if (relayBridges.get(registration.relayId) !== bridge) { - return; - } - try { - writeNativeHookRelayBridgeRecordForRegistration(registration, bridge); - } catch (error) { - log.debug("failed to publish native hook relay bridge record", { - error, - relayId: registration.relayId, - }); - } - }); - server.unref(); -} - -function writeNativeHookRelayBridgeRecordForRegistration( - registration: ActiveNativeHookRelayRegistration, - bridge: NativeHookRelayBridgeRegistration, -): void { - const record = resolveNativeHookRelayBridgeRecord(registration, bridge); - if (!record) { - return; - } - writeNativeHookRelayBridgeRecord({ - record, - stateDbPath: bridge.stateDbPath, - }); -} - -function resolveNativeHookRelayBridgeRecord( - registration: ActiveNativeHookRelayRegistration, - bridge: NativeHookRelayBridgeRegistration, - expiresAtMs = registration.expiresAtMs, -): NativeHookRelayBridgeRecord | undefined { - const address = bridge.server.address(); - if (!address || typeof address === "string") { - log.debug("native hook relay bridge server address unavailable", { - relayId: registration.relayId, - }); - return undefined; - } - const { token } = bridge; - const record: NativeHookRelayBridgeRecord = { - relayId: registration.relayId, - pid: process.pid, - hostname: "127.0.0.1", - port: address.port, - token, - expiresAtMs, - }; - return record; -} - -function unregisterNativeHookRelayBridge( - relayId: string, - options?: { deferBridgeRecordRemovalMs?: number }, -): void { - const bridge = relayBridges.get(relayId); - if (!bridge) { - return; - } - relayBridges.delete(relayId); - bridge.server.close(); - const removeRecord = () => { - try { - deleteNativeHookRelayBridgeRecordIfOwned({ - ...bridge, - pid: process.pid, - }); - } catch (error) { - log.debug("failed to remove native hook relay bridge record", { error, relayId }); - } - }; - const deferBridgeRecordRemovalMs = normalizePositiveInteger( - options?.deferBridgeRecordRemovalMs, - 0, - ); - if (deferBridgeRecordRemovalMs > 0) { - // During stable-id replacement, retain the old locator until the successor - // upserts. The token-scoped timer cannot delete that successor. - const timeout = setTimeout(removeRecord, deferBridgeRecordRemovalMs); - timeout.unref(); - return; - } - removeRecord(); -} - -async function handleNativeHookRelayBridgeRequest( - req: IncomingMessage, - res: ServerResponse, - auth: NativeHookRelayBridgeRequestAuth, -): Promise { - try { - if (req.method !== "POST" || req.url !== "/invoke") { - writeNativeHookRelayBridgeJson(res, 404, { ok: false, error: "not found" }); - return; - } - if (req.headers.authorization !== `Bearer ${auth.token}`) { - writeNativeHookRelayBridgeJson(res, 403, { ok: false, error: "forbidden" }); - return; - } - if (!isCurrentNativeHookRelayBridgeRequest(auth)) { - writeNativeHookRelayBridgeJson(res, 410, { - ok: false, - error: NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR, - }); - return; - } - const body = await readNativeHookRelayBridgeBody(req); - const payload = readNativeHookRelayBridgePayload(JSON.parse(body)); - if (payload.provider !== auth.provider || payload.relayId !== auth.relayId) { - writeNativeHookRelayBridgeJson(res, 403, { - ok: false, - error: "native hook relay bridge target mismatch", - }); - return; - } - if (!isCurrentNativeHookRelayBridgeRequest(auth)) { - writeNativeHookRelayBridgeJson(res, 410, { - ok: false, - error: NATIVE_HOOK_RELAY_BRIDGE_STALE_REGISTRATION_ERROR, - }); - return; - } - const result = await invokeNativeHookRelay({ ...payload, requireGeneration: true }); - writeNativeHookRelayBridgeJson(res, 200, { ok: true, result }); - } catch (error) { - writeNativeHookRelayBridgeJson( - res, - isNativeHookRelayBridgeStaleRegistrationError(error) ? 410 : 500, - { - ok: false, - error: error instanceof Error ? error.message : String(error), - }, - ); - } -} - -function isCurrentNativeHookRelayBridgeRequest(auth: NativeHookRelayBridgeRequestAuth): boolean { - return ( - relays.get(auth.relayId) === auth.registration && relayBridges.get(auth.relayId) === auth.bridge - ); -} - -async function readNativeHookRelayBridgeBody(req: NodeJS.ReadableStream): Promise { - const chunks: Buffer[] = []; - let total = 0; - for await (const chunk of req) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - total += buffer.byteLength; - if (total > MAX_NATIVE_HOOK_BRIDGE_BODY_BYTES) { - throw new Error("native hook relay bridge payload too large"); - } - chunks.push(buffer); - } - return Buffer.concat(chunks, total).toString("utf8"); -} - -function readNativeHookRelayBridgePayload(value: unknown): InvokeNativeHookRelayParams { - if (!isJsonObject(value)) { - throw new Error("native hook relay bridge payload must be an object"); - } - return { - provider: value.provider, - relayId: value.relayId, - generation: readNonEmptyString(value.generation, "generation"), - event: value.event, - rawPayload: value.rawPayload, - }; -} - -function writeNativeHookRelayBridgeJson( - res: ServerResponse, - statusCode: number, - payload: unknown, -): void { - const body = JSON.stringify(payload); - res.writeHead(statusCode, { - "content-type": "application/json", - "content-length": Buffer.byteLength(body), - }); - res.end(body); -} - -function readNativeHookRelayBridgeRecord( - relayId: string, - stateDbPath?: string, -): NativeHookRelayBridgeRecord { - const record = readNativeHookRelayBridgeRecordIfExists(relayId, stateDbPath); - if (!record) { - throw new Error("native hook relay bridge not found"); - } - return record; -} - -function readNativeHookRelayBridgeRecordIfExists( - relayId: string, - stateDbPath?: string, -): NativeHookRelayBridgeRecord | undefined { - try { - return readNativeHookRelayBridgeRecordFromStore({ relayId, stateDbPath }); - } catch (error) { - log.debug("failed to read native hook relay bridge record", { error, relayId }); - } - return undefined; -} - -async function invokeNativeHookRelayBridgeRecord(params: { - record: NativeHookRelayBridgeRecord; - timeoutMs: number; - payload: InvokeNativeHookRelayParams; -}): Promise { - return postNativeHookRelayBridgeRecord(params); -} - -function postNativeHookRelayBridgeRecord(params: { - record: NativeHookRelayBridgeRecord; - timeoutMs: number; - payload: InvokeNativeHookRelayParams; -}): Promise { - const body = JSON.stringify(params.payload); - return new Promise((resolve, reject) => { - let settled = false; - const resolveOnce = (value: NativeHookRelayProcessResponse) => { - if (!settled) { - settled = true; - resolve(value); - } - }; - const rejectOnce = (error: unknown) => { - if (!settled) { - settled = true; - reject(toErrorObject(error, "Non-Error rejection")); - } - }; - const req = httpRequest( - { - hostname: params.record.hostname, - method: "POST", - path: "/invoke", - port: params.record.port, - timeout: params.timeoutMs, - headers: { - authorization: `Bearer ${params.record.token}`, - "content-type": "application/json", - "content-length": Buffer.byteLength(body), - }, - }, - (res) => { - let responseText = ""; - let responseBytes = 0; - res.setEncoding("utf8"); - res.on("data", (chunk) => { - const chunkText = typeof chunk === "string" ? chunk : String(chunk); - responseBytes += Buffer.byteLength(chunkText); - if (responseBytes > MAX_NATIVE_HOOK_BRIDGE_RESPONSE_BYTES) { - rejectOnce(new Error("native hook relay bridge response too large")); - res.destroy(); - return; - } - responseText += chunkText; - }); - res.on("error", rejectOnce); - res.on("end", () => { - if (settled) { - return; - } - try { - const parsed = JSON.parse(responseText) as - | { ok: true; result: NativeHookRelayProcessResponse } - | { ok: false; error?: string }; - if (parsed.ok) { - resolveOnce(parsed.result); - return; - } - rejectOnce(new Error(parsed.error || "native hook relay bridge failed")); - } catch (error) { - rejectOnce(error); - } - }); - }, - ); - req.on("timeout", () => { - req.destroy(new Error("native hook relay bridge timed out")); - }); - req.on("error", rejectOnce); - req.end(body); - }); -} - -function isRetryableNativeHookRelayBridgeError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException).code; - return ( - code === "ENOENT" || - code === "ECONNREFUSED" || - code === "EAGAIN" || - (error instanceof Error && error.message === "native hook relay bridge not found") - ); -} - -function isRetryableNativeHookRelayBridgeLookupError(params: { - error: unknown; - elapsedMs: number; -}): boolean { - return ( - isRetryableNativeHookRelayBridgeError(params.error) || - (params.elapsedMs < NATIVE_HOOK_BRIDGE_REPLACEMENT_RECORD_GRACE_MS && - isNativeHookRelayBridgeStaleRegistrationError(params.error)) - ); -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, Math.max(0, ms)); - }); -} - -async function processNativeHookRelayInvocation(params: { - registration: NativeHookRelayRegistration; - invocation: NativeHookRelayInvocation; - adapter: NativeHookRelayProviderAdapter; -}): Promise { - if (params.invocation.event === "pre_tool_use") { - return runNativeHookRelayPreToolUse(params); - } - if (params.invocation.event === "post_tool_use") { - return runNativeHookRelayPostToolUse(params); - } - if (params.invocation.event === "before_agent_finalize") { - return runNativeHookRelayBeforeAgentFinalize(params); - } - return runNativeHookRelayPermissionRequest(params); -} - -async function runNativeHookRelayPreToolUse(params: { - registration: NativeHookRelayRegistration; - invocation: NativeHookRelayInvocation; - adapter: NativeHookRelayProviderAdapter; -}): Promise { - const toolName = normalizeNativeHookToolName(params.invocation.toolName); - const toolInput = params.adapter.readToolInput(params.invocation.rawPayload); - const originalToolInputFingerprint = stableStringify(toolInput); - const approvalMode = readNativeHookRelayApprovalMode(params.invocation.rawPayload); - const outcome = await runBeforeToolCallHook({ - toolName, - params: toolInput, - ...(params.invocation.toolUseId ? { toolCallId: params.invocation.toolUseId } : {}), - ...(approvalMode === "report" ? { approvalMode: "defer" } : {}), - signal: params.registration.signal, - ctx: { - ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), - sessionId: params.registration.sessionId, - ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), - ...(params.registration.config ? { config: params.registration.config } : {}), - runId: params.registration.runId, - ...(params.registration.channelId ? { channelId: params.registration.channelId } : {}), - ...(params.registration.requester ? { requester: params.registration.requester } : {}), - ...(params.invocation.cwd - ? { cwd: params.invocation.cwd, workspaceDir: params.invocation.cwd } - : {}), - }, - }); - if (outcome.blocked) { - return params.adapter.renderPreToolUseBlockResponse( - outcome.reason, - outcome.kind === "failure" && outcome.disposition !== "blocked" - ? outcome.disposition - : undefined, - ); - } - if (outcome.deferredApproval) { - if ( - !setNativeHookRelayPreToolUseApproval({ - relayId: params.registration.relayId, - toolUseId: params.invocation.toolUseId, - deferredApproval: outcome.deferredApproval, - originalParamsFingerprint: originalToolInputFingerprint, - }) - ) { - cancelDeferredPluginToolApproval(outcome.deferredApproval); - return params.adapter.renderPreToolUseBlockResponse( - "Plugin approval required but Codex tool id unavailable.", - ); - } - return params.adapter.renderNoopResponse(params.invocation.event); - } - if (nativeHookRelayParamsWereRewritten(originalToolInputFingerprint, outcome.params)) { - // Codex app-server may continue with the original params when updatedInput - // is unsupported, so rewrites must fail closed here. - return params.adapter.renderPreToolUseBlockResponse( - "OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.", - ); - } - return params.adapter.renderNoopResponse(params.invocation.event); -} - -async function runNativeHookRelayPostToolUse(params: { - registration: NativeHookRelayRegistration; - invocation: NativeHookRelayInvocation; - adapter: NativeHookRelayProviderAdapter; -}): Promise { - const toolName = normalizeNativeHookToolName(params.invocation.toolName); - const toolCallId = - params.invocation.toolUseId ?? `${params.invocation.event}:${params.invocation.receivedAt}`; - const startArgs = params.adapter.readToolInput(params.invocation.rawPayload); - const rawResult = params.adapter.readToolResponse(params.invocation.rawPayload); - // Native results are observe-only for middleware: codex-rs PostToolUse hooks - // cannot replace tool_response (PostToolUseOutcome has no result field), so a - // transformed result reaches only after_tool_call observers, never the model. - const hasToolResultMiddleware = listAgentToolResultMiddlewares("codex").length > 0; - const result = !hasToolResultMiddleware - ? rawResult - : await createAgentToolResultMiddlewareRunner({ - runtime: "codex", - ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), - sessionId: params.registration.sessionId, - ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), - runId: params.registration.runId, - }).applyToolResultMiddleware({ - turnId: params.invocation.turnId, - toolCallId, - toolName, - args: startArgs, - ...(params.invocation.cwd ? { cwd: params.invocation.cwd } : {}), - result: payloadTextResult(rawResult), - }); - await runAgentHarnessAfterToolCallHook({ - toolName, - toolCallId, - runId: params.registration.runId, - ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), - sessionId: params.registration.sessionId, - ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), - ...(params.registration.channelId ? { channelId: params.registration.channelId } : {}), - startArgs, - result, - }); - return params.adapter.renderNoopResponse(params.invocation.event); -} - -async function runNativeHookRelayPermissionRequest(params: { - registration: NativeHookRelayRegistration; - invocation: NativeHookRelayInvocation; - adapter: NativeHookRelayProviderAdapter; -}): Promise { - const request: NativeHookRelayPermissionApprovalRequest = { - provider: params.registration.provider, - ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), - sessionId: params.registration.sessionId, - ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), - runId: params.registration.runId, - toolName: normalizeNativeHookToolName(params.invocation.toolName), - ...(params.invocation.toolUseId ? { toolCallId: params.invocation.toolUseId } : {}), - ...(params.invocation.cwd ? { cwd: params.invocation.cwd } : {}), - ...(params.invocation.model ? { model: params.invocation.model } : {}), - toolInput: params.adapter.readToolInput(params.invocation.rawPayload), - ...(params.registration.signal ? { signal: params.registration.signal } : {}), - }; - const approvalKey = nativeHookRelayPermissionApprovalKey({ - registration: params.registration, - request, - }); - const allowAlwaysKey = nativeHookRelayPermissionAllowAlwaysKey({ - registration: params.registration, - request, - }); - if (hasNativeHookRelayPermissionAllowAlways(allowAlwaysKey)) { - return params.adapter.renderPermissionDecisionResponse("allow"); - } - const pendingApproval = pendingPermissionApprovals.get(approvalKey); - try { - const decision = await (pendingApproval ?? - startNativeHookRelayPermissionApprovalWithBudget({ - registration: params.registration, - approvalKey, - request, - })); - if (decision === "allow") { - return params.adapter.renderPermissionDecisionResponse("allow"); - } - if (decision === "allow-always") { - rememberNativeHookRelayPermissionAllowAlways(allowAlwaysKey); - return params.adapter.renderPermissionDecisionResponse("allow"); - } - if (decision === "deny") { - return params.adapter.renderPermissionDecisionResponse("deny", "Denied by user"); - } - } catch (error) { - log.warn( - `native hook permission approval failed; deferring to provider approval path: ${String(error)}`, - ); - } - // A PermissionRequest no-op is not an allow decision. Codex interprets it as - // "no hook decision" and falls through to its normal guardian/user approval path. - return params.adapter.renderNoopResponse(params.invocation.event); -} - -async function runNativeHookRelayBeforeAgentFinalize(params: { - registration: NativeHookRelayRegistration; - invocation: NativeHookRelayInvocation; - adapter: NativeHookRelayProviderAdapter; -}): Promise { - const outcome = await runAgentHarnessBeforeAgentFinalizeHook({ - event: { - runId: params.registration.runId, - sessionId: params.registration.sessionId, - ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), - ...(params.invocation.turnId ? { turnId: params.invocation.turnId } : {}), - provider: params.registration.provider, - ...(params.invocation.model ? { model: params.invocation.model } : {}), - ...(params.invocation.cwd ? { cwd: params.invocation.cwd } : {}), - ...(params.invocation.transcriptPath - ? { transcriptPath: params.invocation.transcriptPath } - : {}), - stopHookActive: params.invocation.stopHookActive === true, - ...(params.invocation.lastAssistantMessage - ? { lastAssistantMessage: params.invocation.lastAssistantMessage } - : {}), - }, - ctx: { - ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), - sessionId: params.registration.sessionId, - ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), - runId: params.registration.runId, - ...(params.registration.channelId ? { channelId: params.registration.channelId } : {}), - ...(params.invocation.cwd ? { workspaceDir: params.invocation.cwd } : {}), - ...(params.invocation.model ? { modelId: params.invocation.model } : {}), - }, - }); - if (outcome.action === "revise") { - return params.adapter.renderBeforeAgentFinalizeReviseResponse(outcome.reason); - } - if (outcome.action === "finalize") { - return params.adapter.renderBeforeAgentFinalizeStopResponse(outcome.reason); - } - return params.adapter.renderNoopResponse(params.invocation.event); -} - -async function startNativeHookRelayPermissionApprovalWithBudget(params: { - registration: NativeHookRelayRegistration; - approvalKey: string; - request: NativeHookRelayPermissionApprovalRequest; -}): Promise { - if (!consumeNativeHookRelayPermissionBudget(params.registration.relayId)) { - log.warn( - `native hook permission approval rate limit exceeded; deferring to provider approval path: relay=${params.registration.relayId} run=${params.registration.runId}`, - ); - return "defer"; - } - const approval: Promise = - nativeHookRelayPermissionApprovalRequester(params.request).finally(() => { - if (pendingPermissionApprovals.get(params.approvalKey) === approval) { - pendingPermissionApprovals.delete(params.approvalKey); - } - }); - pendingPermissionApprovals.set(params.approvalKey, approval); - return approval; -} - -function nativeHookRelayPermissionApprovalKey(params: { - registration: NativeHookRelayRegistration; - request: NativeHookRelayPermissionApprovalRequest; -}): string { - return [ - params.registration.relayId, - params.registration.runId, - params.request.toolCallId - ? `call:${params.request.toolCallId}` - : permissionRequestFallbackKey(params.request), - permissionRequestContentFingerprint(params.request), - ].join(":"); -} - -function nativeHookRelayPermissionAllowAlwaysKey(params: { - registration: NativeHookRelayRegistration; - request: NativeHookRelayPermissionApprovalRequest; -}): string { - const hash = createHash("sha256"); - hash.update("openclaw:native-hook-relay:permission-allow-always:v2"); - hash.update("\0"); - hash.update(params.registration.relayId); - hash.update("\0"); - hash.update(params.request.provider); - hash.update("\0"); - hash.update(params.request.agentId ?? ""); - hash.update("\0"); - hash.update(params.request.sessionKey ?? params.request.sessionId); - hash.update("\0"); - hash.update(permissionRequestContentFingerprint(params.request)); - return hash.digest("hex"); -} - -function permissionRequestFallbackKey(request: NativeHookRelayPermissionApprovalRequest): string { - const command = readOptionalString(request.toolInput.command); - if (command) { - return `${request.toolName}:command:${truncateText(command, 240)}`; - } - return `${request.toolName}:keys:${permissionRequestToolInputKeyFingerprint(request.toolInput)}`; -} - -function permissionRequestToolInputKeyFingerprint(toolInput: Record): string { - let fingerprint = ""; - const { keys, truncated } = readBoundedOwnKeys(toolInput, MAX_PERMISSION_FALLBACK_KEYS); - for (const key of keys) { - const separator = fingerprint ? "," : ""; - const remaining = MAX_PERMISSION_FALLBACK_KEY_CHARS - fingerprint.length - separator.length; - if (remaining <= 0) { - break; - } - fingerprint += `${separator}${key.slice(0, remaining)}`; - } - if (truncated && fingerprint.length < MAX_PERMISSION_FALLBACK_KEY_CHARS) { - const marker = `${fingerprint ? "," : ""}...`; - fingerprint += marker.slice(0, MAX_PERMISSION_FALLBACK_KEY_CHARS - fingerprint.length); - } - return fingerprint || "none"; -} - -function permissionRequestContentFingerprint( - request: NativeHookRelayPermissionApprovalRequest, -): string { - const hash = createHash("sha256"); - hash.update(request.toolName); - hash.update("\0"); - hash.update(request.cwd ?? ""); - hash.update("\0"); - updateJsonHash(hash, request.toolInput); - return hash.digest("hex"); -} - -function updateJsonHash(hash: ReturnType, value: JsonValue): void { - if (value === null) { - hash.update("null"); - return; - } - if (typeof value === "string") { - hash.update("string:"); - hash.update(JSON.stringify(value)); - return; - } - if (typeof value === "number") { - hash.update(`number:${String(value)}`); - return; - } - if (typeof value === "boolean") { - hash.update(`boolean:${String(value)}`); - return; - } - if (Array.isArray(value)) { - hash.update("["); - for (const item of value) { - updateJsonHash(hash, item); - hash.update(","); - } - hash.update("]"); - return; - } - hash.update("{"); - const { keys, truncated } = readBoundedOwnKeys(value, MAX_PERMISSION_FINGERPRINT_SORT_KEYS); - for (const key of keys) { - hash.update(JSON.stringify(key)); - hash.update(":"); - const item = value[key]; - if (item !== undefined) { - updateJsonHash(hash, item); - } - hash.update(","); - } - if (truncated) { - // Keep ordinary objects order-independent without sorting a broad native - // hook payload. The tail remains content-sensitive in traversal order. - const sortedKeySet = new Set(keys); - hash.update("#object-tail:"); - for (const key in value) { - if (!Object.hasOwn(value, key) || sortedKeySet.has(key)) { - continue; - } - hash.update(JSON.stringify(key)); - hash.update(":"); - const item = value[key]; - if (item !== undefined) { - updateJsonHash(hash, item); - } - hash.update(","); - } - } - hash.update("}"); -} - -function readBoundedOwnKeys( - value: Record, - maxKeys: number, -): { keys: string[]; truncated: boolean } { - const keys: string[] = []; - let truncated = false; - for (const key in value) { - if (!Object.hasOwn(value, key)) { - continue; - } - if (keys.length >= maxKeys) { - truncated = true; - break; - } - keys.push(key); - } - keys.sort(); - return { keys, truncated }; -} - -function consumeNativeHookRelayPermissionBudget(relayId: string, now = Date.now()): boolean { - const windowStart = now - PERMISSION_APPROVAL_WINDOW_MS; - const timestamps = (permissionApprovalWindows.get(relayId) ?? []).filter( - (timestamp) => timestamp >= windowStart, - ); - if (timestamps.length >= MAX_PERMISSION_APPROVALS_PER_WINDOW) { - permissionApprovalWindows.set(relayId, timestamps); - return false; - } - timestamps.push(now); - permissionApprovalWindows.set(relayId, timestamps); - return true; -} - -function hasNativeHookRelayPermissionAllowAlways(key: string, now = Date.now()): boolean { - const validNow = asDateTimestampMs(now); - if (validNow === undefined) { - return false; - } - const entry = permissionAllowAlwaysApprovals.get(key); - if (!entry) { - return false; - } - const expiresAtMs = asDateTimestampMs(entry.expiresAtMs); - if (expiresAtMs === undefined || expiresAtMs <= validNow) { - permissionAllowAlwaysApprovals.delete(key); - return false; - } - return true; -} - -function rememberNativeHookRelayPermissionAllowAlways(key: string, now = Date.now()): void { - pruneNativeHookRelayPermissionAllowAlways(now); - const expiresAtMs = resolveExpiresAtMsFromDurationMs(PERMISSION_ALLOW_ALWAYS_TTL_MS, { - nowMs: now, - }); - if (expiresAtMs === undefined) { - return; - } - permissionAllowAlwaysApprovals.set(key, { - expiresAtMs, - }); - while (permissionAllowAlwaysApprovals.size > MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES) { - const oldestKey = permissionAllowAlwaysApprovals.keys().next().value; - if (typeof oldestKey !== "string") { - break; - } - permissionAllowAlwaysApprovals.delete(oldestKey); - } -} - -function pruneNativeHookRelayPermissionAllowAlways(now = Date.now()): void { - const validNow = asDateTimestampMs(now); - if (validNow === undefined) { - return; - } - for (const [key, entry] of permissionAllowAlwaysApprovals) { - const expiresAtMs = asDateTimestampMs(entry.expiresAtMs); - if (expiresAtMs === undefined || expiresAtMs <= validNow) { - permissionAllowAlwaysApprovals.delete(key); - } - } -} - -function removeNativeHookRelayPermissionState(relayId: string): void { - permissionApprovalWindows.delete(relayId); - for (const key of pendingPermissionApprovals.keys()) { - if (key.startsWith(`${relayId}:`)) { - pendingPermissionApprovals.delete(key); - } - } -} - -function snapshotNativeHookRelayPayload(payload: JsonValue): JsonValue { - return snapshotJsonValue(payload, { - remainingStringLength: MAX_NATIVE_HOOK_RELAY_HISTORY_TOTAL_STRING_LENGTH, - }); -} - -function snapshotJsonValue(value: JsonValue, state: { remainingStringLength: number }): JsonValue { - if (value === null || typeof value === "number" || typeof value === "boolean") { - return value; - } - if (typeof value === "string") { - return snapshotString(value, state); - } - if (Array.isArray(value)) { - const items = value - .slice(0, MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS) - .map((item) => snapshotJsonValue(item, state)); - if (value.length > MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS) { - items.push("[truncated]"); - } - return items; - } - const snapshot: Record = {}; - const keys = Object.keys(value); - for (const key of keys.slice(0, MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS)) { - const item = value[key]; - if (item !== undefined) { - snapshot[snapshotString(key, state)] = snapshotJsonValue(item, state); - } - } - if (keys.length > MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS) { - snapshot["[truncated]"] = keys.length - MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS; - } - return snapshot; -} - -function snapshotString(value: string, state: { remainingStringLength: number }): string { - if (state.remainingStringLength <= 0) { - return "[truncated]"; - } - const limit = Math.min( - value.length, - MAX_NATIVE_HOOK_RELAY_HISTORY_STRING_LENGTH, - state.remainingStringLength, - ); - if (limit >= value.length) { - state.remainingStringLength -= limit; - return value; - } - const prefix = truncateUtf16Safe(value, limit); - // Charge the retained prefix; a safe boundary may back up one code unit. - state.remainingStringLength -= prefix.length; - return `${prefix}...[truncated]`; -} - -function normalizeNativeHookInvocation(params: { - registration: NativeHookRelayRegistration; - event: NativeHookRelayEvent; - rawPayload: JsonValue; -}): NativeHookRelayInvocation { - const metadata = getNativeHookRelayProviderAdapter( - params.registration.provider, - ).normalizeMetadata(params.rawPayload); - return { - provider: params.registration.provider, - relayId: params.registration.relayId, - event: params.event, - ...metadata, - ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), - sessionId: params.registration.sessionId, - ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), - runId: params.registration.runId, - rawPayload: params.rawPayload, - receivedAt: new Date().toISOString(), - }; -} - -function getNativeHookRelayProviderAdapter( - provider: NativeHookRelayProvider, -): NativeHookRelayProviderAdapter { - return nativeHookRelayProviderAdapters[provider]; -} - -function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvocationMetadata { - const payload = isJsonObject(rawPayload) ? rawPayload : {}; - const metadata: NativeHookRelayInvocationMetadata = {}; - const nativeEventName = readOptionalString(payload.hook_event_name); - if (nativeEventName) { - metadata.nativeEventName = nativeEventName; - } - const cwd = readOptionalString(payload.cwd); - if (cwd) { - metadata.cwd = cwd; - } - const model = readOptionalString(payload.model); - if (model) { - metadata.model = model; - } - const turnId = readOptionalString(payload.turn_id); - if (turnId) { - metadata.turnId = turnId; - } - const transcriptPath = readOptionalString(payload.transcript_path); - if (transcriptPath) { - metadata.transcriptPath = transcriptPath; - } - const permissionMode = readOptionalString(payload.permission_mode); - if (permissionMode) { - metadata.permissionMode = permissionMode; - } - const stopHookActive = readOptionalBoolean(payload.stop_hook_active); - if (stopHookActive !== undefined) { - metadata.stopHookActive = stopHookActive; - } - const lastAssistantMessage = readOptionalString(payload.last_assistant_message); - if (lastAssistantMessage) { - metadata.lastAssistantMessage = lastAssistantMessage; - } - const toolName = readOptionalString(payload.tool_name); - if (toolName) { - metadata.toolName = toolName; - } - const toolUseId = readOptionalString(payload.tool_use_id); - if (toolUseId) { - metadata.toolUseId = toolUseId; - } - return metadata; -} - -function readCodexToolInput(rawPayload: JsonValue): Record { - const payload = isJsonObject(rawPayload) ? rawPayload : {}; - const toolInput = payload.tool_input; - if (isJsonObject(toolInput)) { - const toolName = readOptionalString(payload.tool_name); - return normalizeCodexToolInput( - normalizeNativeHookToolName(toolName), - toolInput as Record, - ); - } - if (toolInput === undefined) { - return {}; - } - return { value: toolInput as JsonValue }; -} - -function normalizeCodexToolInput( - toolName: string, - toolInput: Record, -): Record { - const command = normalizeCodexCommand(toolInput.cmd); - if (toolName !== "exec" || command === undefined) { - return toolInput; - } - return { - ...toolInput, - command, - }; -} - -function normalizeCodexCommand(value: JsonValue | undefined): string | undefined { - if (typeof value === "string") { - return value; - } - if (Array.isArray(value) && value.every((part): part is string => typeof part === "string")) { - return shellQuoteArgs(value); - } - return undefined; -} - -function nativeHookRelayParamsWereRewritten( - originalFingerprint: string, - candidate: unknown, -): boolean { - if (candidate === undefined) { - return false; - } - return stableStringify(candidate) !== originalFingerprint; -} - -function readCodexToolResponse(rawPayload: JsonValue): unknown { - const payload = isJsonObject(rawPayload) ? rawPayload : {}; - return payload.tool_response; -} - -function readNativeHookRelayApprovalMode(rawPayload: JsonValue): "report" | undefined { - const payload = isJsonObject(rawPayload) ? rawPayload : {}; - return payload.openclaw_approval_mode === "report" ? "report" : undefined; -} - -function normalizeNativeHookToolName(toolName: string | undefined): string { - const normalized = normalizeToolName(toolName ?? "tool"); - return NATIVE_HOOK_TOOL_NAME_ALIASES[normalized] ?? normalized; -} - -async function requestNativeHookRelayPermissionApproval( - request: NativeHookRelayPermissionApprovalRequest, -): Promise { - const timeoutMs = DEFAULT_PERMISSION_TIMEOUT_MS; - const requestResult: { - id?: string; - decision?: string | null; - } = await callGatewayTool( - "plugin.approval.request", - { timeoutMs: timeoutMs + 10_000 }, - { - pluginId: `openclaw-native-hook-relay-${request.provider}`, - title: truncateText( - `${nativeHookRelayProviderDisplayName(request.provider)} permission request`, - MAX_APPROVAL_TITLE_LENGTH, - ), - description: truncateText( - 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. - decision = waitResult?.id === approvalId ? waitResult.decision : undefined; - } - if (decision === PluginApprovalResolutions.ALLOW_ONCE) { - return "allow"; - } - if (decision === PluginApprovalResolutions.ALLOW_ALWAYS) { - return "allow-always"; - } - if (decision === PluginApprovalResolutions.DENY) { - return "deny"; - } - return "defer"; -} - -async function waitForNativeHookRelayApprovalDecision(params: { - approvalId: string; - signal?: AbortSignal; - timeoutMs: number; -}): Promise<{ id?: string; decision?: string | null } | undefined> { - const waitPromise: Promise<{ id?: string; decision?: string | null } | undefined> = - callGatewayTool( - "plugin.approval.waitDecision", - { timeoutMs: params.timeoutMs + 10_000 }, - { id: params.approvalId }, - ).catch((error: unknown) => { - if (isApprovalNotFoundError(error)) { - return undefined; - } - throw error; - }); - if (!params.signal) { - return waitPromise; - } - let onAbort: (() => void) | undefined; - const abortPromise = new Promise((_, reject) => { - if (params.signal!.aborted) { - reject(toErrorObject(params.signal!.reason, "Non-Error rejection")); - return; - } - onAbort = () => reject(toErrorObject(params.signal!.reason, "Non-Error rejection")); - params.signal!.addEventListener("abort", onAbort, { once: true }); - }); - try { - return await Promise.race([waitPromise, abortPromise]); - } finally { - if (onAbort) { - params.signal.removeEventListener("abort", onAbort); - } - } -} - -function formatPermissionApprovalDescription( - request: NativeHookRelayPermissionApprovalRequest, -): string { - const lines = [ - `Tool: ${sanitizeApprovalText(request.toolName)}`, - request.cwd ? `Cwd: ${sanitizeApprovalText(request.cwd)}` : undefined, - request.model ? `Model: ${sanitizeApprovalText(request.model)}` : undefined, - formatToolInputPreview(request.toolInput), - ].filter((line): line is string => Boolean(line)); - return lines.join("\n"); -} - -function formatToolInputPreview(toolInput: Record): string | undefined { - const command = readOptionalString(toolInput.command); - if (command) { - return `Command: ${truncateText(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 { - if (provider === "codex") { - return "Codex"; - } - return provider; -} - -function truncateText(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - return `${truncateUtf16Safe(value, Math.max(0, maxLength - 3))}...`; -} - -function resolveOpenClawCliExecutable(): string { - const envPath = process.env.OPENCLAW_CLI_PATH?.trim(); - if (envPath && existsSync(envPath)) { - return envPath; - } - const packageRoot = resolveOpenClawPackageRootSync({ - moduleUrl: import.meta.url, - argv1: process.argv[1], - cwd: process.cwd(), - }); - if (packageRoot) { - for (const candidate of [ - path.join(packageRoot, "openclaw.mjs"), - path.join(packageRoot, "dist", "entry.js"), - path.join(packageRoot, "scripts", "run-node.mjs"), - ]) { - if (existsSync(candidate)) { - return candidate; - } - } - } - const argvEntry = process.argv[1]; - if (argvEntry) { - const resolved = path.resolve(argvEntry); - if (existsSync(resolved)) { - return resolved; - } - } - throw new Error("Cannot resolve OpenClaw CLI executable path for native hook relay"); -} - -function normalizeAllowedEvents( - events: readonly NativeHookRelayEvent[] | undefined, -): readonly NativeHookRelayEvent[] { - if (!events?.length) { - return NATIVE_HOOK_RELAY_EVENTS; - } - return [...new Set(events)]; -} - -function normalizePositiveInteger(value: number | undefined, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.floor(value) - : fallback; -} - -function normalizeOptionalPositiveInteger(value: number | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.floor(value) - : undefined; -} - -function shellQuoteArgs(args: readonly string[]): string { - return args.map((arg) => shellQuoteArg(arg, process.platform)).join(" "); -} - -function shellQuoteArg(value: string, platform: NodeJS.Platform): string { - if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) { - return value; - } - if (platform === "win32") { - return `"${value.replaceAll('"', '\\"')}"`; - } - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function readNativeHookRelayProvider(value: unknown): NativeHookRelayProvider { - if (value === "codex") { - return value; - } - throw new Error("unsupported native hook relay provider"); -} - -function readNativeHookRelayEvent(value: unknown): NativeHookRelayEvent { - if ( - value === "pre_tool_use" || - value === "post_tool_use" || - value === "permission_request" || - value === "before_agent_finalize" - ) { - return value; - } - throw new Error("unsupported native hook relay event"); -} - -function readNonEmptyString(value: unknown, name: string): string { - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - throw new Error(`native hook relay ${name} is required`); -} - -function readOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function readOptionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function isJsonValue(value: unknown): value is JsonValue { - const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; - let nodes = 0; - let totalStringLength = 0; - while (stack.length) { - const current = stack.pop()!; - nodes += 1; - if (nodes > MAX_NATIVE_HOOK_RELAY_JSON_NODES) { - return false; - } - if (current.depth > MAX_NATIVE_HOOK_RELAY_JSON_DEPTH) { - return false; - } - if (current.value === null) { - continue; - } - if (typeof current.value === "string") { - if (current.value.length > MAX_NATIVE_HOOK_RELAY_STRING_LENGTH) { - return false; - } - totalStringLength += current.value.length; - if (totalStringLength > MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH) { - return false; - } - continue; - } - if (typeof current.value === "number") { - if (!Number.isFinite(current.value)) { - return false; - } - continue; - } - if (typeof current.value === "boolean") { - continue; - } - if (Array.isArray(current.value)) { - for (const valueLocal of current.value) { - if (nodes + stack.length + 1 > MAX_NATIVE_HOOK_RELAY_JSON_NODES) { - return false; - } - stack.push({ value: valueLocal, depth: current.depth + 1 }); - } - continue; - } - if (!isJsonObject(current.value)) { - return false; - } - try { - for (const key in current.value) { - if (!Object.hasOwn(current.value, key)) { - continue; - } - if (key.length > MAX_NATIVE_HOOK_RELAY_STRING_LENGTH) { - return false; - } - totalStringLength += key.length; - if (totalStringLength > MAX_NATIVE_HOOK_RELAY_TOTAL_STRING_LENGTH) { - return false; - } - if (nodes + stack.length + 1 > MAX_NATIVE_HOOK_RELAY_JSON_NODES) { - return false; - } - stack.push({ value: current.value[key], depth: current.depth + 1 }); - } - } catch { - return false; - } - } - return true; -} - -function isJsonObject(value: unknown): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - try { - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; - } catch { - return false; - } + return bridgeRuntime.isNativeHookRelayBridgeStaleRegistrationError(error); } export const testing = { clearNativeHookRelaysForTests(): void { for (const relayId of relayBridges.keys()) { - unregisterNativeHookRelayBridge(relayId); + bridgeRuntime.unregisterNativeHookRelayBridge(relayId); } relays.clear(); invocations.length = 0; - pendingPermissionApprovals.clear(); - for (const pendingApproval of pendingPreToolUseApprovals.values()) { - cancelDeferredPluginToolApproval(pendingApproval.deferredApproval); - } - pendingPreToolUseApprovals.clear(); - permissionApprovalWindows.clear(); - permissionAllowAlwaysApprovals.clear(); + permissionRuntime.resetForTests(); clearNativeHookRelayBridgeRecordsForTests(); - nativeHookRelayPermissionApprovalRequester = requestNativeHookRelayPermissionApproval; - nativeHookRelayDeferredToolApprovalRequester = requestDeferredPluginToolApproval; }, getNativeHookRelayInvocationsForTests(): NativeHookRelayInvocation[] { - return [...invocations]; + return runtime.getNativeHookRelayInvocationsForTests(); }, getNativeHookRelayRegistrationForTests(relayId: string): NativeHookRelayRegistration | undefined { - return relays.get(relayId); + return runtime.getNativeHookRelayRegistrationForTests(relayId); }, getNativeHookRelayBridgeDirForTests(): string { throw new Error("native hook relay bridge files were retired"); @@ -2442,34 +373,33 @@ export const testing = { throw new Error("native hook relay bridge files were retired"); }, getNativeHookRelayBridgeRecordForTests(relayId: string): Record | undefined { - const record = readNativeHookRelayBridgeRecordIfExists(relayId); + const record = bridgeRuntime.readNativeHookRelayBridgeRecordIfExists(relayId); return record ? { ...record } : undefined; }, isNativeHookRelayBridgeLookupRetryableForTests(error: unknown, elapsedMs = 0): boolean { - return isRetryableNativeHookRelayBridgeLookupError({ error, elapsedMs }); + return bridgeRuntime.isRetryableNativeHookRelayBridgeLookupError({ error, elapsedMs }); }, formatPermissionApprovalDescriptionForTests( request: NativeHookRelayPermissionApprovalRequest, ): string { - return formatPermissionApprovalDescription(request); + return permissionRuntime.formatPermissionApprovalDescription(request); }, permissionRequestContentFingerprintForTests( request: NativeHookRelayPermissionApprovalRequest, ): string { - return permissionRequestContentFingerprint(request); + return permissionRuntime.permissionRequestContentFingerprint(request); }, permissionRequestToolInputKeyFingerprintForTests(toolInput: Record): string { - return permissionRequestToolInputKeyFingerprint(toolInput); + return permissionRuntime.permissionRequestToolInputKeyFingerprint(toolInput); }, setNativeHookRelayPermissionApprovalRequesterForTests( requester: NativeHookRelayPermissionApprovalRequester, ): void { - nativeHookRelayPermissionApprovalRequester = requester; + permissionRuntime.setPermissionApprovalRequesterForTests(requester); }, setNativeHookRelayDeferredToolApprovalRequesterForTests( requester: NativeHookRelayDeferredToolApprovalRequester, ): void { - nativeHookRelayDeferredToolApprovalRequester = requester; + permissionRuntime.setDeferredToolApprovalRequesterForTests(requester); }, } as const; -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */