Remove legacy OpenClaw adapter and keep gateway-only flow

This commit is contained in:
Dotta
2026-03-07 18:50:25 -06:00
parent 5fae7d4de7
commit 048e2b1bfe
55 changed files with 454 additions and 5057 deletions

View File

@@ -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

View File

@@ -21,7 +21,6 @@ const workspacePaths = [
"packages/adapter-utils",
"packages/adapters/claude-local",
"packages/adapters/codex-local",
"packages/adapters/openclaw",
"packages/adapters/openclaw-gateway",
];

View File

@@ -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:*",

View File

@@ -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<string, CLIAdapterModule>(
openCodeLocalCLIAdapter,
piLocalCLIAdapter,
cursorLocalCLIAdapter,
openclawCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,

View File

@@ -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

View File

@@ -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": "<gateway-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": "<gateway-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 <gateway-token>`
- Optional inspection:
- `openclaw devices list --json --url ws://127.0.0.1:18789 --token <gateway-token>`
- After approval, retries should succeed using the persisted `devicePrivateKeyPem`.
6. Claim API key with `claimSecret`.
7. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
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 users 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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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));
}

View File

@@ -1 +0,0 @@
export { printOpenClawStreamEvent } from "./format-event.js";

View File

@@ -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
`;

View File

@@ -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<string, string>;
payloadTemplate: Record<string, unknown>;
wakePayload: WakePayload;
sessionKey: string;
paperclipEnv: Record<string, string>;
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<string, string> {
const parsed = parseObject(value);
const out: Record<string, string> = {};
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<string, unknown>);
const out: Record<string, unknown> = {};
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<string, string> {
const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl);
const paperclipEnv: Record<string, string> = {
...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, string>): 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 ?? "<set 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=<token from ${claimedApiKeyPath}>`,
"",
`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<string, unknown> {
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<string, unknown>).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<string, string>;
payload: Record<string, unknown>;
signal: AbortSignal;
}): Promise<Response> {
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<string> {
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}) <empty>\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<string, unknown>;
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
const webhookAuthHeader = nonEmpty(ctx.config.webhookAuthHeader);
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
const headers: Record<string, string> = {
"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<string, unknown> {
return {
text: wakeText,
mode: "now",
};
}

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<ConsumedSse> {
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<string, unknown> | null = null;
let terminal = false;
let failed = false;
let errorMessage: string | null = null;
const dispatchEvent = async (): Promise<boolean> => {
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<string, string>; body: Record<string, unknown> } {
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<string, unknown> = 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<string, string> = {
...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<AdapterExecutionResult> {
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);
}
}

View File

@@ -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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> = {
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<string, unknown> {
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<string, unknown> {
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<string, string>;
payload: Record<string, unknown>;
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<AdapterExecutionResult> {
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);
}
}

View File

@@ -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<AdapterExecutionResult> {
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);
}

View File

@@ -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<string, unknown>,
): Promise<HireApprovedHookResult> {
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<string, string> = {
"content-type": "application/json",
};
if (authHeader && !headers.authorization && !headers.Authorization) {
headers.Authorization = authHeader;
}
const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record<string, unknown>;
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,
};
}
}

View File

@@ -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";

View File

@@ -1,15 +0,0 @@
export function parseOpenClawResponse(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function isOpenClawUnknownSessionError(_text: string): boolean {
return false;
}

View File

@@ -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 <host> 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<AdapterEnvironmentTestResult> {
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(),
};
}

View File

@@ -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 };
}

View File

@@ -1,11 +0,0 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
export function buildOpenClawConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.url) ac.url = v.url;
ac.method = "POST";
ac.timeoutSec = 0;
ac.streamTransport = "sse";
ac.sessionKeyStrategy = "issue";
return ac;
}

View File

@@ -1,2 +0,0 @@
export { parseOpenClawStdoutLine } from "./parse-stdout.js";
export { buildOpenClawConfig } from "./build-config.js";

View File

@@ -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<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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<string, unknown> | 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<string, unknown> | 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 }];
}

View File

@@ -1,8 +0,0 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -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

View File

@@ -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

View File

@@ -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];

25
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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.

View File

@@ -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"

View File

@@ -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"

View File

@@ -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:*",

View File

@@ -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(

View File

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

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(result).toMatchObject({
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
it("accepts inbound x-openclaw-token for openclaw_gateway", () => {
const result = buildJoinDefaultsPayloadForAccept({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
},
inboundOpenClawTokenHeader: "gateway-token-1234567890",
}) as Record<string, unknown>;
expect(result).toMatchObject({
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
});
});
it("generates persistent device key for openclaw_gateway when device auth is enabled", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: false,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
});
it("does not generate device key when openclaw_gateway has disableDeviceAuth=true", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: true,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
});
});

View File

@@ -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<string, unknown>;
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",
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, ServerAdapterModule>(
openCodeLocalAdapter,
piLocalAdapter,
cursorLocalAdapter,
openclawAdapter,
openclawGatewayAdapter,
processAdapter,
httpAdapter,

File diff suppressed because it is too large Load Diff

View File

@@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
{ path: ["graceSec"], value: 15 },
{ path: ["maxTurnsPerRun"], value: 80 },
],
openclaw: [
{ path: ["method"], value: "POST" },
{ path: ["timeoutSec"], value: 30 },
],
openclaw_gateway: [
{ path: ["timeoutSec"], value: 120 },
{ path: ["waitTimeoutMs"], value: 120000 },

View File

@@ -253,7 +253,7 @@ npm dist-tag add @paperclipai/db@{version} latest
npm dist-tag add @paperclipai/adapter-utils@{version} latest
npm dist-tag add @paperclipai/adapter-claude-local@{version} latest
npm dist-tag add @paperclipai/adapter-codex-local@{version} latest
npm dist-tag add @paperclipai/adapter-openclaw@{version} latest
npm dist-tag add @paperclipai/adapter-openclaw-gateway@{version} latest
```
**Script option:** Add `./scripts/release.sh --promote {version}` to automate

