feat: add OpenClaw adapter type
Introduce openclaw adapter package with server execution, CLI stream formatting, and UI config fields. Register the adapter across CLI, server, and UI registries. Add adapter label in all relevant pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
packages/adapters/openclaw/src/cli/format-event.ts
Normal file
18
packages/adapters/openclaw/src/cli/format-event.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
export function printOpenClawStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
if (!debug) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("[openclaw]")) {
|
||||
console.log(pc.cyan(line));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
1
packages/adapters/openclaw/src/cli/index.ts
Normal file
1
packages/adapters/openclaw/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { printOpenClawStreamEvent } from "./format-event.js";
|
||||
27
packages/adapters/openclaw/src/index.ts
Normal file
27
packages/adapters/openclaw/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const type = "openclaw";
|
||||
export const label = "OpenClaw";
|
||||
|
||||
export const models: { id: string; label: string }[] = [];
|
||||
|
||||
export const agentConfigurationDoc = `# openclaw agent configuration
|
||||
|
||||
Adapter: openclaw
|
||||
|
||||
Use when:
|
||||
- You run an OpenClaw agent remotely and wake it via webhook.
|
||||
- You want Paperclip heartbeat/task events delivered over HTTP.
|
||||
|
||||
Don't use when:
|
||||
- You need local CLI execution inside Paperclip (use claude_local/codex_local/process).
|
||||
- The OpenClaw endpoint is not reachable from the Paperclip server.
|
||||
|
||||
Core fields:
|
||||
- url (string, required): OpenClaw webhook endpoint URL
|
||||
- method (string, optional): HTTP method, default POST
|
||||
- headers (object, optional): extra HTTP headers for webhook calls
|
||||
- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth
|
||||
- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): request timeout in seconds (default 30)
|
||||
`;
|
||||
144
packages/adapters/openclaw/src/server/execute.ts
Normal file
144
packages/adapters/openclaw/src/server/execute.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
|
||||
import { asNumber, asString, parseObject } from "@paperclip/adapter-utils/server-utils";
|
||||
import { parseOpenClawResponse } from "./parse.js";
|
||||
|
||||
function nonEmpty(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { config, runId, agent, context, onLog, onMeta } = ctx;
|
||||
const url = asString(config.url, "").trim();
|
||||
if (!url) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: "OpenClaw adapter missing url",
|
||||
errorCode: "openclaw_url_missing",
|
||||
};
|
||||
}
|
||||
|
||||
const method = asString(config.method, "POST").trim().toUpperCase() || "POST";
|
||||
const timeoutSec = Math.max(1, asNumber(config.timeoutSec, 30));
|
||||
const headersConfig = parseObject(config.headers) as Record<string, unknown>;
|
||||
const payloadTemplate = parseObject(config.payloadTemplate);
|
||||
const webhookAuthHeader = nonEmpty(config.webhookAuthHeader);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
for (const [key, value] of Object.entries(headersConfig)) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
if (webhookAuthHeader && !headers.authorization && !headers.Authorization) {
|
||||
headers.authorization = webhookAuthHeader;
|
||||
}
|
||||
|
||||
const wakePayload = {
|
||||
runId,
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId),
|
||||
issueId: nonEmpty(context.issueId),
|
||||
wakeReason: nonEmpty(context.wakeReason),
|
||||
wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId),
|
||||
approvalId: nonEmpty(context.approvalId),
|
||||
approvalStatus: nonEmpty(context.approvalStatus),
|
||||
issueIds: Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [],
|
||||
};
|
||||
|
||||
const body = {
|
||||
...payloadTemplate,
|
||||
paperclip: {
|
||||
...wakePayload,
|
||||
context,
|
||||
},
|
||||
};
|
||||
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "openclaw",
|
||||
command: "webhook",
|
||||
commandArgs: [method, url],
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
await onLog("stdout", `[openclaw] invoking ${method} ${url}\n`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutSec * 1000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
if (responseText.trim().length > 0) {
|
||||
await onLog("stdout", `[openclaw] response (${response.status}) ${responseText.slice(0, 2000)}\n`);
|
||||
} else {
|
||||
await onLog("stdout", `[openclaw] response (${response.status}) <empty>\n`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `OpenClaw webhook failed with status ${response.status}`,
|
||||
errorCode: "openclaw_http_error",
|
||||
resultJson: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: parseOpenClawResponse(responseText) ?? responseText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
provider: "openclaw",
|
||||
model: null,
|
||||
summary: `OpenClaw webhook ${method} ${url}`,
|
||||
resultJson: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: parseOpenClawResponse(responseText) ?? responseText,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
await onLog("stderr", `[openclaw] request timed out after ${timeoutSec}s\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
errorCode: "timeout",
|
||||
};
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[openclaw] request failed: ${message}\n`);
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: message,
|
||||
errorCode: "openclaw_request_failed",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
3
packages/adapters/openclaw/src/server/index.ts
Normal file
3
packages/adapters/openclaw/src/server/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js";
|
||||
15
packages/adapters/openclaw/src/server/parse.ts
Normal file
15
packages/adapters/openclaw/src/server/parse.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function parseOpenClawResponse(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenClawUnknownSessionError(_text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
122
packages/adapters/openclaw/src/server/test.ts
Normal file
122
packages/adapters/openclaw/src/server/test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclip/adapter-utils";
|
||||
import { asString, parseObject } from "@paperclip/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";
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const urlValue = asString(config.url, "");
|
||||
|
||||
if (!urlValue) {
|
||||
checks.push({
|
||||
code: "openclaw_url_missing",
|
||||
level: "error",
|
||||
message: "OpenClaw adapter requires a webhook URL.",
|
||||
hint: "Set adapterConfig.url to your OpenClaw webhook 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).",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 webhook 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(),
|
||||
};
|
||||
}
|
||||
9
packages/adapters/openclaw/src/ui/build-config.ts
Normal file
9
packages/adapters/openclaw/src/ui/build-config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { CreateConfigValues } from "@paperclip/adapter-utils";
|
||||
|
||||
export function buildOpenClawConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.url) ac.url = v.url;
|
||||
ac.method = "POST";
|
||||
ac.timeoutSec = 30;
|
||||
return ac;
|
||||
}
|
||||
2
packages/adapters/openclaw/src/ui/index.ts
Normal file
2
packages/adapters/openclaw/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseOpenClawStdoutLine } from "./parse-stdout.js";
|
||||
export { buildOpenClawConfig } from "./build-config.js";
|
||||
5
packages/adapters/openclaw/src/ui/parse-stdout.ts
Normal file
5
packages/adapters/openclaw/src/ui/parse-stdout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { TranscriptEntry } from "@paperclip/adapter-utils";
|
||||
|
||||
export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
Reference in New Issue
Block a user