From 271a632f1c0ce92b5b64f5f4e390cc06370b2fd0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 15:39:12 -0600 Subject: [PATCH 01/14] fix(openclaw): make invite snippet/onboarding gateway-first --- .../__tests__/invite-onboarding-text.test.ts | 8 +- server/src/routes/access.ts | 79 ++++++------------- ui/src/pages/CompanySettings.tsx | 13 ++- 3 files changed, 39 insertions(+), 61 deletions(-) diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 6f22c2d5..0380e8f1 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -37,17 +37,17 @@ 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"); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 186b8515..3c4a9549 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -950,10 +950,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 +967,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." }, registrationEndpoint: { method: "POST", @@ -1001,21 +997,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 `." - : "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 +1063,7 @@ export function buildInviteOnboardingTextDocument( }; appendBlock(` - # Paperclip OpenClaw Onboarding + # Paperclip OpenClaw Gateway Onboarding This document is meant to be readable by both humans and agents. @@ -1116,15 +1103,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 +1124,26 @@ 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. + 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 +1152,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 +1205,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 +1247,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`; } diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 0b9cd255..8cfda155 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -461,7 +461,7 @@ Suggested command: Then verify with: curl -fsS /api/health`; const resolutionLine = resolutionTestUrl - ? `\nYou MUST test callback reachability, call: ${resolutionTestUrl}?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= (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,18 @@ Verify the hostname works from your runtime with: GET /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"]\` -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. From 83488b4ed08172c05089b1e11d1e4b65fc3c2cb9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 16:01:19 -0600 Subject: [PATCH 02/14] fix(openclaw-gateway): enforce join token validation and add smoke preflight gates --- doc/OPENCLAW_ONBOARDING.md | 22 +- .../doc/ONBOARDING_AND_TEST_PLAN.md | 48 ++- scripts/smoke/openclaw-gateway-e2e.sh | 29 ++ .../invite-accept-openclaw-defaults.test.ts | 37 ++ .../__tests__/invite-onboarding-text.test.ts | 1 + server/src/routes/access.ts | 349 +++++++++++++++++- 6 files changed, 456 insertions(+), 30 deletions(-) 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() From e27ec5de8cf98b77b6cb0777d8bf351e6d1f8ad0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 16:16:53 -0600 Subject: [PATCH 03/14] fix(smoke): disambiguate case C ack comment target --- scripts/smoke/openclaw-gateway-e2e.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/smoke/openclaw-gateway-e2e.sh b/scripts/smoke/openclaw-gateway-e2e.sh index 476c4466..eca90d60 100755 --- a/scripts/smoke/openclaw-gateway-e2e.sh +++ b/scripts/smoke/openclaw-gateway-e2e.sh @@ -792,8 +792,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")" From d52f1d4b44fc91474f66c5873d9a842eb8b5ca49 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 16:32:49 -0600 Subject: [PATCH 04/14] openclaw-gateway: document and surface pairing-mode requirements --- doc/OPENCLAW_ONBOARDING.md | 8 ++++++-- .../doc/ONBOARDING_AND_TEST_PLAN.md | 5 +++++ .../openclaw-gateway/src/server/execute.ts | 14 +++++++++++--- server/src/routes/access.ts | 5 +++++ ui/src/pages/CompanySettings.tsx | 2 ++ 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index 10c53030..877fc360 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -29,12 +29,15 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser. - 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: + - 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 - 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)}}' +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` and `tokenLen >= 16`. +- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, and (`disableDeviceAuth=true` OR `hasDeviceKey=true`). 7. Case A (manual issue test). - Create an issue assigned to the OpenClaw agent. @@ -60,6 +63,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. - 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 04331357..0f2e81f6 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -250,6 +250,7 @@ POST /api/companies/$CLA_COMPANY_ID/invites "headers": { "x-openclaw-token": "" }, "role": "operator", "scopes": ["operator.admin"], + "disableDeviceAuth": true, "sessionKeyStrategy": "fixed", "sessionKey": "paperclip", "waitTimeoutMs": 120000 @@ -263,6 +264,9 @@ POST /api/companies/$CLA_COMPANY_ID/invites - `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: + - 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. - Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch. @@ -318,6 +322,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`). - E2E case execution + assertions. - Final summary with run IDs, issue IDs, agent ID. diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 407e455b..ceec0b91 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -1074,15 +1074,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise Date: Sat, 7 Mar 2026 17:05:36 -0600 Subject: [PATCH 05/14] openclaw gateway: persist device keys and smoke pairing flow --- doc/OPENCLAW_ONBOARDING.md | 16 +++-- .../doc/ONBOARDING_AND_TEST_PLAN.md | 19 ++--- scripts/smoke/openclaw-gateway-e2e.sh | 72 ++++++++++++++++--- .../invite-accept-openclaw-defaults.test.ts | 48 ++++++++++++- server/src/routes/access.ts | 68 ++++++++++++++++-- ui/src/pages/CompanySettings.tsx | 4 +- 6 files changed, 197 insertions(+), 30 deletions(-) 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. From df0f101fbd4ef27cc803f39ddb854aec40aff14b Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 17:16:37 -0600 Subject: [PATCH 06/14] plugin spec draft ideas v0 --- doc/plugins/PLUGIN_SPEC.md | 1140 +++++++++++++++++++ doc/plugins/ideas-from-opencode.md | 1637 ++++++++++++++++++++++++++++ 2 files changed, 2777 insertions(+) create mode 100644 doc/plugins/PLUGIN_SPEC.md create mode 100644 doc/plugins/ideas-from-opencode.md diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md new file mode 100644 index 00000000..5c3d3993 --- /dev/null +++ b/doc/plugins/PLUGIN_SPEC.md @@ -0,0 +1,1140 @@ +# Paperclip Plugin System Specification + +Status: proposed complete spec for the post-V1 plugin system + +This document is the complete specification for Paperclip's plugin and extension architecture. +It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be read alongside the comparative analysis in [doc/plugins/ideas-from-opencode.md](./ideas-from-opencode.md). + +This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md). +It is the full target architecture for the plugin system that should follow V1. + +## 1. Scope + +This spec covers: + +- plugin packaging and installation +- runtime model +- trust model +- capability system +- UI extension surfaces +- event, job, and webhook surfaces +- workspace-oriented extension surfaces +- Postgres persistence for extensions +- operator workflows +- compatibility and upgrade rules + +This spec does not cover: + +- a public marketplace +- cloud/SaaS multi-tenancy +- arbitrary third-party schema migrations in the first plugin version +- arbitrary frontend bundle injection in the first plugin version + +## 2. Core Assumptions + +Paperclip plugin design is based on the following assumptions: + +1. Paperclip is single-tenant and self-hosted. +2. Plugin installation is global to the instance. +3. "Companies" remain core Paperclip business objects, but they are not plugin trust boundaries. +4. Board governance, approval gates, budget hard-stops, and core task invariants remain owned by Paperclip core. +5. Projects already have a real workspace model via `project_workspaces`, and local/runtime plugins should build on that instead of inventing a separate workspace abstraction. + +## 3. Goals + +The plugin system must: + +1. Let operators install global instance-wide plugins. +2. Let plugins add major capabilities without editing Paperclip core. +3. Keep core governance and auditing intact. +4. Support both local/runtime plugins and external SaaS connectors. +5. Support future plugin categories such as: + - new agent adapters + - revenue tracking + - knowledge base + - issue tracker sync + - metrics/dashboards + - file/project tooling +6. Use simple, explicit, typed contracts. +7. Keep failures isolated so one plugin does not crash the entire instance. + +## 4. Non-Goals + +The first plugin system must not: + +1. Allow arbitrary plugins to override core routes or core invariants. +2. Allow arbitrary plugins to mutate approval, auth, issue checkout, or budget enforcement logic. +3. Allow arbitrary third-party plugins to run free-form DB migrations. +4. Depend on project-local plugin folders such as `.paperclip/plugins`. +5. Depend on automatic install-and-execute behavior at server startup from arbitrary config files. + +## 5. Terminology + +### 5.1 Instance + +The single Paperclip deployment an operator installs and controls. + +### 5.2 Company + +A first-class Paperclip business object inside the instance. + +### 5.3 Project Workspace + +A workspace attached to a project through `project_workspaces`. +This is the primary local runtime anchor for file, terminal, git, and process tooling. + +### 5.4 Platform Module + +A trusted in-process extension loaded directly by Paperclip core. + +Examples: + +- agent adapters +- storage providers +- secret providers +- run-log backends + +### 5.5 Plugin + +An installable instance-wide extension package loaded through the Paperclip plugin runtime. + +Examples: + +- Linear sync +- GitHub Issues sync +- Grafana widgets +- Stripe revenue sync +- file browser +- terminal +- git workflow + +### 5.6 Plugin Worker + +The runtime process used for a plugin. +In this spec, third-party plugins run out-of-process by default. + +### 5.7 Capability + +A named permission the host grants to a plugin. +Plugins may only call host APIs that are covered by granted capabilities. + +## 6. Extension Classes + +Paperclip has two extension classes. + +## 6.1 Platform Modules + +Platform modules are: + +- trusted +- in-process +- host-integrated +- low-level + +They use explicit registries, not the general plugin worker protocol. + +Platform module surfaces: + +- `registerAgentAdapter()` +- `registerStorageProvider()` +- `registerSecretProvider()` +- `registerRunLogStore()` +- future `registerWorkspaceRuntime()` if needed + +Platform modules are the right place for: + +- new agent adapter packages +- new storage backends +- new secret backends +- other host-internal systems that need direct process or DB integration + +## 6.2 Plugins + +Plugins are: + +- globally installed per instance +- loaded through the plugin runtime +- additive +- capability-gated +- isolated from core via a stable SDK and host protocol + +Plugin categories: + +- `connector` +- `workspace` +- `automation` +- `ui` + +A plugin may declare more than one category. + +## 7. Project Workspaces Are The Local Tooling Anchor + +Paperclip already has a concrete workspace model: + +- projects expose `workspaces` +- projects expose `primaryWorkspace` +- the database contains `project_workspaces` +- project routes already manage workspaces +- heartbeat resolution already prefers project workspaces before falling back to task-session or agent-home workspaces + +Therefore: + +1. File plugins should browse project workspaces first. +2. Terminal sessions should launch against project workspaces by default. +3. Git plugins should treat the selected project workspace as the repo root anchor. +4. Process/server tracking should attach to project workspaces whenever possible. +5. Issue and agent views may deep-link into project workspace context. + +Project workspaces may exist in two modes: + +- local directory mode: `cwd` is present +- repo-only mode: `repoUrl` and optional `repoRef` exist, but there is no local `cwd` + +Plugins must handle repo-only workspaces explicitly: + +- they may show metadata +- they may show sync state +- they may not assume local file/PTY/git access until a real `cwd` exists + +## 8. Installation Model + +Plugin installation is global and operator-driven. + +There is no per-company install table and no per-company enable/disable switch. + +If a plugin needs business-object-specific mappings, those are stored as plugin configuration or plugin state. + +Examples: + +- one global Linear plugin install +- mappings from company A to Linear team X and company B to Linear team Y +- one global git plugin install +- per-project workspace state stored under `project_workspace` + +## 8.1 On-Disk Layout + +Plugins live under the Paperclip instance directory. + +Suggested layout: + +- `~/.paperclip/instances/default/plugins/package.json` +- `~/.paperclip/instances/default/plugins/node_modules/` +- `~/.paperclip/instances/default/plugins/.cache/` +- `~/.paperclip/instances/default/data/plugins//` + +The package install directory and the plugin data directory are separate. + +## 8.2 Operator Commands + +Paperclip should add CLI commands: + +- `pnpm paperclipai plugin list` +- `pnpm paperclipai plugin install ` +- `pnpm paperclipai plugin uninstall ` +- `pnpm paperclipai plugin upgrade [version]` +- `pnpm paperclipai plugin doctor ` + +These commands are instance-level operations. + +## 8.3 Install Process + +The install process is: + +1. Resolve npm package and version. +2. Install into the instance plugin directory. +3. Read and validate plugin manifest. +4. Reject incompatible plugin API versions. +5. Display requested capabilities to the operator. +6. Persist install record in Postgres. +7. Start plugin worker and run health/validation. +8. Mark plugin `ready` or `error`. + +## 9. Load Order And Precedence + +Load order must be deterministic. + +1. core platform modules +2. built-in first-party plugins +3. installed plugins sorted by: + - explicit operator-configured order if present + - otherwise manifest `id` + +Rules: + +- plugin contributions are additive by default +- plugins may not override core routes or core actions by name collision +- if two plugins contribute the same route slug or UI slot id, the host must reject startup or force the operator to resolve the collision explicitly + +## 10. Package Contract + +Each plugin package must export a manifest and a worker entrypoint. + +Suggested package layout: + +- `dist/manifest.js` +- `dist/worker.js` + +Suggested `package.json` keys: + +```json +{ + "name": "@paperclip/plugin-linear", + "version": "0.1.0", + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js" + } +} +``` + +## 10.1 Manifest Shape + +Normative manifest shape: + +```ts +export interface PaperclipPluginManifestV1 { + id: string; + apiVersion: 1; + version: string; + displayName: string; + description: string; + categories: Array<"connector" | "workspace" | "automation" | "ui">; + minimumPaperclipVersion?: string; + capabilities: string[]; + entrypoints: { + worker: string; + }; + instanceConfigSchema?: JsonSchema; + jobs?: PluginJobDeclaration[]; + webhooks?: PluginWebhookDeclaration[]; + ui?: PluginUiDeclaration; +} +``` + +Rules: + +- `id` must be globally unique +- `id` should normally equal the npm package name +- `apiVersion` must match the host-supported plugin API version +- `capabilities` must be static and install-time visible +- config schema must be JSON Schema compatible + +## 11. Runtime Model + +## 11.1 Process Model + +Third-party plugins run out-of-process by default. + +Default runtime: + +- Paperclip server starts one worker process per installed plugin +- the worker process is a Node process +- host and worker communicate over JSON-RPC on stdio + +This design provides: + +- failure isolation +- clearer logging boundaries +- easier resource limits +- a cleaner trust boundary than arbitrary in-process execution + +## 11.2 Host Responsibilities + +The host is responsible for: + +- package install +- manifest validation +- capability enforcement +- process supervision +- job scheduling +- webhook routing +- activity log writes +- secret resolution +- workspace service enforcement +- UI route registration + +## 11.3 Worker Responsibilities + +The plugin worker is responsible for: + +- validating its own config +- handling domain events +- handling scheduled jobs +- handling webhooks +- producing UI view models +- invoking host services through the SDK +- reporting health information + +## 11.4 Failure Policy + +If a worker fails: + +- mark plugin status `error` +- surface error in plugin health UI +- keep the rest of the instance running +- retry start with bounded backoff +- do not drop other plugins or core services + +## 12. Host-Worker Protocol + +The host must support the following worker RPC methods. + +Required methods: + +- `initialize(input)` +- `health()` +- `shutdown()` + +Optional methods: + +- `validateConfig(input)` +- `onEvent(input)` +- `runJob(input)` +- `handleWebhook(input)` +- `getPageModel(input)` +- `getWidgetModel(input)` +- `getDetailTabModel(input)` +- `performAction(input)` + +### 12.1 `initialize` + +Called once on worker startup. + +Input includes: + +- plugin manifest +- resolved plugin config +- instance info +- host API version + +### 12.2 `health` + +Returns: + +- status +- current error if any +- optional plugin-reported diagnostics + +### 12.3 `validateConfig` + +Runs after config changes and startup. + +Returns: + +- `ok` +- warnings +- errors + +### 12.4 `onEvent` + +Receives one typed Paperclip domain event. + +Delivery semantics: + +- at least once +- plugin must be idempotent +- no global ordering guarantee across all event types +- per-entity ordering is best effort but not guaranteed after retries + +### 12.5 `runJob` + +Runs a declared scheduled job. + +The host provides: + +- job key +- trigger source +- run id +- schedule metadata + +### 12.6 `handleWebhook` + +Receives inbound webhook payload routed by the host. + +The host provides: + +- endpoint key +- headers +- raw body +- parsed body if applicable +- request id + +### 12.7 `getPageModel` + +Returns a schema-driven view model for the plugin's main page. + +### 12.8 `getWidgetModel` + +Returns a schema-driven view model for a dashboard widget. + +### 12.9 `getDetailTabModel` + +Returns a schema-driven view model for a project, issue, agent, goal, or run detail tab. + +### 12.10 `performAction` + +Runs an explicit plugin action initiated by the board UI. + +Examples: + +- "resync now" +- "link GitHub issue" +- "create branch from issue" +- "restart process" + +## 13. SDK Surface + +Plugins do not talk to the DB directly. +Plugins do not read raw secret material from persisted config. +Plugins do not touch the filesystem directly outside the host services. + +The SDK exposed to workers must provide typed host clients. + +Required SDK clients: + +- `ctx.config` +- `ctx.events` +- `ctx.jobs` +- `ctx.http` +- `ctx.secrets` +- `ctx.assets` +- `ctx.activity` +- `ctx.state` +- `ctx.entities` +- `ctx.projects` +- `ctx.issues` +- `ctx.agents` +- `ctx.goals` +- `ctx.workspace` +- `ctx.logger` + +## 13.1 Example SDK Shape + +```ts +export interface PluginContext { + manifest: PaperclipPluginManifestV1; + config: { + get(): Promise>; + }; + events: { + on(name: string, fn: (event: unknown) => Promise): void; + }; + jobs: { + register(key: string, input: { cron: string }, fn: (job: PluginJobContext) => Promise): void; + }; + state: { + get(input: ScopeKey): Promise; + set(input: ScopeKey, value: unknown): Promise; + delete(input: ScopeKey): Promise; + }; + entities: { + upsert(input: PluginEntityUpsert): Promise; + list(input: PluginEntityQuery): Promise; + }; + workspace: WorkspacePluginApi; +} +``` + +## 14. Capability Model + +Capabilities are mandatory and static. +Every plugin declares them up front. + +The host enforces capabilities in the SDK layer and refuses calls outside the granted set. + +## 14.1 Capability Categories + +### Data Read + +- `companies.read` +- `projects.read` +- `project.workspaces.read` +- `issues.read` +- `issue.comments.read` +- `agents.read` +- `goals.read` +- `activity.read` +- `costs.read` + +### Data Write + +- `issues.create` +- `issues.update` +- `issue.comments.create` +- `assets.write` +- `assets.read` +- `activity.log.write` +- `metrics.write` + +### Runtime / Integration + +- `events.subscribe` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` + +### UI + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` +- `ui.dashboardWidget.register` +- `ui.action.register` + +### Workspace + +- `workspace.fs.read` +- `workspace.fs.write` +- `workspace.fs.stat` +- `workspace.fs.search` +- `workspace.pty.open` +- `workspace.pty.input` +- `workspace.pty.resize` +- `workspace.pty.terminate` +- `workspace.pty.subscribe` +- `workspace.git.status` +- `workspace.git.diff` +- `workspace.git.log` +- `workspace.git.branch.create` +- `workspace.git.commit` +- `workspace.git.worktree.create` +- `workspace.git.push` +- `workspace.process.register` +- `workspace.process.list` +- `workspace.process.read` +- `workspace.process.terminate` +- `workspace.process.restart` +- `workspace.process.logs.read` +- `workspace.http.probe` + +## 14.2 Forbidden Capabilities + +The host must not expose capabilities for: + +- approval decisions +- budget override +- auth bypass +- issue checkout lock override +- direct DB access +- direct filesystem access outside approved workspace services + +## 14.3 Upgrade Rules + +If a plugin upgrade adds capabilities: + +1. the host must mark the plugin `upgrade_pending` +2. the operator must explicitly approve the new capability set +3. the new version does not become `ready` until approval completes + +## 15. Event System + +The host must emit typed domain events that plugins may subscribe to. + +Minimum event set: + +- `company.created` +- `company.updated` +- `project.created` +- `project.updated` +- `project.workspace_created` +- `project.workspace_updated` +- `project.workspace_deleted` +- `issue.created` +- `issue.updated` +- `issue.comment.created` +- `agent.created` +- `agent.updated` +- `agent.status_changed` +- `agent.run.started` +- `agent.run.finished` +- `agent.run.failed` +- `agent.run.cancelled` +- `approval.created` +- `approval.decided` +- `cost_event.created` +- `activity.logged` + +Each event must include: + +- event id +- event type +- occurred at +- actor metadata when applicable +- primary entity metadata +- typed payload + +## 16. Scheduled Jobs + +Plugins may declare scheduled jobs in their manifest. + +Job rules: + +1. Each job has a stable `job_key`. +2. The host is the scheduler of record. +3. The host prevents overlapping execution of the same plugin/job combination unless explicitly allowed later. +4. Every job run is recorded in Postgres. +5. Failed jobs are retryable. + +## 17. Webhooks + +Plugins may declare webhook endpoints in their manifest. + +Webhook route shape: + +- `POST /api/plugins/:pluginId/webhooks/:endpointKey` + +Rules: + +1. The host owns the public route. +2. The worker receives the request body through `handleWebhook`. +3. Signature verification happens in plugin code using secret refs resolved by the host. +4. Every delivery is recorded. +5. Webhook handling must be idempotent. + +## 18. UI Extension Model + +The first plugin UI system is schema-driven. + +The host renders plugin data using built-in UI components. +Plugins return view models, not arbitrary React bundles. + +## 18.1 Global Operator Routes + +- `/settings/plugins` +- `/settings/plugins/:pluginId` + +These routes are instance-level. + +## 18.2 Company-Context Routes + +- `/:companyPrefix/plugins/:pluginId` + +These routes exist because the board UI is organized around companies even though plugin installation is global. + +## 18.3 Detail Tabs + +Plugins may add tabs to: + +- project detail +- issue detail +- agent detail +- goal detail +- run detail + +Recommended route pattern: + +- `/:companyPrefix//:id?tab=` + +## 18.4 Dashboard Widgets + +Plugins may add cards or sections to the dashboard. + +## 18.5 Sidebar Entries + +Plugins may add sidebar links to: + +- global plugin settings +- company-context plugin pages + +## 18.6 Allowed View Model Types + +The host should support a limited set of schema-rendered components: + +- metric cards +- status lists +- tables +- timeseries charts +- markdown text +- key/value blocks +- action bars +- log views +- JSON/debug views + +Arbitrary frontend bundle injection is explicitly out of scope for the first plugin system. + +## 19. Workspace Service APIs + +Workspace service APIs are the foundation for local tooling plugins. + +All workspace APIs must route through the host and validate against known project workspace roots. + +## 19.1 Project Workspace APIs + +Required host APIs: + +- list project workspaces +- get project primary workspace +- resolve project workspace from issue +- resolve current workspace from agent/run when available + +## 19.2 File APIs + +- read file +- write file +- stat path +- search path or filename +- list directory + +All file APIs take a resolved workspace anchor plus a relative path. + +## 19.3 PTY APIs + +- open terminal session +- send input +- resize +- terminate +- subscribe to output + +PTY sessions should default to the selected project workspace when one exists. + +## 19.4 Git APIs + +- status +- diff +- log +- branch create +- worktree create +- commit +- push + +Git APIs require a local `cwd`. +If the workspace is repo-only, the host must reject local git operations until a local checkout exists. + +## 19.5 Process APIs + +- register process +- list processes +- read process metadata +- terminate +- restart +- read logs +- probe health endpoint + +Process tracking should attach to `project_workspace` when possible. + +## 20. Persistence And Postgres + +## 20.1 Database Principles + +1. Core Paperclip data stays in first-party tables. +2. Most plugin-owned data starts in generic extension tables. +3. Plugin data should scope to existing Paperclip objects before new tables are introduced. +4. Arbitrary third-party schema migrations are out of scope for the first plugin system. + +## 20.2 Core Table Reuse + +If data becomes part of the actual Paperclip product model, it should become a first-party table. + +Examples: + +- `project_workspaces` is already first-party +- if Paperclip later decides git state is core product data, it should become a first-party table too + +## 20.3 Required Tables + +### `plugins` + +- `id` uuid pk +- `plugin_key` text unique not null +- `package_name` text not null +- `version` text not null +- `api_version` int not null +- `categories` text[] not null +- `manifest_json` jsonb not null +- `status` enum: `installed | ready | error | upgrade_pending` +- `install_order` int null +- `installed_at` timestamptz not null +- `updated_at` timestamptz not null +- `last_error` text null + +Indexes: + +- unique `plugin_key` +- `status` + +### `plugin_config` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` unique not null +- `config_json` jsonb not null +- `installed_at` timestamptz not null +- `updated_at` timestamptz not null +- `last_error` text null + +### `plugin_state` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum: `instance | company | project | project_workspace | agent | issue | goal | run` +- `scope_id` uuid/text null +- `namespace` text not null +- `state_key` text not null +- `value_json` jsonb not null +- `updated_at` timestamptz not null + +Constraints: + +- unique `(plugin_id, scope_kind, scope_id, namespace, state_key)` + +Examples: + +- Linear external IDs keyed by `issue` +- GitHub sync cursors keyed by `project` +- file browser preferences keyed by `project_workspace` +- git branch metadata keyed by `project_workspace` +- process metadata keyed by `project_workspace` or `run` + +### `plugin_jobs` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum nullable +- `scope_id` uuid/text null +- `job_key` text not null +- `schedule` text null +- `status` enum: `idle | queued | running | error` +- `next_run_at` timestamptz null +- `last_started_at` timestamptz null +- `last_finished_at` timestamptz null +- `last_succeeded_at` timestamptz null +- `last_error` text null + +Constraints: + +- unique `(plugin_id, scope_kind, scope_id, job_key)` + +### `plugin_job_runs` + +- `id` uuid pk +- `plugin_job_id` uuid fk `plugin_jobs.id` not null +- `plugin_id` uuid fk `plugins.id` not null +- `status` enum: `queued | running | succeeded | failed | cancelled` +- `trigger` enum: `schedule | manual | retry` +- `started_at` timestamptz null +- `finished_at` timestamptz null +- `error` text null +- `details_json` jsonb null + +Indexes: + +- `(plugin_id, started_at desc)` +- `(plugin_job_id, started_at desc)` + +### `plugin_webhook_deliveries` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum nullable +- `scope_id` uuid/text null +- `endpoint_key` text not null +- `status` enum: `received | processed | failed | ignored` +- `request_id` text null +- `headers_json` jsonb null +- `body_json` jsonb null +- `received_at` timestamptz not null +- `handled_at` timestamptz null +- `response_code` int null +- `error` text null + +Indexes: + +- `(plugin_id, received_at desc)` +- `(plugin_id, endpoint_key, received_at desc)` + +### `plugin_entities` (optional but recommended) + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `entity_type` text not null +- `scope_kind` enum not null +- `scope_id` uuid/text null +- `external_id` text null +- `title` text null +- `status` text null +- `data_json` jsonb not null +- `created_at` timestamptz not null +- `updated_at` timestamptz not null + +Indexes: + +- `(plugin_id, entity_type, external_id)` unique when `external_id` is not null +- `(plugin_id, scope_kind, scope_id, entity_type)` + +Use cases: + +- imported Linear issues +- imported GitHub issues +- plugin-owned process records +- plugin-owned external metric bindings + +## 20.4 Activity Log Changes + +The activity log should extend `actor_type` to include `plugin`. + +New actor enum: + +- `agent` +- `user` +- `system` +- `plugin` + +Plugin-originated mutations should write: + +- `actor_type = plugin` +- `actor_id = ` + +## 20.5 Plugin Migrations + +The first plugin system does not allow arbitrary third-party migrations. + +Later, if custom tables become necessary, the system may add a trusted-module-only migration path. + +## 21. Secrets + +Plugin config must never persist raw secret values. + +Rules: + +1. Plugin config stores secret refs only. +2. Secret refs resolve through the existing Paperclip secret provider system. +3. Plugin workers receive resolved secrets only at execution time. +4. Secret values must never be written to: + - plugin config JSON + - activity logs + - webhook delivery rows + - error messages + +## 22. Auditing + +All plugin-originated mutating actions must be auditable. + +Minimum requirements: + +- activity log entry for every mutation +- job run history +- webhook delivery history +- plugin health page +- install/upgrade history in `plugins` + +## 23. Operator UX + +## 23.1 Global Settings + +Global plugin settings page must show: + +- installed plugins +- versions +- status +- requested capabilities +- current errors +- install/upgrade/remove actions + +## 23.2 Plugin Settings Page + +Each plugin may expose: + +- config form derived from `instanceConfigSchema` +- health details +- recent job history +- recent webhook history +- capability list + +Route: + +- `/settings/plugins/:pluginId` + +## 23.3 Company-Context Plugin Page + +Each plugin may expose a company-context main page: + +- `/:companyPrefix/plugins/:pluginId` + +This page is where board users do most day-to-day work. + +## 24. Example Mappings + +This spec directly supports the following plugin types: + +- `@paperclip/plugin-workspace-files` +- `@paperclip/plugin-terminal` +- `@paperclip/plugin-git` +- `@paperclip/plugin-linear` +- `@paperclip/plugin-github-issues` +- `@paperclip/plugin-grafana` +- `@paperclip/plugin-runtime-processes` +- `@paperclip/plugin-stripe` + +## 25. Compatibility And Versioning + +Rules: + +1. Host supports one or more explicit plugin API versions. +2. Plugin manifest declares exactly one `apiVersion`. +3. Host rejects unsupported versions at install time. +4. SDK packages are versioned with the host protocol. +5. Plugin upgrades are explicit operator actions. +6. Capability expansion requires explicit operator approval. + +## 26. Recommended Delivery Order + +## Phase 1 + +- plugin manifest +- install/list/remove/upgrade CLI +- global settings UI +- plugin process manager +- capability enforcement +- `plugins`, `plugin_config`, `plugin_state`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries` +- event bus +- jobs +- webhooks +- settings page +- dashboard widget/page/tab schema rendering + +This phase is enough for: + +- Linear +- GitHub Issues +- Grafana +- Stripe + +## Phase 2 + +- project workspace service built on `project_workspaces` +- file APIs +- PTY APIs +- git APIs +- process APIs +- project-context tabs for plugin pages + +This phase is enough for: + +- file browser +- terminal +- git workflow +- process/server tracking + +## Phase 3 + +- optional `plugin_entities` +- richer action systems +- trusted-module migration path if truly needed +- optional richer frontend extension model +- plugin ecosystem/distribution work + +## 27. Final Design Decision + +Paperclip should not implement a generic in-process hook bag modeled directly after local coding tools. + +Paperclip should implement: + +- trusted platform modules for low-level host integration +- globally installed out-of-process plugins for additive instance-wide capabilities +- schema-driven UI contributions +- project workspace-based local tooling +- generic extension tables for most plugin state +- strict preservation of core governance and audit rules + +That is the complete target design for the Paperclip plugin system. diff --git a/doc/plugins/ideas-from-opencode.md b/doc/plugins/ideas-from-opencode.md new file mode 100644 index 00000000..ac4077f7 --- /dev/null +++ b/doc/plugins/ideas-from-opencode.md @@ -0,0 +1,1637 @@ +# Plugin Ideas From OpenCode + +Status: design report, not a V1 commitment + +Paperclip V1 explicitly excludes a plugin framework in [doc/SPEC-implementation.md](../SPEC-implementation.md), but the long-horizon spec says the architecture should leave room for extensions. This report studies the `opencode` plugin system and translates the useful patterns into a Paperclip-shaped design. + +Assumption for this document: Paperclip is a single-tenant operator-controlled instance. Plugin installation should therefore be global across the instance. "Companies" are still first-class Paperclip objects, but they are organizational records, not tenant-isolation boundaries for plugin trust or installation. + +## Executive Summary + +`opencode` has a real plugin system already. It is intentionally low-friction: + +- plugins are plain JS/TS modules +- they load from local directories and npm packages +- they can hook many runtime events +- they can add custom tools +- they can extend provider auth flows +- they run in-process and can mutate runtime behavior directly + +That model works well for a local coding tool. It should not be copied literally into Paperclip. + +The main conclusion is: + +- Paperclip should copy `opencode`'s typed SDK, deterministic loading, low authoring friction, and clear extension surfaces. +- Paperclip should not copy `opencode`'s trust model, project-local plugin loading, "override by name collision" behavior, or arbitrary in-process mutation hooks for core business logic. +- Paperclip should use multiple extension classes instead of one generic plugin bag: + - trusted in-process modules for low-level platform concerns like agent adapters, storage providers, secret providers, and possibly run-log backends + - out-of-process plugins for most third-party integrations like Linear, GitHub Issues, Grafana, Stripe, and schedulers + - schema-driven UI contributions for dashboard widgets, settings panels, and company pages + - a typed event bus plus scheduled jobs for automation + +If Paperclip does this well, the examples you listed become straightforward: + +- file browser / terminal / git workflow / child process tracking become workspace-runtime plugins built on first-party primitives +- Linear / GitHub / Grafana / Stripe become connector plugins +- future knowledge base and accounting features can also fit the same model + +## Sources Examined + +I cloned `anomalyco/opencode` and reviewed commit: + +- `a965a062595403a8e0083e85770315d5dc9628ab` + +Primary files reviewed: + +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/plugin/src/index.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/plugin/src/tool.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/plugin/index.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/config/config.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/tool/registry.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/provider/auth.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/plugins.mdx` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/custom-tools.mdx` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/ecosystem.mdx` + +Relevant Paperclip files reviewed for current extension seams: + +- [server/src/adapters/registry.ts](../../server/src/adapters/registry.ts) +- [ui/src/adapters/registry.ts](../../ui/src/adapters/registry.ts) +- [server/src/storage/provider-registry.ts](../../server/src/storage/provider-registry.ts) +- [server/src/secrets/provider-registry.ts](../../server/src/secrets/provider-registry.ts) +- [server/src/services/run-log-store.ts](../../server/src/services/run-log-store.ts) +- [server/src/services/activity-log.ts](../../server/src/services/activity-log.ts) +- [doc/SPEC.md](../SPEC.md) +- [doc/SPEC-implementation.md](../SPEC-implementation.md) + +## What OpenCode Actually Implements + +## 1. Plugin authoring API + +`opencode` exposes a small package, `@opencode-ai/plugin`, with a typed `Plugin` function and a typed `tool()` helper. + +Core shape: + +- a plugin is an async function that receives a context object +- the plugin returns a `Hooks` object +- hooks are optional +- plugins can also contribute tools and auth providers + +The plugin init context includes: + +- an SDK client +- current project info +- current directory +- current git worktree +- server URL +- Bun shell access + +That is important: `opencode` gives plugins rich runtime power immediately, not a narrow capability API. + +## 2. Hook model + +The hook set is broad. It includes: + +- event subscription +- config-time hook +- message hooks +- model parameter/header hooks +- permission decision hooks +- shell env injection +- tool execution before/after hooks +- tool definition mutation +- compaction prompt customization +- text completion transforms + +The implementation pattern is very simple: + +- core code constructs an `output` object +- each matching plugin hook runs sequentially +- hooks mutate the `output` +- final mutated output is used by core + +This is elegant and easy to extend. + +It is also extremely powerful. A plugin can change auth headers, model params, permission answers, tool inputs, tool descriptions, and shell environment. + +## 3. Plugin discovery and load order + +`opencode` supports two plugin sources: + +- local files +- npm packages + +Local directories: + +- `~/.config/opencode/plugins/` +- `.opencode/plugins/` + +Npm plugins: + +- listed in config under `plugin: []` + +Load order is deterministic and documented: + +1. global config +2. project config +3. global plugin directory +4. project plugin directory + +Important details: + +- config arrays are concatenated rather than replaced +- duplicate plugin names are deduplicated with higher-precedence entries winning +- internal first-party plugins and default plugins are also loaded through the plugin pipeline + +This gives `opencode` a real precedence model rather than "whatever loaded last by accident." + +## 4. Dependency handling + +For local config/plugin directories, `opencode` will: + +- ensure a `package.json` exists +- inject `@opencode-ai/plugin` +- run `bun install` + +That lets local plugins and local custom tools import dependencies. + +This is excellent for local developer ergonomics. + +It is not a safe default for an operator-controlled control plane server. + +## 5. Error handling + +Plugin load failures do not hard-crash the runtime by default. + +Instead, `opencode`: + +- logs the error +- publishes a session error event +- continues loading other plugins + +That is a good operational pattern. One bad plugin should not brick the entire product unless the operator has explicitly configured it as required. + +## 6. Tools are a first-class extension point + +`opencode` has two ways to add tools: + +- export tools directly from a plugin via `hook.tool` +- define local files in `.opencode/tools/` or global tools directories + +The tool API is strong: + +- tools have descriptions +- tools have Zod schemas +- tool execution gets context like session ID, message ID, directory, and worktree +- tools are merged into the same registry as built-in tools +- tool definitions themselves can be mutated by a `tool.definition` hook + +The most aggressive part of the design: + +- custom tools can override built-in tools by name + +That is very powerful for a local coding assistant. +It is too dangerous for Paperclip core actions. + +## 7. Auth is also a plugin surface + +`opencode` allows plugins to register auth methods for providers. + +A plugin can contribute: + +- auth method metadata +- prompt flows +- OAuth flows +- API key flows +- request loaders that adapt provider behavior after auth succeeds + +This is a strong pattern worth copying. Integrations often need custom auth UX and token handling. + +## 8. Ecosystem evidence + +The ecosystem page is the best proof that the model is working in practice. +Community plugins already cover: + +- sandbox/workspace systems +- auth providers +- session headers / telemetry +- memory/context features +- scheduling +- notifications +- worktree helpers +- background agents +- monitoring + +That validates the main thesis: a simple typed plugin API can create real ecosystem velocity. + +## What OpenCode Gets Right + +## 1. Separate plugin SDK from host runtime + +This is one of the best parts of the design. + +- plugin authors code against a clean public package +- host internals can evolve behind the loader +- runtime code and plugin code have a clean contract boundary + +Paperclip should absolutely do this. + +## 2. Deterministic loading and precedence + +`opencode` is explicit about: + +- where plugins come from +- how config merges +- what order wins + +Paperclip should copy this discipline. + +## 3. Low-ceremony authoring + +A plugin author does not have to learn a giant framework. + +- export async function +- return hooks +- optionally export tools + +That simplicity matters. + +## 4. Typed tool definitions + +The `tool()` helper is excellent: + +- typed +- schema-based +- easy to document +- easy for runtime validation + +Paperclip should adopt this style for plugin actions, automations, and UI schemas. + +## 5. Built-in features and plugins use similar shapes + +`opencode` uses the same hook system for internal and external plugin-style behavior in several places. +That reduces special cases. + +Paperclip can benefit from that with adapters, secret backends, storage providers, and connector modules. + +## 6. Incremental extension, not giant abstraction upfront + +`opencode` did not design a giant marketplace platform first. +It added concrete extension points that real features needed. + +That is the correct mindset for Paperclip too. + +## What Paperclip Should Not Copy Directly + +## 1. In-process arbitrary plugin code as the default + +`opencode` is basically a local agent runtime, so unsandboxed plugin execution is acceptable for its audience. + +Paperclip is a control plane for an operator-managed instance with company objects. +The risk profile is different: + +- secrets matter +- approval gates matter +- budgets matter +- mutating actions require auditability + +Default third-party plugins should not run with unrestricted in-process access to server memory, DB handles, and secrets. + +## 2. Project-local plugin loading + +`opencode` has project-local plugin folders because the tool is centered around a codebase. + +Paperclip is not project-scoped. It is instance-scoped. +The comparable unit is: + +- instance-installed plugin package + +Paperclip should not auto-load arbitrary code from a workspace repo like `.paperclip/plugins` or project directories. + +## 3. Arbitrary mutation hooks on core business decisions + +Hooks like: + +- `permission.ask` +- `tool.execute.before` +- `chat.headers` +- `shell.env` + +make sense in `opencode`. + +For Paperclip, equivalent hooks into: + +- approval decisions +- issue checkout semantics +- activity log behavior +- budget enforcement + +would be a mistake. + +Core invariants should stay in core code, not become hook-rewritable. + +## 4. Override-by-name collision + +Allowing a plugin to replace a built-in tool by name is useful in a local agent product. + +Paperclip should not allow plugins to silently replace: + +- core routes +- core mutating actions +- auth behaviors +- permission evaluators +- budget logic +- audit logic + +Extension should be additive or explicitly delegated, never accidental shadowing. + +## 5. Auto-install and execute from user config + +`opencode`'s "install dependencies at startup" flow is ergonomic. +For Paperclip it would be risky because it combines: + +- package installation +- code loading +- execution + +inside the control-plane server startup path. + +Paperclip should require an explicit operator install step. + +## Why Paperclip Needs A Different Shape + +The products are solving different problems. + +| Topic | OpenCode | Paperclip | +|---|---|---| +| Primary unit | local project/worktree | single-tenant operator instance with company objects | +| Trust assumption | local power user on own machine | operator managing one trusted Paperclip instance | +| Failure blast radius | local session/runtime | entire company control plane | +| Extension style | mutate runtime behavior freely | preserve governance and auditability | +| UI model | local app can load local behavior | board UI must stay coherent and safe | +| Security model | host-trusted local plugins | needs capability boundaries and auditability | + +That means Paperclip should borrow the good ideas from `opencode` but use a stricter architecture. + +## Paperclip Already Has Useful Pre-Plugin Seams + +Paperclip has several extension-like seams already: + +- server adapter registry: [server/src/adapters/registry.ts](../../server/src/adapters/registry.ts) +- UI adapter registry: [ui/src/adapters/registry.ts](../../ui/src/adapters/registry.ts) +- storage provider registry: [server/src/storage/provider-registry.ts](../../server/src/storage/provider-registry.ts) +- secret provider registry: [server/src/secrets/provider-registry.ts](../../server/src/secrets/provider-registry.ts) +- pluggable run-log store seam: [server/src/services/run-log-store.ts](../../server/src/services/run-log-store.ts) +- activity log and live event emission: [server/src/services/activity-log.ts](../../server/src/services/activity-log.ts) + +This is good news. +Paperclip does not need to invent extensibility from scratch. +It needs to unify and harden existing seams. + +## Recommended Paperclip Plugin Model + +## 1. Use multiple extension classes + +Do not create one giant `hooks` object for everything. + +Use distinct plugin classes with different trust models. + +| Extension class | Examples | Runtime model | Trust level | Why | +|---|---|---|---|---| +| Platform module | agent adapters, storage providers, secret providers, run-log backends | in-process | highly trusted | tight integration, performance, low-level APIs | +| Connector plugin | Linear, GitHub Issues, Grafana, Stripe | out-of-process worker or sidecar | medium | external sync, safer isolation, clearer failure boundary | +| Workspace plugin | file browser, terminal, git workflow, child process/server tracking | mixed: core workspace services plus plugin descriptors | high | needs local OS/workspace primitives but should reuse core services | +| UI contribution | dashboard widgets, settings forms, company panels | schema-driven first, remote React later if needed | medium | safer than arbitrary frontend code | +| Automation plugin | alerts, schedulers, sync jobs, webhook processors | out-of-process | medium | event-driven automation is a natural plugin fit | + +This split is the most important design recommendation in this report. + +## 2. Keep low-level modules separate from third-party plugins + +Paperclip already has this pattern implicitly: + +- adapters are one thing +- storage providers are another +- secret providers are another + +Keep that separation. + +I would formalize it like this: + +- `module` means trusted code loaded by the host for low-level runtime services +- `plugin` means integration code that talks to Paperclip through a typed plugin protocol and capability model + +This avoids trying to force Stripe, a PTY terminal, and a new agent adapter into the same abstraction. + +## 3. Prefer event-driven extensions over core-logic mutation + +For third-party plugins, the primary API should be: + +- subscribe to typed domain events +- read instance state, including company-bound business records when relevant +- register webhooks +- run scheduled jobs +- write plugin-owned state +- add additive UI surfaces +- invoke explicit Paperclip actions through the API + +Do not make third-party plugins responsible for: + +- deciding whether an approval passes +- intercepting issue checkout semantics +- rewriting activity log behavior +- overriding budget hard-stops + +Those are core invariants. + +## 4. Start with schema-driven UI contributions + +Arbitrary third-party React bundles inside the board UI are possible later, but they should not be the first version. + +First version should let plugins contribute: + +- settings sections defined by JSON schema +- dashboard widgets with server-provided data +- sidebar entries with fixed shell rendering +- detail-page tabs that render plugin data through core UI components + +Why: + +- simpler to secure +- easier to keep visually coherent +- easier to preserve context and auditability +- easier to test + +Later, if needed, Paperclip can support richer frontend extensions through versioned remote modules. + +## 5. Make installation global and keep mappings/config separate + +`opencode` is mostly user-level local config. +Paperclip should treat plugin installation as a global instance-level action. + +Examples: + +- install `@paperclip/plugin-linear` once +- make it available everywhere immediately +- optionally store mappings over Paperclip objects if one company maps to a different Linear team than another + +## 6. Use project workspaces as the primary anchor for local tooling + +Paperclip already has a concrete workspace model for projects: + +- projects expose `workspaces` and `primaryWorkspace` +- the database already has `project_workspaces` +- project routes already support creating, updating, and deleting workspaces +- heartbeat resolution already prefers project workspaces before falling back to task-session or agent-home workspaces + +That means local/runtime plugins should generally anchor themselves to projects first, not invent a parallel workspace model. + +Practical guidance: + +- file browser should browse project workspaces first +- terminal sessions should be launchable from a project workspace +- git should treat the project workspace as the repo root anchor +- dev server and child-process tracking should attach to project workspaces +- issue and agent views can still deep-link into the relevant project workspace context + +In other words: + +- `project` is the business object +- `project_workspace` is the local runtime anchor +- plugins should build on that instead of creating an unrelated workspace model first + +## A Concrete SDK Shape For Paperclip + +An intentionally narrow first pass could look like this: + +```ts +import { definePlugin, z } from "@paperclipai/plugin-sdk"; + +export default definePlugin({ + id: "@paperclip/plugin-linear", + version: "0.1.0", + kind: ["connector", "ui"], + capabilities: [ + "events.subscribe", + "jobs.schedule", + "http.outbound", + "instance.settings", + "dashboard.widget", + "secrets.read-ref", + ], + instanceConfigSchema: z.object({ + linearBaseUrl: z.string().url().optional(), + companyMappings: z.array( + z.object({ + companyId: z.string(), + teamId: z.string(), + apiTokenSecretRef: z.string(), + }), + ).default([]), + }), + async register(ctx) { + ctx.jobs.register("linear-pull", { cron: "*/5 * * * *" }, async (job) => { + // sync Linear issues into plugin-owned state or explicit Paperclip entities + }); + + ctx.events.on("issue.created", async (event) => { + // optional outbound sync + }); + + ctx.ui.registerDashboardWidget({ + id: "linear-health", + title: "Linear", + loader: async ({ companyId }) => ({ status: "ok" }), + }); + }, +}); +``` + +The important point is not the exact syntax. +The important point is the contract shape: + +- typed manifest +- explicit capabilities +- explicit global config with optional company mappings +- event subscriptions +- jobs +- additive UI contributions + +## Recommended Core Extension Surfaces + +## 1. Platform module surfaces + +These should stay close to the current registry style. + +Candidates: + +- `registerAgentAdapter()` +- `registerStorageProvider()` +- `registerSecretProvider()` +- `registerRunLogStore()` +- maybe `registerWorkspaceRuntime()` later + +These are trusted platform modules, not casual plugins. + +## 2. Connector plugin surfaces + +These are the best near-term plugin candidates. + +Capabilities: + +- subscribe to domain events +- define scheduled sync jobs +- expose plugin-specific API routes under `/api/plugins/:pluginId/...` +- use company secret refs +- write plugin state +- publish dashboard data +- log activity through core APIs + +Examples: + +- Linear issue sync +- GitHub issue sync +- Grafana dashboard cards +- Stripe MRR / subscription rollups + +## 3. Workspace-runtime surfaces + +Your local-ops examples need first-party primitives plus plugin contributions. + +Examples: + +- file browser +- terminal +- git workflow +- child process tracking +- local dev server tracking + +These should not be arbitrary third-party code directly poking the host filesystem and PTY layer through ad-hoc hooks. +Instead, Paperclip should add first-party services such as: + +- project workspace service built on `project_workspaces` +- PTY session service +- process registry +- git service +- dev-server registry + +Then plugins can add: + +- UI panels on top of those services +- automations +- annotations +- external sync logic + +This keeps sensitive local-machine behavior centralized and auditable. + +## 4. UI contribution surfaces + +Recommended first version: + +- dashboard widgets +- settings panels +- detail-page tabs +- sidebar sections +- action buttons that invoke plugin routes + +Recommended later version: + +- richer remote UI modules + +Recommended never or only with extreme caution: + +- arbitrary override of core pages +- arbitrary replacement of routing/auth/layout logic + +## Governance And Safety Requirements + +Any Paperclip plugin system has to preserve core control-plane invariants from the repo docs. + +That means: + +- plugin install is global to the instance +- "companies" remain business objects in the API and data model, not tenant boundaries +- approval gates remain core-owned +- budget hard-stops remain core-owned +- mutating actions are activity-logged +- secrets remain ref-based and redacted in logs + +I would require the following for every plugin: + +## 1. Capability declaration + +Every plugin declares a static capability set such as: + +- `companies.read` +- `issues.read` +- `issues.write` +- `events.subscribe` +- `jobs.schedule` +- `http.outbound` +- `webhooks.receive` +- `assets.read` +- `assets.write` +- `workspace.pty` +- `workspace.fs.read` +- `workspace.fs.write` +- `secrets.read-ref` + +The board/operator sees this before installation. + +## 2. Global installation + +A plugin is installed once and becomes available across the instance. +If it needs mappings over specific Paperclip objects, those are plugin data, not enable/disable boundaries. + +## 3. Activity logging + +Plugin-originated mutations should flow through the same activity log mechanism, with actor identity like: + +- `actor_type = system` +- `actor_id = plugin:@paperclip/plugin-linear` + +or a dedicated `plugin` actor type if you want stronger semantics later. + +## 4. Health and failure reporting + +Each plugin should expose: + +- enabled/disabled state +- last successful run +- last error +- recent webhook/job history + +One broken plugin must not break the rest of the company. + +## 5. Secret handling + +Plugins should receive secret refs, not raw secret values in config persistence. +Resolution should go through the existing secret provider abstraction. + +## 6. Resource limits + +Plugins should have: + +- timeout limits +- concurrency limits +- retry policies +- optional per-plugin budgets + +This matters especially for sync connectors and workspace plugins. + +## Data Model Additions To Consider + +I would avoid "arbitrary third-party plugin-defined SQL migrations" in the first version. +That is too much power too early. + +The right mental model is: + +- reuse core tables when the data is clearly part of Paperclip itself +- use generic extension tables for most plugin-owned state +- only allow plugin-specific tables later, and only for trusted platform modules or a tightly controlled migration workflow + +## Recommended Postgres Strategy For Extensions + +### 1. Core tables stay core + +If a concept is becoming part of Paperclip's actual product model, it should get a normal first-party table. + +Examples: + +- `project_workspaces` is already a core table because project workspaces are now part of Paperclip itself +- if a future "project git state" becomes a core feature rather than plugin-owned metadata, that should also be a first-party table + +### 2. Most plugins should start in generic extension tables + +For most plugins, the host should provide a few generic persistence tables and the plugin stores namespaced records there. + +This keeps the system manageable: + +- simpler migrations +- simpler backup/restore +- simpler portability story +- easier operator review +- fewer chances for plugin schema drift to break the instance + +### 3. Scope plugin data to Paperclip objects before adding custom schemas + +A lot of plugin data naturally hangs off existing Paperclip objects: + +- project workspace plugin state should often scope to `project` or `project_workspace` +- issue sync state should scope to `issue` +- metrics widgets may scope to `company`, `project`, or `goal` +- process tracking may scope to `project_workspace`, `agent`, or `run` + +That gives a good default keying model before introducing custom tables. + +### 4. Add trusted module migrations later, not arbitrary plugin migrations now + +If Paperclip eventually needs extension-owned tables, I would only allow that for: + +- trusted first-party packages +- trusted platform modules +- maybe explicitly installed admin-reviewed plugins with pinned versions + +I would not let random third-party plugins run free-form schema migrations on startup. + +Instead, add a controlled mechanism later if it becomes necessary. + +## Suggested baseline extension tables + +## 1. `plugins` + +Instance-level installation record. + +Suggested fields: + +- `id` +- `package_name` +- `version` +- `kind` +- `manifest_json` +- `installed_at` +- `status` + +## 2. `plugin_config` + +Instance-level plugin config. + +Suggested fields: + +- `id` +- `plugin_id` +- `config_json` +- `installed_at` +- `updated_at` +- `last_error` + +## 3. `plugin_state` + +Generic key/value state for plugins. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` (`instance | company | project | project_workspace | agent | issue | goal | run`) +- `scope_id` nullable +- `namespace` +- `state_key` +- `value_json` +- `updated_at` + +This is enough for many connectors before allowing custom tables. + +Examples: + +- Linear external IDs keyed by `issue` +- GitHub sync cursors keyed by `project` +- file browser preferences keyed by `project_workspace` +- git branch metadata keyed by `project_workspace` +- process metadata keyed by `project_workspace` or `run` + +## 4. `plugin_jobs` + +Scheduled job and run tracking. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` nullable +- `scope_id` nullable +- `job_key` +- `status` +- `last_started_at` +- `last_finished_at` +- `last_error` + +## 5. `plugin_webhook_deliveries` + +If plugins expose webhooks, delivery history is worth storing. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` nullable +- `scope_id` nullable +- `endpoint_key` +- `status` +- `received_at` +- `response_code` +- `error` + +## 6. Maybe later: `plugin_entities` + +If generic plugin state becomes too limiting, add a structured, queryable entity table for connector records before allowing arbitrary plugin migrations. + +Suggested fields: + +- `id` +- `plugin_id` +- `entity_type` +- `scope_kind` +- `scope_id` +- `external_id` +- `title` +- `status` +- `data_json` +- `updated_at` + +This is a useful middle ground: + +- much more queryable than opaque key/value state +- still avoids letting every plugin create its own relational schema immediately + +## How The Requested Examples Map To This Model + +| Use case | Best fit | Core primitives needed | Notes | +|---|---|---|---| +| File browser | workspace plugin + schema UI | project workspaces, file API, audit rules | best anchored on project detail pages | +| Terminal | workspace plugin + PTY service | project workspaces, PTY/session service, process limits, audit events | should launch against a project workspace by default | +| Git workflow | workspace plugin | project workspaces, git service, repo/worktree model | project workspace is the natural repo anchor | +| Linear issue tracking | connector plugin | jobs, webhooks, secret refs, issue sync API | very strong plugin candidate | +| GitHub issue tracking | connector plugin | jobs, webhooks, secret refs | very strong plugin candidate | +| Grafana metrics | connector plugin + dashboard widget | outbound HTTP, widget API | probably read-only first | +| Child process/server tracking | workspace plugin | project workspaces, process registry, server heartbeat model | should attach processes to project workspaces when possible | +| Stripe revenue tracking | connector plugin | secret refs, scheduled sync, company metrics API | strong plugin candidate and aligns with future spec direction | + +# Plugin Examples + +## Workspace File Browser + +Package idea: `@paperclip/plugin-workspace-files` + +This plugin lets the board inspect project workspaces, agent workspaces, generated artifacts, and issue-related files without dropping to the shell. It is useful for: + +- browsing files inside project workspaces +- debugging what an agent changed +- reviewing generated outputs before approval +- attaching files from a workspace to issues +- understanding repo layout for a company +- inspecting agent home workspaces in local-trusted mode + +### UX + +- Settings page: `/settings/plugins/workspace-files` +- Main page: `/:companyPrefix/plugins/workspace-files` +- Project tab: `/:companyPrefix/projects/:projectId?tab=files` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=files` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=workspace` + +Main screens and interactions: + +- Plugin settings: + - choose whether the plugin defaults to `project.primaryWorkspace` + - choose which project workspaces are visible + - choose whether file writes are allowed or read-only + - choose whether hidden files are visible +- Main explorer page: + - project picker at the top + - workspace picker scoped to the selected project's `workspaces` + - tree view on the left + - file preview pane on the right + - search box for filename/path search + - actions: copy path, download file, attach to issue, open diff +- Project tab: + - opens directly into the project's primary workspace + - lets the board switch among all project workspaces + - shows workspace metadata like `cwd`, `repoUrl`, and `repoRef` +- Issue tab: + - resolves the issue's project and opens that project's workspace context + - shows files linked to the issue + - lets the board pull files from the project workspace into issue attachments + - shows the path and last modified info for each linked file +- Agent tab: + - shows the agent's current resolved workspace + - if the run is attached to a project, links back to the project workspace view + - lets the board inspect files the agent is currently touching + +Core workflows: + +- Board opens a project and browses its primary workspace files. +- Board switches from one project workspace to another when a project has multiple checkouts or repo references. +- Board opens an issue, attaches a generated artifact from the file browser, and leaves a review comment. +- Board opens an agent detail page to inspect the exact files behind a failing run. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `issue`, and `agent` +- `projects.read` +- `project.workspaces.read` +- `workspace.fs.read` +- optional `workspace.fs.write` +- `workspace.fs.stat` +- `workspace.fs.search` +- optional `assets.write` +- `activity.log.write` + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.finished)` +- `events.subscribe(issue.attachment.created)` + +Important constraint: + +- the plugin should never read arbitrary host paths directly +- it should treat project workspaces as the canonical local file roots when a project is present +- it should only use first-party workspace/file APIs that enforce approved workspace roots + +## Workspace Terminal + +Package idea: `@paperclip/plugin-terminal` + +This plugin gives the board a controlled terminal UI for project workspaces and agent workspaces. It is useful for: + +- debugging stuck runs +- verifying environment state +- running targeted manual commands +- watching long-running commands +- pairing a human operator with an agent workflow + +### UX + +- Settings page: `/settings/plugins/terminal` +- Main page: `/:companyPrefix/plugins/terminal` +- Project tab: `/:companyPrefix/projects/:projectId?tab=terminal` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=terminal` +- Optional run tab: `/:companyPrefix/agents/:agentId/runs/:runId?tab=terminal` + +Main screens and interactions: + +- Plugin settings: + - allowed shells and shell policy + - whether commands are read-only, free-form, or allow-listed + - whether terminals require an explicit operator confirmation before launch + - whether new terminal sessions default to the project's primary workspace +- Terminal home page: + - list of active terminal sessions + - button to open a new session + - project picker, then workspace picker from that project's workspaces + - optional agent association + - terminal panel with input, resize, and reconnect support + - controls: interrupt, kill, clear, save transcript +- Project terminal tab: + - opens a session already scoped to the project's primary workspace + - lets the board switch among the project's configured workspaces + - shows recent commands and related process/server state for that project +- Agent terminal tab: + - opens a session already scoped to the agent's workspace + - shows recent related runs and commands +- Run terminal tab: + - lets the board inspect the environment around a specific failed run + +Core workflows: + +- Board opens a terminal against an agent workspace to reproduce a failing command. +- Board opens a project page and launches a terminal directly in that project's primary workspace. +- Board watches a long-running dev server or test command from the terminal page. +- Board kills or interrupts a runaway process from the same UI. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `agent`, and `run` +- `projects.read` +- `project.workspaces.read` +- `workspace.pty.open` +- `workspace.pty.input` +- `workspace.pty.resize` +- `workspace.pty.terminate` +- `workspace.pty.subscribe` +- `workspace.process.read` +- `activity.log.write` + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.failed)` +- `events.subscribe(agent.run.cancelled)` + +Important constraint: + +- shell spawning must stay in a first-party PTY service +- the plugin should orchestrate and render sessions, not spawn raw host processes by itself + +## Git Workflow + +Package idea: `@paperclip/plugin-git` + +This plugin adds repo-aware workflow tooling around issues and workspaces. It is useful for: + +- branch creation tied to issues +- quick diff review +- commit and worktree visibility +- PR preparation +- treating the project's primary workspace as the canonical repo anchor +- seeing whether an agent's workspace is clean or dirty + +### UX + +- Settings page: `/settings/plugins/git` +- Main page: `/:companyPrefix/plugins/git` +- Project tab: `/:companyPrefix/projects/:projectId?tab=git` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=git` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=git` + +Main screens and interactions: + +- Plugin settings: + - branch naming template + - optional remote provider token secret ref + - whether write actions are enabled or read-only + - whether the plugin always uses `project.primaryWorkspace` unless a different project workspace is chosen +- Git overview page: + - project picker and workspace picker + - current branch + - ahead/behind status + - dirty files summary + - recent commits + - active worktrees + - actions: refresh, create branch, create worktree, stage all, commit, open diff +- Project tab: + - opens in the project's primary workspace + - shows workspace metadata and repo binding (`cwd`, `repoUrl`, `repoRef`) + - shows branch, diff, and commit history for that project workspace +- Issue tab: + - resolves the issue's project and uses that project's workspace context + - "create branch from issue" action + - diff view scoped to the project's selected workspace + - link branch/worktree metadata to the issue +- Agent tab: + - shows the agent's branch, worktree, and dirty state + - shows recent commits produced by that agent + - if the agent is working inside a project workspace, links back to the project git tab + +Core workflows: + +- Board creates a branch from an issue and ties it to the project's primary workspace. +- Board opens a project page and reviews the diff for that project's workspace without leaving Paperclip. +- Board reviews the diff after a run without leaving Paperclip. +- Board opens a worktree list to understand parallel branches across agents. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `issue`, and `agent` +- `ui.action.register` +- `projects.read` +- `project.workspaces.read` +- `workspace.git.status` +- `workspace.git.diff` +- `workspace.git.log` +- `workspace.git.branch.create` +- optional `workspace.git.commit` +- optional `workspace.git.worktree.create` +- optional `workspace.git.push` +- `activity.log.write` + +Optional event subscriptions: + +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(agent.run.finished)` + +Important constraint: + +- GitHub/GitLab PR creation should likely live in a separate connector plugin rather than overloading the local git plugin + +## Linear Issue Tracking + +Package idea: `@paperclip/plugin-linear` + +This plugin syncs Paperclip work with Linear. It is useful for: + +- importing backlog from Linear +- linking Paperclip issues to Linear issues +- syncing status, comments, and assignees +- mapping company goals/projects to external product planning +- giving board operators a single place to see sync health + +### UX + +- Settings page: `/settings/plugins/linear` +- Main page: `/:companyPrefix/plugins/linear` +- Dashboard widget: `/:companyPrefix/dashboard` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=linear` +- Optional project tab: `/:companyPrefix/projects/:projectId?tab=linear` + +Main screens and interactions: + +- Plugin settings: + - Linear API token secret ref + - workspace/team/project mappings + - status mapping between Paperclip and Linear + - sync direction: import only, export only, bidirectional + - comment sync toggle +- Linear overview page: + - sync health card + - recent sync jobs + - mapped projects and teams + - unresolved conflicts queue + - import actions for teams, projects, and issues +- Issue tab: + - linked Linear issue key and URL + - sync status and last synced time + - actions: link existing, create in Linear, resync now, unlink + - timeline of synced comments/status changes +- Dashboard widget: + - open sync errors + - imported vs linked issues count + - recent webhook/job failures + +Core workflows: + +- Board enables the plugin, maps a Linear team, and imports a backlog into Paperclip. +- Paperclip issue status changes push to Linear and Linear comments arrive back through webhooks. +- Board resolves mapping conflicts from the plugin page instead of silently drifting state. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `issue` and `project` +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(issue.comment.created)` +- `events.subscribe(project.updated)` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `issues.update` +- optional `issue.comments.create` +- `activity.log.write` + +Important constraint: + +- webhook processing should be idempotent and conflict-aware +- external IDs and sync cursors belong in plugin-owned state, not inline on core issue rows in the first version + +## GitHub Issue Tracking + +Package idea: `@paperclip/plugin-github-issues` + +This plugin syncs Paperclip issues with GitHub Issues and optionally links PRs. It is useful for: + +- importing repo backlogs +- mirroring issue status and comments +- linking PRs to Paperclip issues +- tracking cross-repo work from inside one company view +- bridging engineering workflow with Paperclip task governance + +### UX + +- Settings page: `/settings/plugins/github-issues` +- Main page: `/:companyPrefix/plugins/github-issues` +- Dashboard widget: `/:companyPrefix/dashboard` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=github` +- Optional project tab: `/:companyPrefix/projects/:projectId?tab=github` + +Main screens and interactions: + +- Plugin settings: + - GitHub App or PAT secret ref + - org/repo mappings + - label/status mapping + - whether PR linking is enabled + - whether new Paperclip issues should create GitHub issues automatically +- GitHub overview page: + - repo mapping list + - sync health and recent webhook events + - import backlog action + - queue of unlinked GitHub issues +- Issue tab: + - linked GitHub issue and optional linked PRs + - actions: create GitHub issue, link existing issue, unlink, resync + - comment/status sync timeline +- Dashboard widget: + - open PRs linked to active Paperclip issues + - webhook failures + - sync lag metrics + +Core workflows: + +- Board imports GitHub Issues for a repo into Paperclip. +- GitHub webhooks update status/comment state in Paperclip. +- A PR is linked back to the Paperclip issue so the board can follow delivery status. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `issue` and `project` +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(issue.comment.created)` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `issues.update` +- optional `issue.comments.create` +- `activity.log.write` + +Important constraint: + +- keep "local git state" and "remote GitHub issue state" in separate plugins even if they work together + +## Grafana Metrics + +Package idea: `@paperclip/plugin-grafana` + +This plugin surfaces external metrics and dashboards inside Paperclip. It is useful for: + +- company KPI visibility +- infrastructure/incident monitoring +- showing deploy, traffic, latency, or revenue charts next to work +- creating Paperclip issues from anomalous metrics + +### UX + +- Settings page: `/settings/plugins/grafana` +- Main page: `/:companyPrefix/plugins/grafana` +- Dashboard widgets: `/:companyPrefix/dashboard` +- Optional goal tab: `/:companyPrefix/goals/:goalId?tab=metrics` + +Main screens and interactions: + +- Plugin settings: + - Grafana base URL + - service account token secret ref + - dashboard and panel mappings + - refresh interval + - optional alert threshold rules +- Dashboard widgets: + - one or more metric cards on the main dashboard + - quick trend view and last refresh time + - link out to Grafana and link in to the full Paperclip plugin page +- Full metrics page: + - selected dashboard panels embedded or proxied + - metric selector + - time range selector + - "create issue from anomaly" action +- Goal tab: + - metric cards relevant to a specific goal or project + +Core workflows: + +- Board sees service degradation or business KPI movement directly on the Paperclip dashboard. +- Board clicks into the full metrics page to inspect the relevant Grafana panels. +- Board creates a Paperclip issue from a threshold breach with a metric snapshot attached. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.dashboardWidget.register` +- `ui.page.register` +- `ui.detailTab.register` for `goal` or `project` +- `jobs.schedule` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `assets.write` +- `activity.log.write` + +Optional event subscriptions: + +- `events.subscribe(goal.created)` +- `events.subscribe(project.updated)` + +Important constraint: + +- start read-only first +- do not make Grafana alerting logic part of Paperclip core; keep it as additive signal and issue creation + +## Child Process / Server Tracking + +Package idea: `@paperclip/plugin-runtime-processes` + +This plugin tracks long-lived local processes and dev servers started in project workspaces. It is useful for: + +- seeing which agent started which local service +- tracking ports, health, and uptime +- restarting failed dev servers +- exposing process state alongside issue and run state +- making local development workflows visible to the board + +### UX + +- Settings page: `/settings/plugins/runtime-processes` +- Main page: `/:companyPrefix/plugins/runtime-processes` +- Dashboard widget: `/:companyPrefix/dashboard` +- Process detail page: `/:companyPrefix/plugins/runtime-processes/:processId` +- Project tab: `/:companyPrefix/projects/:projectId?tab=processes` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=processes` + +Main screens and interactions: + +- Plugin settings: + - whether manual process registration is allowed + - health check behavior + - whether operators can stop/restart processes + - log retention preferences +- Process list page: + - status table with name, command, cwd, owner agent, port, uptime, and health + - filters for running/exited/crashed processes + - actions: inspect, stop, restart, tail logs +- Project tab: + - filters the process list to the project's workspaces + - shows which workspace each process belongs to + - groups processes by project workspace +- Process detail page: + - process metadata + - live log tail + - health check history + - links to associated issue or run +- Agent tab: + - shows processes started by or assigned to that agent + +Core workflows: + +- An agent starts a dev server; the first-party process service registers it and the plugin renders it. +- Board opens a project and immediately sees the processes attached to that project's workspace. +- Board sees a crashed process on the dashboard and restarts it from the plugin page. +- Board attaches process logs to an issue when debugging a failure. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `project` and `agent` +- `projects.read` +- `project.workspaces.read` +- `workspace.process.register` +- `workspace.process.list` +- `workspace.process.read` +- optional `workspace.process.terminate` +- optional `workspace.process.restart` +- `workspace.process.logs.read` +- optional `workspace.http.probe` +- `plugin.state.read` +- `plugin.state.write` +- `activity.log.write` + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.finished)` +- `events.subscribe(process.started)` +- `events.subscribe(process.exited)` + +Important constraint: + +- this should be built on a first-party process registry +- the plugin should not own raw child-process spawning on its own + +## Stripe Revenue Tracking + +Package idea: `@paperclip/plugin-stripe` + +This plugin pulls Stripe revenue and subscription data into Paperclip. It is useful for: + +- showing MRR and churn next to company goals +- tracking trials, conversions, and failed payments +- letting the board connect revenue movement to ongoing work +- enabling future financial dashboards beyond token costs + +### UX + +- Settings page: `/settings/plugins/stripe` +- Main page: `/:companyPrefix/plugins/stripe` +- Dashboard widgets: `/:companyPrefix/dashboard` +- Optional company/goal metric tabs if those surfaces exist later + +Main screens and interactions: + +- Plugin settings: + - Stripe secret key secret ref + - account selection if needed + - metric definitions such as MRR treatment and trial handling + - sync interval + - webhook signing secret ref +- Dashboard widgets: + - MRR card + - active subscriptions + - trial-to-paid conversion + - failed payment alerts +- Stripe overview page: + - time series charts + - recent customer/subscription events + - webhook health + - sync history + - action: create issue from billing anomaly + +Core workflows: + +- Board enables the plugin and connects a Stripe account. +- Webhooks and scheduled reconciliation keep plugin state current. +- Revenue widgets appear on the main dashboard and can be linked to company goals. +- Failed payment spikes or churn events can generate Paperclip issues for follow-up. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.dashboardWidget.register` +- `ui.page.register` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- `company.metrics.write` +- optional `issues.create` +- `activity.log.write` + +Important constraint: + +- Stripe data should stay additive to Paperclip core +- it should not leak into core budgeting logic, which is specifically about model/token spend in V1 + +## Specific Patterns From OpenCode Worth Adopting + +## Adopt + +- separate SDK package from runtime loader +- deterministic load order and precedence +- very small authoring API +- typed schemas for plugin inputs/config/tools +- internal extensions using the same registration shapes as external ones when reasonable +- plugin load errors isolated from host startup when possible +- explicit community-facing plugin docs and example templates + +## Adapt, not copy + +- local path loading +- dependency auto-install +- hook mutation model +- built-in override behavior +- broad runtime context objects + +## Avoid + +- project-local arbitrary code loading +- implicit trust of npm packages at startup +- plugins overriding core invariants +- unsandboxed in-process execution as the default extension model + +## Suggested Rollout Plan + +## Phase 0: Harden the seams that already exist + +- formalize adapter/storage/secret/run-log registries as "platform modules" +- remove ad-hoc fallback behavior where possible +- document stable registration contracts + +## Phase 1: Add connector plugins first + +This is the highest-value, lowest-risk plugin category. + +Build: + +- plugin manifest +- global install/update lifecycle +- global plugin config and optional company-mapping storage +- secret ref access +- typed domain event subscription +- scheduled jobs +- webhook endpoints +- activity logging helpers +- dashboard widget and settings-panel contributions + +This phase would immediately cover: + +- Linear +- GitHub +- Grafana +- Stripe + +## Phase 2: Add workspace services and workspace plugins + +Build first-party primitives: + +- project workspace service built on `project_workspaces` +- PTY/session service +- file service +- git service +- process/service tracker + +Then expose additive plugin/UI surfaces on top. + +This phase covers: + +- file browser +- terminal +- git workflow +- child process/server tracking + +## Phase 3: Consider richer UI and plugin packaging + +Only after phases 1 and 2 are stable: + +- richer frontend extension support +- signed/verified plugin packages +- plugin marketplace +- optional custom plugin storage backends or migrations + +## Recommended Architecture Decision + +If I had to collapse this report into one architectural decision, it would be: + +Paperclip should not implement "an OpenCode-style generic in-process hook system." +Paperclip should implement "a plugin platform with multiple trust tiers": + +- trusted platform modules for low-level runtime integration +- typed out-of-process plugins for instance-wide integrations and automation +- schema-driven UI contributions +- core-owned invariants that plugins can observe and act around, but not replace + +That gets the upside of `opencode`'s extensibility without importing the wrong threat model. + +## Concrete Next Steps I Would Take In Paperclip + +1. Write a short extension architecture RFC that formalizes the distinction between `platform modules` and `plugins`. +2. Introduce a small plugin manifest type in `packages/shared` and a `plugins` install/config section in the instance config. +3. Build a typed domain event bus around existing activity/live-event patterns, but keep core invariants non-hookable. +4. Implement connector-plugin MVP only: global install/config, secret refs, jobs, webhooks, settings panel, dashboard widget. +5. Treat workspace features as a separate track that starts by building core workspace primitives, not raw plugin hooks. From 3479ea6e80c707f962eda7f5e1589c3091d12446 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 17:34:38 -0600 Subject: [PATCH 07/14] openclaw gateway: persist device keys on create/update and clarify pairing flow --- doc/OPENCLAW_ONBOARDING.md | 5 ++ .../doc/ONBOARDING_AND_TEST_PLAN.md | 3 + .../openclaw-gateway/src/server/execute.ts | 13 +++- server/src/routes/agents.ts | 60 +++++++++++++++---- .../openclaw-gateway/config-fields.tsx | 14 ++--- 5 files changed, 74 insertions(+), 21 deletions(-) diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index b55e755a..c035c7e9 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -41,11 +41,16 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT Pairing handshake note: - The first gateway run may return `pairing required` once for a new device key. +- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - 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\")"' ``` +- You can inspect pending vs paired devices: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"' +``` 7. Case A (manual issue test). - Create an issue assigned to the OpenClaw agent. 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 042b8656..cdf7bfe3 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -267,8 +267,11 @@ POST /api/companies/$CLA_COMPANY_ID/invites - default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs - 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. + - Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates. - Local docker automation path: - `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token ` + - Optional inspection: + - `openclaw devices list --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. diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index ceec0b91..851b28a5 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -22,6 +22,7 @@ type GatewayDeviceIdentity = { deviceId: string; publicKeyRawBase64Url: string; privateKeyPem: string; + source: "configured" | "ephemeral"; }; type GatewayRequestFrame = { @@ -486,6 +487,7 @@ function resolveDeviceIdentity(config: Record): GatewayDeviceId deviceId: crypto.createHash("sha256").update(raw).digest("hex"), publicKeyRawBase64Url: base64UrlEncode(raw), privateKeyPem: configuredPrivateKey, + source: "configured", }; } @@ -497,6 +499,7 @@ function resolveDeviceIdentity(config: Record): GatewayDeviceId deviceId: crypto.createHash("sha256").update(raw).digest("hex"), publicKeyRawBase64Url: base64UrlEncode(raw), privateKeyPem, + source: "ephemeral", }; } @@ -912,6 +915,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` : message; await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 008d9094..a57b63c2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,5 @@ import { Router, type Request } from "express"; -import { randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db"; @@ -181,6 +181,40 @@ export function agentRoutes(db: Db) { return trimmed.length > 0 ? trimmed : null; } + function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return null; + } + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return null; + } + + function generateEd25519PrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("ed25519"); + return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + } + + function ensureGatewayDeviceKey( + adapterType: string | null | undefined, + adapterConfig: Record, + ): Record { + if (adapterType !== "openclaw_gateway") return adapterConfig; + const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true; + if (disableDeviceAuth) return adapterConfig; + if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig; + return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() }; + } + function applyCreateDefaultsByAdapterType( adapterType: string | null | undefined, adapterConfig: Record, @@ -196,13 +230,13 @@ export function agentRoutes(db: Db) { if (!hasBypassFlag) { next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } - return next; + return ensureGatewayDeviceKey(adapterType, next); } // OpenCode requires explicit model selection — no default if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; } - return next; + return ensureGatewayDeviceKey(adapterType, next); } async function assertAdapterConfigConstraints( @@ -930,11 +964,7 @@ export function agentRoutes(db: Db) { if (changingInstructionsPath) { await assertCanManageInstructionsPath(req, existing); } - patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, - adapterConfig, - { strictMode: strictSecretsMode }, - ); + patchData.adapterConfig = adapterConfig; } const requestedAdapterType = @@ -942,15 +972,23 @@ export function agentRoutes(db: Db) { const touchesAdapterConfiguration = Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); - if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + if (touchesAdapterConfiguration) { const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) : (asRecord(existing.adapterConfig) ?? {}); - const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, + const effectiveAdapterConfig = applyCreateDefaultsByAdapterType( + requestedAdapterType, rawEffectiveAdapterConfig, + ); + const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + effectiveAdapterConfig, { strictMode: strictSecretsMode }, ); + patchData.adapterConfig = normalizedEffectiveAdapterConfig; + } + if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; await assertAdapterConfigConstraints( existing.companyId, requestedAdapterType, diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx index 5bcad80b..178f9f61 100644 --- a/ui/src/adapters/openclaw-gateway/config-fields.tsx +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -204,15 +204,11 @@ export function OpenClawGatewayConfigFields({ /> - - + +
+ Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals + remain stable across runs. +
)} From 2223afa0e9d680ce1ea92c5353f131190277956f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 17:46:55 -0600 Subject: [PATCH 08/14] openclaw gateway: auto-approve first pairing and retry --- doc/OPENCLAW_ONBOARDING.md | 3 +- packages/adapters/openclaw-gateway/README.md | 1 + .../doc/ONBOARDING_AND_TEST_PLAN.md | 4 +- .../adapters/openclaw-gateway/src/index.ts | 1 + .../openclaw-gateway/src/server/execute.ts | 636 +++++++++++------- .../openclaw-gateway-adapter.test.ts | 239 +++++++ 6 files changed, 648 insertions(+), 236 deletions(-) diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index c035c7e9..14a251de 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -40,7 +40,8 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT - 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. +- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key. - This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - For local docker smoke, you can approve from host: diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md index 61ebfaea..cadc8198 100644 --- a/packages/adapters/openclaw-gateway/README.md +++ b/packages/adapters/openclaw-gateway/README.md @@ -32,6 +32,7 @@ By default the adapter sends a signed `device` payload in `connect` params. - set `disableDeviceAuth=true` to omit device signing - set `devicePrivateKeyPem` to pin a stable signing key - without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run +- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once. ## Session Strategy 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 cdf7bfe3..f8cacedb 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -266,7 +266,9 @@ POST /api/companies/$CLA_COMPANY_ID/invites - 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. +5. Trigger one connectivity run. Adapter behavior on first pairing gate: + - default: auto-attempt `device.pair.list` + `device.pair.approve` over shared auth, then retry once + - if auto-pair fails, run returns `pairing required`; approve manually in OpenClaw and retry once - Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates. - Local docker automation path: - `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token ` diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index ca16cdc9..34f7201d 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -33,6 +33,7 @@ Request behavior fields: - payloadTemplate (object, optional): additional fields merged into gateway agent params - timeoutSec (number, optional): adapter timeout in seconds (default 120) - waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) +- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true) - paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text Session routing fields: diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 851b28a5..b92dccfa 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -57,6 +57,11 @@ type PendingRequest = { timer: ReturnType | null; }; +type GatewayResponseError = Error & { + gatewayCode?: string; + gatewayDetails?: Record; +}; + type GatewayClientOptions = { url: string; headers: Record; @@ -164,6 +169,10 @@ function normalizeScopes(value: unknown): string[] { return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES]; } +function uniqueScopes(scopes: string[]): string[] { + return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))); +} + function headerMapGetIgnoreCase(headers: Record, key: string): string | null { const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); return match ? match[1] : null; @@ -173,6 +182,21 @@ function headerMapHasIgnoreCase(headers: Record, key: string): b return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase()); } +function getGatewayErrorDetails(err: unknown): Record | null { + if (!err || typeof err !== "object") return null; + const candidate = (err as GatewayResponseError).gatewayDetails; + return asRecord(candidate); +} + +function extractPairingRequestId(err: unknown): string | null { + const details = getGatewayErrorDetails(err); + const fromDetails = nonEmpty(details?.requestId); + if (fromDetails) return fromDetails; + const message = err instanceof Error ? err.message : String(err); + const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i); + return match?.[1] ?? null; +} + function toAuthorizationHeaderValue(rawToken: string): string { const trimmed = rawToken.trim(); if (!trimmed) return trimmed; @@ -691,7 +715,101 @@ class GatewayWsClient { nonEmpty(errorRecord?.message) ?? nonEmpty(errorRecord?.code) ?? "gateway request failed"; - pending.reject(new Error(message)); + const err = new Error(message) as GatewayResponseError; + const code = nonEmpty(errorRecord?.code); + const details = asRecord(errorRecord?.details); + if (code) err.gatewayCode = code; + if (details) err.gatewayDetails = details; + pending.reject(err); + } +} + +async function autoApproveDevicePairing(params: { + url: string; + headers: Record; + connectTimeoutMs: number; + clientId: string; + clientMode: string; + clientVersion: string; + role: string; + scopes: string[]; + authToken: string | null; + password: string | null; + requestId: string | null; + deviceId: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> { + if (!params.authToken && !params.password) { + return { ok: false, reason: "shared auth token/password is missing" }; + } + + const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]); + const client = new GatewayWsClient({ + url: params.url, + headers: params.headers, + onEvent: () => {}, + onLog: params.onLog, + }); + + try { + await params.onLog( + "stdout", + "[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n", + ); + + await client.connect( + () => ({ + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: params.clientId, + version: params.clientVersion, + platform: process.platform, + mode: params.clientMode, + }, + role: params.role, + scopes: approvalScopes, + auth: { + ...(params.authToken ? { token: params.authToken } : {}), + ...(params.password ? { password: params.password } : {}), + }, + }), + params.connectTimeoutMs, + ); + + let requestId = params.requestId; + if (!requestId) { + const listPayload = await client.request>("device.pair.list", {}, { + timeoutMs: params.connectTimeoutMs, + }); + const pending = Array.isArray(listPayload.pending) ? listPayload.pending : []; + const pendingRecords = pending + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)); + const matching = + (params.deviceId + ? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId) + : null) ?? pendingRecords[pendingRecords.length - 1]; + requestId = nonEmpty(matching?.requestId); + } + + if (!requestId) { + return { ok: false, reason: "no pending device pairing request found" }; + } + + await client.request( + "device.pair.approve", + { requestId }, + { + timeoutMs: params.connectTimeoutMs, + }, + ); + + return { ok: true, requestId }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } finally { + client.close(); } } @@ -824,63 +942,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise([ctx.runId]); - const assistantChunks: string[] = []; - let lifecycleError: string | null = null; - let latestResultPayload: unknown = null; - - const onEvent = async (frame: GatewayEventFrame) => { - if (frame.event !== "agent") { - if (frame.event === "shutdown") { - await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`); - } - return; - } - - const payload = asRecord(frame.payload); - if (!payload) return; - - const runId = nonEmpty(payload.runId); - if (!runId || !trackedRunIds.has(runId)) return; - - const stream = nonEmpty(payload.stream) ?? "unknown"; - const data = asRecord(payload.data) ?? {}; - await ctx.onLog( - "stdout", - `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, - ); - - if (stream === "assistant") { - const delta = nonEmpty(data.delta); - const text = nonEmpty(data.text); - if (delta) { - assistantChunks.push(delta); - } else if (text) { - assistantChunks.push(text); - } - return; - } - - if (stream === "error") { - lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; - return; - } - - if (stream === "lifecycle") { - const phase = nonEmpty(data.phase)?.toLowerCase(); - if (phase === "error" || phase === "failed" || phase === "cancelled") { - lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; - } - } - }; - - const client = new GatewayWsClient({ - url: parsedUrl.toString(), - headers, - onEvent, - onLog: ctx.onLog, - }); - if (ctx.onMeta) { await ctx.onMeta({ adapterType: "openclaw_gateway", @@ -913,198 +974,305 @@ export async function execute(ctx: AdapterExecutionContext): Promise([ctx.runId]); + const assistantChunks: string[] = []; + let lifecycleError: string | null = null; + let deviceIdentity: GatewayDeviceIdentity | null = null; + + const onEvent = async (frame: GatewayEventFrame) => { + if (frame.event !== "agent") { + if (frame.event === "shutdown") { + await ctx.onLog( + "stdout", + `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`, + ); + } + return; + } + + const payload = asRecord(frame.payload); + if (!payload) return; + + const runId = nonEmpty(payload.runId); + if (!runId || !trackedRunIds.has(runId)) return; + + const stream = nonEmpty(payload.stream) ?? "unknown"; + const data = asRecord(payload.data) ?? {}; await ctx.onLog( "stdout", - `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, ); - } else { - await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); - } - await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); - - const hello = await client.connect((nonce) => { - const signedAtMs = Date.now(); - const connectParams: Record = { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: clientId, - version: clientVersion, - platform: process.platform, - ...(deviceFamily ? { deviceFamily } : {}), - mode: clientMode, - }, - role, - scopes, - auth: - authToken || password || deviceToken - ? { - ...(authToken ? { token: authToken } : {}), - ...(deviceToken ? { deviceToken } : {}), - ...(password ? { password } : {}), - } - : undefined, - }; - - if (deviceIdentity) { - const payload = buildDeviceAuthPayloadV3({ - deviceId: deviceIdentity.deviceId, - clientId, - clientMode, - role, - scopes, - signedAtMs, - token: authToken, - nonce, - platform: process.platform, - deviceFamily, - }); - connectParams.device = { - id: deviceIdentity.deviceId, - publicKey: deviceIdentity.publicKeyRawBase64Url, - signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; + if (stream === "assistant") { + const delta = nonEmpty(data.delta); + const text = nonEmpty(data.text); + if (delta) { + assistantChunks.push(delta); + } else if (text) { + assistantChunks.push(text); + } + return; } - return connectParams; - }, connectTimeoutMs); - await ctx.onLog( - "stdout", - `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, - ); + if (stream === "error") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + return; + } - const acceptedPayload = await client.request>("agent", agentParams, { - timeoutMs: connectTimeoutMs, + if (stream === "lifecycle") { + const phase = nonEmpty(data.phase)?.toLowerCase(); + if (phase === "error" || phase === "failed" || phase === "cancelled") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + } + } + }; + + const client = new GatewayWsClient({ + url: parsedUrl.toString(), + headers, + onEvent, + onLog: ctx.onLog, }); - latestResultPayload = acceptedPayload; + try { + deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); + if (deviceIdentity) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + ); + } else { + await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); + } - const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; - const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; - trackedRunIds.add(acceptedRunId); + await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); - await ctx.onLog( - "stdout", - `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, - ); + const hello = await client.connect((nonce) => { + const signedAtMs = Date.now(); + const connectParams: Record = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: clientId, + version: clientVersion, + platform: process.platform, + ...(deviceFamily ? { deviceFamily } : {}), + mode: clientMode, + }, + role, + scopes, + auth: + authToken || password || deviceToken + ? { + ...(authToken ? { token: authToken } : {}), + ...(deviceToken ? { deviceToken } : {}), + ...(password ? { password } : {}), + } + : undefined, + }; + + if (deviceIdentity) { + const payload = buildDeviceAuthPayloadV3({ + deviceId: deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken, + nonce, + platform: process.platform, + deviceFamily, + }); + connectParams.device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKeyRawBase64Url, + signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + } + return connectParams; + }, connectTimeoutMs); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, + ); + + const acceptedPayload = await client.request>("agent", agentParams, { + timeoutMs: connectTimeoutMs, + }); + + latestResultPayload = acceptedPayload; + + const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; + const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; + trackedRunIds.add(acceptedRunId); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, + ); + + if (acceptedStatus === "error") { + const errorMessage = + nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage, + errorCode: "openclaw_gateway_agent_error", + resultJson: acceptedPayload, + }; + } + + if (acceptedStatus !== "ok") { + const waitPayload = await client.request>( + "agent.wait", + { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, + { timeoutMs: waitTimeoutMs + connectTimeoutMs }, + ); + + latestResultPayload = waitPayload; + + const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; + if (waitStatus === "timeout") { + return { + exitCode: 1, + signal: null, + timedOut: true, + errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, + errorCode: "openclaw_gateway_wait_timeout", + resultJson: waitPayload, + }; + } + + if (waitStatus === "error") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + nonEmpty(waitPayload?.error) ?? + lifecycleError ?? + "OpenClaw gateway run failed", + errorCode: "openclaw_gateway_wait_error", + resultJson: waitPayload, + }; + } + + if (waitStatus && waitStatus !== "ok") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, + errorCode: "openclaw_gateway_wait_status_unexpected", + resultJson: waitPayload, + }; + } + } + + const summaryFromEvents = assistantChunks.join("").trim(); + const summaryFromPayload = + extractResultText(asRecord(acceptedPayload?.result)) ?? + extractResultText(acceptedPayload) ?? + extractResultText(asRecord(latestResultPayload)) ?? + null; + const summary = summaryFromEvents || summaryFromPayload || null; + + const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); + const agentMeta = asRecord(meta?.agentMeta); + const usage = parseUsage(agentMeta?.usage ?? meta?.usage); + const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; + const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; + const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`, + ); + + return { + exitCode: 0, + signal: null, + timedOut: false, + provider, + ...(model ? { model } : {}), + ...(usage ? { usage } : {}), + ...(costUsd > 0 ? { costUsd } : {}), + resultJson: asRecord(latestResultPayload), + ...(summary ? { summary } : {}), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + const timedOut = lower.includes("timeout"); + const pairingRequired = lower.includes("pairing required"); + + if ( + pairingRequired && + !disableDeviceAuth && + autoPairOnFirstConnect && + !autoPairAttempted && + (authToken || password) + ) { + autoPairAttempted = true; + const pairResult = await autoApproveDevicePairing({ + url: parsedUrl.toString(), + headers, + connectTimeoutMs, + clientId, + clientMode, + clientVersion, + role, + scopes, + authToken, + password, + requestId: extractPairingRequestId(err), + deviceId: deviceIdentity?.deviceId ?? null, + onLog: ctx.onLog, + }); + if (pairResult.ok) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`, + ); + continue; + } + await ctx.onLog( + "stderr", + `[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`, + ); + } + + const detailedMessage = pairingRequired + ? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` + : message; + + await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); - if (acceptedStatus === "error") { - const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; return { exitCode: 1, signal: null, - timedOut: false, - errorMessage, - errorCode: "openclaw_gateway_agent_error", - resultJson: acceptedPayload, + timedOut, + errorMessage: detailedMessage, + errorCode: timedOut + ? "openclaw_gateway_timeout" + : pairingRequired + ? "openclaw_gateway_pairing_required" + : "openclaw_gateway_request_failed", + resultJson: asRecord(latestResultPayload), }; + } finally { + client.close(); } - - if (acceptedStatus !== "ok") { - const waitPayload = await client.request>( - "agent.wait", - { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, - { timeoutMs: waitTimeoutMs + connectTimeoutMs }, - ); - - latestResultPayload = waitPayload; - - const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; - if (waitStatus === "timeout") { - return { - exitCode: 1, - signal: null, - timedOut: true, - errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, - errorCode: "openclaw_gateway_wait_timeout", - resultJson: waitPayload, - }; - } - - if (waitStatus === "error") { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - nonEmpty(waitPayload?.error) ?? - lifecycleError ?? - "OpenClaw gateway run failed", - errorCode: "openclaw_gateway_wait_error", - resultJson: waitPayload, - }; - } - - if (waitStatus && waitStatus !== "ok") { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, - errorCode: "openclaw_gateway_wait_status_unexpected", - resultJson: waitPayload, - }; - } - } - - const summaryFromEvents = assistantChunks.join("").trim(); - const summaryFromPayload = - extractResultText(asRecord(acceptedPayload?.result)) ?? - extractResultText(acceptedPayload) ?? - extractResultText(asRecord(latestResultPayload)) ?? - null; - const summary = summaryFromEvents || summaryFromPayload || null; - - const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); - const agentMeta = asRecord(meta?.agentMeta); - const usage = parseUsage(agentMeta?.usage ?? meta?.usage); - const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; - const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; - const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); - - await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`); - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider, - ...(model ? { model } : {}), - ...(usage ? { usage } : {}), - ...(costUsd > 0 ? { costUsd } : {}), - resultJson: asRecord(latestResultPayload), - ...(summary ? { summary } : {}), - }; - } catch (err) { - 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}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` - : message; - - await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); - - return { - exitCode: 1, - signal: null, - timedOut, - errorMessage: detailedMessage, - errorCode: timedOut - ? "openclaw_gateway_timeout" - : pairingRequired - ? "openclaw_gateway_pairing_required" - : "openclaw_gateway_request_failed", - resultJson: asRecord(latestResultPayload), - }; - } finally { - client.close(); } } diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index df57af32..3a4ac10e 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -167,6 +167,208 @@ async function createMockGatewayServer() { }; } +async function createMockGatewayServerWithPairing() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + + let agentPayload: Record | null = null; + let approved = false; + let pendingRequestId = "req-1"; + let lastSeenDeviceId: string | null = null; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123" }, + }), + ); + + socket.on("message", (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const frame = JSON.parse(text) as { + type: string; + id: string; + method: string; + params?: Record; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + const device = frame.params?.device as Record | undefined; + const deviceId = typeof device?.id === "string" ? device.id : null; + if (deviceId) { + lastSeenDeviceId = deviceId; + } + + if (deviceId && !approved) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { + code: "NOT_PAIRED", + message: "pairing required", + details: { + code: "PAIRING_REQUIRED", + requestId: pendingRequestId, + reason: "not-paired", + }, + }, + }), + ); + socket.close(1008, "pairing required"); + return; + } + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { + methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"], + events: ["agent"], + }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "device.pair.list") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + pending: approved + ? [] + : [ + { + requestId: pendingRequestId, + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + ], + paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [], + }, + }), + ); + return; + } + + if (frame.method === "device.pair.approve") { + const requestId = frame.params?.requestId; + if (requestId !== pendingRequestId) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { code: "INVALID_REQUEST", message: "unknown requestId" }, + }), + ); + return; + } + approved = true; + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + requestId: pendingRequestId, + device: { + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayload = frame.params ?? null; + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : "run-123"; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { delta: "ok" }, + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}`, + getAgentPayload: () => agentPayload, + close: async () => { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + afterEach(() => { // no global mocks }); @@ -238,6 +440,43 @@ describe("openclaw gateway adapter execute", () => { expect(result.exitCode).toBe(1); expect(result.errorCode).toBe("openclaw_gateway_url_missing"); }); + + it("auto-approves pairing once and retries the run", async () => { + const gateway = await createMockGatewayServerWithPairing(); + const logs: string[] = []; + + try { + const result = await execute( + buildContext( + { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2000, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + expect(result.summary).toContain("ok"); + expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe( + true, + ); + expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true); + expect(gateway.getAgentPayload()).toBeTruthy(); + } finally { + await gateway.close(); + } + }); }); describe("openclaw gateway testEnvironment", () => { From 0233525e99f8fae38cace0137a291d9c49854781 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 18:19:06 -0600 Subject: [PATCH 09/14] Add CEO OpenClaw invite endpoint and update onboarding UX --- doc/OPENCLAW_ONBOARDING.md | 21 +- packages/shared/src/index.ts | 2 + packages/shared/src/validators/access.ts | 8 + packages/shared/src/validators/index.ts | 2 + .../openclaw-invite-prompt-route.test.ts | 181 ++++++++++++++++++ server/src/routes/access.ts | 171 ++++++++++++----- skills/paperclip/SKILL.md | 25 +++ skills/paperclip/references/api-reference.md | 18 ++ ui/src/api/access.ts | 33 +++- ui/src/components/NewAgentDialog.tsx | 175 ++++++++++++++--- ui/src/components/OnboardingWizard.tsx | 31 +-- ui/src/pages/CompanySettings.tsx | 12 +- ui/src/pages/NewAgent.tsx | 49 ++++- 13 files changed, 608 insertions(+), 120 deletions(-) create mode 100644 server/src/__tests__/openclaw-invite-prompt-route.test.ts diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index 14a251de..bdb098b3 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -18,20 +18,28 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser. 3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`. -4. Use the agent snippet flow. -- Copy the snippet from company settings. +4. Use the OpenClaw invite prompt flow. +- In the Invites section, click `Generate OpenClaw Invite Prompt`. +- Copy the generated prompt from `OpenClaw Invite Prompt`. - Paste it into OpenClaw main chat as one message. - If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` +Security/control note: +- The OpenClaw invite prompt is created from a controlled endpoint: + - `POST /api/companies/{companyId}/openclaw/invite-prompt` + - board users with invite permission can call it + - agent callers are limited to the company CEO agent + 5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents. 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). +- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding. - 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 + - required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem` + - do not rely on `disableDeviceAuth` for normal onboarding - If you can run API checks with board auth: ```bash AGENT_ID="" @@ -40,8 +48,9 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT - Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. Pairing handshake note: -- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). -- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key. +- Clean run expectation: first task should succeed without manual pairing commands. +- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`. - This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - For local docker smoke, you can approve from host: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59ec9eb6..a91f8844 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -197,6 +197,7 @@ export { updateBudgetSchema, createAssetImageMetadataSchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, @@ -206,6 +207,7 @@ export { type UpdateBudget, type CreateAssetImageMetadata, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 614b302e..75b31709 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({ export type CreateCompanyInvite = z.infer; +export const createOpenClawInvitePromptSchema = z.object({ + agentMessage: z.string().max(4000).optional().nullable(), +}); + +export type CreateOpenClawInvitePrompt = z.infer< + typeof createOpenClawInvitePromptSchema +>; + export const acceptInviteSchema = z.object({ requestType: z.enum(JOIN_REQUEST_TYPES), agentName: z.string().min(1).max(120).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 12ad7ffb..f4130c67 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -119,12 +119,14 @@ export { export { createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts new file mode 100644 index 00000000..68cb8759 --- /dev/null +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -0,0 +1,181 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAccessService = vi.hoisted(() => ({ + hasPermission: vi.fn(), + canUser: vi.fn(), + isInstanceAdmin: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listMembers: vi.fn(), + setMemberPermissions: vi.fn(), + promoteInstanceAdmin: vi.fn(), + demoteInstanceAdmin: vi.fn(), + listUserCompanyAccess: vi.fn(), + setUserCompanyAccess: vi.fn(), + setPrincipalGrants: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + deduplicateAgentName: vi.fn(), + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + defaultsPayload: null, + expiresAt: new Date("2026-03-07T00:10:00.000Z"), + invitedByUserId: null, + tokenHash: "hash", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const returning = vi.fn().mockResolvedValue([createdInvite]); + const values = vi.fn().mockReturnValue({ returning }); + const insert = vi.fn().mockReturnValue({ values }); + return { + insert, + }; +} + +function createApp(actor: Record, db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/openclaw/invite-prompt", () => { + beforeEach(() => { + mockAccessService.canUser.mockResolvedValue(false); + mockAgentService.getById.mockReset(); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("rejects non-CEO agent callers", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + }); + + it("allows CEO agent callers and creates an agent-only invite", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "ceo", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({ agentMessage: "Join and configure OpenClaw gateway." }); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + expect(typeof res.body.token).toBe("string"); + expect(res.body.onboardingTextPath).toContain("/api/invites/"); + }); + + it("allows board callers with invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(true); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + }); + + it("rejects board callers without invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Permission denied"); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 406e4bd3..3e2ba527 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -21,6 +21,7 @@ import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, @@ -1942,6 +1943,80 @@ export function accessRoutes( if (!allowed) throw forbidden("Permission denied"); } + async function assertCanGenerateOpenClawInvitePrompt( + req: Request, + companyId: string + ) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "agent") { + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.role !== "ceo") { + throw forbidden("Only CEO agents can generate OpenClaw invite prompts"); + } + return; + } + if (req.actor.type !== "board") throw unauthorized(); + if (isLocalImplicit(req)) return; + const allowed = await access.canUser(companyId, req.actor.userId, "users:invite"); + if (!allowed) throw forbidden("Permission denied"); + } + + async function createCompanyInviteForCompany(input: { + req: Request; + companyId: string; + allowedJoinTypes: "human" | "agent" | "both"; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }) { + const normalizedAgentMessage = + typeof input.agentMessage === "string" + ? input.agentMessage.trim() || null + : null; + const insertValues = { + companyId: input.companyId, + inviteType: "company_join" as const, + allowedJoinTypes: input.allowedJoinTypes, + defaultsPayload: mergeInviteDefaults( + input.defaultsPayload ?? null, + normalizedAgentMessage + ), + expiresAt: companyInviteExpiresAt(), + invitedByUserId: input.req.actor.userId ?? null + }; + + let token: string | null = null; + let created: typeof invites.$inferSelect | null = null; + for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { + const candidateToken = createInviteToken(); + try { + const row = await db + .insert(invites) + .values({ + ...insertValues, + tokenHash: hashToken(candidateToken) + }) + .returning() + .then((rows) => rows[0]); + token = candidateToken; + created = row; + break; + } catch (error) { + if (!isInviteTokenHashCollisionError(error)) { + throw error; + } + } + } + if (!token || !created) { + throw conflict("Failed to generate a unique invite token. Please retry."); + } + + return { token, created, normalizedAgentMessage }; + } + router.get("/skills/index", (_req, res) => { res.json({ skills: [ @@ -1967,49 +2042,14 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); - const normalizedAgentMessage = - typeof req.body.agentMessage === "string" - ? req.body.agentMessage.trim() || null - : null; - const insertValues = { - companyId, - inviteType: "company_join" as const, - allowedJoinTypes: req.body.allowedJoinTypes, - defaultsPayload: mergeInviteDefaults( - req.body.defaultsPayload ?? null, - normalizedAgentMessage - ), - expiresAt: companyInviteExpiresAt(), - invitedByUserId: req.actor.userId ?? null - }; - - let token: string | null = null; - let created: typeof invites.$inferSelect | null = null; - for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { - const candidateToken = createInviteToken(); - try { - const row = await db - .insert(invites) - .values({ - ...insertValues, - tokenHash: hashToken(candidateToken) - }) - .returning() - .then((rows) => rows[0]); - token = candidateToken; - created = row; - break; - } catch (error) { - if (!isInviteTokenHashCollisionError(error)) { - throw error; - } - } - } - if (!token || !created) { - throw conflict( - "Failed to generate a unique invite token. Please retry." - ); - } + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: req.body.allowedJoinTypes, + defaultsPayload: req.body.defaultsPayload ?? null, + agentMessage: req.body.agentMessage ?? null + }); await logActivity(db, { companyId, @@ -2041,6 +2081,51 @@ export function accessRoutes( } ); + router.post( + "/companies/:companyId/openclaw/invite-prompt", + validate(createOpenClawInvitePromptSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanGenerateOpenClawInvitePrompt(req, companyId); + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: "agent", + defaultsPayload: null, + agentMessage: req.body.agentMessage ?? null + }); + + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", + action: "invite.openclaw_prompt_created", + entityType: "invite", + entityId: created.id, + details: { + inviteType: created.inviteType, + allowedJoinTypes: created.allowedJoinTypes, + expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage) + } + }); + + const inviteSummary = toInviteSummaryResponse(req, token, created); + res.status(201).json({ + ...created, + token, + inviteUrl: `/invite/${token}`, + onboardingTextPath: inviteSummary.onboardingTextPath, + onboardingTextUrl: inviteSummary.onboardingTextUrl, + inviteMessage: inviteSummary.inviteMessage + }); + } + ); + router.get("/invites/:token", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 92fe3ba4..bb3cbb04 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -91,6 +91,30 @@ Workspace rules: - For repo-only setup, omit `cwd` and provide `repoUrl`. - Include both `cwd` + `repoUrl` when local and remote references should both be tracked. +## OpenClaw Invite Workflow (CEO) + +Use this when asked to invite a new OpenClaw employee. + +1. Generate a fresh OpenClaw invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ "agentMessage": "optional onboarding note for OpenClaw" } +``` + +Access control: +- Board users with invite permission can call it. +- Agent callers: only the company CEO agent can call it. + +2. Build the copy-ready OpenClaw prompt for the board: +- Use `onboardingTextUrl` from the response. +- Ask the board to paste that prompt into OpenClaw. +- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`. + +3. Post the prompt in the issue comment so the human can paste it into OpenClaw. + +4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install). + ## Critical Rules - **Always checkout** before working. Never PATCH to `in_progress` manually. @@ -206,6 +230,7 @@ PATCH /api/agents/{agentId}/instructions-path | Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | | Add comment | `POST /api/issues/:issueId/comments` | | Create subtask | `POST /api/companies/:companyId/issues` | +| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` | | Create project | `POST /api/companies/:companyId/projects` | | Create project workspace | `POST /api/projects/:projectId/workspaces` | | Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index a88abb82..cbf5ef05 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -280,6 +280,23 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts, Use the dashboard for situational awareness, especially if you're a manager or CEO. +## OpenClaw Invite Prompt (CEO) + +Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ + "agentMessage": "optional note for the joining OpenClaw agent" +} +``` + +Response includes invite token, onboarding text URL, and expiry metadata. + +Access is intentionally constrained: +- board users with invite permission +- CEO agent only (non-CEO agents are rejected) + --- ## Setting Agent Instructions Path @@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled` | GET | `/api/goals/:goalId` | Goal details | | POST | `/api/companies/:companyId/goals` | Create goal | | PATCH | `/api/goals/:goalId` | Update goal | +| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) | ### Approvals, Costs, Activity, Dashboard diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 7e89afd6..ce565f6d 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -64,6 +64,17 @@ type BoardClaimStatus = { claimedByUserId: string | null; }; +type CompanyInviteCreated = { + id: string; + token: string; + inviteUrl: string; + expiresAt: string; + allowedJoinTypes: "human" | "agent" | "both"; + onboardingTextPath?: string; + onboardingTextUrl?: string; + inviteMessage?: string | null; +}; + export const accessApi = { createCompanyInvite: ( companyId: string, @@ -73,16 +84,18 @@ export const accessApi = { agentMessage?: string | null; } = {}, ) => - api.post<{ - id: string; - token: string; - inviteUrl: string; - expiresAt: string; - allowedJoinTypes: "human" | "agent" | "both"; - onboardingTextPath?: string; - onboardingTextUrl?: string; - inviteMessage?: string | null; - }>(`/companies/${companyId}/invites`, input), + api.post(`/companies/${companyId}/invites`, input), + + createOpenClawInvitePrompt: ( + companyId: string, + input: { + agentMessage?: string | null; + } = {}, + ) => + api.post( + `/companies/${companyId}/openclaw/invite-prompt`, + input, + ), getInvite: (token: string) => api.get(`/invites/${token}`), getInviteOnboarding: (token: string) => diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index b3ab9233..18830792 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,3 +1,4 @@ +import { useState, type ComponentType } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; @@ -9,12 +10,77 @@ import { DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Bot, Sparkles } from "lucide-react"; +import { + ArrowLeft, + Bot, + Code, + MousePointer2, + Sparkles, + Terminal, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; + +type AdvancedAdapterType = + | "claude_local" + | "codex_local" + | "opencode_local" + | "pi_local" + | "cursor" + | "openclaw_gateway"; + +const ADVANCED_ADAPTER_OPTIONS: Array<{ + value: AdvancedAdapterType; + label: string; + desc: string; + icon: ComponentType<{ className?: string }>; + recommended?: boolean; +}> = [ + { + value: "claude_local", + label: "Claude Code", + icon: Sparkles, + desc: "Local Claude agent", + recommended: true, + }, + { + value: "codex_local", + label: "Codex", + icon: Code, + desc: "Local Codex agent", + recommended: true, + }, + { + value: "opencode_local", + label: "OpenCode", + icon: OpenCodeLogoIcon, + desc: "Local multi-provider agent", + }, + { + value: "pi_local", + label: "Pi", + icon: Terminal, + desc: "Local Pi agent", + }, + { + value: "cursor", + label: "Cursor", + icon: MousePointer2, + desc: "Local Cursor agent", + }, + { + value: "openclaw_gateway", + label: "OpenClaw Gateway", + icon: Bot, + desc: "Invoke OpenClaw via gateway protocol", + }, +]; export function NewAgentDialog() { const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); + const [showAdvancedCards, setShowAdvancedCards] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -34,15 +100,23 @@ export function NewAgentDialog() { } function handleAdvancedConfig() { + setShowAdvancedCards(true); + } + + function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) { closeNewAgent(); - navigate("/agents/new"); + setShowAdvancedCards(false); + navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); } return ( { - if (!open) closeNewAgent(); + if (!open) { + setShowAdvancedCards(false); + closeNewAgent(); + } }} > { + setShowAdvancedCards(false); + closeNewAgent(); + }} > ×
- {/* Recommendation */} -
-
- -
-

