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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user