Remove legacy OpenClaw adapter and keep gateway-only flow
This commit is contained in:
@@ -16,7 +16,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
COPY packages/adapters/openclaw/package.json packages/adapters/openclaw/
|
||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ const workspacePaths = [
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/openclaw",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||
import { processCLIAdapter } from "./process/index.js";
|
||||
import { httpCLIAdapter } from "./http/index.js";
|
||||
@@ -34,11 +33,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const openclawCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw",
|
||||
formatStdoutEvent: printOpenClawStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
@@ -51,7 +45,6 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
openclawCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
|
||||
@@ -56,7 +56,7 @@ Use when:
|
||||
- You want structured stream output in run logs via --output-format stream-json
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw or http)
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- Cursor Agent CLI is not installed on the machine
|
||||
|
||||
|
||||
@@ -1,371 +1,109 @@
|
||||
# OpenClaw Gateway Onboarding and Test Plan
|
||||
|
||||
## Objective
|
||||
Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex.
|
||||
|
||||
This plan covers:
|
||||
- Current onboarding flow behavior and gaps.
|
||||
- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol).
|
||||
- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company.
|
||||
|
||||
## Hard Requirements (Testing Contract)
|
||||
These are mandatory for onboarding and smoke testing:
|
||||
|
||||
1. **Stock/clean OpenClaw boot every run**
|
||||
- Use a fresh, unmodified OpenClaw Docker image path each test cycle.
|
||||
- Do not rely on persistent/manual in-UI tweaks from prior runs.
|
||||
- Recreate runtime state each run so results represent first-time user experience.
|
||||
|
||||
2. **One-command/prompt setup inside OpenClaw**
|
||||
- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able).
|
||||
- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”).
|
||||
- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps.
|
||||
|
||||
3. **Two-lane validation is required**
|
||||
- Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate.
|
||||
- Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path.
|
||||
|
||||
## Execution Findings (2026-03-07)
|
||||
Observed from running `scripts/smoke/openclaw-gateway-e2e.sh` against `CLA` in authenticated/private dev mode:
|
||||
|
||||
1. **Baseline failure (before wake-text fix)**
|
||||
- Stock lane had run-level success but failed functional assertions:
|
||||
- connectivity run `64a72d8b-f5b3-4f62-9147-1c60932f50ad` succeeded
|
||||
- case A run `fd29e361-a6bd-4bc6-9270-36ef96e3bd8e` succeeded
|
||||
- issue `CLA-6` (`dad7b967-29d2-4317-8c9d-425b4421e098`) stayed `todo` with `0` comments
|
||||
- Root symptom: OpenClaw reported missing concrete heartbeat procedure and guessed non-existent `/api/*heartbeat` endpoints.
|
||||
|
||||
2. **Post-fix validation (stock-clean lane passes)**
|
||||
- After updating adapter wake text to include explicit Paperclip API workflow steps and explicit endpoint bans:
|
||||
- connectivity run `c297e2d0-020b-4b30-95d3-a4c04e1373bb`: `succeeded`
|
||||
- case A run `baac403e-8d86-48e5-b7d5-239c4755ce7e`: `succeeded`, issue `CLA-7` done with marker
|
||||
- case B run `521fc8ad-2f5a-4bd8-9ddd-c491401c9158`: `succeeded`, issue `CLA-8` done with marker
|
||||
- case C run `a03d86b6-91a8-48b4-8813-758f6bf11aec`: `succeeded`, issue `CLA-9` done, created issue `CLA-10`
|
||||
- Stock release-gate lane now passes scripted checks.
|
||||
|
||||
3. **Instrumentation lane note**
|
||||
- Prompt-augmented diagnostics lane previously timed out (`7537e5d2-a76a-44c5-bf9f-57f1b21f5fc3`) with missing tool runtime utilities (`jq`, `python`) inside the stock container.
|
||||
- Keep this lane for diagnostics only; stock lane remains the acceptance gate.
|
||||
|
||||
## External Protocol Constraints
|
||||
OpenClaw docs to anchor behavior:
|
||||
- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook
|
||||
- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol
|
||||
- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses
|
||||
|
||||
Implication:
|
||||
- `webhook` transport should target `/hooks/*` and requires hook server enablement.
|
||||
- `sse` transport should target `/v1/responses`.
|
||||
- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`.
|
||||
|
||||
## Current Implementation Map (What Exists)
|
||||
|
||||
### Invite + onboarding pipeline
|
||||
- Invite create: `POST /api/companies/:companyId/invites`
|
||||
- Invite onboarding manifest: `GET /api/invites/:token/onboarding`
|
||||
- Agent-readable text: `GET /api/invites/:token/onboarding.txt`
|
||||
- Accept join: `POST /api/invites/:token/accept`
|
||||
- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve`
|
||||
- Claim key: `POST /api/join-requests/:requestId/claim-api-key`
|
||||
|
||||
### Adapter state
|
||||
- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode.
|
||||
- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`).
|
||||
|
||||
### Existing smoke foundation
|
||||
- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`.
|
||||
- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`).
|
||||
|
||||
## Deep Code Findings (Gaps)
|
||||
|
||||
### 1) Onboarding manifest/text gateway path (resolved)
|
||||
Resolved in `server/src/routes/access.ts`:
|
||||
- `recommendedAdapterType` now points to `openclaw_gateway`.
|
||||
- Onboarding examples now require `adapterType: "openclaw_gateway"` + `ws://`/`wss://` URL + gateway token header.
|
||||
- Added fail-fast guidance for short/placeholder tokens.
|
||||
|
||||
### 2) Company settings snippet gateway path (resolved)
|
||||
Resolved in `ui/src/pages/CompanySettings.tsx`:
|
||||
- Snippet now instructs OpenClaw Gateway onboarding.
|
||||
- Snippet explicitly says not to use `/v1/responses` or `/hooks/*` for this flow.
|
||||
|
||||
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters (open)
|
||||
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
|
||||
|
||||
### 4) Join normalization/replay logic parity (partially resolved)
|
||||
Resolved:
|
||||
- `buildJoinDefaultsPayloadForAccept` now normalizes wrapped gateway token headers for `openclaw_gateway`.
|
||||
- `normalizeAgentDefaultsForJoin` now validates `openclaw_gateway` URL/token and rejects short placeholder tokens at invite-accept time.
|
||||
|
||||
Still open:
|
||||
- Invite replay path is still special-cased to legacy `openclaw` joins.
|
||||
|
||||
### 5) Webhook confusion is expected in current setup
|
||||
For `openclaw` + `streamTransport=webhook`:
|
||||
- Adapter may remap `/v1/responses -> /hooks/agent`.
|
||||
- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`.
|
||||
|
||||
If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected.
|
||||
|
||||
### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode
|
||||
- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`).
|
||||
- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`.
|
||||
- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes.
|
||||
|
||||
### 7) Gateway adapter lacks hire-approved callback parity
|
||||
`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not.
|
||||
Not a blocker for core routing, but creates inconsistent onboarding feedback behavior.
|
||||
|
||||
## UX Intention (Target Experience)
|
||||
|
||||
### Product goal
|
||||
Users should pick one clear onboarding path:
|
||||
- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs.
|
||||
- `Invite OpenClaw Gateway` for gateway-native installs.
|
||||
|
||||
### UX design requirements
|
||||
- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route).
|
||||
- Mode-specific generated snippet and mode-specific onboarding text.
|
||||
- Clear compatibility checks before user copies anything.
|
||||
|
||||
### Proposed UX structure
|
||||
1. Add invite buttons:
|
||||
- `Invite OpenClaw (SSE/Webhook)`
|
||||
- `Invite OpenClaw Gateway`
|
||||
|
||||
2. For HTTP invite:
|
||||
- Require transport choice (`sse` or `webhook`).
|
||||
- Validate endpoint expectations:
|
||||
- `sse` with `/v1/responses`.
|
||||
- `webhook` with `/hooks/*` and hooks enablement guidance.
|
||||
|
||||
3. For Gateway invite:
|
||||
- Ask only for `ws://`/`wss://` and token source guidance.
|
||||
- No callback URL/paperclipApiUrl complexity in onboarding.
|
||||
|
||||
4. Always show:
|
||||
- Preflight diagnostics.
|
||||
- Copy-ready command/snippet.
|
||||
- Expected next steps (join -> approve -> claim -> skill install).
|
||||
|
||||
## Why Gateway Improves Onboarding
|
||||
Compared to webhook/SSE onboarding:
|
||||
- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls.
|
||||
- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion.
|
||||
- Better run observability: gateway event frames stream lifecycle/delta events in one protocol.
|
||||
|
||||
Tradeoff:
|
||||
- Requires stable WS endpoint and gateway token handling.
|
||||
|
||||
## Codex-Executable E2E Workflow
|
||||
|
||||
## Scope
|
||||
Run this full flow per test cycle against company `CLA`:
|
||||
1. Assign task to OpenClaw agent -> agent executes -> task closes.
|
||||
2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat.
|
||||
3. OpenClaw in a fresh/new session can still create a Paperclip task.
|
||||
4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup.
|
||||
This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only.
|
||||
|
||||
## 0) Cleanup Before Each Run
|
||||
Use deterministic reset to avoid stale agents/runs/state.
|
||||
- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching)
|
||||
- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`)
|
||||
|
||||
1. OpenClaw Docker cleanup:
|
||||
## Requirements
|
||||
1. OpenClaw test image must be stock/clean every run.
|
||||
2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed).
|
||||
3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`.
|
||||
4. Invite/access flow must be secure:
|
||||
- invite prompt endpoint is board-permission protected
|
||||
- CEO agent is allowed to invoke the invite prompt endpoint for their own company
|
||||
5. E2E pass criteria must include the 3 functional task cases.
|
||||
|
||||
## Current Product Flow
|
||||
1. Board/CEO opens company settings.
|
||||
2. Click `Generate OpenClaw Invite Prompt`.
|
||||
3. Paste generated prompt into OpenClaw chat.
|
||||
4. OpenClaw submits invite acceptance with:
|
||||
- `adapterType: "openclaw_gateway"`
|
||||
- `agentDefaultsPayload.url: ws://... | wss://...`
|
||||
- `agentDefaultsPayload.headers["x-openclaw-token"]`
|
||||
5. Board approves join request.
|
||||
6. OpenClaw claims API key and installs/uses Paperclip skill.
|
||||
7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key.
|
||||
|
||||
## Technical Contract (Gateway)
|
||||
`agentDefaultsPayload` minimum:
|
||||
```json
|
||||
{
|
||||
"url": "ws://127.0.0.1:18789",
|
||||
"headers": { "x-openclaw-token": "<gateway-token>" }
|
||||
}
|
||||
```
|
||||
|
||||
Recommended fields:
|
||||
```json
|
||||
{
|
||||
"paperclipApiUrl": "http://host.docker.internal:3100",
|
||||
"waitTimeoutMs": 120000,
|
||||
"sessionKeyStrategy": "issue",
|
||||
"role": "operator",
|
||||
"scopes": ["operator.admin"]
|
||||
}
|
||||
```
|
||||
|
||||
Security/pairing defaults:
|
||||
- `disableDeviceAuth`: default false
|
||||
- `devicePrivateKeyPem`: generated during join if missing
|
||||
|
||||
## Codex Automation Workflow
|
||||
|
||||
### 0) Reset and boot
|
||||
```bash
|
||||
# stop/remove OpenClaw compose services
|
||||
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
|
||||
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
|
||||
fi
|
||||
|
||||
# remove old image (as requested)
|
||||
docker image rm openclaw:local || true
|
||||
```
|
||||
|
||||
2. Recreate OpenClaw cleanly:
|
||||
```bash
|
||||
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
||||
```
|
||||
This must remain a stock/clean image boot path, with no hidden manual state carried from prior runs.
|
||||
|
||||
3. Remove prior CLA OpenClaw agents:
|
||||
- List `CLA` agents via API.
|
||||
- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding.
|
||||
|
||||
4. Reject/clear stale pending join requests for CLA (optional but recommended).
|
||||
|
||||
## 1) Start Paperclip in Required Mode
|
||||
### 1) Start Paperclip
|
||||
```bash
|
||||
pnpm dev --tailscale-auth
|
||||
```
|
||||
Verify:
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:3100/api/health
|
||||
# expect deploymentMode=authenticated, deploymentExposure=private
|
||||
```
|
||||
|
||||
## 2) Acquire Board Session for Automation
|
||||
Board operations (create invite, approve join, terminate agents) require board session cookie.
|
||||
### 2) Invite + join + approval
|
||||
- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt`
|
||||
- paste prompt to OpenClaw
|
||||
- approve join request
|
||||
- assert created agent:
|
||||
- `adapterType == openclaw_gateway`
|
||||
- token header exists and length >= 16
|
||||
- `devicePrivateKeyPem` exists
|
||||
|
||||
Short-term practical options:
|
||||
1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`.
|
||||
2. Scripted fallback: sign-up/sign-in via `/api/auth/*`, then use a dedicated admin promotion/bootstrap utility for dev (recommended to add as a small internal script).
|
||||
### 3) Pairing stabilization
|
||||
- if first run returns `pairing required`, approve pending device in OpenClaw
|
||||
- rerun task and confirm success
|
||||
- assert later runs do not require re-pairing for same agent
|
||||
|
||||
Note:
|
||||
- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow.
|
||||
### 4) Functional E2E assertions
|
||||
1. Task assigned to OpenClaw is completed and closed.
|
||||
2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat).
|
||||
3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task.
|
||||
|
||||
## 3) Resolve CLA Company ID
|
||||
With board cookie:
|
||||
## Manual Smoke Checklist
|
||||
Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook.
|
||||
|
||||
## Regression Gates
|
||||
Required before merge:
|
||||
```bash
|
||||
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
Pick company where identifier/code is `CLA` and store `CLA_COMPANY_ID`.
|
||||
|
||||
## 4) Preflight OpenClaw Endpoint Capability
|
||||
From host (using current OpenClaw token):
|
||||
- For HTTP SSE mode: confirm `/v1/responses` behavior.
|
||||
- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled.
|
||||
- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`.
|
||||
|
||||
Expected in current docker smoke config:
|
||||
- `/hooks/agent` likely `404` unless hooks explicitly enabled.
|
||||
- WS gateway protocol works.
|
||||
|
||||
## 5) Gateway Join Flow (Primary Path)
|
||||
|
||||
1. Create agent-only invite in CLA:
|
||||
If full suite is too heavy locally, run at least:
|
||||
```bash
|
||||
POST /api/companies/$CLA_COMPANY_ID/invites
|
||||
{ "allowedJoinTypes": "agent" }
|
||||
pnpm --filter @paperclipai/server test:run -- openclaw-gateway
|
||||
pnpm --filter @paperclipai/server typecheck
|
||||
pnpm --filter @paperclipai/ui typecheck
|
||||
pnpm --filter paperclipai typecheck
|
||||
```
|
||||
|
||||
2. Submit join request with gateway defaults:
|
||||
```json
|
||||
{
|
||||
"requestType": "agent",
|
||||
"agentName": "OpenClaw Gateway",
|
||||
"adapterType": "openclaw_gateway",
|
||||
"capabilities": "OpenClaw gateway agent",
|
||||
"agentDefaultsPayload": {
|
||||
"url": "ws://127.0.0.1:18789",
|
||||
"headers": { "x-openclaw-token": "<gateway-token>" },
|
||||
"role": "operator",
|
||||
"scopes": ["operator.admin"],
|
||||
"sessionKeyStrategy": "issue",
|
||||
"waitTimeoutMs": 120000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Approve join request.
|
||||
4. **Hard gate before any task run:** fetch created agent config and validate:
|
||||
- `adapterType == "openclaw_gateway"`
|
||||
- `adapterConfig.url` uses `ws://` or `wss://`
|
||||
- `adapterConfig.headers.x-openclaw-token` exists and is not placeholder/too-short (`len >= 16`)
|
||||
- token hash matches the OpenClaw `gateway.auth.token` used for join
|
||||
- pairing mode is explicit:
|
||||
- default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs
|
||||
- fallback path: `disableDeviceAuth=true` only for environments that cannot support pairing
|
||||
5. Trigger one connectivity run. Adapter behavior on first pairing gate:
|
||||
- default: auto-attempt `device.pair.list` + `device.pair.approve` over shared auth, then retry once
|
||||
- if auto-pair fails, run returns `pairing required`; approve manually in OpenClaw and retry once
|
||||
- Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates.
|
||||
- Local docker automation path:
|
||||
- `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token <gateway-token>`
|
||||
- Optional inspection:
|
||||
- `openclaw devices list --json --url ws://127.0.0.1:18789 --token <gateway-token>`
|
||||
- After approval, retries should succeed using the persisted `devicePrivateKeyPem`.
|
||||
6. Claim API key with `claimSecret`.
|
||||
7. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
|
||||
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
|
||||
8. Ensure Paperclip skill is installed for OpenClaw runtime.
|
||||
9. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
|
||||
|
||||
## 6) E2E Validation Cases
|
||||
|
||||
### Case A: Assigned task execution/closure
|
||||
1. Create issue in CLA assigned to joined OpenClaw agent.
|
||||
2. Poll issue + heartbeat runs until terminal.
|
||||
3. Pass criteria:
|
||||
- At least one run invoked for that agent/issue.
|
||||
- Run status `succeeded`.
|
||||
- Issue reaches `done` (or documented expected terminal state if policy differs).
|
||||
|
||||
### Case B: Message tool to main chat
|
||||
1. Create issue instructing OpenClaw: “send a message to the user’s main chat session in webchat using message tool”.
|
||||
2. Trigger/poll run completion.
|
||||
3. Validate output:
|
||||
- Automated minimum: run log/transcript confirms tool invocation success.
|
||||
- UX-level validation: message visibly appears in main chat UI.
|
||||
|
||||
Current recommendation:
|
||||
- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification.
|
||||
|
||||
### Case C: Fresh session still creates Paperclip task
|
||||
1. Force fresh-session behavior for test:
|
||||
- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key).
|
||||
2. Create issue asking agent to create a new Paperclip task.
|
||||
3. Pass criteria:
|
||||
- New issue appears in CLA with expected title/body.
|
||||
- Agent succeeds without re-onboarding.
|
||||
|
||||
## 7) Observability and Assertions
|
||||
Use these APIs for deterministic assertions:
|
||||
- `GET /api/companies/:companyId/heartbeat-runs?agentId=...`
|
||||
- `GET /api/heartbeat-runs/:runId/events`
|
||||
- `GET /api/heartbeat-runs/:runId/log`
|
||||
- `GET /api/issues/:id`
|
||||
- `GET /api/companies/:companyId/issues?q=...`
|
||||
|
||||
Include explicit timeout budgets per poll loop and hard failure reasons in output.
|
||||
|
||||
## 8) Automation Artifact
|
||||
Implemented smoke harness:
|
||||
- `scripts/smoke/openclaw-gateway-e2e.sh`
|
||||
|
||||
Responsibilities:
|
||||
- OpenClaw docker cleanup/rebuild/start.
|
||||
- Paperclip health/auth preflight.
|
||||
- CLA company resolution.
|
||||
- Old OpenClaw agent cleanup.
|
||||
- Invite/join/approve/claim orchestration.
|
||||
- Gateway agent config/token preflight validation before connectivity or case execution.
|
||||
- Pairing-mode preflight (`disableDeviceAuth=false` + stable `devicePrivateKeyPem` by default).
|
||||
- E2E case execution + assertions.
|
||||
- Final summary with run IDs, issue IDs, agent ID.
|
||||
|
||||
## 9) Required Product/Code Changes to Support This Plan Cleanly
|
||||
|
||||
### Access/onboarding backend
|
||||
- Make onboarding manifest/text adapter-aware (`openclaw` vs `openclaw_gateway`).
|
||||
- Add gateway-specific required fields and examples.
|
||||
- Add gateway-specific diagnostics (WS URL/token/role/scopes/device-auth hints).
|
||||
|
||||
### Company settings UX
|
||||
- Replace single generic snippet with mode-specific invite actions.
|
||||
- Add “Invite OpenClaw Gateway” path with concise copy/paste onboarding.
|
||||
|
||||
### Invite landing UX
|
||||
- Enable OpenClaw adapter options when invite allows agent join.
|
||||
- Allow `agentDefaultsPayload` entry for advanced joins where needed.
|
||||
|
||||
### Adapter parity
|
||||
- Consider `onHireApproved` support for `openclaw_gateway` for consistency.
|
||||
|
||||
### Test coverage
|
||||
- Add integration tests for adapter-aware onboarding manifest generation.
|
||||
- Add route tests for gateway join/approve/claim path.
|
||||
- Add smoke test target for gateway E2E flow.
|
||||
|
||||
## 10) Execution Order
|
||||
1. Implement onboarding manifest/text split by adapter mode.
|
||||
2. Add company settings invite UX split (HTTP vs Gateway).
|
||||
3. Add gateway E2E smoke script.
|
||||
4. Run full CLA workflow in authenticated/private mode.
|
||||
5. Iterate on message-tool verification automation.
|
||||
|
||||
## Acceptance Criteria
|
||||
- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal.
|
||||
- Gateway onboarding is first-class and copy/pasteable from company settings.
|
||||
- Gateway join fails fast if token is missing/placeholder, and smoke preflight verifies adapter/token parity before task runs.
|
||||
- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup.
|
||||
- All three validation cases are documented with pass/fail criteria and reproducible evidence paths.
|
||||
|
||||
@@ -12,7 +12,7 @@ Use when:
|
||||
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
|
||||
|
||||
Don't use when:
|
||||
- You only expose OpenClaw HTTP endpoints (use openclaw adapter with sse/webhook transport).
|
||||
- You only expose OpenClaw HTTP endpoints.
|
||||
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
|
||||
|
||||
Core fields:
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# @paperclipai/adapter-openclaw
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.6
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.5
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.4
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.3
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.2
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.1
|
||||
@@ -1,139 +0,0 @@
|
||||
# OpenClaw Adapter Modes
|
||||
|
||||
This document describes how `@paperclipai/adapter-openclaw` selects request shape and endpoint behavior.
|
||||
|
||||
## Transport Modes
|
||||
|
||||
The adapter has two transport modes:
|
||||
|
||||
- `sse` (default)
|
||||
- `webhook`
|
||||
|
||||
Configured via `adapterConfig.streamTransport` (or legacy `adapterConfig.transport`).
|
||||
|
||||
## Mode Matrix
|
||||
|
||||
| streamTransport | configured URL path | behavior |
|
||||
| --- | --- | --- |
|
||||
| `sse` | `/v1/responses` | Sends OpenResponses request with `stream: true`, expects `text/event-stream` response until terminal event. |
|
||||
| `sse` | `/hooks/*` | Rejected (`openclaw_sse_incompatible_endpoint`). Hooks are not stream-capable. |
|
||||
| `sse` | other endpoint | Sends generic streaming payload (`stream: true`, `text`, `paperclip`) and expects SSE response. |
|
||||
| `webhook` | `/hooks/wake` | Sends wake payload `{ text, mode }`. |
|
||||
| `webhook` | `/hooks/agent` | Sends agent payload `{ message, ...hook fields }`. |
|
||||
| `webhook` | `/v1/responses` | Compatibility flow: tries `/hooks/agent` first, then falls back to original `/v1/responses` if hook endpoint returns `404`. |
|
||||
| `webhook` | other endpoint | Sends legacy generic webhook payload (`stream: false`, `text`, `paperclip`). |
|
||||
|
||||
## Webhook Payload Shapes
|
||||
|
||||
### 1) Hook Wake (`/hooks/wake`)
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Paperclip wake event ...",
|
||||
"mode": "now"
|
||||
}
|
||||
```
|
||||
|
||||
### 2) Hook Agent (`/hooks/agent`)
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Paperclip wake event ...",
|
||||
"name": "Optional hook name",
|
||||
"agentId": "Optional OpenClaw agent id",
|
||||
"wakeMode": "now",
|
||||
"deliver": true,
|
||||
"channel": "last",
|
||||
"to": "Optional channel recipient",
|
||||
"model": "Optional model override",
|
||||
"thinking": "Optional thinking override",
|
||||
"timeoutSeconds": 120
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `message` is always used (not `text`) for `/hooks/agent`.
|
||||
- `sessionKey` is **not** sent by default for `/hooks/agent`.
|
||||
- To include derived session keys in `/hooks/agent`, set:
|
||||
- `hookIncludeSessionKey: true`
|
||||
|
||||
### 3) OpenResponses (`/v1/responses`)
|
||||
|
||||
When used directly (SSE mode or webhook fallback), payload uses OpenResponses shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"stream": false,
|
||||
"model": "openclaw",
|
||||
"input": "...",
|
||||
"metadata": {
|
||||
"paperclip_session_key": "paperclip:issue:ISSUE_ID"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Header Behavior
|
||||
|
||||
You can provide auth either explicitly or via token headers:
|
||||
|
||||
- Explicit auth header:
|
||||
- `webhookAuthHeader: "Bearer ..."`
|
||||
- Token headers (adapter derives `Authorization` automatically when missing):
|
||||
- `headers["x-openclaw-token"]` (preferred)
|
||||
- `headers["x-openclaw-auth"]` (legacy compatibility)
|
||||
|
||||
## Session Key Behavior
|
||||
|
||||
Session keys are resolved from:
|
||||
|
||||
- `sessionKeyStrategy`: `issue` (default), `fixed`, `run`
|
||||
- `sessionKey`: used when strategy is `fixed` (default value `paperclip`)
|
||||
|
||||
Where session keys are applied:
|
||||
|
||||
- `/v1/responses`: sent via `x-openclaw-session-key` header + metadata.
|
||||
- `/hooks/wake`: not sent as a dedicated field.
|
||||
- `/hooks/agent`: only sent if `hookIncludeSessionKey=true`.
|
||||
- Generic webhook fallback: sent as `sessionKey` field.
|
||||
|
||||
## Recommended Config Examples
|
||||
|
||||
### SSE (streaming endpoint)
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://127.0.0.1:18789/v1/responses",
|
||||
"streamTransport": "sse",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"x-openclaw-token": "replace-me"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook (hooks endpoint)
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://127.0.0.1:18789/hooks/agent",
|
||||
"streamTransport": "webhook",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"x-openclaw-token": "replace-me"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook with legacy URL retained
|
||||
|
||||
If URL is still `/v1/responses` and `streamTransport=webhook`, the adapter will:
|
||||
|
||||
1. try `.../hooks/agent`
|
||||
2. fallback to original `.../v1/responses` when hook endpoint returns `404`
|
||||
|
||||
This lets older OpenClaw setups continue working while migrating to hooks.
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-openclaw",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
export function printOpenClawStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
if (!debug) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("[openclaw]")) {
|
||||
console.log(pc.cyan(line));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { printOpenClawStreamEvent } from "./format-event.js";
|
||||
@@ -1,42 +0,0 @@
|
||||
export const type = "openclaw";
|
||||
export const label = "OpenClaw";
|
||||
|
||||
export const models: { id: string; label: string }[] = [];
|
||||
|
||||
export const agentConfigurationDoc = `# openclaw agent configuration
|
||||
|
||||
Adapter: openclaw
|
||||
|
||||
Use when:
|
||||
- You run an OpenClaw agent remotely and wake it over HTTP.
|
||||
- You want selectable transport:
|
||||
- \`sse\` for streaming execution in one Paperclip run.
|
||||
- \`webhook\` for wake-style callbacks (\`/hooks/wake\`, \`/hooks/agent\`, or compatibility webhooks).
|
||||
|
||||
Don't use when:
|
||||
- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process).
|
||||
- The OpenClaw endpoint is not reachable from the Paperclip server.
|
||||
|
||||
Core fields:
|
||||
- url (string, required): OpenClaw endpoint URL
|
||||
- streamTransport (string, optional): \`sse\` (default) or \`webhook\`
|
||||
- method (string, optional): HTTP method, default POST
|
||||
- headers (object, optional): extra HTTP headers for requests
|
||||
- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth
|
||||
- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload
|
||||
- paperclipApiUrl (string, optional): absolute http(s) Paperclip base URL to advertise to OpenClaw as \`PAPERCLIP_API_URL\`
|
||||
- hookIncludeSessionKey (boolean, optional): when true, include derived \`sessionKey\` in \`/hooks/agent\` webhook payloads (default false)
|
||||
|
||||
Session routing fields:
|
||||
- sessionKeyStrategy (string, optional): \`issue\` (default), \`fixed\`, or \`run\`
|
||||
- sessionKey (string, optional): fixed session key value when strategy is \`fixed\` (default \`paperclip\`)
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): SSE request timeout in seconds (default 0 = no adapter timeout)
|
||||
|
||||
Hire-approved callback fields (optional):
|
||||
- hireApprovedCallbackUrl (string): callback endpoint invoked when this agent is approved/hired
|
||||
- hireApprovedCallbackMethod (string): HTTP method for the callback (default POST)
|
||||
- hireApprovedCallbackAuthHeader (string): Authorization header value for callback requests
|
||||
- hireApprovedCallbackHeaders (object): extra headers merged into callback requests
|
||||
`;
|
||||
@@ -1,534 +0,0 @@
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseOpenClawResponse } from "./parse.js";
|
||||
|
||||
export type OpenClawTransport = "sse" | "webhook";
|
||||
export type SessionKeyStrategy = "fixed" | "issue" | "run";
|
||||
export type OpenClawEndpointKind = "open_responses" | "hook_wake" | "hook_agent" | "generic";
|
||||
|
||||
export type WakePayload = {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
issueId: string | null;
|
||||
wakeReason: string | null;
|
||||
wakeCommentId: string | null;
|
||||
approvalId: string | null;
|
||||
approvalStatus: string | null;
|
||||
issueIds: string[];
|
||||
};
|
||||
|
||||
export type OpenClawExecutionState = {
|
||||
method: string;
|
||||
timeoutSec: number;
|
||||
headers: Record<string, string>;
|
||||
payloadTemplate: Record<string, unknown>;
|
||||
wakePayload: WakePayload;
|
||||
sessionKey: string;
|
||||
paperclipEnv: Record<string, string>;
|
||||
wakeText: string;
|
||||
};
|
||||
|
||||
const SENSITIVE_LOG_KEY_PATTERN =
|
||||
/(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i;
|
||||
|
||||
export function nonEmpty(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function toAuthorizationHeaderValue(rawToken: string): string {
|
||||
const trimmed = rawToken.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
||||
}
|
||||
|
||||
export function resolvePaperclipApiUrlOverride(value: unknown): string | null {
|
||||
const raw = nonEmpty(value);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy {
|
||||
const normalized = asString(value, "issue").trim().toLowerCase();
|
||||
if (normalized === "fixed" || normalized === "run") return normalized;
|
||||
return "issue";
|
||||
}
|
||||
|
||||
export function resolveSessionKey(input: {
|
||||
strategy: SessionKeyStrategy;
|
||||
configuredSessionKey: string | null;
|
||||
runId: string;
|
||||
issueId: string | null;
|
||||
}): string {
|
||||
const fallback = input.configuredSessionKey ?? "paperclip";
|
||||
if (input.strategy === "run") return `paperclip:run:${input.runId}`;
|
||||
if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeUrlPath(pathname: string): string {
|
||||
const trimmed = pathname.trim().toLowerCase();
|
||||
if (!trimmed) return "/";
|
||||
return trimmed.endsWith("/") && trimmed !== "/" ? trimmed.slice(0, -1) : trimmed;
|
||||
}
|
||||
|
||||
function isWakePath(pathname: string): boolean {
|
||||
const normalized = normalizeUrlPath(pathname);
|
||||
return normalized === "/hooks/wake" || normalized.endsWith("/hooks/wake");
|
||||
}
|
||||
|
||||
function isHookAgentPath(pathname: string): boolean {
|
||||
const normalized = normalizeUrlPath(pathname);
|
||||
return normalized === "/hooks/agent" || normalized.endsWith("/hooks/agent");
|
||||
}
|
||||
|
||||
function isHookPath(pathname: string): boolean {
|
||||
const normalized = normalizeUrlPath(pathname);
|
||||
return (
|
||||
normalized === "/hooks" ||
|
||||
normalized.startsWith("/hooks/") ||
|
||||
normalized.endsWith("/hooks") ||
|
||||
normalized.includes("/hooks/")
|
||||
);
|
||||
}
|
||||
|
||||
export function isHookEndpoint(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return isHookPath(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isWakeCompatibilityEndpoint(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return isWakePath(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isHookAgentEndpoint(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return isHookAgentPath(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenResponsesEndpoint(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const path = normalizeUrlPath(parsed.pathname);
|
||||
return path === "/v1/responses" || path.endsWith("/v1/responses");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEndpointKind(url: string): OpenClawEndpointKind {
|
||||
if (isOpenResponsesEndpoint(url)) return "open_responses";
|
||||
if (isWakeCompatibilityEndpoint(url)) return "hook_wake";
|
||||
if (isHookAgentEndpoint(url)) return "hook_agent";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
export function deriveHookAgentUrlFromResponses(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const path = normalizeUrlPath(parsed.pathname);
|
||||
if (path === "/v1/responses") {
|
||||
parsed.pathname = "/hooks/agent";
|
||||
return parsed.toString();
|
||||
}
|
||||
if (path.endsWith("/v1/responses")) {
|
||||
parsed.pathname = `${path.slice(0, -"/v1/responses".length)}/hooks/agent`;
|
||||
return parsed.toString();
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function toStringRecord(value: unknown): Record<string, string> {
|
||||
const parsed = parseObject(value);
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (typeof entry === "string") {
|
||||
out[key] = entry;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSensitiveLogKey(key: string): boolean {
|
||||
return SENSITIVE_LOG_KEY_PATTERN.test(key.trim());
|
||||
}
|
||||
|
||||
function sha256Prefix(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function redactSecretForLog(value: string): string {
|
||||
return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`;
|
||||
}
|
||||
|
||||
function truncateForLog(value: string, maxChars = 320): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`;
|
||||
}
|
||||
|
||||
export function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown {
|
||||
const currentKey = keyPath[keyPath.length - 1] ?? "";
|
||||
if (typeof value === "string") {
|
||||
if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value);
|
||||
return truncateForLog(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || value == null) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (depth >= 6) return "[array-truncated]";
|
||||
const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1));
|
||||
if (value.length > 20) out.push(`[+${value.length - 20} more items]`);
|
||||
return out;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
if (depth >= 6) return "[object-truncated]";
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, entry] of entries.slice(0, 80)) {
|
||||
out[key] = redactForLog(entry, [...keyPath, key], depth + 1);
|
||||
}
|
||||
if (entries.length > 80) {
|
||||
out.__truncated__ = `+${entries.length - 80} keys`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function stringifyForLog(value: unknown, maxChars: number): string {
|
||||
const text = JSON.stringify(value);
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
|
||||
}
|
||||
|
||||
export function buildWakePayload(ctx: AdapterExecutionContext): WakePayload {
|
||||
const { runId, agent, context } = ctx;
|
||||
return {
|
||||
runId,
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId),
|
||||
issueId: nonEmpty(context.issueId),
|
||||
wakeReason: nonEmpty(context.wakeReason),
|
||||
wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId),
|
||||
approvalId: nonEmpty(context.approvalId),
|
||||
approvalStatus: nonEmpty(context.approvalStatus),
|
||||
issueIds: Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record<string, string> {
|
||||
const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl);
|
||||
const paperclipEnv: Record<string, string> = {
|
||||
...buildPaperclipEnv(ctx.agent),
|
||||
PAPERCLIP_RUN_ID: ctx.runId,
|
||||
};
|
||||
|
||||
if (paperclipApiUrlOverride) {
|
||||
paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride;
|
||||
}
|
||||
if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId;
|
||||
if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason;
|
||||
if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId;
|
||||
if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId;
|
||||
if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus;
|
||||
if (wakePayload.issueIds.length > 0) {
|
||||
paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(",");
|
||||
}
|
||||
|
||||
return paperclipEnv;
|
||||
}
|
||||
|
||||
export function buildWakeText(payload: WakePayload, paperclipEnv: Record<string, string>): string {
|
||||
const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json";
|
||||
const orderedKeys = [
|
||||
"PAPERCLIP_RUN_ID",
|
||||
"PAPERCLIP_AGENT_ID",
|
||||
"PAPERCLIP_COMPANY_ID",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_TASK_ID",
|
||||
"PAPERCLIP_WAKE_REASON",
|
||||
"PAPERCLIP_WAKE_COMMENT_ID",
|
||||
"PAPERCLIP_APPROVAL_ID",
|
||||
"PAPERCLIP_APPROVAL_STATUS",
|
||||
"PAPERCLIP_LINKED_ISSUE_IDS",
|
||||
];
|
||||
|
||||
const envLines: string[] = [];
|
||||
for (const key of orderedKeys) {
|
||||
const value = paperclipEnv[key];
|
||||
if (!value) continue;
|
||||
envLines.push(`${key}=${value}`);
|
||||
}
|
||||
|
||||
const issueIdHint = payload.taskId ?? payload.issueId ?? "";
|
||||
const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? "<set PAPERCLIP_API_URL>";
|
||||
|
||||
const lines = [
|
||||
"Paperclip wake event for a cloud adapter.",
|
||||
"",
|
||||
"Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.",
|
||||
"",
|
||||
"Set these values in your run context:",
|
||||
...envLines,
|
||||
`PAPERCLIP_API_KEY=<token from ${claimedApiKeyPath}>`,
|
||||
"",
|
||||
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`,
|
||||
"",
|
||||
`api_base=${apiBaseHint}`,
|
||||
`task_id=${payload.taskId ?? ""}`,
|
||||
`issue_id=${payload.issueId ?? ""}`,
|
||||
`wake_reason=${payload.wakeReason ?? ""}`,
|
||||
`wake_comment_id=${payload.wakeCommentId ?? ""}`,
|
||||
`approval_id=${payload.approvalId ?? ""}`,
|
||||
`approval_status=${payload.approvalStatus ?? ""}`,
|
||||
`linked_issue_ids=${payload.issueIds.join(",")}`,
|
||||
"",
|
||||
"HTTP rules:",
|
||||
"- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.",
|
||||
"- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.",
|
||||
"- Use only /api endpoints listed below.",
|
||||
"- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.",
|
||||
"",
|
||||
"Workflow:",
|
||||
"1) GET /api/agents/me",
|
||||
`2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`,
|
||||
"3) If issueId exists:",
|
||||
" - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}",
|
||||
" - GET /api/issues/{issueId}",
|
||||
" - GET /api/issues/{issueId}/comments",
|
||||
" - Execute the issue instructions exactly.",
|
||||
" - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.",
|
||||
" - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.",
|
||||
"4) If issueId does not exist:",
|
||||
" - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked",
|
||||
" - Pick in_progress first, then todo, then blocked, then execute step 3.",
|
||||
"",
|
||||
"Useful endpoints for issue work:",
|
||||
"- POST /api/issues/{issueId}/comments",
|
||||
"- PATCH /api/issues/{issueId}",
|
||||
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
|
||||
"",
|
||||
"Complete the workflow in this run.",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function appendWakeText(baseText: string, wakeText: string): string {
|
||||
const trimmedBase = baseText.trim();
|
||||
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
||||
}
|
||||
|
||||
function buildOpenResponsesWakeInputMessage(wakeText: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: wakeText,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function appendWakeTextToOpenResponsesInput(input: unknown, wakeText: string): unknown {
|
||||
if (typeof input === "string") {
|
||||
return appendWakeText(input, wakeText);
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return [...input, buildOpenResponsesWakeInputMessage(wakeText)];
|
||||
}
|
||||
|
||||
if (typeof input === "object" && input !== null) {
|
||||
const parsed = parseObject(input);
|
||||
const content = parsed.content;
|
||||
if (typeof content === "string") {
|
||||
return {
|
||||
...parsed,
|
||||
content: appendWakeText(content, wakeText),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return {
|
||||
...parsed,
|
||||
content: [
|
||||
...content,
|
||||
{
|
||||
type: "input_text",
|
||||
text: wakeText,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return [parsed, buildOpenResponsesWakeInputMessage(wakeText)];
|
||||
}
|
||||
|
||||
return wakeText;
|
||||
}
|
||||
|
||||
export function isTextRequiredResponse(responseText: string): boolean {
|
||||
const parsed = parseOpenClawResponse(responseText);
|
||||
const parsedError = parsed && typeof parsed.error === "string" ? parsed.error : null;
|
||||
if (parsedError && parsedError.toLowerCase().includes("text required")) {
|
||||
return true;
|
||||
}
|
||||
return responseText.toLowerCase().includes("text required");
|
||||
}
|
||||
|
||||
function extractResponseErrorMessage(responseText: string): string {
|
||||
const parsed = parseOpenClawResponse(responseText);
|
||||
if (!parsed) return responseText;
|
||||
|
||||
const directError = parsed.error;
|
||||
if (typeof directError === "string") return directError;
|
||||
if (directError && typeof directError === "object") {
|
||||
const nestedMessage = (directError as Record<string, unknown>).message;
|
||||
if (typeof nestedMessage === "string") return nestedMessage;
|
||||
}
|
||||
|
||||
const directMessage = parsed.message;
|
||||
if (typeof directMessage === "string") return directMessage;
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
export function isWakeCompatibilityRetryableResponse(responseText: string): boolean {
|
||||
if (isTextRequiredResponse(responseText)) return true;
|
||||
|
||||
const normalized = extractResponseErrorMessage(responseText).toLowerCase();
|
||||
const expectsStringInput =
|
||||
normalized.includes("invalid input") &&
|
||||
normalized.includes("expected string") &&
|
||||
normalized.includes("undefined");
|
||||
if (expectsStringInput) return true;
|
||||
|
||||
const missingInputField =
|
||||
normalized.includes("input") &&
|
||||
(normalized.includes("required") || normalized.includes("missing"));
|
||||
if (missingInputField) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function sendJsonRequest(params: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
payload: Record<string, unknown>;
|
||||
signal: AbortSignal;
|
||||
}): Promise<Response> {
|
||||
return fetch(params.url, {
|
||||
method: params.method,
|
||||
headers: params.headers,
|
||||
body: JSON.stringify(params.payload),
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readAndLogResponseText(params: {
|
||||
response: Response;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<string> {
|
||||
const responseText = await params.response.text();
|
||||
if (responseText.trim().length > 0) {
|
||||
await params.onLog(
|
||||
"stdout",
|
||||
`[openclaw] response (${params.response.status}) ${responseText.slice(0, 2000)}\n`,
|
||||
);
|
||||
} else {
|
||||
await params.onLog("stdout", `[openclaw] response (${params.response.status}) <empty>\n`);
|
||||
}
|
||||
return responseText;
|
||||
}
|
||||
|
||||
export function buildExecutionState(ctx: AdapterExecutionContext): OpenClawExecutionState {
|
||||
const method = asString(ctx.config.method, "POST").trim().toUpperCase() || "POST";
|
||||
const timeoutSecRaw = asNumber(ctx.config.timeoutSec, 0);
|
||||
const timeoutSec = timeoutSecRaw > 0 ? Math.max(1, Math.floor(timeoutSecRaw)) : 0;
|
||||
const headersConfig = parseObject(ctx.config.headers) as Record<string, unknown>;
|
||||
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
|
||||
const webhookAuthHeader = nonEmpty(ctx.config.webhookAuthHeader);
|
||||
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
for (const [key, value] of Object.entries(headersConfig)) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const openClawAuthHeader = nonEmpty(
|
||||
headers["x-openclaw-token"] ??
|
||||
headers["X-OpenClaw-Token"] ??
|
||||
headers["x-openclaw-auth"] ??
|
||||
headers["X-OpenClaw-Auth"],
|
||||
);
|
||||
if (openClawAuthHeader && !headers.authorization && !headers.Authorization) {
|
||||
headers.authorization = toAuthorizationHeaderValue(openClawAuthHeader);
|
||||
}
|
||||
if (webhookAuthHeader && !headers.authorization && !headers.Authorization) {
|
||||
headers.authorization = webhookAuthHeader;
|
||||
}
|
||||
|
||||
const wakePayload = buildWakePayload(ctx);
|
||||
const sessionKey = resolveSessionKey({
|
||||
strategy: sessionKeyStrategy,
|
||||
configuredSessionKey: nonEmpty(ctx.config.sessionKey),
|
||||
runId: ctx.runId,
|
||||
issueId: wakePayload.issueId ?? wakePayload.taskId,
|
||||
});
|
||||
|
||||
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
|
||||
const wakeText = buildWakeText(wakePayload, paperclipEnv);
|
||||
|
||||
return {
|
||||
method,
|
||||
timeoutSec,
|
||||
headers,
|
||||
payloadTemplate,
|
||||
wakePayload,
|
||||
sessionKey,
|
||||
paperclipEnv,
|
||||
wakeText,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWakeCompatibilityPayload(wakeText: string): Record<string, unknown> {
|
||||
return {
|
||||
text: wakeText,
|
||||
mode: "now",
|
||||
};
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
appendWakeTextToOpenResponsesInput,
|
||||
buildExecutionState,
|
||||
isOpenResponsesEndpoint,
|
||||
isTextRequiredResponse,
|
||||
readAndLogResponseText,
|
||||
redactForLog,
|
||||
sendJsonRequest,
|
||||
stringifyForLog,
|
||||
toStringRecord,
|
||||
type OpenClawExecutionState,
|
||||
} from "./execute-common.js";
|
||||
import { parseOpenClawResponse } from "./parse.js";
|
||||
|
||||
type ConsumedSse = {
|
||||
eventCount: number;
|
||||
lastEventType: string | null;
|
||||
lastData: string | null;
|
||||
lastPayload: Record<string, unknown> | null;
|
||||
terminal: boolean;
|
||||
failed: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
function nonEmpty(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function inferSseTerminal(input: {
|
||||
eventType: string;
|
||||
data: string;
|
||||
parsedPayload: Record<string, unknown> | null;
|
||||
}): { terminal: boolean; failed: boolean; errorMessage: string | null } {
|
||||
const normalizedType = input.eventType.trim().toLowerCase();
|
||||
const trimmedData = input.data.trim();
|
||||
const payload = input.parsedPayload;
|
||||
const payloadType = nonEmpty(payload?.type)?.toLowerCase() ?? null;
|
||||
const payloadStatus = nonEmpty(payload?.status)?.toLowerCase() ?? null;
|
||||
|
||||
if (trimmedData === "[DONE]") {
|
||||
return { terminal: true, failed: false, errorMessage: null };
|
||||
}
|
||||
|
||||
const failType =
|
||||
normalizedType.includes("error") ||
|
||||
normalizedType.includes("failed") ||
|
||||
normalizedType.includes("cancel");
|
||||
if (failType) {
|
||||
return {
|
||||
terminal: true,
|
||||
failed: true,
|
||||
errorMessage:
|
||||
nonEmpty(payload?.error) ??
|
||||
nonEmpty(payload?.message) ??
|
||||
(trimmedData.length > 0 ? trimmedData : "OpenClaw SSE error"),
|
||||
};
|
||||
}
|
||||
|
||||
const doneType =
|
||||
normalizedType === "done" ||
|
||||
normalizedType.endsWith(".completed") ||
|
||||
normalizedType === "completed";
|
||||
if (doneType) {
|
||||
return { terminal: true, failed: false, errorMessage: null };
|
||||
}
|
||||
|
||||
if (payloadStatus) {
|
||||
if (
|
||||
payloadStatus === "completed" ||
|
||||
payloadStatus === "succeeded" ||
|
||||
payloadStatus === "done"
|
||||
) {
|
||||
return { terminal: true, failed: false, errorMessage: null };
|
||||
}
|
||||
if (
|
||||
payloadStatus === "failed" ||
|
||||
payloadStatus === "cancelled" ||
|
||||
payloadStatus === "error"
|
||||
) {
|
||||
return {
|
||||
terminal: true,
|
||||
failed: true,
|
||||
errorMessage:
|
||||
nonEmpty(payload?.error) ??
|
||||
nonEmpty(payload?.message) ??
|
||||
`OpenClaw SSE status ${payloadStatus}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (payloadType) {
|
||||
if (payloadType.endsWith(".completed")) {
|
||||
return { terminal: true, failed: false, errorMessage: null };
|
||||
}
|
||||
if (
|
||||
payloadType.endsWith(".failed") ||
|
||||
payloadType.endsWith(".cancelled") ||
|
||||
payloadType.endsWith(".error")
|
||||
) {
|
||||
return {
|
||||
terminal: true,
|
||||
failed: true,
|
||||
errorMessage:
|
||||
nonEmpty(payload?.error) ??
|
||||
nonEmpty(payload?.message) ??
|
||||
`OpenClaw SSE type ${payloadType}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.done === true) {
|
||||
return { terminal: true, failed: false, errorMessage: null };
|
||||
}
|
||||
|
||||
return { terminal: false, failed: false, errorMessage: null };
|
||||
}
|
||||
|
||||
async function consumeSseResponse(params: {
|
||||
response: Response;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<ConsumedSse> {
|
||||
const reader = params.response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("OpenClaw SSE response body is missing");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventType = "message";
|
||||
let dataLines: string[] = [];
|
||||
let eventCount = 0;
|
||||
let lastEventType: string | null = null;
|
||||
let lastData: string | null = null;
|
||||
let lastPayload: Record<string, unknown> | null = null;
|
||||
let terminal = false;
|
||||
let failed = false;
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
const dispatchEvent = async (): Promise<boolean> => {
|
||||
if (dataLines.length === 0) {
|
||||
eventType = "message";
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = dataLines.join("\n");
|
||||
const trimmedData = data.trim();
|
||||
const parsedPayload = parseOpenClawResponse(trimmedData);
|
||||
|
||||
eventCount += 1;
|
||||
lastEventType = eventType;
|
||||
lastData = data;
|
||||
if (parsedPayload) lastPayload = parsedPayload;
|
||||
|
||||
const preview =
|
||||
trimmedData.length > 1000 ? `${trimmedData.slice(0, 1000)}...` : trimmedData;
|
||||
await params.onLog("stdout", `[openclaw:sse] event=${eventType} data=${preview}\n`);
|
||||
|
||||
const resolution = inferSseTerminal({
|
||||
eventType,
|
||||
data,
|
||||
parsedPayload,
|
||||
});
|
||||
|
||||
dataLines = [];
|
||||
eventType = "message";
|
||||
|
||||
if (resolution.terminal) {
|
||||
terminal = true;
|
||||
failed = resolution.failed;
|
||||
errorMessage = resolution.errorMessage;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
let shouldStop = false;
|
||||
while (!shouldStop) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (!shouldStop) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) break;
|
||||
|
||||
let line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line.endsWith("\r")) line = line.slice(0, -1);
|
||||
|
||||
if (line.length === 0) {
|
||||
shouldStop = await dispatchEvent();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(":")) continue;
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
|
||||
const rawValue =
|
||||
colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, "");
|
||||
|
||||
if (field === "event") {
|
||||
eventType = rawValue || "message";
|
||||
} else if (field === "data") {
|
||||
dataLines.push(rawValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
if (!shouldStop && buffer.trim().length > 0) {
|
||||
for (const rawLine of buffer.split(/\r?\n/)) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (line.length === 0) {
|
||||
shouldStop = await dispatchEvent();
|
||||
if (shouldStop) break;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(":")) continue;
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
|
||||
const rawValue =
|
||||
colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, "");
|
||||
|
||||
if (field === "event") {
|
||||
eventType = rawValue || "message";
|
||||
} else if (field === "data") {
|
||||
dataLines.push(rawValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldStop && dataLines.length > 0) {
|
||||
await dispatchEvent();
|
||||
}
|
||||
|
||||
return {
|
||||
eventCount,
|
||||
lastEventType,
|
||||
lastData,
|
||||
lastPayload,
|
||||
terminal,
|
||||
failed,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSseBody(input: {
|
||||
url: string;
|
||||
state: OpenClawExecutionState;
|
||||
context: AdapterExecutionContext["context"];
|
||||
configModel: unknown;
|
||||
}): { headers: Record<string, string>; body: Record<string, unknown> } {
|
||||
const { url, state, context, configModel } = input;
|
||||
const templateText = nonEmpty(state.payloadTemplate.text);
|
||||
const payloadText = templateText ? `${templateText}\n\n${state.wakeText}` : state.wakeText;
|
||||
|
||||
const isOpenResponses = isOpenResponsesEndpoint(url);
|
||||
const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input")
|
||||
? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText)
|
||||
: payloadText;
|
||||
|
||||
const body: Record<string, unknown> = isOpenResponses
|
||||
? {
|
||||
...state.payloadTemplate,
|
||||
stream: true,
|
||||
model:
|
||||
nonEmpty(state.payloadTemplate.model) ??
|
||||
nonEmpty(configModel) ??
|
||||
"openclaw",
|
||||
input: openResponsesInput,
|
||||
metadata: {
|
||||
...toStringRecord(state.payloadTemplate.metadata),
|
||||
...state.paperclipEnv,
|
||||
paperclip_session_key: state.sessionKey,
|
||||
},
|
||||
}
|
||||
: {
|
||||
...state.payloadTemplate,
|
||||
stream: true,
|
||||
sessionKey: state.sessionKey,
|
||||
text: payloadText,
|
||||
paperclip: {
|
||||
...state.wakePayload,
|
||||
sessionKey: state.sessionKey,
|
||||
streamTransport: "sse",
|
||||
env: state.paperclipEnv,
|
||||
context,
|
||||
},
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...state.headers,
|
||||
accept: "text/event-stream",
|
||||
};
|
||||
|
||||
if (isOpenResponses && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) {
|
||||
headers["x-openclaw-session-key"] = state.sessionKey;
|
||||
}
|
||||
|
||||
return { headers, body };
|
||||
}
|
||||
|
||||
export async function executeSse(ctx: AdapterExecutionContext, url: string): Promise<AdapterExecutionResult> {
|
||||
const { onLog, onMeta, context } = ctx;
|
||||
const state = buildExecutionState(ctx);
|
||||
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "openclaw",
|
||||
command: "sse",
|
||||
commandArgs: [state.method, url],
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const { headers, body } = buildSseBody({
|
||||
url,
|
||||
state,
|
||||
context,
|
||||
configModel: ctx.config.model,
|
||||
});
|
||||
|
||||
const outboundHeaderKeys = Object.keys(headers).sort();
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
||||
);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(body), 12_000)}\n`,
|
||||
);
|
||||
await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
||||
await onLog("stdout", `[openclaw] invoking ${state.method} ${url} (transport=sse)\n`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null;
|
||||
|
||||
try {
|
||||
const response = await sendJsonRequest({
|
||||
url,
|
||||
method: state.method,
|
||||
headers,
|
||||
payload: body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await readAndLogResponseText({ response, onLog });
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
isTextRequiredResponse(responseText)
|
||||
? "OpenClaw endpoint rejected the payload as text-required."
|
||||
: `OpenClaw SSE request failed with status ${response.status}`,
|
||||
errorCode: isTextRequiredResponse(responseText)
|
||||
? "openclaw_text_required"
|
||||
: "openclaw_http_error",
|
||||
resultJson: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: parseOpenClawResponse(responseText) ?? responseText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
||||
if (!contentType.includes("text/event-stream")) {
|
||||
const responseText = await readAndLogResponseText({ response, onLog });
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: "OpenClaw SSE endpoint did not return text/event-stream",
|
||||
errorCode: "openclaw_sse_expected_event_stream",
|
||||
resultJson: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
contentType,
|
||||
response: parseOpenClawResponse(responseText) ?? responseText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const consumed = await consumeSseResponse({ response, onLog });
|
||||
if (consumed.failed) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: consumed.errorMessage ?? "OpenClaw SSE stream failed",
|
||||
errorCode: "openclaw_sse_stream_failed",
|
||||
resultJson: {
|
||||
eventCount: consumed.eventCount,
|
||||
terminal: consumed.terminal,
|
||||
lastEventType: consumed.lastEventType,
|
||||
lastData: consumed.lastData,
|
||||
response: consumed.lastPayload ?? consumed.lastData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!consumed.terminal) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: "OpenClaw SSE stream closed without a terminal event",
|
||||
errorCode: "openclaw_sse_stream_incomplete",
|
||||
resultJson: {
|
||||
eventCount: consumed.eventCount,
|
||||
terminal: consumed.terminal,
|
||||
lastEventType: consumed.lastEventType,
|
||||
lastData: consumed.lastData,
|
||||
response: consumed.lastPayload ?? consumed.lastData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
provider: "openclaw",
|
||||
model: null,
|
||||
summary: `OpenClaw SSE ${state.method} ${url}`,
|
||||
resultJson: {
|
||||
eventCount: consumed.eventCount,
|
||||
terminal: consumed.terminal,
|
||||
lastEventType: consumed.lastEventType,
|
||||
lastData: consumed.lastData,
|
||||
response: consumed.lastPayload ?? consumed.lastData,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
const timeoutMessage =
|
||||
state.timeoutSec > 0
|
||||
? `[openclaw] SSE request timed out after ${state.timeoutSec}s\n`
|
||||
: "[openclaw] SSE request aborted\n";
|
||||
await onLog("stderr", timeoutMessage);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted",
|
||||
errorCode: "openclaw_sse_timeout",
|
||||
};
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[openclaw] request failed: ${message}\n`);
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: message,
|
||||
errorCode: "openclaw_request_failed",
|
||||
};
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
appendWakeText,
|
||||
appendWakeTextToOpenResponsesInput,
|
||||
buildExecutionState,
|
||||
buildWakeCompatibilityPayload,
|
||||
deriveHookAgentUrlFromResponses,
|
||||
isTextRequiredResponse,
|
||||
isWakeCompatibilityRetryableResponse,
|
||||
readAndLogResponseText,
|
||||
redactForLog,
|
||||
resolveEndpointKind,
|
||||
sendJsonRequest,
|
||||
stringifyForLog,
|
||||
toStringRecord,
|
||||
type OpenClawEndpointKind,
|
||||
type OpenClawExecutionState,
|
||||
} from "./execute-common.js";
|
||||
import { parseOpenClawResponse } from "./parse.js";
|
||||
|
||||
function nonEmpty(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asBooleanFlag(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1") return true;
|
||||
if (normalized === "false" || normalized === "0") return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeWakeMode(value: unknown): "now" | "next-heartbeat" | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "now" || normalized === "next-heartbeat") return normalized;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const normalized = Math.max(1, Math.floor(value));
|
||||
return Number.isFinite(normalized) ? normalized : null;
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
const normalized = Math.max(1, Math.floor(parsed));
|
||||
return Number.isFinite(normalized) ? normalized : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildOpenResponsesWebhookBody(input: {
|
||||
state: OpenClawExecutionState;
|
||||
configModel: unknown;
|
||||
}): Record<string, unknown> {
|
||||
const { state, configModel } = input;
|
||||
const templateText = nonEmpty(state.payloadTemplate.text);
|
||||
const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText;
|
||||
const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input")
|
||||
? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText)
|
||||
: payloadText;
|
||||
|
||||
return {
|
||||
...state.payloadTemplate,
|
||||
stream: false,
|
||||
model:
|
||||
nonEmpty(state.payloadTemplate.model) ??
|
||||
nonEmpty(configModel) ??
|
||||
"openclaw",
|
||||
input: openResponsesInput,
|
||||
metadata: {
|
||||
...toStringRecord(state.payloadTemplate.metadata),
|
||||
...state.paperclipEnv,
|
||||
paperclip_session_key: state.sessionKey,
|
||||
paperclip_stream_transport: "webhook",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildHookWakeBody(state: OpenClawExecutionState): Record<string, unknown> {
|
||||
const templateText = nonEmpty(state.payloadTemplate.text) ?? nonEmpty(state.payloadTemplate.message);
|
||||
const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText;
|
||||
const wakeMode = normalizeWakeMode(state.payloadTemplate.mode ?? state.payloadTemplate.wakeMode) ?? "now";
|
||||
|
||||
return {
|
||||
text: payloadText,
|
||||
mode: wakeMode,
|
||||
};
|
||||
}
|
||||
|
||||
function buildHookAgentBody(input: {
|
||||
state: OpenClawExecutionState;
|
||||
includeSessionKey: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const { state, includeSessionKey } = input;
|
||||
const templateMessage = nonEmpty(state.payloadTemplate.message) ?? nonEmpty(state.payloadTemplate.text);
|
||||
const message = templateMessage ? appendWakeText(templateMessage, state.wakeText) : state.wakeText;
|
||||
const payload: Record<string, unknown> = {
|
||||
message,
|
||||
};
|
||||
|
||||
const name = nonEmpty(state.payloadTemplate.name);
|
||||
if (name) payload.name = name;
|
||||
|
||||
const agentId = nonEmpty(state.payloadTemplate.agentId);
|
||||
if (agentId) payload.agentId = agentId;
|
||||
|
||||
const wakeMode = normalizeWakeMode(state.payloadTemplate.wakeMode ?? state.payloadTemplate.mode);
|
||||
if (wakeMode) payload.wakeMode = wakeMode;
|
||||
|
||||
const deliver = state.payloadTemplate.deliver;
|
||||
if (typeof deliver === "boolean") payload.deliver = deliver;
|
||||
|
||||
const channel = nonEmpty(state.payloadTemplate.channel);
|
||||
if (channel) payload.channel = channel;
|
||||
|
||||
const to = nonEmpty(state.payloadTemplate.to);
|
||||
if (to) payload.to = to;
|
||||
|
||||
const model = nonEmpty(state.payloadTemplate.model);
|
||||
if (model) payload.model = model;
|
||||
|
||||
const thinking = nonEmpty(state.payloadTemplate.thinking);
|
||||
if (thinking) payload.thinking = thinking;
|
||||
|
||||
const timeoutSeconds = parseOptionalPositiveInteger(state.payloadTemplate.timeoutSeconds);
|
||||
if (timeoutSeconds != null) payload.timeoutSeconds = timeoutSeconds;
|
||||
|
||||
const explicitSessionKey = nonEmpty(state.payloadTemplate.sessionKey);
|
||||
if (explicitSessionKey) {
|
||||
payload.sessionKey = explicitSessionKey;
|
||||
} else if (includeSessionKey) {
|
||||
payload.sessionKey = state.sessionKey;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildLegacyWebhookBody(input: {
|
||||
state: OpenClawExecutionState;
|
||||
context: AdapterExecutionContext["context"];
|
||||
}): Record<string, unknown> {
|
||||
const { state, context } = input;
|
||||
const templateText = nonEmpty(state.payloadTemplate.text);
|
||||
const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText;
|
||||
return {
|
||||
...state.payloadTemplate,
|
||||
stream: false,
|
||||
sessionKey: state.sessionKey,
|
||||
text: payloadText,
|
||||
paperclip: {
|
||||
...state.wakePayload,
|
||||
sessionKey: state.sessionKey,
|
||||
streamTransport: "webhook",
|
||||
env: state.paperclipEnv,
|
||||
context,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildWebhookBody(input: {
|
||||
endpointKind: OpenClawEndpointKind;
|
||||
state: OpenClawExecutionState;
|
||||
context: AdapterExecutionContext["context"];
|
||||
configModel: unknown;
|
||||
includeHookSessionKey: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const { endpointKind, state, context, configModel, includeHookSessionKey } = input;
|
||||
if (endpointKind === "open_responses") {
|
||||
return buildOpenResponsesWebhookBody({ state, configModel });
|
||||
}
|
||||
if (endpointKind === "hook_wake") {
|
||||
return buildHookWakeBody(state);
|
||||
}
|
||||
if (endpointKind === "hook_agent") {
|
||||
return buildHookAgentBody({ state, includeSessionKey: includeHookSessionKey });
|
||||
}
|
||||
|
||||
return buildLegacyWebhookBody({ state, context });
|
||||
}
|
||||
|
||||
async function sendWebhookRequest(params: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
payload: Record<string, unknown>;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
signal: AbortSignal;
|
||||
}): Promise<{ response: Response; responseText: string }> {
|
||||
const response = await sendJsonRequest({
|
||||
url: params.url,
|
||||
method: params.method,
|
||||
headers: params.headers,
|
||||
payload: params.payload,
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
const responseText = await readAndLogResponseText({ response, onLog: params.onLog });
|
||||
return { response, responseText };
|
||||
}
|
||||
|
||||
export async function executeWebhook(ctx: AdapterExecutionContext, url: string): Promise<AdapterExecutionResult> {
|
||||
const { onLog, onMeta, context } = ctx;
|
||||
const state = buildExecutionState(ctx);
|
||||
const originalUrl = url;
|
||||
const originalEndpointKind = resolveEndpointKind(originalUrl);
|
||||
let targetUrl = originalUrl;
|
||||
let endpointKind = resolveEndpointKind(targetUrl);
|
||||
const remappedFromResponses = originalEndpointKind === "open_responses";
|
||||
|
||||
// In webhook mode, /v1/responses is legacy wiring. Prefer hooks/agent.
|
||||
if (remappedFromResponses) {
|
||||
const rewritten = deriveHookAgentUrlFromResponses(targetUrl);
|
||||
if (rewritten) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] webhook transport selected; remapping ${targetUrl} -> ${rewritten}\n`,
|
||||
);
|
||||
targetUrl = rewritten;
|
||||
endpointKind = resolveEndpointKind(targetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = { ...state.headers };
|
||||
if (endpointKind === "open_responses" && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) {
|
||||
headers["x-openclaw-session-key"] = state.sessionKey;
|
||||
}
|
||||
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "openclaw",
|
||||
command: "webhook",
|
||||
commandArgs: [state.method, targetUrl],
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const includeHookSessionKey = asBooleanFlag(ctx.config.hookIncludeSessionKey, false);
|
||||
const webhookBody = buildWebhookBody({
|
||||
endpointKind,
|
||||
state,
|
||||
context,
|
||||
configModel: ctx.config.model,
|
||||
includeHookSessionKey,
|
||||
});
|
||||
const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText);
|
||||
const preferWakeCompatibilityBody = endpointKind === "hook_wake";
|
||||
const initialBody = webhookBody;
|
||||
|
||||
const outboundHeaderKeys = Object.keys(headers).sort();
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
||||
);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(initialBody), 12_000)}\n`,
|
||||
);
|
||||
await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
||||
await onLog("stdout", `[openclaw] invoking ${state.method} ${targetUrl} (transport=webhook kind=${endpointKind})\n`);
|
||||
|
||||
if (preferWakeCompatibilityBody) {
|
||||
await onLog("stdout", "[openclaw] using webhook wake payload for /hooks/wake\n");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null;
|
||||
|
||||
try {
|
||||
const initialResponse = await sendWebhookRequest({
|
||||
url: targetUrl,
|
||||
method: state.method,
|
||||
headers,
|
||||
payload: initialBody,
|
||||
onLog,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let activeResponse = initialResponse;
|
||||
let activeEndpointKind = endpointKind;
|
||||
let activeUrl = targetUrl;
|
||||
let activeHeaders = headers;
|
||||
let usedLegacyResponsesFallback = false;
|
||||
|
||||
if (
|
||||
remappedFromResponses &&
|
||||
targetUrl !== originalUrl &&
|
||||
initialResponse.response.status === 404
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] remapped hook endpoint returned 404; retrying legacy endpoint ${originalUrl}\n`,
|
||||
);
|
||||
|
||||
activeEndpointKind = originalEndpointKind;
|
||||
activeUrl = originalUrl;
|
||||
usedLegacyResponsesFallback = true;
|
||||
const fallbackHeaders = { ...state.headers };
|
||||
if (
|
||||
activeEndpointKind === "open_responses" &&
|
||||
!fallbackHeaders["x-openclaw-session-key"] &&
|
||||
!fallbackHeaders["X-OpenClaw-Session-Key"]
|
||||
) {
|
||||
fallbackHeaders["x-openclaw-session-key"] = state.sessionKey;
|
||||
}
|
||||
|
||||
const fallbackBody = buildWebhookBody({
|
||||
endpointKind: activeEndpointKind,
|
||||
state,
|
||||
context,
|
||||
configModel: ctx.config.model,
|
||||
includeHookSessionKey,
|
||||
});
|
||||
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] fallback headers (redacted): ${stringifyForLog(redactForLog(fallbackHeaders), 4_000)}\n`,
|
||||
);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] fallback payload (redacted): ${stringifyForLog(redactForLog(fallbackBody), 12_000)}\n`,
|
||||
);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[openclaw] invoking fallback ${state.method} ${activeUrl} (transport=webhook kind=${activeEndpointKind})\n`,
|
||||
);
|
||||
|
||||
activeResponse = await sendWebhookRequest({
|
||||
url: activeUrl,
|
||||
method: state.method,
|
||||
headers: fallbackHeaders,
|
||||
payload: fallbackBody,
|
||||
onLog,
|
||||
signal: controller.signal,
|
||||
});
|
||||
activeHeaders = fallbackHeaders;
|
||||
}
|
||||
|
||||
if (!activeResponse.response.ok) {
|
||||
const canRetryWithWakeCompatibility =
|
||||
(activeEndpointKind === "open_responses" || activeEndpointKind === "generic") &&
|
||||
isWakeCompatibilityRetryableResponse(activeResponse.responseText);
|
||||
|
||||
if (canRetryWithWakeCompatibility) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
"[openclaw] endpoint requires text payload; retrying with wake compatibility format\n",
|
||||
);
|
||||
|
||||
const retryResponse = await sendWebhookRequest({
|
||||
url: activeUrl,
|
||||
method: state.method,
|
||||
headers: activeHeaders,
|
||||
payload: wakeCompatibilityBody,
|
||||
onLog,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (retryResponse.response.ok) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
provider: "openclaw",
|
||||
model: null,
|
||||
summary: `OpenClaw webhook ${state.method} ${activeUrl} (wake compatibility)`,
|
||||
resultJson: {
|
||||
status: retryResponse.response.status,
|
||||
statusText: retryResponse.response.statusText,
|
||||
compatibilityMode: "wake_text",
|
||||
usedLegacyResponsesFallback,
|
||||
response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
isTextRequiredResponse(retryResponse.responseText)
|
||||
? "OpenClaw endpoint rejected the wake compatibility payload as text-required."
|
||||
: `OpenClaw webhook failed with status ${retryResponse.response.status}`,
|
||||
errorCode: isTextRequiredResponse(retryResponse.responseText)
|
||||
? "openclaw_text_required"
|
||||
: "openclaw_http_error",
|
||||
resultJson: {
|
||||
status: retryResponse.response.status,
|
||||
statusText: retryResponse.response.statusText,
|
||||
compatibilityMode: "wake_text",
|
||||
response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
isTextRequiredResponse(activeResponse.responseText)
|
||||
? "OpenClaw endpoint rejected the payload as text-required."
|
||||
: `OpenClaw webhook failed with status ${activeResponse.response.status}`,
|
||||
errorCode: isTextRequiredResponse(activeResponse.responseText)
|
||||
? "openclaw_text_required"
|
||||
: "openclaw_http_error",
|
||||
resultJson: {
|
||||
status: activeResponse.response.status,
|
||||
statusText: activeResponse.response.statusText,
|
||||
response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
provider: "openclaw",
|
||||
model: null,
|
||||
summary: `OpenClaw webhook ${state.method} ${activeUrl}`,
|
||||
resultJson: {
|
||||
status: activeResponse.response.status,
|
||||
statusText: activeResponse.response.statusText,
|
||||
usedLegacyResponsesFallback,
|
||||
response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
const timeoutMessage =
|
||||
state.timeoutSec > 0
|
||||
? `[openclaw] webhook request timed out after ${state.timeoutSec}s\n`
|
||||
: "[openclaw] webhook request aborted\n";
|
||||
await onLog("stderr", timeoutMessage);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted",
|
||||
errorCode: "openclaw_webhook_timeout",
|
||||
};
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[openclaw] request failed: ${message}\n`);
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: message,
|
||||
errorCode: "openclaw_request_failed",
|
||||
};
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import { asString } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isHookEndpoint } from "./execute-common.js";
|
||||
import { executeSse } from "./execute-sse.js";
|
||||
import { executeWebhook } from "./execute-webhook.js";
|
||||
|
||||
function normalizeTransport(value: unknown): "sse" | "webhook" | null {
|
||||
const normalized = asString(value, "sse").trim().toLowerCase();
|
||||
if (!normalized || normalized === "sse") return "sse";
|
||||
if (normalized === "webhook") return "webhook";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const url = asString(ctx.config.url, "").trim();
|
||||
if (!url) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: "OpenClaw adapter missing url",
|
||||
errorCode: "openclaw_url_missing",
|
||||
};
|
||||
}
|
||||
|
||||
const transportInput = ctx.config.streamTransport ?? ctx.config.transport;
|
||||
const transport = normalizeTransport(transportInput);
|
||||
if (!transport) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `OpenClaw adapter does not support transport: ${String(transportInput)}`,
|
||||
errorCode: "openclaw_stream_transport_unsupported",
|
||||
};
|
||||
}
|
||||
|
||||
if (transport === "sse" && isHookEndpoint(url)) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: "OpenClaw /hooks/* endpoints are not stream-capable. Use webhook transport for hooks.",
|
||||
errorCode: "openclaw_sse_incompatible_endpoint",
|
||||
};
|
||||
}
|
||||
|
||||
if (transport === "webhook") {
|
||||
return executeWebhook(ctx, url);
|
||||
}
|
||||
|
||||
return executeSse(ctx, url);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { HireApprovedPayload, HireApprovedHookResult } from "@paperclipai/adapter-utils";
|
||||
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const HIRE_CALLBACK_TIMEOUT_MS = 10_000;
|
||||
|
||||
function nonEmpty(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenClaw adapter lifecycle hook: when an agent is approved/hired, POST the payload to a
|
||||
* configured callback URL so the cloud operator can notify the user (e.g. "you're hired").
|
||||
* Best-effort; failures are non-fatal to the approval flow.
|
||||
*/
|
||||
export async function onHireApproved(
|
||||
payload: HireApprovedPayload,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
): Promise<HireApprovedHookResult> {
|
||||
const config = parseObject(adapterConfig);
|
||||
const url = nonEmpty(config.hireApprovedCallbackUrl);
|
||||
if (!url) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const method = (asString(config.hireApprovedCallbackMethod, "POST").trim().toUpperCase()) || "POST";
|
||||
const authHeader = nonEmpty(config.hireApprovedCallbackAuthHeader) ?? nonEmpty(config.webhookAuthHeader);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (authHeader && !headers.authorization && !headers.Authorization) {
|
||||
headers.Authorization = authHeader;
|
||||
}
|
||||
const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(extraHeaders)) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
...payload,
|
||||
event: "hire_approved",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), HIRE_CALLBACK_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
error: `HTTP ${response.status} ${response.statusText}`,
|
||||
detail: { status: response.status, statusText: response.statusText, body: text.slice(0, 500) },
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const cause = err instanceof Error ? err.cause : undefined;
|
||||
return {
|
||||
ok: false,
|
||||
error: message,
|
||||
detail: cause != null ? { cause: String(cause) } : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js";
|
||||
export { onHireApproved } from "./hire-hook.js";
|
||||
@@ -1,15 +0,0 @@
|
||||
export function parseOpenClawResponse(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenClawUnknownSessionError(_text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||
}
|
||||
|
||||
function normalizeHostname(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith("[")) {
|
||||
const end = trimmed.indexOf("]");
|
||||
return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
|
||||
}
|
||||
const firstColon = trimmed.indexOf(":");
|
||||
if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase();
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function isWakePath(pathname: string): boolean {
|
||||
const value = pathname.trim().toLowerCase();
|
||||
return value === "/hooks/wake" || value.endsWith("/hooks/wake");
|
||||
}
|
||||
|
||||
function isHooksPath(pathname: string): boolean {
|
||||
const value = pathname.trim().toLowerCase();
|
||||
return (
|
||||
value === "/hooks" ||
|
||||
value.startsWith("/hooks/") ||
|
||||
value.endsWith("/hooks") ||
|
||||
value.includes("/hooks/")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTransport(value: unknown): "sse" | "webhook" | null {
|
||||
const normalized = asString(value, "sse").trim().toLowerCase();
|
||||
if (!normalized || normalized === "sse") return "sse";
|
||||
if (normalized === "webhook") return "webhook";
|
||||
return null;
|
||||
}
|
||||
|
||||
function pushDeploymentDiagnostics(
|
||||
checks: AdapterEnvironmentCheck[],
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
endpointUrl: URL | null,
|
||||
) {
|
||||
const mode = ctx.deployment?.mode;
|
||||
const exposure = ctx.deployment?.exposure;
|
||||
const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null);
|
||||
const allowSet = new Set(
|
||||
(ctx.deployment?.allowedHostnames ?? [])
|
||||
.map((entry) => normalizeHostname(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
);
|
||||
const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null;
|
||||
|
||||
if (!mode) return;
|
||||
|
||||
checks.push({
|
||||
code: "openclaw_deployment_context",
|
||||
level: "info",
|
||||
message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`,
|
||||
});
|
||||
|
||||
if (mode === "authenticated" && exposure === "private") {
|
||||
if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
|
||||
checks.push({
|
||||
code: "openclaw_private_bind_hostname_not_allowed",
|
||||
level: "warn",
|
||||
message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`,
|
||||
hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!bindHost || isLoopbackHost(bindHost)) {
|
||||
checks.push({
|
||||
code: "openclaw_private_bind_loopback",
|
||||
level: "warn",
|
||||
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
||||
hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.",
|
||||
});
|
||||
}
|
||||
|
||||
if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) {
|
||||
checks.push({
|
||||
code: "openclaw_private_no_allowed_hostnames",
|
||||
level: "warn",
|
||||
message: "No explicit allowed hostnames are configured for authenticated/private mode.",
|
||||
hint: "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs on another machine.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") {
|
||||
checks.push({
|
||||
code: "openclaw_public_http_endpoint",
|
||||
level: "warn",
|
||||
message: "OpenClaw endpoint uses HTTP in authenticated/public mode.",
|
||||
hint: "Prefer HTTPS for public deployments.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const urlValue = asString(config.url, "");
|
||||
const streamTransportValue = config.streamTransport ?? config.transport;
|
||||
const streamTransport = normalizeTransport(streamTransportValue);
|
||||
|
||||
if (!urlValue) {
|
||||
checks.push({
|
||||
code: "openclaw_url_missing",
|
||||
level: "error",
|
||||
message: "OpenClaw adapter requires an endpoint URL.",
|
||||
hint: "Set adapterConfig.url to your OpenClaw transport endpoint.",
|
||||
});
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
let url: URL | null = null;
|
||||
try {
|
||||
url = new URL(urlValue);
|
||||
} catch {
|
||||
checks.push({
|
||||
code: "openclaw_url_invalid",
|
||||
level: "error",
|
||||
message: `Invalid URL: ${urlValue}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (url && url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
checks.push({
|
||||
code: "openclaw_url_protocol_invalid",
|
||||
level: "error",
|
||||
message: `Unsupported URL protocol: ${url.protocol}`,
|
||||
hint: "Use an http:// or https:// endpoint.",
|
||||
});
|
||||
}
|
||||
|
||||
if (url) {
|
||||
checks.push({
|
||||
code: "openclaw_url_valid",
|
||||
level: "info",
|
||||
message: `Configured endpoint: ${url.toString()}`,
|
||||
});
|
||||
|
||||
if (isLoopbackHost(url.hostname)) {
|
||||
checks.push({
|
||||
code: "openclaw_loopback_endpoint",
|
||||
level: "warn",
|
||||
message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.",
|
||||
hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).",
|
||||
});
|
||||
}
|
||||
|
||||
if (streamTransport === "sse" && (isWakePath(url.pathname) || isHooksPath(url.pathname))) {
|
||||
checks.push({
|
||||
code: "openclaw_wake_endpoint_incompatible",
|
||||
level: "error",
|
||||
message: "Endpoint targets /hooks/*, which is not stream-capable for SSE transport.",
|
||||
hint: "Use webhook transport for /hooks/* endpoints.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamTransport) {
|
||||
checks.push({
|
||||
code: "openclaw_stream_transport_unsupported",
|
||||
level: "error",
|
||||
message: `Unsupported streamTransport: ${String(streamTransportValue)}`,
|
||||
hint: "Use streamTransport=sse or streamTransport=webhook.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "openclaw_stream_transport_configured",
|
||||
level: "info",
|
||||
message: `Configured stream transport: ${streamTransport}`,
|
||||
});
|
||||
}
|
||||
|
||||
pushDeploymentDiagnostics(checks, ctx, url);
|
||||
|
||||
const method = asString(config.method, "POST").trim().toUpperCase() || "POST";
|
||||
checks.push({
|
||||
code: "openclaw_method_configured",
|
||||
level: "info",
|
||||
message: `Configured method: ${method}`,
|
||||
});
|
||||
|
||||
if (url && (url.protocol === "http:" || url.protocol === "https:")) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(url, { method: "HEAD", signal: controller.signal });
|
||||
if (!response.ok && response.status !== 405 && response.status !== 501) {
|
||||
checks.push({
|
||||
code: "openclaw_endpoint_probe_unexpected_status",
|
||||
level: "warn",
|
||||
message: `Endpoint probe returned HTTP ${response.status}.`,
|
||||
hint: "Verify OpenClaw endpoint reachability and auth/network settings.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "openclaw_endpoint_probe_ok",
|
||||
level: "info",
|
||||
message: "Endpoint responded to a HEAD probe.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "openclaw_endpoint_probe_failed",
|
||||
level: "warn",
|
||||
message: err instanceof Error ? err.message : "Endpoint probe failed",
|
||||
hint: "This may be expected in restricted networks; validate from the Paperclip server host.",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export function normalizeOpenClawStreamLine(rawLine: string): {
|
||||
stream: "stdout" | "stderr" | null;
|
||||
line: string;
|
||||
} {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) return { stream: null, line: "" };
|
||||
|
||||
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i);
|
||||
if (!prefixed) {
|
||||
return { stream: null, line: trimmed };
|
||||
}
|
||||
|
||||
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
|
||||
const line = (prefixed[2] ?? "").trim();
|
||||
return { stream, line };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
export function buildOpenClawConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.url) ac.url = v.url;
|
||||
ac.method = "POST";
|
||||
ac.timeoutSec = 0;
|
||||
ac.streamTransport = "sse";
|
||||
ac.sessionKeyStrategy = "issue";
|
||||
return ac;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { parseOpenClawStdoutLine } from "./parse-stdout.js";
|
||||
export { buildOpenClawConfig } from "./build-config.js";
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import { normalizeOpenClawStreamLine } from "../shared/stream.js";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function readErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const obj = asRecord(value);
|
||||
if (!obj) return stringifyUnknown(value);
|
||||
return (
|
||||
asString(obj.message).trim() ||
|
||||
asString(obj.error).trim() ||
|
||||
asString(obj.code).trim() ||
|
||||
stringifyUnknown(obj)
|
||||
);
|
||||
}
|
||||
|
||||
function readDeltaText(payload: Record<string, unknown> | null): string {
|
||||
if (!payload) return "";
|
||||
|
||||
if (typeof payload.delta === "string") return payload.delta;
|
||||
|
||||
const deltaObj = asRecord(payload.delta);
|
||||
if (deltaObj) {
|
||||
const nestedDelta =
|
||||
asString(deltaObj.text) ||
|
||||
asString(deltaObj.value) ||
|
||||
asString(deltaObj.delta);
|
||||
if (nestedDelta.length > 0) return nestedDelta;
|
||||
}
|
||||
|
||||
const part = asRecord(payload.part);
|
||||
if (part) {
|
||||
const partText = asString(part.text);
|
||||
if (partText.length > 0) return partText;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractResponseOutputText(response: Record<string, unknown> | null): string {
|
||||
if (!response) return "";
|
||||
|
||||
const output = Array.isArray(response.output) ? response.output : [];
|
||||
const parts: string[] = [];
|
||||
for (const itemRaw of output) {
|
||||
const item = asRecord(itemRaw);
|
||||
if (!item) continue;
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim().toLowerCase();
|
||||
if (type !== "output_text" && type !== "text" && type !== "refusal") continue;
|
||||
const text = asString(part.text).trim();
|
||||
if (text) parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const match = line.match(/^\[openclaw:sse\]\s+event=([^\s]+)\s+data=(.*)$/s);
|
||||
if (!match) return [{ kind: "stdout", ts, text: line }];
|
||||
|
||||
const eventType = (match[1] ?? "").trim();
|
||||
const dataText = (match[2] ?? "").trim();
|
||||
const parsed = asRecord(safeJsonParse(dataText));
|
||||
const normalizedEventType = eventType.toLowerCase();
|
||||
|
||||
if (dataText === "[DONE]") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const delta = readDeltaText(parsed);
|
||||
if (normalizedEventType.endsWith(".delta") && delta.length > 0) {
|
||||
return [{ kind: "assistant", ts, text: delta, delta: true }];
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedEventType.includes("error") ||
|
||||
normalizedEventType.includes("failed") ||
|
||||
normalizedEventType.includes("cancel")
|
||||
) {
|
||||
const message = readErrorText(parsed?.error) || readErrorText(parsed?.message) || dataText;
|
||||
return message ? [{ kind: "stderr", ts, text: message }] : [];
|
||||
}
|
||||
|
||||
if (normalizedEventType === "response.completed" || normalizedEventType.endsWith(".completed")) {
|
||||
const response = asRecord(parsed?.response);
|
||||
const usage = asRecord(response?.usage);
|
||||
const status = asString(response?.status, asString(parsed?.status, eventType));
|
||||
const statusLower = status.trim().toLowerCase();
|
||||
const errorText =
|
||||
readErrorText(response?.error).trim() ||
|
||||
readErrorText(parsed?.error).trim() ||
|
||||
readErrorText(parsed?.message).trim();
|
||||
const isError =
|
||||
statusLower === "failed" ||
|
||||
statusLower === "error" ||
|
||||
statusLower === "cancelled";
|
||||
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: extractResponseOutputText(response),
|
||||
inputTokens: asNumber(usage?.input_tokens),
|
||||
outputTokens: asNumber(usage?.output_tokens),
|
||||
cachedTokens: asNumber(usage?.cached_input_tokens),
|
||||
costUsd: asNumber(usage?.cost_usd, asNumber(usage?.total_cost_usd)),
|
||||
subtype: status || eventType,
|
||||
isError,
|
||||
errors: errorText ? [errorText] : [],
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const normalized = normalizeOpenClawStreamLine(line);
|
||||
if (normalized.stream === "stderr") {
|
||||
return [{ kind: "stderr", ts, text: normalized.line }];
|
||||
}
|
||||
|
||||
const trimmed = normalized.line.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (trimmed.startsWith("[openclaw:sse]")) {
|
||||
return parseOpenClawSseLine(trimmed, ts);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[openclaw]")) {
|
||||
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw\]\s*/, "") }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ Use when:
|
||||
- You want OpenCode session resume across heartbeats via --session
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw or http)
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- OpenCode CLI is not installed on the machine
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Use when:
|
||||
- You need Pi's tool set (read, bash, edit, write, grep, find, ls)
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw or http)
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- Pi CLI is not installed on the machine
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"openclaw",
|
||||
"openclaw_gateway",
|
||||
] as const;
|
||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -35,9 +35,6 @@ importers:
|
||||
'@paperclipai/adapter-cursor-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/cursor-local
|
||||
'@paperclipai/adapter-openclaw':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw
|
||||
'@paperclipai/adapter-openclaw-gateway':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw-gateway
|
||||
@@ -139,22 +136,6 @@ importers:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/adapters/openclaw:
|
||||
dependencies:
|
||||
'@paperclipai/adapter-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../adapter-utils
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/adapters/openclaw-gateway:
|
||||
dependencies:
|
||||
'@paperclipai/adapter-utils':
|
||||
@@ -261,9 +242,6 @@ importers:
|
||||
'@paperclipai/adapter-cursor-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/cursor-local
|
||||
'@paperclipai/adapter-openclaw':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw
|
||||
'@paperclipai/adapter-openclaw-gateway':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw-gateway
|
||||
@@ -379,9 +357,6 @@ importers:
|
||||
'@paperclipai/adapter-cursor-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/cursor-local
|
||||
'@paperclipai/adapter-openclaw':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw
|
||||
'@paperclipai/adapter-openclaw-gateway':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw-gateway
|
||||
|
||||
@@ -33,7 +33,7 @@ const workspacePaths = [
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/opencode-local",
|
||||
"packages/adapters/openclaw",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
// Workspace packages that are NOT bundled and must stay as npm dependencies.
|
||||
|
||||
@@ -115,7 +115,7 @@ const { readFileSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
const root = '$REPO_ROOT';
|
||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway',
|
||||
'server', 'cli'];
|
||||
const names = [];
|
||||
for (const d of dirs) {
|
||||
@@ -221,7 +221,7 @@ const { resolve } = require('path');
|
||||
const root = '$REPO_ROOT';
|
||||
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway',
|
||||
'server', 'cli'];
|
||||
const names = [];
|
||||
for (const d of dirs) {
|
||||
@@ -279,7 +279,7 @@ pnpm --filter @paperclipai/db build
|
||||
pnpm --filter @paperclipai/adapter-claude-local build
|
||||
pnpm --filter @paperclipai/adapter-codex-local build
|
||||
pnpm --filter @paperclipai/adapter-opencode-local build
|
||||
pnpm --filter @paperclipai/adapter-openclaw build
|
||||
pnpm --filter @paperclipai/adapter-openclaw-gateway build
|
||||
pnpm --filter @paperclipai/server build
|
||||
|
||||
# Build UI and bundle into server package for static serving
|
||||
@@ -314,7 +314,7 @@ if [ "$dry_run" = true ]; then
|
||||
echo ""
|
||||
echo " Preview what would be published:"
|
||||
for dir in packages/shared packages/adapter-utils packages/db \
|
||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \
|
||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \
|
||||
server cli; do
|
||||
echo " --- $dir ---"
|
||||
cd "$REPO_ROOT/$dir"
|
||||
|
||||
@@ -179,24 +179,32 @@ if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then
|
||||
fi
|
||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
||||
assert_status "200"
|
||||
if ! grep -q "Paperclip OpenClaw Onboarding" <<<"$RESPONSE_BODY"; then
|
||||
if ! grep -q "Paperclip OpenClaw Gateway Onboarding" <<<"$RESPONSE_BODY"; then
|
||||
fail "onboarding.txt response missing expected header"
|
||||
fi
|
||||
|
||||
log "submitting OpenClaw agent join request"
|
||||
OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}"
|
||||
OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-${OPENCLAW_WEBHOOK_AUTH#Bearer }}"
|
||||
if [[ -z "$OPENCLAW_GATEWAY_TOKEN" ]]; then
|
||||
fail "OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_AUTH) is required for gateway join"
|
||||
fi
|
||||
|
||||
log "submitting OpenClaw gateway agent join request"
|
||||
JOIN_PAYLOAD="$(jq -nc \
|
||||
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||
--arg url "$OPENCLAW_WEBHOOK_URL" \
|
||||
--arg auth "$OPENCLAW_WEBHOOK_AUTH" \
|
||||
--arg url "$OPENCLAW_GATEWAY_URL" \
|
||||
--arg token "$OPENCLAW_GATEWAY_TOKEN" \
|
||||
'{
|
||||
requestType: "agent",
|
||||
agentName: $name,
|
||||
adapterType: "openclaw",
|
||||
capabilities: "Automated OpenClaw smoke harness",
|
||||
agentDefaultsPayload: (
|
||||
{ url: $url, method: "POST", timeoutSec: 30 }
|
||||
+ (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end)
|
||||
)
|
||||
adapterType: "openclaw_gateway",
|
||||
capabilities: "Automated OpenClaw gateway smoke harness",
|
||||
agentDefaultsPayload: {
|
||||
url: $url,
|
||||
headers: { "x-openclaw-token": $token },
|
||||
sessionKeyStrategy: "issue",
|
||||
waitTimeoutMs: 120000
|
||||
}
|
||||
}')"
|
||||
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
||||
assert_status "202"
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
|
||||
@@ -40,7 +40,7 @@ afterEach(() => {
|
||||
describe("notifyHireApproved", () => {
|
||||
it("writes success activity when adapter hook returns ok", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw",
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||
} as any);
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("notifyHireApproved", () => {
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -65,7 +65,7 @@ describe("notifyHireApproved", () => {
|
||||
expect.objectContaining({
|
||||
action: "hire_hook.succeeded",
|
||||
entityId: "a1",
|
||||
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw" }),
|
||||
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe("notifyHireApproved", () => {
|
||||
|
||||
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw",
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
||||
} as any);
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("notifyHireApproved", () => {
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -148,7 +148,7 @@ describe("notifyHireApproved", () => {
|
||||
|
||||
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw",
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
} as any);
|
||||
|
||||
@@ -156,7 +156,7 @@ describe("notifyHireApproved", () => {
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
119
server/src/__tests__/invite-accept-gateway-defaults.test.ts
Normal file
119
server/src/__tests__/invite-accept-gateway-defaults.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
normalizeAgentDefaultsForJoin,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => {
|
||||
it("leaves non-gateway payloads unchanged", () => {
|
||||
const defaultsPayload = { command: "echo hello" };
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "process",
|
||||
defaultsPayload,
|
||||
inboundOpenClawAuthHeader: "ignored-token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(defaultsPayload);
|
||||
});
|
||||
|
||||
it("normalizes wrapped x-openclaw-token header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": {
|
||||
value: "gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts inbound x-openclaw-token for gateway joins", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
},
|
||||
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("derives x-openclaw-token from authorization header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
authorization: "Bearer gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
authorization: "Bearer gateway-token-1234567890",
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => {
|
||||
it("generates persistent device key when device auth is enabled", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: false,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
|
||||
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
|
||||
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
|
||||
});
|
||||
|
||||
it("does not generate device key when disableDeviceAuth=true", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: true,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
|
||||
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
normalizeAgentDefaultsForJoin,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("buildJoinDefaultsPayloadForAccept", () => {
|
||||
it("maps OpenClaw compatibility fields into agent defaults", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: null,
|
||||
responsesWebhookUrl: "http://localhost:18789/v1/responses",
|
||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||
inboundOpenClawAuthHeader: "gateway-token",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "http://localhost:18789/v1/responses",
|
||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
url: "https://example.com/v1/responses",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-openclaw-auth": "existing-token",
|
||||
},
|
||||
paperclipApiUrl: "https://paperclip.example.com",
|
||||
},
|
||||
responsesWebhookUrl: "https://legacy.example.com/v1/responses",
|
||||
responsesWebhookMethod: "PUT",
|
||||
paperclipApiUrl: "https://legacy-paperclip.example.com",
|
||||
inboundOpenClawAuthHeader: "legacy-token",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "https://example.com/v1/responses",
|
||||
method: "POST",
|
||||
paperclipApiUrl: "https://paperclip.example.com",
|
||||
webhookAuthHeader: "Bearer existing-token",
|
||||
headers: {
|
||||
"x-openclaw-auth": "existing-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit webhookAuthHeader when configured", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
url: "https://example.com/v1/responses",
|
||||
webhookAuthHeader: "Bearer explicit-token",
|
||||
headers: {
|
||||
"x-openclaw-auth": "existing-token",
|
||||
},
|
||||
},
|
||||
inboundOpenClawAuthHeader: "legacy-token",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
webhookAuthHeader: "Bearer explicit-token",
|
||||
headers: {
|
||||
"x-openclaw-auth": "existing-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth from agentDefaultsPayload.headers.x-openclaw-auth", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
url: "http://127.0.0.1:18789/v1/responses",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth from agentDefaultsPayload.headers.x-openclaw-token", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
url: "http://127.0.0.1:18789/hooks/agent",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts inbound x-openclaw-token compatibility header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: null,
|
||||
inboundOpenClawTokenHeader: "gateway-token",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts wrapped auth values in headers for compatibility", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
headers: {
|
||||
"x-openclaw-auth": {
|
||||
value: "gateway-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth headers provided as tuple entries", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
headers: [["x-openclaw-auth", "gateway-token"]],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth headers provided as name/value entries", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
headers: [{ name: "x-openclaw-auth", value: { authToken: "gateway-token" } }],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth headers wrapped in a single unknown key", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
headers: {
|
||||
"x-openclaw-auth": {
|
||||
gatewayToken: "gateway-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-openclaw payloads unchanged", () => {
|
||||
const defaultsPayload = { command: "echo hello" };
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "process",
|
||||
defaultsPayload,
|
||||
responsesWebhookUrl: "https://ignored.example.com",
|
||||
inboundOpenClawAuthHeader: "ignored-token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(defaultsPayload);
|
||||
});
|
||||
|
||||
it("normalizes wrapped gateway token headers for openclaw_gateway", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": {
|
||||
value: "gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts inbound x-openclaw-token for openclaw_gateway", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
},
|
||||
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("generates persistent device key for openclaw_gateway when device auth is enabled", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: false,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
|
||||
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
|
||||
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
|
||||
});
|
||||
|
||||
it("does not generate device key when openclaw_gateway has disableDeviceAuth=true", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: true,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
|
||||
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
canReplayOpenClawInviteAccept,
|
||||
canReplayOpenClawGatewayInviteAccept,
|
||||
mergeJoinDefaultsPayloadForReplay,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("canReplayOpenClawInviteAccept", () => {
|
||||
it("allows replay only for openclaw agent joins in pending or approved state", () => {
|
||||
describe("canReplayOpenClawGatewayInviteAccept", () => {
|
||||
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "approved",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "rejected",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "human",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "process",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
@@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => {
|
||||
});
|
||||
|
||||
describe("mergeJoinDefaultsPayloadForReplay", () => {
|
||||
it("merges replay payloads and preserves existing fields while allowing auth/header overrides", () => {
|
||||
it("merges replay payloads and allows gateway token override", () => {
|
||||
const merged = mergeJoinDefaultsPayloadForReplay(
|
||||
{
|
||||
url: "https://old.example/v1/responses",
|
||||
method: "POST",
|
||||
url: "ws://old.example:18789",
|
||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||
headers: {
|
||||
"x-openclaw-auth": "old-token",
|
||||
"x-openclaw-token": "old-token-1234567890",
|
||||
"x-custom": "keep-me",
|
||||
},
|
||||
},
|
||||
{
|
||||
paperclipApiUrl: "https://paperclip.example.com",
|
||||
headers: {
|
||||
"x-openclaw-auth": "new-token",
|
||||
"x-openclaw-token": "new-token-1234567890",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const normalized = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: merged,
|
||||
inboundOpenClawAuthHeader: null,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(normalized.url).toBe("https://old.example/v1/responses");
|
||||
expect(normalized.url).toBe("ws://old.example:18789");
|
||||
expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com");
|
||||
expect(normalized.webhookAuthHeader).toBe("Bearer new-token");
|
||||
expect(normalized.headers).toMatchObject({
|
||||
"x-openclaw-auth": "new-token",
|
||||
"x-openclaw-token": "new-token-1234567890",
|
||||
"x-custom": "keep-me",
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,15 +26,6 @@ import {
|
||||
import {
|
||||
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
||||
} from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
execute as openclawExecute,
|
||||
testEnvironment as openclawTestEnvironment,
|
||||
onHireApproved as openclawOnHireApproved,
|
||||
} from "@paperclipai/adapter-openclaw/server";
|
||||
import {
|
||||
agentConfigurationDoc as openclawAgentConfigurationDoc,
|
||||
models as openclawModels,
|
||||
} from "@paperclipai/adapter-openclaw";
|
||||
import {
|
||||
execute as openclawGatewayExecute,
|
||||
testEnvironment as openclawGatewayTestEnvironment,
|
||||
@@ -89,16 +80,6 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openclawAdapter: ServerAdapterModule = {
|
||||
type: "openclaw",
|
||||
execute: openclawExecute,
|
||||
testEnvironment: openclawTestEnvironment,
|
||||
onHireApproved: openclawOnHireApproved,
|
||||
models: openclawModels,
|
||||
supportsLocalAgentJwt: false,
|
||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
execute: openclawGatewayExecute,
|
||||
@@ -137,7 +118,6 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
openCodeLocalAdapter,
|
||||
piLocalAdapter,
|
||||
cursorLocalAdapter,
|
||||
openclawAdapter,
|
||||
openclawGatewayAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
{ path: ["maxTurnsPerRun"], value: 80 },
|
||||
],
|
||||
openclaw: [
|
||||
{ path: ["method"], value: "POST" },
|
||||
{ path: ["timeoutSec"], value: 30 },
|
||||
],
|
||||
openclaw_gateway: [
|
||||
{ path: ["timeoutSec"], value: 120 },
|
||||
{ path: ["waitTimeoutMs"], value: 120000 },
|
||||
|
||||
@@ -253,7 +253,7 @@ npm dist-tag add @paperclipai/db@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-utils@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-claude-local@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-codex-local@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-openclaw@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-openclaw-gateway@{version} latest
|
||||
```
|
||||
|
||||
**Script option:** Add `./scripts/release.sh --promote {version}` to automate
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
|
||||
function SecretField({
|
||||
label,
|
||||
value,
|
||||
onCommit,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<DraftInput
|
||||
value={value}
|
||||
onCommit={onCommit}
|
||||
immediate
|
||||
type={visible ? "text" : "password"}
|
||||
className={inputClass + " pl-8"}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenClawConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const configuredHeaders =
|
||||
config.headers && typeof config.headers === "object" && !Array.isArray(config.headers)
|
||||
? (config.headers as Record<string, unknown>)
|
||||
: {};
|
||||
const effectiveHeaders =
|
||||
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
|
||||
const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string"
|
||||
? String(effectiveHeaders["x-openclaw-auth"])
|
||||
: "";
|
||||
|
||||
const commitGatewayAuthHeader = (rawValue: string) => {
|
||||
const nextValue = rawValue.trim();
|
||||
const nextHeaders: Record<string, unknown> = { ...effectiveHeaders };
|
||||
if (nextValue) {
|
||||
nextHeaders["x-openclaw-auth"] = nextValue;
|
||||
} else {
|
||||
delete nextHeaders["x-openclaw-auth"];
|
||||
}
|
||||
mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined);
|
||||
};
|
||||
|
||||
const transport = eff(
|
||||
"adapterConfig",
|
||||
"streamTransport",
|
||||
String(config.streamTransport ?? "sse"),
|
||||
);
|
||||
const sessionStrategy = eff(
|
||||
"adapterConfig",
|
||||
"sessionKeyStrategy",
|
||||
String(config.sessionKeyStrategy ?? "fixed"),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Gateway URL" hint={help.webhookUrl}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.url
|
||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ url: v })
|
||||
: mark("adapterConfig", "url", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Field>
|
||||
{!isCreate && (
|
||||
<>
|
||||
<Field label="Paperclip API URL override">
|
||||
<DraftInput
|
||||
value={
|
||||
eff(
|
||||
"adapterConfig",
|
||||
"paperclipApiUrl",
|
||||
String(config.paperclipApiUrl ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://paperclip.example"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Transport">
|
||||
<select
|
||||
value={transport}
|
||||
onChange={(e) => mark("adapterConfig", "streamTransport", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="sse">SSE (recommended)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Session strategy">
|
||||
<select
|
||||
value={sessionStrategy}
|
||||
onChange={(e) => mark("adapterConfig", "sessionKeyStrategy", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="issue">Per issue</option>
|
||||
<option value="run">Per run</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{sessionStrategy === "fixed" && (
|
||||
<Field label="Session key">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))}
|
||||
onCommit={(v) => mark("adapterConfig", "sessionKey", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="paperclip"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<SecretField
|
||||
label="Webhook auth header (optional)"
|
||||
value={eff("adapterConfig", "webhookAuthHeader", String(config.webhookAuthHeader ?? ""))}
|
||||
onCommit={(v) => mark("adapterConfig", "webhookAuthHeader", v || undefined)}
|
||||
placeholder="Bearer <token>"
|
||||
/>
|
||||
|
||||
<SecretField
|
||||
label="Gateway auth token (x-openclaw-auth)"
|
||||
value={effectiveGatewayAuthHeader}
|
||||
onCommit={commitGatewayAuthHeader}
|
||||
placeholder="OpenClaw gateway token"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui";
|
||||
import { buildOpenClawConfig } from "@paperclipai/adapter-openclaw/ui";
|
||||
import { OpenClawConfigFields } from "./config-fields";
|
||||
|
||||
export const openClawUIAdapter: UIAdapterModule = {
|
||||
type: "openclaw",
|
||||
label: "OpenClaw",
|
||||
parseStdoutLine: parseOpenClawStdoutLine,
|
||||
ConfigFields: OpenClawConfigFields,
|
||||
buildAdapterConfig: buildOpenClawConfig,
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { piLocalUIAdapter } from "./pi-local";
|
||||
import { openClawUIAdapter } from "./openclaw";
|
||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
@@ -16,7 +15,6 @@ const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
openClawUIAdapter,
|
||||
openClawGatewayUIAdapter,
|
||||
processUIAdapter,
|
||||
httpUIAdapter,
|
||||
|
||||
@@ -18,7 +18,6 @@ const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
|
||||
@@ -157,7 +157,7 @@ function parseStdoutChunk(
|
||||
if (!trimmed) continue;
|
||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
||||
if (parsed.length === 0) {
|
||||
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
|
||||
if (run.adapterType === "openclaw_gateway") {
|
||||
continue;
|
||||
}
|
||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
||||
|
||||
@@ -56,7 +56,6 @@ type AdapterType =
|
||||
| "cursor"
|
||||
| "process"
|
||||
| "http"
|
||||
| "openclaw"
|
||||
| "openclaw_gateway";
|
||||
|
||||
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md)
|
||||
@@ -971,7 +970,7 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
|
||||
{(adapterType === "http" || adapterType === "openclaw_gateway") && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
||||
role: "Organizational role. Determines position and capabilities.",
|
||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
|
||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||
model: "Override the default model used by the adapter.",
|
||||
@@ -53,7 +53,6 @@ export const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
|
||||
@@ -25,7 +25,6 @@ const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
@@ -315,7 +315,7 @@ export function CompanySettings() {
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Generate an openclaw agent invite snippet.
|
||||
Generate an OpenClaw agent invite snippet.
|
||||
</span>
|
||||
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
||||
</div>
|
||||
|
||||
@@ -10,16 +10,12 @@ import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
|
||||
type JoinType = "human" | "agent";
|
||||
const joinAdapterOptions: AgentAdapterType[] = [
|
||||
"openclaw",
|
||||
...AGENT_ADAPTER_TYPES.filter((type): type is Exclude<AgentAdapterType, "openclaw"> => type !== "openclaw"),
|
||||
];
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
|
||||
@@ -120,7 +120,6 @@ const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
Reference in New Issue
Block a user