openclaw gateway: persist device keys on create/update and clarify pairing flow

This commit is contained in:
Dotta
2026-03-07 17:34:38 -06:00
parent df0f101fbd
commit 3479ea6e80
5 changed files with 74 additions and 21 deletions

View File

@@ -41,11 +41,16 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT
Pairing handshake note: Pairing handshake note:
- The first gateway run may return `pairing required` once for a new device key. - 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. - Approve it in OpenClaw, then retry the task.
- For local docker smoke, you can approve from host: - For local docker smoke, you can approve from host:
```bash ```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\")"' 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). 7. Case A (manual issue test).
- Create an issue assigned to the OpenClaw agent. - Create an issue assigned to the OpenClaw agent.

View File

@@ -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 - 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 - 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. 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: - Local docker automation path:
- `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token <gateway-token>` - `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token <gateway-token>`
- Optional inspection:
- `openclaw devices list --json --url ws://127.0.0.1:18789 --token <gateway-token>`
- After approval, retries should succeed using the persisted `devicePrivateKeyPem`. - After approval, retries should succeed using the persisted `devicePrivateKeyPem`.
6. Claim API key with `claimSecret`. 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. 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.

View File

@@ -22,6 +22,7 @@ type GatewayDeviceIdentity = {
deviceId: string; deviceId: string;
publicKeyRawBase64Url: string; publicKeyRawBase64Url: string;
privateKeyPem: string; privateKeyPem: string;
source: "configured" | "ephemeral";
}; };
type GatewayRequestFrame = { type GatewayRequestFrame = {
@@ -486,6 +487,7 @@ function resolveDeviceIdentity(config: Record<string, unknown>): GatewayDeviceId
deviceId: crypto.createHash("sha256").update(raw).digest("hex"), deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
publicKeyRawBase64Url: base64UrlEncode(raw), publicKeyRawBase64Url: base64UrlEncode(raw),
privateKeyPem: configuredPrivateKey, privateKeyPem: configuredPrivateKey,
source: "configured",
}; };
} }
@@ -497,6 +499,7 @@ function resolveDeviceIdentity(config: Record<string, unknown>): GatewayDeviceId
deviceId: crypto.createHash("sha256").update(raw).digest("hex"), deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
publicKeyRawBase64Url: base64UrlEncode(raw), publicKeyRawBase64Url: base64UrlEncode(raw),
privateKeyPem, privateKeyPem,
source: "ephemeral",
}; };
} }
@@ -912,6 +915,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
try { try {
const deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); const 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");
}
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
@@ -1076,7 +1087,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const timedOut = lower.includes("timeout"); const timedOut = lower.includes("timeout");
const pairingRequired = lower.includes("pairing required"); const pairingRequired = lower.includes("pairing required");
const detailedMessage = pairingRequired const detailedMessage = pairingRequired
? `${message}. Configure adapterConfig.disableDeviceAuth=true for smoke/dev, or set adapterConfig.devicePrivateKeyPem so pairing persists across runs.` ? `${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; : message;
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);

View File

@@ -1,5 +1,5 @@
import { Router, type Request } from "express"; import { Router, type Request } from "express";
import { randomUUID } from "node:crypto"; import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path"; import path from "node:path";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns } 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; 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<string, unknown>,
): Record<string, unknown> {
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( function applyCreateDefaultsByAdapterType(
adapterType: string | null | undefined, adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>, adapterConfig: Record<string, unknown>,
@@ -196,13 +230,13 @@ export function agentRoutes(db: Db) {
if (!hasBypassFlag) { if (!hasBypassFlag) {
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
} }
return next; return ensureGatewayDeviceKey(adapterType, next);
} }
// OpenCode requires explicit model selection — no default // OpenCode requires explicit model selection — no default
if (adapterType === "cursor" && !asNonEmptyString(next.model)) { if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL; next.model = DEFAULT_CURSOR_LOCAL_MODEL;
} }
return next; return ensureGatewayDeviceKey(adapterType, next);
} }
async function assertAdapterConfigConstraints( async function assertAdapterConfigConstraints(
@@ -930,11 +964,7 @@ export function agentRoutes(db: Db) {
if (changingInstructionsPath) { if (changingInstructionsPath) {
await assertCanManageInstructionsPath(req, existing); await assertCanManageInstructionsPath(req, existing);
} }
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( patchData.adapterConfig = adapterConfig;
existing.companyId,
adapterConfig,
{ strictMode: strictSecretsMode },
);
} }
const requestedAdapterType = const requestedAdapterType =
@@ -942,15 +972,23 @@ export function agentRoutes(db: Db) {
const touchesAdapterConfiguration = const touchesAdapterConfiguration =
Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { if (touchesAdapterConfiguration) {
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {}) ? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.adapterConfig) ?? {}); : (asRecord(existing.adapterConfig) ?? {});
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
existing.companyId, requestedAdapterType,
rawEffectiveAdapterConfig, rawEffectiveAdapterConfig,
);
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
effectiveAdapterConfig,
{ strictMode: strictSecretsMode }, { strictMode: strictSecretsMode },
); );
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
}
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
await assertAdapterConfigConstraints( await assertAdapterConfigConstraints(
existing.companyId, existing.companyId,
requestedAdapterType, requestedAdapterType,

View File

@@ -204,15 +204,11 @@ export function OpenClawGatewayConfigFields({
/> />
</Field> </Field>
<Field label="Disable device auth"> <Field label="Device auth">
<select <div className="text-xs text-muted-foreground leading-relaxed">
value={String(eff("adapterConfig", "disableDeviceAuth", Boolean(config.disableDeviceAuth ?? false)))} Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals
onChange={(e) => mark("adapterConfig", "disableDeviceAuth", e.target.value === "true")} remain stable across runs.
className={inputClass} </div>
>
<option value="false">No (recommended)</option>
<option value="true">Yes</option>
</select>
</Field> </Field>
</> </>
)} )}