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
@@ -243,7 +243,11 @@ function resolveAuthToken(config: Record<string, unknown>, headers: Record<strin
const authHeader =
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
headerMapGetIgnoreCase(headers, "authorization");
return tokenFromAuthHeader(authHeader);
const fromHeader = tokenFromAuthHeader(authHeader);
if (fromHeader) return fromHeader;
// Fallback to environment variable
return nonEmpty(process.env.OPENCLAW_TOKEN);
}
function isSensitiveLogKey(key: string): boolean {
@@ -1120,10 +1124,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
const configuredSessionKey = nonEmpty(ctx.config.sessionKey);
const configuredAgentId = nonEmpty(ctx.config.agentId);
const sessionKey = resolveSessionKey({
strategy: sessionKeyStrategy,
configuredSessionKey,
agentId: nonEmpty(ctx.config.agentId),
agentId: configuredAgentId,
runId: ctx.runId,
issueId: wakePayload.issueId,
});
@@ -1141,7 +1146,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
delete agentParams.text;
agentParams.paperclip = paperclipPayload;
const configuredAgentId = nonEmpty(ctx.config.agentId);
if (configuredAgentId && !nonEmpty(agentParams.agentId)) {
agentParams.agentId = configuredAgentId;
}
@@ -1185,6 +1189,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true);
let autoPairAttempted = false;
let latestResultPayload: unknown = null;
let retryCount = 0;
const MAX_RETRIES = 2;
while (true) {
const trackedRunIds = new Set<string>([ctx.runId]);
@@ -1474,6 +1480,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
// Retry transient errors (connection refused, reset, socket hang up)
const isTransient =
!pairingRequired &&
(lower.includes("econnrefused") ||
lower.includes("econnreset") ||
lower.includes("socket hang up") ||
(timedOut && !lower.includes("agent.wait")));
if (isTransient && retryCount < MAX_RETRIES) {
retryCount++;
const backoffMs = retryCount * 2000;
await ctx.onLog(
"stdout",
`[openclaw-gateway] transient error, retry ${retryCount}/${MAX_RETRIES} after ${backoffMs}ms: ${message}\n`,
);
await new Promise((r) => setTimeout(r, backoffMs));
continue;
}
const detailedMessage = pairingRequired
? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url <gateway-ws-url> --token <gateway-token>) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.`
: message;
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildOpenClawGatewayConfig } from "./build-config.js";
function baseValues(): CreateConfigValues {
return {
adapterType: "openclaw_gateway",
cwd: "",
promptTemplate: "",
model: "",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: false,
search: false,
fastMode: false,
dangerouslyBypassSandbox: false,
command: "",
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "wss://gateway.example/ws",
bootstrapPrompt: "",
maxTurnsPerRun: 0,
heartbeatEnabled: false,
intervalSec: 0,
};
}
describe("buildOpenClawGatewayConfig", () => {
it("applies the documented timeout defaults when unset (timeoutSec=120, waitTimeoutMs=120000)", () => {
const config = buildOpenClawGatewayConfig(baseValues());
expect(config.timeoutSec).toBe(120);
expect(config.waitTimeoutMs).toBe(120000);
});
it("preserves explicit timeout values when provided", () => {
const config = buildOpenClawGatewayConfig({
...baseValues(),
timeoutSec: 45,
waitTimeoutMs: 9000,
});
expect(config.timeoutSec).toBe(45);
expect(config.waitTimeoutMs).toBe(9000);
});
it("applies the documented identity defaults when unset", () => {
const config = buildOpenClawGatewayConfig(baseValues());
expect(config.sessionKeyStrategy).toBe("issue");
expect(config.role).toBe("operator");
expect(config.scopes).toEqual(["operator.admin"]);
});
});
@@ -14,17 +14,61 @@ function parseJsonObject(text: string): Record<string, unknown> | null {
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
// Required / Primary fields
if (v.url) ac.url = v.url;
ac.timeoutSec = 120;
ac.waitTimeoutMs = 120000;
ac.sessionKeyStrategy = "issue";
ac.role = "operator";
ac.scopes = ["operator.admin"];
if (v.authToken) ac.authToken = v.authToken;
if (v.password) ac.password = v.password;
if (v.agentId) ac.agentId = v.agentId;
// Session routing fields
if (v.sessionKeyStrategy) ac.sessionKeyStrategy = v.sessionKeyStrategy;
if (v.sessionKey) ac.sessionKey = v.sessionKey;
// Timeout fields
if (typeof v.timeoutSec === "number") ac.timeoutSec = v.timeoutSec;
if (typeof v.waitTimeoutMs === "number") ac.waitTimeoutMs = v.waitTimeoutMs;
// Device auth fields
if (typeof v.disableDeviceAuth === "boolean") ac.disableDeviceAuth = v.disableDeviceAuth;
if (typeof v.autoPairOnFirstConnect === "boolean") ac.autoPairOnFirstConnect = v.autoPairOnFirstConnect;
if (v.devicePrivateKeyPem) ac.devicePrivateKeyPem = v.devicePrivateKeyPem;
// Gateway identity fields
if (v.role) ac.role = v.role;
if (v.scopes) {
const parsed = v.scopes.split(",").map((s) => s.trim()).filter(Boolean);
if (parsed.length > 0) ac.scopes = parsed;
}
// Paperclip API override
if (v.paperclipApiUrl) ac.paperclipApiUrl = v.paperclipApiUrl;
// Headers — parse headersJson first, then inject authToken on top
const headers = parseJsonObject(v.headersJson ?? "");
if (headers) ac.headers = headers;
if (v.authToken) {
const h = (ac.headers as Record<string, unknown>) ?? {};
h["x-openclaw-token"] = v.authToken;
ac.headers = h;
}
// Payload template
const payloadTemplate = parseJsonObject(v.payloadTemplateJson ?? "");
if (payloadTemplate) ac.payloadTemplate = payloadTemplate;
// Workspace runtime (from runtimeServicesJson)
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
// Safe defaults — applied when fields are not explicitly set
if (ac.timeoutSec == null) ac.timeoutSec = 120;
if (ac.waitTimeoutMs == null) ac.waitTimeoutMs = 120000;
if (!ac.sessionKeyStrategy) ac.sessionKeyStrategy = "issue";
if (!ac.role) ac.role = "operator";
if (!ac.scopes) ac.scopes = ["operator.admin"];
return ac;
}