openclaw gateway: persist device keys and smoke pairing flow

This commit is contained in:
Dotta
2026-03-07 17:05:36 -06:00
parent d52f1d4b44
commit 0abb6a1205
6 changed files with 197 additions and 30 deletions

View File

@@ -30,14 +30,22 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
- Confirm gateway URL is `ws://...` or `wss://...`.
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
- Confirm pairing mode is explicit:
- smoke/dev default: set `adapterConfig.disableDeviceAuth=true` to avoid interactive pairing prompts on each run
- if keeping device auth enabled: set a stable `adapterConfig.devicePrivateKeyPem` so pairing is approved once and reused
- recommended default: `adapterConfig.disableDeviceAuth` is false/absent and `adapterConfig.devicePrivateKeyPem` is present
- fallback only: `adapterConfig.disableDeviceAuth=true` when pairing cannot be supported in that environment
- If you can run API checks with board auth:
```bash
AGENT_ID="<newly-created-agent-id>"
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}'
```
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, and (`disableDeviceAuth=true` OR `hasDeviceKey=true`).
- 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.
- 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\")"'
```
7. Case A (manual issue test).
- Create an issue assigned to the OpenClaw agent.
@@ -63,7 +71,7 @@ docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docke
11. Expected pass criteria.
- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`).
- Pairing mode: either `disableDeviceAuth=true` (smoke/dev) or stable `devicePrivateKeyPem` configured.
- Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path).
- Case A: `done` + marker comment.
- Case B: `done` + marker comment + main-chat message visible.
- Case C: original task done and new issue created from `/new` session.

View File

