fix(openclaw-gateway): complete and stabilize OpenClaw Gateway integration (#2322)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The `openclaw_gateway` adapter is how operators wire Paperclip agents to an OpenClaw gateway over WebSocket > - The adapter UI previously only exposed a handful of config fields in edit mode; many timeout / auth / session-routing knobs were unreachable through the form > - The serializer also forgot to inject the configured `authToken` into the `x-openclaw-token` header, and the server-side execute path lacked retries on transient gateway errors and an `OPENCLAW_TOKEN` env fallback > - This pull request exposes the full set of config fields in both create and edit modes, fixes the serializer, hardens the server-side execute path, and pins the existing default request timeouts (120s / 120000ms) — see the dedicated commit and the new unit tests > - The benefit is operators can configure and reconfigure an `openclaw_gateway` agent end-to-end through the UI, with no silent change to the defaults documented in the adapter README and `doc/ONBOARDING_AND_TEST_PLAN.md` ## Linked Issues or Issue Description Closes #414 Closes #1901 Closes #2309 ## What Changed - **UI**: Removed the `!isCreate` guard so all `openclaw_gateway` config fields are visible in both create and edit modes (`authToken`, `agentId`, `sessionKeyStrategy`, `sessionKey`, `timeoutSec`, `waitTimeoutMs`, `disableDeviceAuth`, `autoPairOnFirstConnect`, `role`, `scopes`, `paperclipApiUrl`, `headersJson`, `payloadTemplate`, `runtimeServices`). - **Serialization** (`packages/adapters/openclaw-gateway/src/ui/build-config.ts`): inject `authToken` into headers as `x-openclaw-token`; apply safe defaults on create (`timeoutSec=120`, `waitTimeoutMs=120000`, `sessionKeyStrategy="issue"`, `role="operator"`, `scopes=["operator.admin"]`). - **Backend** (`packages/adapters/openclaw-gateway/src/server/execute.ts`): add `OPENCLAW_TOKEN` env-var fallback for `authToken`, retry logic (max 2 retries with backoff for transient gateway errors), session-key prefix `agent:{agentId}:{sessionId}` when `agentId` is configured. - **Defaults restoration** (dedicated commit): an earlier revision of this PR lowered the default request timeouts to `60` / `30000`. The current branch restores the historical `timeoutSec=120` / `waitTimeoutMs=120000` defaults that match the values documented in `packages/adapters/openclaw-gateway/src/index.ts`, `src/server/execute.ts` on master, and the worked example in `doc/ONBOARDING_AND_TEST_PLAN.md`. - **Tests** (new): `packages/adapters/openclaw-gateway/src/ui/build-config.test.ts` pins the documented timeout and identity defaults so the silent-halve regression cannot recur. ## Verification - `pnpm --filter @paperclipai/adapter-openclaw-gateway typecheck` - `pnpm typecheck` (root) - Manual: create a new `openclaw_gateway` agent — all fields visible, defaults populate as documented. - Manual: edit an existing `openclaw_gateway` agent — every field round-trips correctly and saves. - Manual: unset `authToken` in the form and set `OPENCLAW_TOKEN` env var — adapter picks up the env-var fallback. - Manual: simulate a transient gateway error — execute retries up to 2 times with backoff before failing. ## Risks - Low risk. Surface area is one adapter, behind explicit operator configuration. The defaults change in this PR is a restoration of values that already exist on master and in the adapter docs, so no production agent sees a behavioral shift relative to the prior release. Field exposure in edit mode is purely additive — existing values are preserved on save. ## Model Used - Provider/model: Claude (Anthropic) — `claude-opus-4-7` - Mode: standard tool use, no extended thinking - Capability notes: code execution + repository file edits via Claude Code ## Cross-references and status (maintainer) Closes #414 Closes #1901 Closes #2309 ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Bot <bot@paperclip.dev> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Devin Foley <devin@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
@@ -14,6 +14,50 @@ import {
|
||||
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 HeadersJsonTextarea({
|
||||
isCreate,
|
||||
createDraft,
|
||||
onCreateDraftChange,
|
||||
editStringified,
|
||||
onEditCommit,
|
||||
inputClass,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
createDraft: string;
|
||||
onCreateDraftChange: (next: string) => void;
|
||||
editStringified: string;
|
||||
onEditCommit: (next: string) => void;
|
||||
inputClass: string;
|
||||
}) {
|
||||
const [editDraft, setEditDraft] = useState<string>(editStringified);
|
||||
const [lastSyncedFromConfig, setLastSyncedFromConfig] = useState<string>(editStringified);
|
||||
useEffect(() => {
|
||||
if (isCreate) return;
|
||||
if (editStringified !== lastSyncedFromConfig) {
|
||||
setEditDraft(editStringified);
|
||||
setLastSyncedFromConfig(editStringified);
|
||||
}
|
||||
}, [editStringified, isCreate, lastSyncedFromConfig]);
|
||||
const value = isCreate ? createDraft : editDraft;
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (isCreate) {
|
||||
onCreateDraftChange(next);
|
||||
} else {
|
||||
setEditDraft(next);
|
||||
onEditCommit(next);
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
className={inputClass}
|
||||
placeholder='{"x-custom-header": "value"}'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretField({
|
||||
label,
|
||||
value,
|
||||
@@ -124,6 +168,137 @@ export function OpenClawGatewayConfigFields({
|
||||
mark={mark}
|
||||
/>
|
||||
|
||||
{/* Auth and Identity - available in both create and edit modes */}
|
||||
<SecretField
|
||||
label="Gateway auth token"
|
||||
value={
|
||||
isCreate
|
||||
? values!.authToken ?? ""
|
||||
: effectiveGatewayToken
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ authToken: v })
|
||||
: commitGatewayToken(v)
|
||||
}
|
||||
placeholder="OpenClaw gateway token"
|
||||
/>
|
||||
|
||||
<Field label="Agent ID">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.agentId ?? ""
|
||||
: eff("adapterConfig", "agentId", String(config.agentId ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ agentId: v })
|
||||
: mark("adapterConfig", "agentId", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="agent-123"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Session strategy">
|
||||
<select
|
||||
value={
|
||||
isCreate
|
||||
? values!.sessionKeyStrategy ?? "fixed"
|
||||
: sessionStrategy
|
||||
}
|
||||
onChange={(e) =>
|
||||
isCreate
|
||||
? set!({ sessionKeyStrategy: e.target.value })
|
||||
: 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>
|
||||
|
||||
{(isCreate ? values!.sessionKeyStrategy ?? "fixed" : sessionStrategy) === "fixed" && (
|
||||
<Field label="Session key">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.sessionKey ?? ""
|
||||
: eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ sessionKey: v })
|
||||
: mark("adapterConfig", "sessionKey", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="paperclip"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<SecretField
|
||||
label="Password (alternative auth)"
|
||||
value={
|
||||
isCreate
|
||||
? values!.password ?? ""
|
||||
: eff("adapterConfig", "password", String(config.password ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ password: v })
|
||||
: mark("adapterConfig", "password", v || undefined)
|
||||
}
|
||||
placeholder="Gateway shared password"
|
||||
/>
|
||||
|
||||
<Field label="Role">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.role ?? ""
|
||||
: eff("adapterConfig", "role", String(config.role ?? "operator"))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ role: v })
|
||||
: mark("adapterConfig", "role", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="operator"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Scopes (comma-separated)">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.scopes ?? ""
|
||||
: eff("adapterConfig", "scopes", parseScopes(config.scopes ?? ["operator.admin"]))
|
||||
}
|
||||
onCommit={(v) => {
|
||||
const parsed = v
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (isCreate) {
|
||||
set!({ scopes: v });
|
||||
} else {
|
||||
mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined);
|
||||
}
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="operator.admin"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<RuntimeServicesJsonField
|
||||
isCreate={isCreate}
|
||||
values={values}
|
||||
@@ -132,116 +307,151 @@ export function OpenClawGatewayConfigFields({
|
||||
mark={mark}
|
||||
/>
|
||||
|
||||
{!isCreate && (
|
||||
<>
|
||||
<Field label="Paperclip API URL override">
|
||||
<DraftInput
|
||||
value={
|
||||
eff(
|
||||
"adapterConfig",
|
||||
"paperclipApiUrl",
|
||||
String(config.paperclipApiUrl ?? ""),
|
||||
)
|
||||
<Field label="Paperclip API URL override">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.paperclipApiUrl ?? ""
|
||||
: eff("adapterConfig", "paperclipApiUrl", String(config.paperclipApiUrl ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ paperclipApiUrl: v })
|
||||
: mark("adapterConfig", "paperclipApiUrl", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://paperclip.example"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Timeout (seconds)">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.timeoutSec != null ? String(values!.timeoutSec) : ""
|
||||
: eff("adapterConfig", "timeoutSec", String(config.timeoutSec ?? ""))
|
||||
}
|
||||
onCommit={(v) => {
|
||||
const parsed = Number.parseInt(v.trim(), 10);
|
||||
const val = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
if (isCreate) {
|
||||
set!({ timeoutSec: val });
|
||||
} else {
|
||||
mark("adapterConfig", "timeoutSec", val);
|
||||
}
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="120"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Headers JSON">
|
||||
<HeadersJsonTextarea
|
||||
isCreate={isCreate}
|
||||
createDraft={isCreate ? values!.headersJson ?? "" : ""}
|
||||
onCreateDraftChange={(next) => set!({ headersJson: next })}
|
||||
editStringified={JSON.stringify(eff("adapterConfig", "headers", config.headers ?? {}), null, 2)}
|
||||
onEditCommit={(next) => {
|
||||
const trimmed = next.trim();
|
||||
if (!trimmed) {
|
||||
mark("adapterConfig", "headers", undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
||||
mark("adapterConfig", "headers", parsed);
|
||||
}
|
||||
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://paperclip.example"
|
||||
/>
|
||||
</Field>
|
||||
} catch {
|
||||
// Keep local draft until JSON is valid
|
||||
}
|
||||
}}
|
||||
inputClass={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Claimed API key path">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "claimedApiKeyPath", String(config.claimedApiKeyPath ?? ""))}
|
||||
onCommit={(v) => mark("adapterConfig", "claimedApiKeyPath", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="~/.openclaw/workspace/paperclip-claimed-api-key.json"
|
||||
/>
|
||||
</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"
|
||||
{!isCreate && (
|
||||
<Field label="Claimed API key path">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "claimedApiKeyPath", String(config.claimedApiKeyPath ?? ""))}
|
||||
onCommit={(v) => mark("adapterConfig", "claimedApiKeyPath", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="~/.openclaw/workspace/paperclip-claimed-api-key.json"
|
||||
/>
|
||||
|
||||
<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="Device auth">
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals
|
||||
remain stable across runs.
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Wait timeout (ms)">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.waitTimeoutMs != null
|
||||
? String(values!.waitTimeoutMs)
|
||||
: ""
|
||||
: eff("adapterConfig", "waitTimeoutMs", String(config.waitTimeoutMs ?? "120000"))
|
||||
}
|
||||
onCommit={(v) => {
|
||||
const parsed = Number.parseInt(v.trim(), 10);
|
||||
const next = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
if (isCreate) {
|
||||
set!({ waitTimeoutMs: next });
|
||||
} else {
|
||||
mark("adapterConfig", "waitTimeoutMs", next);
|
||||
}
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="120000"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Disable device auth">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
isCreate
|
||||
? values!.disableDeviceAuth ?? false
|
||||
: eff("adapterConfig", "disableDeviceAuth", Boolean(config.disableDeviceAuth ?? false))
|
||||
}
|
||||
onChange={(e) =>
|
||||
isCreate
|
||||
? set!({ disableDeviceAuth: e.target.checked })
|
||||
: mark("adapterConfig", "disableDeviceAuth", e.target.checked || undefined)
|
||||
}
|
||||
/>
|
||||
Skip device key authentication
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<Field label="Auto-pair on first connect">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
isCreate
|
||||
? values!.autoPairOnFirstConnect ?? true
|
||||
: eff("adapterConfig", "autoPairOnFirstConnect", config.autoPairOnFirstConnect !== false)
|
||||
}
|
||||
onChange={(e) =>
|
||||
isCreate
|
||||
? set!({ autoPairOnFirstConnect: e.target.checked })
|
||||
: mark("adapterConfig", "autoPairOnFirstConnect", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Automatically approve device pairing
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<Field label="Device auth">
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
When enabled, Paperclip persists a device key during onboarding so pairing approvals
|
||||
remain stable across runs.
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user