Merge pull request #256 from paperclipai/dotta

Dotta
This commit is contained in:
Dotta
2026-03-07 17:18:46 -06:00
committed by GitHub
10 changed files with 3478 additions and 105 deletions

View File

@@ -25,29 +25,53 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
6. Case A (manual issue test).
6. Gateway preflight (required before task tests).
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
- Confirm gateway URL is `ws://...` or `wss://...`.
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
- Confirm pairing mode is explicit:
- 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`, `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.
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
- Verify in UI: issue status becomes `done` and comment exists.
7. Case B (message tool test).
8. Case B (message tool test).
- Create another issue assigned to OpenClaw.
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
- Verify both:
- marker comment on issue
- marker text appears in OpenClaw main chat
8. Case C (new session memory/skills test).
9. Case C (new session memory/skills test).
- In OpenClaw, start `/new` session.
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
- Verify in Paperclip UI that new issue exists.
9. Watch logs during test (optional but helpful):
10. Watch logs during test (optional but helpful):
```bash
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
```
10. Expected pass criteria.
11. Expected pass criteria.
- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`).
- 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.

1140
doc/plugins/PLUGIN_SPEC.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -78,26 +78,27 @@ Implication:
## Deep Code Findings (Gaps)
### 1) Onboarding content is still OpenClaw-HTTP specific
`server/src/routes/access.ts` hardcodes onboarding to:
- `recommendedAdapterType: "openclaw"`
- Required `agentDefaultsPayload.headers.x-openclaw-auth`
- HTTP callback URL guidance and `/v1/responses` examples.
### 1) Onboarding manifest/text gateway path (resolved)
Resolved in `server/src/routes/access.ts`:
- `recommendedAdapterType` now points to `openclaw_gateway`.
- Onboarding examples now require `adapterType: "openclaw_gateway"` + `ws://`/`wss://` URL + gateway token header.
- Added fail-fast guidance for short/placeholder tokens.
There is no adapter-specific onboarding manifest/text for `openclaw_gateway`.
### 2) Company settings snippet gateway path (resolved)
Resolved in `ui/src/pages/CompanySettings.tsx`:
- Snippet now instructs OpenClaw Gateway onboarding.
- Snippet explicitly says not to use `/v1/responses` or `/hooks/*` for this flow.
### 2) Company settings snippet is OpenClaw HTTP-first
`ui/src/pages/CompanySettings.tsx` generates one snippet that:
- Assumes OpenClaw HTTP callback setup.
- Instructs enabling `gateway.http.endpoints.responses.enabled=true`.
- Does not provide a dedicated gateway onboarding path.
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters (open)
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"`
`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific.
No equivalent normalization/diagnostics for gateway defaults.
### 4) Join normalization/replay logic parity (partially resolved)
Resolved:
- `buildJoinDefaultsPayloadForAccept` now normalizes wrapped gateway token headers for `openclaw_gateway`.
- `normalizeAgentDefaultsForJoin` now validates `openclaw_gateway` URL/token and rejects short placeholder tokens at invite-accept time.
Still open:
- Invite replay path is still special-cased to legacy `openclaw` joins.
### 5) Webhook confusion is expected in current setup
For `openclaw` + `streamTransport=webhook`:
@@ -257,11 +258,23 @@ POST /api/companies/$CLA_COMPANY_ID/invites
```
3. Approve join request.
4. Claim API key with `claimSecret`.
5. 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.
4. **Hard gate before any task run:** fetch created agent config and validate:
- `adapterType == "openclaw_gateway"`
- `adapterConfig.url` uses `ws://` or `wss://`
- `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:
- 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.
6. Ensure Paperclip skill is installed for OpenClaw runtime.
7. 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
@@ -311,6 +324,8 @@ Responsibilities:
- CLA company resolution.
- Old OpenClaw agent cleanup.
- Invite/join/approve/claim orchestration.
- Gateway agent config/token preflight validation before connectivity or case execution.
- Pairing-mode preflight (`disableDeviceAuth=false` + stable `devicePrivateKeyPem` by default).
- E2E case execution + assertions.
- Final summary with run IDs, issue IDs, agent ID.
@@ -347,5 +362,6 @@ Responsibilities:
## Acceptance Criteria
- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal.
- Gateway onboarding is first-class and copy/pasteable from company settings.
- Gateway join fails fast if token is missing/placeholder, and smoke preflight verifies adapter/token parity before task runs.
- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup.
- All three validation cases are documented with pass/fail criteria and reproducible evidence paths.

View File

@@ -1074,15 +1074,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
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}. Configure adapterConfig.disableDeviceAuth=true for smoke/dev, or set adapterConfig.devicePrivateKeyPem so pairing persists across runs.`
: message;
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${message}\n`);
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
return {
exitCode: 1,
signal: null,
timedOut,
errorMessage: message,
errorCode: timedOut ? "openclaw_gateway_timeout" : "openclaw_gateway_request_failed",
errorMessage: detailedMessage,
errorCode: timedOut
? "openclaw_gateway_timeout"
: pairingRequired
? "openclaw_gateway_pairing_required"
: "openclaw_gateway_request_failed",
resultJson: asRecord(latestResultPayload),
};
} finally {

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,
@@ -524,6 +524,73 @@ inject_agent_api_key_payload_template() {
assert_status "200"
}
validate_joined_gateway_agent() {
local expected_gateway_token="$1"
api_request "GET" "/agents/${AGENT_ID}"
assert_status "200"
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}'"
[[ -n "$configured_token" ]] || fail "joined agent missing adapterConfig.headers.x-openclaw-token"
if (( ${#configured_token} < 16 )); then
fail "joined agent gateway token looks too short (${#configured_token} chars)"
fi
local expected_hash configured_hash
expected_hash="$(hash_prefix "$expected_gateway_token")"
configured_hash="$(hash_prefix "$configured_token")"
if [[ "$expected_hash" != "$configured_hash" ]]; then
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:-}"
@@ -764,8 +831,9 @@ run_case_c() {
local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)"
local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)"
local original_issue_reference="the original case issue you are currently reading"
local description
description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on this issue containing exactly: ${ack_marker}\nThen mark this issue done."
description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on ${original_issue_reference} containing exactly: ${ack_marker}\nDo NOT post the ACK comment on the newly created issue.\nThen mark the original case issue done."
local created
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")"
@@ -840,14 +908,32 @@ main() {
create_and_approve_gateway_join "$gateway_token"
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

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", () => {
@@ -208,4 +211,84 @@ describe("buildJoinDefaultsPayloadForAccept", () => {
expect(result).toEqual(defaultsPayload);
});
it("normalizes wrapped gateway token headers for openclaw_gateway", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": {
value: "gateway-token-1234567890",
},
},
},
}) as Record<string, unknown>;
expect(result).toMatchObject({
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
it("accepts inbound x-openclaw-token for openclaw_gateway", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
},
inboundOpenClawTokenHeader: "gateway-token-1234567890",
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
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

@@ -37,21 +37,22 @@ describe("buildInviteOnboardingTextDocument", () => {
allowedHostnames: [],
});
expect(text).toContain("Paperclip OpenClaw Onboarding");
expect(text).toContain("Paperclip OpenClaw Gateway Onboarding");
expect(text).toContain("/api/invites/token-123/accept");
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
expect(text).toContain("/api/invites/token-123/onboarding.txt");
expect(text).toContain("/api/invites/token-123/test-resolution");
expect(text).toContain("Suggested Paperclip base URLs to try");
expect(text).toContain("http://localhost:3100");
expect(text).toContain("host.docker.internal");
expect(text).toContain("paperclipApiUrl");
expect(text).toContain("You MUST include agentDefaultsPayload.headers.x-openclaw-auth");
expect(text).toContain("will fail with 401 Unauthorized");
expect(text).toContain("adapterType \"openclaw_gateway\"");
expect(text).toContain("headers.x-openclaw-token");
expect(text).toContain("Do NOT use /v1/responses or /hooks/*");
expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
expect(text).toContain("PAPERCLIP_API_KEY");
expect(text).toContain("saved token field");
expect(text).toContain("Gateway token unexpectedly short");
});
it("includes loopback diagnostics for authenticated/private onboarding", () => {

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";
@@ -311,6 +316,32 @@ function toAuthorizationHeaderValue(rawToken: string): string {
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
}
function tokenFromAuthorizationHeader(rawHeader: string | null): string | null {
const trimmed = nonEmptyTrimmedString(rawHeader);
if (!trimmed) return null;
const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i);
if (bearerMatch?.[1]) {
return nonEmptyTrimmedString(bearerMatch[1]);
}
return trimmed;
}
function parseBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
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;
@@ -322,6 +353,59 @@ export function buildJoinDefaultsPayloadForAccept(input: {
inboundOpenClawAuthHeader?: string | null;
inboundOpenClawTokenHeader?: string | null;
}): unknown {
if (input.adapterType === "openclaw_gateway") {
const merged = isPlainObject(input.defaultsPayload)
? { ...(input.defaultsPayload as Record<string, unknown>) }
: ({} as Record<string, unknown>);
if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
}
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(
input.inboundOpenClawAuthHeader
);
const inboundOpenClawTokenHeader = nonEmptyTrimmedString(
input.inboundOpenClawTokenHeader
);
if (
inboundOpenClawTokenHeader &&
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
) {
mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader;
}
if (
inboundOpenClawAuthHeader &&
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")
) {
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
}
const discoveredToken =
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
tokenFromAuthorizationHeader(
headerMapGetIgnoreCase(mergedHeaders, "authorization")
);
if (
discoveredToken &&
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
) {
mergedHeaders["x-openclaw-token"] = discoveredToken;
}
if (Object.keys(mergedHeaders).length > 0) {
merged.headers = mergedHeaders;
} else {
delete merged.headers;
}
return Object.keys(merged).length > 0 ? merged : null;
}
if (input.adapterType !== "openclaw") {
return input.defaultsPayload;
}
@@ -516,6 +600,43 @@ function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) {
};
}
function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
const defaults = isPlainObject(defaultsPayload)
? (defaultsPayload as Record<string, unknown>)
: null;
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
const gatewayTokenValue = headers
? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
tokenFromAuthorizationHeader(
headerMapGetIgnoreCase(headers, "authorization")
)
: null;
return {
present: Boolean(defaults),
keys: defaults ? Object.keys(defaults).sort() : [],
url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
paperclipApiUrl: defaults
? nonEmptyTrimmedString(defaults.paperclipApiUrl)
: null,
headerKeys: headers ? Object.keys(headers).sort() : [],
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)
};
}
function buildJoinConnectivityDiagnostics(input: {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
@@ -589,7 +710,7 @@ function buildJoinConnectivityDiagnostics(input: {
return diagnostics;
}
function normalizeAgentDefaultsForJoin(input: {
export function normalizeAgentDefaultsForJoin(input: {
adapterType: string | null;
defaultsPayload: unknown;
deploymentMode: DeploymentMode;
@@ -597,12 +718,259 @@ function normalizeAgentDefaultsForJoin(input: {
bindHost: string;
allowedHostnames: string[];
}) {
const fatalErrors: string[] = [];
const diagnostics: JoinDiagnostic[] = [];
if (input.adapterType !== "openclaw") {
if (
input.adapterType !== "openclaw" &&
input.adapterType !== "openclaw_gateway"
) {
const normalized = isPlainObject(input.defaultsPayload)
? (input.defaultsPayload as Record<string, unknown>)
: null;
return { normalized, diagnostics };
return { normalized, diagnostics, fatalErrors };
}
if (input.adapterType === "openclaw_gateway") {
if (!isPlainObject(input.defaultsPayload)) {
diagnostics.push({
code: "openclaw_gateway_defaults_missing",
level: "warn",
message:
"No OpenClaw gateway config was provided in agentDefaultsPayload.",
hint:
"Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins."
});
fatalErrors.push(
"agentDefaultsPayload is required for adapterType=openclaw_gateway"
);
return {
normalized: null as Record<string, unknown> | null,
diagnostics,
fatalErrors
};
}
const defaults = input.defaultsPayload as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
let gatewayUrl: URL | null = null;
const rawGatewayUrl = nonEmptyTrimmedString(defaults.url);
if (!rawGatewayUrl) {
diagnostics.push({
code: "openclaw_gateway_url_missing",
level: "warn",
message: "OpenClaw gateway URL is missing.",
hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL."
});
fatalErrors.push("agentDefaultsPayload.url is required");
} else {
try {
gatewayUrl = new URL(rawGatewayUrl);
if (
gatewayUrl.protocol !== "ws:" &&
gatewayUrl.protocol !== "wss:"
) {
diagnostics.push({
code: "openclaw_gateway_url_protocol",
level: "warn",
message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).`
});
fatalErrors.push(
"agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway"
);
} else {
normalized.url = gatewayUrl.toString();
diagnostics.push({
code: "openclaw_gateway_url_configured",
level: "info",
message: `Gateway endpoint set to ${gatewayUrl.toString()}`
});
}
} catch {
diagnostics.push({
code: "openclaw_gateway_url_invalid",
level: "warn",
message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}`
});
fatalErrors.push("agentDefaultsPayload.url is not a valid URL");
}
}
const headers = normalizeHeaderMap(defaults.headers) ?? {};
const gatewayToken =
headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
if (
gatewayToken &&
!headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")
) {
headers["x-openclaw-token"] = gatewayToken;
}
if (Object.keys(headers).length > 0) {
normalized.headers = headers;
}
if (!gatewayToken) {
diagnostics.push({
code: "openclaw_gateway_auth_header_missing",
level: "warn",
message: "Gateway auth token is missing from agent defaults.",
hint:
"Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)."
});
fatalErrors.push(
"agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required"
);
} else if (gatewayToken.trim().length < 16) {
diagnostics.push({
code: "openclaw_gateway_auth_header_too_short",
level: "warn",
message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`,
hint:
"Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)."
});
fatalErrors.push(
"agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token"
);
} else {
diagnostics.push({
code: "openclaw_gateway_auth_header_configured",
level: "info",
message: "Gateway auth token configured."
});
}
if (isPlainObject(defaults.payloadTemplate)) {
normalized.payloadTemplate = defaults.payloadTemplate;
}
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)
? Math.floor(defaults.waitTimeoutMs)
: typeof defaults.waitTimeoutMs === "string"
? Number.parseInt(defaults.waitTimeoutMs.trim(), 10)
: NaN;
if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) {
normalized.waitTimeoutMs = waitTimeoutMs;
}
const timeoutSec =
typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)
? Math.floor(defaults.timeoutSec)
: typeof defaults.timeoutSec === "string"
? Number.parseInt(defaults.timeoutSec.trim(), 10)
: NaN;
if (Number.isFinite(timeoutSec) && timeoutSec > 0) {
normalized.timeoutSec = timeoutSec;
}
const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy);
if (
sessionKeyStrategy === "fixed" ||
sessionKeyStrategy === "issue" ||
sessionKeyStrategy === "run"
) {
normalized.sessionKeyStrategy = sessionKeyStrategy;
}
const sessionKey = nonEmptyTrimmedString(defaults.sessionKey);
if (sessionKey) {
normalized.sessionKey = sessionKey;
}
const role = nonEmptyTrimmedString(defaults.role);
if (role) {
normalized.role = role;
}
if (Array.isArray(defaults.scopes)) {
const scopes = defaults.scopes
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
if (scopes.length > 0) {
normalized.scopes = scopes;
}
}
const rawPaperclipApiUrl =
typeof defaults.paperclipApiUrl === "string"
? defaults.paperclipApiUrl.trim()
: "";
if (rawPaperclipApiUrl) {
try {
const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
if (
parsedPaperclipApiUrl.protocol !== "http:" &&
parsedPaperclipApiUrl.protocol !== "https:"
) {
diagnostics.push({
code: "openclaw_gateway_paperclip_api_url_protocol",
level: "warn",
message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`
});
} else {
normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString();
diagnostics.push({
code: "openclaw_gateway_paperclip_api_url_configured",
level: "info",
message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`
});
}
} catch {
diagnostics.push({
code: "openclaw_gateway_paperclip_api_url_invalid",
level: "warn",
message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`
});
}
}
return { normalized, diagnostics, fatalErrors };
}
if (!isPlainObject(input.defaultsPayload)) {
@@ -613,7 +981,11 @@ function normalizeAgentDefaultsForJoin(input: {
"No OpenClaw callback config was provided in agentDefaultsPayload.",
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval."
});
return { normalized: null as Record<string, unknown> | null, diagnostics };
return {
normalized: null as Record<string, unknown> | null,
diagnostics,
fatalErrors
};
}
const defaults = input.defaultsPayload as Record<string, unknown>;
@@ -790,7 +1162,7 @@ function normalizeAgentDefaultsForJoin(input: {
})
);
return { normalized, diagnostics };
return { normalized, diagnostics, fatalErrors };
}
function toInviteSummaryResponse(
@@ -950,10 +1322,6 @@ function buildInviteOnboardingManifest(
const onboardingTextUrl = baseUrl
? `${baseUrl}${onboardingTextPath}`
: onboardingTextPath;
const testResolutionPath = `/api/invites/${token}/test-resolution`;
const testResolutionUrl = baseUrl
? `${baseUrl}${testResolutionPath}`
: testResolutionPath;
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({
apiBaseUrl: baseUrl,
deploymentMode: opts.deploymentMode,
@@ -971,16 +1339,16 @@ function buildInviteOnboardingManifest(
invite: toInviteSummaryResponse(req, token, invite),
onboarding: {
instructions:
"Join as an OpenClaw agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST include agentDefaultsPayload.headers.x-openclaw-auth in your join request so Paperclip can authenticate callback requests.",
"Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).",
inviteMessage: extractInviteMessage(invite),
recommendedAdapterType: "openclaw",
recommendedAdapterType: "openclaw_gateway",
requiredFields: {
requestType: "agent",
agentName: "Display name for this agent",
adapterType: "Use 'openclaw' for OpenClaw agents",
adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents",
capabilities: "Optional capability summary",
agentDefaultsPayload:
"Adapter config for OpenClaw endpoint. MUST include headers.x-openclaw-auth; include streamTransport ('sse' or 'webhook') plus url/method/paperclipApiUrl (and optional webhookAuthHeader/timeoutSec/payloadTemplate)."
"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",
@@ -1001,21 +1369,12 @@ function buildInviteOnboardingManifest(
bindHost: opts.bindHost,
allowedHostnames: opts.allowedHostnames,
connectionCandidates,
testResolutionEndpoint: {
method: "GET",
path: testResolutionPath,
url: testResolutionUrl,
query: {
url: "https://your-openclaw-agent.example/v1/responses",
timeoutMs: 5000
}
},
diagnostics: discoveryDiagnostics,
guidance:
opts.deploymentMode === "authenticated" &&
opts.deploymentExposure === "private"
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
: "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims."
: "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls."
},
textInstructions: {
path: onboardingTextPath,
@@ -1076,7 +1435,7 @@ export function buildInviteOnboardingTextDocument(
};
appendBlock(`
# Paperclip OpenClaw Onboarding
# Paperclip OpenClaw Gateway Onboarding
This document is meant to be readable by both humans and agents.
@@ -1104,6 +1463,7 @@ export function buildInviteOnboardingTextDocument(
TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1)
test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1)
3) IMPORTANT: Don't accidentally drop the token when generating JSON
If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var.
@@ -1116,15 +1476,17 @@ export function buildInviteOnboardingTextDocument(
const body = {
requestType: "agent",
agentName: "OpenClaw",
adapterType: "openclaw",
adapterType: "openclaw_gateway",
capabilities: "OpenClaw agent adapter",
agentDefaultsPayload: {
url: "http://127.0.0.1:18789/v1/responses",
url: "ws://127.0.0.1:18789",
paperclipApiUrl: "http://host.docker.internal:3100",
streamTransport: "sse",
method: "POST",
headers: { "x-openclaw-auth": token },
timeoutSec: 0
headers: { "x-openclaw-token": token },
waitTimeoutMs: 120000,
sessionKeyStrategy: "fixed",
sessionKey: "paperclip",
role: "operator",
scopes: ["operator.admin"]
}
};
process.stdout.write(JSON.stringify(body));
@@ -1135,23 +1497,30 @@ export function buildInviteOnboardingTextDocument(
onboarding.registrationEndpoint.url
}
IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-auth with your gateway token.
Without this token, Paperclip callback requests to your OpenClaw endpoint will fail with 401 Unauthorized.
Set "streamTransport" to "sse" for streaming /v1/responses endpoints, or "webhook" for wake-style callbacks.
IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token.
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:
- 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):
{
"requestType": "agent",
"agentName": "My OpenClaw Agent",
"adapterType": "openclaw",
"adapterType": "openclaw_gateway",
"capabilities": "Optional summary",
"agentDefaultsPayload": {
"url": "https://your-openclaw-agent.example/v1/responses",
"url": "wss://your-openclaw-gateway.example",
"paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",
"streamTransport": "sse",
"method": "POST",
"headers": { "x-openclaw-auth": "replace-me" },
"timeoutSec": 0
"headers": { "x-openclaw-token": "replace-me" },
"waitTimeoutMs": 120000,
"sessionKeyStrategy": "fixed",
"sessionKey": "paperclip",
"role": "operator",
"scopes": ["operator.admin"]
}
}
@@ -1160,11 +1529,6 @@ export function buildInviteOnboardingTextDocument(
- one-time claimSecret
- claimApiKeyPath
Verify the response diagnostics include:
'openclaw_auth_header_configured'
and do not include:
'openclaw_auth_header_missing'
## Step 2: Wait for board approval
The board approves the join request in Paperclip before key claim is allowed.
@@ -1218,17 +1582,6 @@ export function buildInviteOnboardingTextDocument(
}
`);
if (onboarding.connectivity?.testResolutionEndpoint?.url) {
appendBlock(`
## Optional: test callback resolution from Paperclip
${onboarding.connectivity.testResolutionEndpoint.method ?? "GET"} ${
onboarding.connectivity.testResolutionEndpoint.url
}?url=https%3A%2F%2Fyour-openclaw-agent.example%2Fv1%2Fresponses
This endpoint checks whether Paperclip can reach your OpenClaw endpoint and reports reachable, timeout, or unreachable.
`);
}
const connectionCandidates = Array.isArray(
onboarding.connectivity?.connectionCandidates
)
@@ -1271,9 +1624,6 @@ export function buildInviteOnboardingTextDocument(
${onboarding.skill.path}
${manifest.invite.onboardingPath}
`);
if (onboarding.connectivity?.testResolutionEndpoint?.path) {
lines.push(`${onboarding.connectivity.testResolutionEndpoint.path}`);
}
return `${lines.join("\n")}\n`;
}
@@ -1958,9 +2308,14 @@ export function accessRoutes(
})
: {
normalized: null as Record<string, unknown> | null,
diagnostics: [] as JoinDiagnostic[]
diagnostics: [] as JoinDiagnostic[],
fatalErrors: [] as string[]
};
if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) {
throw badRequest(joinDefaults.fatalErrors.join("; "));
}
if (requestType === "agent" && adapterType === "openclaw") {
logger.info(
{
@@ -1977,6 +2332,22 @@ export function accessRoutes(
);
}
if (requestType === "agent" && adapterType === "openclaw_gateway") {
logger.info(
{
inviteId: invite.id,
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
code: diag.code,
level: diag.level
})),
normalizedAgentDefaults: summarizeOpenClawGatewayDefaultsForLog(
joinDefaults.normalized
)
},
"invite accept normalized OpenClaw gateway defaults"
);
}
const claimSecret =
requestType === "agent" && !inviteAlreadyAccepted
? createClaimSecret()

