diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cdb6efffa..63f23a68961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Agents/subagents: add optional forked context for native `sessions_spawn` runs so agents can let a child inherit the requester transcript when needed, while keeping clean isolated sessions as the default; includes prompt guidance, context-engine hook metadata, docs, and QA coverage. - Codex harness: add structured debug logging for embedded harness selection decisions so `/status` stays simple while gateway logs explain auto-selection and Pi fallback reasons. (#70760) Thanks @100yenadmin. - Plugin SDK/Codex harness: add provider-owned transport/auth/follow-up seams and harness result classification so Codex-style runtimes can participate in fallback policy without core special-casing. (#70772) Thanks @100yenadmin. +- Codex harness: bridge Codex-native tool hooks into OpenClaw plugin hooks and approvals, with bounded relay payloads and approval spam protection. (#71008) Thanks @pashpashpash. - Dependencies/Pi: update bundled Pi packages to `0.70.2`, use Pi's upstream `gpt-5.5` and DeepSeek V4 catalog metadata, and keep only local `gpt-5.5-pro` forward-compat handling. - Models/CLI: speed up `openclaw models list --all --provider ` for bundled providers with safe static catalogs while keeping live and third-party providers on registry discovery. (#70632) Thanks @shakkernerd. - Models/CLI: avoid broad registry enumeration for default `openclaw models list`, reducing default listing latency while preserving configured-row output. (#70883) Thanks @shakkernerd. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 2864a9a3fbe..0b9962c95ec 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -b125289f628c19afb6087dcd58b674fa8acc8899545f99db81c264c4c964d17f plugin-sdk-api-baseline.json -2a2e9959cd35a375ec97682ec5d5108d94d4e77a82085929c58e9a994313d5e6 plugin-sdk-api-baseline.jsonl +3e0d36fbe1db58f01c297a35c9a26d1037471720a8e71dc7149d108bf0f9bf40 plugin-sdk-api-baseline.json +aa4065f3efaf8ed6f7641ad7384039123e5bbb21a3e682f7599ca75195ceb8cd plugin-sdk-api-baseline.jsonl diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index e98642365dc..2d5484736b5 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -58,7 +58,15 @@ Natural-language triggers that should route to the native Codex plugin: - "Attach this chat to Codex thread ``." - "Show Codex threads, then bind this one." -Native Codex conversation binding is the default chat-control path, but it is intentionally conservative for interactive Codex approval/tool flows: OpenClaw dynamic tools and approval prompts are not exposed through this bound-chat path yet, so those requests are declined with a clear explanation. Use the Codex harness path or explicit ACP fallback when the workflow depends on OpenClaw dynamic tools or long-running interactive approvals. +Native Codex conversation binding is the default chat-control path. OpenClaw +dynamic tools still execute through OpenClaw, while Codex-native tools such as +shell/apply-patch execute inside Codex. For Codex-native tool events, OpenClaw +injects a per-turn native hook relay so plugin hooks can block +`before_tool_call`, observe `after_tool_call`, and route Codex +`PermissionRequest` events through OpenClaw approvals. The v1 relay is +deliberately conservative: it does not mutate Codex-native tool arguments, +rewrite Codex thread records, or gate final answers/Stop hooks. Use explicit +ACP only when you want the ACP runtime/session model. Natural-language triggers that should route to the ACP runtime: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 74780758f3a..a3dd676bce4 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -394,6 +394,12 @@ Hook guard behavior for typed lifecycle hooks: - `message_sending`: `{ cancel: true }` is terminal; lower-priority handlers are skipped. - `message_sending`: `{ cancel: false }` is a no-op and does not clear an earlier cancel. +Native Codex app-server runs bridge Codex-native tool events back into this +hook surface. Plugins can block native Codex tools through `before_tool_call`, +observe results through `after_tool_call`, and participate in Codex +`PermissionRequest` approvals. The bridge does not rewrite Codex-native tool +arguments yet. + For full typed hook behavior, see [SDK Overview](/plugins/sdk-overview#hook-decision-semantics). ## Related diff --git a/extensions/codex/src/app-server/native-hook-relay.test.ts b/extensions/codex/src/app-server/native-hook-relay.test.ts new file mode 100644 index 00000000000..bb5c24663e4 --- /dev/null +++ b/extensions/codex/src/app-server/native-hook-relay.test.ts @@ -0,0 +1,118 @@ +import type { NativeHookRelayRegistrationHandle } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { describe, expect, it } from "vitest"; +import { + buildCodexNativeHookRelayConfig, + buildCodexNativeHookRelayDisabledConfig, +} from "./native-hook-relay.js"; + +describe("Codex native hook relay config", () => { + it("builds deterministic Codex config overrides with command hooks", () => { + const config = buildCodexNativeHookRelayConfig({ + relay: createRelay(), + hookTimeoutSec: 7, + }); + + expect(config).toEqual({ + "features.codex_hooks": true, + "hooks.PreToolUse": [ + { + matcher: null, + hooks: [ + { + type: "command", + command: + "openclaw hooks relay --provider codex --relay-id relay-1 --event pre_tool_use", + timeout: 7, + async: false, + statusMessage: "OpenClaw native hook relay", + }, + ], + }, + ], + "hooks.PostToolUse": [ + { + matcher: null, + hooks: [ + { + type: "command", + command: + "openclaw hooks relay --provider codex --relay-id relay-1 --event post_tool_use", + timeout: 7, + async: false, + statusMessage: "OpenClaw native hook relay", + }, + ], + }, + ], + "hooks.PermissionRequest": [ + { + matcher: null, + hooks: [ + { + type: "command", + command: + "openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request", + timeout: 7, + async: false, + statusMessage: "OpenClaw native hook relay", + }, + ], + }, + ], + }); + expect(JSON.stringify(config)).not.toContain("timeoutSec"); + expect(config).not.toHaveProperty("hooks.SessionStart"); + expect(config).not.toHaveProperty("hooks.UserPromptSubmit"); + expect(config).not.toHaveProperty("hooks.Stop"); + }); + + it("includes only requested hook events", () => { + expect( + buildCodexNativeHookRelayConfig({ + relay: createRelay(), + events: ["permission_request"], + }), + ).toEqual({ + "features.codex_hooks": true, + "hooks.PermissionRequest": [ + { + matcher: null, + hooks: [ + { + type: "command", + command: + "openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request", + timeout: 5, + async: false, + statusMessage: "OpenClaw native hook relay", + }, + ], + }, + ], + }); + }); + + it("builds deterministic clearing config when the relay is disabled", () => { + expect(buildCodexNativeHookRelayDisabledConfig()).toEqual({ + "features.codex_hooks": false, + "hooks.PreToolUse": [], + "hooks.PostToolUse": [], + "hooks.PermissionRequest": [], + }); + }); +}); + +function createRelay(): NativeHookRelayRegistrationHandle { + return { + relayId: "relay-1", + provider: "codex", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use", "post_tool_use", "permission_request"], + expiresAtMs: Date.now() + 1000, + commandForEvent: (event) => + `openclaw hooks relay --provider codex --relay-id relay-1 --event ${event}`, + unregister: () => undefined, + }; +} diff --git a/extensions/codex/src/app-server/native-hook-relay.ts b/extensions/codex/src/app-server/native-hook-relay.ts new file mode 100644 index 00000000000..44ee0db6c13 --- /dev/null +++ b/extensions/codex/src/app-server/native-hook-relay.ts @@ -0,0 +1,61 @@ +import type { + NativeHookRelayEvent, + NativeHookRelayRegistrationHandle, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { JsonObject, JsonValue } from "./protocol.js"; + +export const CODEX_NATIVE_HOOK_RELAY_EVENTS = [ + "pre_tool_use", + "post_tool_use", + "permission_request", +] as const satisfies readonly NativeHookRelayEvent[]; + +type CodexHookEventName = "PreToolUse" | "PostToolUse" | "PermissionRequest"; + +const CODEX_HOOK_EVENT_BY_NATIVE_EVENT: Record = { + pre_tool_use: "PreToolUse", + post_tool_use: "PostToolUse", + permission_request: "PermissionRequest", +}; + +export function buildCodexNativeHookRelayConfig(params: { + relay: NativeHookRelayRegistrationHandle; + events?: readonly NativeHookRelayEvent[]; + hookTimeoutSec?: number; +}): JsonObject { + const events = params.events?.length ? params.events : CODEX_NATIVE_HOOK_RELAY_EVENTS; + const config: JsonObject = { + "features.codex_hooks": true, + }; + for (const event of events) { + const codexEvent = CODEX_HOOK_EVENT_BY_NATIVE_EVENT[event]; + config[`hooks.${codexEvent}`] = [ + { + matcher: null, + hooks: [ + { + type: "command", + command: params.relay.commandForEvent(event), + timeout: normalizeHookTimeoutSec(params.hookTimeoutSec), + async: false, + statusMessage: "OpenClaw native hook relay", + }, + ], + }, + ] satisfies JsonValue; + } + return config; +} + +export function buildCodexNativeHookRelayDisabledConfig(): JsonObject { + return { + "features.codex_hooks": false, + "hooks.PreToolUse": [], + "hooks.PostToolUse": [], + "hooks.PermissionRequest": [], + }; +} + +function normalizeHookTimeoutSec(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.ceil(value) : 5; +} diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index cb9207f2cb0..ea2ebaa1398 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -8,6 +8,7 @@ import { type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __testing as nativeHookRelayTesting } from "../../../../src/agents/harness/native-hook-relay.js"; import { initializeGlobalHookRunner, resetGlobalHookRunner, @@ -188,7 +189,7 @@ function expectResumeRequest( expect.arrayContaining([ { method: "thread/resume", - params, + params: expect.objectContaining(params), }, ]), ); @@ -258,6 +259,21 @@ function createMessageDynamicTool( }; } +function extractRelayIdFromThreadRequest(params: unknown): string { + const command = ( + params as { + config?: { + "hooks.PreToolUse"?: Array<{ hooks?: Array<{ command?: string }> }>; + }; + } + ).config?.["hooks.PreToolUse"]?.[0]?.hooks?.[0]?.command; + const match = command?.match(/--relay-id ([^ ]+)/); + if (!match?.[1]) { + throw new Error(`relay id missing from command: ${command}`); + } + return match[1]; +} + describe("runCodexAppServerAttempt", () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-")); @@ -265,6 +281,7 @@ describe("runCodexAppServerAttempt", () => { afterEach(async () => { __testing.resetCodexAppServerClientFactoryForTests(); + nativeHookRelayTesting.clearNativeHookRelaysForTests(); resetGlobalHookRunner(); vi.restoreAllMocks(); await fs.rm(tempDir, { recursive: true, force: true }); @@ -406,6 +423,111 @@ describe("runCodexAppServerAttempt", () => { ); }); + it("registers native hook relay config for an enabled Codex turn and cleans it up", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const harness = createStartedThreadHarness(); + + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), { + nativeHookRelay: { + enabled: true, + events: ["pre_tool_use"], + gatewayTimeoutMs: 4321, + hookTimeoutSec: 9, + }, + }); + await harness.waitForMethod("turn/start"); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const startRequest = harness.requests.find((request) => request.method === "thread/start"); + expect(startRequest?.params).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ + "features.codex_hooks": true, + "hooks.PreToolUse": [ + expect.objectContaining({ + hooks: [ + expect.objectContaining({ + type: "command", + timeout: 9, + command: expect.stringContaining("--event pre_tool_use --timeout 4321"), + }), + ], + }), + ], + }), + }), + ); + const relayId = extractRelayIdFromThreadRequest(startRequest?.params); + expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined(); + }); + + it("sends clearing Codex native hook config when the relay is disabled", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const harness = createStartedThreadHarness(); + + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), { + nativeHookRelay: { enabled: false }, + }); + await harness.waitForMethod("turn/start"); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const startRequest = harness.requests.find((request) => request.method === "thread/start"); + expect(startRequest?.params).toEqual( + expect.objectContaining({ + config: { + "features.codex_hooks": false, + "hooks.PreToolUse": [], + "hooks.PostToolUse": [], + "hooks.PermissionRequest": [], + }, + }), + ); + }); + + it("cleans up native hook relay state when turn/start fails", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const harness = createStartedThreadHarness(async (method) => { + if (method === "turn/start") { + throw new Error("turn start exploded"); + } + return undefined; + }); + + await expect( + runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), { + nativeHookRelay: { enabled: true }, + }), + ).rejects.toThrow("turn start exploded"); + + const startRequest = harness.requests.find((request) => request.method === "thread/start"); + const relayId = extractRelayIdFromThreadRequest(startRequest?.params); + expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined(); + }); + + it("cleans up native hook relay state when the Codex turn aborts", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const harness = createStartedThreadHarness(); + + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), { + nativeHookRelay: { enabled: true }, + }); + await harness.waitForMethod("turn/start"); + const startRequest = harness.requests.find((request) => request.method === "thread/start"); + const relayId = extractRelayIdFromThreadRequest(startRequest?.params); + expect(abortAgentHarnessRun("session-1")).toBe(true); + + const result = await run; + + expect(result.aborted).toBe(true); + expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined(); + }); + it("fires agent_end with failure metadata when the codex turn fails", async () => { const agentEnd = vi.fn(); initializeGlobalHookRunner( @@ -1170,6 +1292,58 @@ describe("runCodexAppServerAttempt", () => { expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); }); + it("passes native hook relay config on thread start and resume", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-existing"); + } + if (method === "thread/resume") { + return threadStartResult("thread-existing"); + } + throw new Error(`unexpected method: ${method}`); + }); + const config = { + "features.codex_hooks": true, + "hooks.PreToolUse": [], + }; + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + config, + }); + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + config, + }); + + expect(request.mock.calls).toEqual([ + [ + "thread/start", + expect.objectContaining({ + config, + }), + ], + [ + "thread/resume", + expect.objectContaining({ + config, + }), + ], + ]); + }); + it("starts a new Codex thread when dynamic tool schemas change", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 4ddd00f1482..f4e04a13ea0 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -25,10 +25,13 @@ import { runAgentHarnessLlmInputHook, runAgentHarnessLlmOutputHook, runHarnessContextEngineMaintenance, + registerNativeHookRelay, setActiveEmbeddedRun, supportsModelTools, type EmbeddedRunAttemptParams, type EmbeddedRunAttemptResult, + type NativeHookRelayEvent, + type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; import { @@ -41,6 +44,11 @@ import { projectContextEngineAssemblyForCodex } from "./context-engine-projectio import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js"; import { CodexAppServerEventProjector } from "./event-projector.js"; +import { + buildCodexNativeHookRelayDisabledConfig, + buildCodexNativeHookRelayConfig, + CODEX_NATIVE_HOOK_RELAY_EVENTS, +} from "./native-hook-relay.js"; import { assertCodexTurnStartResponse, readCodexDynamicToolCallParams, @@ -90,7 +98,17 @@ function emitCodexAppServerEvent( export async function runCodexAppServerAttempt( params: EmbeddedRunAttemptParams, - options: { pluginConfig?: unknown; startupTimeoutFloorMs?: number } = {}, + options: { + pluginConfig?: unknown; + startupTimeoutFloorMs?: number; + nativeHookRelay?: { + enabled?: boolean; + events?: readonly NativeHookRelayEvent[]; + ttlMs?: number; + gatewayTimeoutMs?: number; + hookTimeoutSec?: number; + }; + } = {}, ): Promise { const attemptStartedAt = Date.now(); const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig }); @@ -241,11 +259,29 @@ export async function runCodexAppServerAttempt( let client: CodexAppServerClient; let thread: CodexAppServerThreadBinding; let trajectoryEndRecorded = false; + let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined; try { emitCodexAppServerEvent(params, { stream: "codex_app_server.lifecycle", data: { phase: "startup" }, }); + nativeHookRelay = createCodexNativeHookRelay({ + options: options.nativeHookRelay, + agentId: sessionAgentId, + sessionId: params.sessionId, + sessionKey: sandboxSessionKey, + runId: params.runId, + signal: runAbortController.signal, + }); + const nativeHookRelayConfig = nativeHookRelay + ? buildCodexNativeHookRelayConfig({ + relay: nativeHookRelay, + events: options.nativeHookRelay?.events, + hookTimeoutSec: options.nativeHookRelay?.hookTimeoutSec, + }) + : options.nativeHookRelay?.enabled === false + ? buildCodexNativeHookRelayDisabledConfig() + : undefined; ({ client, thread } = await withCodexStartupTimeout({ timeoutMs: params.timeoutMs, timeoutFloorMs: options.startupTimeoutFloorMs, @@ -259,6 +295,7 @@ export async function runCodexAppServerAttempt( dynamicTools: toolBridge.specs, appServer, developerInstructions: promptBuild.developerInstructions, + config: nativeHookRelayConfig, }); return { client: startupClient, thread: startupThread }; }, @@ -268,6 +305,7 @@ export async function runCodexAppServerAttempt( data: { phase: "thread_ready", threadId: thread.threadId }, }); } catch (error) { + nativeHookRelay?.unregister(); clearSharedCodexAppServerClient(); params.abortSignal?.removeEventListener("abort", abortFromUpstream); throw error; @@ -464,6 +502,7 @@ export async function runCodexAppServerAttempt( }); notificationCleanup(); requestCleanup(); + nativeHookRelay?.unregister(); await trajectoryRecorder?.flush(); params.abortSignal?.removeEventListener("abort", abortFromUpstream); throw error; @@ -641,12 +680,46 @@ export async function runCodexAppServerAttempt( clearTimeout(timeout); notificationCleanup(); requestCleanup(); + nativeHookRelay?.unregister(); runAbortController.signal.removeEventListener("abort", abortListener); params.abortSignal?.removeEventListener("abort", abortFromUpstream); clearActiveEmbeddedRun(params.sessionId, handle, params.sessionKey); } } +function createCodexNativeHookRelay(params: { + options: + | { + enabled?: boolean; + events?: readonly NativeHookRelayEvent[]; + ttlMs?: number; + gatewayTimeoutMs?: number; + } + | undefined; + agentId: string | undefined; + sessionId: string; + sessionKey: string | undefined; + runId: string; + signal: AbortSignal; +}): NativeHookRelayRegistrationHandle | undefined { + if (params.options?.enabled === false) { + return undefined; + } + return registerNativeHookRelay({ + provider: "codex", + ...(params.agentId ? { agentId: params.agentId } : {}), + sessionId: params.sessionId, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + runId: params.runId, + allowedEvents: params.options?.events ?? CODEX_NATIVE_HOOK_RELAY_EVENTS, + ttlMs: params.options?.ttlMs, + signal: params.signal, + command: { + timeoutMs: params.options?.gatewayTimeoutMs, + }, + }); +} + function interruptCodexTurnBestEffort( client: CodexAppServerClient, params: { diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 51222b543ef..3bbbb5bb111 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -33,6 +33,7 @@ export async function startOrResumeThread(params: { dynamicTools: CodexDynamicToolSpec[]; appServer: CodexAppServerRuntimeOptions; developerInstructions?: string; + config?: JsonObject; }): Promise { const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools); const binding = await readCodexAppServerBinding(params.params.sessionFile); @@ -59,6 +60,7 @@ export async function startOrResumeThread(params: { threadId: binding.threadId, appServer: params.appServer, developerInstructions: params.developerInstructions, + config: params.config, }), ), ); @@ -102,6 +104,7 @@ export async function startOrResumeThread(params: { sandbox: params.appServer.sandbox, ...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}), serviceName: "OpenClaw", + ...(params.config ? { config: params.config } : {}), developerInstructions: params.developerInstructions ?? buildDeveloperInstructions(params.params), dynamicTools: params.dynamicTools, @@ -139,6 +142,7 @@ export function buildThreadResumeParams( threadId: string; appServer: CodexAppServerRuntimeOptions; developerInstructions?: string; + config?: JsonObject; }, ): CodexThreadResumeParams { const modelProvider = resolveCodexAppServerModelProvider(params.provider); @@ -150,6 +154,7 @@ export function buildThreadResumeParams( approvalsReviewer: options.appServer.approvalsReviewer, sandbox: options.appServer.sandbox, ...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}), + ...(options.config ? { config: options.config } : {}), developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params), persistExtendedHistory: true, }; diff --git a/extensions/google-meet/src/node-host.ts b/extensions/google-meet/src/node-host.ts index 265bd71d64c..5523565924c 100644 --- a/extensions/google-meet/src/node-host.ts +++ b/extensions/google-meet/src/node-host.ts @@ -112,10 +112,11 @@ end tell`; }; } const [browserUrl = "", browserTitle = ""] = result.stdout.split(/\r?\n/u); + const trimmedBrowserTitle = browserTitle.trim(); return { - inCall: Boolean(browserUrl.trim()) && !/Meet$/u.test(browserTitle.trim()), + inCall: Boolean(browserUrl.trim()) && !trimmedBrowserTitle.endsWith("Meet"), browserUrl: browserUrl.trim() || undefined, - browserTitle: browserTitle.trim() || undefined, + browserTitle: trimmedBrowserTitle || undefined, status: "ok", }; } diff --git a/scripts/lib/run-extension-oxlint.mjs b/scripts/lib/run-extension-oxlint.mjs index 405f28e687b..c069c7f89cf 100644 --- a/scripts/lib/run-extension-oxlint.mjs +++ b/scripts/lib/run-extension-oxlint.mjs @@ -130,6 +130,9 @@ function collectTypeScriptFiles(directoryPath) { for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { const entryPath = path.join(directoryPath, entry.name); if (entry.isDirectory()) { + if (shouldSkipExtensionLintDirectory(entry.name)) { + continue; + } files.push(...collectTypeScriptFiles(entryPath)); continue; } @@ -147,3 +150,7 @@ function collectTypeScriptFiles(directoryPath) { return files; } + +function shouldSkipExtensionLintDirectory(name) { + return name === "node_modules"; +} diff --git a/src/agents/harness/hook-helpers.ts b/src/agents/harness/hook-helpers.ts index e7b084245ad..c3efccf600c 100644 --- a/src/agents/harness/hook-helpers.ts +++ b/src/agents/harness/hook-helpers.ts @@ -1,4 +1,4 @@ -import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { consumeAdjustedParamsForToolCall } from "../pi-tools.before-tool-call.js"; @@ -13,7 +13,7 @@ export async function runAgentHarnessAfterToolCallHook(params: { sessionId?: string; sessionKey?: string; startArgs: Record; - result?: AgentToolResult; + result?: unknown; error?: string; startedAt?: number; }): Promise { diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts new file mode 100644 index 00000000000..65ad3dce095 --- /dev/null +++ b/src/agents/harness/native-hook-relay.test.ts @@ -0,0 +1,593 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../../plugins/hook-runner-global.js"; +import { createMockPluginRegistry } from "../../plugins/hooks.test-helpers.js"; +import { + __testing, + buildNativeHookRelayCommand, + invokeNativeHookRelay, + registerNativeHookRelay, +} from "./native-hook-relay.js"; + +afterEach(() => { + vi.useRealTimers(); + resetGlobalHookRunner(); + __testing.clearNativeHookRelaysForTests(); +}); + +describe("native hook relay registry", () => { + it("registers a short-lived relay and builds hidden CLI commands", () => { + const relay = registerNativeHookRelay({ + provider: "codex", + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + ttlMs: 10_000, + command: { + executable: "/opt/Open Claw/openclaw.mjs", + nodeExecutable: "/usr/local/bin/node", + timeoutMs: 1234, + }, + }); + + expect(__testing.getNativeHookRelayRegistrationForTests(relay.relayId)).toMatchObject({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + }); + expect(relay.commandForEvent("pre_tool_use")).toBe( + "/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " + + `${relay.relayId} --event pre_tool_use --timeout 1234`, + ); + }); + + it("accepts an allowed Codex invocation and preserves raw payload", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + }); + + const response = await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + cwd: "/repo", + model: "gpt-5.4", + tool_name: "Bash", + tool_use_id: "call-1", + tool_input: { command: "pnpm test" }, + }, + }); + + expect(response).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + expect(__testing.getNativeHookRelayInvocationsForTests()).toEqual([ + expect.objectContaining({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + nativeEventName: "PreToolUse", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + cwd: "/repo", + model: "gpt-5.4", + toolName: "Bash", + toolUseId: "call-1", + rawPayload: expect.objectContaining({ + tool_input: { command: "pnpm test" }, + }), + }), + ]); + }); + + it("removes retained invocations when a relay is unregistered", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + }); + + await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: "call-1", + tool_input: { command: "pnpm test" }, + }, + }); + + expect(__testing.getNativeHookRelayInvocationsForTests()).toHaveLength(1); + + relay.unregister(); + + expect(__testing.getNativeHookRelayRegistrationForTests(relay.relayId)).toBeUndefined(); + expect(__testing.getNativeHookRelayInvocationsForTests()).toEqual([]); + }); + + it("keeps only a bounded history of retained invocations", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + }); + + for (let index = 0; index < 210; index += 1) { + await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: `call-${index}`, + tool_input: { command: `echo ${index}` }, + }, + }); + } + + const invocations = __testing.getNativeHookRelayInvocationsForTests(); + expect(invocations).toHaveLength(200); + expect(invocations.some((invocation) => invocation.toolUseId === "call-0")).toBe(false); + expect(invocations.at(-1)).toEqual(expect.objectContaining({ toolUseId: "call-209" })); + }); + + it("rejects missing, wrong-provider, and disallowed-event invocations", async () => { + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId: "missing", + event: "pre_tool_use", + rawPayload: {}, + }), + ).rejects.toThrow("not found"); + + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["post_tool_use"], + }); + + await expect( + invokeNativeHookRelay({ + provider: "claude-code", + relayId: relay.relayId, + event: "post_tool_use", + rawPayload: {}, + }), + ).rejects.toThrow("unsupported"); + + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + rawPayload: {}, + }), + ).rejects.toThrow("not allowed"); + }); + + it("rejects payloads beyond the relay JSON budget without recursive traversal", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["pre_tool_use"], + }); + let rawPayload: Record = {}; + for (let index = 0; index < 80; index += 1) { + rawPayload = { child: rawPayload }; + } + + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + rawPayload, + }), + ).rejects.toThrow("JSON-compatible"); + }); + + it("rejects expired relay ids", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-24T12:00:00Z")); + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + ttlMs: 1, + }); + + vi.setSystemTime(new Date("2026-04-24T12:00:01Z")); + + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + rawPayload: {}, + }), + ).rejects.toThrow("expired"); + expect(__testing.getNativeHookRelayRegistrationForTests(relay.relayId)).toBeUndefined(); + }); + + it("uses the Codex no-op output when no OpenClaw hook decides", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + }); + + for (const event of ["pre_tool_use", "post_tool_use", "permission_request"] as const) { + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event, + rawPayload: { hook_event_name: event }, + }), + ).resolves.toEqual({ stdout: "", stderr: "", exitCode: 0 }); + } + }); + + it("maps Codex PreToolUse to OpenClaw before_tool_call and blocks before execution", async () => { + const beforeToolCall = vi.fn(async () => ({ + block: true, + blockReason: "repo policy blocks this command", + })); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]), + ); + const relay = registerNativeHookRelay({ + provider: "codex", + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + }); + + const response = await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + cwd: "/repo", + model: "gpt-5.4", + tool_name: "Bash", + tool_use_id: "native-call-1", + tool_input: { command: "rm -rf dist" }, + }, + }); + + expect(JSON.parse(response.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "repo policy blocks this command", + }, + }); + expect(response.exitCode).toBe(0); + expect(beforeToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + params: { command: "rm -rf dist" }, + runId: "run-1", + toolCallId: "native-call-1", + }), + expect.objectContaining({ + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + toolName: "exec", + toolCallId: "native-call-1", + }), + ); + }); + + it("does not rewrite Codex native tool input when before_tool_call adjusts params", async () => { + const beforeToolCall = vi.fn(async () => ({ + params: { command: "echo replaced" }, + })); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]), + ); + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + }); + + const response = await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: "native-call-1", + tool_input: { command: "echo original" }, + }, + }); + + expect(response).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + expect(beforeToolCall).toHaveBeenCalledTimes(1); + }); + + it("maps Codex PostToolUse to OpenClaw after_tool_call observation", async () => { + const afterToolCall = vi.fn(); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]), + ); + const relay = registerNativeHookRelay({ + provider: "codex", + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + }); + + const response = await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "post_tool_use", + rawPayload: { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_use_id: "native-call-1", + tool_input: { command: "pnpm test" }, + tool_response: { output: "ok", exit_code: 0 }, + }, + }); + + expect(response).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + expect(afterToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "exec", + params: { command: "pnpm test" }, + runId: "run-1", + toolCallId: "native-call-1", + result: { output: "ok", exit_code: 0 }, + }), + expect.objectContaining({ + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + toolName: "exec", + toolCallId: "native-call-1", + }), + ); + }); + + it("maps PermissionRequest approval allow and deny decisions to Codex hook output", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + }); + const approvalRequester = vi + .fn() + .mockResolvedValueOnce("allow" as const) + .mockResolvedValueOnce("deny" as const); + __testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester); + + const allow = await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "permission_request", + rawPayload: { + hook_event_name: "PermissionRequest", + cwd: "/repo", + model: "gpt-5.4", + tool_name: "Bash", + tool_input: { command: "git push" }, + }, + }); + const deny = await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "permission_request", + rawPayload: { + hook_event_name: "PermissionRequest", + tool_name: "Bash", + tool_input: { command: "curl https://example.com" }, + }, + }); + + expect(JSON.parse(allow.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { behavior: "allow" }, + }, + }); + expect(JSON.parse(deny.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { behavior: "deny", message: "Denied by user" }, + }, + }); + expect(approvalRequester).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "codex", + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + toolName: "exec", + cwd: "/repo", + model: "gpt-5.4", + toolInput: { command: "git push" }, + }), + ); + }); + + it("defers PermissionRequest when OpenClaw approval does not decide", async () => { + __testing.setNativeHookRelayPermissionApprovalRequesterForTests( + vi.fn(async () => "defer" as const), + ); + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + }); + + await expect( + invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "permission_request", + rawPayload: { + hook_event_name: "PermissionRequest", + tool_name: "Bash", + tool_input: { command: "cargo test" }, + }, + }), + ).resolves.toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + it("deduplicates pending PermissionRequest approvals by relay, run, and tool call", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + }); + let resolveDecision: ((decision: "allow") => void) | undefined; + const pendingDecision = new Promise<"allow">((resolve) => { + resolveDecision = resolve; + }); + const approvalRequester = vi.fn(() => pendingDecision); + __testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester); + + const payload = { + hook_event_name: "PermissionRequest", + tool_name: "Bash", + tool_use_id: "native-call-1", + tool_input: { command: "git push" }, + }; + const first = invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "permission_request", + rawPayload: payload, + }); + const second = invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "permission_request", + rawPayload: payload, + }); + + await Promise.resolve(); + expect(approvalRequester).toHaveBeenCalledTimes(1); + resolveDecision?.("allow"); + const responses = await Promise.all([first, second]); + + expect(responses.map((response) => JSON.parse(response.stdout))).toEqual([ + { + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { behavior: "allow" }, + }, + }, + { + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { behavior: "allow" }, + }, + }, + ]); + }); + + it("defers PermissionRequest approvals after the per-relay approval budget is exhausted", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + }); + const approvalRequester = vi.fn(async () => "allow" as const); + __testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester); + + const responses = []; + for (let index = 0; index < 13; index += 1) { + responses.push( + await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "permission_request", + rawPayload: { + hook_event_name: "PermissionRequest", + tool_name: "Bash", + tool_use_id: `native-call-${index}`, + tool_input: { command: `echo ${index}` }, + }, + }), + ); + } + + expect(approvalRequester).toHaveBeenCalledTimes(12); + expect(responses.at(-1)).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + it("sanitizes PermissionRequest approval previews and reports omitted keys", () => { + expect( + __testing.formatPermissionApprovalDescriptionForTests({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + toolName: "exec", + cwd: "/repo\u001b[31m/red\u001b[0m", + model: "gpt-5.4\u202edenied", + toolInput: { + command: "printf 'ok'\r\n\u001b[31mred\u001b[0m", + }, + }), + ).toBe("Tool: exec\nCwd: /repo/red\nModel: gpt-5.4 denied\nCommand: printf 'ok' red"); + + expect( + __testing.formatPermissionApprovalDescriptionForTests({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + toolName: "exec", + toolInput: Object.fromEntries( + Array.from({ length: 13 }, (_, index) => [`key-${index}`, index]), + ), + }), + ).toContain("(1 omitted)"); + }); +}); + +describe("native hook relay command builder", () => { + it("uses the Codex hook relay command shape", () => { + expect( + buildNativeHookRelayCommand({ + provider: "codex", + relayId: "relay-1", + event: "permission_request", + executable: "openclaw", + }), + ).toBe( + "openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request --timeout 5000", + ); + }); +}); diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts new file mode 100644 index 00000000000..d9e22ec609b --- /dev/null +++ b/src/agents/harness/native-hook-relay.ts @@ -0,0 +1,882 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { PluginApprovalResolutions } from "../../plugins/types.js"; +import { runBeforeToolCallHook } from "../pi-tools.before-tool-call.js"; +import { normalizeToolName } from "../tool-policy.js"; +import { callGatewayTool } from "../tools/gateway.js"; +import { runAgentHarnessAfterToolCallHook } from "./hook-helpers.js"; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export const NATIVE_HOOK_RELAY_EVENTS = [ + "pre_tool_use", + "post_tool_use", + "permission_request", +] as const; + +export 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]; + +export type NativeHookRelayInvocation = { + provider: NativeHookRelayProvider; + relayId: string; + event: NativeHookRelayEvent; + nativeEventName?: string; + agentId?: string; + sessionId: string; + sessionKey?: string; + runId: string; + cwd?: string; + model?: string; + toolName?: string; + toolUseId?: string; + rawPayload: JsonValue; + receivedAt: string; +}; + +export type NativeHookRelayProcessResponse = { + stdout: string; + stderr: string; + exitCode: number; +}; + +export type NativeHookRelayRegistration = { + relayId: string; + provider: NativeHookRelayProvider; + agentId?: string; + sessionId: string; + sessionKey?: string; + runId: string; + allowedEvents: readonly NativeHookRelayEvent[]; + expiresAtMs: number; + signal?: AbortSignal; +}; + +export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistration & { + commandForEvent: (event: NativeHookRelayEvent) => string; + unregister: () => void; +}; + +export type RegisterNativeHookRelayParams = { + provider: NativeHookRelayProvider; + agentId?: string; + sessionId: string; + sessionKey?: string; + runId: string; + allowedEvents?: readonly NativeHookRelayEvent[]; + ttlMs?: number; + command?: NativeHookRelayCommandOptions; + signal?: AbortSignal; +}; + +export type NativeHookRelayCommandOptions = { + executable?: string; + nodeExecutable?: string; + timeoutMs?: number; +}; + +export type InvokeNativeHookRelayParams = { + provider: unknown; + relayId: unknown; + event: unknown; + rawPayload: unknown; +}; + +type NativeHookRelayInvocationMetadata = Partial< + Pick +>; + +type NativeHookRelayProviderAdapter = { + normalizeMetadata: (rawPayload: JsonValue) => NativeHookRelayInvocationMetadata; + readToolInput: (rawPayload: JsonValue) => Record; + readToolResponse: (rawPayload: JsonValue) => unknown; + renderNoopResponse: (event: NativeHookRelayEvent) => NativeHookRelayProcessResponse; + renderPreToolUseBlockResponse: (reason: string) => NativeHookRelayProcessResponse; + renderPermissionDecisionResponse: ( + decision: NativeHookRelayPermissionDecision, + message?: string, + ) => NativeHookRelayProcessResponse; +}; + +const DEFAULT_RELAY_TTL_MS = 30 * 60 * 1000; +const DEFAULT_RELAY_TIMEOUT_MS = 5_000; +const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; +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_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 ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g"); +const relays = new Map(); +const invocations: NativeHookRelayInvocation[] = []; +const pendingPermissionApprovals = new Map< + string, + Promise +>(); +const permissionApprovalWindows = new Map(); +const log = createSubsystemLogger("agents/harness/native-hook-relay"); + +type NativeHookRelayPermissionDecision = "allow" | "deny"; + +type NativeHookRelayPermissionApprovalResult = NativeHookRelayPermissionDecision | "defer"; + +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; + +let nativeHookRelayPermissionApprovalRequester: NativeHookRelayPermissionApprovalRequester = + requestNativeHookRelayPermissionApproval; + +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) => ({ + stdout: `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + })}\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 registerNativeHookRelay( + params: RegisterNativeHookRelayParams, +): NativeHookRelayRegistrationHandle { + pruneExpiredNativeHookRelays(); + const relayId = randomUUID(); + const allowedEvents = normalizeAllowedEvents(params.allowedEvents); + const registration: NativeHookRelayRegistration = { + relayId, + provider: params.provider, + ...(params.agentId ? { agentId: params.agentId } : {}), + sessionId: params.sessionId, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + runId: params.runId, + allowedEvents, + expiresAtMs: Date.now() + normalizePositiveInteger(params.ttlMs, DEFAULT_RELAY_TTL_MS), + ...(params.signal ? { signal: params.signal } : {}), + }; + relays.set(relayId, registration); + return { + ...registration, + commandForEvent: (event) => + buildNativeHookRelayCommand({ + provider: params.provider, + relayId, + event, + timeoutMs: params.command?.timeoutMs, + executable: params.command?.executable, + nodeExecutable: params.command?.nodeExecutable, + }), + unregister: () => unregisterNativeHookRelay(relayId), + }; +} + +export function unregisterNativeHookRelay(relayId: string): void { + relays.delete(relayId); + removeNativeHookRelayInvocations(relayId); + removeNativeHookRelayPermissionState(relayId); +} + +export function buildNativeHookRelayCommand(params: { + provider: NativeHookRelayProvider; + relayId: string; + event: NativeHookRelayEvent; + timeoutMs?: number; + executable?: string; + 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]; + return shellQuoteArgs([ + ...argv, + "hooks", + "relay", + "--provider", + params.provider, + "--relay-id", + params.relayId, + "--event", + params.event, + "--timeout", + String(timeoutMs), + ]); +} + +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) { + relays.delete(relayId); + removeNativeHookRelayInvocations(relayId); + throw new Error("native hook relay expired"); + } + if (registration.provider !== provider) { + throw new Error("native hook relay provider mismatch"); + } + 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); + return processNativeHookRelayInvocation({ + registration, + invocation: normalized, + adapter: getNativeHookRelayProviderAdapter(provider), + }); +} + +export function renderNativeHookRelayUnavailableResponse(params: { + provider: unknown; + event: 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") { + return adapter.renderPreToolUseBlockResponse(message); + } + if (event === "permission_request") { + return adapter.renderPermissionDecisionResponse("deny", message); + } + return adapter.renderNoopResponse(event); +} + +function recordNativeHookRelayInvocation(invocation: NativeHookRelayInvocation): void { + invocations.push(invocation); + 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 pruneExpiredNativeHookRelays(now = Date.now()): void { + for (const [relayId, registration] of relays) { + if (now > registration.expiresAtMs) { + relays.delete(relayId); + removeNativeHookRelayInvocations(relayId); + } + } +} + +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); + } + 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 outcome = await runBeforeToolCallHook({ + toolName, + params: toolInput, + ...(params.invocation.toolUseId ? { toolCallId: params.invocation.toolUseId } : {}), + signal: params.registration.signal, + ctx: { + ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + runId: params.registration.runId, + }, + }); + if (outcome.blocked) { + return params.adapter.renderPreToolUseBlockResponse(outcome.reason); + } + // Codex PreToolUse supports block/allow, not argument mutation. If an + // OpenClaw plugin returns adjusted params here, we intentionally ignore them. + 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}`; + 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 } : {}), + startArgs: params.adapter.readToolInput(params.invocation.rawPayload), + result: params.adapter.readToolResponse(params.invocation.rawPayload), + }); + 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 pendingApproval = pendingPermissionApprovals.get(approvalKey); + try { + const decision = await (pendingApproval ?? + requestNativeHookRelayPermissionApprovalWithBudget({ + registration: params.registration, + approvalKey, + request, + })); + if (decision === "allow") { + 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: ${String(error)}`); + } + return params.adapter.renderNoopResponse(params.invocation.event); +} + +async function requestNativeHookRelayPermissionApprovalWithBudget(params: { + registration: NativeHookRelayRegistration; + approvalKey: string; + request: NativeHookRelayPermissionApprovalRequest; +}): Promise { + if (!consumeNativeHookRelayPermissionBudget(params.registration.relayId)) { + log.warn( + `native hook permission approval rate limit exceeded; deferring: relay=${params.registration.relayId} run=${params.registration.runId}`, + ); + return "defer"; + } + const approval = nativeHookRelayPermissionApprovalRequester(params.request).finally(() => { + 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 ?? permissionRequestFallbackKey(params.request), + ].join(":"); +} + +function permissionRequestFallbackKey(request: NativeHookRelayPermissionApprovalRequest): string { + const command = readOptionalString(request.toolInput.command); + if (command) { + return `${request.toolName}:command:${truncateText(command, 240)}`; + } + const keys = Object.keys(request.toolInput).toSorted().join(","); + return `${request.toolName}:keys:${truncateText(keys, 240)}`; +} + +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 removeNativeHookRelayPermissionState(relayId: string): void { + permissionApprovalWindows.delete(relayId); + for (const key of pendingPermissionApprovals.keys()) { + if (key.startsWith(`${relayId}:`)) { + pendingPermissionApprovals.delete(key); + } + } +} + +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 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)) { + return toolInput; + } + if (toolInput === undefined) { + return {}; + } + return { value: toolInput }; +} + +function readCodexToolResponse(rawPayload: JsonValue): unknown { + const payload = isJsonObject(rawPayload) ? rawPayload : {}; + return payload.tool_response; +} + +function normalizeNativeHookToolName(toolName: string | undefined): string { + return normalizeToolName(toolName ?? "tool"); +} + +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, + 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.prototype.hasOwnProperty.call(requestResult ?? {}, "decision")) { + decision = requestResult.decision; + } else { + const waitResult = await waitForNativeHookRelayApprovalDecision({ + approvalId, + signal: request.signal, + timeoutMs, + }); + decision = waitResult?.decision; + } + if ( + decision === PluginApprovalResolutions.ALLOW_ONCE || + decision === PluginApprovalResolutions.ALLOW_ALWAYS + ) { + return "allow"; + } + 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 }, + ); + if (!params.signal) { + return waitPromise; + } + let onAbort: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + if (params.signal!.aborted) { + reject(params.signal!.reason); + return; + } + onAbort = () => reject(params.signal!.reason); + 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 value.replace(ANSI_ESCAPE_PATTERN, "")) { + 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 `${value.slice(0, Math.max(0, maxLength - 3))}...`; +} + +function resolveOpenClawCliExecutable(): string { + 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 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") { + 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 isJsonValue(value: unknown): value is JsonValue { + const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; + let nodes = 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 || typeof current.value === "string") { + 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 item of current.value) { + stack.push({ value: item, depth: current.depth + 1 }); + } + continue; + } + if (!isJsonObject(current.value)) { + return false; + } + try { + for (const item of Object.values(current.value)) { + stack.push({ value: item, 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; + } +} + +export const __testing = { + clearNativeHookRelaysForTests(): void { + relays.clear(); + invocations.length = 0; + pendingPermissionApprovals.clear(); + permissionApprovalWindows.clear(); + nativeHookRelayPermissionApprovalRequester = requestNativeHookRelayPermissionApproval; + }, + getNativeHookRelayInvocationsForTests(): NativeHookRelayInvocation[] { + return [...invocations]; + }, + getNativeHookRelayRegistrationForTests(relayId: string): NativeHookRelayRegistration | undefined { + return relays.get(relayId); + }, + formatPermissionApprovalDescriptionForTests( + request: NativeHookRelayPermissionApprovalRequest, + ): string { + return formatPermissionApprovalDescription(request); + }, + setNativeHookRelayPermissionApprovalRequesterForTests( + requester: NativeHookRelayPermissionApprovalRequester, + ): void { + nativeHookRelayPermissionApprovalRequester = requester; + }, +} as const; diff --git a/src/agents/openai-completions-compat.ts b/src/agents/openai-completions-compat.ts index 9e04c807360..99fc74860ca 100644 --- a/src/agents/openai-completions-compat.ts +++ b/src/agents/openai-completions-compat.ts @@ -135,7 +135,9 @@ export function resolveOpenAICompletionsCompatDefaultsFromCapabilities( } export function detectOpenAICompletionsCompat( - model: Pick, "provider" | "baseUrl" | "id" | "compat">, + model: Pick, "provider" | "baseUrl" | "id"> & { + compat?: { supportsStore?: boolean } | null; + }, ): DetectedOpenAICompletionsCompat { const capabilities = resolveProviderRequestCapabilities({ provider: model.provider, diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index e04d10176f4..9b0ba6c3210 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -18,6 +18,7 @@ import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { runNativeHookRelayCli, type NativeHookRelayCliOptions } from "./native-hook-relay-cli.js"; import { runPluginInstallCommand } from "./plugins-install-command.js"; import { runPluginUpdateCommand } from "./plugins-update-command.js"; @@ -516,6 +517,19 @@ export function registerHooksCli(program: Command): void { }), ); + hooks + .command("relay", { hidden: true }) + .description("Internal native harness hook relay") + .requiredOption("--provider ", "Native harness provider") + .requiredOption("--relay-id ", "Native hook relay id") + .requiredOption("--event ", "Native hook event") + .option("--timeout ", "Gateway timeout in ms", "5000") + .action(async (opts: NativeHookRelayCliOptions) => + runHooksCliAction(async () => { + process.exitCode = await runNativeHookRelayCli(opts); + }), + ); + hooks .command("install") .description("Deprecated: install a hook pack via `openclaw plugins install`") diff --git a/src/cli/native-hook-relay-cli.test.ts b/src/cli/native-hook-relay-cli.test.ts new file mode 100644 index 00000000000..ccfe1e20ec7 --- /dev/null +++ b/src/cli/native-hook-relay-cli.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createReadableTextStream, + createWritableTextBuffer, + runNativeHookRelayCli, +} from "./native-hook-relay-cli.js"; + +describe("native hook relay CLI", () => { + it("reads Codex hook JSON from stdin and forwards it to the gateway relay", async () => { + const callGateway = vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })); + const stdout = createWritableTextBuffer(); + const stderr = createWritableTextBuffer(); + + const exitCode = await runNativeHookRelayCli( + { + provider: "codex", + relayId: "relay-1", + event: "pre_tool_use", + timeout: "1234", + }, + { + stdin: createReadableTextStream( + JSON.stringify({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "pnpm test" }, + }), + ), + stdout, + stderr, + callGateway: callGateway as never, + }, + ); + + expect(exitCode).toBe(0); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toBe(""); + expect(callGateway).toHaveBeenCalledWith({ + method: "nativeHook.invoke", + params: { + provider: "codex", + relayId: "relay-1", + event: "pre_tool_use", + rawPayload: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "pnpm test" }, + }, + }, + timeoutMs: 1234, + scopes: ["operator.admin"], + }); + }); + + it("renders provider-compatible stdout, stderr, and exit code from the gateway response", async () => { + const callGateway = vi.fn(async () => ({ stdout: "out", stderr: "err", exitCode: 2 })); + const stdout = createWritableTextBuffer(); + const stderr = createWritableTextBuffer(); + + const exitCode = await runNativeHookRelayCli( + { provider: "codex", relayId: "relay-1", event: "permission_request" }, + { + stdin: createReadableTextStream("{}"), + stdout, + stderr, + callGateway: callGateway as never, + }, + ); + + expect(exitCode).toBe(2); + expect(stdout.text()).toBe("out"); + expect(stderr.text()).toBe("err"); + }); + + it("returns a nonzero code for malformed hook input without touching the gateway", async () => { + const callGateway = vi.fn(); + const stderr = createWritableTextBuffer(); + + const exitCode = await runNativeHookRelayCli( + { provider: "codex", relayId: "relay-1", event: "pre_tool_use" }, + { + stdin: createReadableTextStream("{nope"), + stderr, + callGateway: callGateway as never, + }, + ); + + expect(exitCode).toBe(1); + expect(stderr.text()).toContain("failed to read native hook input"); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("rejects oversized hook input without touching the gateway", async () => { + const callGateway = vi.fn(); + const stderr = createWritableTextBuffer(); + + const exitCode = await runNativeHookRelayCli( + { provider: "codex", relayId: "relay-1", event: "post_tool_use" }, + { + stdin: createReadableTextStream("x".repeat(1024 * 1024 + 1)), + stderr, + callGateway: callGateway as never, + }, + ); + + expect(exitCode).toBe(1); + expect(stderr.text()).toContain("native hook input exceeds"); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("fails closed for PreToolUse when the gateway relay is unavailable", async () => { + const callGateway = vi.fn(async () => { + throw new Error("gateway closed"); + }); + const stdout = createWritableTextBuffer(); + const stderr = createWritableTextBuffer(); + + const exitCode = await runNativeHookRelayCli( + { provider: "codex", relayId: "relay-1", event: "pre_tool_use" }, + { + stdin: createReadableTextStream("{}"), + stdout, + stderr, + callGateway: callGateway as never, + }, + ); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout.text())).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Native hook relay unavailable", + }, + }); + expect(stderr.text()).toContain("native hook relay unavailable"); + }); + + it("fails closed for PermissionRequest when the gateway relay is unavailable", async () => { + const callGateway = vi.fn(async () => { + throw new Error("gateway closed"); + }); + const stdout = createWritableTextBuffer(); + const stderr = createWritableTextBuffer(); + + const exitCode = await runNativeHookRelayCli( + { provider: "codex", relayId: "relay-1", event: "permission_request" }, + { + stdin: createReadableTextStream("{}"), + stdout, + stderr, + callGateway: callGateway as never, + }, + ); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout.text())).toEqual({ + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "deny", + message: "Native hook relay unavailable", + }, + }, + }); + }); + + it("keeps PostToolUse unavailable handling observational", async () => { + const callGateway = vi.fn(async () => { + throw new Error("gateway closed"); + }); + const stdout = createWritableTextBuffer(); + const stderr = createWritableTextBuffer(); + + const exitCode = await runNativeHookRelayCli( + { provider: "codex", relayId: "relay-1", event: "post_tool_use" }, + { + stdin: createReadableTextStream("{}"), + stdout, + stderr, + callGateway: callGateway as never, + }, + ); + + expect(exitCode).toBe(0); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain("native hook relay unavailable"); + }); +}); diff --git a/src/cli/native-hook-relay-cli.ts b/src/cli/native-hook-relay-cli.ts new file mode 100644 index 00000000000..dd33dca52ef --- /dev/null +++ b/src/cli/native-hook-relay-cli.ts @@ -0,0 +1,121 @@ +import { Readable, Writable } from "node:stream"; +import { + renderNativeHookRelayUnavailableResponse, + type NativeHookRelayProcessResponse, +} from "../agents/harness/native-hook-relay.js"; +import { callGateway } from "../gateway/call.js"; +import { ADMIN_SCOPE } from "../gateway/method-scopes.js"; + +const MAX_NATIVE_HOOK_STDIN_BYTES = 1024 * 1024; + +export type NativeHookRelayCliOptions = { + provider?: string; + relayId?: string; + event?: string; + timeout?: string; +}; + +export type NativeHookRelayCliDeps = { + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; + stderr?: NodeJS.WritableStream; + callGateway?: typeof callGateway; +}; + +export async function runNativeHookRelayCli( + opts: NativeHookRelayCliOptions, + deps: NativeHookRelayCliDeps = {}, +): Promise { + const stdin = deps.stdin ?? process.stdin; + const stdout = deps.stdout ?? process.stdout; + const stderr = deps.stderr ?? process.stderr; + const callGatewayFn = deps.callGateway ?? callGateway; + const provider = readRequiredOption(opts.provider, "provider"); + const relayId = readRequiredOption(opts.relayId, "relay-id"); + const event = readRequiredOption(opts.event, "event"); + + let rawPayload: unknown; + try { + const rawInput = await readStreamText(stdin, MAX_NATIVE_HOOK_STDIN_BYTES); + rawPayload = rawInput.trim() ? JSON.parse(rawInput) : null; + } catch (error) { + writeText(stderr, formatRelayCliError("failed to read native hook input", error)); + return 1; + } + + try { + const response = await callGatewayFn({ + method: "nativeHook.invoke", + params: { provider, relayId, event, rawPayload }, + timeoutMs: normalizeTimeoutMs(opts.timeout), + scopes: [ADMIN_SCOPE], + }); + writeText(stdout, response.stdout); + writeText(stderr, response.stderr); + return response.exitCode; + } catch (error) { + writeText(stderr, formatRelayCliError("native hook relay unavailable", error)); + const response = renderNativeHookRelayUnavailableResponse({ + provider, + event, + message: "Native hook relay unavailable", + }); + writeText(stdout, response.stdout); + writeText(stderr, response.stderr); + return response.exitCode; + } +} + +function readRequiredOption(value: string | undefined, name: string): string { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + throw new Error(`Missing required option --${name}`); +} + +async function readStreamText(stream: NodeJS.ReadableStream, maxBytes: number): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.byteLength; + if (total > maxBytes) { + throw new Error(`native hook input exceeds ${maxBytes} bytes`); + } + chunks.push(buffer); + } + return Buffer.concat(chunks, total).toString("utf8"); +} + +function normalizeTimeoutMs(value: string | undefined): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 5_000; +} + +function writeText(stream: NodeJS.WritableStream, value: string | undefined): void { + if (value) { + stream.write(value); + } +} + +function formatRelayCliError(prefix: string, error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `${prefix}: ${message}\n`; +} + +export function createReadableTextStream(text: string): NodeJS.ReadableStream { + return Readable.from([text]); +} + +export function createWritableTextBuffer(): NodeJS.WritableStream & { text: () => string } { + const chunks: Buffer[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + callback(); + }, + }); + return Object.assign(stream, { + text: () => Buffer.concat(chunks).toString("utf8"), + }); +} diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 0efe8498a05..5ad3ac1ead2 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -51,6 +51,7 @@ type SupportedAnthropicMessagesCompatFields = Pick< type SupportedThinkingFormat = | NonNullable + | "deepseek" | "openrouter" | "qwen-chat-template"; diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index bf9e111c433..afba94d8f8d 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -36,6 +36,7 @@ describe("method scope resolution", () => { ["node.pair.approve", ["operator.pairing"]], ["poll", ["operator.write"]], ["config.patch", ["operator.admin"]], + ["nativeHook.invoke", ["operator.admin"]], ["wizard.start", ["operator.admin"]], ["update.run", ["operator.admin"]], ])("resolves least-privilege scopes for %s", (method, expected) => { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 834be3dea7d..8c1c8332bdc 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -169,6 +169,7 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.compaction.restore", "connect", "chat.inject", + "nativeHook.invoke", "web.login.start", "web.login.wait", "set-heartbeats", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index d8c774f16c3..d40b176970b 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -20,6 +20,7 @@ import { healthHandlers } from "./server-methods/health.js"; import { logsHandlers } from "./server-methods/logs.js"; import { modelsAuthStatusHandlers } from "./server-methods/models-auth-status.js"; import { modelsHandlers } from "./server-methods/models.js"; +import { nativeHookRelayHandlers } from "./server-methods/native-hook-relay.js"; import { nodePendingHandlers } from "./server-methods/nodes-pending.js"; import { nodeHandlers } from "./server-methods/nodes.js"; import { pushHandlers } from "./server-methods/push.js"; @@ -84,6 +85,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...webHandlers, ...modelsHandlers, ...modelsAuthStatusHandlers, + ...nativeHookRelayHandlers, ...configHandlers, ...wizardHandlers, ...talkHandlers, diff --git a/src/gateway/server-methods/native-hook-relay.test.ts b/src/gateway/server-methods/native-hook-relay.test.ts new file mode 100644 index 00000000000..176ec4afba0 --- /dev/null +++ b/src/gateway/server-methods/native-hook-relay.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { __testing, registerNativeHookRelay } from "../../agents/harness/native-hook-relay.js"; +import { nativeHookRelayHandlers } from "./native-hook-relay.js"; + +afterEach(() => { + __testing.clearNativeHookRelaysForTests(); +}); + +describe("native hook relay gateway method", () => { + it("accepts a live relay invocation", async () => { + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + allowedEvents: ["post_tool_use"], + }); + const respond = viRespond(); + + await nativeHookRelayHandlers["nativeHook.invoke"]({ + req: { type: "req", id: "1", method: "nativeHook.invoke" }, + params: { + provider: "codex", + relayId: relay.relayId, + event: "post_tool_use", + rawPayload: { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_response: { output: "ok" }, + }, + }, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + + expect(respond).toHaveBeenCalledWith(true, { stdout: "", stderr: "", exitCode: 0 }); + expect(__testing.getNativeHookRelayInvocationsForTests()).toHaveLength(1); + }); + + it("rejects unknown relay ids", async () => { + const respond = viRespond(); + + await nativeHookRelayHandlers["nativeHook.invoke"]({ + req: { type: "req", id: "1", method: "nativeHook.invoke" }, + params: { + provider: "codex", + relayId: "missing", + event: "pre_tool_use", + rawPayload: {}, + }, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("not found"), + }), + ); + }); +}); + +function viRespond() { + return vi.fn(); +} diff --git a/src/gateway/server-methods/native-hook-relay.ts b/src/gateway/server-methods/native-hook-relay.ts new file mode 100644 index 00000000000..b33cbbc165a --- /dev/null +++ b/src/gateway/server-methods/native-hook-relay.ts @@ -0,0 +1,29 @@ +import { + invokeNativeHookRelay, + type NativeHookRelayProcessResponse, +} from "../../agents/harness/native-hook-relay.js"; +import { ErrorCodes, errorShape } from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +export const nativeHookRelayHandlers: GatewayRequestHandlers = { + "nativeHook.invoke": async ({ params, respond }) => { + try { + const result: NativeHookRelayProcessResponse = await invokeNativeHookRelay({ + provider: params.provider, + relayId: params.relayId, + event: params.event, + rawPayload: params.rawPayload, + }); + respond(true, result); + } catch (error) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + error instanceof Error ? error.message : "native hook relay failed", + ), + ); + } + }, +}; diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 75a28747a34..a463c83ddec 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -37,6 +37,11 @@ export type { CodexAppServerToolResultEvent, CodexAppServerToolResultHandlerResult, } from "../plugins/codex-app-server-extension-types.js"; +export type { + NativeHookRelayEvent, + NativeHookRelayProvider, + NativeHookRelayRegistrationHandle, +} from "../agents/harness/native-hook-relay.js"; export { VERSION as OPENCLAW_VERSION } from "../version.js"; export { formatErrorMessage } from "../infra/errors.js"; @@ -97,6 +102,10 @@ export { runAgentHarnessLlmInputHook, runAgentHarnessLlmOutputHook, } from "../agents/harness/lifecycle-hook-helpers.js"; +export { + buildNativeHookRelayCommand, + registerNativeHookRelay, +} from "../agents/harness/native-hook-relay.js"; /** * Derive the same compact user-facing tool detail that Pi uses for progress logs.