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 ( +