@@ -250,7 +250,6 @@ POST /api/companies/$CLA_COMPANY_ID/invites
"headers": { "x-openclaw-token": "<gateway-token>" },
"role": "operator",
"scopes": ["operator.admin"],
"disableDeviceAuth": true,
"sessionKeyStrategy": "fixed",
"sessionKey": "paperclip",
"waitTimeoutMs": 120000
@@ -265,13 +264,17 @@ POST /api/companies/$CLA_COMPANY_ID/invites
- `adapterConfig.headers.x-openclaw-token` exists and is not placeholder/too-short (`len >= 16`)
- token hash matches the OpenClaw `gateway.auth.token` used for join
- pairing mode is explicit:
- smoke/dev: `adapterConfig.disableDeviceAuth == true` (no interactive pairing gate)
- otherwise: stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs
5. Claim API key with `claimSecret`.
6. 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.
- 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.
- Local docker automation path:
- `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token <gateway-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.
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
7. Ensure Paperclip skill is installed for OpenClaw runtime.
8. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
8. Ensure Paperclip skill is installed for OpenClaw runtime.
9. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
## 6) E2E Validation Cases
@@ -322,7 +325,7 @@ Responsibilities:
- Old OpenClaw agent cleanup.
- Invite/join/approve/claim orchestration.
- Gateway agent config/token preflight validation before connectivity or case execution.
- Pairing-mode preflight (`disableDeviceAuth=true` for smoke/dev or stable `devicePrivateKeyPem`).
- Pairing-mode preflight (`disableDeviceAuth=false` + stable `devicePrivateKeyPem` by default).
- E2E case execution + assertions.
- Final summary with run IDs, issue IDs, agent ID.

View File

@@ -53,6 +53,7 @@ AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}"
OPENCLAW_DIAG_DIR="${OPENCLAW_DIAG_DIR:-/tmp/openclaw-gateway-e2e-diag-$(date +%Y%m%d-%H%M%S)}"
OPENCLAW_ADAPTER_TIMEOUT_SEC="${OPENCLAW_ADAPTER_TIMEOUT_SEC:-120}"
OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS="${OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS:-120000}"
PAIRING_AUTO_APPROVE="${PAIRING_AUTO_APPROVE:-1}"
PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}"
AUTH_HEADERS=()
@@ -418,7 +419,6 @@ create_and_approve_gateway_join() {
headers: { "x-openclaw-token": $token },
role: "operator",
scopes: ["operator.admin"],
disableDeviceAuth: true,
sessionKeyStrategy: "fixed",
sessionKey: "paperclip",
timeoutSec: $timeoutSec,
@@ -530,10 +530,12 @@ validate_joined_gateway_agent() {
api_request "GET" "/agents/${AGENT_ID}"
assert_status "200"
local adapter_type gateway_url configured_token
local adapter_type gateway_url configured_token disable_device_auth device_key_len
adapter_type="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")"
gateway_url="$(jq -r '.adapterConfig.url // empty' <<<"$RESPONSE_BODY")"
configured_token="$(jq -r '.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // empty' <<<"$RESPONSE_BODY")"
disable_device_auth="$(jq -r 'if .adapterConfig.disableDeviceAuth == true then "true" else "false" end' <<<"$RESPONSE_BODY")"
device_key_len="$(jq -r '(.adapterConfig.devicePrivateKeyPem // "" | length)' <<<"$RESPONSE_BODY")"
[[ "$adapter_type" == "openclaw_gateway" ]] || fail "joined agent adapterType is '${adapter_type}', expected 'openclaw_gateway'"
[[ "$gateway_url" =~ ^wss?:// ]] || fail "joined agent gateway url is invalid: '${gateway_url}'"
@@ -549,9 +551,46 @@ validate_joined_gateway_agent() {
fail "joined agent gateway token hash mismatch (expected ${expected_hash}, got ${configured_hash})"
fi
[[ "$disable_device_auth" == "false" ]] || fail "joined agent has disableDeviceAuth=true; smoke requires device auth enabled with persistent key"
if (( device_key_len < 32 )); then
fail "joined agent missing persistent devicePrivateKeyPem (length=${device_key_len})"
fi
log "validated joined gateway agent config (token sha256 prefix ${configured_hash})"
}
run_log_contains_pairing_required() {
local run_id="$1"
api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=262144"
if [[ "$RESPONSE_CODE" != "200" ]]; then
return 1
fi
local content
content="$(jq -r '.content // ""' <<<"$RESPONSE_BODY")"
grep -qi "pairing required" <<<"$content"
}
approve_latest_pairing_request() {
local gateway_token="$1"
local container
container="$(detect_openclaw_container || true)"
[[ -n "$container" ]] || return 1
log "approving latest gateway pairing request in ${container}"
local output
if output="$(docker exec \
-e OPENCLAW_GATEWAY_URL="$OPENCLAW_GATEWAY_URL" \
-e OPENCLAW_GATEWAY_TOKEN="$gateway_token" \
"$container" \
sh -lc 'openclaw devices approve --latest --json --url "$OPENCLAW_GATEWAY_URL" --token "$OPENCLAW_GATEWAY_TOKEN"' 2>&1)"; then
log "pairing approval response: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)"
return 0
fi
warn "pairing auto-approve failed: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)"
return 1
}
trigger_wakeup() {
local reason="$1"
local issue_id="${2:-}"
@@ -871,13 +910,30 @@ main() {
log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}"
validate_joined_gateway_agent "$gateway_token"
trigger_wakeup "openclaw_gateway_smoke_connectivity"
if [[ -n "$RUN_ID" ]]; then
local connect_status
connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
[[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run failed: ${connect_status}"
log "connectivity wake run ${RUN_ID} succeeded"
local connect_status="unknown"
local connect_attempt
for connect_attempt in 1 2; do
trigger_wakeup "openclaw_gateway_smoke_connectivity_attempt_${connect_attempt}"
if [[ -z "$RUN_ID" ]]; then
connect_status="unknown"
break
fi
connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
if [[ "$connect_status" == "succeeded" ]]; then
log "connectivity wake run ${RUN_ID} succeeded (attempt=${connect_attempt})"
break
fi
if [[ "$PAIRING_AUTO_APPROVE" == "1" && "$connect_attempt" -eq 1 ]] && run_log_contains_pairing_required "$RUN_ID"; then
log "connectivity run hit pairing gate; attempting one-time pairing approval"
approve_latest_pairing_request "$gateway_token" || fail "pairing approval failed after pairing-required run ${RUN_ID}"
sleep 2
continue
fi
fail "connectivity wake run failed: ${connect_status} (attempt=${connect_attempt}, runId=${RUN_ID})"
done
[[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run did not succeed after retries"
run_case_a
run_case_b

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { buildJoinDefaultsPayloadForAccept } from "../routes/access.js";
import {
buildJoinDefaultsPayloadForAccept,
normalizeAgentDefaultsForJoin,
} from "../routes/access.js";
describe("buildJoinDefaultsPayloadForAccept", () => {
it("maps OpenClaw compatibility fields into agent defaults", () => {
@@ -245,4 +248,47 @@ describe("buildJoinDefaultsPayloadForAccept", () => {
},
});
});
it("generates persistent device key for openclaw_gateway when device auth is enabled", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: false,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
});
it("does not generate device key when openclaw_gateway has disableDeviceAuth=true", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: true,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
});
});

View File

@@ -1,4 +1,9 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import {
createHash,
generateKeyPairSync,
randomBytes,
timingSafeEqual
} from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -330,6 +335,13 @@ function parseBooleanLike(value: unknown): boolean | null {
return null;
}
function generateEd25519PrivateKeyPem(): string {
const generated = generateKeyPairSync("ed25519");
return generated.privateKey
.export({ type: "pkcs8", format: "pem" })
.toString();
}
export function buildJoinDefaultsPayloadForAccept(input: {
adapterType: string | null;
defaultsPayload: unknown;
@@ -611,10 +623,16 @@ function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
sessionKeyStrategy: defaults
? nonEmptyTrimmedString(defaults.sessionKeyStrategy)
: null,
disableDeviceAuth: defaults
? parseBooleanLike(defaults.disableDeviceAuth)
: null,
waitTimeoutMs:
defaults && typeof defaults.waitTimeoutMs === "number"
? defaults.waitTimeoutMs
: null,
devicePrivateKeyPem: defaults
? summarizeSecretForLog(defaults.devicePrivateKeyPem)
: null,
gatewayToken: summarizeSecretForLog(gatewayTokenValue)
};
}
@@ -692,7 +710,7 @@ function buildJoinConnectivityDiagnostics(input: {
return diagnostics;
}
function normalizeAgentDefaultsForJoin(input: {
export function normalizeAgentDefaultsForJoin(input: {
adapterType: string | null;
defaultsPayload: unknown;
deploymentMode: DeploymentMode;
@@ -828,10 +846,47 @@ function normalizeAgentDefaultsForJoin(input: {
}
const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth);
const disableDeviceAuth = parsedDisableDeviceAuth === true;
if (parsedDisableDeviceAuth !== null) {
normalized.disableDeviceAuth = parsedDisableDeviceAuth;
}
const configuredDevicePrivateKeyPem = nonEmptyTrimmedString(
defaults.devicePrivateKeyPem
);
if (configuredDevicePrivateKeyPem) {
normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem;
diagnostics.push({
code: "openclaw_gateway_device_key_configured",
level: "info",
message:
"Gateway device key configured. Pairing approvals should persist for this agent."
});
} else if (!disableDeviceAuth) {
try {
normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem();
diagnostics.push({
code: "openclaw_gateway_device_key_generated",
level: "info",
message:
"Generated persistent gateway device key for this join. Pairing approvals should persist for this agent."
});
} catch (err) {
diagnostics.push({
code: "openclaw_gateway_device_key_generate_failed",
level: "warn",
message: `Failed to generate gateway device key: ${
err instanceof Error ? err.message : String(err)
}`,
hint:
"Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true."
});
fatalErrors.push(
"Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true."
);
}
}
const waitTimeoutMs =
typeof defaults.waitTimeoutMs === "number" &&
Number.isFinite(defaults.waitTimeoutMs)
@@ -1293,7 +1348,7 @@ function buildInviteOnboardingManifest(
adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents",
capabilities: "Optional capability summary",
agentDefaultsPayload:
"Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth."
"Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem."
},
registrationEndpoint: {
method: "POST",
@@ -1430,7 +1485,6 @@ export function buildInviteOnboardingTextDocument(
waitTimeoutMs: 120000,
sessionKeyStrategy: "fixed",
sessionKey: "paperclip",
disableDeviceAuth: true,
role: "operator",
scopes: ["operator.admin"]
}
@@ -1447,8 +1501,9 @@ export function buildInviteOnboardingTextDocument(
Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred.
Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL.
Pairing mode requirement:
- For smoke/dev, set "disableDeviceAuth": true to avoid interactive pairing blocks.
- If device auth remains enabled, set a stable "devicePrivateKeyPem"; otherwise each run may generate a new device identity and trigger pairing again.
- Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable.
- You may set disableDeviceAuth=true only for special environments that cannot support pairing.
- First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry.
Do NOT use /v1/responses or /hooks/* in this gateway join flow.
Body (JSON):
@@ -1464,7 +1519,6 @@ export function buildInviteOnboardingTextDocument(
"waitTimeoutMs": 120000,
"sessionKeyStrategy": "fixed",
"sessionKey": "paperclip",
"disableDeviceAuth": true,
"role": "operator",
"scopes": ["operator.admin"]
}

View File

@@ -486,8 +486,8 @@ When you submit the join request, use:
- \`agentDefaultsPayload.url\` as your \`ws://\` or \`wss://\` gateway URL
- \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token
- (legacy accepted) \`agentDefaultsPayload.headers["x-openclaw-auth"]\`
- For stock smoke/dev onboarding: set \`agentDefaultsPayload.disableDeviceAuth = true\` to avoid repeated pairing prompts.
- If keeping device auth enabled, provide a stable \`agentDefaultsPayload.devicePrivateKeyPem\`; otherwise a new ephemeral device ID may require pairing every run.
- Keep device auth enabled (recommended). If \`devicePrivateKeyPem\` is omitted, Paperclip will generate and persist one during join so pairing approvals remain stable.
- Only use \`disableDeviceAuth=true\` for special environments where pairing cannot be completed.
Do NOT use \`/v1/responses\` or \`/hooks/*\` in this join flow.