mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
318 lines
9.1 KiB
TypeScript
318 lines
9.1 KiB
TypeScript
|
|
import type {
|
||
|
|
AdapterEnvironmentCheck,
|
||
|
|
AdapterEnvironmentTestContext,
|
||
|
|
AdapterEnvironmentTestResult,
|
||
|
|
} from "@paperclipai/adapter-utils";
|
||
|
|
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||
|
|
import { randomUUID } from "node:crypto";
|
||
|
|
import { WebSocket } from "ws";
|
||
|
|
|
||
|
|
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 nonEmpty(value: unknown): string | null {
|
||
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isLoopbackHost(hostname: string): boolean {
|
||
|
|
const value = hostname.trim().toLowerCase();
|
||
|
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||
|
|
}
|
||
|
|
|
||
|
|
function toStringRecord(value: unknown): Record<string, string> {
|
||
|
|
const parsed = parseObject(value);
|
||
|
|
const out: Record<string, string> = {};
|
||
|
|
for (const [key, entry] of Object.entries(parsed)) {
|
||
|
|
if (typeof entry === "string") out[key] = entry;
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function toStringArray(value: unknown): string[] {
|
||
|
|
if (Array.isArray(value)) {
|
||
|
|
return value
|
||
|
|
.filter((entry): entry is string => typeof entry === "string")
|
||
|
|
.map((entry) => entry.trim())
|
||
|
|
.filter(Boolean);
|
||
|
|
}
|
||
|
|
if (typeof value === "string") {
|
||
|
|
return value
|
||
|
|
.split(",")
|
||
|
|
.map((entry) => entry.trim())
|
||
|
|
.filter(Boolean);
|
||
|
|
}
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
||
|
|
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
||
|
|
return match ? match[1] : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function tokenFromAuthHeader(rawHeader: string | null): string | null {
|
||
|
|
if (!rawHeader) return null;
|
||
|
|
const trimmed = rawHeader.trim();
|
||
|
|
if (!trimmed) return null;
|
||
|
|
const match = trimmed.match(/^bearer\s+(.+)$/i);
|
||
|
|
return match ? nonEmpty(match[1]) : trimmed;
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
|
||
|
|
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
|
||
|
|
if (explicit) return explicit;
|
||
|
|
|
||
|
|
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
|
||
|
|
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
|
||
|
|
|
||
|
|
const authHeader =
|
||
|
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
||
|
|
headerMapGetIgnoreCase(headers, "authorization");
|
||
|
|
return tokenFromAuthHeader(authHeader);
|
||
|
|
}
|
||
|
|
|
||
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||
|
|
return value as Record<string, unknown>;
|
||
|
|
}
|
||
|
|
|
||
|
|
function rawDataToString(data: unknown): string {
|
||
|
|
if (typeof data === "string") return data;
|
||
|
|
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
||
|
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
||
|
|
if (Array.isArray(data)) {
|
||
|
|
return Buffer.concat(
|
||
|
|
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
|
||
|
|
).toString("utf8");
|
||
|
|
}
|
||
|
|
return String(data ?? "");
|
||
|
|
}
|
||
|
|
|
||
|
|
async function probeGateway(input: {
|
||
|
|
url: string;
|
||
|
|
headers: Record<string, string>;
|
||
|
|
authToken: string | null;
|
||
|
|
role: string;
|
||
|
|
scopes: string[];
|
||
|
|
timeoutMs: number;
|
||
|
|
}): Promise<"ok" | "challenge_only" | "failed"> {
|
||
|
|
return await new Promise((resolve) => {
|
||
|
|
const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 });
|
||
|
|
const timeout = setTimeout(() => {
|
||
|
|
try {
|
||
|
|
ws.close();
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
resolve("failed");
|
||
|
|
}, input.timeoutMs);
|
||
|
|
|
||
|
|
let completed = false;
|
||
|
|
|
||
|
|
const finish = (status: "ok" | "challenge_only" | "failed") => {
|
||
|
|
if (completed) return;
|
||
|
|
completed = true;
|
||
|
|
clearTimeout(timeout);
|
||
|
|
try {
|
||
|
|
ws.close();
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
resolve(status);
|
||
|
|
};
|
||
|
|
|
||
|
|
ws.on("message", (raw) => {
|
||
|
|
let parsed: unknown;
|
||
|
|
try {
|
||
|
|
parsed = JSON.parse(rawDataToString(raw));
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const event = asRecord(parsed);
|
||
|
|
if (event?.type === "event" && event.event === "connect.challenge") {
|
||
|
|
const nonce = nonEmpty(asRecord(event.payload)?.nonce);
|
||
|
|
if (!nonce) {
|
||
|
|
finish("failed");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const connectId = randomUUID();
|
||
|
|
ws.send(
|
||
|
|
JSON.stringify({
|
||
|
|
type: "req",
|
||
|
|
id: connectId,
|
||
|
|
method: "connect",
|
||
|
|
params: {
|
||
|
|
minProtocol: 3,
|
||
|
|
maxProtocol: 3,
|
||
|
|
client: {
|
||
|
|
id: "gateway-client",
|
||
|
|
version: "paperclip-probe",
|
||
|
|
platform: process.platform,
|
||
|
|
mode: "probe",
|
||
|
|
},
|
||
|
|
role: input.role,
|
||
|
|
scopes: input.scopes,
|
||
|
|
...(input.authToken
|
||
|
|
? {
|
||
|
|
auth: {
|
||
|
|
token: input.authToken,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
: {}),
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (event?.type === "res") {
|
||
|
|
if (event.ok === true) {
|
||
|
|
finish("ok");
|
||
|
|
} else {
|
||
|
|
finish("challenge_only");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
ws.on("error", () => {
|
||
|
|
finish("failed");
|
||
|
|
});
|
||
|
|
|
||
|
|
ws.on("close", () => {
|
||
|
|
if (!completed) finish("failed");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function testEnvironment(
|
||
|
|
ctx: AdapterEnvironmentTestContext,
|
||
|
|
): Promise<AdapterEnvironmentTestResult> {
|
||
|
|
const checks: AdapterEnvironmentCheck[] = [];
|
||
|
|
const config = parseObject(ctx.config);
|
||
|
|
const urlValue = asString(config.url, "").trim();
|
||
|
|
|
||
|
|
if (!urlValue) {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_url_missing",
|
||
|
|
level: "error",
|
||
|
|
message: "OpenClaw gateway adapter requires a WebSocket URL.",
|
||
|
|
hint: "Set adapterConfig.url to ws://host:port (or wss://).",
|
||
|
|
});
|
||
|
|
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_gateway_url_invalid",
|
||
|
|
level: "error",
|
||
|
|
message: `Invalid URL: ${urlValue}`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (url && url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_url_protocol_invalid",
|
||
|
|
level: "error",
|
||
|
|
message: `Unsupported URL protocol: ${url.protocol}`,
|
||
|
|
hint: "Use ws:// or wss://.",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (url) {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_url_valid",
|
||
|
|
level: "info",
|
||
|
|
message: `Configured gateway URL: ${url.toString()}`,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_plaintext_remote_ws",
|
||
|
|
level: "warn",
|
||
|
|
message: "Gateway URL uses plaintext ws:// on a non-loopback host.",
|
||
|
|
hint: "Prefer wss:// for remote gateways.",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const headers = toStringRecord(config.headers);
|
||
|
|
const authToken = resolveAuthToken(config, headers);
|
||
|
|
const password = nonEmpty(config.password);
|
||
|
|
const role = nonEmpty(config.role) ?? "operator";
|
||
|
|
const scopes = toStringArray(config.scopes);
|
||
|
|
|
||
|
|
if (authToken || password) {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_auth_present",
|
||
|
|
level: "info",
|
||
|
|
message: "Gateway credentials are configured.",
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_auth_missing",
|
||
|
|
level: "warn",
|
||
|
|
message: "No gateway credentials detected in adapter config.",
|
||
|
|
hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (url && (url.protocol === "ws:" || url.protocol === "wss:")) {
|
||
|
|
try {
|
||
|
|
const probeResult = await probeGateway({
|
||
|
|
url: url.toString(),
|
||
|
|
headers,
|
||
|
|
authToken,
|
||
|
|
role,
|
||
|
|
scopes: scopes.length > 0 ? scopes : ["operator.admin"],
|
||
|
|
timeoutMs: 3_000,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (probeResult === "ok") {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_probe_ok",
|
||
|
|
level: "info",
|
||
|
|
message: "Gateway connect probe succeeded.",
|
||
|
|
});
|
||
|
|
} else if (probeResult === "challenge_only") {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_probe_challenge_only",
|
||
|
|
level: "warn",
|
||
|
|
message: "Gateway challenge was received, but connect probe was rejected.",
|
||
|
|
hint: "Check gateway credentials, scopes, role, and device-auth requirements.",
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_probe_failed",
|
||
|
|
level: "warn",
|
||
|
|
message: "Gateway probe failed.",
|
||
|
|
hint: "Verify network reachability and gateway URL from the Paperclip server host.",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
checks.push({
|
||
|
|
code: "openclaw_gateway_probe_error",
|
||
|
|
level: "warn",
|
||
|
|
message: err instanceof Error ? err.message : "Gateway probe failed",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
adapterType: ctx.adapterType,
|
||
|
|
status: summarizeStatus(checks),
|
||
|
|
checks,
|
||
|
|
testedAt: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
}
|