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:
Dotta
2026-03-07 08:59:29 -06:00
parent fa8499719a
commit a498c268c5
34 changed files with 4290 additions and 19 deletions

View File

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

View File

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

View File

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

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

View File

@@ -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 users main chat session in webchat using message tool”.
2. Trigger/poll run completion.
3. Validate output:
- Automated minimum: run log/transcript confirms tool invocation success.
- UX-level validation: message visibly appears in main chat UI.
Current recommendation:
- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification.
### Case C: Fresh session still creates Paperclip task
1. Force fresh-session behavior for test:
- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key).
2. Create issue asking agent to create a new Paperclip task.
3. Pass criteria:
- New issue appears in CLA with expected title/body.
- Agent succeeds without re-onboarding.
## 7) Observability and Assertions
Use these APIs for deterministic assertions:
- `GET /api/companies/:companyId/heartbeat-runs?agentId=...`
- `GET /api/heartbeat-runs/:runId/events`
- `GET /api/heartbeat-runs/:runId/log`
- `GET /api/issues/:id`
- `GET /api/companies/:companyId/issues?q=...`
Include explicit timeout budgets per poll loop and hard failure reasons in output.
## 8) Automation Artifact
Implemented smoke harness:
- `scripts/smoke/openclaw-gateway-e2e.sh`
Responsibilities:
- OpenClaw docker cleanup/rebuild/start.
- Paperclip health/auth preflight.
- CLA company resolution.
- Old OpenClaw agent cleanup.
- Invite/join/approve/claim orchestration.
- 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.

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

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

View File

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

View 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)
`;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";

View 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(),
};
}

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

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

View File

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

View 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 }];
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

View File

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

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

View File

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

View File

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

View 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>
</>
)}
</>
);
}

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

View File

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

View File

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

View File

@@ -157,7 +157,7 @@ function parseStdoutChunk(
if (!trimmed) continue;
const parsed = adapter.parseStdoutLine(trimmed, ts);
if (parsed.length === 0) {
if (run.adapterType === "openclaw") {
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
continue;
}
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);

View File

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

View File

@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
role: "Organizational role. Determines position and capabilities.",
reportsTo: "The agent this one reports to in the org hierarchy.",
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw 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",

View File

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

View File

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

View File

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