Add OpenClawEndpointKind type to distinguish between /hooks/wake, /hooks/agent, open_responses, and generic endpoints. Build appropriate payloads per endpoint kind with optional sessionKey inclusion. Refactor webhook execution to use endpoint-aware payload construction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
8.1 KiB
TypeScript
248 lines
8.1 KiB
TypeScript
import type {
|
|
AdapterEnvironmentCheck,
|
|
AdapterEnvironmentTestContext,
|
|
AdapterEnvironmentTestResult,
|
|
} from "@paperclipai/adapter-utils";
|
|
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
|
|
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
|
if (checks.some((check) => check.level === "error")) return "fail";
|
|
if (checks.some((check) => check.level === "warn")) return "warn";
|
|
return "pass";
|
|
}
|
|
|
|
function isLoopbackHost(hostname: string): boolean {
|
|
const value = hostname.trim().toLowerCase();
|
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
|
}
|
|
|
|
function normalizeHostname(value: string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith("[")) {
|
|
const end = trimmed.indexOf("]");
|
|
return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
|
|
}
|
|
const firstColon = trimmed.indexOf(":");
|
|
if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase();
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
function isWakePath(pathname: string): boolean {
|
|
const value = pathname.trim().toLowerCase();
|
|
return value === "/hooks/wake" || value.endsWith("/hooks/wake");
|
|
}
|
|
|
|
function isHooksPath(pathname: string): boolean {
|
|
const value = pathname.trim().toLowerCase();
|
|
return (
|
|
value === "/hooks" ||
|
|
value.startsWith("/hooks/") ||
|
|
value.endsWith("/hooks") ||
|
|
value.includes("/hooks/")
|
|
);
|
|
}
|
|
|
|
function normalizeTransport(value: unknown): "sse" | "webhook" | null {
|
|
const normalized = asString(value, "sse").trim().toLowerCase();
|
|
if (!normalized || normalized === "sse") return "sse";
|
|
if (normalized === "webhook") return "webhook";
|
|
return null;
|
|
}
|
|
|
|
function pushDeploymentDiagnostics(
|
|
checks: AdapterEnvironmentCheck[],
|
|
ctx: AdapterEnvironmentTestContext,
|
|
endpointUrl: URL | null,
|
|
) {
|
|
const mode = ctx.deployment?.mode;
|
|
const exposure = ctx.deployment?.exposure;
|
|
const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null);
|
|
const allowSet = new Set(
|
|
(ctx.deployment?.allowedHostnames ?? [])
|
|
.map((entry) => normalizeHostname(entry))
|
|
.filter((entry): entry is string => Boolean(entry)),
|
|
);
|
|
const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null;
|
|
|
|
if (!mode) return;
|
|
|
|
checks.push({
|
|
code: "openclaw_deployment_context",
|
|
level: "info",
|
|
message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`,
|
|
});
|
|
|
|
if (mode === "authenticated" && exposure === "private") {
|
|
if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
|
|
checks.push({
|
|
code: "openclaw_private_bind_hostname_not_allowed",
|
|
level: "warn",
|
|
message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`,
|
|
hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`,
|
|
});
|
|
}
|
|
|
|
if (!bindHost || isLoopbackHost(bindHost)) {
|
|
checks.push({
|
|
code: "openclaw_private_bind_loopback",
|
|
level: "warn",
|
|
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
|
hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.",
|
|
});
|
|
}
|
|
|
|
if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) {
|
|
checks.push({
|
|
code: "openclaw_private_no_allowed_hostnames",
|
|
level: "warn",
|
|
message: "No explicit allowed hostnames are configured for authenticated/private mode.",
|
|
hint: "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs on another machine.",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") {
|
|
checks.push({
|
|
code: "openclaw_public_http_endpoint",
|
|
level: "warn",
|
|
message: "OpenClaw endpoint uses HTTP in authenticated/public mode.",
|
|
hint: "Prefer HTTPS for public deployments.",
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function testEnvironment(
|
|
ctx: AdapterEnvironmentTestContext,
|
|
): Promise<AdapterEnvironmentTestResult> {
|
|
const checks: AdapterEnvironmentCheck[] = [];
|
|
const config = parseObject(ctx.config);
|
|
const urlValue = asString(config.url, "");
|
|
const streamTransportValue = config.streamTransport ?? config.transport;
|
|
const streamTransport = normalizeTransport(streamTransportValue);
|
|
|
|
if (!urlValue) {
|
|
checks.push({
|
|
code: "openclaw_url_missing",
|
|
level: "error",
|
|
message: "OpenClaw adapter requires an endpoint URL.",
|
|
hint: "Set adapterConfig.url to your OpenClaw transport endpoint.",
|
|
});
|
|
return {
|
|
adapterType: ctx.adapterType,
|
|
status: summarizeStatus(checks),
|
|
checks,
|
|
testedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
let url: URL | null = null;
|
|
try {
|
|
url = new URL(urlValue);
|
|
} catch {
|
|
checks.push({
|
|
code: "openclaw_url_invalid",
|
|
level: "error",
|
|
message: `Invalid URL: ${urlValue}`,
|
|
});
|
|
}
|
|
|
|
if (url && url.protocol !== "http:" && url.protocol !== "https:") {
|
|
checks.push({
|
|
code: "openclaw_url_protocol_invalid",
|
|
level: "error",
|
|
message: `Unsupported URL protocol: ${url.protocol}`,
|
|
hint: "Use an http:// or https:// endpoint.",
|
|
});
|
|
}
|
|
|
|
if (url) {
|
|
checks.push({
|
|
code: "openclaw_url_valid",
|
|
level: "info",
|
|
message: `Configured endpoint: ${url.toString()}`,
|
|
});
|
|
|
|
if (isLoopbackHost(url.hostname)) {
|
|
checks.push({
|
|
code: "openclaw_loopback_endpoint",
|
|
level: "warn",
|
|
message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.",
|
|
hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).",
|
|
});
|
|
}
|
|
|
|
if (streamTransport === "sse" && (isWakePath(url.pathname) || isHooksPath(url.pathname))) {
|
|
checks.push({
|
|
code: "openclaw_wake_endpoint_incompatible",
|
|
level: "error",
|
|
message: "Endpoint targets /hooks/*, which is not stream-capable for SSE transport.",
|
|
hint: "Use webhook transport for /hooks/* endpoints.",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!streamTransport) {
|
|
checks.push({
|
|
code: "openclaw_stream_transport_unsupported",
|
|
level: "error",
|
|
message: `Unsupported streamTransport: ${String(streamTransportValue)}`,
|
|
hint: "Use streamTransport=sse or streamTransport=webhook.",
|
|
});
|
|
} else {
|
|
checks.push({
|
|
code: "openclaw_stream_transport_configured",
|
|
level: "info",
|
|
message: `Configured stream transport: ${streamTransport}`,
|
|
});
|
|
}
|
|
|
|
pushDeploymentDiagnostics(checks, ctx, url);
|
|
|
|
const method = asString(config.method, "POST").trim().toUpperCase() || "POST";
|
|
checks.push({
|
|
code: "openclaw_method_configured",
|
|
level: "info",
|
|
message: `Configured method: ${method}`,
|
|
});
|
|
|
|
if (url && (url.protocol === "http:" || url.protocol === "https:")) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
try {
|
|
const response = await fetch(url, { method: "HEAD", signal: controller.signal });
|
|
if (!response.ok && response.status !== 405 && response.status !== 501) {
|
|
checks.push({
|
|
code: "openclaw_endpoint_probe_unexpected_status",
|
|
level: "warn",
|
|
message: `Endpoint probe returned HTTP ${response.status}.`,
|
|
hint: "Verify OpenClaw endpoint reachability and auth/network settings.",
|
|
});
|
|
} else {
|
|
checks.push({
|
|
code: "openclaw_endpoint_probe_ok",
|
|
level: "info",
|
|
message: "Endpoint responded to a HEAD probe.",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
checks.push({
|
|
code: "openclaw_endpoint_probe_failed",
|
|
level: "warn",
|
|
message: err instanceof Error ? err.message : "Endpoint probe failed",
|
|
hint: "This may be expected in restricted networks; validate from the Paperclip server host.",
|
|
});
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
return {
|
|
adapterType: ctx.adapterType,
|
|
status: summarizeStatus(checks),
|
|
checks,
|
|
testedAt: new Date().toISOString(),
|
|
};
|
|
}
|