View File

@@ -461,7 +461,7 @@ Suggested command:
Then verify with: curl -fsS <base-url>/api/health`;
const resolutionLine = resolutionTestUrl
? `\nYou MUST test callback reachability, call: ${resolutionTestUrl}?url=<urlencoded-webhook-url> (well using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
? `\nYou MUST test Paperclip-to-gateway reachability, call: ${resolutionTestUrl}?url=<urlencoded-gateway-url> (using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
: "";
return `You're invited to join a Paperclip organization.
@@ -476,13 +476,20 @@ Verify the hostname works from your runtime with: GET <base-url>/api/health
${connectivityBlock}
For your "agentName", just use your own agent name (OpenClaw or whatever your name is) and send it directly.
You also need to send a webhook URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
You also need to send a gateway URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
---
In order for Paperclip to work smoothly, you need to enable \`gateway.http.endpoints.responses.enabled=true\` in your OpenClaw config.
This onboarding flow is for OpenClaw Gateway.
When you submit the join request, use:
- \`adapterType: "openclaw_gateway"\`
- \`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"]\`
- 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.
Please enable that setting and notify your user that we did so.
Do NOT use \`/v1/responses\` or \`/hooks/*\` in this join flow.
Before you do anything, please respond to your user that you understand the instructions and you're going to work on them. Then do the step above in another session called "paperclip-onboarding" and then tell your user when you're done. Update your user in intermediate steps along the way so they know what's going on.