diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index e31a6f8b..10c53030 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -25,29 +25,41 @@ 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). +- 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)}}' +``` +- Expected: `adapterType=openclaw_gateway` and `tokenLen >= 16`. + +7. Case A (manual issue test). - Create an issue assigned to the OpenClaw agent. - Put instructions: “post comment `OPENCLAW_CASE_A_OK_` 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_` 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_`. - 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`). - 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 6c804d22..04331357 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -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,16 @@ 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 +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. - 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. +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. ## 6) E2E Validation Cases @@ -311,6 +317,7 @@ Responsibilities: - CLA company resolution. - Old OpenClaw agent cleanup. - Invite/join/approve/claim orchestration. +- Gateway agent config/token preflight validation before connectivity or case execution. - E2E case execution + assertions. - Final summary with run IDs, issue IDs, agent ID. @@ -347,5 +354,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. diff --git a/scripts/smoke/openclaw-gateway-e2e.sh b/scripts/smoke/openclaw-gateway-e2e.sh index e45df9f9..476c4466 100755 --- a/scripts/smoke/openclaw-gateway-e2e.sh +++ b/scripts/smoke/openclaw-gateway-e2e.sh @@ -524,6 +524,34 @@ 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 + 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")" + + [[ "$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 + + log "validated joined gateway agent config (token sha256 prefix ${configured_hash})" +} + trigger_wakeup() { local reason="$1" local issue_id="${2:-}" @@ -840,6 +868,7 @@ 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 diff --git a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts index b94dd55d..3cb34f8b 100644 --- a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts +++ b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts @@ -208,4 +208,41 @@ 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; + + 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; + + expect(result).toMatchObject({ + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); }); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 0380e8f1..8ba30115 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -52,6 +52,7 @@ describe("buildInviteOnboardingTextDocument", () => { 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", () => { diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 3c4a9549..d83d3a83 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -311,6 +311,25 @@ 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; +} + export function buildJoinDefaultsPayloadForAccept(input: { adapterType: string | null; defaultsPayload: unknown; @@ -322,6 +341,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) } + : ({} as Record); + + 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 +588,37 @@ function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) { }; } +function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) { + const defaults = isPlainObject(defaultsPayload) + ? (defaultsPayload as Record) + : 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, + waitTimeoutMs: + defaults && typeof defaults.waitTimeoutMs === "number" + ? defaults.waitTimeoutMs + : null, + gatewayToken: summarizeSecretForLog(gatewayTokenValue) + }; +} + function buildJoinConnectivityDiagnostics(input: { deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; @@ -597,12 +700,222 @@ 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) : 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 | null, + diagnostics, + fatalErrors + }; + } + + const defaults = input.defaultsPayload as Record; + const normalized: Record = {}; + + 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); + if (parsedDisableDeviceAuth !== null) { + normalized.disableDeviceAuth = parsedDisableDeviceAuth; + } + + 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 +926,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 | null, diagnostics }; + return { + normalized: null as Record | null, + diagnostics, + fatalErrors + }; } const defaults = input.defaultsPayload as Record; @@ -790,7 +1107,7 @@ function normalizeAgentDefaultsForJoin(input: { }) ); - return { normalized, diagnostics }; + return { normalized, diagnostics, fatalErrors }; } function toInviteSummaryResponse( @@ -1091,6 +1408,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. @@ -1931,9 +2249,14 @@ export function accessRoutes( }) : { normalized: null as Record | 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( { @@ -1950,6 +2273,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()