From 3b7c42be867cc711c32e114d88e015a692ed6765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20O=2E?= Date: Fri, 12 Jun 2026 08:31:39 +0200 Subject: [PATCH] fix(openclaw-gateway): complete and stabilize OpenClaw Gateway integration (#2322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Devin Foley Co-authored-by: Paperclip --- packages/adapter-utils/src/types.ts | 15 + .../openclaw-gateway/src/server/execute.ts | 31 +- .../src/ui/build-config.test.ts | 53 +++ .../openclaw-gateway/src/ui/build-config.ts | 54 ++- ...tbeat-lock-release-on-reassignment.test.ts | 2 + server/src/services/heartbeat.ts | 7 +- .../openclaw-gateway/config-fields.tsx | 424 +++++++++++++----- ui/src/components/agent-config-defaults.ts | 15 + 8 files changed, 482 insertions(+), 119 deletions(-) create mode 100644 packages/adapters/openclaw-gateway/src/ui/build-config.test.ts diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 2aac3d8c..60cf1279 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -505,4 +505,19 @@ export interface CreateConfigValues { intervalSec: number; /** Arbitrary key-value pairs populated by schema-driven config fields. */ adapterSchemaValues?: Record; + // openclaw_gateway adapter fields + authToken?: string; + agentId?: string; + sessionKeyStrategy?: string; + sessionKey?: string; + timeoutSec?: number; + waitTimeoutMs?: number; + disableDeviceAuth?: boolean; + autoPairOnFirstConnect?: boolean; + devicePrivateKeyPem?: string; + role?: string; + scopes?: string; + paperclipApiUrl?: string; + headersJson?: string; + password?: string; } diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index f019bfa8..d5caabd2 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -243,7 +243,11 @@ function resolveAuthToken(config: Record, headers: Record([ctx.runId]); @@ -1474,6 +1480,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise setTimeout(r, backoffMs)); + continue; + } + const detailedMessage = pairingRequired ? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` : message; diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.test.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.test.ts new file mode 100644 index 00000000..d92dd7fb --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.test.ts @@ -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"]); + }); +}); diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts index 70604f20..b13906d0 100644 --- a/packages/adapters/openclaw-gateway/src/ui/build-config.ts +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -14,17 +14,61 @@ function parseJsonObject(text: string): Record | null { export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record { const ac: Record = {}; + + // 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) ?? {}; + 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; } diff --git a/server/src/__tests__/heartbeat-lock-release-on-reassignment.test.ts b/server/src/__tests__/heartbeat-lock-release-on-reassignment.test.ts index 167c4c18..2345748b 100644 --- a/server/src/__tests__/heartbeat-lock-release-on-reassignment.test.ts +++ b/server/src/__tests__/heartbeat-lock-release-on-reassignment.test.ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, + agentRuntimeState, agentWakeupRequests, companies, createDb, @@ -39,6 +40,7 @@ describeEmbeddedPostgres("heartbeat lock release on cross-agent reassignment", ( await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(issues); + await db.delete(agentRuntimeState); await db.delete(agents); await db.delete(companies); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 56180470..c5b6cc23 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10593,10 +10593,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }) && activeExecutionRun.status === "running" && isSameExecutionAgent; - const availableActiveExecutionRun = filterZombieCoalesceTarget( - activeExecutionRun, - liveRunExecutions, - ); + const availableActiveExecutionRun = isSameExecutionAgent + ? filterZombieCoalesceTarget(activeExecutionRun, liveRunExecutions) + : activeExecutionRun; if ( isSameExecutionAgent diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx index 82bff3e2..18b0cc00 100644 --- a/ui/src/adapters/openclaw-gateway/config-fields.tsx +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -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(editStringified); + const [lastSyncedFromConfig, setLastSyncedFromConfig] = useState(editStringified); + useEffect(() => { + if (isCreate) return; + if (editStringified !== lastSyncedFromConfig) { + setEditDraft(editStringified); + setLastSyncedFromConfig(editStringified); + } + }, [editStringified, isCreate, lastSyncedFromConfig]); + const value = isCreate ? createDraft : editDraft; + return ( +