- We recommend letting your CEO handle agent setup — they know the - org structure and can configure reporting, permissions, and - adapters. -

-
+ {!showAdvancedCards ? ( + <> + {/* Recommendation */} +
+
+ +
+

+ We recommend letting your CEO handle agent setup — they know the + org structure and can configure reporting, permissions, and + adapters. +

+
- + - {/* Advanced link */} -
- -
+ {/* Advanced link */} +
+ +
+ + ) : ( + <> +
+ +

+ Choose your adapter type for advanced setup. +

+
+ +
+ {ADVANCED_ADAPTER_OPTIONS.map((opt) => ( + + ))} +
+ + )}
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 7a4bceeb..e1520356 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -38,7 +38,6 @@ import { ArrowLeft, ArrowRight, Terminal, - Globe, Sparkles, MousePointer2, Check, @@ -673,38 +672,19 @@ export function OnboardingWizard() { icon: Terminal, desc: "Local Pi agent" }, - { - value: "openclaw" as const, - label: "OpenClaw", - icon: Bot, - desc: "Notify OpenClaw webhook", - comingSoon: true - }, { value: "openclaw_gateway" as const, label: "OpenClaw Gateway", icon: Bot, - desc: "Invoke OpenClaw via gateway protocol" + desc: "Invoke OpenClaw via gateway protocol", + comingSoon: true, + disabledLabel: "Configure OpenClaw within the App" }, { value: "cursor" as const, label: "Cursor", icon: MousePointer2, desc: "Local Cursor agent" - }, - { - value: "process" as const, - label: "Shell Command", - icon: Terminal, - desc: "Run a process", - comingSoon: true - }, - { - value: "http" as const, - label: "HTTP Webhook", - icon: Globe, - desc: "Call an endpoint", - comingSoon: true } ].map((opt) => ( ))} diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index c11bd8b9..878c5193 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -77,9 +77,7 @@ export function CompanySettings() { const inviteMutation = useMutation({ mutationFn: () => - accessApi.createCompanyInvite(selectedCompanyId!, { - allowedJoinTypes: "agent" - }), + accessApi.createOpenClawInvitePrompt(selectedCompanyId!), onSuccess: async (invite) => { setInviteError(null); const base = window.location.origin.replace(/\/+$/, ""); @@ -317,9 +315,9 @@ export function CompanySettings() {
- Generate an agent snippet for join flows. + Generate an openclaw agent invite snippet. - +
{inviteError && ( @@ -339,7 +337,7 @@ export function CompanySettings() {
- Agent Snippet + OpenClaw Invite Prompt
{snippetCopied && ( ([ + "claude_local", + "codex_local", + "opencode_local", + "pi_local", + "cursor", + "openclaw_gateway", +]); + +function createValuesForAdapterType( + adapterType: CreateConfigValues["adapterType"], +): CreateConfigValues { + const { adapterType: _discard, ...defaults } = defaultCreateValues; + const nextValues: CreateConfigValues = { ...defaults, adapterType }; + if (adapterType === "codex_local") { + nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; + nextValues.dangerouslyBypassSandbox = + DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + } else if (adapterType === "cursor") { + nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; + } else if (adapterType === "opencode_local") { + nextValues.model = ""; + } + return nextValues; +} export function NewAgent() { - const { selectedCompanyId, selectedCompany } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const presetAdapterType = searchParams.get("adapterType"); const [name, setName] = useState(""); const [title, setTitle] = useState(""); @@ -71,6 +104,18 @@ export function NewAgent() { } }, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + const requested = presetAdapterType; + if (!requested) return; + if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) { + return; + } + setConfigValues((prev) => { + if (prev.adapterType === requested) return prev; + return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]); + }); + }, [presetAdapterType]); + const createAgent = useMutation({ mutationFn: (data: Record) => agentsApi.hire(selectedCompanyId!, data), From 0f32fffe797b401870828f595d93e97275c87156 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sat, 7 Mar 2026 16:20:19 -0800 Subject: [PATCH 10/14] fix(server): serve cached index.html in SPA catch-all to prevent 500 res.sendFile can emit NotFoundError from the send module in certain path resolution scenarios, causing 500s on company-scoped SPA routes. Cache index.html at startup and serve it directly, which is both more reliable and faster. Fixes #233 Co-Authored-By: Claude Opus 4.6 --- server/src/app.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/app.ts b/server/src/app.ts index d663654f..b21ec39f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -134,9 +134,10 @@ export async function createApp( ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { + const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"); app.use(express.static(uiDist)); app.get(/.*/, (_req, res) => { - res.sendFile("index.html", { root: uiDist }); + res.status(200).set("Content-Type", "text/html").end(indexHtml); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); From 5fae7d4de72c46e431b181fa059857e9004b6736 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 18:33:40 -0600 Subject: [PATCH 11/14] Fix CI typecheck and default OpenClaw sessions to issue scope --- cli/src/commands/client/agent.ts | 6 ++++++ packages/adapters/cursor-local/package.json | 1 + packages/adapters/cursor-local/tsconfig.json | 3 ++- packages/adapters/openclaw-gateway/README.md | 2 +- .../openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md | 3 +-- packages/adapters/openclaw-gateway/src/index.ts | 2 +- .../adapters/openclaw-gateway/src/server/execute.ts | 6 +++--- .../adapters/openclaw-gateway/src/ui/build-config.ts | 3 +-- packages/adapters/openclaw/README.md | 4 ++-- packages/adapters/openclaw/src/index.ts | 2 +- .../adapters/openclaw/src/server/execute-common.ts | 6 +++--- packages/adapters/openclaw/src/ui/build-config.ts | 3 +-- pnpm-lock.yaml | 3 +++ server/src/__tests__/openclaw-adapter.test.ts | 12 ++++++------ .../src/__tests__/openclaw-gateway-adapter.test.ts | 2 +- server/src/routes/access.ts | 6 ++---- 16 files changed, 35 insertions(+), 29 deletions(-) diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index c98ca158..36eb04e6 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -197,10 +197,16 @@ export function registerAgentCommands(program: Command): void { const agentRow = await ctx.api.get( `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, ); + if (!agentRow) { + throw new Error(`Agent not found: ${agentRef}`); + } const now = new Date().toISOString().replaceAll(":", "-"); const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + if (!key) { + throw new Error("Failed to create API key"); + } const installSummaries: SkillsInstallSummary[] = []; if (opts.installSkills !== false) { diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 575f9e1b..4ef66052 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/cursor-local/tsconfig.json b/packages/adapters/cursor-local/tsconfig.json index 2f355cfe..90314411 100644 --- a/packages/adapters/cursor-local/tsconfig.json +++ b/packages/adapters/cursor-local/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"] } diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md index cadc8198..ba3edde2 100644 --- a/packages/adapters/openclaw-gateway/README.md +++ b/packages/adapters/openclaw-gateway/README.md @@ -38,7 +38,7 @@ By default the adapter sends a signed `device` payload in `connect` params. The adapter supports the same session routing model as HTTP OpenClaw mode: -- `sessionKeyStrategy=fixed|issue|run` +- `sessionKeyStrategy=issue|fixed|run` - `sessionKey` is used when strategy is `fixed` Resolved session key is sent as `agent.sessionKey`. 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 f8cacedb..61c9b331 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -250,8 +250,7 @@ POST /api/companies/$CLA_COMPANY_ID/invites "headers": { "x-openclaw-token": "" }, "role": "operator", "scopes": ["operator.admin"], - "sessionKeyStrategy": "fixed", - "sessionKey": "paperclip", + "sessionKeyStrategy": "issue", "waitTimeoutMs": 120000 } } diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index 34f7201d..e15ca45c 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -37,6 +37,6 @@ Request behavior fields: - paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text Session routing fields: -- sessionKeyStrategy (string, optional): fixed (default), issue, or run +- sessionKeyStrategy (string, optional): issue (default), fixed, or run - sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) `; diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index b92dccfa..c8de510d 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -117,9 +117,9 @@ function parseBoolean(value: unknown, fallback = false): boolean { } function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { - const normalized = asString(value, "fixed").trim().toLowerCase(); - if (normalized === "issue" || normalized === "run") return normalized; - return "fixed"; + const normalized = asString(value, "issue").trim().toLowerCase(); + if (normalized === "fixed" || normalized === "run") return normalized; + return "issue"; } function resolveSessionKey(input: { diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts index fcbbbf4e..6a749f84 100644 --- a/packages/adapters/openclaw-gateway/src/ui/build-config.ts +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -5,8 +5,7 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record { const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; expect(body.foo).toBe("bar"); expect(body.stream).toBe(true); - expect(body.sessionKey).toBe("paperclip"); + expect(body.sessionKey).toBe("paperclip:issue:issue-123"); expect((body.paperclip as Record).streamTransport).toBe("sse"); expect((body.paperclip as Record).runId).toBe("run-123"); - expect((body.paperclip as Record).sessionKey).toBe("paperclip"); + expect((body.paperclip as Record).sessionKey).toBe("paperclip:issue:issue-123"); expect( ((body.paperclip as Record).env as Record).PAPERCLIP_RUN_ID, ).toBe("run-123"); @@ -414,7 +414,7 @@ describe("openclaw adapter execute", () => { expect(body.sessionKey).toBeUndefined(); const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-session-key"]).toBe("paperclip"); + expect(headers["x-openclaw-session-key"]).toBe("paperclip:issue:issue-123"); }); it("does not treat response.output_text.done as a terminal OpenResponses event", async () => { @@ -584,7 +584,7 @@ describe("openclaw adapter execute", () => { const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; expect(body.foo).toBe("bar"); expect(body.stream).toBe(false); - expect(body.sessionKey).toBe("paperclip"); + expect(body.sessionKey).toBe("paperclip:issue:issue-123"); expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect((body.paperclip as Record).streamTransport).toBe("webhook"); }); @@ -668,7 +668,7 @@ describe("openclaw adapter execute", () => { expect(String(secondBody.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); const secondHeaders = (fetchMock.mock.calls[1]?.[1]?.headers ?? {}) as Record; - expect(secondHeaders["x-openclaw-session-key"]).toBe("paperclip"); + expect(secondHeaders["x-openclaw-session-key"]).toBe("paperclip:issue:issue-123"); expect(result.resultJson).toEqual( expect.objectContaining({ usedLegacyResponsesFallback: true, @@ -766,7 +766,7 @@ describe("openclaw adapter execute", () => { expect(result.exitCode).toBe(0); expect(fetchMock).toHaveBeenCalledTimes(1); const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.sessionKey).toBe("paperclip"); + expect(body.sessionKey).toBe("paperclip:issue:issue-123"); }); it("retries webhook payloads with wake compatibility format on text-required errors", async () => { diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 3a4ac10e..364f5a97 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -424,7 +424,7 @@ describe("openclaw gateway adapter execute", () => { const payload = gateway.getAgentPayload(); expect(payload).toBeTruthy(); expect(payload?.idempotencyKey).toBe("run-123"); - expect(payload?.sessionKey).toBe("paperclip"); + expect(payload?.sessionKey).toBe("paperclip:issue:issue-123"); expect(String(payload?.message ?? "")).toContain("wake now"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 3e2ba527..9eaacf71 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1484,8 +1484,7 @@ export function buildInviteOnboardingTextDocument( paperclipApiUrl: "http://host.docker.internal:3100", headers: { "x-openclaw-token": token }, waitTimeoutMs: 120000, - sessionKeyStrategy: "fixed", - sessionKey: "paperclip", + sessionKeyStrategy: "issue", role: "operator", scopes: ["operator.admin"] } @@ -1518,8 +1517,7 @@ export function buildInviteOnboardingTextDocument( "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100", "headers": { "x-openclaw-token": "replace-me" }, "waitTimeoutMs": 120000, - "sessionKeyStrategy": "fixed", - "sessionKey": "paperclip", + "sessionKeyStrategy": "issue", "role": "operator", "scopes": ["operator.admin"] } From 048e2b1bfed872540bef79542047f0b22546f233 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 18:50:25 -0600 Subject: [PATCH 12/14] Remove legacy OpenClaw adapter and keep gateway-only flow --- Dockerfile | 2 +- cli/esbuild.config.mjs | 1 - cli/package.json | 1 - cli/src/adapters/registry.ts | 7 - packages/adapters/cursor-local/src/index.ts | 2 +- .../doc/ONBOARDING_AND_TEST_PLAN.md | 424 ++----- .../adapters/openclaw-gateway/src/index.ts | 2 +- packages/adapters/openclaw/CHANGELOG.md | 57 - packages/adapters/openclaw/README.md | 139 --- packages/adapters/openclaw/package.json | 50 - .../adapters/openclaw/src/cli/format-event.ts | 18 - packages/adapters/openclaw/src/cli/index.ts | 1 - packages/adapters/openclaw/src/index.ts | 42 - .../openclaw/src/server/execute-common.ts | 534 --------- .../openclaw/src/server/execute-sse.ts | 469 -------- .../openclaw/src/server/execute-webhook.ts | 463 ------- .../adapters/openclaw/src/server/execute.ts | 53 - .../adapters/openclaw/src/server/hire-hook.ts | 77 -- .../adapters/openclaw/src/server/index.ts | 4 - .../adapters/openclaw/src/server/parse.ts | 15 - packages/adapters/openclaw/src/server/test.ts | 247 ---- .../adapters/openclaw/src/shared/stream.ts | 16 - .../adapters/openclaw/src/ui/build-config.ts | 11 - packages/adapters/openclaw/src/ui/index.ts | 2 - .../adapters/openclaw/src/ui/parse-stdout.ts | 167 --- packages/adapters/openclaw/tsconfig.json | 8 - packages/adapters/opencode-local/src/index.ts | 2 +- packages/adapters/pi-local/src/index.ts | 2 +- packages/shared/src/constants.ts | 1 - pnpm-lock.yaml | 25 - scripts/generate-npm-package-json.mjs | 2 +- scripts/release.sh | 8 +- scripts/smoke/openclaw-join.sh | 28 +- server/package.json | 1 - server/src/__tests__/hire-hook.test.ts | 14 +- .../invite-accept-gateway-defaults.test.ts | 119 ++ .../invite-accept-openclaw-defaults.test.ts | 294 ----- .../__tests__/invite-accept-replay.test.ts | 60 +- server/src/__tests__/openclaw-adapter.test.ts | 1063 ----------------- server/src/adapters/registry.ts | 20 - server/src/routes/access.ts | 843 +++---------- server/src/services/company-portability.ts | 4 - skills/release/SKILL.md | 2 +- ui/package.json | 1 - ui/src/adapters/openclaw/config-fields.tsx | 177 --- ui/src/adapters/openclaw/index.ts | 12 - ui/src/adapters/registry.ts | 2 - ui/src/components/AgentProperties.tsx | 1 - ui/src/components/LiveRunWidget.tsx | 2 +- ui/src/components/OnboardingWizard.tsx | 3 +- ui/src/components/agent-config-primitives.tsx | 3 +- ui/src/pages/Agents.tsx | 1 - ui/src/pages/CompanySettings.tsx | 2 +- ui/src/pages/InviteLanding.tsx | 6 +- ui/src/pages/OrgChart.tsx | 1 - 55 files changed, 454 insertions(+), 5057 deletions(-) delete mode 100644 packages/adapters/openclaw/CHANGELOG.md delete mode 100644 packages/adapters/openclaw/README.md delete mode 100644 packages/adapters/openclaw/package.json delete mode 100644 packages/adapters/openclaw/src/cli/format-event.ts delete mode 100644 packages/adapters/openclaw/src/cli/index.ts delete mode 100644 packages/adapters/openclaw/src/index.ts delete mode 100644 packages/adapters/openclaw/src/server/execute-common.ts delete mode 100644 packages/adapters/openclaw/src/server/execute-sse.ts delete mode 100644 packages/adapters/openclaw/src/server/execute-webhook.ts delete mode 100644 packages/adapters/openclaw/src/server/execute.ts delete mode 100644 packages/adapters/openclaw/src/server/hire-hook.ts delete mode 100644 packages/adapters/openclaw/src/server/index.ts delete mode 100644 packages/adapters/openclaw/src/server/parse.ts delete mode 100644 packages/adapters/openclaw/src/server/test.ts delete mode 100644 packages/adapters/openclaw/src/shared/stream.ts delete mode 100644 packages/adapters/openclaw/src/ui/build-config.ts delete mode 100644 packages/adapters/openclaw/src/ui/index.ts delete mode 100644 packages/adapters/openclaw/src/ui/parse-stdout.ts delete mode 100644 packages/adapters/openclaw/tsconfig.json create mode 100644 server/src/__tests__/invite-accept-gateway-defaults.test.ts delete mode 100644 server/src/__tests__/invite-accept-openclaw-defaults.test.ts delete mode 100644 server/src/__tests__/openclaw-adapter.test.ts delete mode 100644 ui/src/adapters/openclaw/config-fields.tsx delete mode 100644 ui/src/adapters/openclaw/index.ts diff --git a/Dockerfile b/Dockerfile index 0fcc3216..e99f9323 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ -COPY packages/adapters/openclaw/package.json packages/adapters/openclaw/ +COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ RUN pnpm install --frozen-lockfile diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index 495fad99..7976b7c9 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -21,7 +21,6 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", - "packages/adapters/openclaw", "packages/adapters/openclaw-gateway", ]; diff --git a/cli/package.json b/cli/package.json index 1bddae42..9670d997 100644 --- a/cli/package.json +++ b/cli/package.json @@ -39,7 +39,6 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", - "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 41d95f77..21b915f5 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -4,7 +4,6 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; -import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -34,11 +33,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCursorStreamEvent, }; -const openclawCLIAdapter: CLIAdapterModule = { - type: "openclaw", - formatStdoutEvent: printOpenClawStreamEvent, -}; - const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -51,7 +45,6 @@ const adaptersByType = new Map( openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, - openclawCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 662bc8a7..5845fba8 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -56,7 +56,7 @@ Use when: - You want structured stream output in run logs via --output-format stream-json Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Cursor Agent CLI is not installed on the machine 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 61c9b331..66ff2a4a 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -1,371 +1,109 @@ # OpenClaw Gateway Onboarding and Test Plan -## Objective -Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex. - -This plan covers: -- Current onboarding flow behavior and gaps. -- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol). -- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company. - -## Hard Requirements (Testing Contract) -These are mandatory for onboarding and smoke testing: - -1. **Stock/clean OpenClaw boot every run** -- Use a fresh, unmodified OpenClaw Docker image path each test cycle. -- Do not rely on persistent/manual in-UI tweaks from prior runs. -- Recreate runtime state each run so results represent first-time user experience. - -2. **One-command/prompt setup inside OpenClaw** -- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able). -- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”). -- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps. - -3. **Two-lane validation is required** -- Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate. -- Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path. - -## Execution Findings (2026-03-07) -Observed from running `scripts/smoke/openclaw-gateway-e2e.sh` against `CLA` in authenticated/private dev mode: - -1. **Baseline failure (before wake-text fix)** -- Stock lane had run-level success but failed functional assertions: - - connectivity run `64a72d8b-f5b3-4f62-9147-1c60932f50ad` succeeded - - case A run `fd29e361-a6bd-4bc6-9270-36ef96e3bd8e` succeeded - - issue `CLA-6` (`dad7b967-29d2-4317-8c9d-425b4421e098`) stayed `todo` with `0` comments -- Root symptom: OpenClaw reported missing concrete heartbeat procedure and guessed non-existent `/api/*heartbeat` endpoints. - -2. **Post-fix validation (stock-clean lane passes)** -- After updating adapter wake text to include explicit Paperclip API workflow steps and explicit endpoint bans: - - connectivity run `c297e2d0-020b-4b30-95d3-a4c04e1373bb`: `succeeded` - - case A run `baac403e-8d86-48e5-b7d5-239c4755ce7e`: `succeeded`, issue `CLA-7` done with marker - - case B run `521fc8ad-2f5a-4bd8-9ddd-c491401c9158`: `succeeded`, issue `CLA-8` done with marker - - case C run `a03d86b6-91a8-48b4-8813-758f6bf11aec`: `succeeded`, issue `CLA-9` done, created issue `CLA-10` -- Stock release-gate lane now passes scripted checks. - -3. **Instrumentation lane note** -- Prompt-augmented diagnostics lane previously timed out (`7537e5d2-a76a-44c5-bf9f-57f1b21f5fc3`) with missing tool runtime utilities (`jq`, `python`) inside the stock container. -- Keep this lane for diagnostics only; stock lane remains the acceptance gate. - -## External Protocol Constraints -OpenClaw docs to anchor behavior: -- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook -- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol -- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses - -Implication: -- `webhook` transport should target `/hooks/*` and requires hook server enablement. -- `sse` transport should target `/v1/responses`. -- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`. - -## Current Implementation Map (What Exists) - -### Invite + onboarding pipeline -- Invite create: `POST /api/companies/:companyId/invites` -- Invite onboarding manifest: `GET /api/invites/:token/onboarding` -- Agent-readable text: `GET /api/invites/:token/onboarding.txt` -- Accept join: `POST /api/invites/:token/accept` -- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve` -- Claim key: `POST /api/join-requests/:requestId/claim-api-key` - -### Adapter state -- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode. -- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`). - -### Existing smoke foundation -- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`. -- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`). - -## Deep Code Findings (Gaps) - -### 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. - -### 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. - -### 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 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`: -- Adapter may remap `/v1/responses -> /hooks/agent`. -- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`. - -If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected. - -### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode -- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`). -- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`. -- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes. - -### 7) Gateway adapter lacks hire-approved callback parity -`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not. -Not a blocker for core routing, but creates inconsistent onboarding feedback behavior. - -## UX Intention (Target Experience) - -### Product goal -Users should pick one clear onboarding path: -- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs. -- `Invite OpenClaw Gateway` for gateway-native installs. - -### UX design requirements -- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route). -- Mode-specific generated snippet and mode-specific onboarding text. -- Clear compatibility checks before user copies anything. - -### Proposed UX structure -1. Add invite buttons: -- `Invite OpenClaw (SSE/Webhook)` -- `Invite OpenClaw Gateway` - -2. For HTTP invite: -- Require transport choice (`sse` or `webhook`). -- Validate endpoint expectations: - - `sse` with `/v1/responses`. - - `webhook` with `/hooks/*` and hooks enablement guidance. - -3. For Gateway invite: -- Ask only for `ws://`/`wss://` and token source guidance. -- No callback URL/paperclipApiUrl complexity in onboarding. - -4. Always show: -- Preflight diagnostics. -- Copy-ready command/snippet. -- Expected next steps (join -> approve -> claim -> skill install). - -## Why Gateway Improves Onboarding -Compared to webhook/SSE onboarding: -- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls. -- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion. -- Better run observability: gateway event frames stream lifecycle/delta events in one protocol. - -Tradeoff: -- Requires stable WS endpoint and gateway token handling. - -## Codex-Executable E2E Workflow - ## Scope -Run this full flow per test cycle against company `CLA`: -1. Assign task to OpenClaw agent -> agent executes -> task closes. -2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat. -3. OpenClaw in a fresh/new session can still create a Paperclip task. -4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup. +This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only. -## 0) Cleanup Before Each Run -Use deterministic reset to avoid stale agents/runs/state. +- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching) +- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`) -1. OpenClaw Docker cleanup: +## Requirements +1. OpenClaw test image must be stock/clean every run. +2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed). +3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`. +4. Invite/access flow must be secure: +- invite prompt endpoint is board-permission protected +- CEO agent is allowed to invoke the invite prompt endpoint for their own company +5. E2E pass criteria must include the 3 functional task cases. + +## Current Product Flow +1. Board/CEO opens company settings. +2. Click `Generate OpenClaw Invite Prompt`. +3. Paste generated prompt into OpenClaw chat. +4. OpenClaw submits invite acceptance with: +- `adapterType: "openclaw_gateway"` +- `agentDefaultsPayload.url: ws://... | wss://...` +- `agentDefaultsPayload.headers["x-openclaw-token"]` +5. Board approves join request. +6. OpenClaw claims API key and installs/uses Paperclip skill. +7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key. + +## Technical Contract (Gateway) +`agentDefaultsPayload` minimum: +```json +{ + "url": "ws://127.0.0.1:18789", + "headers": { "x-openclaw-token": "" } +} +``` + +Recommended fields: +```json +{ + "paperclipApiUrl": "http://host.docker.internal:3100", + "waitTimeoutMs": 120000, + "sessionKeyStrategy": "issue", + "role": "operator", + "scopes": ["operator.admin"] +} +``` + +Security/pairing defaults: +- `disableDeviceAuth`: default false +- `devicePrivateKeyPem`: generated during join if missing + +## Codex Automation Workflow + +### 0) Reset and boot ```bash -# stop/remove OpenClaw compose services OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker if [ -d "$OPENCLAW_DOCKER_DIR" ]; then docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true fi -# remove old image (as requested) docker image rm openclaw:local || true -``` - -2. Recreate OpenClaw cleanly: -```bash OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh ``` -This must remain a stock/clean image boot path, with no hidden manual state carried from prior runs. -3. Remove prior CLA OpenClaw agents: -- List `CLA` agents via API. -- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding. - -4. Reject/clear stale pending join requests for CLA (optional but recommended). - -## 1) Start Paperclip in Required Mode +### 1) Start Paperclip ```bash pnpm dev --tailscale-auth -``` -Verify: -```bash curl -fsS http://127.0.0.1:3100/api/health -# expect deploymentMode=authenticated, deploymentExposure=private ``` -## 2) Acquire Board Session for Automation -Board operations (create invite, approve join, terminate agents) require board session cookie. +### 2) Invite + join + approval +- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt` +- paste prompt to OpenClaw +- approve join request +- assert created agent: + - `adapterType == openclaw_gateway` + - token header exists and length >= 16 + - `devicePrivateKeyPem` exists -Short-term practical options: -1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`. -2. Scripted fallback: sign-up/sign-in via `/api/auth/*`, then use a dedicated admin promotion/bootstrap utility for dev (recommended to add as a small internal script). +### 3) Pairing stabilization +- if first run returns `pairing required`, approve pending device in OpenClaw +- rerun task and confirm success +- assert later runs do not require re-pairing for same agent -Note: -- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow. +### 4) Functional E2E assertions +1. Task assigned to OpenClaw is completed and closed. +2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat). +3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task. -## 3) Resolve CLA Company ID -With board cookie: +## Manual Smoke Checklist +Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook. + +## Regression Gates +Required before merge: ```bash -curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies +pnpm -r typecheck +pnpm test:run +pnpm build ``` -Pick company where identifier/code is `CLA` and store `CLA_COMPANY_ID`. -## 4) Preflight OpenClaw Endpoint Capability -From host (using current OpenClaw token): -- For HTTP SSE mode: confirm `/v1/responses` behavior. -- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled. -- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`. - -Expected in current docker smoke config: -- `/hooks/agent` likely `404` unless hooks explicitly enabled. -- WS gateway protocol works. - -## 5) Gateway Join Flow (Primary Path) - -1. Create agent-only invite in CLA: +If full suite is too heavy locally, run at least: ```bash -POST /api/companies/$CLA_COMPANY_ID/invites -{ "allowedJoinTypes": "agent" } +pnpm --filter @paperclipai/server test:run -- openclaw-gateway +pnpm --filter @paperclipai/server typecheck +pnpm --filter @paperclipai/ui typecheck +pnpm --filter paperclipai typecheck ``` - -2. Submit join request with gateway defaults: -```json -{ - "requestType": "agent", - "agentName": "OpenClaw Gateway", - "adapterType": "openclaw_gateway", - "capabilities": "OpenClaw gateway agent", - "agentDefaultsPayload": { - "url": "ws://127.0.0.1:18789", - "headers": { "x-openclaw-token": "" }, - "role": "operator", - "scopes": ["operator.admin"], - "sessionKeyStrategy": "issue", - "waitTimeoutMs": 120000 - } -} -``` - -3. Approve join request. -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. Adapter behavior on first pairing gate: - - default: auto-attempt `device.pair.list` + `device.pair.approve` over shared auth, then retry once - - if auto-pair fails, run returns `pairing required`; approve manually in OpenClaw and retry once - - Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates. - - Local docker automation path: - - `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token ` - - Optional inspection: - - `openclaw devices list --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. -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 - -### Case A: Assigned task execution/closure -1. Create issue in CLA assigned to joined OpenClaw agent. -2. Poll issue + heartbeat runs until terminal. -3. Pass criteria: -- At least one run invoked for that agent/issue. -- Run status `succeeded`. -- Issue reaches `done` (or documented expected terminal state if policy differs). - -### Case B: Message tool to main chat -1. Create issue instructing OpenClaw: “send a message to the user’s main chat session in webchat using message tool”. -2. Trigger/poll run completion. -3. Validate output: -- Automated minimum: run log/transcript confirms tool invocation success. -- UX-level validation: message visibly appears in main chat UI. - -Current recommendation: -- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification. - -### Case C: Fresh session still creates Paperclip task -1. Force fresh-session behavior for test: -- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key). -2. Create issue asking agent to create a new Paperclip task. -3. Pass criteria: -- New issue appears in CLA with expected title/body. -- Agent succeeds without re-onboarding. - -## 7) Observability and Assertions -Use these APIs for deterministic assertions: -- `GET /api/companies/:companyId/heartbeat-runs?agentId=...` -- `GET /api/heartbeat-runs/:runId/events` -- `GET /api/heartbeat-runs/:runId/log` -- `GET /api/issues/:id` -- `GET /api/companies/:companyId/issues?q=...` - -Include explicit timeout budgets per poll loop and hard failure reasons in output. - -## 8) Automation Artifact -Implemented smoke harness: -- `scripts/smoke/openclaw-gateway-e2e.sh` - -Responsibilities: -- OpenClaw docker cleanup/rebuild/start. -- Paperclip health/auth preflight. -- 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. - -## 9) Required Product/Code Changes to Support This Plan Cleanly - -### Access/onboarding backend -- Make onboarding manifest/text adapter-aware (`openclaw` vs `openclaw_gateway`). -- Add gateway-specific required fields and examples. -- Add gateway-specific diagnostics (WS URL/token/role/scopes/device-auth hints). - -### Company settings UX -- Replace single generic snippet with mode-specific invite actions. -- Add “Invite OpenClaw Gateway” path with concise copy/paste onboarding. - -### Invite landing UX -- Enable OpenClaw adapter options when invite allows agent join. -- Allow `agentDefaultsPayload` entry for advanced joins where needed. - -### Adapter parity -- Consider `onHireApproved` support for `openclaw_gateway` for consistency. - -### Test coverage -- Add integration tests for adapter-aware onboarding manifest generation. -- Add route tests for gateway join/approve/claim path. -- Add smoke test target for gateway E2E flow. - -## 10) Execution Order -1. Implement onboarding manifest/text split by adapter mode. -2. Add company settings invite UX split (HTTP vs Gateway). -3. Add gateway E2E smoke script. -4. Run full CLA workflow in authenticated/private mode. -5. Iterate on message-tool verification automation. - -## 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/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index e15ca45c..2af13f99 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -12,7 +12,7 @@ Use when: - You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*. Don't use when: -- You only expose OpenClaw HTTP endpoints (use openclaw adapter with sse/webhook transport). +- You only expose OpenClaw HTTP endpoints. - Your deployment does not permit outbound WebSocket access from the Paperclip server. Core fields: diff --git a/packages/adapters/openclaw/CHANGELOG.md b/packages/adapters/openclaw/CHANGELOG.md deleted file mode 100644 index 79174ae2..00000000 --- a/packages/adapters/openclaw/CHANGELOG.md +++ /dev/null @@ -1,57 +0,0 @@ -# @paperclipai/adapter-openclaw - -## 0.2.7 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.7 - -## 0.2.6 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.6 - -## 0.2.5 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.5 - -## 0.2.4 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.4 - -## 0.2.3 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.3 - -## 0.2.2 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.2 - -## 0.2.1 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.1 diff --git a/packages/adapters/openclaw/README.md b/packages/adapters/openclaw/README.md deleted file mode 100644 index 01dbc661..00000000 --- a/packages/adapters/openclaw/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# OpenClaw Adapter Modes - -This document describes how `@paperclipai/adapter-openclaw` selects request shape and endpoint behavior. - -## Transport Modes - -The adapter has two transport modes: - -- `sse` (default) -- `webhook` - -Configured via `adapterConfig.streamTransport` (or legacy `adapterConfig.transport`). - -## Mode Matrix - -| streamTransport | configured URL path | behavior | -| --- | --- | --- | -| `sse` | `/v1/responses` | Sends OpenResponses request with `stream: true`, expects `text/event-stream` response until terminal event. | -| `sse` | `/hooks/*` | Rejected (`openclaw_sse_incompatible_endpoint`). Hooks are not stream-capable. | -| `sse` | other endpoint | Sends generic streaming payload (`stream: true`, `text`, `paperclip`) and expects SSE response. | -| `webhook` | `/hooks/wake` | Sends wake payload `{ text, mode }`. | -| `webhook` | `/hooks/agent` | Sends agent payload `{ message, ...hook fields }`. | -| `webhook` | `/v1/responses` | Compatibility flow: tries `/hooks/agent` first, then falls back to original `/v1/responses` if hook endpoint returns `404`. | -| `webhook` | other endpoint | Sends legacy generic webhook payload (`stream: false`, `text`, `paperclip`). | - -## Webhook Payload Shapes - -### 1) Hook Wake (`/hooks/wake`) - -Payload: - -```json -{ - "text": "Paperclip wake event ...", - "mode": "now" -} -``` - -### 2) Hook Agent (`/hooks/agent`) - -Payload: - -```json -{ - "message": "Paperclip wake event ...", - "name": "Optional hook name", - "agentId": "Optional OpenClaw agent id", - "wakeMode": "now", - "deliver": true, - "channel": "last", - "to": "Optional channel recipient", - "model": "Optional model override", - "thinking": "Optional thinking override", - "timeoutSeconds": 120 -} -``` - -Notes: - -- `message` is always used (not `text`) for `/hooks/agent`. -- `sessionKey` is **not** sent by default for `/hooks/agent`. -- To include derived session keys in `/hooks/agent`, set: - - `hookIncludeSessionKey: true` - -### 3) OpenResponses (`/v1/responses`) - -When used directly (SSE mode or webhook fallback), payload uses OpenResponses shape: - -```json -{ - "stream": false, - "model": "openclaw", - "input": "...", - "metadata": { - "paperclip_session_key": "paperclip:issue:ISSUE_ID" - } -} -``` - -## Auth Header Behavior - -You can provide auth either explicitly or via token headers: - -- Explicit auth header: - - `webhookAuthHeader: "Bearer ..."` -- Token headers (adapter derives `Authorization` automatically when missing): - - `headers["x-openclaw-token"]` (preferred) - - `headers["x-openclaw-auth"]` (legacy compatibility) - -## Session Key Behavior - -Session keys are resolved from: - -- `sessionKeyStrategy`: `issue` (default), `fixed`, `run` -- `sessionKey`: used when strategy is `fixed` (default value `paperclip`) - -Where session keys are applied: - -- `/v1/responses`: sent via `x-openclaw-session-key` header + metadata. -- `/hooks/wake`: not sent as a dedicated field. -- `/hooks/agent`: only sent if `hookIncludeSessionKey=true`. -- Generic webhook fallback: sent as `sessionKey` field. - -## Recommended Config Examples - -### SSE (streaming endpoint) - -```json -{ - "url": "http://127.0.0.1:18789/v1/responses", - "streamTransport": "sse", - "method": "POST", - "headers": { - "x-openclaw-token": "replace-me" - } -} -``` - -### Webhook (hooks endpoint) - -```json -{ - "url": "http://127.0.0.1:18789/hooks/agent", - "streamTransport": "webhook", - "method": "POST", - "headers": { - "x-openclaw-token": "replace-me" - } -} -``` - -### Webhook with legacy URL retained - -If URL is still `/v1/responses` and `streamTransport=webhook`, the adapter will: - -1. try `.../hooks/agent` -2. fallback to original `.../v1/responses` when hook endpoint returns `404` - -This lets older OpenClaw setups continue working while migrating to hooks. diff --git a/packages/adapters/openclaw/package.json b/packages/adapters/openclaw/package.json deleted file mode 100644 index c8bd561d..00000000 --- a/packages/adapters/openclaw/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@paperclipai/adapter-openclaw", - "version": "0.2.7", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./server": "./src/server/index.ts", - "./ui": "./src/ui/index.ts", - "./cli": "./src/cli/index.ts" - }, - "publishConfig": { - "access": "public", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./server": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.js" - }, - "./ui": { - "types": "./dist/ui/index.d.ts", - "import": "./dist/ui/index.js" - }, - "./cli": { - "types": "./dist/cli/index.d.ts", - "import": "./dist/cli/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@paperclipai/adapter-utils": "workspace:*", - "picocolors": "^1.1.1" - }, - "devDependencies": { - "@types/node": "^24.6.0", - "typescript": "^5.7.3" - } -} diff --git a/packages/adapters/openclaw/src/cli/format-event.ts b/packages/adapters/openclaw/src/cli/format-event.ts deleted file mode 100644 index c0c0c910..00000000 --- a/packages/adapters/openclaw/src/cli/format-event.ts +++ /dev/null @@ -1,18 +0,0 @@ -import pc from "picocolors"; - -export function printOpenClawStreamEvent(raw: string, debug: boolean): void { - const line = raw.trim(); - if (!line) return; - - if (!debug) { - console.log(line); - return; - } - - if (line.startsWith("[openclaw]")) { - console.log(pc.cyan(line)); - return; - } - - console.log(pc.gray(line)); -} diff --git a/packages/adapters/openclaw/src/cli/index.ts b/packages/adapters/openclaw/src/cli/index.ts deleted file mode 100644 index 107ebf8b..00000000 --- a/packages/adapters/openclaw/src/cli/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { printOpenClawStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts deleted file mode 100644 index 940dbbc6..00000000 --- a/packages/adapters/openclaw/src/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -export const type = "openclaw"; -export const label = "OpenClaw"; - -export const models: { id: string; label: string }[] = []; - -export const agentConfigurationDoc = `# openclaw agent configuration - -Adapter: openclaw - -Use when: -- You run an OpenClaw agent remotely and wake it over HTTP. -- You want selectable transport: - - \`sse\` for streaming execution in one Paperclip run. - - \`webhook\` for wake-style callbacks (\`/hooks/wake\`, \`/hooks/agent\`, or compatibility webhooks). - -Don't use when: -- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process). -- The OpenClaw endpoint is not reachable from the Paperclip server. - -Core fields: -- url (string, required): OpenClaw endpoint URL -- streamTransport (string, optional): \`sse\` (default) or \`webhook\` -- method (string, optional): HTTP method, default POST -- headers (object, optional): extra HTTP headers for requests -- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth -- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload -- paperclipApiUrl (string, optional): absolute http(s) Paperclip base URL to advertise to OpenClaw as \`PAPERCLIP_API_URL\` -- hookIncludeSessionKey (boolean, optional): when true, include derived \`sessionKey\` in \`/hooks/agent\` webhook payloads (default false) - -Session routing fields: -- sessionKeyStrategy (string, optional): \`issue\` (default), \`fixed\`, or \`run\` -- sessionKey (string, optional): fixed session key value when strategy is \`fixed\` (default \`paperclip\`) - -Operational fields: -- timeoutSec (number, optional): SSE request timeout in seconds (default 0 = no adapter timeout) - -Hire-approved callback fields (optional): -- hireApprovedCallbackUrl (string): callback endpoint invoked when this agent is approved/hired -- hireApprovedCallbackMethod (string): HTTP method for the callback (default POST) -- hireApprovedCallbackAuthHeader (string): Authorization header value for callback requests -- hireApprovedCallbackHeaders (object): extra headers merged into callback requests -`; diff --git a/packages/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts deleted file mode 100644 index 427a6c86..00000000 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ /dev/null @@ -1,534 +0,0 @@ -import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; -import { createHash } from "node:crypto"; -import { parseOpenClawResponse } from "./parse.js"; - -export type OpenClawTransport = "sse" | "webhook"; -export type SessionKeyStrategy = "fixed" | "issue" | "run"; -export type OpenClawEndpointKind = "open_responses" | "hook_wake" | "hook_agent" | "generic"; - -export type WakePayload = { - runId: string; - agentId: string; - companyId: string; - taskId: string | null; - issueId: string | null; - wakeReason: string | null; - wakeCommentId: string | null; - approvalId: string | null; - approvalStatus: string | null; - issueIds: string[]; -}; - -export type OpenClawExecutionState = { - method: string; - timeoutSec: number; - headers: Record; - payloadTemplate: Record; - wakePayload: WakePayload; - sessionKey: string; - paperclipEnv: Record; - wakeText: string; -}; - -const SENSITIVE_LOG_KEY_PATTERN = - /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; - -export function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -export function toAuthorizationHeaderValue(rawToken: string): string { - const trimmed = rawToken.trim(); - if (!trimmed) return trimmed; - return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; -} - -export function resolvePaperclipApiUrlOverride(value: unknown): string | null { - const raw = nonEmpty(value); - if (!raw) return null; - try { - const parsed = new URL(raw); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - return parsed.toString(); - } catch { - return null; - } -} - -export function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { - const normalized = asString(value, "issue").trim().toLowerCase(); - if (normalized === "fixed" || normalized === "run") return normalized; - return "issue"; -} - -export function resolveSessionKey(input: { - strategy: SessionKeyStrategy; - configuredSessionKey: string | null; - runId: string; - issueId: string | null; -}): string { - const fallback = input.configuredSessionKey ?? "paperclip"; - if (input.strategy === "run") return `paperclip:run:${input.runId}`; - if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`; - return fallback; -} - -function normalizeUrlPath(pathname: string): string { - const trimmed = pathname.trim().toLowerCase(); - if (!trimmed) return "/"; - return trimmed.endsWith("/") && trimmed !== "/" ? trimmed.slice(0, -1) : trimmed; -} - -function isWakePath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return normalized === "/hooks/wake" || normalized.endsWith("/hooks/wake"); -} - -function isHookAgentPath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return normalized === "/hooks/agent" || normalized.endsWith("/hooks/agent"); -} - -function isHookPath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return ( - normalized === "/hooks" || - normalized.startsWith("/hooks/") || - normalized.endsWith("/hooks") || - normalized.includes("/hooks/") - ); -} - -export function isHookEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isHookPath(parsed.pathname); - } catch { - return false; - } -} - -export function isWakeCompatibilityEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isWakePath(parsed.pathname); - } catch { - return false; - } -} - -export function isHookAgentEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isHookAgentPath(parsed.pathname); - } catch { - return false; - } -} - -export function isOpenResponsesEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - const path = normalizeUrlPath(parsed.pathname); - return path === "/v1/responses" || path.endsWith("/v1/responses"); - } catch { - return false; - } -} - -export function resolveEndpointKind(url: string): OpenClawEndpointKind { - if (isOpenResponsesEndpoint(url)) return "open_responses"; - if (isWakeCompatibilityEndpoint(url)) return "hook_wake"; - if (isHookAgentEndpoint(url)) return "hook_agent"; - return "generic"; -} - -export function deriveHookAgentUrlFromResponses(url: string): string | null { - try { - const parsed = new URL(url); - const path = normalizeUrlPath(parsed.pathname); - if (path === "/v1/responses") { - parsed.pathname = "/hooks/agent"; - return parsed.toString(); - } - if (path.endsWith("/v1/responses")) { - parsed.pathname = `${path.slice(0, -"/v1/responses".length)}/hooks/agent`; - return parsed.toString(); - } - return null; - } catch { - return null; - } -} - -export function toStringRecord(value: unknown): Record { - const parsed = parseObject(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(parsed)) { - if (typeof entry === "string") { - out[key] = entry; - } - } - return out; -} - -function isSensitiveLogKey(key: string): boolean { - return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); -} - -function sha256Prefix(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, 12); -} - -function redactSecretForLog(value: string): string { - return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`; -} - -function truncateForLog(value: string, maxChars = 320): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`; -} - -export function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown { - const currentKey = keyPath[keyPath.length - 1] ?? ""; - if (typeof value === "string") { - if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value); - return truncateForLog(value); - } - if (typeof value === "number" || typeof value === "boolean" || value == null) { - return value; - } - if (Array.isArray(value)) { - if (depth >= 6) return "[array-truncated]"; - const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1)); - if (value.length > 20) out.push(`[+${value.length - 20} more items]`); - return out; - } - if (typeof value === "object") { - if (depth >= 6) return "[object-truncated]"; - const entries = Object.entries(value as Record); - const out: Record = {}; - for (const [key, entry] of entries.slice(0, 80)) { - out[key] = redactForLog(entry, [...keyPath, key], depth + 1); - } - if (entries.length > 80) { - out.__truncated__ = `+${entries.length - 80} keys`; - } - return out; - } - return String(value); -} - -export function stringifyForLog(value: unknown, maxChars: number): string { - const text = JSON.stringify(value); - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; -} - -export function buildWakePayload(ctx: AdapterExecutionContext): WakePayload { - const { runId, agent, context } = ctx; - return { - runId, - agentId: agent.id, - companyId: agent.companyId, - taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId), - issueId: nonEmpty(context.issueId), - wakeReason: nonEmpty(context.wakeReason), - wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId), - approvalId: nonEmpty(context.approvalId), - approvalStatus: nonEmpty(context.approvalStatus), - issueIds: Array.isArray(context.issueIds) - ? context.issueIds.filter( - (value): value is string => typeof value === "string" && value.trim().length > 0, - ) - : [], - }; -} - -export function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record { - const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl); - const paperclipEnv: Record = { - ...buildPaperclipEnv(ctx.agent), - PAPERCLIP_RUN_ID: ctx.runId, - }; - - if (paperclipApiUrlOverride) { - paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; - } - if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; - if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; - if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; - if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId; - if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus; - if (wakePayload.issueIds.length > 0) { - paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(","); - } - - return paperclipEnv; -} - -export function buildWakeText(payload: WakePayload, paperclipEnv: Record): string { - const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; - const orderedKeys = [ - "PAPERCLIP_RUN_ID", - "PAPERCLIP_AGENT_ID", - "PAPERCLIP_COMPANY_ID", - "PAPERCLIP_API_URL", - "PAPERCLIP_TASK_ID", - "PAPERCLIP_WAKE_REASON", - "PAPERCLIP_WAKE_COMMENT_ID", - "PAPERCLIP_APPROVAL_ID", - "PAPERCLIP_APPROVAL_STATUS", - "PAPERCLIP_LINKED_ISSUE_IDS", - ]; - - const envLines: string[] = []; - for (const key of orderedKeys) { - const value = paperclipEnv[key]; - if (!value) continue; - envLines.push(`${key}=${value}`); - } - - const issueIdHint = payload.taskId ?? payload.issueId ?? ""; - const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? ""; - - const lines = [ - "Paperclip wake event for a cloud adapter.", - "", - "Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.", - "", - "Set these values in your run context:", - ...envLines, - `PAPERCLIP_API_KEY=`, - "", - `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, - "", - `api_base=${apiBaseHint}`, - `task_id=${payload.taskId ?? ""}`, - `issue_id=${payload.issueId ?? ""}`, - `wake_reason=${payload.wakeReason ?? ""}`, - `wake_comment_id=${payload.wakeCommentId ?? ""}`, - `approval_id=${payload.approvalId ?? ""}`, - `approval_status=${payload.approvalStatus ?? ""}`, - `linked_issue_ids=${payload.issueIds.join(",")}`, - "", - "HTTP rules:", - "- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.", - "- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.", - "- Use only /api endpoints listed below.", - "- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.", - "", - "Workflow:", - "1) GET /api/agents/me", - `2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`, - "3) If issueId exists:", - " - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}", - " - GET /api/issues/{issueId}", - " - GET /api/issues/{issueId}/comments", - " - Execute the issue instructions exactly.", - " - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.", - " - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.", - "4) If issueId does not exist:", - " - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked", - " - Pick in_progress first, then todo, then blocked, then execute step 3.", - "", - "Useful endpoints for issue work:", - "- POST /api/issues/{issueId}/comments", - "- PATCH /api/issues/{issueId}", - "- POST /api/companies/{companyId}/issues (when asked to create a new issue)", - "", - "Complete the workflow in this run.", - ]; - return lines.join("\n"); -} - -export function appendWakeText(baseText: string, wakeText: string): string { - const trimmedBase = baseText.trim(); - return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; -} - -function buildOpenResponsesWakeInputMessage(wakeText: string): Record { - return { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: wakeText, - }, - ], - }; -} - -export function appendWakeTextToOpenResponsesInput(input: unknown, wakeText: string): unknown { - if (typeof input === "string") { - return appendWakeText(input, wakeText); - } - - if (Array.isArray(input)) { - return [...input, buildOpenResponsesWakeInputMessage(wakeText)]; - } - - if (typeof input === "object" && input !== null) { - const parsed = parseObject(input); - const content = parsed.content; - if (typeof content === "string") { - return { - ...parsed, - content: appendWakeText(content, wakeText), - }; - } - if (Array.isArray(content)) { - return { - ...parsed, - content: [ - ...content, - { - type: "input_text", - text: wakeText, - }, - ], - }; - } - return [parsed, buildOpenResponsesWakeInputMessage(wakeText)]; - } - - return wakeText; -} - -export function isTextRequiredResponse(responseText: string): boolean { - const parsed = parseOpenClawResponse(responseText); - const parsedError = parsed && typeof parsed.error === "string" ? parsed.error : null; - if (parsedError && parsedError.toLowerCase().includes("text required")) { - return true; - } - return responseText.toLowerCase().includes("text required"); -} - -function extractResponseErrorMessage(responseText: string): string { - const parsed = parseOpenClawResponse(responseText); - if (!parsed) return responseText; - - const directError = parsed.error; - if (typeof directError === "string") return directError; - if (directError && typeof directError === "object") { - const nestedMessage = (directError as Record).message; - if (typeof nestedMessage === "string") return nestedMessage; - } - - const directMessage = parsed.message; - if (typeof directMessage === "string") return directMessage; - - return responseText; -} - -export function isWakeCompatibilityRetryableResponse(responseText: string): boolean { - if (isTextRequiredResponse(responseText)) return true; - - const normalized = extractResponseErrorMessage(responseText).toLowerCase(); - const expectsStringInput = - normalized.includes("invalid input") && - normalized.includes("expected string") && - normalized.includes("undefined"); - if (expectsStringInput) return true; - - const missingInputField = - normalized.includes("input") && - (normalized.includes("required") || normalized.includes("missing")); - if (missingInputField) return true; - - return false; -} - -export async function sendJsonRequest(params: { - url: string; - method: string; - headers: Record; - payload: Record; - signal: AbortSignal; -}): Promise { - return fetch(params.url, { - method: params.method, - headers: params.headers, - body: JSON.stringify(params.payload), - signal: params.signal, - }); -} - -export async function readAndLogResponseText(params: { - response: Response; - onLog: AdapterExecutionContext["onLog"]; -}): Promise { - const responseText = await params.response.text(); - if (responseText.trim().length > 0) { - await params.onLog( - "stdout", - `[openclaw] response (${params.response.status}) ${responseText.slice(0, 2000)}\n`, - ); - } else { - await params.onLog("stdout", `[openclaw] response (${params.response.status}) \n`); - } - return responseText; -} - -export function buildExecutionState(ctx: AdapterExecutionContext): OpenClawExecutionState { - const method = asString(ctx.config.method, "POST").trim().toUpperCase() || "POST"; - const timeoutSecRaw = asNumber(ctx.config.timeoutSec, 0); - const timeoutSec = timeoutSecRaw > 0 ? Math.max(1, Math.floor(timeoutSecRaw)) : 0; - const headersConfig = parseObject(ctx.config.headers) as Record; - const payloadTemplate = parseObject(ctx.config.payloadTemplate); - const webhookAuthHeader = nonEmpty(ctx.config.webhookAuthHeader); - const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); - - const headers: Record = { - "content-type": "application/json", - }; - for (const [key, value] of Object.entries(headersConfig)) { - if (typeof value === "string" && value.trim().length > 0) { - headers[key] = value; - } - } - - const openClawAuthHeader = nonEmpty( - headers["x-openclaw-token"] ?? - headers["X-OpenClaw-Token"] ?? - headers["x-openclaw-auth"] ?? - headers["X-OpenClaw-Auth"], - ); - if (openClawAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = toAuthorizationHeaderValue(openClawAuthHeader); - } - if (webhookAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = webhookAuthHeader; - } - - const wakePayload = buildWakePayload(ctx); - const sessionKey = resolveSessionKey({ - strategy: sessionKeyStrategy, - configuredSessionKey: nonEmpty(ctx.config.sessionKey), - runId: ctx.runId, - issueId: wakePayload.issueId ?? wakePayload.taskId, - }); - - const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload); - const wakeText = buildWakeText(wakePayload, paperclipEnv); - - return { - method, - timeoutSec, - headers, - payloadTemplate, - wakePayload, - sessionKey, - paperclipEnv, - wakeText, - }; -} - -export function buildWakeCompatibilityPayload(wakeText: string): Record { - return { - text: wakeText, - mode: "now", - }; -} diff --git a/packages/adapters/openclaw/src/server/execute-sse.ts b/packages/adapters/openclaw/src/server/execute-sse.ts deleted file mode 100644 index 2729f466..00000000 --- a/packages/adapters/openclaw/src/server/execute-sse.ts +++ /dev/null @@ -1,469 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { - appendWakeTextToOpenResponsesInput, - buildExecutionState, - isOpenResponsesEndpoint, - isTextRequiredResponse, - readAndLogResponseText, - redactForLog, - sendJsonRequest, - stringifyForLog, - toStringRecord, - type OpenClawExecutionState, -} from "./execute-common.js"; -import { parseOpenClawResponse } from "./parse.js"; - -type ConsumedSse = { - eventCount: number; - lastEventType: string | null; - lastData: string | null; - lastPayload: Record | null; - terminal: boolean; - failed: boolean; - errorMessage: string | null; -}; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function inferSseTerminal(input: { - eventType: string; - data: string; - parsedPayload: Record | null; -}): { terminal: boolean; failed: boolean; errorMessage: string | null } { - const normalizedType = input.eventType.trim().toLowerCase(); - const trimmedData = input.data.trim(); - const payload = input.parsedPayload; - const payloadType = nonEmpty(payload?.type)?.toLowerCase() ?? null; - const payloadStatus = nonEmpty(payload?.status)?.toLowerCase() ?? null; - - if (trimmedData === "[DONE]") { - return { terminal: true, failed: false, errorMessage: null }; - } - - const failType = - normalizedType.includes("error") || - normalizedType.includes("failed") || - normalizedType.includes("cancel"); - if (failType) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - (trimmedData.length > 0 ? trimmedData : "OpenClaw SSE error"), - }; - } - - const doneType = - normalizedType === "done" || - normalizedType.endsWith(".completed") || - normalizedType === "completed"; - if (doneType) { - return { terminal: true, failed: false, errorMessage: null }; - } - - if (payloadStatus) { - if ( - payloadStatus === "completed" || - payloadStatus === "succeeded" || - payloadStatus === "done" - ) { - return { terminal: true, failed: false, errorMessage: null }; - } - if ( - payloadStatus === "failed" || - payloadStatus === "cancelled" || - payloadStatus === "error" - ) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - `OpenClaw SSE status ${payloadStatus}`, - }; - } - } - - if (payloadType) { - if (payloadType.endsWith(".completed")) { - return { terminal: true, failed: false, errorMessage: null }; - } - if ( - payloadType.endsWith(".failed") || - payloadType.endsWith(".cancelled") || - payloadType.endsWith(".error") - ) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - `OpenClaw SSE type ${payloadType}`, - }; - } - } - - if (payload?.done === true) { - return { terminal: true, failed: false, errorMessage: null }; - } - - return { terminal: false, failed: false, errorMessage: null }; -} - -async function consumeSseResponse(params: { - response: Response; - onLog: AdapterExecutionContext["onLog"]; -}): Promise { - const reader = params.response.body?.getReader(); - if (!reader) { - throw new Error("OpenClaw SSE response body is missing"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - let eventType = "message"; - let dataLines: string[] = []; - let eventCount = 0; - let lastEventType: string | null = null; - let lastData: string | null = null; - let lastPayload: Record | null = null; - let terminal = false; - let failed = false; - let errorMessage: string | null = null; - - const dispatchEvent = async (): Promise => { - if (dataLines.length === 0) { - eventType = "message"; - return false; - } - - const data = dataLines.join("\n"); - const trimmedData = data.trim(); - const parsedPayload = parseOpenClawResponse(trimmedData); - - eventCount += 1; - lastEventType = eventType; - lastData = data; - if (parsedPayload) lastPayload = parsedPayload; - - const preview = - trimmedData.length > 1000 ? `${trimmedData.slice(0, 1000)}...` : trimmedData; - await params.onLog("stdout", `[openclaw:sse] event=${eventType} data=${preview}\n`); - - const resolution = inferSseTerminal({ - eventType, - data, - parsedPayload, - }); - - dataLines = []; - eventType = "message"; - - if (resolution.terminal) { - terminal = true; - failed = resolution.failed; - errorMessage = resolution.errorMessage; - return true; - } - - return false; - }; - - let shouldStop = false; - while (!shouldStop) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - while (!shouldStop) { - const newlineIndex = buffer.indexOf("\n"); - if (newlineIndex === -1) break; - - let line = buffer.slice(0, newlineIndex); - buffer = buffer.slice(newlineIndex + 1); - if (line.endsWith("\r")) line = line.slice(0, -1); - - if (line.length === 0) { - shouldStop = await dispatchEvent(); - continue; - } - - if (line.startsWith(":")) continue; - - const colonIndex = line.indexOf(":"); - const field = colonIndex === -1 ? line : line.slice(0, colonIndex); - const rawValue = - colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, ""); - - if (field === "event") { - eventType = rawValue || "message"; - } else if (field === "data") { - dataLines.push(rawValue); - } - } - } - - buffer += decoder.decode(); - if (!shouldStop && buffer.trim().length > 0) { - for (const rawLine of buffer.split(/\r?\n/)) { - const line = rawLine.trimEnd(); - if (line.length === 0) { - shouldStop = await dispatchEvent(); - if (shouldStop) break; - continue; - } - if (line.startsWith(":")) continue; - - const colonIndex = line.indexOf(":"); - const field = colonIndex === -1 ? line : line.slice(0, colonIndex); - const rawValue = - colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, ""); - - if (field === "event") { - eventType = rawValue || "message"; - } else if (field === "data") { - dataLines.push(rawValue); - } - } - } - - if (!shouldStop && dataLines.length > 0) { - await dispatchEvent(); - } - - return { - eventCount, - lastEventType, - lastData, - lastPayload, - terminal, - failed, - errorMessage, - }; -} - -function buildSseBody(input: { - url: string; - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; - configModel: unknown; -}): { headers: Record; body: Record } { - const { url, state, context, configModel } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? `${templateText}\n\n${state.wakeText}` : state.wakeText; - - const isOpenResponses = isOpenResponsesEndpoint(url); - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; - - const body: Record = isOpenResponses - ? { - ...state.payloadTemplate, - stream: true, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - }, - } - : { - ...state.payloadTemplate, - stream: true, - sessionKey: state.sessionKey, - text: payloadText, - paperclip: { - ...state.wakePayload, - sessionKey: state.sessionKey, - streamTransport: "sse", - env: state.paperclipEnv, - context, - }, - }; - - const headers: Record = { - ...state.headers, - accept: "text/event-stream", - }; - - if (isOpenResponses && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - - return { headers, body }; -} - -export async function executeSse(ctx: AdapterExecutionContext, url: string): Promise { - const { onLog, onMeta, context } = ctx; - const state = buildExecutionState(ctx); - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "sse", - commandArgs: [state.method, url], - context, - }); - } - - const { headers, body } = buildSseBody({ - url, - state, - context, - configModel: ctx.config.model, - }); - - const outboundHeaderKeys = Object.keys(headers).sort(); - await onLog( - "stdout", - `[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(body), 12_000)}\n`, - ); - await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${url} (transport=sse)\n`); - - const controller = new AbortController(); - const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null; - - try { - const response = await sendJsonRequest({ - url, - method: state.method, - headers, - payload: body, - signal: controller.signal, - }); - - if (!response.ok) { - const responseText = await readAndLogResponseText({ response, onLog }); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw SSE request failed with status ${response.status}`, - errorCode: isTextRequiredResponse(responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: response.status, - statusText: response.statusText, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); - if (!contentType.includes("text/event-stream")) { - const responseText = await readAndLogResponseText({ response, onLog }); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw SSE endpoint did not return text/event-stream", - errorCode: "openclaw_sse_expected_event_stream", - resultJson: { - status: response.status, - statusText: response.statusText, - contentType, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - const consumed = await consumeSseResponse({ response, onLog }); - if (consumed.failed) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: consumed.errorMessage ?? "OpenClaw SSE stream failed", - errorCode: "openclaw_sse_stream_failed", - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } - - if (!consumed.terminal) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw SSE stream closed without a terminal event", - errorCode: "openclaw_sse_stream_incomplete", - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw SSE ${state.method} ${url}`, - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - const timeoutMessage = - state.timeoutSec > 0 - ? `[openclaw] SSE request timed out after ${state.timeoutSec}s\n` - : "[openclaw] SSE request aborted\n"; - await onLog("stderr", timeoutMessage); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted", - errorCode: "openclaw_sse_timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/execute-webhook.ts b/packages/adapters/openclaw/src/server/execute-webhook.ts deleted file mode 100644 index a4f55989..00000000 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ /dev/null @@ -1,463 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { - appendWakeText, - appendWakeTextToOpenResponsesInput, - buildExecutionState, - buildWakeCompatibilityPayload, - deriveHookAgentUrlFromResponses, - isTextRequiredResponse, - isWakeCompatibilityRetryableResponse, - readAndLogResponseText, - redactForLog, - resolveEndpointKind, - sendJsonRequest, - stringifyForLog, - toStringRecord, - type OpenClawEndpointKind, - type OpenClawExecutionState, -} from "./execute-common.js"; -import { parseOpenClawResponse } from "./parse.js"; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function asBooleanFlag(value: unknown, fallback = false): boolean { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true" || normalized === "1") return true; - if (normalized === "false" || normalized === "0") return false; - } - return fallback; -} - -function normalizeWakeMode(value: unknown): "now" | "next-heartbeat" | null { - if (typeof value !== "string") return null; - const normalized = value.trim().toLowerCase(); - if (normalized === "now" || normalized === "next-heartbeat") return normalized; - return null; -} - -function parseOptionalPositiveInteger(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - const normalized = Math.max(1, Math.floor(value)); - return Number.isFinite(normalized) ? normalized : null; - } - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number.parseInt(value.trim(), 10); - if (Number.isFinite(parsed)) { - const normalized = Math.max(1, Math.floor(parsed)); - return Number.isFinite(normalized) ? normalized : null; - } - } - return null; -} - -function buildOpenResponsesWebhookBody(input: { - state: OpenClawExecutionState; - configModel: unknown; -}): Record { - const { state, configModel } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; - - return { - ...state.payloadTemplate, - stream: false, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - paperclip_stream_transport: "webhook", - }, - }; -} - -function buildHookWakeBody(state: OpenClawExecutionState): Record { - const templateText = nonEmpty(state.payloadTemplate.text) ?? nonEmpty(state.payloadTemplate.message); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const wakeMode = normalizeWakeMode(state.payloadTemplate.mode ?? state.payloadTemplate.wakeMode) ?? "now"; - - return { - text: payloadText, - mode: wakeMode, - }; -} - -function buildHookAgentBody(input: { - state: OpenClawExecutionState; - includeSessionKey: boolean; -}): Record { - const { state, includeSessionKey } = input; - const templateMessage = nonEmpty(state.payloadTemplate.message) ?? nonEmpty(state.payloadTemplate.text); - const message = templateMessage ? appendWakeText(templateMessage, state.wakeText) : state.wakeText; - const payload: Record = { - message, - }; - - const name = nonEmpty(state.payloadTemplate.name); - if (name) payload.name = name; - - const agentId = nonEmpty(state.payloadTemplate.agentId); - if (agentId) payload.agentId = agentId; - - const wakeMode = normalizeWakeMode(state.payloadTemplate.wakeMode ?? state.payloadTemplate.mode); - if (wakeMode) payload.wakeMode = wakeMode; - - const deliver = state.payloadTemplate.deliver; - if (typeof deliver === "boolean") payload.deliver = deliver; - - const channel = nonEmpty(state.payloadTemplate.channel); - if (channel) payload.channel = channel; - - const to = nonEmpty(state.payloadTemplate.to); - if (to) payload.to = to; - - const model = nonEmpty(state.payloadTemplate.model); - if (model) payload.model = model; - - const thinking = nonEmpty(state.payloadTemplate.thinking); - if (thinking) payload.thinking = thinking; - - const timeoutSeconds = parseOptionalPositiveInteger(state.payloadTemplate.timeoutSeconds); - if (timeoutSeconds != null) payload.timeoutSeconds = timeoutSeconds; - - const explicitSessionKey = nonEmpty(state.payloadTemplate.sessionKey); - if (explicitSessionKey) { - payload.sessionKey = explicitSessionKey; - } else if (includeSessionKey) { - payload.sessionKey = state.sessionKey; - } - - return payload; -} - -function buildLegacyWebhookBody(input: { - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; -}): Record { - const { state, context } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - return { - ...state.payloadTemplate, - stream: false, - sessionKey: state.sessionKey, - text: payloadText, - paperclip: { - ...state.wakePayload, - sessionKey: state.sessionKey, - streamTransport: "webhook", - env: state.paperclipEnv, - context, - }, - }; -} - -function buildWebhookBody(input: { - endpointKind: OpenClawEndpointKind; - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; - configModel: unknown; - includeHookSessionKey: boolean; -}): Record { - const { endpointKind, state, context, configModel, includeHookSessionKey } = input; - if (endpointKind === "open_responses") { - return buildOpenResponsesWebhookBody({ state, configModel }); - } - if (endpointKind === "hook_wake") { - return buildHookWakeBody(state); - } - if (endpointKind === "hook_agent") { - return buildHookAgentBody({ state, includeSessionKey: includeHookSessionKey }); - } - - return buildLegacyWebhookBody({ state, context }); -} - -async function sendWebhookRequest(params: { - url: string; - method: string; - headers: Record; - payload: Record; - onLog: AdapterExecutionContext["onLog"]; - signal: AbortSignal; -}): Promise<{ response: Response; responseText: string }> { - const response = await sendJsonRequest({ - url: params.url, - method: params.method, - headers: params.headers, - payload: params.payload, - signal: params.signal, - }); - - const responseText = await readAndLogResponseText({ response, onLog: params.onLog }); - return { response, responseText }; -} - -export async function executeWebhook(ctx: AdapterExecutionContext, url: string): Promise { - const { onLog, onMeta, context } = ctx; - const state = buildExecutionState(ctx); - const originalUrl = url; - const originalEndpointKind = resolveEndpointKind(originalUrl); - let targetUrl = originalUrl; - let endpointKind = resolveEndpointKind(targetUrl); - const remappedFromResponses = originalEndpointKind === "open_responses"; - - // In webhook mode, /v1/responses is legacy wiring. Prefer hooks/agent. - if (remappedFromResponses) { - const rewritten = deriveHookAgentUrlFromResponses(targetUrl); - if (rewritten) { - await onLog( - "stdout", - `[openclaw] webhook transport selected; remapping ${targetUrl} -> ${rewritten}\n`, - ); - targetUrl = rewritten; - endpointKind = resolveEndpointKind(targetUrl); - } - } - - const headers = { ...state.headers }; - if (endpointKind === "open_responses" && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "webhook", - commandArgs: [state.method, targetUrl], - context, - }); - } - - const includeHookSessionKey = asBooleanFlag(ctx.config.hookIncludeSessionKey, false); - const webhookBody = buildWebhookBody({ - endpointKind, - state, - context, - configModel: ctx.config.model, - includeHookSessionKey, - }); - const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); - const preferWakeCompatibilityBody = endpointKind === "hook_wake"; - const initialBody = webhookBody; - - const outboundHeaderKeys = Object.keys(headers).sort(); - await onLog( - "stdout", - `[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(initialBody), 12_000)}\n`, - ); - await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${targetUrl} (transport=webhook kind=${endpointKind})\n`); - - if (preferWakeCompatibilityBody) { - await onLog("stdout", "[openclaw] using webhook wake payload for /hooks/wake\n"); - } - - const controller = new AbortController(); - const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null; - - try { - const initialResponse = await sendWebhookRequest({ - url: targetUrl, - method: state.method, - headers, - payload: initialBody, - onLog, - signal: controller.signal, - }); - - let activeResponse = initialResponse; - let activeEndpointKind = endpointKind; - let activeUrl = targetUrl; - let activeHeaders = headers; - let usedLegacyResponsesFallback = false; - - if ( - remappedFromResponses && - targetUrl !== originalUrl && - initialResponse.response.status === 404 - ) { - await onLog( - "stdout", - `[openclaw] remapped hook endpoint returned 404; retrying legacy endpoint ${originalUrl}\n`, - ); - - activeEndpointKind = originalEndpointKind; - activeUrl = originalUrl; - usedLegacyResponsesFallback = true; - const fallbackHeaders = { ...state.headers }; - if ( - activeEndpointKind === "open_responses" && - !fallbackHeaders["x-openclaw-session-key"] && - !fallbackHeaders["X-OpenClaw-Session-Key"] - ) { - fallbackHeaders["x-openclaw-session-key"] = state.sessionKey; - } - - const fallbackBody = buildWebhookBody({ - endpointKind: activeEndpointKind, - state, - context, - configModel: ctx.config.model, - includeHookSessionKey, - }); - - await onLog( - "stdout", - `[openclaw] fallback headers (redacted): ${stringifyForLog(redactForLog(fallbackHeaders), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] fallback payload (redacted): ${stringifyForLog(redactForLog(fallbackBody), 12_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] invoking fallback ${state.method} ${activeUrl} (transport=webhook kind=${activeEndpointKind})\n`, - ); - - activeResponse = await sendWebhookRequest({ - url: activeUrl, - method: state.method, - headers: fallbackHeaders, - payload: fallbackBody, - onLog, - signal: controller.signal, - }); - activeHeaders = fallbackHeaders; - } - - if (!activeResponse.response.ok) { - const canRetryWithWakeCompatibility = - (activeEndpointKind === "open_responses" || activeEndpointKind === "generic") && - isWakeCompatibilityRetryableResponse(activeResponse.responseText); - - if (canRetryWithWakeCompatibility) { - await onLog( - "stdout", - "[openclaw] endpoint requires text payload; retrying with wake compatibility format\n", - ); - - const retryResponse = await sendWebhookRequest({ - url: activeUrl, - method: state.method, - headers: activeHeaders, - payload: wakeCompatibilityBody, - onLog, - signal: controller.signal, - }); - - if (retryResponse.response.ok) { - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${state.method} ${activeUrl} (wake compatibility)`, - resultJson: { - status: retryResponse.response.status, - statusText: retryResponse.response.statusText, - compatibilityMode: "wake_text", - usedLegacyResponsesFallback, - response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, - }, - }; - } - - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(retryResponse.responseText) - ? "OpenClaw endpoint rejected the wake compatibility payload as text-required." - : `OpenClaw webhook failed with status ${retryResponse.response.status}`, - errorCode: isTextRequiredResponse(retryResponse.responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: retryResponse.response.status, - statusText: retryResponse.response.statusText, - compatibilityMode: "wake_text", - response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, - }, - }; - } - - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(activeResponse.responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw webhook failed with status ${activeResponse.response.status}`, - errorCode: isTextRequiredResponse(activeResponse.responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: activeResponse.response.status, - statusText: activeResponse.response.statusText, - response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${state.method} ${activeUrl}`, - resultJson: { - status: activeResponse.response.status, - statusText: activeResponse.response.statusText, - usedLegacyResponsesFallback, - response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - const timeoutMessage = - state.timeoutSec > 0 - ? `[openclaw] webhook request timed out after ${state.timeoutSec}s\n` - : "[openclaw] webhook request aborted\n"; - await onLog("stderr", timeoutMessage); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted", - errorCode: "openclaw_webhook_timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts deleted file mode 100644 index c560a067..00000000 --- a/packages/adapters/openclaw/src/server/execute.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { asString } from "@paperclipai/adapter-utils/server-utils"; -import { isHookEndpoint } from "./execute-common.js"; -import { executeSse } from "./execute-sse.js"; -import { executeWebhook } from "./execute-webhook.js"; - -function normalizeTransport(value: unknown): "sse" | "webhook" | null { - const normalized = asString(value, "sse").trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - -export async function execute(ctx: AdapterExecutionContext): Promise { - const url = asString(ctx.config.url, "").trim(); - if (!url) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw adapter missing url", - errorCode: "openclaw_url_missing", - }; - } - - const transportInput = ctx.config.streamTransport ?? ctx.config.transport; - const transport = normalizeTransport(transportInput); - if (!transport) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `OpenClaw adapter does not support transport: ${String(transportInput)}`, - errorCode: "openclaw_stream_transport_unsupported", - }; - } - - if (transport === "sse" && isHookEndpoint(url)) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw /hooks/* endpoints are not stream-capable. Use webhook transport for hooks.", - errorCode: "openclaw_sse_incompatible_endpoint", - }; - } - - if (transport === "webhook") { - return executeWebhook(ctx, url); - } - - return executeSse(ctx, url); -} diff --git a/packages/adapters/openclaw/src/server/hire-hook.ts b/packages/adapters/openclaw/src/server/hire-hook.ts deleted file mode 100644 index 2b6262c9..00000000 --- a/packages/adapters/openclaw/src/server/hire-hook.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { HireApprovedPayload, HireApprovedHookResult } from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -const HIRE_CALLBACK_TIMEOUT_MS = 10_000; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -/** - * OpenClaw adapter lifecycle hook: when an agent is approved/hired, POST the payload to a - * configured callback URL so the cloud operator can notify the user (e.g. "you're hired"). - * Best-effort; failures are non-fatal to the approval flow. - */ -export async function onHireApproved( - payload: HireApprovedPayload, - adapterConfig: Record, -): Promise { - const config = parseObject(adapterConfig); - const url = nonEmpty(config.hireApprovedCallbackUrl); - if (!url) { - return { ok: true }; - } - - const method = (asString(config.hireApprovedCallbackMethod, "POST").trim().toUpperCase()) || "POST"; - const authHeader = nonEmpty(config.hireApprovedCallbackAuthHeader) ?? nonEmpty(config.webhookAuthHeader); - - const headers: Record = { - "content-type": "application/json", - }; - if (authHeader && !headers.authorization && !headers.Authorization) { - headers.Authorization = authHeader; - } - const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record; - for (const [key, value] of Object.entries(extraHeaders)) { - if (typeof value === "string" && value.trim().length > 0) { - headers[key] = value; - } - } - - const body = JSON.stringify({ - ...payload, - event: "hire_approved", - }); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HIRE_CALLBACK_TIMEOUT_MS); - - try { - const response = await fetch(url, { - method, - headers, - body, - signal: controller.signal, - }); - clearTimeout(timeout); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - ok: false, - error: `HTTP ${response.status} ${response.statusText}`, - detail: { status: response.status, statusText: response.statusText, body: text.slice(0, 500) }, - }; - } - return { ok: true }; - } catch (err) { - clearTimeout(timeout); - const message = err instanceof Error ? err.message : String(err); - const cause = err instanceof Error ? err.cause : undefined; - return { - ok: false, - error: message, - detail: cause != null ? { cause: String(cause) } : undefined, - }; - } -} diff --git a/packages/adapters/openclaw/src/server/index.ts b/packages/adapters/openclaw/src/server/index.ts deleted file mode 100644 index 05c4b355..00000000 --- a/packages/adapters/openclaw/src/server/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { execute } from "./execute.js"; -export { testEnvironment } from "./test.js"; -export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js"; -export { onHireApproved } from "./hire-hook.js"; diff --git a/packages/adapters/openclaw/src/server/parse.ts b/packages/adapters/openclaw/src/server/parse.ts deleted file mode 100644 index 5045c202..00000000 --- a/packages/adapters/openclaw/src/server/parse.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function parseOpenClawResponse(text: string): Record | null { - try { - const parsed = JSON.parse(text); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - return null; - } - return parsed as Record; - } catch { - return null; - } -} - -export function isOpenClawUnknownSessionError(_text: string): boolean { - return false; -} diff --git a/packages/adapters/openclaw/src/server/test.ts b/packages/adapters/openclaw/src/server/test.ts deleted file mode 100644 index ea5bcd85..00000000 --- a/packages/adapters/openclaw/src/server/test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { - AdapterEnvironmentCheck, - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, -} from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { - if (checks.some((check) => check.level === "error")) return "fail"; - if (checks.some((check) => check.level === "warn")) return "warn"; - return "pass"; -} - -function isLoopbackHost(hostname: string): boolean { - const value = hostname.trim().toLowerCase(); - return value === "localhost" || value === "127.0.0.1" || value === "::1"; -} - -function normalizeHostname(value: string | null | undefined): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (!trimmed) return null; - if (trimmed.startsWith("[")) { - const end = trimmed.indexOf("]"); - return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); - } - const firstColon = trimmed.indexOf(":"); - if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); - return trimmed.toLowerCase(); -} - -function isWakePath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return value === "/hooks/wake" || value.endsWith("/hooks/wake"); -} - -function isHooksPath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return ( - value === "/hooks" || - value.startsWith("/hooks/") || - value.endsWith("/hooks") || - value.includes("/hooks/") - ); -} - -function normalizeTransport(value: unknown): "sse" | "webhook" | null { - const normalized = asString(value, "sse").trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - -function pushDeploymentDiagnostics( - checks: AdapterEnvironmentCheck[], - ctx: AdapterEnvironmentTestContext, - endpointUrl: URL | null, -) { - const mode = ctx.deployment?.mode; - const exposure = ctx.deployment?.exposure; - const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null); - const allowSet = new Set( - (ctx.deployment?.allowedHostnames ?? []) - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), - ); - const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null; - - if (!mode) return; - - checks.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`, - }); - - if (mode === "authenticated" && exposure === "private") { - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - checks.push({ - code: "openclaw_private_bind_hostname_not_allowed", - level: "warn", - message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`, - }); - } - - if (!bindHost || isLoopbackHost(bindHost)) { - checks.push({ - code: "openclaw_private_bind_loopback", - level: "warn", - message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.", - }); - } - - if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) { - checks.push({ - code: "openclaw_private_no_allowed_hostnames", - level: "warn", - message: "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs on another machine.", - }); - } - } - - if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") { - checks.push({ - code: "openclaw_public_http_endpoint", - level: "warn", - message: "OpenClaw endpoint uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments.", - }); - } -} - -export async function testEnvironment( - ctx: AdapterEnvironmentTestContext, -): Promise { - const checks: AdapterEnvironmentCheck[] = []; - const config = parseObject(ctx.config); - const urlValue = asString(config.url, ""); - const streamTransportValue = config.streamTransport ?? config.transport; - const streamTransport = normalizeTransport(streamTransportValue); - - if (!urlValue) { - checks.push({ - code: "openclaw_url_missing", - level: "error", - message: "OpenClaw adapter requires an endpoint URL.", - hint: "Set adapterConfig.url to your OpenClaw transport endpoint.", - }); - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; - } - - let url: URL | null = null; - try { - url = new URL(urlValue); - } catch { - checks.push({ - code: "openclaw_url_invalid", - level: "error", - message: `Invalid URL: ${urlValue}`, - }); - } - - if (url && url.protocol !== "http:" && url.protocol !== "https:") { - checks.push({ - code: "openclaw_url_protocol_invalid", - level: "error", - message: `Unsupported URL protocol: ${url.protocol}`, - hint: "Use an http:// or https:// endpoint.", - }); - } - - if (url) { - checks.push({ - code: "openclaw_url_valid", - level: "info", - message: `Configured endpoint: ${url.toString()}`, - }); - - if (isLoopbackHost(url.hostname)) { - checks.push({ - code: "openclaw_loopback_endpoint", - level: "warn", - message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).", - }); - } - - if (streamTransport === "sse" && (isWakePath(url.pathname) || isHooksPath(url.pathname))) { - checks.push({ - code: "openclaw_wake_endpoint_incompatible", - level: "error", - message: "Endpoint targets /hooks/*, which is not stream-capable for SSE transport.", - hint: "Use webhook transport for /hooks/* endpoints.", - }); - } - } - - if (!streamTransport) { - checks.push({ - code: "openclaw_stream_transport_unsupported", - level: "error", - message: `Unsupported streamTransport: ${String(streamTransportValue)}`, - hint: "Use streamTransport=sse or streamTransport=webhook.", - }); - } else { - checks.push({ - code: "openclaw_stream_transport_configured", - level: "info", - message: `Configured stream transport: ${streamTransport}`, - }); - } - - pushDeploymentDiagnostics(checks, ctx, url); - - const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; - checks.push({ - code: "openclaw_method_configured", - level: "info", - message: `Configured method: ${method}`, - }); - - if (url && (url.protocol === "http:" || url.protocol === "https:")) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - try { - const response = await fetch(url, { method: "HEAD", signal: controller.signal }); - if (!response.ok && response.status !== 405 && response.status !== 501) { - checks.push({ - code: "openclaw_endpoint_probe_unexpected_status", - level: "warn", - message: `Endpoint probe returned HTTP ${response.status}.`, - hint: "Verify OpenClaw endpoint reachability and auth/network settings.", - }); - } else { - checks.push({ - code: "openclaw_endpoint_probe_ok", - level: "info", - message: "Endpoint responded to a HEAD probe.", - }); - } - } catch (err) { - checks.push({ - code: "openclaw_endpoint_probe_failed", - level: "warn", - message: err instanceof Error ? err.message : "Endpoint probe failed", - hint: "This may be expected in restricted networks; validate from the Paperclip server host.", - }); - } finally { - clearTimeout(timeout); - } - } - - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; -} diff --git a/packages/adapters/openclaw/src/shared/stream.ts b/packages/adapters/openclaw/src/shared/stream.ts deleted file mode 100644 index a2e84357..00000000 --- a/packages/adapters/openclaw/src/shared/stream.ts +++ /dev/null @@ -1,16 +0,0 @@ -export function normalizeOpenClawStreamLine(rawLine: string): { - stream: "stdout" | "stderr" | null; - line: string; -} { - const trimmed = rawLine.trim(); - if (!trimmed) return { stream: null, line: "" }; - - const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i); - if (!prefixed) { - return { stream: null, line: trimmed }; - } - - const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout"; - const line = (prefixed[2] ?? "").trim(); - return { stream, line }; -} diff --git a/packages/adapters/openclaw/src/ui/build-config.ts b/packages/adapters/openclaw/src/ui/build-config.ts deleted file mode 100644 index ca8c98e3..00000000 --- a/packages/adapters/openclaw/src/ui/build-config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { CreateConfigValues } from "@paperclipai/adapter-utils"; - -export function buildOpenClawConfig(v: CreateConfigValues): Record { - const ac: Record = {}; - if (v.url) ac.url = v.url; - ac.method = "POST"; - ac.timeoutSec = 0; - ac.streamTransport = "sse"; - ac.sessionKeyStrategy = "issue"; - return ac; -} diff --git a/packages/adapters/openclaw/src/ui/index.ts b/packages/adapters/openclaw/src/ui/index.ts deleted file mode 100644 index f3f1905e..00000000 --- a/packages/adapters/openclaw/src/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parseOpenClawStdoutLine } from "./parse-stdout.js"; -export { buildOpenClawConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw/src/ui/parse-stdout.ts b/packages/adapters/openclaw/src/ui/parse-stdout.ts deleted file mode 100644 index 55c7f3fe..00000000 --- a/packages/adapters/openclaw/src/ui/parse-stdout.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { TranscriptEntry } from "@paperclipai/adapter-utils"; -import { normalizeOpenClawStreamLine } from "../shared/stream.js"; - -function safeJsonParse(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asNumber(value: unknown, fallback = 0): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function stringifyUnknown(value: unknown): string { - if (typeof value === "string") return value; - if (value === null || value === undefined) return ""; - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function readErrorText(value: unknown): string { - if (typeof value === "string") return value; - const obj = asRecord(value); - if (!obj) return stringifyUnknown(value); - return ( - asString(obj.message).trim() || - asString(obj.error).trim() || - asString(obj.code).trim() || - stringifyUnknown(obj) - ); -} - -function readDeltaText(payload: Record | null): string { - if (!payload) return ""; - - if (typeof payload.delta === "string") return payload.delta; - - const deltaObj = asRecord(payload.delta); - if (deltaObj) { - const nestedDelta = - asString(deltaObj.text) || - asString(deltaObj.value) || - asString(deltaObj.delta); - if (nestedDelta.length > 0) return nestedDelta; - } - - const part = asRecord(payload.part); - if (part) { - const partText = asString(part.text); - if (partText.length > 0) return partText; - } - - return ""; -} - -function extractResponseOutputText(response: Record | null): string { - if (!response) return ""; - - const output = Array.isArray(response.output) ? response.output : []; - const parts: string[] = []; - for (const itemRaw of output) { - const item = asRecord(itemRaw); - if (!item) continue; - const content = Array.isArray(item.content) ? item.content : []; - for (const partRaw of content) { - const part = asRecord(partRaw); - if (!part) continue; - const type = asString(part.type).trim().toLowerCase(); - if (type !== "output_text" && type !== "text" && type !== "refusal") continue; - const text = asString(part.text).trim(); - if (text) parts.push(text); - } - } - return parts.join("\n\n").trim(); -} - -function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] { - const match = line.match(/^\[openclaw:sse\]\s+event=([^\s]+)\s+data=(.*)$/s); - if (!match) return [{ kind: "stdout", ts, text: line }]; - - const eventType = (match[1] ?? "").trim(); - const dataText = (match[2] ?? "").trim(); - const parsed = asRecord(safeJsonParse(dataText)); - const normalizedEventType = eventType.toLowerCase(); - - if (dataText === "[DONE]") { - return []; - } - - const delta = readDeltaText(parsed); - if (normalizedEventType.endsWith(".delta") && delta.length > 0) { - return [{ kind: "assistant", ts, text: delta, delta: true }]; - } - - if ( - normalizedEventType.includes("error") || - normalizedEventType.includes("failed") || - normalizedEventType.includes("cancel") - ) { - const message = readErrorText(parsed?.error) || readErrorText(parsed?.message) || dataText; - return message ? [{ kind: "stderr", ts, text: message }] : []; - } - - if (normalizedEventType === "response.completed" || normalizedEventType.endsWith(".completed")) { - const response = asRecord(parsed?.response); - const usage = asRecord(response?.usage); - const status = asString(response?.status, asString(parsed?.status, eventType)); - const statusLower = status.trim().toLowerCase(); - const errorText = - readErrorText(response?.error).trim() || - readErrorText(parsed?.error).trim() || - readErrorText(parsed?.message).trim(); - const isError = - statusLower === "failed" || - statusLower === "error" || - statusLower === "cancelled"; - - return [{ - kind: "result", - ts, - text: extractResponseOutputText(response), - inputTokens: asNumber(usage?.input_tokens), - outputTokens: asNumber(usage?.output_tokens), - cachedTokens: asNumber(usage?.cached_input_tokens), - costUsd: asNumber(usage?.cost_usd, asNumber(usage?.total_cost_usd)), - subtype: status || eventType, - isError, - errors: errorText ? [errorText] : [], - }]; - } - - return []; -} - -export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] { - const normalized = normalizeOpenClawStreamLine(line); - if (normalized.stream === "stderr") { - return [{ kind: "stderr", ts, text: normalized.line }]; - } - - const trimmed = normalized.line.trim(); - if (!trimmed) return []; - - if (trimmed.startsWith("[openclaw:sse]")) { - return parseOpenClawSseLine(trimmed, ts); - } - - if (trimmed.startsWith("[openclaw]")) { - return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw\]\s*/, "") }]; - } - - return [{ kind: "stdout", ts, text: normalized.line }]; -} diff --git a/packages/adapters/openclaw/tsconfig.json b/packages/adapters/openclaw/tsconfig.json deleted file mode 100644 index 2f355cfe..00000000 --- a/packages/adapters/openclaw/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 1661a85b..0c16e2d8 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -13,7 +13,7 @@ Use when: - You want OpenCode session resume across heartbeats via --session Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - OpenCode CLI is not installed on the machine diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index 3794426f..a81750c3 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -14,7 +14,7 @@ Use when: - You need Pi's tool set (read, bash, edit, write, grep, find, ls) Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Pi CLI is not installed on the machine diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c7f85b57..252c3690 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -29,7 +29,6 @@ export const AGENT_ADAPTER_TYPES = [ "opencode_local", "pi_local", "cursor", - "openclaw", "openclaw_gateway", ] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..9536ff75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -139,22 +136,6 @@ importers: specifier: ^5.7.3 version: 5.9.3 - packages/adapters/openclaw: - dependencies: - '@paperclipai/adapter-utils': - specifier: workspace:* - version: link:../../adapter-utils - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - devDependencies: - '@types/node': - specifier: ^24.6.0 - version: 24.12.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - packages/adapters/openclaw-gateway: dependencies: '@paperclipai/adapter-utils': @@ -261,9 +242,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +357,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index 635a3e15..c18bce72 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -33,7 +33,7 @@ const workspacePaths = [ "packages/adapters/claude-local", "packages/adapters/codex-local", "packages/adapters/opencode-local", - "packages/adapters/openclaw", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that are NOT bundled and must stay as npm dependencies. diff --git a/scripts/release.sh b/scripts/release.sh index 769b5f47..6827e0fa 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -115,7 +115,7 @@ const { readFileSync } = require('fs'); const { resolve } = require('path'); const root = '$REPO_ROOT'; const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -221,7 +221,7 @@ const { resolve } = require('path'); const root = '$REPO_ROOT'; const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -279,7 +279,7 @@ pnpm --filter @paperclipai/db build pnpm --filter @paperclipai/adapter-claude-local build pnpm --filter @paperclipai/adapter-codex-local build pnpm --filter @paperclipai/adapter-opencode-local build -pnpm --filter @paperclipai/adapter-openclaw build +pnpm --filter @paperclipai/adapter-openclaw-gateway build pnpm --filter @paperclipai/server build # Build UI and bundle into server package for static serving @@ -314,7 +314,7 @@ if [ "$dry_run" = true ]; then echo "" echo " Preview what would be published:" for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \ + packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \ server cli; do echo " --- $dir ---" cd "$REPO_ROOT/$dir" diff --git a/scripts/smoke/openclaw-join.sh b/scripts/smoke/openclaw-join.sh index 151ae277..23896e8a 100755 --- a/scripts/smoke/openclaw-join.sh +++ b/scripts/smoke/openclaw-join.sh @@ -179,24 +179,32 @@ if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then fi api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt" assert_status "200" -if ! grep -q "Paperclip OpenClaw Onboarding" <<<"$RESPONSE_BODY"; then +if ! grep -q "Paperclip OpenClaw Gateway Onboarding" <<<"$RESPONSE_BODY"; then fail "onboarding.txt response missing expected header" fi -log "submitting OpenClaw agent join request" +OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}" +OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-${OPENCLAW_WEBHOOK_AUTH#Bearer }}" +if [[ -z "$OPENCLAW_GATEWAY_TOKEN" ]]; then + fail "OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_AUTH) is required for gateway join" +fi + +log "submitting OpenClaw gateway agent join request" JOIN_PAYLOAD="$(jq -nc \ --arg name "$OPENCLAW_AGENT_NAME" \ - --arg url "$OPENCLAW_WEBHOOK_URL" \ - --arg auth "$OPENCLAW_WEBHOOK_AUTH" \ + --arg url "$OPENCLAW_GATEWAY_URL" \ + --arg token "$OPENCLAW_GATEWAY_TOKEN" \ '{ requestType: "agent", agentName: $name, - adapterType: "openclaw", - capabilities: "Automated OpenClaw smoke harness", - agentDefaultsPayload: ( - { url: $url, method: "POST", timeoutSec: 30 } - + (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end) - ) + adapterType: "openclaw_gateway", + capabilities: "Automated OpenClaw gateway smoke harness", + agentDefaultsPayload: { + url: $url, + headers: { "x-openclaw-token": $token }, + sessionKeyStrategy: "issue", + waitTimeoutMs: 120000 + } }')" api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD" assert_status "202" diff --git a/server/package.json b/server/package.json index eaf73505..3e74286b 100644 --- a/server/package.json +++ b/server/package.json @@ -36,7 +36,6 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", - "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", diff --git a/server/src/__tests__/hire-hook.test.ts b/server/src/__tests__/hire-hook.test.ts index 3161949a..0a2cbbfd 100644 --- a/server/src/__tests__/hire-hook.test.ts +++ b/server/src/__tests__/hire-hook.test.ts @@ -40,7 +40,7 @@ afterEach(() => { describe("notifyHireApproved", () => { it("writes success activity when adapter hook returns ok", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: true }), } as any); @@ -48,7 +48,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( @@ -65,7 +65,7 @@ describe("notifyHireApproved", () => { expect.objectContaining({ action: "hire_hook.succeeded", entityId: "a1", - details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw" }), + details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }), }), ); }); @@ -116,7 +116,7 @@ describe("notifyHireApproved", () => { it("logs failed result when adapter onHireApproved returns ok=false", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }), } as any); @@ -124,7 +124,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( @@ -148,7 +148,7 @@ describe("notifyHireApproved", () => { it("does not throw when adapter onHireApproved throws (non-fatal)", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")), } as any); @@ -156,7 +156,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( diff --git a/server/src/__tests__/invite-accept-gateway-defaults.test.ts b/server/src/__tests__/invite-accept-gateway-defaults.test.ts new file mode 100644 index 00000000..3ff239f6 --- /dev/null +++ b/server/src/__tests__/invite-accept-gateway-defaults.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + buildJoinDefaultsPayloadForAccept, + normalizeAgentDefaultsForJoin, +} from "../routes/access.js"; + +describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => { + it("leaves non-gateway payloads unchanged", () => { + const defaultsPayload = { command: "echo hello" }; + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "process", + defaultsPayload, + inboundOpenClawAuthHeader: "ignored-token", + }); + + expect(result).toEqual(defaultsPayload); + }); + + it("normalizes wrapped x-openclaw-token header", () => { + 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 gateway joins", () => { + 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", + }, + }); + }); + + it("derives x-openclaw-token from authorization header", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + authorization: "Bearer gateway-token-1234567890", + }, + }, + }) as Record; + + expect(result).toMatchObject({ + headers: { + authorization: "Bearer gateway-token-1234567890", + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); +}); + +describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => { + it("generates persistent device key 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 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/__tests__/invite-accept-openclaw-defaults.test.ts b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts deleted file mode 100644 index dc7b58e1..00000000 --- a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildJoinDefaultsPayloadForAccept, - normalizeAgentDefaultsForJoin, -} from "../routes/access.js"; - -describe("buildJoinDefaultsPayloadForAccept", () => { - it("maps OpenClaw compatibility fields into agent defaults", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: null, - responsesWebhookUrl: "http://localhost:18789/v1/responses", - paperclipApiUrl: "http://host.docker.internal:3100", - inboundOpenClawAuthHeader: "gateway-token", - }) as Record; - - expect(result).toMatchObject({ - url: "http://localhost:18789/v1/responses", - paperclipApiUrl: "http://host.docker.internal:3100", - webhookAuthHeader: "Bearer gateway-token", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }); - }); - - it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "https://example.com/v1/responses", - method: "POST", - headers: { - "x-openclaw-auth": "existing-token", - }, - paperclipApiUrl: "https://paperclip.example.com", - }, - responsesWebhookUrl: "https://legacy.example.com/v1/responses", - responsesWebhookMethod: "PUT", - paperclipApiUrl: "https://legacy-paperclip.example.com", - inboundOpenClawAuthHeader: "legacy-token", - }) as Record; - - expect(result).toMatchObject({ - url: "https://example.com/v1/responses", - method: "POST", - paperclipApiUrl: "https://paperclip.example.com", - webhookAuthHeader: "Bearer existing-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }); - }); - - it("preserves explicit webhookAuthHeader when configured", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "https://example.com/v1/responses", - webhookAuthHeader: "Bearer explicit-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }, - inboundOpenClawAuthHeader: "legacy-token", - }) as Record; - - expect(result).toMatchObject({ - webhookAuthHeader: "Bearer explicit-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }); - }); - - it("accepts auth from agentDefaultsPayload.headers.x-openclaw-auth", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "http://127.0.0.1:18789/v1/responses", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth from agentDefaultsPayload.headers.x-openclaw-token", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "http://127.0.0.1:18789/hooks/agent", - method: "POST", - headers: { - "x-openclaw-token": "gateway-token", - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-token": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts inbound x-openclaw-token compatibility header", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: null, - inboundOpenClawTokenHeader: "gateway-token", - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-token": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts wrapped auth values in headers for compatibility", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: { - "x-openclaw-auth": { - value: "gateway-token", - }, - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers provided as tuple entries", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: [["x-openclaw-auth", "gateway-token"]], - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers provided as name/value entries", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: [{ name: "x-openclaw-auth", value: { authToken: "gateway-token" } }], - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers wrapped in a single unknown key", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: { - "x-openclaw-auth": { - gatewayToken: "gateway-token", - }, - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("leaves non-openclaw payloads unchanged", () => { - const defaultsPayload = { command: "echo hello" }; - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "process", - defaultsPayload, - responsesWebhookUrl: "https://ignored.example.com", - inboundOpenClawAuthHeader: "ignored-token", - }); - - 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", - }, - }); - }); - - 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/__tests__/invite-accept-replay.test.ts b/server/src/__tests__/invite-accept-replay.test.ts index 78a2bb1c..dba43dbd 100644 --- a/server/src/__tests__/invite-accept-replay.test.ts +++ b/server/src/__tests__/invite-accept-replay.test.ts @@ -1,63 +1,55 @@ import { describe, expect, it } from "vitest"; import { buildJoinDefaultsPayloadForAccept, - canReplayOpenClawInviteAccept, + canReplayOpenClawGatewayInviteAccept, mergeJoinDefaultsPayloadForReplay, } from "../routes/access.js"; -describe("canReplayOpenClawInviteAccept", () => { - it("allows replay only for openclaw agent joins in pending or approved state", () => { +describe("canReplayOpenClawGatewayInviteAccept", () => { + it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => { expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "pending_approval", }, }), ).toBe(true); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "approved", }, }), ).toBe(true); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "rejected", }, }), ).toBe(false); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "human", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", - status: "pending_approval", - }, - }), - ).toBe(false); - expect( - canReplayOpenClawInviteAccept({ - requestType: "agent", - adapterType: "process", - existingJoinRequest: { - requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "pending_approval", }, }), @@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => { }); describe("mergeJoinDefaultsPayloadForReplay", () => { - it("merges replay payloads and preserves existing fields while allowing auth/header overrides", () => { + it("merges replay payloads and allows gateway token override", () => { const merged = mergeJoinDefaultsPayloadForReplay( { - url: "https://old.example/v1/responses", - method: "POST", + url: "ws://old.example:18789", paperclipApiUrl: "http://host.docker.internal:3100", headers: { - "x-openclaw-auth": "old-token", + "x-openclaw-token": "old-token-1234567890", "x-custom": "keep-me", }, }, { paperclipApiUrl: "https://paperclip.example.com", headers: { - "x-openclaw-auth": "new-token", + "x-openclaw-token": "new-token-1234567890", }, }, ); const normalized = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", + adapterType: "openclaw_gateway", defaultsPayload: merged, inboundOpenClawAuthHeader: null, }) as Record; - expect(normalized.url).toBe("https://old.example/v1/responses"); + expect(normalized.url).toBe("ws://old.example:18789"); expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com"); - expect(normalized.webhookAuthHeader).toBe("Bearer new-token"); expect(normalized.headers).toMatchObject({ - "x-openclaw-auth": "new-token", + "x-openclaw-token": "new-token-1234567890", "x-custom": "keep-me", }); }); diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts deleted file mode 100644 index a77b21bb..00000000 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ /dev/null @@ -1,1063 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { execute, testEnvironment, onHireApproved } from "@paperclipai/adapter-openclaw/server"; -import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; -import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; - -function buildContext( - config: Record, - overrides?: Partial, -): AdapterExecutionContext { - return { - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "OpenClaw Agent", - adapterType: "openclaw", - adapterConfig: {}, - }, - runtime: { - sessionId: null, - sessionParams: null, - sessionDisplayId: null, - taskKey: null, - }, - config, - context: { - taskId: "task-123", - issueId: "issue-123", - wakeReason: "issue_assigned", - issueIds: ["issue-123"], - }, - onLog: async () => {}, - ...overrides, - }; -} - -function sseResponse(lines: string[]) { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - for (const line of lines) { - controller.enqueue(encoder.encode(line)); - } - controller.close(); - }, - }); - return new Response(stream, { - status: 200, - statusText: "OK", - headers: { - "content-type": "text/event-stream", - }, - }); -} - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("openclaw ui stdout parser", () => { - it("parses SSE deltas into assistant streaming entries", () => { - const ts = "2026-03-05T23:07:16.296Z"; - const line = - '[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":"hello"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "assistant", - ts, - text: "hello", - delta: true, - }, - ]); - }); - - it("parses stdout-prefixed SSE deltas and preserves spacing", () => { - const ts = "2026-03-05T23:07:16.296Z"; - const line = - 'stdout[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":" can"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "assistant", - ts, - text: " can", - delta: true, - }, - ]); - }); - - it("parses response.completed into usage-aware result entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = JSON.stringify({ - type: "response.completed", - response: { - status: "completed", - usage: { - input_tokens: 12, - output_tokens: 34, - cached_input_tokens: 5, - }, - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "All done", - }, - ], - }, - ], - }, - }); - - expect(parseOpenClawStdoutLine(`[openclaw:sse] event=response.completed data=${line}`, ts)).toEqual([ - { - kind: "result", - ts, - text: "All done", - inputTokens: 12, - outputTokens: 34, - cachedTokens: 5, - costUsd: 0, - subtype: "completed", - isError: false, - errors: [], - }, - ]); - }); - - it("maps SSE errors to stderr entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = - '[openclaw:sse] event=response.failed data={"type":"response.failed","error":"timeout"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "stderr", - ts, - text: "timeout", - }, - ]); - }); - - it("maps stderr-prefixed lines to stderr transcript entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = "stderr OpenClaw transport error"; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "stderr", - ts, - text: "OpenClaw transport error", - }, - ]); - }); -}); - -describe("openclaw adapter execute", () => { - it("uses SSE transport and includes canonical PAPERCLIP context in text payload", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - payloadTemplate: { foo: "bar", text: "OpenClaw task prompt" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.foo).toBe("bar"); - expect(body.stream).toBe(true); - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - expect((body.paperclip as Record).streamTransport).toBe("sse"); - expect((body.paperclip as Record).runId).toBe("run-123"); - expect((body.paperclip as Record).sessionKey).toBe("paperclip:issue:issue-123"); - expect( - ((body.paperclip as Record).env as Record).PAPERCLIP_RUN_ID, - ).toBe("run-123"); - const text = String(body.text ?? ""); - expect(text).toContain("OpenClaw task prompt"); - expect(text).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(text).toContain("PAPERCLIP_AGENT_ID=agent-123"); - expect(text).toContain("PAPERCLIP_COMPANY_ID=company-123"); - expect(text).toContain("PAPERCLIP_TASK_ID=task-123"); - expect(text).toContain("PAPERCLIP_WAKE_REASON=issue_assigned"); - expect(text).toContain("PAPERCLIP_LINKED_ISSUE_IDS=issue-123"); - expect(text).toContain("PAPERCLIP_API_KEY="); - expect(text).toContain("Load PAPERCLIP_API_KEY from ~/.openclaw/workspace/paperclip-claimed-api-key.json"); - }); - - it("uses paperclipApiUrl override when provided", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - paperclipApiUrl: "http://dotta-macbook-pro:3100", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const paperclip = body.paperclip as Record; - const env = paperclip.env as Record; - expect(env.PAPERCLIP_API_URL).toBe("http://dotta-macbook-pro:3100/"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_API_URL=http://dotta-macbook-pro:3100/"); - }); - - it("logs outbound header keys for auth debugging", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const logs: string[] = []; - const result = await execute( - buildContext( - { - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }, - { - onLog: async (_stream, chunk) => { - logs.push(chunk); - }, - }, - ), - ); - - expect(result.exitCode).toBe(0); - expect( - logs.some((line) => line.includes("[openclaw] outbound header keys:") && line.includes("x-openclaw-auth")), - ).toBe(true); - }); - - it("logs outbound payload with sensitive fields redacted", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const logs: string[] = []; - const result = await execute( - buildContext( - { - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - payloadTemplate: { - text: "task prompt", - nested: { - token: "secret-token", - visible: "keep-me", - }, - }, - }, - { - onLog: async (_stream, chunk) => { - logs.push(chunk); - }, - }, - ), - ); - - expect(result.exitCode).toBe(0); - - const headerLog = logs.find((line) => line.includes("[openclaw] outbound headers (redacted):")); - expect(headerLog).toBeDefined(); - expect(headerLog).toContain("\"x-openclaw-auth\":\"[redacted"); - expect(headerLog).toContain("\"authorization\":\"[redacted"); - expect(headerLog).not.toContain("gateway-token"); - - const payloadLog = logs.find((line) => line.includes("[openclaw] outbound payload (redacted):")); - expect(payloadLog).toBeDefined(); - expect(payloadLog).toContain("\"token\":\"[redacted"); - expect(payloadLog).not.toContain("secret-token"); - expect(payloadLog).toContain("\"visible\":\"keep-me\""); - }); - - it("derives Authorization header from x-openclaw-auth when webhookAuthHeader is unset", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-auth"]).toBe("gateway-token"); - expect(headers.authorization).toBe("Bearer gateway-token"); - }); - - it("derives Authorization header from x-openclaw-token when webhookAuthHeader is unset", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-token": "gateway-token", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-token"]).toBe("gateway-token"); - expect(headers.authorization).toBe("Bearer gateway-token"); - }); - - it("derives issue session keys when configured", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: done\n", - "data: [DONE]\n\n", - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - sessionKeyStrategy: "issue", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - expect((body.paperclip as Record).sessionKey).toBe("paperclip:issue:issue-123"); - }); - - it("maps requests to OpenResponses schema for /v1/responses endpoints", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - payloadTemplate: { - model: "openclaw", - user: "paperclip", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.stream).toBe(true); - expect(body.model).toBe("openclaw"); - expect(typeof body.input).toBe("string"); - expect(String(body.input)).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(String(body.input)).toContain("PAPERCLIP_API_KEY="); - expect(body.metadata).toBeTypeOf("object"); - expect((body.metadata as Record).PAPERCLIP_RUN_ID).toBe("run-123"); - expect(body.text).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - expect(body.sessionKey).toBeUndefined(); - - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-session-key"]).toBe("paperclip:issue:issue-123"); - }); - - it("does not treat response.output_text.done as a terminal OpenResponses event", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.output_text.done\n", - 'data: {"type":"response.output_text.done","text":"partial"}\n\n', - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - }), - ); - - expect(result.exitCode).toBe(0); - expect(result.resultJson).toEqual( - expect.objectContaining({ - terminal: true, - eventCount: 2, - lastEventType: "response.completed", - }), - ); - }); - - it("appends wake text when OpenResponses input is provided as a message object", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - payloadTemplate: { - model: "openclaw", - input: { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: "start with this context", - }, - ], - }, - }, - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const input = body.input as Record; - expect(input.type).toBe("message"); - expect(input.role).toBe("user"); - expect(Array.isArray(input.content)).toBe(true); - - const content = input.content as Record[]; - expect(content).toHaveLength(2); - expect(content[0]).toEqual({ - type: "input_text", - text: "start with this context", - }); - expect(content[1]).toEqual( - expect.objectContaining({ - type: "input_text", - }), - ); - expect(String(content[1]?.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("fails when SSE endpoint does not return text/event-stream", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: false, error: "unexpected payload" }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_expected_event_stream"); - }); - - it("fails when SSE stream closes without a terminal event", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.delta\n", - 'data: {"type":"response.delta","delta":"partial"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_stream_incomplete"); - }); - - it("fails with explicit text-required error when endpoint rejects payload", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "text required" }), { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_text_required"); - }); - - it("supports webhook transport and sends Paperclip webhook payloads", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - payloadTemplate: { foo: "bar" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.foo).toBe("bar"); - expect(body.stream).toBe(false); - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect((body.paperclip as Record).streamTransport).toBe("webhook"); - }); - - it("remaps legacy /v1/responses URLs to /hooks/agent in webhook transport", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - streamTransport: "webhook", - payloadTemplate: { foo: "bar" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("https://agent.example/hooks/agent"); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof body.message).toBe("string"); - expect(String(body.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.stream).toBeUndefined(); - expect(body.input).toBeUndefined(); - expect(body.metadata).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-session-key"]).toBeUndefined(); - }); - - it("falls back to legacy /v1/responses when remapped /hooks/agent returns 404", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response("Not Found", { - status: 404, - statusText: "Not Found", - headers: { - "content-type": "text/plain", - }, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("https://agent.example/hooks/agent"); - expect(String(fetchMock.mock.calls[1]?.[0] ?? "")).toBe("https://agent.example/v1/responses"); - - const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof firstBody.message).toBe("string"); - expect(String(firstBody.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(secondBody.stream).toBe(false); - expect(typeof secondBody.input).toBe("string"); - expect(String(secondBody.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - - const secondHeaders = (fetchMock.mock.calls[1]?.[1]?.headers ?? {}) as Record; - expect(secondHeaders["x-openclaw-session-key"]).toBe("paperclip:issue:issue-123"); - expect(result.resultJson).toEqual( - expect.objectContaining({ - usedLegacyResponsesFallback: true, - }), - ); - }); - - it("uses wake compatibility payloads for /hooks/wake when transport=webhook", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/wake", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.mode).toBe("now"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.paperclip).toBeUndefined(); - }); - - it("uses /hooks/agent payloads for webhook transport and omits sessionKey by default", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - streamTransport: "webhook", - payloadTemplate: { - name: "Paperclip Hook", - wakeMode: "next-heartbeat", - deliver: true, - channel: "last", - model: "openai/gpt-5.2-mini", - }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof body.message).toBe("string"); - expect(String(body.message)).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.name).toBe("Paperclip Hook"); - expect(body.wakeMode).toBe("next-heartbeat"); - expect(body.deliver).toBe(true); - expect(body.channel).toBe("last"); - expect(body.model).toBe("openai/gpt-5.2-mini"); - expect(body.sessionKey).toBeUndefined(); - expect(body.text).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - }); - - it("includes sessionKey for /hooks/agent payloads only when hookIncludeSessionKey=true", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - streamTransport: "webhook", - hookIncludeSessionKey: true, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - }); - - it("retries webhook payloads with wake compatibility format on text-required errors", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "text required" }), { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(String(firstBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(firstBody.paperclip).toBeTypeOf("object"); - expect(secondBody.mode).toBe("now"); - expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("retries webhook payloads when /v1/responses reports missing string input", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - message: "model: Invalid input: expected string, received undefined", - type: "invalid_request_error", - }, - }), - { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }, - ), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(secondBody.mode).toBe("now"); - expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("rejects unsupported transport configuration", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - streamTransport: "invalid", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_stream_transport_unsupported"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("rejects /hooks/wake compatibility endpoints in SSE mode", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/wake", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("rejects /hooks/agent endpoints in SSE mode", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint"); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - -describe("openclaw adapter environment checks", () => { - it("reports /hooks/wake endpoints as incompatible for SSE mode", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/wake", - }, - deployment: { - mode: "authenticated", - exposure: "private", - bindHost: "paperclip.internal", - allowedHostnames: ["paperclip.internal"], - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(check?.level).toBe("error"); - }); - - it("reports /hooks/agent endpoints as incompatible for SSE mode", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/agent", - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(check?.level).toBe("error"); - }); - - it("reports unsupported streamTransport settings", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/sse", - streamTransport: "invalid", - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_stream_transport_unsupported"); - expect(check?.level).toBe("error"); - }); - - it("accepts webhook streamTransport settings", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/wake", - streamTransport: "webhook", - }, - }); - - const unsupported = result.checks.find((entry) => entry.code === "openclaw_stream_transport_unsupported"); - const configured = result.checks.find((entry) => entry.code === "openclaw_stream_transport_configured"); - const wakeIncompatible = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(unsupported).toBeUndefined(); - expect(configured?.level).toBe("info"); - expect(wakeIncompatible).toBeUndefined(); - }); -}); - -describe("onHireApproved", () => { - it("returns ok when hireApprovedCallbackUrl is not set (no-op)", async () => { - const result = await onHireApproved( - { - companyId: "c1", - agentId: "a1", - agentName: "Test Agent", - adapterType: "openclaw", - source: "join_request", - sourceId: "jr1", - approvedAt: "2026-03-06T00:00:00.000Z", - message: "You're hired.", - }, - {}, - ); - expect(result).toEqual({ ok: true }); - }); - - it("POSTs payload to hireApprovedCallbackUrl with correct headers and body", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - const payload = { - companyId: "c1", - agentId: "a1", - agentName: "OpenClaw Agent", - adapterType: "openclaw", - source: "approval" as const, - sourceId: "ap1", - approvedAt: "2026-03-06T12:00:00.000Z", - message: "Tell your user that your hire was approved.", - }; - - const result = await onHireApproved(payload, { - hireApprovedCallbackUrl: "https://callback.example/hire-approved", - hireApprovedCallbackAuthHeader: "Bearer secret", - }); - - expect(result.ok).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe("https://callback.example/hire-approved"); - expect(init?.method).toBe("POST"); - expect((init?.headers as Record)["content-type"]).toBe("application/json"); - expect((init?.headers as Record)["Authorization"]).toBe("Bearer secret"); - const body = JSON.parse(init?.body as string); - expect(body.event).toBe("hire_approved"); - expect(body.companyId).toBe(payload.companyId); - expect(body.agentId).toBe(payload.agentId); - expect(body.message).toBe(payload.message); - }); - - it("returns failure when callback returns non-2xx", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response("Server Error", { status: 500 })); - vi.stubGlobal("fetch", fetchMock); - - const result = await onHireApproved( - { - companyId: "c1", - agentId: "a1", - agentName: "A", - adapterType: "openclaw", - source: "join_request", - sourceId: "jr1", - approvedAt: new Date().toISOString(), - message: "Hired", - }, - { hireApprovedCallbackUrl: "https://example.com/hook" }, - ); - - expect(result.ok).toBe(false); - expect(result.error).toContain("500"); - }); -}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index d9e153ed..9fe536a0 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -26,15 +26,6 @@ import { import { agentConfigurationDoc as openCodeAgentConfigurationDoc, } from "@paperclipai/adapter-opencode-local"; -import { - execute as openclawExecute, - testEnvironment as openclawTestEnvironment, - onHireApproved as openclawOnHireApproved, -} from "@paperclipai/adapter-openclaw/server"; -import { - agentConfigurationDoc as openclawAgentConfigurationDoc, - models as openclawModels, -} from "@paperclipai/adapter-openclaw"; import { execute as openclawGatewayExecute, testEnvironment as openclawGatewayTestEnvironment, @@ -89,16 +80,6 @@ const cursorLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: cursorAgentConfigurationDoc, }; -const openclawAdapter: ServerAdapterModule = { - type: "openclaw", - execute: openclawExecute, - testEnvironment: openclawTestEnvironment, - onHireApproved: openclawOnHireApproved, - models: openclawModels, - supportsLocalAgentJwt: false, - agentConfigurationDoc: openclawAgentConfigurationDoc, -}; - const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -137,7 +118,6 @@ const adaptersByType = new Map( openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, - openclawAdapter, openclawGatewayAdapter, processAdapter, httpAdapter, diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 9eaacf71..c13366ff 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -136,19 +136,6 @@ function isLoopbackHost(hostname: string): boolean { return value === "localhost" || value === "127.0.0.1" || value === "::1"; } -function isWakePath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return value === "/hooks/wake" || value.endsWith("/hooks/wake"); -} - -function normalizeOpenClawTransport(value: unknown): "sse" | "webhook" | null { - if (typeof value !== "string") return "sse"; - const normalized = value.trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - function normalizeHostname(value: string | null | undefined): string | null { if (!value) return null; const trimmed = value.trim(); @@ -311,12 +298,6 @@ function headerMapGetIgnoreCase( return typeof value === "string" ? value : null; } -function toAuthorizationHeaderValue(rawToken: string): string { - const trimmed = rawToken.trim(); - if (!trimmed) return trimmed; - return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; -} - function tokenFromAuthorizationHeader(rawHeader: string | null): string | null { const trimmed = nonEmptyTrimmedString(rawHeader); if (!trimmed) return null; @@ -346,68 +327,11 @@ function generateEd25519PrivateKeyPem(): string { export function buildJoinDefaultsPayloadForAccept(input: { adapterType: string | null; defaultsPayload: unknown; - responsesWebhookUrl?: unknown; - responsesWebhookMethod?: unknown; - responsesWebhookHeaders?: unknown; paperclipApiUrl?: unknown; - webhookAuthHeader?: unknown; 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") { + if (input.adapterType !== "openclaw_gateway") { return input.defaultsPayload; } @@ -415,40 +339,11 @@ export function buildJoinDefaultsPayloadForAccept(input: { ? { ...(input.defaultsPayload as Record) } : ({} as Record); - if (!nonEmptyTrimmedString(merged.url)) { - const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl); - if (legacyUrl) merged.url = legacyUrl; - } - - if (!nonEmptyTrimmedString(merged.method)) { - const legacyMethod = nonEmptyTrimmedString(input.responsesWebhookMethod); - if (legacyMethod) merged.method = legacyMethod.toUpperCase(); - } - if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) { const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl); if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl; } - - if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) { - const providedWebhookAuthHeader = nonEmptyTrimmedString( - input.webhookAuthHeader - ); - if (providedWebhookAuthHeader) - merged.webhookAuthHeader = providedWebhookAuthHeader; - } - const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {}; - const compatibilityHeaders = normalizeHeaderMap( - input.responsesWebhookHeaders - ); - if (compatibilityHeaders) { - for (const [key, value] of Object.entries(compatibilityHeaders)) { - if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) { - mergedHeaders[key] = value; - } - } - } const inboundOpenClawAuthHeader = nonEmptyTrimmedString( input.inboundOpenClawAuthHeader @@ -475,23 +370,17 @@ export function buildJoinDefaultsPayloadForAccept(input: { delete merged.headers; } - const hasAuthorizationHeader = headerMapHasKeyIgnoreCase( - mergedHeaders, - "authorization" - ); - const hasWebhookAuthHeader = Boolean( - nonEmptyTrimmedString(merged.webhookAuthHeader) - ); - if (!hasAuthorizationHeader && !hasWebhookAuthHeader) { - const openClawAuthToken = - headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? - headerMapGetIgnoreCase( - mergedHeaders, - "x-openclaw-auth" + const discoveredToken = + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader( + headerMapGetIgnoreCase(mergedHeaders, "authorization") ); - if (openClawAuthToken) { - merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken); - } + if ( + discoveredToken && + !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token") + ) { + mergedHeaders["x-openclaw-token"] = discoveredToken; } return Object.keys(merged).length > 0 ? merged : null; @@ -537,7 +426,7 @@ export function mergeJoinDefaultsPayloadForReplay( return merged; } -export function canReplayOpenClawInviteAccept(input: { +export function canReplayOpenClawGatewayInviteAccept(input: { requestType: "human" | "agent"; adapterType: string | null; existingJoinRequest: Pick< @@ -545,7 +434,10 @@ export function canReplayOpenClawInviteAccept(input: { "requestType" | "adapterType" | "status" > | null; }): boolean { - if (input.requestType !== "agent" || input.adapterType !== "openclaw") { + if ( + input.requestType !== "agent" || + input.adapterType !== "openclaw_gateway" + ) { return false; } if (!input.existingJoinRequest) { @@ -553,7 +445,7 @@ export function canReplayOpenClawInviteAccept(input: { } if ( input.existingJoinRequest.requestType !== "agent" || - input.existingJoinRequest.adapterType !== "openclaw" + input.existingJoinRequest.adapterType !== "openclaw_gateway" ) { return false; } @@ -575,32 +467,6 @@ function summarizeSecretForLog( }; } -function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) { - const defaults = isPlainObject(defaultsPayload) - ? (defaultsPayload as Record) - : null; - const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined; - const openClawAuthHeaderValue = headers - ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? - headerMapGetIgnoreCase(headers, "x-openclaw-auth") - : null; - - return { - present: Boolean(defaults), - keys: defaults ? Object.keys(defaults).sort() : [], - url: defaults ? nonEmptyTrimmedString(defaults.url) : null, - method: defaults ? nonEmptyTrimmedString(defaults.method) : null, - paperclipApiUrl: defaults - ? nonEmptyTrimmedString(defaults.paperclipApiUrl) - : null, - headerKeys: headers ? Object.keys(headers).sort() : [], - webhookAuthHeader: defaults - ? summarizeSecretForLog(defaults.webhookAuthHeader) - : null, - openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue) - }; -} - function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) { const defaults = isPlainObject(defaultsPayload) ? (defaultsPayload as Record) @@ -638,79 +504,6 @@ function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) { }; } -function buildJoinConnectivityDiagnostics(input: { - deploymentMode: DeploymentMode; - deploymentExposure: DeploymentExposure; - bindHost: string; - allowedHostnames: string[]; - callbackUrl: URL | null; -}): JoinDiagnostic[] { - const diagnostics: JoinDiagnostic[] = []; - const bindHost = normalizeHostname(input.bindHost); - const callbackHost = input.callbackUrl - ? normalizeHostname(input.callbackUrl.hostname) - : null; - const allowSet = new Set( - input.allowedHostnames - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)) - ); - - diagnostics.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.` - }); - - if ( - input.deploymentMode === "authenticated" && - input.deploymentExposure === "private" - ) { - if (!bindHost || isLoopbackHost(bindHost)) { - diagnostics.push({ - code: "openclaw_private_bind_loopback", - level: "warn", - message: - "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks." - }); - } - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - diagnostics.push({ - code: "openclaw_private_bind_not_allowed", - level: "warn", - message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost}` - }); - } - if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) { - diagnostics.push({ - code: "openclaw_private_allowed_hostnames_empty", - level: "warn", - message: - "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs off-host." - }); - } - } - - if ( - input.deploymentMode === "authenticated" && - input.deploymentExposure === "public" && - input.callbackUrl && - input.callbackUrl.protocol !== "https:" - ) { - diagnostics.push({ - code: "openclaw_public_http_callback", - level: "warn", - message: "OpenClaw callback URL uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments." - }); - } - - return diagnostics; -} - export function normalizeAgentDefaultsForJoin(input: { adapterType: string | null; defaultsPayload: unknown; @@ -721,267 +514,25 @@ export function normalizeAgentDefaultsForJoin(input: { }) { const fatalErrors: string[] = []; const diagnostics: JoinDiagnostic[] = []; - if ( - input.adapterType !== "openclaw" && - input.adapterType !== "openclaw_gateway" - ) { + if (input.adapterType !== "openclaw_gateway") { const normalized = isPlainObject(input.defaultsPayload) ? (input.defaultsPayload as Record) : null; 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); - 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)) { diagnostics.push({ - code: "openclaw_callback_config_missing", + code: "openclaw_gateway_defaults_missing", level: "warn", message: - "No OpenClaw callback config was provided in agentDefaultsPayload.", - hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval." + "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, @@ -990,120 +541,87 @@ export function normalizeAgentDefaultsForJoin(input: { } const defaults = input.defaultsPayload as Record; - const streamTransportInput = defaults.streamTransport ?? defaults.transport; - const streamTransport = normalizeOpenClawTransport(streamTransportInput); - const normalized: Record = { streamTransport: "sse" }; - if (!streamTransport) { - diagnostics.push({ - code: "openclaw_stream_transport_unsupported", - level: "warn", - message: `Unsupported streamTransport: ${String(streamTransportInput)}`, - hint: "Use streamTransport=sse or streamTransport=webhook." - }); - } else { - normalized.streamTransport = streamTransport; - } + const normalized: Record = {}; - let callbackUrl: URL | null = null; - const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : ""; - if (!rawUrl) { + let gatewayUrl: URL | null = null; + const rawGatewayUrl = nonEmptyTrimmedString(defaults.url); + if (!rawGatewayUrl) { diagnostics.push({ - code: "openclaw_callback_url_missing", + code: "openclaw_gateway_url_missing", level: "warn", - message: "OpenClaw callback URL is missing.", - hint: "Set agentDefaultsPayload.url to your OpenClaw endpoint." + message: "OpenClaw gateway URL is missing.", + hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL." }); + fatalErrors.push("agentDefaultsPayload.url is required"); } else { try { - callbackUrl = new URL(rawUrl); - if ( - callbackUrl.protocol !== "http:" && - callbackUrl.protocol !== "https:" - ) { + gatewayUrl = new URL(rawGatewayUrl); + if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") { diagnostics.push({ - code: "openclaw_callback_url_protocol", + code: "openclaw_gateway_url_protocol", level: "warn", - message: `Unsupported callback protocol: ${callbackUrl.protocol}`, - hint: "Use http:// or https://." + 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 = callbackUrl.toString(); + normalized.url = gatewayUrl.toString(); diagnostics.push({ - code: "openclaw_callback_url_configured", + code: "openclaw_gateway_url_configured", level: "info", - message: `Callback endpoint set to ${callbackUrl.toString()}` - }); - } - if ((streamTransport ?? "sse") === "sse" && isWakePath(callbackUrl.pathname)) { - diagnostics.push({ - code: "openclaw_callback_wake_path_incompatible", - level: "warn", - message: - "Configured callback path targets /hooks/wake, which is not stream-capable for SSE transport.", - hint: "Use an endpoint that returns text/event-stream for the full run duration." - }); - } - if (isLoopbackHost(callbackUrl.hostname)) { - diagnostics.push({ - code: "openclaw_callback_loopback", - level: "warn", - message: "OpenClaw callback endpoint uses loopback hostname.", - hint: "Use a reachable hostname/IP when OpenClaw runs on another machine." + message: `Gateway endpoint set to ${gatewayUrl.toString()}` }); } } catch { diagnostics.push({ - code: "openclaw_callback_url_invalid", + code: "openclaw_gateway_url_invalid", level: "warn", - message: `Invalid callback URL: ${rawUrl}` + message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}` }); + fatalErrors.push("agentDefaultsPayload.url is not a valid URL"); } } - const rawMethod = - typeof defaults.method === "string" - ? defaults.method.trim().toUpperCase() - : ""; - normalized.method = rawMethod || "POST"; - - if ( - typeof defaults.timeoutSec === "number" && - Number.isFinite(defaults.timeoutSec) - ) { - normalized.timeoutSec = Math.max( - 0, - Math.min(7200, Math.floor(defaults.timeoutSec)) - ); + 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; } - const headers = normalizeHeaderMap(defaults.headers); - if (headers) normalized.headers = headers; - - if ( - typeof defaults.webhookAuthHeader === "string" && - defaults.webhookAuthHeader.trim() - ) { - normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim(); - } - - const openClawAuthHeader = headers - ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? - headerMapGetIgnoreCase(headers, "x-openclaw-auth") - : null; - if (openClawAuthHeader) { + if (!gatewayToken) { diagnostics.push({ - code: "openclaw_auth_header_configured", - level: "info", - message: - "Gateway auth token received via headers.x-openclaw-token (or legacy x-openclaw-auth)." - }); - } else { - diagnostics.push({ - code: "openclaw_auth_header_missing", + 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) to the token your OpenClaw endpoint requires." + "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." }); } @@ -1111,6 +629,98 @@ export function normalizeAgentDefaultsForJoin(input: { 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() @@ -1123,46 +733,27 @@ export function normalizeAgentDefaultsForJoin(input: { parsedPaperclipApiUrl.protocol !== "https:" ) { diagnostics.push({ - code: "openclaw_paperclip_api_url_protocol", + 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_paperclip_api_url_configured", + code: "openclaw_gateway_paperclip_api_url_configured", level: "info", message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}` }); - if (isLoopbackHost(parsedPaperclipApiUrl.hostname)) { - diagnostics.push({ - code: "openclaw_paperclip_api_url_loopback", - level: "warn", - message: - "paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments." - }); - } } } catch { diagnostics.push({ - code: "openclaw_paperclip_api_url_invalid", + code: "openclaw_gateway_paperclip_api_url_invalid", level: "warn", message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}` }); } } - diagnostics.push( - ...buildJoinConnectivityDiagnostics({ - deploymentMode: input.deploymentMode, - deploymentExposure: input.deploymentExposure, - bindHost: input.bindHost, - allowedHostnames: input.allowedHostnames, - callbackUrl - }) - ); - return { normalized, diagnostics, fatalErrors }; } @@ -2309,7 +1900,7 @@ export function accessRoutes( const adapterType = req.body.adapterType ?? null; if ( inviteAlreadyAccepted && - !canReplayOpenClawInviteAccept({ + !canReplayOpenClawGatewayInviteAccept({ requestType, adapterType, existingJoinRequest: existingJoinRequestForInvite @@ -2331,59 +1922,22 @@ export function accessRoutes( ) : req.body.agentDefaultsPayload ?? null; - const openClawDefaultsPayload = + const gatewayDefaultsPayload = requestType === "agent" ? buildJoinDefaultsPayloadForAccept({ adapterType, defaultsPayload: replayMergedDefaults, - responsesWebhookUrl: req.body.responsesWebhookUrl ?? null, - responsesWebhookMethod: req.body.responsesWebhookMethod ?? null, - responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null, paperclipApiUrl: req.body.paperclipApiUrl ?? null, - webhookAuthHeader: req.body.webhookAuthHeader ?? null, inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null }) : null; - if (requestType === "agent" && adapterType === "openclaw") { - logger.info( - { - inviteId: invite.id, - requestType, - adapterType, - bodyKeys: isPlainObject(req.body) - ? Object.keys(req.body).sort() - : [], - responsesWebhookUrl: nonEmptyTrimmedString( - req.body.responsesWebhookUrl - ), - paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl), - webhookAuthHeader: summarizeSecretForLog( - req.body.webhookAuthHeader - ), - inboundOpenClawAuthHeader: summarizeSecretForLog( - req.header("x-openclaw-auth") ?? null - ), - inboundOpenClawTokenHeader: summarizeSecretForLog( - req.header("x-openclaw-token") ?? null - ), - rawAgentDefaults: summarizeOpenClawDefaultsForLog( - req.body.agentDefaultsPayload ?? null - ), - mergedAgentDefaults: summarizeOpenClawDefaultsForLog( - openClawDefaultsPayload - ) - }, - "invite accept received OpenClaw join payload" - ); - } - const joinDefaults = requestType === "agent" ? normalizeAgentDefaultsForJoin({ adapterType, - defaultsPayload: openClawDefaultsPayload, + defaultsPayload: gatewayDefaultsPayload, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, @@ -2399,22 +1953,6 @@ export function accessRoutes( throw badRequest(joinDefaults.fatalErrors.join("; ")); } - if (requestType === "agent" && adapterType === "openclaw") { - logger.info( - { - inviteId: invite.id, - joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({ - code: diag.code, - level: diag.level - })), - normalizedAgentDefaults: summarizeOpenClawDefaultsForLog( - joinDefaults.normalized - ) - }, - "invite accept normalized OpenClaw defaults" - ); - } - if (requestType === "agent" && adapterType === "openclaw_gateway") { logger.info( { @@ -2516,7 +2054,7 @@ export function accessRoutes( if ( inviteAlreadyAccepted && requestType === "agent" && - adapterType === "openclaw" && + adapterType === "openclaw_gateway" && created.status === "approved" && created.createdAgentId ) { @@ -2552,11 +2090,11 @@ export function accessRoutes( }); } - if (requestType === "agent" && adapterType === "openclaw") { - const expectedDefaults = summarizeOpenClawDefaultsForLog( + if (requestType === "agent" && adapterType === "openclaw_gateway") { + const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog( joinDefaults.normalized ); - const persistedDefaults = summarizeOpenClawDefaultsForLog( + const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog( created.agentDefaultsPayload ); const missingPersistedFields: string[] = []; @@ -2569,19 +2107,14 @@ export function accessRoutes( ) { missingPersistedFields.push("paperclipApiUrl"); } - if ( - expectedDefaults.webhookAuthHeader && - !persistedDefaults.webhookAuthHeader - ) { - missingPersistedFields.push("webhookAuthHeader"); + if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) { + missingPersistedFields.push("headers.x-openclaw-token"); } if ( - expectedDefaults.openClawAuthHeader && - !persistedDefaults.openClawAuthHeader + expectedDefaults.devicePrivateKeyPem && + !persistedDefaults.devicePrivateKeyPem ) { - missingPersistedFields.push( - "headers.x-openclaw-token|headers.x-openclaw-auth" - ); + missingPersistedFields.push("devicePrivateKeyPem"); } if ( expectedDefaults.headerKeys.length > 0 && @@ -2604,7 +2137,7 @@ export function accessRoutes( hint: diag.hint ?? null })) }, - "invite accept persisted OpenClaw join request" + "invite accept persisted OpenClaw gateway join request" ); if (missingPersistedFields.length > 0) { @@ -2614,7 +2147,7 @@ export function accessRoutes( joinRequestId: created.id, missingPersistedFields }, - "invite accept detected missing persisted OpenClaw defaults" + "invite accept detected missing persisted OpenClaw gateway defaults" ); } } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index afe54ffc..ac6de363 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record void; - placeholder?: string; -}) { - const [visible, setVisible] = useState(false); - return ( - -
- - -
-
- ); -} - -export function OpenClawConfigFields({ - isCreate, - values, - set, - config, - eff, - mark, -}: AdapterConfigFieldsProps) { - const configuredHeaders = - config.headers && typeof config.headers === "object" && !Array.isArray(config.headers) - ? (config.headers as Record) - : {}; - const effectiveHeaders = - (eff("adapterConfig", "headers", configuredHeaders) as Record) ?? {}; - const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string" - ? String(effectiveHeaders["x-openclaw-auth"]) - : ""; - - const commitGatewayAuthHeader = (rawValue: string) => { - const nextValue = rawValue.trim(); - const nextHeaders: Record = { ...effectiveHeaders }; - if (nextValue) { - nextHeaders["x-openclaw-auth"] = nextValue; - } else { - delete nextHeaders["x-openclaw-auth"]; - } - mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined); - }; - - const transport = eff( - "adapterConfig", - "streamTransport", - String(config.streamTransport ?? "sse"), - ); - const sessionStrategy = eff( - "adapterConfig", - "sessionKeyStrategy", - String(config.sessionKeyStrategy ?? "fixed"), - ); - - return ( - <> - - - isCreate - ? set!({ url: v }) - : mark("adapterConfig", "url", v || undefined) - } - immediate - className={inputClass} - placeholder="https://..." - /> - - {!isCreate && ( - <> - - mark("adapterConfig", "paperclipApiUrl", v || undefined)} - immediate - className={inputClass} - placeholder="https://paperclip.example" - /> - - - - - - - - - - - {sessionStrategy === "fixed" && ( - - mark("adapterConfig", "sessionKey", v || undefined)} - immediate - className={inputClass} - placeholder="paperclip" - /> - - )} - - mark("adapterConfig", "webhookAuthHeader", v || undefined)} - placeholder="Bearer " - /> - - - - )} - - ); -} diff --git a/ui/src/adapters/openclaw/index.ts b/ui/src/adapters/openclaw/index.ts deleted file mode 100644 index 890d83bc..00000000 --- a/ui/src/adapters/openclaw/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { UIAdapterModule } from "../types"; -import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; -import { buildOpenClawConfig } from "@paperclipai/adapter-openclaw/ui"; -import { OpenClawConfigFields } from "./config-fields"; - -export const openClawUIAdapter: UIAdapterModule = { - type: "openclaw", - label: "OpenClaw", - parseStdoutLine: parseOpenClawStdoutLine, - ConfigFields: OpenClawConfigFields, - buildAdapterConfig: buildOpenClawConfig, -}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index a641b265..1a36af6b 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -4,7 +4,6 @@ import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; -import { openClawUIAdapter } from "./openclaw"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; @@ -16,7 +15,6 @@ const adaptersByType = new Map( openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, - openClawUIAdapter, openClawGatewayUIAdapter, processUIAdapter, httpUIAdapter, diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 4e1bc76e..6ff2dfeb 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -18,7 +18,6 @@ const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", opencode_local: "OpenCode (local)", - openclaw: "OpenClaw", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 02bdf74c..9d176179 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -157,7 +157,7 @@ function parseStdoutChunk( if (!trimmed) continue; const parsed = adapter.parseStdoutLine(trimmed, ts); if (parsed.length === 0) { - if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") { + if (run.adapterType === "openclaw_gateway") { continue; } const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index e1520356..fbcbc7bf 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -56,7 +56,6 @@ type AdapterType = | "cursor" | "process" | "http" - | "openclaw" | "openclaw_gateway"; const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md) @@ -971,7 +970,7 @@ export function OnboardingWizard() {
)} - {(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && ( + {(adapterType === "http" || adapterType === "openclaw_gateway") && (