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:
Ismaël O.
2026-06-12 08:31:39 +02:00
committed by GitHub
parent d7049e0cae
commit 3b7c42be86
8 changed files with 482 additions and 119 deletions
+317 -107
View File
@@ -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>
</>
);
}