From 3479ea6e80c707f962eda7f5e1589c3091d12446 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 17:34:38 -0600 Subject: [PATCH] openclaw gateway: persist device keys on create/update and clarify pairing flow --- doc/OPENCLAW_ONBOARDING.md | 5 ++ .../doc/ONBOARDING_AND_TEST_PLAN.md | 3 + .../openclaw-gateway/src/server/execute.ts | 13 +++- server/src/routes/agents.ts | 60 +++++++++++++++---- .../openclaw-gateway/config-fields.tsx | 14 ++--- 5 files changed, 74 insertions(+), 21 deletions(-) diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index b55e755a..c035c7e9 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -41,11 +41,16 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT Pairing handshake note: - The first gateway run may 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: ```bash docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"' ``` +- You can inspect pending vs paired devices: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"' +``` 7. Case A (manual issue test). - Create an issue assigned to the OpenClaw agent. 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 042b8656..cdf7bfe3 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -267,8 +267,11 @@ POST /api/companies/$CLA_COMPANY_ID/invites - 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. + - 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 ` + - Optional inspection: + - `openclaw devices list --json --url ws://127.0.0.1:18789 --token ` - After approval, retries should succeed using the persisted `devicePrivateKeyPem`. 6. Claim API key with `claimSecret`. 7. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context. diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index ceec0b91..851b28a5 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -22,6 +22,7 @@ type GatewayDeviceIdentity = { deviceId: string; publicKeyRawBase64Url: string; privateKeyPem: string; + source: "configured" | "ephemeral"; }; type GatewayRequestFrame = { @@ -486,6 +487,7 @@ function resolveDeviceIdentity(config: Record): GatewayDeviceId deviceId: crypto.createHash("sha256").update(raw).digest("hex"), publicKeyRawBase64Url: base64UrlEncode(raw), privateKeyPem: configuredPrivateKey, + source: "configured", }; } @@ -497,6 +499,7 @@ function resolveDeviceIdentity(config: Record): GatewayDeviceId deviceId: crypto.createHash("sha256").update(raw).digest("hex"), publicKeyRawBase64Url: base64UrlEncode(raw), privateKeyPem, + source: "ephemeral", }; } @@ -912,6 +915,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise --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`); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 008d9094..a57b63c2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,5 @@ import { Router, type Request } from "express"; -import { randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db"; @@ -181,6 +181,40 @@ export function agentRoutes(db: Db) { return trimmed.length > 0 ? trimmed : null; } + function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return null; + } + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return null; + } + + function generateEd25519PrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("ed25519"); + return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + } + + function ensureGatewayDeviceKey( + adapterType: string | null | undefined, + adapterConfig: Record, + ): Record { + if (adapterType !== "openclaw_gateway") return adapterConfig; + const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true; + if (disableDeviceAuth) return adapterConfig; + if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig; + return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() }; + } + function applyCreateDefaultsByAdapterType( adapterType: string | null | undefined, adapterConfig: Record, @@ -196,13 +230,13 @@ export function agentRoutes(db: Db) { if (!hasBypassFlag) { next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } - return next; + return ensureGatewayDeviceKey(adapterType, next); } // OpenCode requires explicit model selection — no default if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; } - return next; + return ensureGatewayDeviceKey(adapterType, next); } async function assertAdapterConfigConstraints( @@ -930,11 +964,7 @@ export function agentRoutes(db: Db) { if (changingInstructionsPath) { await assertCanManageInstructionsPath(req, existing); } - patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, - adapterConfig, - { strictMode: strictSecretsMode }, - ); + patchData.adapterConfig = adapterConfig; } const requestedAdapterType = @@ -942,15 +972,23 @@ export function agentRoutes(db: Db) { const touchesAdapterConfiguration = Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); - if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + if (touchesAdapterConfiguration) { const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) : (asRecord(existing.adapterConfig) ?? {}); - const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, + const effectiveAdapterConfig = applyCreateDefaultsByAdapterType( + requestedAdapterType, rawEffectiveAdapterConfig, + ); + const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + effectiveAdapterConfig, { strictMode: strictSecretsMode }, ); + patchData.adapterConfig = normalizedEffectiveAdapterConfig; + } + if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; await assertAdapterConfigConstraints( existing.companyId, requestedAdapterType, diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx index 5bcad80b..178f9f61 100644 --- a/ui/src/adapters/openclaw-gateway/config-fields.tsx +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -204,15 +204,11 @@ export function OpenClawGatewayConfigFields({ /> - - + +
+ Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals + remain stable across runs. +
)}