From 2223afa0e9d680ce1ea92c5353f131190277956f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 17:46:55 -0600 Subject: [PATCH] openclaw gateway: auto-approve first pairing and retry --- doc/OPENCLAW_ONBOARDING.md | 3 +- packages/adapters/openclaw-gateway/README.md | 1 + .../doc/ONBOARDING_AND_TEST_PLAN.md | 4 +- .../adapters/openclaw-gateway/src/index.ts | 1 + .../openclaw-gateway/src/server/execute.ts | 636 +++++++++++------- .../openclaw-gateway-adapter.test.ts | 239 +++++++ 6 files changed, 648 insertions(+), 236 deletions(-) diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index c035c7e9..14a251de 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -40,7 +40,8 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT - Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. Pairing handshake note: -- The first gateway run may return `pairing required` once for a new device key. +- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key. - This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - For local docker smoke, you can approve from host: diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md index 61ebfaea..cadc8198 100644 --- a/packages/adapters/openclaw-gateway/README.md +++ b/packages/adapters/openclaw-gateway/README.md @@ -32,6 +32,7 @@ By default the adapter sends a signed `device` payload in `connect` params. - set `disableDeviceAuth=true` to omit device signing - set `devicePrivateKeyPem` to pin a stable signing key - without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run +- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once. ## Session Strategy diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index cdf7bfe3..f8cacedb 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -266,7 +266,9 @@ POST /api/companies/$CLA_COMPANY_ID/invites - pairing mode is explicit: - default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs - fallback path: `disableDeviceAuth=true` only for environments that cannot support pairing -5. Trigger one connectivity run. If it returns `pairing required`, approve the pending device request in OpenClaw and retry once. +5. Trigger one connectivity run. Adapter behavior on first pairing gate: + - default: auto-attempt `device.pair.list` + `device.pair.approve` over shared auth, then retry once + - if auto-pair fails, run returns `pairing required`; approve manually in OpenClaw and retry once - Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates. - Local docker automation path: - `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token ` diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index ca16cdc9..34f7201d 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -33,6 +33,7 @@ Request behavior fields: - payloadTemplate (object, optional): additional fields merged into gateway agent params - timeoutSec (number, optional): adapter timeout in seconds (default 120) - waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) +- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true) - paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text Session routing fields: diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 851b28a5..b92dccfa 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -57,6 +57,11 @@ type PendingRequest = { timer: ReturnType | null; }; +type GatewayResponseError = Error & { + gatewayCode?: string; + gatewayDetails?: Record; +}; + type GatewayClientOptions = { url: string; headers: Record; @@ -164,6 +169,10 @@ function normalizeScopes(value: unknown): string[] { return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES]; } +function uniqueScopes(scopes: string[]): string[] { + return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))); +} + function headerMapGetIgnoreCase(headers: Record, key: string): string | null { const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); return match ? match[1] : null; @@ -173,6 +182,21 @@ function headerMapHasIgnoreCase(headers: Record, key: string): b return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase()); } +function getGatewayErrorDetails(err: unknown): Record | null { + if (!err || typeof err !== "object") return null; + const candidate = (err as GatewayResponseError).gatewayDetails; + return asRecord(candidate); +} + +function extractPairingRequestId(err: unknown): string | null { + const details = getGatewayErrorDetails(err); + const fromDetails = nonEmpty(details?.requestId); + if (fromDetails) return fromDetails; + const message = err instanceof Error ? err.message : String(err); + const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i); + return match?.[1] ?? null; +} + function toAuthorizationHeaderValue(rawToken: string): string { const trimmed = rawToken.trim(); if (!trimmed) return trimmed; @@ -691,7 +715,101 @@ class GatewayWsClient { nonEmpty(errorRecord?.message) ?? nonEmpty(errorRecord?.code) ?? "gateway request failed"; - pending.reject(new Error(message)); + const err = new Error(message) as GatewayResponseError; + const code = nonEmpty(errorRecord?.code); + const details = asRecord(errorRecord?.details); + if (code) err.gatewayCode = code; + if (details) err.gatewayDetails = details; + pending.reject(err); + } +} + +async function autoApproveDevicePairing(params: { + url: string; + headers: Record; + connectTimeoutMs: number; + clientId: string; + clientMode: string; + clientVersion: string; + role: string; + scopes: string[]; + authToken: string | null; + password: string | null; + requestId: string | null; + deviceId: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> { + if (!params.authToken && !params.password) { + return { ok: false, reason: "shared auth token/password is missing" }; + } + + const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]); + const client = new GatewayWsClient({ + url: params.url, + headers: params.headers, + onEvent: () => {}, + onLog: params.onLog, + }); + + try { + await params.onLog( + "stdout", + "[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n", + ); + + await client.connect( + () => ({ + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: params.clientId, + version: params.clientVersion, + platform: process.platform, + mode: params.clientMode, + }, + role: params.role, + scopes: approvalScopes, + auth: { + ...(params.authToken ? { token: params.authToken } : {}), + ...(params.password ? { password: params.password } : {}), + }, + }), + params.connectTimeoutMs, + ); + + let requestId = params.requestId; + if (!requestId) { + const listPayload = await client.request>("device.pair.list", {}, { + timeoutMs: params.connectTimeoutMs, + }); + const pending = Array.isArray(listPayload.pending) ? listPayload.pending : []; + const pendingRecords = pending + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)); + const matching = + (params.deviceId + ? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId) + : null) ?? pendingRecords[pendingRecords.length - 1]; + requestId = nonEmpty(matching?.requestId); + } + + if (!requestId) { + return { ok: false, reason: "no pending device pairing request found" }; + } + + await client.request( + "device.pair.approve", + { requestId }, + { + timeoutMs: params.connectTimeoutMs, + }, + ); + + return { ok: true, requestId }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } finally { + client.close(); } } @@ -824,63 +942,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise([ctx.runId]); - const assistantChunks: string[] = []; - let lifecycleError: string | null = null; - let latestResultPayload: unknown = null; - - const onEvent = async (frame: GatewayEventFrame) => { - if (frame.event !== "agent") { - if (frame.event === "shutdown") { - await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`); - } - return; - } - - const payload = asRecord(frame.payload); - if (!payload) return; - - const runId = nonEmpty(payload.runId); - if (!runId || !trackedRunIds.has(runId)) return; - - const stream = nonEmpty(payload.stream) ?? "unknown"; - const data = asRecord(payload.data) ?? {}; - await ctx.onLog( - "stdout", - `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, - ); - - if (stream === "assistant") { - const delta = nonEmpty(data.delta); - const text = nonEmpty(data.text); - if (delta) { - assistantChunks.push(delta); - } else if (text) { - assistantChunks.push(text); - } - return; - } - - if (stream === "error") { - lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; - return; - } - - if (stream === "lifecycle") { - const phase = nonEmpty(data.phase)?.toLowerCase(); - if (phase === "error" || phase === "failed" || phase === "cancelled") { - lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; - } - } - }; - - const client = new GatewayWsClient({ - url: parsedUrl.toString(), - headers, - onEvent, - onLog: ctx.onLog, - }); - if (ctx.onMeta) { await ctx.onMeta({ adapterType: "openclaw_gateway", @@ -913,198 +974,305 @@ export async function execute(ctx: AdapterExecutionContext): Promise([ctx.runId]); + const assistantChunks: string[] = []; + let lifecycleError: string | null = null; + let deviceIdentity: GatewayDeviceIdentity | null = null; + + const onEvent = async (frame: GatewayEventFrame) => { + if (frame.event !== "agent") { + if (frame.event === "shutdown") { + await ctx.onLog( + "stdout", + `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`, + ); + } + return; + } + + const payload = asRecord(frame.payload); + if (!payload) return; + + const runId = nonEmpty(payload.runId); + if (!runId || !trackedRunIds.has(runId)) return; + + const stream = nonEmpty(payload.stream) ?? "unknown"; + const data = asRecord(payload.data) ?? {}; await ctx.onLog( "stdout", - `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, ); - } else { - await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); - } - await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); - - const hello = await client.connect((nonce) => { - const signedAtMs = Date.now(); - const connectParams: Record = { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: clientId, - version: clientVersion, - platform: process.platform, - ...(deviceFamily ? { deviceFamily } : {}), - mode: clientMode, - }, - role, - scopes, - auth: - authToken || password || deviceToken - ? { - ...(authToken ? { token: authToken } : {}), - ...(deviceToken ? { deviceToken } : {}), - ...(password ? { password } : {}), - } - : undefined, - }; - - if (deviceIdentity) { - const payload = buildDeviceAuthPayloadV3({ - deviceId: deviceIdentity.deviceId, - clientId, - clientMode, - role, - scopes, - signedAtMs, - token: authToken, - nonce, - platform: process.platform, - deviceFamily, - }); - connectParams.device = { - id: deviceIdentity.deviceId, - publicKey: deviceIdentity.publicKeyRawBase64Url, - signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; + if (stream === "assistant") { + const delta = nonEmpty(data.delta); + const text = nonEmpty(data.text); + if (delta) { + assistantChunks.push(delta); + } else if (text) { + assistantChunks.push(text); + } + return; } - return connectParams; - }, connectTimeoutMs); - await ctx.onLog( - "stdout", - `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, - ); + if (stream === "error") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + return; + } - const acceptedPayload = await client.request>("agent", agentParams, { - timeoutMs: connectTimeoutMs, + if (stream === "lifecycle") { + const phase = nonEmpty(data.phase)?.toLowerCase(); + if (phase === "error" || phase === "failed" || phase === "cancelled") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + } + } + }; + + const client = new GatewayWsClient({ + url: parsedUrl.toString(), + headers, + onEvent, + onLog: ctx.onLog, }); - latestResultPayload = acceptedPayload; + try { + deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); + if (deviceIdentity) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + ); + } else { + await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); + } - const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; - const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; - trackedRunIds.add(acceptedRunId); + await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); - await ctx.onLog( - "stdout", - `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, - ); + const hello = await client.connect((nonce) => { + const signedAtMs = Date.now(); + const connectParams: Record = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: clientId, + version: clientVersion, + platform: process.platform, + ...(deviceFamily ? { deviceFamily } : {}), + mode: clientMode, + }, + role, + scopes, + auth: + authToken || password || deviceToken + ? { + ...(authToken ? { token: authToken } : {}), + ...(deviceToken ? { deviceToken } : {}), + ...(password ? { password } : {}), + } + : undefined, + }; + + if (deviceIdentity) { + const payload = buildDeviceAuthPayloadV3({ + deviceId: deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken, + nonce, + platform: process.platform, + deviceFamily, + }); + connectParams.device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKeyRawBase64Url, + signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + } + return connectParams; + }, connectTimeoutMs); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, + ); + + const acceptedPayload = await client.request>("agent", agentParams, { + timeoutMs: connectTimeoutMs, + }); + + latestResultPayload = acceptedPayload; + + const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; + const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; + trackedRunIds.add(acceptedRunId); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, + ); + + if (acceptedStatus === "error") { + const errorMessage = + nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage, + errorCode: "openclaw_gateway_agent_error", + resultJson: acceptedPayload, + }; + } + + if (acceptedStatus !== "ok") { + const waitPayload = await client.request>( + "agent.wait", + { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, + { timeoutMs: waitTimeoutMs + connectTimeoutMs }, + ); + + latestResultPayload = waitPayload; + + const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; + if (waitStatus === "timeout") { + return { + exitCode: 1, + signal: null, + timedOut: true, + errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, + errorCode: "openclaw_gateway_wait_timeout", + resultJson: waitPayload, + }; + } + + if (waitStatus === "error") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + nonEmpty(waitPayload?.error) ?? + lifecycleError ?? + "OpenClaw gateway run failed", + errorCode: "openclaw_gateway_wait_error", + resultJson: waitPayload, + }; + } + + if (waitStatus && waitStatus !== "ok") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, + errorCode: "openclaw_gateway_wait_status_unexpected", + resultJson: waitPayload, + }; + } + } + + const summaryFromEvents = assistantChunks.join("").trim(); + const summaryFromPayload = + extractResultText(asRecord(acceptedPayload?.result)) ?? + extractResultText(acceptedPayload) ?? + extractResultText(asRecord(latestResultPayload)) ?? + null; + const summary = summaryFromEvents || summaryFromPayload || null; + + const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); + const agentMeta = asRecord(meta?.agentMeta); + const usage = parseUsage(agentMeta?.usage ?? meta?.usage); + const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; + const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; + const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`, + ); + + return { + exitCode: 0, + signal: null, + timedOut: false, + provider, + ...(model ? { model } : {}), + ...(usage ? { usage } : {}), + ...(costUsd > 0 ? { costUsd } : {}), + resultJson: asRecord(latestResultPayload), + ...(summary ? { summary } : {}), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + const timedOut = lower.includes("timeout"); + const pairingRequired = lower.includes("pairing required"); + + if ( + pairingRequired && + !disableDeviceAuth && + autoPairOnFirstConnect && + !autoPairAttempted && + (authToken || password) + ) { + autoPairAttempted = true; + const pairResult = await autoApproveDevicePairing({ + url: parsedUrl.toString(), + headers, + connectTimeoutMs, + clientId, + clientMode, + clientVersion, + role, + scopes, + authToken, + password, + requestId: extractPairingRequestId(err), + deviceId: deviceIdentity?.deviceId ?? null, + onLog: ctx.onLog, + }); + if (pairResult.ok) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`, + ); + continue; + } + await ctx.onLog( + "stderr", + `[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`, + ); + } + + 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; + + await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); - if (acceptedStatus === "error") { - const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; return { exitCode: 1, signal: null, - timedOut: false, - errorMessage, - errorCode: "openclaw_gateway_agent_error", - resultJson: acceptedPayload, + timedOut, + errorMessage: detailedMessage, + errorCode: timedOut + ? "openclaw_gateway_timeout" + : pairingRequired + ? "openclaw_gateway_pairing_required" + : "openclaw_gateway_request_failed", + resultJson: asRecord(latestResultPayload), }; + } finally { + client.close(); } - - if (acceptedStatus !== "ok") { - const waitPayload = await client.request>( - "agent.wait", - { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, - { timeoutMs: waitTimeoutMs + connectTimeoutMs }, - ); - - latestResultPayload = waitPayload; - - const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; - if (waitStatus === "timeout") { - return { - exitCode: 1, - signal: null, - timedOut: true, - errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, - errorCode: "openclaw_gateway_wait_timeout", - resultJson: waitPayload, - }; - } - - if (waitStatus === "error") { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - nonEmpty(waitPayload?.error) ?? - lifecycleError ?? - "OpenClaw gateway run failed", - errorCode: "openclaw_gateway_wait_error", - resultJson: waitPayload, - }; - } - - if (waitStatus && waitStatus !== "ok") { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, - errorCode: "openclaw_gateway_wait_status_unexpected", - resultJson: waitPayload, - }; - } - } - - const summaryFromEvents = assistantChunks.join("").trim(); - const summaryFromPayload = - extractResultText(asRecord(acceptedPayload?.result)) ?? - extractResultText(acceptedPayload) ?? - extractResultText(asRecord(latestResultPayload)) ?? - null; - const summary = summaryFromEvents || summaryFromPayload || null; - - const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); - const agentMeta = asRecord(meta?.agentMeta); - const usage = parseUsage(agentMeta?.usage ?? meta?.usage); - const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; - const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; - const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); - - await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`); - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider, - ...(model ? { model } : {}), - ...(usage ? { usage } : {}), - ...(costUsd > 0 ? { costUsd } : {}), - resultJson: asRecord(latestResultPayload), - ...(summary ? { summary } : {}), - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const lower = message.toLowerCase(); - const timedOut = lower.includes("timeout"); - const pairingRequired = lower.includes("pairing required"); - 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; - - await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); - - return { - exitCode: 1, - signal: null, - timedOut, - errorMessage: detailedMessage, - errorCode: timedOut - ? "openclaw_gateway_timeout" - : pairingRequired - ? "openclaw_gateway_pairing_required" - : "openclaw_gateway_request_failed", - resultJson: asRecord(latestResultPayload), - }; - } finally { - client.close(); } } diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index df57af32..3a4ac10e 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -167,6 +167,208 @@ async function createMockGatewayServer() { }; } +async function createMockGatewayServerWithPairing() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + + let agentPayload: Record | null = null; + let approved = false; + let pendingRequestId = "req-1"; + let lastSeenDeviceId: string | null = null; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123" }, + }), + ); + + socket.on("message", (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const frame = JSON.parse(text) as { + type: string; + id: string; + method: string; + params?: Record; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + const device = frame.params?.device as Record | undefined; + const deviceId = typeof device?.id === "string" ? device.id : null; + if (deviceId) { + lastSeenDeviceId = deviceId; + } + + if (deviceId && !approved) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { + code: "NOT_PAIRED", + message: "pairing required", + details: { + code: "PAIRING_REQUIRED", + requestId: pendingRequestId, + reason: "not-paired", + }, + }, + }), + ); + socket.close(1008, "pairing required"); + return; + } + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { + methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"], + events: ["agent"], + }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "device.pair.list") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + pending: approved + ? [] + : [ + { + requestId: pendingRequestId, + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + ], + paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [], + }, + }), + ); + return; + } + + if (frame.method === "device.pair.approve") { + const requestId = frame.params?.requestId; + if (requestId !== pendingRequestId) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { code: "INVALID_REQUEST", message: "unknown requestId" }, + }), + ); + return; + } + approved = true; + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + requestId: pendingRequestId, + device: { + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayload = frame.params ?? null; + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : "run-123"; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { delta: "ok" }, + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}`, + getAgentPayload: () => agentPayload, + close: async () => { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + afterEach(() => { // no global mocks }); @@ -238,6 +440,43 @@ describe("openclaw gateway adapter execute", () => { expect(result.exitCode).toBe(1); expect(result.errorCode).toBe("openclaw_gateway_url_missing"); }); + + it("auto-approves pairing once and retries the run", async () => { + const gateway = await createMockGatewayServerWithPairing(); + const logs: string[] = []; + + try { + const result = await execute( + buildContext( + { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2000, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + expect(result.summary).toContain("ok"); + expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe( + true, + ); + expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true); + expect(gateway.getAgentPayload()).toBeTruthy(); + } finally { + await gateway.close(); + } + }); }); describe("openclaw gateway testEnvironment", () => {