mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 06:39:35 +00:00
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
// CLI adapter for invoking native provider hooks through direct relay or gateway fallback.
|
|
import {
|
|
invokeNativeHookRelayBridge,
|
|
isNativeHookRelayBridgeStaleRegistrationError,
|
|
renderNativeHookRelayUnavailableResponse,
|
|
type NativeHookRelayProcessResponse,
|
|
} from "../agents/harness/native-hook-relay.js";
|
|
import { callGateway } from "../gateway/call.js";
|
|
import { ADMIN_SCOPE } from "../gateway/method-scopes.js";
|
|
import { setSafeTimeout } from "../utils/timer-delay.js";
|
|
import { parseTimeoutMsWithFallback } from "./parse-timeout.js";
|
|
|
|
const MAX_NATIVE_HOOK_STDIN_BYTES = 1024 * 1024;
|
|
|
|
/** User-facing flags for the native hook relay command. */
|
|
export type NativeHookRelayCliOptions = {
|
|
provider?: string;
|
|
relayId?: string;
|
|
generation?: string;
|
|
event?: string;
|
|
preToolUseUnavailable?: string;
|
|
timeout?: string;
|
|
};
|
|
|
|
type NativeHookRelayCliDeps = {
|
|
stdin?: NodeJS.ReadableStream;
|
|
stdout?: NodeJS.WritableStream;
|
|
stderr?: NodeJS.WritableStream;
|
|
invokeBridge?: typeof invokeNativeHookRelayBridge;
|
|
callGateway?: typeof callGateway;
|
|
};
|
|
|
|
type NativeHookRelayDeadline = {
|
|
expiresAtMs: number;
|
|
signal: AbortSignal;
|
|
timeoutMs: number;
|
|
dispose: () => void;
|
|
};
|
|
|
|
class NativeHookRelayDeadlineError extends Error {
|
|
constructor(timeoutMs: number) {
|
|
super(`native hook relay timed out after ${timeoutMs}ms`);
|
|
this.name = "NativeHookRelayDeadlineError";
|
|
}
|
|
}
|
|
|
|
/** Run one native hook relay invocation from stdin JSON to stdout/stderr response streams. */
|
|
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 invokeBridge = deps.invokeBridge ?? invokeNativeHookRelayBridge;
|
|
const callGatewayFn = deps.callGateway ?? callGateway;
|
|
const provider = readRequiredOption(opts.provider, "provider");
|
|
const relayId = readRequiredOption(opts.relayId, "relay-id");
|
|
const generation = opts.generation?.trim() || undefined;
|
|
const event = readRequiredOption(opts.event, "event");
|
|
let timeoutMs: number;
|
|
try {
|
|
timeoutMs = parseTimeoutMsWithFallback(opts.timeout, 5_000);
|
|
} catch (error) {
|
|
writeText(stderr, formatRelayCliError("invalid native hook timeout", error));
|
|
return 1;
|
|
}
|
|
|
|
const deadline = createNativeHookRelayDeadline(timeoutMs);
|
|
try {
|
|
let rawPayload: unknown;
|
|
try {
|
|
const rawInput = await readStreamText(stdin, MAX_NATIVE_HOOK_STDIN_BYTES, deadline);
|
|
rawPayload = rawInput.trim() ? JSON.parse(rawInput) : null;
|
|
} catch (error) {
|
|
if (isNativeHookRelayDeadlineError(error)) {
|
|
return writeNativeHookRelayDeadlineResponse({
|
|
stdout,
|
|
stderr,
|
|
opts,
|
|
provider,
|
|
event,
|
|
error,
|
|
});
|
|
}
|
|
writeText(stderr, formatRelayCliError("failed to read native hook input", error));
|
|
return 1;
|
|
}
|
|
|
|
try {
|
|
const remainingMs = remainingNativeHookRelayDeadlineMs(deadline);
|
|
const response = await withNativeHookRelayDeadline(
|
|
deadline,
|
|
invokeBridge({
|
|
provider,
|
|
relayId,
|
|
generation,
|
|
event,
|
|
rawPayload,
|
|
registrationTimeoutMs: Math.min(100, remainingMs),
|
|
timeoutMs: remainingMs,
|
|
}),
|
|
);
|
|
writeText(stdout, response.stdout);
|
|
writeText(stderr, response.stderr);
|
|
return response.exitCode;
|
|
} catch (error) {
|
|
if (isNativeHookRelayDeadlineError(error)) {
|
|
return writeNativeHookRelayDeadlineResponse({
|
|
stdout,
|
|
stderr,
|
|
opts,
|
|
provider,
|
|
event,
|
|
error,
|
|
});
|
|
}
|
|
if (isNativeHookRelayBridgeStaleRegistrationError(error)) {
|
|
writeText(stderr, formatRelayCliError("native hook relay unavailable", error));
|
|
return writeNativeHookRelayUnavailableResponse({ stdout, stderr, opts, provider, event });
|
|
}
|
|
// Fall through to the gateway path for embedded/local gateway cases and
|
|
// older registrations that predate the direct relay bridge.
|
|
}
|
|
|
|
try {
|
|
const response = await withNativeHookRelayDeadline(
|
|
deadline,
|
|
callGatewayFn<NativeHookRelayProcessResponse>({
|
|
method: "nativeHook.invoke",
|
|
params: { provider, relayId, generation, event, rawPayload },
|
|
timeoutMs: remainingNativeHookRelayDeadlineMs(deadline),
|
|
signal: deadline.signal,
|
|
scopes: [ADMIN_SCOPE],
|
|
}),
|
|
);
|
|
writeText(stdout, response.stdout);
|
|
writeText(stderr, response.stderr);
|
|
return response.exitCode;
|
|
} catch (error) {
|
|
if (isNativeHookRelayDeadlineError(error)) {
|
|
return writeNativeHookRelayDeadlineResponse({
|
|
stdout,
|
|
stderr,
|
|
opts,
|
|
provider,
|
|
event,
|
|
error,
|
|
});
|
|
}
|
|
writeText(stderr, formatRelayCliError("native hook relay unavailable", error));
|
|
return writeNativeHookRelayUnavailableResponse({ stdout, stderr, opts, provider, event });
|
|
}
|
|
} finally {
|
|
deadline.dispose();
|
|
}
|
|
}
|
|
|
|
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,
|
|
deadline: NativeHookRelayDeadline,
|
|
): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
const abortRead = () => {
|
|
destroyReadableStream(stream, createNativeHookRelayDeadlineError(deadline));
|
|
};
|
|
deadline.signal.addEventListener("abort", abortRead, { once: true });
|
|
try {
|
|
throwIfNativeHookRelayDeadlineExpired(deadline);
|
|
for await (const chunk of stream) {
|
|
throwIfNativeHookRelayDeadlineExpired(deadline);
|
|
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);
|
|
}
|
|
throwIfNativeHookRelayDeadlineExpired(deadline);
|
|
return Buffer.concat(chunks, total).toString("utf8");
|
|
} catch (error) {
|
|
if (isNativeHookRelayDeadlineError(error) || deadline.signal.aborted) {
|
|
throw createNativeHookRelayDeadlineError(deadline);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
deadline.signal.removeEventListener("abort", abortRead);
|
|
}
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
function createNativeHookRelayDeadline(timeoutMs: number): NativeHookRelayDeadline {
|
|
const controller = new AbortController();
|
|
const timer = setSafeTimeout(() => controller.abort(), timeoutMs);
|
|
timer.unref?.();
|
|
return {
|
|
expiresAtMs: Date.now() + timeoutMs,
|
|
signal: controller.signal,
|
|
timeoutMs,
|
|
dispose: () => clearTimeout(timer),
|
|
};
|
|
}
|
|
|
|
function createNativeHookRelayDeadlineError(
|
|
deadline: NativeHookRelayDeadline,
|
|
): NativeHookRelayDeadlineError {
|
|
return new NativeHookRelayDeadlineError(deadline.timeoutMs);
|
|
}
|
|
|
|
function isNativeHookRelayDeadlineError(error: unknown): error is NativeHookRelayDeadlineError {
|
|
return error instanceof Error && error.name === "NativeHookRelayDeadlineError";
|
|
}
|
|
|
|
function remainingNativeHookRelayDeadlineMs(deadline: NativeHookRelayDeadline): number {
|
|
const remainingMs = deadline.expiresAtMs - Date.now();
|
|
if (remainingMs <= 0 || deadline.signal.aborted) {
|
|
throw createNativeHookRelayDeadlineError(deadline);
|
|
}
|
|
return Math.max(1, remainingMs);
|
|
}
|
|
|
|
function throwIfNativeHookRelayDeadlineExpired(deadline: NativeHookRelayDeadline): void {
|
|
void remainingNativeHookRelayDeadlineMs(deadline);
|
|
}
|
|
|
|
function destroyReadableStream(stream: NodeJS.ReadableStream, error: Error): void {
|
|
const destroy = (stream as NodeJS.ReadableStream & { destroy?: (error?: Error) => void }).destroy;
|
|
if (typeof destroy === "function") {
|
|
destroy.call(stream, error);
|
|
return;
|
|
}
|
|
stream.pause();
|
|
}
|
|
|
|
async function withNativeHookRelayDeadline<T>(
|
|
deadline: NativeHookRelayDeadline,
|
|
promise: Promise<T>,
|
|
): Promise<T> {
|
|
throwIfNativeHookRelayDeadlineExpired(deadline);
|
|
return await new Promise<T>((resolve, reject) => {
|
|
const cleanup = () => deadline.signal.removeEventListener("abort", abort);
|
|
const abort = () => {
|
|
cleanup();
|
|
reject(createNativeHookRelayDeadlineError(deadline));
|
|
};
|
|
deadline.signal.addEventListener("abort", abort, { once: true });
|
|
promise.then(
|
|
(value) => {
|
|
cleanup();
|
|
resolve(value);
|
|
},
|
|
(error: unknown) => {
|
|
cleanup();
|
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function writeNativeHookRelayUnavailableResponse(params: {
|
|
stdout: NodeJS.WritableStream;
|
|
stderr: NodeJS.WritableStream;
|
|
opts: NativeHookRelayCliOptions;
|
|
provider: string;
|
|
event: string;
|
|
message?: string;
|
|
}): number {
|
|
const response = renderNativeHookRelayUnavailableResponse({
|
|
provider: params.provider,
|
|
event: params.event,
|
|
preToolUseUnavailable: params.opts.preToolUseUnavailable,
|
|
message: params.message ?? "Native hook relay unavailable",
|
|
});
|
|
writeText(params.stdout, response.stdout);
|
|
writeText(params.stderr, response.stderr);
|
|
return response.exitCode;
|
|
}
|
|
|
|
function writeNativeHookRelayDeadlineResponse(params: {
|
|
stdout: NodeJS.WritableStream;
|
|
stderr: NodeJS.WritableStream;
|
|
opts: NativeHookRelayCliOptions;
|
|
provider: string;
|
|
event: string;
|
|
error: NativeHookRelayDeadlineError;
|
|
}): number {
|
|
writeText(params.stderr, formatRelayCliError("native hook relay timed out", params.error));
|
|
return writeNativeHookRelayUnavailableResponse({
|
|
stdout: params.stdout,
|
|
stderr: params.stderr,
|
|
opts: params.opts,
|
|
provider: params.provider,
|
|
event: params.event,
|
|
message: "Native hook relay timed out",
|
|
});
|
|
}
|