Bridge Codex native hooks into OpenClaw

Bridge Codex-native tool events into the OpenClaw plugin hook surface, including native permission approval routing, bounded relay payloads, approval spam protection, and docs/changelog updates.\n\nCo-authored-by: pashpashpash <nik@vault77.ai>
This commit is contained in:
pashpashpash
2026-04-24 08:48:26 -07:00
committed by GitHub
parent 3a64aa49a9
commit 7a958d920c
25 changed files with 2379 additions and 10 deletions

View File

@@ -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 <id>` 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.

View File

@@ -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

View File

@@ -58,7 +58,15 @@ Natural-language triggers that should route to the native Codex plugin:
- "Attach this chat to Codex thread `<id>`."
- "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:

View File

@@ -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

View File

@@ -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,
};
}

View File

@@ -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<NativeHookRelayEvent, CodexHookEventName> = {
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;
}

View File

@@ -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");

View File

@@ -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<EmbeddedRunAttemptResult> {
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: {

View File

@@ -33,6 +33,7 @@ export async function startOrResumeThread(params: {
dynamicTools: CodexDynamicToolSpec[];
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
}): Promise<CodexAppServerThreadBinding> {
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,
};

View File

@@ -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",
};
}

View File

@@ -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";
}

View File

@@ -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<string, unknown>;
result?: AgentToolResult<unknown>;
result?: unknown;
error?: string;
startedAt?: number;
}): Promise<void> {

View File

@@ -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<string, unknown> = {};
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",
);
});
});

View File

@@ -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<NativeHookRelayInvocation, "nativeEventName" | "cwd" | "model" | "toolName" | "toolUseId">
>;
type NativeHookRelayProviderAdapter = {
normalizeMetadata: (rawPayload: JsonValue) => NativeHookRelayInvocationMetadata;
readToolInput: (rawPayload: JsonValue) => Record<string, unknown>;
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<string, NativeHookRelayRegistration>();
const invocations: NativeHookRelayInvocation[] = [];
const pendingPermissionApprovals = new Map<
string,
Promise<NativeHookRelayPermissionApprovalResult>
>();
const permissionApprovalWindows = new Map<string, number[]>();
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<string, unknown>;
signal?: AbortSignal;
};
type NativeHookRelayPermissionApprovalRequester = (
request: NativeHookRelayPermissionApprovalRequest,
) => Promise<NativeHookRelayPermissionApprovalResult>;
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<NativeHookRelayProcessResponse> {
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<NativeHookRelayProcessResponse> {
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<NativeHookRelayProcessResponse> {
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<NativeHookRelayProcessResponse> {
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<NativeHookRelayProcessResponse> {
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<NativeHookRelayPermissionApprovalResult> {
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<string, unknown> {
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<NativeHookRelayPermissionApprovalResult> {
const timeoutMs = DEFAULT_PERMISSION_TIMEOUT_MS;
const requestResult: {
id?: string;
decision?: string | null;
} = await callGatewayTool(
"plugin.approval.request",
{ timeoutMs: timeoutMs + 10_000 },
{
pluginId: `openclaw-native-hook-relay-${request.provider}`,
title: 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<never>((_, 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, unknown>): 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<string, unknown> {
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;

View File

@@ -135,7 +135,9 @@ export function resolveOpenAICompletionsCompatDefaultsFromCapabilities(
}
export function detectOpenAICompletionsCompat(
model: Pick<Model<"openai-completions">, "provider" | "baseUrl" | "id" | "compat">,
model: Pick<Model<"openai-completions">, "provider" | "baseUrl" | "id"> & {
compat?: { supportsStore?: boolean } | null;
},
): DetectedOpenAICompletionsCompat {
const capabilities = resolveProviderRequestCapabilities({
provider: model.provider,

View File

@@ -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 <provider>", "Native harness provider")
.requiredOption("--relay-id <id>", "Native hook relay id")
.requiredOption("--event <event>", "Native hook event")
.option("--timeout <ms>", "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`")

View File

@@ -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");
});
});

View File

@@ -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<number> {
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<NativeHookRelayProcessResponse>({
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<string> {
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"),
});
}

View File

@@ -51,6 +51,7 @@ type SupportedAnthropicMessagesCompatFields = Pick<
type SupportedThinkingFormat =
| NonNullable<OpenAICompletionsCompat["thinkingFormat"]>
| "deepseek"
| "openrouter"
| "qwen-chat-template";

View File

@@ -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) => {

View File

@@ -169,6 +169,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"sessions.compaction.restore",
"connect",
"chat.inject",
"nativeHook.invoke",
"web.login.start",
"web.login.wait",
"set-heartbeats",

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -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",
),
);
}
},
};

View File

@@ -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.