diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index 877fc360..b55e755a 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -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="" 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. 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 0f2e81f6..042b8656 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -250,7 +250,6 @@ POST /api/companies/$CLA_COMPANY_ID/invites "headers": { "x-openclaw-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 ` + - 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. diff --git a/scripts/smoke/openclaw-gateway-e2e.sh b/scripts/smoke/openclaw-gateway-e2e.sh index eca90d60..b1a17e50 100755 --- a/scripts/smoke/openclaw-gateway-e2e.sh +++ b/scripts/smoke/openclaw-gateway-e2e.sh @@ -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 + 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")" - [[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run failed: ${connect_status}" - log "connectivity wake run ${RUN_ID} succeeded" - fi + 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 diff --git a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts index 3cb34f8b..dc7b58e1 100644 --- a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts +++ b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts @@ -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(); + }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 56ca7a92..406e4bd3 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -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"] } diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 129556b5..c11bd8b9 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -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.