View File

@@ -19,7 +19,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/shared": "workspace:*",

View File

@@ -1,177 +0,0 @@
import { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
help,
} from "../../components/agent-config-primitives";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
function SecretField({
label,
value,
onCommit,
placeholder,
}: {
label: string;
value: string;
onCommit: (v: string) => void;
placeholder?: string;
}) {
const [visible, setVisible] = useState(false);
return (
<Field label={label}>
<div className="relative">
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
<DraftInput
value={value}
onCommit={onCommit}
immediate
type={visible ? "text" : "password"}
className={inputClass + " pl-8"}
placeholder={placeholder}
/>
</div>
</Field>
);
}
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<string, unknown>)
: {};
const effectiveHeaders =
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string"
? String(effectiveHeaders["x-openclaw-auth"])
: "";
const commitGatewayAuthHeader = (rawValue: string) => {
const nextValue = rawValue.trim();
const nextHeaders: Record<string, unknown> = { ...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 (
<>
<Field label="Gateway URL" hint={help.webhookUrl}>
<DraftInput
value={
isCreate
? values!.url
: eff("adapterConfig", "url", String(config.url ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ url: v })
: mark("adapterConfig", "url", v || undefined)
}
immediate
className={inputClass}
placeholder="https://..."
/>
</Field>
{!isCreate && (
<>
<Field label="Paperclip API URL override">
<DraftInput
value={
eff(
"adapterConfig",
"paperclipApiUrl",
String(config.paperclipApiUrl ?? ""),
)
}
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
immediate
className={inputClass}
placeholder="https://paperclip.example"
/>
</Field>
<Field label="Transport">
<select
value={transport}
onChange={(e) => mark("adapterConfig", "streamTransport", e.target.value)}
className={inputClass}
>
<option value="sse">SSE (recommended)</option>
<option value="webhook">Webhook</option>
</select>
</Field>
<Field label="Session strategy">
<select
value={sessionStrategy}
onChange={(e) => mark("adapterConfig", "sessionKeyStrategy", e.target.value)}
className={inputClass}
>
<option value="fixed">Fixed</option>
<option value="issue">Per issue</option>
<option value="run">Per run</option>
</select>
</Field>
{sessionStrategy === "fixed" && (
<Field label="Session key">
<DraftInput
value={eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))}
onCommit={(v) => mark("adapterConfig", "sessionKey", v || undefined)}
immediate
className={inputClass}
placeholder="paperclip"
/>
</Field>
)}
<SecretField
label="Webhook auth header (optional)"
value={eff("adapterConfig", "webhookAuthHeader", String(config.webhookAuthHeader ?? ""))}
onCommit={(v) => mark("adapterConfig", "webhookAuthHeader", v || undefined)}
placeholder="Bearer <token>"
/>
<SecretField
label="Gateway auth token (x-openclaw-auth)"
value={effectiveGatewayAuthHeader}
onCommit={commitGatewayAuthHeader}
placeholder="OpenClaw gateway token"
/>
</>
)}
</>
);
}

View File

@@ -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,
};

View File

@@ -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<string, UIAdapterModule>(
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
openClawUIAdapter,
openClawGatewayUIAdapter,
processUIAdapter,
httpUIAdapter,

View File

@@ -18,7 +18,6 @@ const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
process: "Process",

View File

@@ -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++);

View File

@@ -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() {
</div>
)}
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
{(adapterType === "http" || adapterType === "openclaw_gateway") && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}

View File

@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
role: "Organizational role. Determines position and capabilities.",
reportsTo: "The agent this one reports to in the org hierarchy.",
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.",
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
model: "Override the default model used by the adapter.",
@@ -53,7 +53,6 @@ export const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
process: "Process",

View File

@@ -25,7 +25,6 @@ const adapterLabels: Record<string, string> = {
codex_local: "Codex",
opencode_local: "OpenCode",
cursor: "Cursor",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",

View File

@@ -315,7 +315,7 @@ export function CompanySettings() {
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
Generate an openclaw agent invite snippet.
Generate an OpenClaw agent invite snippet.
</span>
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
</div>

View File

@@ -10,16 +10,12 @@ import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
type JoinType = "human" | "agent";
const joinAdapterOptions: AgentAdapterType[] = [
"openclaw",
...AGENT_ADAPTER_TYPES.filter((type): type is Exclude<AgentAdapterType, "openclaw"> => type !== "openclaw"),
];
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
process: "Process",

View File

@@ -120,7 +120,6 @@ const adapterLabels: Record<string, string> = {
codex_local: "Codex",
opencode_local: "OpenCode",
cursor: "Cursor",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",