mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
118
extensions/codex/src/app-server/native-hook-relay.test.ts
Normal file
118
extensions/codex/src/app-server/native-hook-relay.test.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
61
extensions/codex/src/app-server/native-hook-relay.ts
Normal file
61
extensions/codex/src/app-server/native-hook-relay.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
593
src/agents/harness/native-hook-relay.test.ts
Normal file
593
src/agents/harness/native-hook-relay.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
882
src/agents/harness/native-hook-relay.ts
Normal file
882
src/agents/harness/native-hook-relay.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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`")
|
||||
|
||||
189
src/cli/native-hook-relay-cli.test.ts
Normal file
189
src/cli/native-hook-relay-cli.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
121
src/cli/native-hook-relay-cli.ts
Normal file
121
src/cli/native-hook-relay-cli.ts
Normal 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"),
|
||||
});
|
||||
}
|
||||
@@ -51,6 +51,7 @@ type SupportedAnthropicMessagesCompatFields = Pick<
|
||||
|
||||
type SupportedThinkingFormat =
|
||||
| NonNullable<OpenAICompletionsCompat["thinkingFormat"]>
|
||||
| "deepseek"
|
||||
| "openrouter"
|
||||
| "qwen-chat-template";
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
71
src/gateway/server-methods/native-hook-relay.test.ts
Normal file
71
src/gateway/server-methods/native-hook-relay.test.ts
Normal 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();
|
||||
}
|
||||
29
src/gateway/server-methods/native-hook-relay.ts
Normal file
29
src/gateway/server-methods/native-hook-relay.ts
Normal 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",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user