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/claude-local/package.json packages/adapters/claude-local/
|
||||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-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/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/
|
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const workspacePaths = [
|
|||||||
"packages/adapter-utils",
|
"packages/adapter-utils",
|
||||||
"packages/adapters/claude-local",
|
"packages/adapters/claude-local",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
"packages/adapters/openclaw",
|
|
||||||
"packages/adapters/openclaw-gateway",
|
"packages/adapters/openclaw-gateway",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "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 { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-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 { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||||
import { processCLIAdapter } from "./process/index.js";
|
import { processCLIAdapter } from "./process/index.js";
|
||||||
import { httpCLIAdapter } from "./http/index.js";
|
import { httpCLIAdapter } from "./http/index.js";
|
||||||
@@ -34,11 +33,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
|||||||
formatStdoutEvent: printCursorStreamEvent,
|
formatStdoutEvent: printCursorStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const openclawCLIAdapter: CLIAdapterModule = {
|
|
||||||
type: "openclaw",
|
|
||||||
formatStdoutEvent: printOpenClawStreamEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||||
@@ -51,7 +45,6 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
|||||||
openCodeLocalCLIAdapter,
|
openCodeLocalCLIAdapter,
|
||||||
piLocalCLIAdapter,
|
piLocalCLIAdapter,
|
||||||
cursorLocalCLIAdapter,
|
cursorLocalCLIAdapter,
|
||||||
openclawCLIAdapter,
|
|
||||||
openclawGatewayCLIAdapter,
|
openclawGatewayCLIAdapter,
|
||||||
processCLIAdapter,
|
processCLIAdapter,
|
||||||
httpCLIAdapter,
|
httpCLIAdapter,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Use when:
|
|||||||
- You want structured stream output in run logs via --output-format stream-json
|
- You want structured stream output in run logs via --output-format stream-json
|
||||||
|
|
||||||
Don't use when:
|
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)
|
- You only need one-shot shell commands (use process)
|
||||||
- Cursor Agent CLI is not installed on the machine
|
- Cursor Agent CLI is not installed on the machine
|
||||||
|
|
||||||
|
|||||||
@@ -1,371 +1,109 @@
|
|||||||
# OpenClaw Gateway Onboarding and Test Plan
|
# 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
|
## Scope
|
||||||
Run this full flow per test cycle against company `CLA`:
|
This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only.
|
||||||
1. Assign task to OpenClaw agent -> agent executes -> task closes.
|
|
||||||
2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat.
|
|
||||||
3. OpenClaw in a fresh/new session can still create a Paperclip task.
|
|
||||||
4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup.
|
|
||||||
|
|
||||||
## 0) Cleanup Before Each Run
|
- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching)
|
||||||
Use deterministic reset to avoid stale agents/runs/state.
|
- 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
|
```bash
|
||||||
# stop/remove OpenClaw compose services
|
|
||||||
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
|
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
|
||||||
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
|
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
|
||||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
|
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# remove old image (as requested)
|
|
||||||
docker image rm openclaw:local || true
|
docker image rm openclaw:local || true
|
||||||
```
|
|
||||||
|
|
||||||
2. Recreate OpenClaw cleanly:
|
|
||||||
```bash
|
|
||||||
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
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:
|
### 1) Start Paperclip
|
||||||
- List `CLA` agents via API.
|
|
||||||
- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding.
|
|
||||||
|
|
||||||
4. Reject/clear stale pending join requests for CLA (optional but recommended).
|
|
||||||
|
|
||||||
## 1) Start Paperclip in Required Mode
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev --tailscale-auth
|
pnpm dev --tailscale-auth
|
||||||
```
|
|
||||||
Verify:
|
|
||||||
```bash
|
|
||||||
curl -fsS http://127.0.0.1:3100/api/health
|
curl -fsS http://127.0.0.1:3100/api/health
|
||||||
# expect deploymentMode=authenticated, deploymentExposure=private
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2) Acquire Board Session for Automation
|
### 2) Invite + join + approval
|
||||||
Board operations (create invite, approve join, terminate agents) require board session cookie.
|
- 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:
|
### 3) Pairing stabilization
|
||||||
1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`.
|
- if first run returns `pairing required`, approve pending device in OpenClaw
|
||||||
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).
|
- rerun task and confirm success
|
||||||
|
- assert later runs do not require re-pairing for same agent
|
||||||
|
|
||||||
Note:
|
### 4) Functional E2E assertions
|
||||||
- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow.
|
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
|
## Manual Smoke Checklist
|
||||||
With board cookie:
|
Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook.
|
||||||
|
|
||||||
|
## Regression Gates
|
||||||
|
Required before merge:
|
||||||
```bash
|
```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
|
If full suite is too heavy locally, run at least:
|
||||||
From host (using current OpenClaw token):
|
|
||||||
- For HTTP SSE mode: confirm `/v1/responses` behavior.
|
|
||||||
- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled.
|
|
||||||
- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`.
|
|
||||||
|
|
||||||
Expected in current docker smoke config:
|
|
||||||
- `/hooks/agent` likely `404` unless hooks explicitly enabled.
|
|
||||||
- WS gateway protocol works.
|
|
||||||
|
|
||||||
## 5) Gateway Join Flow (Primary Path)
|
|
||||||
|
|
||||||
1. Create agent-only invite in CLA:
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/companies/$CLA_COMPANY_ID/invites
|
pnpm --filter @paperclipai/server test:run -- openclaw-gateway
|
||||||
{ "allowedJoinTypes": "agent" }
|
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/*.
|
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
|
||||||
|
|
||||||
Don't use when:
|
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.
|
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
|
||||||
|
|
||||||
Core fields:
|
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
|
- You want OpenCode session resume across heartbeats via --session
|
||||||
|
|
||||||
Don't use when:
|
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)
|
- You only need one-shot shell commands (use process)
|
||||||
- OpenCode CLI is not installed on the machine
|
- 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)
|
- You need Pi's tool set (read, bash, edit, write, grep, find, ls)
|
||||||
|
|
||||||
Don't use when:
|
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)
|
- You only need one-shot shell commands (use process)
|
||||||
- Pi CLI is not installed on the machine
|
- Pi CLI is not installed on the machine
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export const AGENT_ADAPTER_TYPES = [
|
|||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
"openclaw",
|
|
||||||
"openclaw_gateway",
|
"openclaw_gateway",
|
||||||
] as const;
|
] as const;
|
||||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
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':
|
'@paperclipai/adapter-cursor-local':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/cursor-local
|
version: link:../packages/adapters/cursor-local
|
||||||
'@paperclipai/adapter-openclaw':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../packages/adapters/openclaw
|
|
||||||
'@paperclipai/adapter-openclaw-gateway':
|
'@paperclipai/adapter-openclaw-gateway':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw-gateway
|
version: link:../packages/adapters/openclaw-gateway
|
||||||
@@ -139,22 +136,6 @@ importers:
|
|||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.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:
|
packages/adapters/openclaw-gateway:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@paperclipai/adapter-utils':
|
'@paperclipai/adapter-utils':
|
||||||
@@ -261,9 +242,6 @@ importers:
|
|||||||
'@paperclipai/adapter-cursor-local':
|
'@paperclipai/adapter-cursor-local':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/cursor-local
|
version: link:../packages/adapters/cursor-local
|
||||||
'@paperclipai/adapter-openclaw':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../packages/adapters/openclaw
|
|
||||||
'@paperclipai/adapter-openclaw-gateway':
|
'@paperclipai/adapter-openclaw-gateway':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw-gateway
|
version: link:../packages/adapters/openclaw-gateway
|
||||||
@@ -379,9 +357,6 @@ importers:
|
|||||||
'@paperclipai/adapter-cursor-local':
|
'@paperclipai/adapter-cursor-local':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/cursor-local
|
version: link:../packages/adapters/cursor-local
|
||||||
'@paperclipai/adapter-openclaw':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../packages/adapters/openclaw
|
|
||||||
'@paperclipai/adapter-openclaw-gateway':
|
'@paperclipai/adapter-openclaw-gateway':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw-gateway
|
version: link:../packages/adapters/openclaw-gateway
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const workspacePaths = [
|
|||||||
"packages/adapters/claude-local",
|
"packages/adapters/claude-local",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
"packages/adapters/opencode-local",
|
"packages/adapters/opencode-local",
|
||||||
"packages/adapters/openclaw",
|
"packages/adapters/openclaw-gateway",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Workspace packages that are NOT bundled and must stay as npm dependencies.
|
// 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 { resolve } = require('path');
|
||||||
const root = '$REPO_ROOT';
|
const root = '$REPO_ROOT';
|
||||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
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'];
|
'server', 'cli'];
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const d of dirs) {
|
for (const d of dirs) {
|
||||||
@@ -221,7 +221,7 @@ const { resolve } = require('path');
|
|||||||
const root = '$REPO_ROOT';
|
const root = '$REPO_ROOT';
|
||||||
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
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'];
|
'server', 'cli'];
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const d of dirs) {
|
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-claude-local build
|
||||||
pnpm --filter @paperclipai/adapter-codex-local build
|
pnpm --filter @paperclipai/adapter-codex-local build
|
||||||
pnpm --filter @paperclipai/adapter-opencode-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
|
pnpm --filter @paperclipai/server build
|
||||||
|
|
||||||
# Build UI and bundle into server package for static serving
|
# Build UI and bundle into server package for static serving
|
||||||
@@ -314,7 +314,7 @@ if [ "$dry_run" = true ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Preview what would be published:"
|
echo " Preview what would be published:"
|
||||||
for dir in packages/shared packages/adapter-utils packages/db \
|
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
|
server cli; do
|
||||||
echo " --- $dir ---"
|
echo " --- $dir ---"
|
||||||
cd "$REPO_ROOT/$dir"
|
cd "$REPO_ROOT/$dir"
|
||||||
|
|||||||
@@ -179,24 +179,32 @@ if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then
|
|||||||
fi
|
fi
|
||||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
||||||
assert_status "200"
|
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"
|
fail "onboarding.txt response missing expected header"
|
||||||
fi
|
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 \
|
JOIN_PAYLOAD="$(jq -nc \
|
||||||
--arg name "$OPENCLAW_AGENT_NAME" \
|
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||||
--arg url "$OPENCLAW_WEBHOOK_URL" \
|
--arg url "$OPENCLAW_GATEWAY_URL" \
|
||||||
--arg auth "$OPENCLAW_WEBHOOK_AUTH" \
|
--arg token "$OPENCLAW_GATEWAY_TOKEN" \
|
||||||
'{
|
'{
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
agentName: $name,
|
agentName: $name,
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
capabilities: "Automated OpenClaw smoke harness",
|
capabilities: "Automated OpenClaw gateway smoke harness",
|
||||||
agentDefaultsPayload: (
|
agentDefaultsPayload: {
|
||||||
{ url: $url, method: "POST", timeoutSec: 30 }
|
url: $url,
|
||||||
+ (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end)
|
headers: { "x-openclaw-token": $token },
|
||||||
)
|
sessionKeyStrategy: "issue",
|
||||||
|
waitTimeoutMs: 120000
|
||||||
|
}
|
||||||
}')"
|
}')"
|
||||||
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
||||||
assert_status "202"
|
assert_status "202"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ afterEach(() => {
|
|||||||
describe("notifyHireApproved", () => {
|
describe("notifyHireApproved", () => {
|
||||||
it("writes success activity when adapter hook returns ok", async () => {
|
it("writes success activity when adapter hook returns ok", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findServerAdapter).mockReturnValue({
|
||||||
type: "openclaw",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe("notifyHireApproved", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
companyId: "c1",
|
companyId: "c1",
|
||||||
name: "OpenClaw Agent",
|
name: "OpenClaw Agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -65,7 +65,7 @@ describe("notifyHireApproved", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: "hire_hook.succeeded",
|
action: "hire_hook.succeeded",
|
||||||
entityId: "a1",
|
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 () => {
|
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findServerAdapter).mockReturnValue({
|
||||||
type: "openclaw",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ describe("notifyHireApproved", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
companyId: "c1",
|
companyId: "c1",
|
||||||
name: "OpenClaw Agent",
|
name: "OpenClaw Agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -148,7 +148,7 @@ describe("notifyHireApproved", () => {
|
|||||||
|
|
||||||
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findServerAdapter).mockReturnValue({
|
||||||
type: "openclaw",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ describe("notifyHireApproved", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
companyId: "c1",
|
companyId: "c1",
|
||||||
name: "OpenClaw Agent",
|
name: "OpenClaw Agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildJoinDefaultsPayloadForAccept,
|
buildJoinDefaultsPayloadForAccept,
|
||||||
canReplayOpenClawInviteAccept,
|
canReplayOpenClawGatewayInviteAccept,
|
||||||
mergeJoinDefaultsPayloadForReplay,
|
mergeJoinDefaultsPayloadForReplay,
|
||||||
} from "../routes/access.js";
|
} from "../routes/access.js";
|
||||||
|
|
||||||
describe("canReplayOpenClawInviteAccept", () => {
|
describe("canReplayOpenClawGatewayInviteAccept", () => {
|
||||||
it("allows replay only for openclaw agent joins in pending or approved state", () => {
|
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
|
||||||
expect(
|
expect(
|
||||||
canReplayOpenClawInviteAccept({
|
canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
existingJoinRequest: {
|
existingJoinRequest: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
status: "pending_approval",
|
status: "pending_approval",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
canReplayOpenClawInviteAccept({
|
canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
existingJoinRequest: {
|
existingJoinRequest: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
status: "approved",
|
status: "approved",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
canReplayOpenClawInviteAccept({
|
canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
existingJoinRequest: {
|
existingJoinRequest: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
status: "rejected",
|
status: "rejected",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
canReplayOpenClawInviteAccept({
|
canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType: "human",
|
requestType: "human",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
existingJoinRequest: {
|
existingJoinRequest: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
status: "pending_approval",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
canReplayOpenClawInviteAccept({
|
|
||||||
requestType: "agent",
|
|
||||||
adapterType: "process",
|
|
||||||
existingJoinRequest: {
|
|
||||||
requestType: "agent",
|
|
||||||
adapterType: "openclaw",
|
|
||||||
status: "pending_approval",
|
status: "pending_approval",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("mergeJoinDefaultsPayloadForReplay", () => {
|
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(
|
const merged = mergeJoinDefaultsPayloadForReplay(
|
||||||
{
|
{
|
||||||
url: "https://old.example/v1/responses",
|
url: "ws://old.example:18789",
|
||||||
method: "POST",
|
|
||||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||||
headers: {
|
headers: {
|
||||||
"x-openclaw-auth": "old-token",
|
"x-openclaw-token": "old-token-1234567890",
|
||||||
"x-custom": "keep-me",
|
"x-custom": "keep-me",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
paperclipApiUrl: "https://paperclip.example.com",
|
paperclipApiUrl: "https://paperclip.example.com",
|
||||||
headers: {
|
headers: {
|
||||||
"x-openclaw-auth": "new-token",
|
"x-openclaw-token": "new-token-1234567890",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalized = buildJoinDefaultsPayloadForAccept({
|
const normalized = buildJoinDefaultsPayloadForAccept({
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
defaultsPayload: merged,
|
defaultsPayload: merged,
|
||||||
inboundOpenClawAuthHeader: null,
|
inboundOpenClawAuthHeader: null,
|
||||||
}) as Record<string, unknown>;
|
}) 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.paperclipApiUrl).toBe("https://paperclip.example.com");
|
||||||
expect(normalized.webhookAuthHeader).toBe("Bearer new-token");
|
|
||||||
expect(normalized.headers).toMatchObject({
|
expect(normalized.headers).toMatchObject({
|
||||||
"x-openclaw-auth": "new-token",
|
"x-openclaw-token": "new-token-1234567890",
|
||||||
"x-custom": "keep-me",
|
"x-custom": "keep-me",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,15 +26,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
||||||
} from "@paperclipai/adapter-opencode-local";
|
} 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 {
|
import {
|
||||||
execute as openclawGatewayExecute,
|
execute as openclawGatewayExecute,
|
||||||
testEnvironment as openclawGatewayTestEnvironment,
|
testEnvironment as openclawGatewayTestEnvironment,
|
||||||
@@ -89,16 +80,6 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
const openclawAdapter: ServerAdapterModule = {
|
|
||||||
type: "openclaw",
|
|
||||||
execute: openclawExecute,
|
|
||||||
testEnvironment: openclawTestEnvironment,
|
|
||||||
onHireApproved: openclawOnHireApproved,
|
|
||||||
models: openclawModels,
|
|
||||||
supportsLocalAgentJwt: false,
|
|
||||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
|
||||||
};
|
|
||||||
|
|
||||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
execute: openclawGatewayExecute,
|
execute: openclawGatewayExecute,
|
||||||
@@ -137,7 +118,6 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
|||||||
openCodeLocalAdapter,
|
openCodeLocalAdapter,
|
||||||
piLocalAdapter,
|
piLocalAdapter,
|
||||||
cursorLocalAdapter,
|
cursorLocalAdapter,
|
||||||
openclawAdapter,
|
|
||||||
openclawGatewayAdapter,
|
openclawGatewayAdapter,
|
||||||
processAdapter,
|
processAdapter,
|
||||||
httpAdapter,
|
httpAdapter,
|
||||||
|
|||||||
@@ -136,19 +136,6 @@ function isLoopbackHost(hostname: string): boolean {
|
|||||||
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWakePath(pathname: string): boolean {
|
|
||||||
const value = pathname.trim().toLowerCase();
|
|
||||||
return value === "/hooks/wake" || value.endsWith("/hooks/wake");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOpenClawTransport(value: unknown): "sse" | "webhook" | null {
|
|
||||||
if (typeof value !== "string") return "sse";
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (!normalized || normalized === "sse") return "sse";
|
|
||||||
if (normalized === "webhook") return "webhook";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHostname(value: string | null | undefined): string | null {
|
function normalizeHostname(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -311,12 +298,6 @@ function headerMapGetIgnoreCase(
|
|||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAuthorizationHeaderValue(rawToken: string): string {
|
|
||||||
const trimmed = rawToken.trim();
|
|
||||||
if (!trimmed) return trimmed;
|
|
||||||
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenFromAuthorizationHeader(rawHeader: string | null): string | null {
|
function tokenFromAuthorizationHeader(rawHeader: string | null): string | null {
|
||||||
const trimmed = nonEmptyTrimmedString(rawHeader);
|
const trimmed = nonEmptyTrimmedString(rawHeader);
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -346,15 +327,14 @@ function generateEd25519PrivateKeyPem(): string {
|
|||||||
export function buildJoinDefaultsPayloadForAccept(input: {
|
export function buildJoinDefaultsPayloadForAccept(input: {
|
||||||
adapterType: string | null;
|
adapterType: string | null;
|
||||||
defaultsPayload: unknown;
|
defaultsPayload: unknown;
|
||||||
responsesWebhookUrl?: unknown;
|
|
||||||
responsesWebhookMethod?: unknown;
|
|
||||||
responsesWebhookHeaders?: unknown;
|
|
||||||
paperclipApiUrl?: unknown;
|
paperclipApiUrl?: unknown;
|
||||||
webhookAuthHeader?: unknown;
|
|
||||||
inboundOpenClawAuthHeader?: string | null;
|
inboundOpenClawAuthHeader?: string | null;
|
||||||
inboundOpenClawTokenHeader?: string | null;
|
inboundOpenClawTokenHeader?: string | null;
|
||||||
}): unknown {
|
}): unknown {
|
||||||
if (input.adapterType === "openclaw_gateway") {
|
if (input.adapterType !== "openclaw_gateway") {
|
||||||
|
return input.defaultsPayload;
|
||||||
|
}
|
||||||
|
|
||||||
const merged = isPlainObject(input.defaultsPayload)
|
const merged = isPlainObject(input.defaultsPayload)
|
||||||
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
||||||
: ({} as Record<string, unknown>);
|
: ({} as Record<string, unknown>);
|
||||||
@@ -363,7 +343,6 @@ export function buildJoinDefaultsPayloadForAccept(input: {
|
|||||||
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
||||||
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
||||||
|
|
||||||
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(
|
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(
|
||||||
@@ -385,6 +364,12 @@ export function buildJoinDefaultsPayloadForAccept(input: {
|
|||||||
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mergedHeaders).length > 0) {
|
||||||
|
merged.headers = mergedHeaders;
|
||||||
|
} else {
|
||||||
|
delete merged.headers;
|
||||||
|
}
|
||||||
|
|
||||||
const discoveredToken =
|
const discoveredToken =
|
||||||
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
|
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
|
||||||
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
|
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
|
||||||
@@ -398,102 +383,6 @@ export function buildJoinDefaultsPayloadForAccept(input: {
|
|||||||
mergedHeaders["x-openclaw-token"] = discoveredToken;
|
mergedHeaders["x-openclaw-token"] = discoveredToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(mergedHeaders).length > 0) {
|
|
||||||
merged.headers = mergedHeaders;
|
|
||||||
} else {
|
|
||||||
delete merged.headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(merged).length > 0 ? merged : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.adapterType !== "openclaw") {
|
|
||||||
return input.defaultsPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = isPlainObject(input.defaultsPayload)
|
|
||||||
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
|
||||||
: ({} as Record<string, unknown>);
|
|
||||||
|
|
||||||
if (!nonEmptyTrimmedString(merged.url)) {
|
|
||||||
const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl);
|
|
||||||
if (legacyUrl) merged.url = legacyUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nonEmptyTrimmedString(merged.method)) {
|
|
||||||
const legacyMethod = nonEmptyTrimmedString(input.responsesWebhookMethod);
|
|
||||||
if (legacyMethod) merged.method = legacyMethod.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
|
|
||||||
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
|
||||||
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) {
|
|
||||||
const providedWebhookAuthHeader = nonEmptyTrimmedString(
|
|
||||||
input.webhookAuthHeader
|
|
||||||
);
|
|
||||||
if (providedWebhookAuthHeader)
|
|
||||||
merged.webhookAuthHeader = providedWebhookAuthHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
|
||||||
const compatibilityHeaders = normalizeHeaderMap(
|
|
||||||
input.responsesWebhookHeaders
|
|
||||||
);
|
|
||||||
if (compatibilityHeaders) {
|
|
||||||
for (const [key, value] of Object.entries(compatibilityHeaders)) {
|
|
||||||
if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) {
|
|
||||||
mergedHeaders[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(
|
|
||||||
input.inboundOpenClawAuthHeader
|
|
||||||
);
|
|
||||||
const inboundOpenClawTokenHeader = nonEmptyTrimmedString(
|
|
||||||
input.inboundOpenClawTokenHeader
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
inboundOpenClawTokenHeader &&
|
|
||||||
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
|
|
||||||
) {
|
|
||||||
mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
inboundOpenClawAuthHeader &&
|
|
||||||
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")
|
|
||||||
) {
|
|
||||||
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(mergedHeaders).length > 0) {
|
|
||||||
merged.headers = mergedHeaders;
|
|
||||||
} else {
|
|
||||||
delete merged.headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAuthorizationHeader = headerMapHasKeyIgnoreCase(
|
|
||||||
mergedHeaders,
|
|
||||||
"authorization"
|
|
||||||
);
|
|
||||||
const hasWebhookAuthHeader = Boolean(
|
|
||||||
nonEmptyTrimmedString(merged.webhookAuthHeader)
|
|
||||||
);
|
|
||||||
if (!hasAuthorizationHeader && !hasWebhookAuthHeader) {
|
|
||||||
const openClawAuthToken =
|
|
||||||
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
|
|
||||||
headerMapGetIgnoreCase(
|
|
||||||
mergedHeaders,
|
|
||||||
"x-openclaw-auth"
|
|
||||||
);
|
|
||||||
if (openClawAuthToken) {
|
|
||||||
merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(merged).length > 0 ? merged : null;
|
return Object.keys(merged).length > 0 ? merged : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +426,7 @@ export function mergeJoinDefaultsPayloadForReplay(
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canReplayOpenClawInviteAccept(input: {
|
export function canReplayOpenClawGatewayInviteAccept(input: {
|
||||||
requestType: "human" | "agent";
|
requestType: "human" | "agent";
|
||||||
adapterType: string | null;
|
adapterType: string | null;
|
||||||
existingJoinRequest: Pick<
|
existingJoinRequest: Pick<
|
||||||
@@ -545,7 +434,10 @@ export function canReplayOpenClawInviteAccept(input: {
|
|||||||
"requestType" | "adapterType" | "status"
|
"requestType" | "adapterType" | "status"
|
||||||
> | null;
|
> | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (input.requestType !== "agent" || input.adapterType !== "openclaw") {
|
if (
|
||||||
|
input.requestType !== "agent" ||
|
||||||
|
input.adapterType !== "openclaw_gateway"
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!input.existingJoinRequest) {
|
if (!input.existingJoinRequest) {
|
||||||
@@ -553,7 +445,7 @@ export function canReplayOpenClawInviteAccept(input: {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
input.existingJoinRequest.requestType !== "agent" ||
|
input.existingJoinRequest.requestType !== "agent" ||
|
||||||
input.existingJoinRequest.adapterType !== "openclaw"
|
input.existingJoinRequest.adapterType !== "openclaw_gateway"
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -575,32 +467,6 @@ function summarizeSecretForLog(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) {
|
|
||||||
const defaults = isPlainObject(defaultsPayload)
|
|
||||||
? (defaultsPayload as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
|
|
||||||
const openClawAuthHeaderValue = headers
|
|
||||||
? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
|
||||||
headerMapGetIgnoreCase(headers, "x-openclaw-auth")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
present: Boolean(defaults),
|
|
||||||
keys: defaults ? Object.keys(defaults).sort() : [],
|
|
||||||
url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
|
|
||||||
method: defaults ? nonEmptyTrimmedString(defaults.method) : null,
|
|
||||||
paperclipApiUrl: defaults
|
|
||||||
? nonEmptyTrimmedString(defaults.paperclipApiUrl)
|
|
||||||
: null,
|
|
||||||
headerKeys: headers ? Object.keys(headers).sort() : [],
|
|
||||||
webhookAuthHeader: defaults
|
|
||||||
? summarizeSecretForLog(defaults.webhookAuthHeader)
|
|
||||||
: null,
|
|
||||||
openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
|
function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
|
||||||
const defaults = isPlainObject(defaultsPayload)
|
const defaults = isPlainObject(defaultsPayload)
|
||||||
? (defaultsPayload as Record<string, unknown>)
|
? (defaultsPayload as Record<string, unknown>)
|
||||||
@@ -638,79 +504,6 @@ function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildJoinConnectivityDiagnostics(input: {
|
|
||||||
deploymentMode: DeploymentMode;
|
|
||||||
deploymentExposure: DeploymentExposure;
|
|
||||||
bindHost: string;
|
|
||||||
allowedHostnames: string[];
|
|
||||||
callbackUrl: URL | null;
|
|
||||||
}): JoinDiagnostic[] {
|
|
||||||
const diagnostics: JoinDiagnostic[] = [];
|
|
||||||
const bindHost = normalizeHostname(input.bindHost);
|
|
||||||
const callbackHost = input.callbackUrl
|
|
||||||
? normalizeHostname(input.callbackUrl.hostname)
|
|
||||||
: null;
|
|
||||||
const allowSet = new Set(
|
|
||||||
input.allowedHostnames
|
|
||||||
.map((entry) => normalizeHostname(entry))
|
|
||||||
.filter((entry): entry is string => Boolean(entry))
|
|
||||||
);
|
|
||||||
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_deployment_context",
|
|
||||||
level: "info",
|
|
||||||
message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.`
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
input.deploymentMode === "authenticated" &&
|
|
||||||
input.deploymentExposure === "private"
|
|
||||||
) {
|
|
||||||
if (!bindHost || isLoopbackHost(bindHost)) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_private_bind_loopback",
|
|
||||||
level: "warn",
|
|
||||||
message:
|
|
||||||
"Paperclip is bound to loopback in authenticated/private mode.",
|
|
||||||
hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_private_bind_not_allowed",
|
|
||||||
level: "warn",
|
|
||||||
message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`,
|
|
||||||
hint: `Run pnpm paperclipai allowed-hostname ${bindHost}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_private_allowed_hostnames_empty",
|
|
||||||
level: "warn",
|
|
||||||
message:
|
|
||||||
"No explicit allowed hostnames are configured for authenticated/private mode.",
|
|
||||||
hint: "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs off-host."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
input.deploymentMode === "authenticated" &&
|
|
||||||
input.deploymentExposure === "public" &&
|
|
||||||
input.callbackUrl &&
|
|
||||||
input.callbackUrl.protocol !== "https:"
|
|
||||||
) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_public_http_callback",
|
|
||||||
level: "warn",
|
|
||||||
message: "OpenClaw callback URL uses HTTP in authenticated/public mode.",
|
|
||||||
hint: "Prefer HTTPS for public deployments."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return diagnostics;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAgentDefaultsForJoin(input: {
|
export function normalizeAgentDefaultsForJoin(input: {
|
||||||
adapterType: string | null;
|
adapterType: string | null;
|
||||||
defaultsPayload: unknown;
|
defaultsPayload: unknown;
|
||||||
@@ -721,17 +514,13 @@ export function normalizeAgentDefaultsForJoin(input: {
|
|||||||
}) {
|
}) {
|
||||||
const fatalErrors: string[] = [];
|
const fatalErrors: string[] = [];
|
||||||
const diagnostics: JoinDiagnostic[] = [];
|
const diagnostics: JoinDiagnostic[] = [];
|
||||||
if (
|
if (input.adapterType !== "openclaw_gateway") {
|
||||||
input.adapterType !== "openclaw" &&
|
|
||||||
input.adapterType !== "openclaw_gateway"
|
|
||||||
) {
|
|
||||||
const normalized = isPlainObject(input.defaultsPayload)
|
const normalized = isPlainObject(input.defaultsPayload)
|
||||||
? (input.defaultsPayload as Record<string, unknown>)
|
? (input.defaultsPayload as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
return { normalized, diagnostics, fatalErrors };
|
return { normalized, diagnostics, fatalErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.adapterType === "openclaw_gateway") {
|
|
||||||
if (!isPlainObject(input.defaultsPayload)) {
|
if (!isPlainObject(input.defaultsPayload)) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
code: "openclaw_gateway_defaults_missing",
|
code: "openclaw_gateway_defaults_missing",
|
||||||
@@ -767,10 +556,7 @@ export function normalizeAgentDefaultsForJoin(input: {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
gatewayUrl = new URL(rawGatewayUrl);
|
gatewayUrl = new URL(rawGatewayUrl);
|
||||||
if (
|
if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") {
|
||||||
gatewayUrl.protocol !== "ws:" &&
|
|
||||||
gatewayUrl.protocol !== "wss:"
|
|
||||||
) {
|
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
code: "openclaw_gateway_url_protocol",
|
code: "openclaw_gateway_url_protocol",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
@@ -802,10 +588,7 @@ export function normalizeAgentDefaultsForJoin(input: {
|
|||||||
headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
||||||
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
||||||
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
|
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
|
||||||
if (
|
if (gatewayToken && !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")) {
|
||||||
gatewayToken &&
|
|
||||||
!headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")
|
|
||||||
) {
|
|
||||||
headers["x-openclaw-token"] = gatewayToken;
|
headers["x-openclaw-token"] = gatewayToken;
|
||||||
}
|
}
|
||||||
if (Object.keys(headers).length > 0) {
|
if (Object.keys(headers).length > 0) {
|
||||||
@@ -971,198 +754,6 @@ export function normalizeAgentDefaultsForJoin(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { normalized, diagnostics, fatalErrors };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPlainObject(input.defaultsPayload)) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_config_missing",
|
|
||||||
level: "warn",
|
|
||||||
message:
|
|
||||||
"No OpenClaw callback config was provided in agentDefaultsPayload.",
|
|
||||||
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval."
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
normalized: null as Record<string, unknown> | null,
|
|
||||||
diagnostics,
|
|
||||||
fatalErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaults = input.defaultsPayload as Record<string, unknown>;
|
|
||||||
const streamTransportInput = defaults.streamTransport ?? defaults.transport;
|
|
||||||
const streamTransport = normalizeOpenClawTransport(streamTransportInput);
|
|
||||||
const normalized: Record<string, unknown> = { streamTransport: "sse" };
|
|
||||||
if (!streamTransport) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_stream_transport_unsupported",
|
|
||||||
level: "warn",
|
|
||||||
message: `Unsupported streamTransport: ${String(streamTransportInput)}`,
|
|
||||||
hint: "Use streamTransport=sse or streamTransport=webhook."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
normalized.streamTransport = streamTransport;
|
|
||||||
}
|
|
||||||
|
|
||||||
let callbackUrl: URL | null = null;
|
|
||||||
const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : "";
|
|
||||||
if (!rawUrl) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_url_missing",
|
|
||||||
level: "warn",
|
|
||||||
message: "OpenClaw callback URL is missing.",
|
|
||||||
hint: "Set agentDefaultsPayload.url to your OpenClaw endpoint."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
callbackUrl = new URL(rawUrl);
|
|
||||||
if (
|
|
||||||
callbackUrl.protocol !== "http:" &&
|
|
||||||
callbackUrl.protocol !== "https:"
|
|
||||||
) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_url_protocol",
|
|
||||||
level: "warn",
|
|
||||||
message: `Unsupported callback protocol: ${callbackUrl.protocol}`,
|
|
||||||
hint: "Use http:// or https://."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
normalized.url = callbackUrl.toString();
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_url_configured",
|
|
||||||
level: "info",
|
|
||||||
message: `Callback endpoint set to ${callbackUrl.toString()}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if ((streamTransport ?? "sse") === "sse" && isWakePath(callbackUrl.pathname)) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_wake_path_incompatible",
|
|
||||||
level: "warn",
|
|
||||||
message:
|
|
||||||
"Configured callback path targets /hooks/wake, which is not stream-capable for SSE transport.",
|
|
||||||
hint: "Use an endpoint that returns text/event-stream for the full run duration."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isLoopbackHost(callbackUrl.hostname)) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_loopback",
|
|
||||||
level: "warn",
|
|
||||||
message: "OpenClaw callback endpoint uses loopback hostname.",
|
|
||||||
hint: "Use a reachable hostname/IP when OpenClaw runs on another machine."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_url_invalid",
|
|
||||||
level: "warn",
|
|
||||||
message: `Invalid callback URL: ${rawUrl}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawMethod =
|
|
||||||
typeof defaults.method === "string"
|
|
||||||
? defaults.method.trim().toUpperCase()
|
|
||||||
: "";
|
|
||||||
normalized.method = rawMethod || "POST";
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof defaults.timeoutSec === "number" &&
|
|
||||||
Number.isFinite(defaults.timeoutSec)
|
|
||||||
) {
|
|
||||||
normalized.timeoutSec = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(7200, Math.floor(defaults.timeoutSec))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = normalizeHeaderMap(defaults.headers);
|
|
||||||
if (headers) normalized.headers = headers;
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof defaults.webhookAuthHeader === "string" &&
|
|
||||||
defaults.webhookAuthHeader.trim()
|
|
||||||
) {
|
|
||||||
normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const openClawAuthHeader = headers
|
|
||||||
? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
|
||||||
headerMapGetIgnoreCase(headers, "x-openclaw-auth")
|
|
||||||
: null;
|
|
||||||
if (openClawAuthHeader) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_auth_header_configured",
|
|
||||||
level: "info",
|
|
||||||
message:
|
|
||||||
"Gateway auth token received via headers.x-openclaw-token (or legacy x-openclaw-auth)."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_auth_header_missing",
|
|
||||||
level: "warn",
|
|
||||||
message: "Gateway auth token is missing from agent defaults.",
|
|
||||||
hint:
|
|
||||||
"Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth) to the token your OpenClaw endpoint requires."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlainObject(defaults.payloadTemplate)) {
|
|
||||||
normalized.payloadTemplate = defaults.payloadTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPaperclipApiUrl =
|
|
||||||
typeof defaults.paperclipApiUrl === "string"
|
|
||||||
? defaults.paperclipApiUrl.trim()
|
|
||||||
: "";
|
|
||||||
if (rawPaperclipApiUrl) {
|
|
||||||
try {
|
|
||||||
const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
|
|
||||||
if (
|
|
||||||
parsedPaperclipApiUrl.protocol !== "http:" &&
|
|
||||||
parsedPaperclipApiUrl.protocol !== "https:"
|
|
||||||
) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_paperclip_api_url_protocol",
|
|
||||||
level: "warn",
|
|
||||||
message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString();
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_paperclip_api_url_configured",
|
|
||||||
level: "info",
|
|
||||||
message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`
|
|
||||||
});
|
|
||||||
if (isLoopbackHost(parsedPaperclipApiUrl.hostname)) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_paperclip_api_url_loopback",
|
|
||||||
level: "warn",
|
|
||||||
message:
|
|
||||||
"paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.",
|
|
||||||
hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_paperclip_api_url_invalid",
|
|
||||||
level: "warn",
|
|
||||||
message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diagnostics.push(
|
|
||||||
...buildJoinConnectivityDiagnostics({
|
|
||||||
deploymentMode: input.deploymentMode,
|
|
||||||
deploymentExposure: input.deploymentExposure,
|
|
||||||
bindHost: input.bindHost,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
callbackUrl
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return { normalized, diagnostics, fatalErrors };
|
return { normalized, diagnostics, fatalErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2309,7 +1900,7 @@ export function accessRoutes(
|
|||||||
const adapterType = req.body.adapterType ?? null;
|
const adapterType = req.body.adapterType ?? null;
|
||||||
if (
|
if (
|
||||||
inviteAlreadyAccepted &&
|
inviteAlreadyAccepted &&
|
||||||
!canReplayOpenClawInviteAccept({
|
!canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType,
|
requestType,
|
||||||
adapterType,
|
adapterType,
|
||||||
existingJoinRequest: existingJoinRequestForInvite
|
existingJoinRequest: existingJoinRequestForInvite
|
||||||
@@ -2331,59 +1922,22 @@ export function accessRoutes(
|
|||||||
)
|
)
|
||||||
: req.body.agentDefaultsPayload ?? null;
|
: req.body.agentDefaultsPayload ?? null;
|
||||||
|
|
||||||
const openClawDefaultsPayload =
|
const gatewayDefaultsPayload =
|
||||||
requestType === "agent"
|
requestType === "agent"
|
||||||
? buildJoinDefaultsPayloadForAccept({
|
? buildJoinDefaultsPayloadForAccept({
|
||||||
adapterType,
|
adapterType,
|
||||||
defaultsPayload: replayMergedDefaults,
|
defaultsPayload: replayMergedDefaults,
|
||||||
responsesWebhookUrl: req.body.responsesWebhookUrl ?? null,
|
|
||||||
responsesWebhookMethod: req.body.responsesWebhookMethod ?? null,
|
|
||||||
responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null,
|
|
||||||
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
|
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
|
||||||
webhookAuthHeader: req.body.webhookAuthHeader ?? null,
|
|
||||||
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
|
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
|
||||||
inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null
|
inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (requestType === "agent" && adapterType === "openclaw") {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
inviteId: invite.id,
|
|
||||||
requestType,
|
|
||||||
adapterType,
|
|
||||||
bodyKeys: isPlainObject(req.body)
|
|
||||||
? Object.keys(req.body).sort()
|
|
||||||
: [],
|
|
||||||
responsesWebhookUrl: nonEmptyTrimmedString(
|
|
||||||
req.body.responsesWebhookUrl
|
|
||||||
),
|
|
||||||
paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl),
|
|
||||||
webhookAuthHeader: summarizeSecretForLog(
|
|
||||||
req.body.webhookAuthHeader
|
|
||||||
),
|
|
||||||
inboundOpenClawAuthHeader: summarizeSecretForLog(
|
|
||||||
req.header("x-openclaw-auth") ?? null
|
|
||||||
),
|
|
||||||
inboundOpenClawTokenHeader: summarizeSecretForLog(
|
|
||||||
req.header("x-openclaw-token") ?? null
|
|
||||||
),
|
|
||||||
rawAgentDefaults: summarizeOpenClawDefaultsForLog(
|
|
||||||
req.body.agentDefaultsPayload ?? null
|
|
||||||
),
|
|
||||||
mergedAgentDefaults: summarizeOpenClawDefaultsForLog(
|
|
||||||
openClawDefaultsPayload
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"invite accept received OpenClaw join payload"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const joinDefaults =
|
const joinDefaults =
|
||||||
requestType === "agent"
|
requestType === "agent"
|
||||||
? normalizeAgentDefaultsForJoin({
|
? normalizeAgentDefaultsForJoin({
|
||||||
adapterType,
|
adapterType,
|
||||||
defaultsPayload: openClawDefaultsPayload,
|
defaultsPayload: gatewayDefaultsPayload,
|
||||||
deploymentMode: opts.deploymentMode,
|
deploymentMode: opts.deploymentMode,
|
||||||
deploymentExposure: opts.deploymentExposure,
|
deploymentExposure: opts.deploymentExposure,
|
||||||
bindHost: opts.bindHost,
|
bindHost: opts.bindHost,
|
||||||
@@ -2399,22 +1953,6 @@ export function accessRoutes(
|
|||||||
throw badRequest(joinDefaults.fatalErrors.join("; "));
|
throw badRequest(joinDefaults.fatalErrors.join("; "));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestType === "agent" && adapterType === "openclaw") {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
inviteId: invite.id,
|
|
||||||
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
|
|
||||||
code: diag.code,
|
|
||||||
level: diag.level
|
|
||||||
})),
|
|
||||||
normalizedAgentDefaults: summarizeOpenClawDefaultsForLog(
|
|
||||||
joinDefaults.normalized
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"invite accept normalized OpenClaw defaults"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -2516,7 +2054,7 @@ export function accessRoutes(
|
|||||||
if (
|
if (
|
||||||
inviteAlreadyAccepted &&
|
inviteAlreadyAccepted &&
|
||||||
requestType === "agent" &&
|
requestType === "agent" &&
|
||||||
adapterType === "openclaw" &&
|
adapterType === "openclaw_gateway" &&
|
||||||
created.status === "approved" &&
|
created.status === "approved" &&
|
||||||
created.createdAgentId
|
created.createdAgentId
|
||||||
) {
|
) {
|
||||||
@@ -2552,11 +2090,11 @@ export function accessRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestType === "agent" && adapterType === "openclaw") {
|
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
||||||
const expectedDefaults = summarizeOpenClawDefaultsForLog(
|
const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog(
|
||||||
joinDefaults.normalized
|
joinDefaults.normalized
|
||||||
);
|
);
|
||||||
const persistedDefaults = summarizeOpenClawDefaultsForLog(
|
const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog(
|
||||||
created.agentDefaultsPayload
|
created.agentDefaultsPayload
|
||||||
);
|
);
|
||||||
const missingPersistedFields: string[] = [];
|
const missingPersistedFields: string[] = [];
|
||||||
@@ -2569,19 +2107,14 @@ export function accessRoutes(
|
|||||||
) {
|
) {
|
||||||
missingPersistedFields.push("paperclipApiUrl");
|
missingPersistedFields.push("paperclipApiUrl");
|
||||||
}
|
}
|
||||||
if (
|
if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) {
|
||||||
expectedDefaults.webhookAuthHeader &&
|
missingPersistedFields.push("headers.x-openclaw-token");
|
||||||
!persistedDefaults.webhookAuthHeader
|
|
||||||
) {
|
|
||||||
missingPersistedFields.push("webhookAuthHeader");
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
expectedDefaults.openClawAuthHeader &&
|
expectedDefaults.devicePrivateKeyPem &&
|
||||||
!persistedDefaults.openClawAuthHeader
|
!persistedDefaults.devicePrivateKeyPem
|
||||||
) {
|
) {
|
||||||
missingPersistedFields.push(
|
missingPersistedFields.push("devicePrivateKeyPem");
|
||||||
"headers.x-openclaw-token|headers.x-openclaw-auth"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
expectedDefaults.headerKeys.length > 0 &&
|
expectedDefaults.headerKeys.length > 0 &&
|
||||||
@@ -2604,7 +2137,7 @@ export function accessRoutes(
|
|||||||
hint: diag.hint ?? null
|
hint: diag.hint ?? null
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
"invite accept persisted OpenClaw join request"
|
"invite accept persisted OpenClaw gateway join request"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (missingPersistedFields.length > 0) {
|
if (missingPersistedFields.length > 0) {
|
||||||
@@ -2614,7 +2147,7 @@ export function accessRoutes(
|
|||||||
joinRequestId: created.id,
|
joinRequestId: created.id,
|
||||||
missingPersistedFields
|
missingPersistedFields
|
||||||
},
|
},
|
||||||
"invite accept detected missing persisted OpenClaw defaults"
|
"invite accept detected missing persisted OpenClaw gateway defaults"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||||||
{ path: ["graceSec"], value: 15 },
|
{ path: ["graceSec"], value: 15 },
|
||||||
{ path: ["maxTurnsPerRun"], value: 80 },
|
{ path: ["maxTurnsPerRun"], value: 80 },
|
||||||
],
|
],
|
||||||
openclaw: [
|
|
||||||
{ path: ["method"], value: "POST" },
|
|
||||||
{ path: ["timeoutSec"], value: 30 },
|
|
||||||
],
|
|
||||||
openclaw_gateway: [
|
openclaw_gateway: [
|
||||||
{ path: ["timeoutSec"], value: 120 },
|
{ path: ["timeoutSec"], value: 120 },
|
||||||
{ path: ["waitTimeoutMs"], value: 120000 },
|
{ 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-utils@{version} latest
|
||||||
npm dist-tag add @paperclipai/adapter-claude-local@{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-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
|
**Script option:** Add `./scripts/release.sh --promote {version}` to automate
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "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 { cursorLocalUIAdapter } from "./cursor";
|
||||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { piLocalUIAdapter } from "./pi-local";
|
import { piLocalUIAdapter } from "./pi-local";
|
||||||
import { openClawUIAdapter } from "./openclaw";
|
|
||||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||||
import { processUIAdapter } from "./process";
|
import { processUIAdapter } from "./process";
|
||||||
import { httpUIAdapter } from "./http";
|
import { httpUIAdapter } from "./http";
|
||||||
@@ -16,7 +15,6 @@ const adaptersByType = new Map<string, UIAdapterModule>(
|
|||||||
openCodeLocalUIAdapter,
|
openCodeLocalUIAdapter,
|
||||||
piLocalUIAdapter,
|
piLocalUIAdapter,
|
||||||
cursorLocalUIAdapter,
|
cursorLocalUIAdapter,
|
||||||
openClawUIAdapter,
|
|
||||||
openClawGatewayUIAdapter,
|
openClawGatewayUIAdapter,
|
||||||
processUIAdapter,
|
processUIAdapter,
|
||||||
httpUIAdapter,
|
httpUIAdapter,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const adapterLabels: Record<string, string> = {
|
|||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ function parseStdoutChunk(
|
|||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
|
if (run.adapterType === "openclaw_gateway") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ type AdapterType =
|
|||||||
| "cursor"
|
| "cursor"
|
||||||
| "process"
|
| "process"
|
||||||
| "http"
|
| "http"
|
||||||
| "openclaw"
|
|
||||||
| "openclaw_gateway";
|
| "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)
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
|
{(adapterType === "http" || adapterType === "openclaw_gateway") && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
|||||||
role: "Organizational role. Determines position and capabilities.",
|
role: "Organizational role. Determines position and capabilities.",
|
||||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
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.",
|
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.",
|
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.",
|
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.",
|
model: "Override the default model used by the adapter.",
|
||||||
@@ -53,7 +53,6 @@ export const adapterLabels: Record<string, string> = {
|
|||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
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="space-y-3 rounded-md border border-border px-4 py-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Generate an openclaw agent invite snippet.
|
Generate an OpenClaw agent invite snippet.
|
||||||
</span>
|
</span>
|
||||||
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
|||||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||||
|
|
||||||
type JoinType = "human" | "agent";
|
type JoinType = "human" | "agent";
|
||||||
const joinAdapterOptions: AgentAdapterType[] = [
|
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||||
"openclaw",
|
|
||||||
...AGENT_ADAPTER_TYPES.filter((type): type is Exclude<AgentAdapterType, "openclaw"> => type !== "openclaw"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
Reference in New Issue
Block a user