feat: add openclaw_gateway adapter
New adapter type for invoking OpenClaw agents via the gateway protocol. Registers in server, CLI, and UI adapter registries. Adds onboarding wizard support with gateway URL field and e2e smoke test script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ const workspacePaths = [
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/openclaw",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
// Workspace packages that should NOT be bundled — they'll be published
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/server": "workspace:*",
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { 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";
|
||||
|
||||
@@ -32,8 +33,22 @@ const openclawCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printOpenClawStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
[
|
||||
claudeLocalCLIAdapter,
|
||||
codexLocalCLIAdapter,
|
||||
openCodeLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
openclawCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||
|
||||
71
packages/adapters/openclaw-gateway/README.md
Normal file
71
packages/adapters/openclaw-gateway/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# OpenClaw Gateway Adapter
|
||||
|
||||
This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol.
|
||||
|
||||
## Transport
|
||||
|
||||
This adapter always uses WebSocket gateway transport.
|
||||
|
||||
- URL must be `ws://` or `wss://`
|
||||
- Connect flow follows gateway protocol:
|
||||
1. receive `connect.challenge`
|
||||
2. send `req connect` (protocol/client/auth/device payload)
|
||||
3. send `req agent`
|
||||
4. wait for completion via `req agent.wait`
|
||||
5. stream `event agent` frames into Paperclip logs/transcript parsing
|
||||
|
||||
## Auth Modes
|
||||
|
||||
Gateway credentials can be provided in any of these ways:
|
||||
|
||||
- `authToken` / `token` in adapter config
|
||||
- `headers.x-openclaw-token`
|
||||
- `headers.x-openclaw-auth` (legacy)
|
||||
- `password` (shared password mode)
|
||||
|
||||
When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer <token>`.
|
||||
|
||||
## Device Auth
|
||||
|
||||
By default the adapter sends a signed `device` payload in `connect` params.
|
||||
|
||||
- set `disableDeviceAuth=true` to omit device signing
|
||||
- set `devicePrivateKeyPem` to pin a stable signing key
|
||||
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
|
||||
|
||||
## Session Strategy
|
||||
|
||||
The adapter supports the same session routing model as HTTP OpenClaw mode:
|
||||
|
||||
- `sessionKeyStrategy=fixed|issue|run`
|
||||
- `sessionKey` is used when strategy is `fixed`
|
||||
|
||||
Resolved session key is sent as `agent.sessionKey`.
|
||||
|
||||
## Payload Mapping
|
||||
|
||||
The agent request is built as:
|
||||
|
||||
- required fields:
|
||||
- `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix)
|
||||
- `idempotencyKey` (Paperclip `runId`)
|
||||
- `sessionKey` (resolved strategy)
|
||||
- optional additions:
|
||||
- all `payloadTemplate` fields merged in
|
||||
- `agentId` from config if set and not already in template
|
||||
|
||||
## Timeouts
|
||||
|
||||
- `timeoutSec` controls adapter-level request budget
|
||||
- `waitTimeoutMs` controls `agent.wait.timeoutMs`
|
||||
|
||||
If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`.
|
||||
|
||||
## Log Format
|
||||
|
||||
Structured gateway event logs use:
|
||||
|
||||
- `[openclaw-gateway] ...` for lifecycle/system logs
|
||||
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
|
||||
|
||||
UI/CLI parsers consume these lines to render transcript updates.
|
||||
@@ -0,0 +1,324 @@
|
||||
# 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.
|
||||
|
||||
## 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 content is still OpenClaw-HTTP specific
|
||||
`server/src/routes/access.ts` hardcodes onboarding to:
|
||||
- `recommendedAdapterType: "openclaw"`
|
||||
- Required `agentDefaultsPayload.headers.x-openclaw-auth`
|
||||
- HTTP callback URL guidance and `/v1/responses` examples.
|
||||
|
||||
There is no adapter-specific onboarding manifest/text for `openclaw_gateway`.
|
||||
|
||||
### 2) Company settings snippet is OpenClaw HTTP-first
|
||||
`ui/src/pages/CompanySettings.tsx` generates one snippet that:
|
||||
- Assumes OpenClaw HTTP callback setup.
|
||||
- Instructs enabling `gateway.http.endpoints.responses.enabled=true`.
|
||||
- Does not provide a dedicated gateway onboarding path.
|
||||
|
||||
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters
|
||||
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
|
||||
|
||||
### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"`
|
||||
`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific.
|
||||
No equivalent normalization/diagnostics for gateway defaults.
|
||||
|
||||
### 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.
|
||||
|
||||
## 0) Cleanup Before Each Run
|
||||
Use deterministic reset to avoid stale agents/runs/state.
|
||||
|
||||
1. OpenClaw Docker cleanup:
|
||||
```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
|
||||
```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.
|
||||
|
||||
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).
|
||||
|
||||
Note:
|
||||
- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow.
|
||||
|
||||
## 3) Resolve CLA Company ID
|
||||
With board cookie:
|
||||
```bash
|
||||
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies
|
||||
```
|
||||
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:
|
||||
```bash
|
||||
POST /api/companies/$CLA_COMPANY_ID/invites
|
||||
{ "allowedJoinTypes": "agent" }
|
||||
```
|
||||
|
||||
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": "fixed",
|
||||
"sessionKey": "paperclip",
|
||||
"waitTimeoutMs": 120000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Approve join request.
|
||||
4. Claim API key with `claimSecret`.
|
||||
5. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
|
||||
6. Ensure Paperclip skill is installed for OpenClaw runtime.
|
||||
7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
|
||||
|
||||
## 6) E2E Validation Cases
|
||||
|
||||
### Case A: Assigned task execution/closure
|
||||
1. Create issue in CLA assigned to joined OpenClaw agent.
|
||||
2. Poll issue + heartbeat runs until terminal.
|
||||
3. Pass criteria:
|
||||
- At least one run invoked for that agent/issue.
|
||||
- Run status `succeeded`.
|
||||
- Issue reaches `done` (or documented expected terminal state if policy differs).
|
||||
|
||||
### Case B: Message tool to main chat
|
||||
1. Create issue instructing OpenClaw: “send a message to the user’s main chat session in webchat using message tool”.
|
||||
2. Trigger/poll run completion.
|
||||
3. Validate output:
|
||||
- Automated minimum: run log/transcript confirms tool invocation success.
|
||||
- UX-level validation: message visibly appears in main chat UI.
|
||||
|
||||
Current recommendation:
|
||||
- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification.
|
||||
|
||||
### Case C: Fresh session still creates Paperclip task
|
||||
1. Force fresh-session behavior for test:
|
||||
- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key).
|
||||
2. Create issue asking agent to create a new Paperclip task.
|
||||
3. Pass criteria:
|
||||
- New issue appears in CLA with expected title/body.
|
||||
- Agent succeeds without re-onboarding.
|
||||
|
||||
## 7) Observability and Assertions
|
||||
Use these APIs for deterministic assertions:
|
||||
- `GET /api/companies/:companyId/heartbeat-runs?agentId=...`
|
||||
- `GET /api/heartbeat-runs/:runId/events`
|
||||
- `GET /api/heartbeat-runs/:runId/log`
|
||||
- `GET /api/issues/:id`
|
||||
- `GET /api/companies/:companyId/issues?q=...`
|
||||
|
||||
Include explicit timeout budgets per poll loop and hard failure reasons in output.
|
||||
|
||||
## 8) Automation Artifact
|
||||
Implemented smoke harness:
|
||||
- `scripts/smoke/openclaw-gateway-e2e.sh`
|
||||
|
||||
Responsibilities:
|
||||
- OpenClaw docker cleanup/rebuild/start.
|
||||
- Paperclip health/auth preflight.
|
||||
- CLA company resolution.
|
||||
- Old OpenClaw agent cleanup.
|
||||
- Invite/join/approve/claim orchestration.
|
||||
- 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.
|
||||
- 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.
|
||||
52
packages/adapters/openclaw-gateway/package.json
Normal file
52
packages/adapters/openclaw-gateway/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-openclaw-gateway",
|
||||
"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",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
23
packages/adapters/openclaw-gateway/src/cli/format-event.ts
Normal file
23
packages/adapters/openclaw-gateway/src/cli/format-event.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
if (!debug) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("[openclaw-gateway:event]")) {
|
||||
console.log(pc.cyan(line));
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("[openclaw-gateway]")) {
|
||||
console.log(pc.blue(line));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
1
packages/adapters/openclaw-gateway/src/cli/index.ts
Normal file
1
packages/adapters/openclaw-gateway/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { printOpenClawGatewayStreamEvent } from "./format-event.js";
|
||||
41
packages/adapters/openclaw-gateway/src/index.ts
Normal file
41
packages/adapters/openclaw-gateway/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const type = "openclaw_gateway";
|
||||
export const label = "OpenClaw Gateway";
|
||||
|
||||
export const models: { id: string; label: string }[] = [];
|
||||
|
||||
export const agentConfigurationDoc = `# openclaw_gateway agent configuration
|
||||
|
||||
Adapter: openclaw_gateway
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol.
|
||||
- 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).
|
||||
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
|
||||
|
||||
Core fields:
|
||||
- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://)
|
||||
- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth
|
||||
- authToken (string, optional): shared gateway token override
|
||||
- password (string, optional): gateway shared password, if configured
|
||||
|
||||
Gateway connect identity fields:
|
||||
- clientId (string, optional): gateway client id (default gateway-client)
|
||||
- clientMode (string, optional): gateway client mode (default backend)
|
||||
- clientVersion (string, optional): client version string
|
||||
- role (string, optional): gateway role (default operator)
|
||||
- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"])
|
||||
- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false)
|
||||
|
||||
Request behavior fields:
|
||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
|
||||
|
||||
Session routing fields:
|
||||
- sessionKeyStrategy (string, optional): fixed (default), issue, or run
|
||||
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
|
||||
`;
|
||||
1060
packages/adapters/openclaw-gateway/src/server/execute.ts
Normal file
1060
packages/adapters/openclaw-gateway/src/server/execute.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
packages/adapters/openclaw-gateway/src/server/index.ts
Normal file
2
packages/adapters/openclaw-gateway/src/server/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
317
packages/adapters/openclaw-gateway/src/server/test.ts
Normal file
317
packages/adapters/openclaw-gateway/src/server/test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
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 nonEmpty(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||
}
|
||||
|
||||
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 toStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
||||
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function tokenFromAuthHeader(rawHeader: string | null): string | null {
|
||||
if (!rawHeader) return null;
|
||||
const trimmed = rawHeader.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = trimmed.match(/^bearer\s+(.+)$/i);
|
||||
return match ? nonEmpty(match[1]) : trimmed;
|
||||
}
|
||||
|
||||
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
|
||||
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
|
||||
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
|
||||
|
||||
const authHeader =
|
||||
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
||||
headerMapGetIgnoreCase(headers, "authorization");
|
||||
return tokenFromAuthHeader(authHeader);
|
||||
}
|
||||
|
||||
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 rawDataToString(data: unknown): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.concat(
|
||||
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
|
||||
).toString("utf8");
|
||||
}
|
||||
return String(data ?? "");
|
||||
}
|
||||
|
||||
async function probeGateway(input: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
authToken: string | null;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
timeoutMs: number;
|
||||
}): Promise<"ok" | "challenge_only" | "failed"> {
|
||||
return await new Promise((resolve) => {
|
||||
const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 });
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve("failed");
|
||||
}, input.timeoutMs);
|
||||
|
||||
let completed = false;
|
||||
|
||||
const finish = (status: "ok" | "challenge_only" | "failed") => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(status);
|
||||
};
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawDataToString(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const event = asRecord(parsed);
|
||||
if (event?.type === "event" && event.event === "connect.challenge") {
|
||||
const nonce = nonEmpty(asRecord(event.payload)?.nonce);
|
||||
if (!nonce) {
|
||||
finish("failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const connectId = randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
version: "paperclip-probe",
|
||||
platform: process.platform,
|
||||
mode: "probe",
|
||||
},
|
||||
role: input.role,
|
||||
scopes: input.scopes,
|
||||
...(input.authToken
|
||||
? {
|
||||
auth: {
|
||||
token: input.authToken,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.type === "res") {
|
||||
if (event.ok === true) {
|
||||
finish("ok");
|
||||
} else {
|
||||
finish("challenge_only");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
finish("failed");
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (!completed) finish("failed");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const urlValue = asString(config.url, "").trim();
|
||||
|
||||
if (!urlValue) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_url_missing",
|
||||
level: "error",
|
||||
message: "OpenClaw gateway adapter requires a WebSocket URL.",
|
||||
hint: "Set adapterConfig.url to ws://host:port (or wss://).",
|
||||
});
|
||||
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_gateway_url_invalid",
|
||||
level: "error",
|
||||
message: `Invalid URL: ${urlValue}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (url && url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_url_protocol_invalid",
|
||||
level: "error",
|
||||
message: `Unsupported URL protocol: ${url.protocol}`,
|
||||
hint: "Use ws:// or wss://.",
|
||||
});
|
||||
}
|
||||
|
||||
if (url) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_url_valid",
|
||||
level: "info",
|
||||
message: `Configured gateway URL: ${url.toString()}`,
|
||||
});
|
||||
|
||||
if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_plaintext_remote_ws",
|
||||
level: "warn",
|
||||
message: "Gateway URL uses plaintext ws:// on a non-loopback host.",
|
||||
hint: "Prefer wss:// for remote gateways.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const headers = toStringRecord(config.headers);
|
||||
const authToken = resolveAuthToken(config, headers);
|
||||
const password = nonEmpty(config.password);
|
||||
const role = nonEmpty(config.role) ?? "operator";
|
||||
const scopes = toStringArray(config.scopes);
|
||||
|
||||
if (authToken || password) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_auth_present",
|
||||
level: "info",
|
||||
message: "Gateway credentials are configured.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_auth_missing",
|
||||
level: "warn",
|
||||
message: "No gateway credentials detected in adapter config.",
|
||||
hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.",
|
||||
});
|
||||
}
|
||||
|
||||
if (url && (url.protocol === "ws:" || url.protocol === "wss:")) {
|
||||
try {
|
||||
const probeResult = await probeGateway({
|
||||
url: url.toString(),
|
||||
headers,
|
||||
authToken,
|
||||
role,
|
||||
scopes: scopes.length > 0 ? scopes : ["operator.admin"],
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
|
||||
if (probeResult === "ok") {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_probe_ok",
|
||||
level: "info",
|
||||
message: "Gateway connect probe succeeded.",
|
||||
});
|
||||
} else if (probeResult === "challenge_only") {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_probe_challenge_only",
|
||||
level: "warn",
|
||||
message: "Gateway challenge was received, but connect probe was rejected.",
|
||||
hint: "Check gateway credentials, scopes, role, and device-auth requirements.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_probe_failed",
|
||||
level: "warn",
|
||||
message: "Gateway probe failed.",
|
||||
hint: "Verify network reachability and gateway URL from the Paperclip server host.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_probe_error",
|
||||
level: "warn",
|
||||
message: err instanceof Error ? err.message : "Gateway probe failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
16
packages/adapters/openclaw-gateway/src/shared/stream.ts
Normal file
16
packages/adapters/openclaw-gateway/src/shared/stream.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function normalizeOpenClawGatewayStreamLine(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 };
|
||||
}
|
||||
13
packages/adapters/openclaw-gateway/src/ui/build-config.ts
Normal file
13
packages/adapters/openclaw-gateway/src/ui/build-config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.url) ac.url = v.url;
|
||||
ac.timeoutSec = 120;
|
||||
ac.waitTimeoutMs = 120000;
|
||||
ac.sessionKeyStrategy = "fixed";
|
||||
ac.sessionKey = "paperclip";
|
||||
ac.role = "operator";
|
||||
ac.scopes = ["operator.admin"];
|
||||
return ac;
|
||||
}
|
||||
2
packages/adapters/openclaw-gateway/src/ui/index.ts
Normal file
2
packages/adapters/openclaw-gateway/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js";
|
||||
export { buildOpenClawGatewayConfig } from "./build-config.js";
|
||||
75
packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts
Normal file
75
packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import { normalizeOpenClawGatewayStreamLine } 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): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s);
|
||||
if (!match) return [{ kind: "stdout", ts, text: line }];
|
||||
|
||||
const stream = asString(match[2]).toLowerCase();
|
||||
const data = asRecord(safeJsonParse(asString(match[3]).trim()));
|
||||
|
||||
if (stream === "assistant") {
|
||||
const delta = asString(data?.delta);
|
||||
if (delta.length > 0) {
|
||||
return [{ kind: "assistant", ts, text: delta, delta: true }];
|
||||
}
|
||||
|
||||
const text = asString(data?.text);
|
||||
if (text.length > 0) {
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (stream === "error") {
|
||||
const message = asString(data?.error) || asString(data?.message);
|
||||
return message ? [{ kind: "stderr", ts, text: message }] : [];
|
||||
}
|
||||
|
||||
if (stream === "lifecycle") {
|
||||
const phase = asString(data?.phase).toLowerCase();
|
||||
const message = asString(data?.error) || asString(data?.message);
|
||||
if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) {
|
||||
return [{ kind: "stderr", ts, text: message }];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const normalized = normalizeOpenClawGatewayStreamLine(line);
|
||||
if (normalized.stream === "stderr") {
|
||||
return [{ kind: "stderr", ts, text: normalized.line }];
|
||||
}
|
||||
|
||||
const trimmed = normalized.line.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (trimmed.startsWith("[openclaw-gateway:event]")) {
|
||||
return parseAgentEventLine(trimmed, ts);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[openclaw-gateway]")) {
|
||||
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||
}
|
||||
8
packages/adapters/openclaw-gateway/tsconfig.json
Normal file
8
packages/adapters/openclaw-gateway/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"opencode_local",
|
||||
"cursor",
|
||||
"openclaw",
|
||||
"openclaw_gateway",
|
||||
] as const;
|
||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||
|
||||
|
||||
976
pnpm-lock.yaml
generated
976
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
752
scripts/smoke/openclaw-gateway-e2e.sh
Executable file
752
scripts/smoke/openclaw-gateway-e2e.sh
Executable file
@@ -0,0 +1,752 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() {
|
||||
echo "[openclaw-gateway-e2e] $*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo "[openclaw-gateway-e2e] WARN: $*" >&2
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[openclaw-gateway-e2e] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd"
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd docker
|
||||
require_cmd node
|
||||
require_cmd shasum
|
||||
|
||||
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://127.0.0.1:3100}"
|
||||
API_BASE="${PAPERCLIP_API_URL%/}/api"
|
||||
|
||||
COMPANY_SELECTOR="${COMPANY_SELECTOR:-CLA}"
|
||||
OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Gateway Smoke Agent}"
|
||||
OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}"
|
||||
OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}"
|
||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}"
|
||||
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${OPENCLAW_TMP_DIR}/openclaw-paperclip-smoke}"
|
||||
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${OPENCLAW_CONFIG_DIR}/workspace}"
|
||||
OPENCLAW_CONTAINER_NAME="${OPENCLAW_CONTAINER_NAME:-openclaw-docker-openclaw-gateway-1}"
|
||||
OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}"
|
||||
OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}"
|
||||
OPENCLAW_RESET_DOCKER="${OPENCLAW_RESET_DOCKER:-1}"
|
||||
OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}"
|
||||
OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-60}"
|
||||
OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}"
|
||||
|
||||
PAPERCLIP_API_URL_FOR_OPENCLAW="${PAPERCLIP_API_URL_FOR_OPENCLAW:-http://host.docker.internal:3100}"
|
||||
CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}"
|
||||
RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-300}"
|
||||
STRICT_CASES="${STRICT_CASES:-1}"
|
||||
AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}"
|
||||
|
||||
AUTH_HEADERS=()
|
||||
if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then
|
||||
AUTH_HEADERS+=( -H "Authorization: ${PAPERCLIP_AUTH_HEADER}" )
|
||||
fi
|
||||
if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then
|
||||
AUTH_HEADERS+=( -H "Cookie: ${PAPERCLIP_COOKIE}" )
|
||||
PAPERCLIP_BROWSER_ORIGIN="${PAPERCLIP_BROWSER_ORIGIN:-${PAPERCLIP_API_URL%/}}"
|
||||
AUTH_HEADERS+=( -H "Origin: ${PAPERCLIP_BROWSER_ORIGIN}" -H "Referer: ${PAPERCLIP_BROWSER_ORIGIN}/" )
|
||||
fi
|
||||
|
||||
RESPONSE_CODE=""
|
||||
RESPONSE_BODY=""
|
||||
COMPANY_ID=""
|
||||
AGENT_ID=""
|
||||
AGENT_API_KEY=""
|
||||
JOIN_REQUEST_ID=""
|
||||
INVITE_ID=""
|
||||
RUN_ID=""
|
||||
|
||||
CASE_A_ISSUE_ID=""
|
||||
CASE_B_ISSUE_ID=""
|
||||
CASE_C_ISSUE_ID=""
|
||||
CASE_C_CREATED_ISSUE_ID=""
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local data="${3-}"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
local url
|
||||
if [[ "$path" == http://* || "$path" == https://* ]]; then
|
||||
url="$path"
|
||||
elif [[ "$path" == /api/* ]]; then
|
||||
url="${PAPERCLIP_API_URL%/}${path}"
|
||||
else
|
||||
url="${API_BASE}${path}"
|
||||
fi
|
||||
|
||||
if [[ -n "$data" ]]; then
|
||||
if (( ${#AUTH_HEADERS[@]} > 0 )); then
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||
else
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||
fi
|
||||
else
|
||||
if (( ${#AUTH_HEADERS[@]} > 0 )); then
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")"
|
||||
else
|
||||
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url")"
|
||||
fi
|
||||
fi
|
||||
|
||||
RESPONSE_BODY="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
if [[ "$RESPONSE_CODE" != "$expected" ]]; then
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
fail "expected HTTP ${expected}, got ${RESPONSE_CODE}"
|
||||
fi
|
||||
}
|
||||
|
||||
require_board_auth() {
|
||||
if [[ ${#AUTH_HEADERS[@]} -eq 0 ]]; then
|
||||
fail "board auth required. Set PAPERCLIP_COOKIE or PAPERCLIP_AUTH_HEADER."
|
||||
fi
|
||||
api_request "GET" "/companies"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
fail "board auth invalid for /api/companies (HTTP ${RESPONSE_CODE})"
|
||||
fi
|
||||
}
|
||||
|
||||
maybe_cleanup_openclaw_docker() {
|
||||
if [[ "$OPENCLAW_RESET_DOCKER" != "1" ]]; then
|
||||
log "OPENCLAW_RESET_DOCKER=${OPENCLAW_RESET_DOCKER}; skipping docker cleanup"
|
||||
return
|
||||
fi
|
||||
|
||||
log "cleaning OpenClaw docker state"
|
||||
if [[ -d "$OPENCLAW_DOCKER_DIR" ]]; then
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true
|
||||
fi
|
||||
if docker ps -a --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then
|
||||
docker rm -f "$OPENCLAW_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
docker image rm "$OPENCLAW_IMAGE" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
start_openclaw_docker() {
|
||||
log "starting clean OpenClaw docker"
|
||||
OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \
|
||||
OPENCLAW_RESET_STATE="$OPENCLAW_RESET_STATE" OPENCLAW_BUILD="$OPENCLAW_BUILD" OPENCLAW_WAIT_SECONDS="$OPENCLAW_WAIT_SECONDS" \
|
||||
./scripts/smoke/openclaw-docker-ui.sh
|
||||
}
|
||||
|
||||
wait_http_ready() {
|
||||
local url="$1"
|
||||
local timeout_sec="$2"
|
||||
local started_at now code
|
||||
started_at="$(date +%s)"
|
||||
while true; do
|
||||
code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
return 0
|
||||
fi
|
||||
now="$(date +%s)"
|
||||
if (( now - started_at >= timeout_sec )); then
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
detect_openclaw_container() {
|
||||
if docker ps --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then
|
||||
echo "$OPENCLAW_CONTAINER_NAME"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local detected
|
||||
detected="$(docker ps --format '{{.Names}}' | grep 'openclaw-gateway' | head -n1 || true)"
|
||||
if [[ -n "$detected" ]]; then
|
||||
echo "$detected"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_gateway_token() {
|
||||
if [[ -n "$OPENCLAW_GATEWAY_TOKEN" ]]; then
|
||||
echo "$OPENCLAW_GATEWAY_TOKEN"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local config_path
|
||||
config_path="${OPENCLAW_CONFIG_DIR%/}/openclaw.json"
|
||||
if [[ -f "$config_path" ]]; then
|
||||
local token
|
||||
token="$(jq -r '.gateway.auth.token // empty' "$config_path")"
|
||||
if [[ -n "$token" ]]; then
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local container
|
||||
container="$(detect_openclaw_container || true)"
|
||||
if [[ -n "$container" ]]; then
|
||||
local token_from_container
|
||||
token_from_container="$(docker exec "$container" sh -lc "node -e 'const fs=require(\"fs\");const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\"));process.stdout.write(c.gateway?.auth?.token||\"\");'" 2>/dev/null || true)"
|
||||
if [[ -n "$token_from_container" ]]; then
|
||||
echo "$token_from_container"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
hash_prefix() {
|
||||
local value="$1"
|
||||
printf "%s" "$value" | shasum -a 256 | awk '{print $1}' | cut -c1-12
|
||||
}
|
||||
|
||||
probe_gateway_ws() {
|
||||
local url="$1"
|
||||
local token="$2"
|
||||
|
||||
node - "$url" "$token" <<'NODE'
|
||||
const WebSocket = require("ws");
|
||||
const url = process.argv[2];
|
||||
const token = process.argv[3];
|
||||
|
||||
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("gateway probe timed out");
|
||||
process.exit(2);
|
||||
}, 8000);
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
try {
|
||||
const message = JSON.parse(String(raw));
|
||||
if (message?.type === "event" && message?.event === "connect.challenge") {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(err?.message || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
resolve_company_id() {
|
||||
api_request "GET" "/companies"
|
||||
assert_status "200"
|
||||
|
||||
local selector
|
||||
selector="$(printf "%s" "$COMPANY_SELECTOR" | tr '[:lower:]' '[:upper:]')"
|
||||
|
||||
COMPANY_ID="$(jq -r --arg sel "$selector" '
|
||||
map(select(
|
||||
((.id // "") | ascii_upcase) == $sel or
|
||||
((.name // "") | ascii_upcase) == $sel or
|
||||
((.issuePrefix // "") | ascii_upcase) == $sel
|
||||
))
|
||||
| .[0].id // empty
|
||||
' <<<"$RESPONSE_BODY")"
|
||||
|
||||
if [[ -z "$COMPANY_ID" ]]; then
|
||||
local available
|
||||
available="$(jq -r '.[] | "- id=\(.id) issuePrefix=\(.issuePrefix // "") name=\(.name // "")"' <<<"$RESPONSE_BODY")"
|
||||
echo "$available" >&2
|
||||
fail "could not find company for selector '${COMPANY_SELECTOR}'"
|
||||
fi
|
||||
|
||||
log "resolved company ${COMPANY_ID} from selector ${COMPANY_SELECTOR}"
|
||||
}
|
||||
|
||||
cleanup_openclaw_agents() {
|
||||
api_request "GET" "/companies/${COMPANY_ID}/agents"
|
||||
assert_status "200"
|
||||
|
||||
local ids
|
||||
ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$ids" ]]; then
|
||||
log "no prior OpenClaw agents to cleanup"
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r id; do
|
||||
[[ -n "$id" ]] || continue
|
||||
log "terminating prior OpenClaw agent ${id}"
|
||||
api_request "POST" "/agents/${id}/terminate" "{}"
|
||||
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then
|
||||
warn "terminate ${id} returned HTTP ${RESPONSE_CODE}"
|
||||
fi
|
||||
|
||||
api_request "DELETE" "/agents/${id}"
|
||||
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then
|
||||
warn "delete ${id} returned HTTP ${RESPONSE_CODE}"
|
||||
fi
|
||||
done <<<"$ids"
|
||||
}
|
||||
|
||||
cleanup_pending_join_requests() {
|
||||
api_request "GET" "/companies/${COMPANY_ID}/join-requests?status=pending_approval"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
warn "join-request cleanup skipped (HTTP ${RESPONSE_CODE})"
|
||||
return
|
||||
fi
|
||||
|
||||
local ids
|
||||
ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$ids" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r request_id; do
|
||||
[[ -n "$request_id" ]] || continue
|
||||
log "rejecting stale pending join request ${request_id}"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${request_id}/reject" "{}"
|
||||
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "409" ]]; then
|
||||
warn "reject ${request_id} returned HTTP ${RESPONSE_CODE}"
|
||||
fi
|
||||
done <<<"$ids"
|
||||
}
|
||||
|
||||
create_and_approve_gateway_join() {
|
||||
local gateway_token="$1"
|
||||
|
||||
local invite_payload
|
||||
invite_payload="$(jq -nc '{allowedJoinTypes:"agent"}')"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/invites" "$invite_payload"
|
||||
assert_status "201"
|
||||
|
||||
local invite_token
|
||||
invite_token="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")"
|
||||
INVITE_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$invite_token" && -n "$INVITE_ID" ]] || fail "invite creation missing token/id"
|
||||
|
||||
local join_payload
|
||||
join_payload="$(jq -nc \
|
||||
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||
--arg url "$OPENCLAW_GATEWAY_URL" \
|
||||
--arg token "$gateway_token" \
|
||||
--arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_OPENCLAW" \
|
||||
'{
|
||||
requestType: "agent",
|
||||
agentName: $name,
|
||||
adapterType: "openclaw_gateway",
|
||||
capabilities: "OpenClaw gateway smoke harness",
|
||||
agentDefaultsPayload: {
|
||||
url: $url,
|
||||
headers: { "x-openclaw-token": $token },
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
disableDeviceAuth: true,
|
||||
sessionKeyStrategy: "fixed",
|
||||
sessionKey: "paperclip",
|
||||
waitTimeoutMs: 120000,
|
||||
paperclipApiUrl: $paperclipApiUrl
|
||||
}
|
||||
}')"
|
||||
|
||||
api_request "POST" "/invites/${invite_token}/accept" "$join_payload"
|
||||
assert_status "202"
|
||||
|
||||
JOIN_REQUEST_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
local claim_secret
|
||||
claim_secret="$(jq -r '.claimSecret // empty' <<<"$RESPONSE_BODY")"
|
||||
local claim_path
|
||||
claim_path="$(jq -r '.claimApiKeyPath // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$JOIN_REQUEST_ID" && -n "$claim_secret" && -n "$claim_path" ]] || fail "join accept missing claim metadata"
|
||||
|
||||
log "approving join request ${JOIN_REQUEST_ID}"
|
||||
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}"
|
||||
assert_status "200"
|
||||
|
||||
AGENT_ID="$(jq -r '.createdAgentId // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$AGENT_ID" ]] || fail "join approval missing createdAgentId"
|
||||
|
||||
log "claiming one-time agent API key"
|
||||
local claim_payload
|
||||
claim_payload="$(jq -nc --arg secret "$claim_secret" '{claimSecret:$secret}')"
|
||||
api_request "POST" "$claim_path" "$claim_payload"
|
||||
assert_status "201"
|
||||
|
||||
AGENT_API_KEY="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$AGENT_API_KEY" ]] || fail "claim response missing token"
|
||||
|
||||
persist_claimed_key_artifacts "$RESPONSE_BODY"
|
||||
inject_agent_api_key_payload_template
|
||||
}
|
||||
|
||||
persist_claimed_key_artifacts() {
|
||||
local claim_json="$1"
|
||||
local workspace_dir="${OPENCLAW_CONFIG_DIR%/}/workspace"
|
||||
local skill_dir="${OPENCLAW_CONFIG_DIR%/}/skills/paperclip"
|
||||
local claimed_file="${workspace_dir}/paperclip-claimed-api-key.json"
|
||||
|
||||
mkdir -p "$workspace_dir" "$skill_dir"
|
||||
printf "%s\n" "$claim_json" > "$claimed_file"
|
||||
chmod 600 "$claimed_file"
|
||||
|
||||
if [[ "$AUTO_INSTALL_SKILL" == "1" ]]; then
|
||||
api_request "GET" "/skills/paperclip"
|
||||
assert_status "200"
|
||||
{
|
||||
echo "PAPERCLIP_API_URL=${PAPERCLIP_API_URL_FOR_OPENCLAW}"
|
||||
echo
|
||||
printf "%s\n" "$RESPONSE_BODY"
|
||||
} > "${skill_dir}/SKILL.md"
|
||||
chmod 600 "${skill_dir}/SKILL.md"
|
||||
fi
|
||||
|
||||
log "wrote claimed key to ${claimed_file}"
|
||||
}
|
||||
|
||||
inject_agent_api_key_payload_template() {
|
||||
[[ -n "$AGENT_ID" ]] || fail "inject_agent_api_key_payload_template requires AGENT_ID"
|
||||
[[ -n "$AGENT_API_KEY" ]] || fail "inject_agent_api_key_payload_template requires AGENT_API_KEY"
|
||||
|
||||
api_request "GET" "/agents/${AGENT_ID}"
|
||||
assert_status "200"
|
||||
|
||||
local patch_payload
|
||||
patch_payload="$(jq -c --arg token "$AGENT_API_KEY" '
|
||||
{adapterConfig: ((.adapterConfig // {}) + {
|
||||
payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + {
|
||||
message: ("Set PAPERCLIP_API_KEY=" + $token + " in your run context before running Paperclip heartbeat steps.")
|
||||
})
|
||||
})}
|
||||
' <<<"$RESPONSE_BODY")"
|
||||
|
||||
api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload"
|
||||
assert_status "200"
|
||||
}
|
||||
|
||||
trigger_wakeup() {
|
||||
local reason="$1"
|
||||
local issue_id="${2:-}"
|
||||
|
||||
local payload
|
||||
if [[ -n "$issue_id" ]]; then
|
||||
payload="$(jq -nc --arg issueId "$issue_id" --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason,payload:{issueId:$issueId,taskId:$issueId}}')"
|
||||
else
|
||||
payload="$(jq -nc --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason}')"
|
||||
fi
|
||||
|
||||
api_request "POST" "/agents/${AGENT_ID}/wakeup" "$payload"
|
||||
if [[ "$RESPONSE_CODE" != "202" ]]; then
|
||||
echo "$RESPONSE_BODY" >&2
|
||||
fail "wakeup failed (HTTP ${RESPONSE_CODE})"
|
||||
fi
|
||||
|
||||
RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
if [[ -z "$RUN_ID" ]]; then
|
||||
warn "wakeup response did not include run id; body: ${RESPONSE_BODY}"
|
||||
fi
|
||||
}
|
||||
|
||||
get_run_status() {
|
||||
local run_id="$1"
|
||||
api_request "GET" "/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=200"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
jq -r --arg runId "$run_id" '.[] | select(.id == $runId) | .status' <<<"$RESPONSE_BODY" | head -n1
|
||||
}
|
||||
|
||||
wait_for_run_terminal() {
|
||||
local run_id="$1"
|
||||
local timeout_sec="$2"
|
||||
local started now status
|
||||
|
||||
[[ -n "$run_id" ]] || fail "wait_for_run_terminal requires run id"
|
||||
started="$(date +%s)"
|
||||
|
||||
while true; do
|
||||
status="$(get_run_status "$run_id")"
|
||||
if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then
|
||||
echo "$status"
|
||||
return 0
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
if (( now - started >= timeout_sec )); then
|
||||
echo "timeout"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
get_issue_status() {
|
||||
local issue_id="$1"
|
||||
api_request "GET" "/issues/${issue_id}"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
jq -r '.status // empty' <<<"$RESPONSE_BODY"
|
||||
}
|
||||
|
||||
wait_for_issue_terminal() {
|
||||
local issue_id="$1"
|
||||
local timeout_sec="$2"
|
||||
local started now status
|
||||
started="$(date +%s)"
|
||||
|
||||
while true; do
|
||||
status="$(get_issue_status "$issue_id")"
|
||||
if [[ "$status" == "done" || "$status" == "blocked" || "$status" == "cancelled" ]]; then
|
||||
echo "$status"
|
||||
return 0
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
if (( now - started >= timeout_sec )); then
|
||||
echo "timeout"
|
||||
return 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
issue_comments_contain() {
|
||||
local issue_id="$1"
|
||||
local marker="$2"
|
||||
api_request "GET" "/issues/${issue_id}/comments"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo "false"
|
||||
return 0
|
||||
fi
|
||||
jq -r --arg marker "$marker" '[.[] | (.body // "") | contains($marker)] | any' <<<"$RESPONSE_BODY"
|
||||
}
|
||||
|
||||
create_issue_for_case() {
|
||||
local title="$1"
|
||||
local description="$2"
|
||||
local priority="${3:-high}"
|
||||
|
||||
local payload
|
||||
payload="$(jq -nc \
|
||||
--arg title "$title" \
|
||||
--arg description "$description" \
|
||||
--arg assignee "$AGENT_ID" \
|
||||
--arg priority "$priority" \
|
||||
'{title:$title,description:$description,status:"todo",priority:$priority,assigneeAgentId:$assignee}')"
|
||||
|
||||
api_request "POST" "/companies/${COMPANY_ID}/issues" "$payload"
|
||||
assert_status "201"
|
||||
|
||||
local issue_id issue_identifier
|
||||
issue_id="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||
issue_identifier="$(jq -r '.identifier // empty' <<<"$RESPONSE_BODY")"
|
||||
[[ -n "$issue_id" ]] || fail "issue create missing id"
|
||||
|
||||
echo "${issue_id}|${issue_identifier}"
|
||||
}
|
||||
|
||||
patch_agent_session_strategy_run() {
|
||||
api_request "GET" "/agents/${AGENT_ID}"
|
||||
assert_status "200"
|
||||
|
||||
local patch_payload
|
||||
patch_payload="$(jq -c '{adapterConfig: ((.adapterConfig // {}) + {sessionKeyStrategy:"run"})}' <<<"$RESPONSE_BODY")"
|
||||
api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload"
|
||||
assert_status "200"
|
||||
}
|
||||
|
||||
find_issue_by_query() {
|
||||
local query="$1"
|
||||
local encoded_query
|
||||
encoded_query="$(jq -rn --arg q "$query" '$q|@uri')"
|
||||
api_request "GET" "/companies/${COMPANY_ID}/issues?q=${encoded_query}"
|
||||
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
jq -r '.[] | .id' <<<"$RESPONSE_BODY" | head -n1
|
||||
}
|
||||
|
||||
run_case_a() {
|
||||
local marker="OPENCLAW_CASE_A_OK_$(date +%s)"
|
||||
local description
|
||||
description="Case A validation.\n\n1) Read this issue.\n2) Post a comment containing exactly: ${marker}\n3) Mark this issue done."
|
||||
|
||||
local created
|
||||
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case A" "$description")"
|
||||
CASE_A_ISSUE_ID="${created%%|*}"
|
||||
local case_identifier="${created##*|}"
|
||||
|
||||
log "case A issue ${CASE_A_ISSUE_ID} (${case_identifier})"
|
||||
trigger_wakeup "openclaw_gateway_smoke_case_a" "$CASE_A_ISSUE_ID"
|
||||
|
||||
local run_status issue_status marker_found
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||
log "case A run ${RUN_ID} status=${run_status}"
|
||||
else
|
||||
run_status="unknown"
|
||||
fi
|
||||
|
||||
issue_status="$(wait_for_issue_terminal "$CASE_A_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||
marker_found="$(issue_comments_contain "$CASE_A_ISSUE_ID" "$marker")"
|
||||
log "case A issue_status=${issue_status} marker_found=${marker_found}"
|
||||
|
||||
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||
[[ "$run_status" == "succeeded" ]] || fail "case A run did not succeed"
|
||||
[[ "$issue_status" == "done" ]] || fail "case A issue did not reach done"
|
||||
[[ "$marker_found" == "true" ]] || fail "case A marker not found in comments"
|
||||
fi
|
||||
}
|
||||
|
||||
run_case_b() {
|
||||
local marker="OPENCLAW_CASE_B_OK_$(date +%s)"
|
||||
local message_text="${marker}"
|
||||
local description
|
||||
description="Case B validation.\n\nUse the message tool to send this exact text to the user's main chat session in webchat:\n${message_text}\n\nAfter sending, post a Paperclip issue comment containing exactly: ${marker}\nThen mark this issue done."
|
||||
|
||||
local created
|
||||
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case B" "$description")"
|
||||
CASE_B_ISSUE_ID="${created%%|*}"
|
||||
local case_identifier="${created##*|}"
|
||||
|
||||
log "case B issue ${CASE_B_ISSUE_ID} (${case_identifier})"
|
||||
trigger_wakeup "openclaw_gateway_smoke_case_b" "$CASE_B_ISSUE_ID"
|
||||
|
||||
local run_status issue_status marker_found
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||
log "case B run ${RUN_ID} status=${run_status}"
|
||||
else
|
||||
run_status="unknown"
|
||||
fi
|
||||
|
||||
issue_status="$(wait_for_issue_terminal "$CASE_B_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||
marker_found="$(issue_comments_contain "$CASE_B_ISSUE_ID" "$marker")"
|
||||
log "case B issue_status=${issue_status} marker_found=${marker_found}"
|
||||
|
||||
warn "case B requires manual UX confirmation in OpenClaw main webchat: message '${message_text}' appears in main chat"
|
||||
|
||||
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||
[[ "$run_status" == "succeeded" ]] || fail "case B run did not succeed"
|
||||
[[ "$issue_status" == "done" ]] || fail "case B issue did not reach done"
|
||||
[[ "$marker_found" == "true" ]] || fail "case B marker not found in comments"
|
||||
fi
|
||||
}
|
||||
|
||||
run_case_c() {
|
||||
patch_agent_session_strategy_run
|
||||
|
||||
local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)"
|
||||
local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)"
|
||||
local description
|
||||
description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on this issue containing exactly: ${ack_marker}\nThen mark this issue done."
|
||||
|
||||
local created
|
||||
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")"
|
||||
CASE_C_ISSUE_ID="${created%%|*}"
|
||||
local case_identifier="${created##*|}"
|
||||
|
||||
log "case C issue ${CASE_C_ISSUE_ID} (${case_identifier})"
|
||||
trigger_wakeup "openclaw_gateway_smoke_case_c" "$CASE_C_ISSUE_ID"
|
||||
|
||||
local run_status issue_status marker_found created_issue
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||
log "case C run ${RUN_ID} status=${run_status}"
|
||||
else
|
||||
run_status="unknown"
|
||||
fi
|
||||
|
||||
issue_status="$(wait_for_issue_terminal "$CASE_C_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||
marker_found="$(issue_comments_contain "$CASE_C_ISSUE_ID" "$ack_marker")"
|
||||
created_issue="$(find_issue_by_query "$marker")"
|
||||
if [[ "$created_issue" == "$CASE_C_ISSUE_ID" ]]; then
|
||||
created_issue=""
|
||||
fi
|
||||
CASE_C_CREATED_ISSUE_ID="$created_issue"
|
||||
log "case C issue_status=${issue_status} marker_found=${marker_found} created_issue_id=${CASE_C_CREATED_ISSUE_ID:-none}"
|
||||
|
||||
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||
[[ "$run_status" == "succeeded" ]] || fail "case C run did not succeed"
|
||||
[[ "$issue_status" == "done" ]] || fail "case C issue did not reach done"
|
||||
[[ "$marker_found" == "true" ]] || fail "case C ack marker not found in comments"
|
||||
[[ -n "$CASE_C_CREATED_ISSUE_ID" ]] || fail "case C did not create the expected new issue"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
log "starting OpenClaw gateway E2E smoke"
|
||||
|
||||
wait_http_ready "${PAPERCLIP_API_URL%/}/api/health" 15 || fail "Paperclip API health endpoint not reachable"
|
||||
api_request "GET" "/health"
|
||||
assert_status "200"
|
||||
log "paperclip health deploymentMode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")"
|
||||
|
||||
require_board_auth
|
||||
resolve_company_id
|
||||
cleanup_openclaw_agents
|
||||
cleanup_pending_join_requests
|
||||
|
||||
maybe_cleanup_openclaw_docker
|
||||
start_openclaw_docker
|
||||
wait_http_ready "http://127.0.0.1:18789/" "$OPENCLAW_WAIT_SECONDS" || fail "OpenClaw HTTP health not reachable"
|
||||
|
||||
local gateway_token
|
||||
gateway_token="$(detect_gateway_token || true)"
|
||||
[[ -n "$gateway_token" ]] || fail "could not resolve OpenClaw gateway token"
|
||||
log "resolved gateway token (sha256 prefix $(hash_prefix "$gateway_token"))"
|
||||
|
||||
log "probing gateway websocket challenge at ${OPENCLAW_GATEWAY_URL}"
|
||||
probe_gateway_ws "$OPENCLAW_GATEWAY_URL" "$gateway_token"
|
||||
|
||||
create_and_approve_gateway_join "$gateway_token"
|
||||
log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}"
|
||||
|
||||
trigger_wakeup "openclaw_gateway_smoke_connectivity"
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
local connect_status
|
||||
connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||
[[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run failed: ${connect_status}"
|
||||
log "connectivity wake run ${RUN_ID} succeeded"
|
||||
fi
|
||||
|
||||
run_case_a
|
||||
run_case_b
|
||||
run_case_c
|
||||
|
||||
log "success"
|
||||
log "companyId=${COMPANY_ID}"
|
||||
log "agentId=${AGENT_ID}"
|
||||
log "inviteId=${INVITE_ID}"
|
||||
log "joinRequestId=${JOIN_REQUEST_ID}"
|
||||
log "caseA_issueId=${CASE_A_ISSUE_ID}"
|
||||
log "caseB_issueId=${CASE_B_ISSUE_ID}"
|
||||
log "caseC_issueId=${CASE_C_ISSUE_ID}"
|
||||
log "caseC_createdIssueId=${CASE_C_CREATED_ISSUE_ID:-none}"
|
||||
log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -36,6 +36,7 @@
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
|
||||
254
server/src/__tests__/openclaw-gateway-adapter.test.ts
Normal file
254
server/src/__tests__/openclaw-gateway-adapter.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
|
||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
function buildContext(
|
||||
config: Record<string, unknown>,
|
||||
overrides?: Partial<AdapterExecutionContext>,
|
||||
): AdapterExecutionContext {
|
||||
return {
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "OpenClaw Gateway Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config,
|
||||
context: {
|
||||
taskId: "task-123",
|
||||
issueId: "issue-123",
|
||||
wakeReason: "issue_assigned",
|
||||
issueIds: ["issue-123"],
|
||||
},
|
||||
onLog: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServer() {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
let agentPayload: Record<string, unknown> | null = null;
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "nonce-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||
const frame = JSON.parse(text) as {
|
||||
type: string;
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (frame.type !== "req") return;
|
||||
|
||||
if (frame.method === "connect") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
server: { version: "test", connId: "conn-1" },
|
||||
features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] },
|
||||
snapshot: { version: 1, ts: Date.now() },
|
||||
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent") {
|
||||
agentPayload = frame.params ?? null;
|
||||
const runId =
|
||||
typeof frame.params?.idempotencyKey === "string"
|
||||
? frame.params.idempotencyKey
|
||||
: "run-123";
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "cha" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 2,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "chacha" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent.wait") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve test server address");
|
||||
}
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
getAgentPayload: () => agentPayload,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// no global mocks
|
||||
});
|
||||
|
||||
describe("openclaw gateway ui stdout parser", () => {
|
||||
it("parses assistant deltas from gateway event lines", () => {
|
||||
const ts = "2026-03-06T15:00:00.000Z";
|
||||
const line =
|
||||
'[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}';
|
||||
|
||||
expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts,
|
||||
text: "hello",
|
||||
delta: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway adapter execute", () => {
|
||||
it("runs connect -> agent -> agent.wait and forwards wake payload", async () => {
|
||||
const gateway = await createMockGatewayServer();
|
||||
const logs: string[] = [];
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext(
|
||||
{
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
},
|
||||
{
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.summary).toContain("chachacha");
|
||||
expect(result.provider).toBe("openclaw");
|
||||
|
||||
const payload = gateway.getAgentPayload();
|
||||
expect(payload).toBeTruthy();
|
||||
expect(payload?.idempotencyKey).toBe("run-123");
|
||||
expect(payload?.sessionKey).toBe("paperclip");
|
||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||
|
||||
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast when url is missing", async () => {
|
||||
const result = await execute(buildContext({}));
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway testEnvironment", () => {
|
||||
it("reports missing url as failure", async () => {
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-123",
|
||||
adapterType: "openclaw_gateway",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,14 @@ import {
|
||||
agentConfigurationDoc as openclawAgentConfigurationDoc,
|
||||
models as openclawModels,
|
||||
} from "@paperclipai/adapter-openclaw";
|
||||
import {
|
||||
execute as openclawGatewayExecute,
|
||||
testEnvironment as openclawGatewayTestEnvironment,
|
||||
} from "@paperclipai/adapter-openclaw-gateway/server";
|
||||
import {
|
||||
agentConfigurationDoc as openclawGatewayAgentConfigurationDoc,
|
||||
models as openclawGatewayModels,
|
||||
} from "@paperclipai/adapter-openclaw-gateway";
|
||||
import { listCodexModels } from "./codex-models.js";
|
||||
import { listCursorModels } from "./cursor-models.js";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
@@ -82,6 +90,15 @@ const openclawAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
execute: openclawGatewayExecute,
|
||||
testEnvironment: openclawGatewayTestEnvironment,
|
||||
models: openclawGatewayModels,
|
||||
supportsLocalAgentJwt: false,
|
||||
agentConfigurationDoc: openclawGatewayAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: openCodeExecute,
|
||||
@@ -94,7 +111,16 @@ const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
[
|
||||
claudeLocalAdapter,
|
||||
codexLocalAdapter,
|
||||
openCodeLocalAdapter,
|
||||
cursorLocalAdapter,
|
||||
openclawAdapter,
|
||||
openclawGatewayAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@@ -28,6 +29,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.574.0",
|
||||
"mermaid": "^11.12.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
221
ui/src/adapters/openclaw-gateway/config-fields.tsx
Normal file
221
ui/src/adapters/openclaw-gateway/config-fields.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function parseScopes(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === "string").join(", ");
|
||||
}
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function OpenClawGatewayConfigFields({
|
||||
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 effectiveGatewayToken = typeof effectiveHeaders["x-openclaw-token"] === "string"
|
||||
? String(effectiveHeaders["x-openclaw-token"])
|
||||
: typeof effectiveHeaders["x-openclaw-auth"] === "string"
|
||||
? String(effectiveHeaders["x-openclaw-auth"])
|
||||
: "";
|
||||
|
||||
const commitGatewayToken = (rawValue: string) => {
|
||||
const nextValue = rawValue.trim();
|
||||
const nextHeaders: Record<string, unknown> = { ...effectiveHeaders };
|
||||
if (nextValue) {
|
||||
nextHeaders["x-openclaw-token"] = nextValue;
|
||||
delete nextHeaders["x-openclaw-auth"];
|
||||
} else {
|
||||
delete nextHeaders["x-openclaw-token"];
|
||||
delete nextHeaders["x-openclaw-auth"];
|
||||
}
|
||||
mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined);
|
||||
};
|
||||
|
||||
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="ws://127.0.0.1:18789"
|
||||
/>
|
||||
</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="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="Gateway auth token (x-openclaw-token)"
|
||||
value={effectiveGatewayToken}
|
||||
onCommit={commitGatewayToken}
|
||||
placeholder="OpenClaw gateway token"
|
||||
/>
|
||||
|
||||
<Field label="Role">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "role", String(config.role ?? "operator"))}
|
||||
onCommit={(v) => mark("adapterConfig", "role", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="operator"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Scopes (comma-separated)">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "scopes", parseScopes(config.scopes ?? ["operator.admin"]))}
|
||||
onCommit={(v) => {
|
||||
const parsed = v
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined);
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="operator.admin"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Wait timeout (ms)">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "waitTimeoutMs", String(config.waitTimeoutMs ?? "120000"))}
|
||||
onCommit={(v) => {
|
||||
const parsed = Number.parseInt(v.trim(), 10);
|
||||
mark(
|
||||
"adapterConfig",
|
||||
"waitTimeoutMs",
|
||||
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined,
|
||||
);
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="120000"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Disable device auth">
|
||||
<select
|
||||
value={String(eff("adapterConfig", "disableDeviceAuth", Boolean(config.disableDeviceAuth ?? false)))}
|
||||
onChange={(e) => mark("adapterConfig", "disableDeviceAuth", e.target.value === "true")}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="false">No (recommended)</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/openclaw-gateway/index.ts
Normal file
12
ui/src/adapters/openclaw-gateway/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import { buildOpenClawGatewayConfig } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import { OpenClawGatewayConfigFields } from "./config-fields";
|
||||
|
||||
export const openClawGatewayUIAdapter: UIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
label: "OpenClaw Gateway",
|
||||
parseStdoutLine: parseOpenClawGatewayStdoutLine,
|
||||
ConfigFields: OpenClawGatewayConfigFields,
|
||||
buildAdapterConfig: buildOpenClawGatewayConfig,
|
||||
};
|
||||
@@ -4,11 +4,21 @@ import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { openClawUIAdapter } from "./openclaw";
|
||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
|
||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
||||
[
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
openClawUIAdapter,
|
||||
openClawGatewayUIAdapter,
|
||||
processUIAdapter,
|
||||
httpUIAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getUIAdapter(type: string): UIAdapterModule {
|
||||
|
||||
@@ -19,6 +19,7 @@ const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
@@ -157,7 +157,7 @@ function parseStdoutChunk(
|
||||
if (!trimmed) continue;
|
||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
||||
if (parsed.length === 0) {
|
||||
if (run.adapterType === "openclaw") {
|
||||
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
|
||||
continue;
|
||||
}
|
||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
||||
|
||||
@@ -56,7 +56,8 @@ type AdapterType =
|
||||
| "cursor"
|
||||
| "process"
|
||||
| "http"
|
||||
| "openclaw";
|
||||
| "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)
|
||||
|
||||
@@ -672,6 +673,12 @@ export function OnboardingWizard() {
|
||||
desc: "Notify OpenClaw webhook",
|
||||
comingSoon: true
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol"
|
||||
},
|
||||
{
|
||||
value: "cursor" as const,
|
||||
label: "Cursor",
|
||||
@@ -973,14 +980,14 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(adapterType === "http" || adapterType === "openclaw") && (
|
||||
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Webhook URL
|
||||
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="https://..."
|
||||
placeholder={adapterType === "openclaw_gateway" ? "ws://127.0.0.1:18789" : "https://..."}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -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 webhook, spawned process, or generic HTTP webhook.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), 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.",
|
||||
@@ -54,6 +54,7 @@ export const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
@@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
@@ -121,6 +121,7 @@ const adapterLabels: Record<string, string> = {
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user