feat: add openclaw_gateway adapter
New adapter type for invoking OpenClaw agents via the gateway protocol. Registers in server, CLI, and UI adapter registries. Adds onboarding wizard support with gateway URL field and e2e smoke test script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
221
ui/src/adapters/openclaw-gateway/config-fields.tsx
Normal file
221
ui/src/adapters/openclaw-gateway/config-fields.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
|
||||
function SecretField({
|
||||
label,
|
||||
value,
|
||||
onCommit,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<DraftInput
|
||||
value={value}
|
||||
onCommit={onCommit}
|
||||
immediate
|
||||
type={visible ? "text" : "password"}
|
||||
className={inputClass + " pl-8"}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function parseScopes(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === "string").join(", ");
|
||||
}
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function OpenClawGatewayConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const configuredHeaders =
|
||||
config.headers && typeof config.headers === "object" && !Array.isArray(config.headers)
|
||||
? (config.headers as Record<string, unknown>)
|
||||
: {};
|
||||
const effectiveHeaders =
|
||||
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
|
||||
|
||||
const effectiveGatewayToken = typeof effectiveHeaders["x-openclaw-token"] === "string"
|
||||
? String(effectiveHeaders["x-openclaw-token"])
|
||||
: typeof effectiveHeaders["x-openclaw-auth"] === "string"
|
||||
? String(effectiveHeaders["x-openclaw-auth"])
|
||||
: "";
|
||||
|
||||
const commitGatewayToken = (rawValue: string) => {
|
||||
const nextValue = rawValue.trim();
|
||||
const nextHeaders: Record<string, unknown> = { ...effectiveHeaders };
|
||||
if (nextValue) {
|
||||
nextHeaders["x-openclaw-token"] = nextValue;
|
||||
delete nextHeaders["x-openclaw-auth"];
|
||||
} else {
|
||||
delete nextHeaders["x-openclaw-token"];
|
||||
delete nextHeaders["x-openclaw-auth"];
|
||||
}
|
||||
mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined);
|
||||
};
|
||||
|
||||
const sessionStrategy = eff(
|
||||
"adapterConfig",
|
||||
"sessionKeyStrategy",
|
||||
String(config.sessionKeyStrategy ?? "fixed"),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Gateway URL" hint={help.webhookUrl}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.url
|
||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ url: v })
|
||||
: mark("adapterConfig", "url", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="ws://127.0.0.1:18789"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isCreate && (
|
||||
<>
|
||||
<Field label="Paperclip API URL override">
|
||||
<DraftInput
|
||||
value={
|
||||
eff(
|
||||
"adapterConfig",
|
||||
"paperclipApiUrl",
|
||||
String(config.paperclipApiUrl ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://paperclip.example"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Session strategy">
|
||||
<select
|
||||
value={sessionStrategy}
|
||||
onChange={(e) => mark("adapterConfig", "sessionKeyStrategy", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="issue">Per issue</option>
|
||||
<option value="run">Per run</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{sessionStrategy === "fixed" && (
|
||||
<Field label="Session key">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))}
|
||||
onCommit={(v) => mark("adapterConfig", "sessionKey", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="paperclip"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<SecretField
|
||||
label="Gateway auth token (x-openclaw-token)"
|
||||
value={effectiveGatewayToken}
|
||||
onCommit={commitGatewayToken}
|
||||
placeholder="OpenClaw gateway token"
|
||||
/>
|
||||
|
||||
<Field label="Role">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "role", String(config.role ?? "operator"))}
|
||||
onCommit={(v) => mark("adapterConfig", "role", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="operator"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Scopes (comma-separated)">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "scopes", parseScopes(config.scopes ?? ["operator.admin"]))}
|
||||
onCommit={(v) => {
|
||||
const parsed = v
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined);
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="operator.admin"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Wait timeout (ms)">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "waitTimeoutMs", String(config.waitTimeoutMs ?? "120000"))}
|
||||
onCommit={(v) => {
|
||||
const parsed = Number.parseInt(v.trim(), 10);
|
||||
mark(
|
||||
"adapterConfig",
|
||||
"waitTimeoutMs",
|
||||
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined,
|
||||
);
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="120000"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Disable device auth">
|
||||
<select
|
||||
value={String(eff("adapterConfig", "disableDeviceAuth", Boolean(config.disableDeviceAuth ?? false)))}
|
||||
onChange={(e) => mark("adapterConfig", "disableDeviceAuth", e.target.value === "true")}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="false">No (recommended)</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/openclaw-gateway/index.ts
Normal file
12
ui/src/adapters/openclaw-gateway/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import { buildOpenClawGatewayConfig } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import { OpenClawGatewayConfigFields } from "./config-fields";
|
||||
|
||||
export const openClawGatewayUIAdapter: UIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
label: "OpenClaw Gateway",
|
||||
parseStdoutLine: parseOpenClawGatewayStdoutLine,
|
||||
ConfigFields: OpenClawGatewayConfigFields,
|
||||
buildAdapterConfig: buildOpenClawGatewayConfig,
|
||||
};
|
||||
@@ -4,11 +4,21 @@ import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { openClawUIAdapter } from "./openclaw";
|
||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
|
||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
||||
[
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
openClawUIAdapter,
|
||||
openClawGatewayUIAdapter,
|
||||
processUIAdapter,
|
||||
httpUIAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getUIAdapter(type: string): UIAdapterModule {
|
||||
|
||||
@@ -19,6 +19,7 @@ const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
@@ -157,7 +157,7 @@ function parseStdoutChunk(
|
||||
if (!trimmed) continue;
|
||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
||||
if (parsed.length === 0) {
|
||||
if (run.adapterType === "openclaw") {
|
||||
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
|
||||
continue;
|
||||
}
|
||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
||||
|
||||
@@ -56,7 +56,8 @@ type AdapterType =
|
||||
| "cursor"
|
||||
| "process"
|
||||
| "http"
|
||||
| "openclaw";
|
||||
| "openclaw"
|
||||
| "openclaw_gateway";
|
||||
|
||||
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md)
|
||||
|
||||
@@ -672,6 +673,12 @@ export function OnboardingWizard() {
|
||||
desc: "Notify OpenClaw webhook",
|
||||
comingSoon: true
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol"
|
||||
},
|
||||
{
|
||||
value: "cursor" as const,
|
||||
label: "Cursor",
|
||||
@@ -973,14 +980,14 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(adapterType === "http" || adapterType === "openclaw") && (
|
||||
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Webhook URL
|
||||
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="https://..."
|
||||
placeholder={adapterType === "openclaw_gateway" ? "ws://127.0.0.1:18789" : "https://..."}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
||||
role: "Organizational role. Determines position and capabilities.",
|
||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.",
|
||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||
model: "Override the default model used by the adapter.",
|
||||
@@ -54,6 +54,7 @@ export const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
@@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
@@ -121,6 +121,7 @@ const adapterLabels: Record<string, string> = {
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user