From 048e2b1bfed872540bef79542047f0b22546f233 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 18:50:25 -0600 Subject: [PATCH] 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") && (