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:
@@ -505,4 +505,19 @@ export interface CreateConfigValues {
|
||||
intervalSec: number;
|
||||
/** Arbitrary key-value pairs populated by schema-driven config fields. */
|
||||
adapterSchemaValues?: Record<string, unknown>;
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,4 +28,19 @@ export const defaultCreateValues: CreateConfigValues = {
|
||||
maxTurnsPerRun: 1000,
|
||||
heartbeatEnabled: false,
|
||||
intervalSec: 300,
|
||||
// openclaw_gateway defaults
|
||||
authToken: "",
|
||||
agentId: "",
|
||||
sessionKeyStrategy: "issue",
|
||||
sessionKey: "",
|
||||
timeoutSec: undefined,
|
||||
waitTimeoutMs: undefined,
|
||||
disableDeviceAuth: undefined,
|
||||
autoPairOnFirstConnect: undefined,
|
||||
devicePrivateKeyPem: "",
|
||||
role: "",
|
||||
scopes: "",
|
||||
paperclipApiUrl: "",
|
||||
headersJson: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user