mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 09:19:30 +00:00
554 lines
16 KiB
TypeScript
554 lines
16 KiB
TypeScript
// Native hook relay CLI tests cover relay command registration and runtime delegation.
|
|
import { PassThrough, Readable, Writable } from "node:stream";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { runNativeHookRelayCli } from "./native-hook-relay-cli.js";
|
|
|
|
function createReadableTextStream(text: string): NodeJS.ReadableStream {
|
|
return Readable.from([text]);
|
|
}
|
|
|
|
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"),
|
|
});
|
|
}
|
|
|
|
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 (_opts: unknown) => ({ stdout: "", stderr: "", exitCode: 0 }));
|
|
const stdout = createWritableTextBuffer();
|
|
const stderr = createWritableTextBuffer();
|
|
|
|
const exitCode = await runNativeHookRelayCli(
|
|
{
|
|
provider: "codex",
|
|
relayId: "relay-1",
|
|
generation: "generation-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",
|
|
generation: "generation-1",
|
|
event: "pre_tool_use",
|
|
rawPayload: {
|
|
hook_event_name: "PreToolUse",
|
|
tool_name: "Bash",
|
|
tool_input: { command: "pnpm test" },
|
|
},
|
|
},
|
|
timeoutMs: expect.any(Number),
|
|
signal: expect.any(AbortSignal),
|
|
scopes: ["operator.admin"],
|
|
});
|
|
const call = callGateway.mock.calls[0]?.[0] as { timeoutMs?: number } | undefined;
|
|
expect(call).toBeDefined();
|
|
expect(call?.timeoutMs).toBeGreaterThan(0);
|
|
expect(call?.timeoutMs).toBeLessThanOrEqual(1234);
|
|
});
|
|
|
|
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",
|
|
generation: "generation-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("rejects malformed timeouts before reading relay input", async () => {
|
|
const invokeBridge = vi.fn();
|
|
const callGateway = vi.fn();
|
|
const stdout = createWritableTextBuffer();
|
|
const stderr = createWritableTextBuffer();
|
|
|
|
const exitCode = await runNativeHookRelayCli(
|
|
{
|
|
provider: "codex",
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
event: "pre_tool_use",
|
|
timeout: "5000ms",
|
|
},
|
|
{
|
|
stdin: createReadableTextStream("{}"),
|
|
stdout,
|
|
stderr,
|
|
invokeBridge: invokeBridge as never,
|
|
callGateway: callGateway as never,
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout.text()).toBe("");
|
|
expect(stderr.text()).toContain("invalid native hook timeout");
|
|
expect(stderr.text()).toContain('Received: "5000ms"');
|
|
expect(invokeBridge).not.toHaveBeenCalled();
|
|
expect(callGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fractional timeouts before gateway fallback", async () => {
|
|
const invokeBridge = vi.fn();
|
|
const callGateway = vi.fn();
|
|
const stderr = createWritableTextBuffer();
|
|
|
|
const exitCode = await runNativeHookRelayCli(
|
|
{
|
|
provider: "codex",
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
event: "pre_tool_use",
|
|
timeout: "1.5",
|
|
},
|
|
{
|
|
stdin: createReadableTextStream("{}"),
|
|
stderr,
|
|
invokeBridge: invokeBridge as never,
|
|
callGateway: callGateway as never,
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr.text()).toContain('Received: "1.5"');
|
|
expect(invokeBridge).not.toHaveBeenCalled();
|
|
expect(callGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders unavailable output for legacy relay commands without a generation", async () => {
|
|
const invokeBridge = vi.fn(async () => {
|
|
throw new Error("generation must be non-empty string");
|
|
});
|
|
const callGateway = vi.fn(async () => {
|
|
throw new Error("generation must be non-empty string");
|
|
});
|
|
const stdout = createWritableTextBuffer();
|
|
const stderr = createWritableTextBuffer();
|
|
|
|
const exitCode = await runNativeHookRelayCli(
|
|
{ provider: "codex", relayId: "relay-1", event: "pre_tool_use" },
|
|
{
|
|
stdin: createReadableTextStream("{}"),
|
|
stdout,
|
|
stderr,
|
|
invokeBridge: invokeBridge as never,
|
|
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");
|
|
expect(stderr.text()).toContain("generation must be non-empty string");
|
|
expect(callGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: "nativeHook.invoke",
|
|
params: expect.objectContaining({ generation: undefined }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
event: "pre_tool_use",
|
|
stdout: {
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
permissionDecision: "deny",
|
|
permissionDecisionReason: "Native hook relay unavailable",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
event: "permission_request",
|
|
stdout: {
|
|
hookSpecificOutput: {
|
|
hookEventName: "PermissionRequest",
|
|
decision: {
|
|
behavior: "deny",
|
|
message: "Native hook relay unavailable",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
event: "post_tool_use",
|
|
stdout: null,
|
|
},
|
|
])(
|
|
"does not fall back to the gateway after a stale direct bridge error for $event",
|
|
async (testCase) => {
|
|
const invokeBridge = vi.fn(async () => {
|
|
throw new Error("native hook relay bridge stale registration");
|
|
});
|
|
const callGateway = vi.fn(async () => ({ stdout: "unexpected", stderr: "", exitCode: 0 }));
|
|
const stdout = createWritableTextBuffer();
|
|
const stderr = createWritableTextBuffer();
|
|
|
|
const exitCode = await runNativeHookRelayCli(
|
|
{
|
|
provider: "codex",
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
event: testCase.event,
|
|
},
|
|
{
|
|
stdin: createReadableTextStream("{}"),
|
|
stdout,
|
|
stderr,
|
|
invokeBridge: invokeBridge as never,
|
|
callGateway: callGateway as never,
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(0);
|
|
if (testCase.stdout) {
|
|
expect(JSON.parse(stdout.text())).toEqual(testCase.stdout);
|
|
} else {
|
|
expect(stdout.text()).toBe("");
|
|
}
|
|
expect(stderr.text()).toContain("native hook relay unavailable");
|
|
expect(stderr.text()).toContain("native hook relay bridge stale registration");
|
|
expect(callGateway).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
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", generation: "generation-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.each([
|
|
{
|
|
event: "pre_tool_use",
|
|
preToolUseUnavailable: "noop",
|
|
stdout: null,
|
|
},
|
|
{
|
|
event: "pre_tool_use",
|
|
stdout: {
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
permissionDecision: "deny",
|
|
permissionDecisionReason: "Native hook relay timed out",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
event: "permission_request",
|
|
stdout: {
|
|
hookSpecificOutput: {
|
|
hookEventName: "PermissionRequest",
|
|
decision: {
|
|
behavior: "deny",
|
|
message: "Native hook relay timed out",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
event: "post_tool_use",
|
|
stdout: null,
|
|
},
|
|
])(
|
|
"bounds valid $event hook input that never reaches EOF",
|
|
async (testCase) => {
|
|
const invokeBridge = vi.fn();
|
|
const callGateway = vi.fn();
|
|
const stdin = createHeldOpenTextStream("{}");
|
|
const stdout = createWritableTextBuffer();
|
|
const stderr = createWritableTextBuffer();
|
|
|
|
const exitCode = await runNativeHookRelayCli(
|
|
{
|
|
provider: "codex",
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
event: testCase.event,
|
|
preToolUseUnavailable: testCase.preToolUseUnavailable,
|
|
timeout: "25",
|
|
},
|
|
{
|
|
stdin,
|
|
stdout,
|
|
stderr,
|
|
invokeBridge: invokeBridge as never,
|
|
callGateway: callGateway as never,
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(0);
|
|
if (testCase.stdout) {
|
|
expect(JSON.parse(stdout.text())).toEqual(testCase.stdout);
|
|
} else {
|
|
expect(stdout.text()).toBe("");
|
|
}
|
|
expect(stderr.text()).toContain("native hook relay timed out");
|
|
expect(stdin.destroyed).toBe(true);
|
|
expect(invokeBridge).not.toHaveBeenCalled();
|
|
expect(callGateway).not.toHaveBeenCalled();
|
|
},
|
|
1_000,
|
|
);
|
|
|
|
it("applies the relay deadline to gateway fallback", async () => {
|
|
const invokeBridge = vi.fn(async () => {
|
|
throw new Error("bridge unavailable");
|
|
});
|
|
const callGateway = vi.fn(async () => await new Promise<never>(() => {}));
|
|
const stdout = createWritableTextBuffer();
|
|
const stderr = createWritableTextBuffer();
|
|
|
|
const exitCode = await runNativeHookRelayCli(
|
|
{
|
|
provider: "codex",
|
|
relayId: "relay-1",
|
|
generation: "generation-1",
|
|
event: "post_tool_use",
|
|
timeout: "25",
|
|
},
|
|
{
|
|
stdin: createReadableTextStream("{}"),
|
|
stdout,
|
|
stderr,
|
|
invokeBridge: invokeBridge as never,
|
|
callGateway: callGateway as never,
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.text()).toBe("");
|
|
expect(stderr.text()).toContain("native hook relay timed out");
|
|
expect(callGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: "nativeHook.invoke",
|
|
signal: expect.any(AbortSignal),
|
|
}),
|
|
);
|
|
}, 1_000);
|
|
|
|
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", generation: "generation-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", generation: "generation-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("keeps PreToolUse unavailable handling observational only with an explicit no-policy marker", 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",
|
|
generation: "generation-1",
|
|
event: "pre_tool_use",
|
|
preToolUseUnavailable: "noop",
|
|
},
|
|
{
|
|
stdin: createReadableTextStream("{}"),
|
|
stdout,
|
|
stderr,
|
|
callGateway: callGateway as never,
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.text()).toBe("");
|
|
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",
|
|
generation: "generation-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", generation: "generation-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");
|
|
});
|
|
|
|
it("keeps before_agent_finalize 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",
|
|
generation: "generation-1",
|
|
event: "before_agent_finalize",
|
|
},
|
|
{
|
|
stdin: createReadableTextStream("{}"),
|
|
stdout,
|
|
stderr,
|
|
callGateway: callGateway as never,
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.text()).toBe("");
|
|
expect(stderr.text()).toContain("native hook relay unavailable");
|
|
});
|
|
});
|
|
|
|
function createHeldOpenTextStream(text: string): PassThrough {
|
|
const stream = new PassThrough();
|
|
stream.write(text);
|
|
return stream;
|
|
}
|