Merge pull request #270 from paperclipai/openclawgateway
Openclaw gateway
This commit is contained in:
@@ -16,7 +16,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
COPY packages/adapters/openclaw/package.json packages/adapters/openclaw/
|
||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ const workspacePaths = [
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/openclaw",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||
import { processCLIAdapter } from "./process/index.js";
|
||||
import { httpCLIAdapter } from "./http/index.js";
|
||||
@@ -34,11 +33,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const openclawCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw",
|
||||
formatStdoutEvent: printOpenClawStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
@@ -51,7 +45,6 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
openclawCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
|
||||
@@ -197,10 +197,16 @@ export function registerAgentCommands(program: Command): void {
|
||||
const agentRow = await ctx.api.get<Agent>(
|
||||
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
|
||||
);
|
||||
if (!agentRow) {
|
||||
throw new Error(`Agent not found: ${agentRef}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().replaceAll(":", "-");
|
||||
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
|
||||
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
|
||||
if (!key) {
|
||||
throw new Error("Failed to create API key");
|
||||
}
|
||||
|
||||
const installSummaries: SkillsInstallSummary[] = [];
|
||||
if (opts.installSkills !== false) {
|
||||
|
||||
@@ -18,20 +18,28 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
|
||||
|
||||
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
|
||||
|
||||
4. Use the agent snippet flow.
|
||||
- Copy the snippet from company settings.
|
||||
4. Use the OpenClaw invite prompt flow.
|
||||
- In the Invites section, click `Generate OpenClaw Invite Prompt`.
|
||||
- Copy the generated prompt from `OpenClaw Invite Prompt`.
|
||||
- Paste it into OpenClaw main chat as one message.
|
||||
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
|
||||
|
||||
Security/control note:
|
||||
- The OpenClaw invite prompt is created from a controlled endpoint:
|
||||
- `POST /api/companies/{companyId}/openclaw/invite-prompt`
|
||||
- board users with invite permission can call it
|
||||
- agent callers are limited to the company CEO agent
|
||||
|
||||
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
|
||||
|
||||
6. Gateway preflight (required before task tests).
|
||||
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
|
||||
- Confirm gateway URL is `ws://...` or `wss://...`.
|
||||
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
|
||||
- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding.
|
||||
- Confirm pairing mode is explicit:
|
||||
- recommended default: `adapterConfig.disableDeviceAuth` is false/absent and `adapterConfig.devicePrivateKeyPem` is present
|
||||
- fallback only: `adapterConfig.disableDeviceAuth=true` when pairing cannot be supported in that environment
|
||||
- required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem`
|
||||
- do not rely on `disableDeviceAuth` for normal onboarding
|
||||
- If you can run API checks with board auth:
|
||||
```bash
|
||||
AGENT_ID="<newly-created-agent-id>"
|
||||
@@ -40,12 +48,19 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT
|
||||
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
||||
|
||||
Pairing handshake note:
|
||||
- The first gateway run may return `pairing required` once for a new device key.
|
||||
- Clean run expectation: first task should succeed without manual pairing commands.
|
||||
- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
|
||||
- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`.
|
||||
- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself.
|
||||
- Approve it in OpenClaw, then retry the task.
|
||||
- For local docker smoke, you can approve from host:
|
||||
```bash
|
||||
docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"'
|
||||
```
|
||||
- You can inspect pending vs paired devices:
|
||||
```bash
|
||||
docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"'
|
||||
```
|
||||
|
||||
7. Case A (manual issue test).
|
||||
- Create an issue assigned to the OpenClaw agent.
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ Use when:
|
||||
- You want structured stream output in run logs via --output-format stream-json
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw or http)
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- Cursor Agent CLI is not installed on the machine
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -32,12 +32,13 @@ By default the adapter sends a signed `device` payload in `connect` params.
|
||||
- set `disableDeviceAuth=true` to omit device signing
|
||||
- set `devicePrivateKeyPem` to pin a stable signing key
|
||||
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
|
||||
- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once.
|
||||
|
||||
## Session Strategy
|
||||
|
||||
The adapter supports the same session routing model as HTTP OpenClaw mode:
|
||||
|
||||
- `sessionKeyStrategy=fixed|issue|run`
|
||||
- `sessionKeyStrategy=issue|fixed|run`
|
||||
- `sessionKey` is used when strategy is `fixed`
|
||||
|
||||
Resolved session key is sent as `agent.sessionKey`.
|
||||
|
||||
@@ -1,367 +1,109 @@
|
||||
# OpenClaw Gateway Onboarding and Test Plan
|
||||
|
||||
## Objective
|
||||
Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex.
|
||||
|
||||
This plan covers:
|
||||
- Current onboarding flow behavior and gaps.
|
||||
- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol).
|
||||
- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company.
|
||||
|
||||
## Hard Requirements (Testing Contract)
|
||||
These are mandatory for onboarding and smoke testing:
|
||||
|
||||
1. **Stock/clean OpenClaw boot every run**
|
||||
- Use a fresh, unmodified OpenClaw Docker image path each test cycle.
|
||||
- Do not rely on persistent/manual in-UI tweaks from prior runs.
|
||||
- Recreate runtime state each run so results represent first-time user experience.
|
||||
|
||||
2. **One-command/prompt setup inside OpenClaw**
|
||||
- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able).
|
||||
- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”).
|
||||
- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps.
|
||||
|
||||
3. **Two-lane validation is required**
|
||||
- Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate.
|
||||
- Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path.
|
||||
|
||||
## Execution Findings (2026-03-07)
|
||||
Observed from running `scripts/smoke/openclaw-gateway-e2e.sh` against `CLA` in authenticated/private dev mode:
|
||||
|
||||
1. **Baseline failure (before wake-text fix)**
|
||||
- Stock lane had run-level success but failed functional assertions:
|
||||
- connectivity run `64a72d8b-f5b3-4f62-9147-1c60932f50ad` succeeded
|
||||
- case A run `fd29e361-a6bd-4bc6-9270-36ef96e3bd8e` succeeded
|
||||
- issue `CLA-6` (`dad7b967-29d2-4317-8c9d-425b4421e098`) stayed `todo` with `0` comments
|
||||
- Root symptom: OpenClaw reported missing concrete heartbeat procedure and guessed non-existent `/api/*heartbeat` endpoints.
|
||||
|
||||
2. **Post-fix validation (stock-clean lane passes)**
|
||||
- After updating adapter wake text to include explicit Paperclip API workflow steps and explicit endpoint bans:
|
||||
- connectivity run `c297e2d0-020b-4b30-95d3-a4c04e1373bb`: `succeeded`
|
||||
- case A run `baac403e-8d86-48e5-b7d5-239c4755ce7e`: `succeeded`, issue `CLA-7` done with marker
|
||||
- case B run `521fc8ad-2f5a-4bd8-9ddd-c491401c9158`: `succeeded`, issue `CLA-8` done with marker
|
||||
- case C run `a03d86b6-91a8-48b4-8813-758f6bf11aec`: `succeeded`, issue `CLA-9` done, created issue `CLA-10`
|
||||
- Stock release-gate lane now passes scripted checks.
|
||||
|
||||
3. **Instrumentation lane note**
|
||||
- Prompt-augmented diagnostics lane previously timed out (`7537e5d2-a76a-44c5-bf9f-57f1b21f5fc3`) with missing tool runtime utilities (`jq`, `python`) inside the stock container.
|
||||
- Keep this lane for diagnostics only; stock lane remains the acceptance gate.
|
||||
|
||||
## External Protocol Constraints
|
||||
OpenClaw docs to anchor behavior:
|
||||
- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook
|
||||
- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol
|
||||
- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses
|
||||
|
||||
Implication:
|
||||
- `webhook` transport should target `/hooks/*` and requires hook server enablement.
|
||||
- `sse` transport should target `/v1/responses`.
|
||||
- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`.
|
||||
|
||||
## Current Implementation Map (What Exists)
|
||||
|
||||
### Invite + onboarding pipeline
|
||||
- Invite create: `POST /api/companies/:companyId/invites`
|
||||
- Invite onboarding manifest: `GET /api/invites/:token/onboarding`
|
||||
- Agent-readable text: `GET /api/invites/:token/onboarding.txt`
|
||||
- Accept join: `POST /api/invites/:token/accept`
|
||||
- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve`
|
||||
- Claim key: `POST /api/join-requests/:requestId/claim-api-key`
|
||||
|
||||
### Adapter state
|
||||
- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode.
|
||||
- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`).
|
||||
|
||||
### Existing smoke foundation
|
||||
- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`.
|
||||
- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`).
|
||||
|
||||
## Deep Code Findings (Gaps)
|
||||
|
||||
### 1) Onboarding manifest/text gateway path (resolved)
|
||||
Resolved in `server/src/routes/access.ts`:
|
||||
- `recommendedAdapterType` now points to `openclaw_gateway`.
|
||||
- Onboarding examples now require `adapterType: "openclaw_gateway"` + `ws://`/`wss://` URL + gateway token header.
|
||||
- Added fail-fast guidance for short/placeholder tokens.
|
||||
|
||||
### 2) Company settings snippet gateway path (resolved)
|
||||
Resolved in `ui/src/pages/CompanySettings.tsx`:
|
||||
- Snippet now instructs OpenClaw Gateway onboarding.
|
||||
- Snippet explicitly says not to use `/v1/responses` or `/hooks/*` for this flow.
|
||||
|
||||
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters (open)
|
||||
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
|
||||
|
||||
### 4) Join normalization/replay logic parity (partially resolved)
|
||||
Resolved:
|
||||
- `buildJoinDefaultsPayloadForAccept` now normalizes wrapped gateway token headers for `openclaw_gateway`.
|
||||
- `normalizeAgentDefaultsForJoin` now validates `openclaw_gateway` URL/token and rejects short placeholder tokens at invite-accept time.
|
||||
|
||||
Still open:
|
||||
- Invite replay path is still special-cased to legacy `openclaw` joins.
|
||||
|
||||
### 5) Webhook confusion is expected in current setup
|
||||
For `openclaw` + `streamTransport=webhook`:
|
||||
- Adapter may remap `/v1/responses -> /hooks/agent`.
|
||||
- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`.
|
||||
|
||||
If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected.
|
||||
|
||||
### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode
|
||||
- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`).
|
||||
- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`.
|
||||
- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes.
|
||||
|
||||
### 7) Gateway adapter lacks hire-approved callback parity
|
||||
`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not.
|
||||
Not a blocker for core routing, but creates inconsistent onboarding feedback behavior.
|
||||
|
||||
## UX Intention (Target Experience)
|
||||
|
||||
### Product goal
|
||||
Users should pick one clear onboarding path:
|
||||
- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs.
|
||||
- `Invite OpenClaw Gateway` for gateway-native installs.
|
||||
|
||||
### UX design requirements
|
||||
- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route).
|
||||
- Mode-specific generated snippet and mode-specific onboarding text.
|
||||
- Clear compatibility checks before user copies anything.
|
||||
|
||||
### Proposed UX structure
|
||||
1. Add invite buttons:
|
||||
- `Invite OpenClaw (SSE/Webhook)`
|
||||
- `Invite OpenClaw Gateway`
|
||||
|
||||
2. For HTTP invite:
|
||||
- Require transport choice (`sse` or `webhook`).
|
||||
- Validate endpoint expectations:
|
||||
- `sse` with `/v1/responses`.
|
||||
- `webhook` with `/hooks/*` and hooks enablement guidance.
|
||||
|
||||
3. For Gateway invite:
|
||||
- Ask only for `ws://`/`wss://` and token source guidance.
|
||||
- No callback URL/paperclipApiUrl complexity in onboarding.
|
||||
|
||||
4. Always show:
|
||||
- Preflight diagnostics.
|
||||
- Copy-ready command/snippet.
|
||||
- Expected next steps (join -> approve -> claim -> skill install).
|
||||
|
||||
## Why Gateway Improves Onboarding
|
||||
Compared to webhook/SSE onboarding:
|
||||
- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls.
|
||||
- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion.
|
||||
- Better run observability: gateway event frames stream lifecycle/delta events in one protocol.
|
||||
|
||||
Tradeoff:
|
||||
- Requires stable WS endpoint and gateway token handling.
|
||||
|
||||
## Codex-Executable E2E Workflow
|
||||
|
||||
## Scope
|
||||
Run this full flow per test cycle against company `CLA`:
|
||||
1. Assign task to OpenClaw agent -> agent executes -> task closes.
|
||||
2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat.
|
||||
3. OpenClaw in a fresh/new session can still create a Paperclip task.
|
||||
4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup.
|
||||
This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only.
|
||||
|
||||
## 0) Cleanup Before Each Run
|
||||
Use deterministic reset to avoid stale agents/runs/state.
|
||||
- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching)
|
||||
- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`)
|
||||
|
||||
1. OpenClaw Docker cleanup:
|
||||
## Requirements
|
||||
1. OpenClaw test image must be stock/clean every run.
|
||||
2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed).
|
||||
3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`.
|
||||
4. Invite/access flow must be secure:
|
||||
- invite prompt endpoint is board-permission protected
|
||||
- CEO agent is allowed to invoke the invite prompt endpoint for their own company
|
||||
5. E2E pass criteria must include the 3 functional task cases.
|
||||
|
||||
## Current Product Flow
|
||||
1. Board/CEO opens company settings.
|
||||
2. Click `Generate OpenClaw Invite Prompt`.
|
||||
3. Paste generated prompt into OpenClaw chat.
|
||||
4. OpenClaw submits invite acceptance with:
|
||||
- `adapterType: "openclaw_gateway"`
|
||||
- `agentDefaultsPayload.url: ws://... | wss://...`
|
||||
- `agentDefaultsPayload.headers["x-openclaw-token"]`
|
||||
5. Board approves join request.
|
||||
6. OpenClaw claims API key and installs/uses Paperclip skill.
|
||||
7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key.
|
||||
|
||||
## Technical Contract (Gateway)
|
||||
`agentDefaultsPayload` minimum:
|
||||
```json
|
||||
{
|
||||
"url": "ws://127.0.0.1:18789",
|
||||
"headers": { "x-openclaw-token": "<gateway-token>" }
|
||||
}
|
||||
```
|
||||
|
||||
Recommended fields:
|
||||
```json
|
||||
{
|
||||
"paperclipApiUrl": "http://host.docker.internal:3100",
|
||||
"waitTimeoutMs": 120000,
|
||||
"sessionKeyStrategy": "issue",
|
||||
"role": "operator",
|
||||
"scopes": ["operator.admin"]
|
||||
}
|
||||
```
|
||||
|
||||
Security/pairing defaults:
|
||||
- `disableDeviceAuth`: default false
|
||||
- `devicePrivateKeyPem`: generated during join if missing
|
||||
|
||||
## Codex Automation Workflow
|
||||
|
||||
### 0) Reset and boot
|
||||
```bash
|
||||
# stop/remove OpenClaw compose services
|
||||
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
|
||||
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
|
||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
|
||||
fi
|
||||
|
||||
# remove old image (as requested)
|
||||
docker image rm openclaw:local || true
|
||||
```
|
||||
|
||||
2. Recreate OpenClaw cleanly:
|
||||
```bash
|
||||
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
||||
```
|
||||
This must remain a stock/clean image boot path, with no hidden manual state carried from prior runs.
|
||||
|
||||
3. Remove prior CLA OpenClaw agents:
|
||||
- List `CLA` agents via API.
|
||||
- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding.
|
||||
|
||||
4. Reject/clear stale pending join requests for CLA (optional but recommended).
|
||||
|
||||
## 1) Start Paperclip in Required Mode
|
||||
### 1) Start Paperclip
|
||||
```bash
|
||||
pnpm dev --tailscale-auth
|
||||
```
|
||||
Verify:
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:3100/api/health
|
||||
# expect deploymentMode=authenticated, deploymentExposure=private
|
||||
```
|
||||
|
||||
## 2) Acquire Board Session for Automation
|
||||
Board operations (create invite, approve join, terminate agents) require board session cookie.
|
||||
### 2) Invite + join + approval
|
||||
- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt`
|
||||
- paste prompt to OpenClaw
|
||||
- approve join request
|
||||
- assert created agent:
|
||||
- `adapterType == openclaw_gateway`
|
||||
- token header exists and length >= 16
|
||||
- `devicePrivateKeyPem` exists
|
||||
|
||||
Short-term practical options:
|
||||
1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`.
|
||||
2. Scripted fallback: sign-up/sign-in via `/api/auth/*`, then use a dedicated admin promotion/bootstrap utility for dev (recommended to add as a small internal script).
|
||||
### 3) Pairing stabilization
|
||||
- if first run returns `pairing required`, approve pending device in OpenClaw
|
||||
- rerun task and confirm success
|
||||
- assert later runs do not require re-pairing for same agent
|
||||
|
||||
Note:
|
||||
- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow.
|
||||
### 4) Functional E2E assertions
|
||||
1. Task assigned to OpenClaw is completed and closed.
|
||||
2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat).
|
||||
3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task.
|
||||
|
||||
## 3) Resolve CLA Company ID
|
||||
With board cookie:
|
||||
## Manual Smoke Checklist
|
||||
Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook.
|
||||
|
||||
## Regression Gates
|
||||
Required before merge:
|
||||
```bash
|
||||
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
Pick company where identifier/code is `CLA` and store `CLA_COMPANY_ID`.
|
||||
|
||||
## 4) Preflight OpenClaw Endpoint Capability
|
||||
From host (using current OpenClaw token):
|
||||
- For HTTP SSE mode: confirm `/v1/responses` behavior.
|
||||
- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled.
|
||||
- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`.
|
||||
|
||||
Expected in current docker smoke config:
|
||||
- `/hooks/agent` likely `404` unless hooks explicitly enabled.
|
||||
- WS gateway protocol works.
|
||||
|
||||
## 5) Gateway Join Flow (Primary Path)
|
||||
|
||||
1. Create agent-only invite in CLA:
|
||||
If full suite is too heavy locally, run at least:
|
||||
```bash
|
||||
POST /api/companies/$CLA_COMPANY_ID/invites
|
||||
{ "allowedJoinTypes": "agent" }
|
||||
pnpm --filter @paperclipai/server test:run -- openclaw-gateway
|
||||
pnpm --filter @paperclipai/server typecheck
|
||||
pnpm --filter @paperclipai/ui typecheck
|
||||
pnpm --filter paperclipai typecheck
|
||||
```
|
||||
|
||||
2. Submit join request with gateway defaults:
|
||||
```json
|
||||
{
|
||||
"requestType": "agent",
|
||||
"agentName": "OpenClaw Gateway",
|
||||
"adapterType": "openclaw_gateway",
|
||||
"capabilities": "OpenClaw gateway agent",
|
||||
"agentDefaultsPayload": {
|
||||
"url": "ws://127.0.0.1:18789",
|
||||
"headers": { "x-openclaw-token": "<gateway-token>" },
|
||||
"role": "operator",
|
||||
"scopes": ["operator.admin"],
|
||||
"sessionKeyStrategy": "fixed",
|
||||
"sessionKey": "paperclip",
|
||||
"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. If it returns `pairing required`, approve the pending device request in OpenClaw and retry once.
|
||||
- Local docker automation path:
|
||||
- `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token <gateway-token>`
|
||||
- After approval, retries should succeed using the persisted `devicePrivateKeyPem`.
|
||||
6. Claim API key with `claimSecret`.
|
||||
7. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
|
||||
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
|
||||
8. Ensure Paperclip skill is installed for OpenClaw runtime.
|
||||
9. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
|
||||
|
||||
## 6) E2E Validation Cases
|
||||
|
||||
### Case A: Assigned task execution/closure
|
||||
1. Create issue in CLA assigned to joined OpenClaw agent.
|
||||
2. Poll issue + heartbeat runs until terminal.
|
||||
3. Pass criteria:
|
||||
- At least one run invoked for that agent/issue.
|
||||
- Run status `succeeded`.
|
||||
- Issue reaches `done` (or documented expected terminal state if policy differs).
|
||||
|
||||
### Case B: Message tool to main chat
|
||||
1. Create issue instructing OpenClaw: “send a message to the user’s main chat session in webchat using message tool”.
|
||||
2. Trigger/poll run completion.
|
||||
3. Validate output:
|
||||
- Automated minimum: run log/transcript confirms tool invocation success.
|
||||
- UX-level validation: message visibly appears in main chat UI.
|
||||
|
||||
Current recommendation:
|
||||
- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification.
|
||||
|
||||
### Case C: Fresh session still creates Paperclip task
|
||||
1. Force fresh-session behavior for test:
|
||||
- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key).
|
||||
2. Create issue asking agent to create a new Paperclip task.
|
||||
3. Pass criteria:
|
||||
- New issue appears in CLA with expected title/body.
|
||||
- Agent succeeds without re-onboarding.
|
||||
|
||||
## 7) Observability and Assertions
|
||||
Use these APIs for deterministic assertions:
|
||||
- `GET /api/companies/:companyId/heartbeat-runs?agentId=...`
|
||||
- `GET /api/heartbeat-runs/:runId/events`
|
||||
- `GET /api/heartbeat-runs/:runId/log`
|
||||
- `GET /api/issues/:id`
|
||||
- `GET /api/companies/:companyId/issues?q=...`
|
||||
|
||||
Include explicit timeout budgets per poll loop and hard failure reasons in output.
|
||||
|
||||
## 8) Automation Artifact
|
||||
Implemented smoke harness:
|
||||
- `scripts/smoke/openclaw-gateway-e2e.sh`
|
||||
|
||||
Responsibilities:
|
||||
- OpenClaw docker cleanup/rebuild/start.
|
||||
- Paperclip health/auth preflight.
|
||||
- CLA company resolution.
|
||||
- Old OpenClaw agent cleanup.
|
||||
- Invite/join/approve/claim orchestration.
|
||||
- Gateway agent config/token preflight validation before connectivity or case execution.
|
||||
- Pairing-mode preflight (`disableDeviceAuth=false` + stable `devicePrivateKeyPem` by default).
|
||||
- E2E case execution + assertions.
|
||||
- Final summary with run IDs, issue IDs, agent ID.
|
||||
|
||||
## 9) Required Product/Code Changes to Support This Plan Cleanly
|
||||
|
||||
### Access/onboarding backend
|
||||
- Make onboarding manifest/text adapter-aware (`openclaw` vs `openclaw_gateway`).
|
||||
- Add gateway-specific required fields and examples.
|
||||
- Add gateway-specific diagnostics (WS URL/token/role/scopes/device-auth hints).
|
||||
|
||||
### Company settings UX
|
||||
- Replace single generic snippet with mode-specific invite actions.
|
||||
- Add “Invite OpenClaw Gateway” path with concise copy/paste onboarding.
|
||||
|
||||
### Invite landing UX
|
||||
- Enable OpenClaw adapter options when invite allows agent join.
|
||||
- Allow `agentDefaultsPayload` entry for advanced joins where needed.
|
||||
|
||||
### Adapter parity
|
||||
- Consider `onHireApproved` support for `openclaw_gateway` for consistency.
|
||||
|
||||
### Test coverage
|
||||
- Add integration tests for adapter-aware onboarding manifest generation.
|
||||
- Add route tests for gateway join/approve/claim path.
|
||||
- Add smoke test target for gateway E2E flow.
|
||||
|
||||
## 10) Execution Order
|
||||
1. Implement onboarding manifest/text split by adapter mode.
|
||||
2. Add company settings invite UX split (HTTP vs Gateway).
|
||||
3. Add gateway E2E smoke script.
|
||||
4. Run full CLA workflow in authenticated/private mode.
|
||||
5. Iterate on message-tool verification automation.
|
||||
|
||||
## Acceptance Criteria
|
||||
- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal.
|
||||
- Gateway onboarding is first-class and copy/pasteable from company settings.
|
||||
- Gateway join fails fast if token is missing/placeholder, and smoke preflight verifies adapter/token parity before task runs.
|
||||
- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup.
|
||||
- All three validation cases are documented with pass/fail criteria and reproducible evidence paths.
|
||||
|
||||
@@ -12,7 +12,7 @@ Use when:
|
||||
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
|
||||
|
||||
Don't use when:
|
||||
- You only expose OpenClaw HTTP endpoints (use openclaw adapter with sse/webhook transport).
|
||||
- You only expose OpenClaw HTTP endpoints.
|
||||
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
|
||||
|
||||
Core fields:
|
||||
@@ -33,9 +33,10 @@ Request behavior fields:
|
||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
|
||||
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
|
||||
|
||||
Session routing fields:
|
||||
- sessionKeyStrategy (string, optional): fixed (default), issue, or run
|
||||
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
|
||||
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
|
||||
`;
|
||||
|
||||
@@ -22,6 +22,7 @@ type GatewayDeviceIdentity = {
|
||||
deviceId: string;
|
||||
publicKeyRawBase64Url: string;
|
||||
privateKeyPem: string;
|
||||
source: "configured" | "ephemeral";
|
||||
};
|
||||
|
||||
type GatewayRequestFrame = {
|
||||
@@ -56,6 +57,11 @@ type PendingRequest = {
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type GatewayResponseError = Error & {
|
||||
gatewayCode?: string;
|
||||
gatewayDetails?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type GatewayClientOptions = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -111,9 +117,9 @@ function parseBoolean(value: unknown, fallback = false): boolean {
|
||||
}
|
||||
|
||||
function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy {
|
||||
const normalized = asString(value, "fixed").trim().toLowerCase();
|
||||
if (normalized === "issue" || normalized === "run") return normalized;
|
||||
return "fixed";
|
||||
const normalized = asString(value, "issue").trim().toLowerCase();
|
||||
if (normalized === "fixed" || normalized === "run") return normalized;
|
||||
return "issue";
|
||||
}
|
||||
|
||||
function resolveSessionKey(input: {
|
||||
@@ -163,6 +169,10 @@ function normalizeScopes(value: unknown): string[] {
|
||||
return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES];
|
||||
}
|
||||
|
||||
function uniqueScopes(scopes: string[]): string[] {
|
||||
return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
||||
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
||||
return match ? match[1] : null;
|
||||
@@ -172,6 +182,21 @@ function headerMapHasIgnoreCase(headers: Record<string, string>, key: string): b
|
||||
return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase());
|
||||
}
|
||||
|
||||
function getGatewayErrorDetails(err: unknown): Record<string, unknown> | null {
|
||||
if (!err || typeof err !== "object") return null;
|
||||
const candidate = (err as GatewayResponseError).gatewayDetails;
|
||||
return asRecord(candidate);
|
||||
}
|
||||
|
||||
function extractPairingRequestId(err: unknown): string | null {
|
||||
const details = getGatewayErrorDetails(err);
|
||||
const fromDetails = nonEmpty(details?.requestId);
|
||||
if (fromDetails) return fromDetails;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function toAuthorizationHeaderValue(rawToken: string): string {
|
||||
const trimmed = rawToken.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
@@ -486,6 +511,7 @@ function resolveDeviceIdentity(config: Record<string, unknown>): GatewayDeviceId
|
||||
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
||||
publicKeyRawBase64Url: base64UrlEncode(raw),
|
||||
privateKeyPem: configuredPrivateKey,
|
||||
source: "configured",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -497,6 +523,7 @@ function resolveDeviceIdentity(config: Record<string, unknown>): GatewayDeviceId
|
||||
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
||||
publicKeyRawBase64Url: base64UrlEncode(raw),
|
||||
privateKeyPem,
|
||||
source: "ephemeral",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -688,7 +715,101 @@ class GatewayWsClient {
|
||||
nonEmpty(errorRecord?.message) ??
|
||||
nonEmpty(errorRecord?.code) ??
|
||||
"gateway request failed";
|
||||
pending.reject(new Error(message));
|
||||
const err = new Error(message) as GatewayResponseError;
|
||||
const code = nonEmpty(errorRecord?.code);
|
||||
const details = asRecord(errorRecord?.details);
|
||||
if (code) err.gatewayCode = code;
|
||||
if (details) err.gatewayDetails = details;
|
||||
pending.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function autoApproveDevicePairing(params: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
connectTimeoutMs: number;
|
||||
clientId: string;
|
||||
clientMode: string;
|
||||
clientVersion: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
authToken: string | null;
|
||||
password: string | null;
|
||||
requestId: string | null;
|
||||
deviceId: string | null;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> {
|
||||
if (!params.authToken && !params.password) {
|
||||
return { ok: false, reason: "shared auth token/password is missing" };
|
||||
}
|
||||
|
||||
const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]);
|
||||
const client = new GatewayWsClient({
|
||||
url: params.url,
|
||||
headers: params.headers,
|
||||
onEvent: () => {},
|
||||
onLog: params.onLog,
|
||||
});
|
||||
|
||||
try {
|
||||
await params.onLog(
|
||||
"stdout",
|
||||
"[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n",
|
||||
);
|
||||
|
||||
await client.connect(
|
||||
() => ({
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: params.clientId,
|
||||
version: params.clientVersion,
|
||||
platform: process.platform,
|
||||
mode: params.clientMode,
|
||||
},
|
||||
role: params.role,
|
||||
scopes: approvalScopes,
|
||||
auth: {
|
||||
...(params.authToken ? { token: params.authToken } : {}),
|
||||
...(params.password ? { password: params.password } : {}),
|
||||
},
|
||||
}),
|
||||
params.connectTimeoutMs,
|
||||
);
|
||||
|
||||
let requestId = params.requestId;
|
||||
if (!requestId) {
|
||||
const listPayload = await client.request<Record<string, unknown>>("device.pair.list", {}, {
|
||||
timeoutMs: params.connectTimeoutMs,
|
||||
});
|
||||
const pending = Array.isArray(listPayload.pending) ? listPayload.pending : [];
|
||||
const pendingRecords = pending
|
||||
.map((entry) => asRecord(entry))
|
||||
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
|
||||
const matching =
|
||||
(params.deviceId
|
||||
? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId)
|
||||
: null) ?? pendingRecords[pendingRecords.length - 1];
|
||||
requestId = nonEmpty(matching?.requestId);
|
||||
}
|
||||
|
||||
if (!requestId) {
|
||||
return { ok: false, reason: "no pending device pairing request found" };
|
||||
}
|
||||
|
||||
await client.request(
|
||||
"device.pair.approve",
|
||||
{ requestId },
|
||||
{
|
||||
timeoutMs: params.connectTimeoutMs,
|
||||
},
|
||||
);
|
||||
|
||||
return { ok: true, requestId };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,63 +942,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agentParams.timeout = waitTimeoutMs;
|
||||
}
|
||||
|
||||
const trackedRunIds = new Set<string>([ctx.runId]);
|
||||
const assistantChunks: string[] = [];
|
||||
let lifecycleError: string | null = null;
|
||||
let latestResultPayload: unknown = null;
|
||||
|
||||
const onEvent = async (frame: GatewayEventFrame) => {
|
||||
if (frame.event !== "agent") {
|
||||
if (frame.event === "shutdown") {
|
||||
await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = asRecord(frame.payload);
|
||||
if (!payload) return;
|
||||
|
||||
const runId = nonEmpty(payload.runId);
|
||||
if (!runId || !trackedRunIds.has(runId)) return;
|
||||
|
||||
const stream = nonEmpty(payload.stream) ?? "unknown";
|
||||
const data = asRecord(payload.data) ?? {};
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`,
|
||||
);
|
||||
|
||||
if (stream === "assistant") {
|
||||
const delta = nonEmpty(data.delta);
|
||||
const text = nonEmpty(data.text);
|
||||
if (delta) {
|
||||
assistantChunks.push(delta);
|
||||
} else if (text) {
|
||||
assistantChunks.push(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (stream === "error") {
|
||||
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (stream === "lifecycle") {
|
||||
const phase = nonEmpty(data.phase)?.toLowerCase();
|
||||
if (phase === "error" || phase === "failed" || phase === "cancelled") {
|
||||
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const client = new GatewayWsClient({
|
||||
url: parsedUrl.toString(),
|
||||
headers,
|
||||
onEvent,
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
|
||||
if (ctx.onMeta) {
|
||||
await ctx.onMeta({
|
||||
adapterType: "openclaw_gateway",
|
||||
@@ -910,190 +974,305 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
||||
const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true);
|
||||
let autoPairAttempted = false;
|
||||
let latestResultPayload: unknown = null;
|
||||
|
||||
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
||||
while (true) {
|
||||
const trackedRunIds = new Set<string>([ctx.runId]);
|
||||
const assistantChunks: string[] = [];
|
||||
let lifecycleError: string | null = null;
|
||||
let deviceIdentity: GatewayDeviceIdentity | null = null;
|
||||
|
||||
const hello = await client.connect((nonce) => {
|
||||
const signedAtMs = Date.now();
|
||||
const connectParams: Record<string, unknown> = {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: clientId,
|
||||
version: clientVersion,
|
||||
platform: process.platform,
|
||||
...(deviceFamily ? { deviceFamily } : {}),
|
||||
mode: clientMode,
|
||||
},
|
||||
role,
|
||||
scopes,
|
||||
auth:
|
||||
authToken || password || deviceToken
|
||||
? {
|
||||
...(authToken ? { token: authToken } : {}),
|
||||
...(deviceToken ? { deviceToken } : {}),
|
||||
...(password ? { password } : {}),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (deviceIdentity) {
|
||||
const payload = buildDeviceAuthPayloadV3({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken,
|
||||
nonce,
|
||||
platform: process.platform,
|
||||
deviceFamily,
|
||||
});
|
||||
connectParams.device = {
|
||||
id: deviceIdentity.deviceId,
|
||||
publicKey: deviceIdentity.publicKeyRawBase64Url,
|
||||
signature: signDevicePayload(deviceIdentity.privateKeyPem, payload),
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
};
|
||||
const onEvent = async (frame: GatewayEventFrame) => {
|
||||
if (frame.event !== "agent") {
|
||||
if (frame.event === "shutdown") {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return connectParams;
|
||||
}, connectTimeoutMs);
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`,
|
||||
);
|
||||
const payload = asRecord(frame.payload);
|
||||
if (!payload) return;
|
||||
|
||||
const acceptedPayload = await client.request<Record<string, unknown>>("agent", agentParams, {
|
||||
timeoutMs: connectTimeoutMs,
|
||||
const runId = nonEmpty(payload.runId);
|
||||
if (!runId || !trackedRunIds.has(runId)) return;
|
||||
|
||||
const stream = nonEmpty(payload.stream) ?? "unknown";
|
||||
const data = asRecord(payload.data) ?? {};
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`,
|
||||
);
|
||||
|
||||
if (stream === "assistant") {
|
||||
const delta = nonEmpty(data.delta);
|
||||
const text = nonEmpty(data.text);
|
||||
if (delta) {
|
||||
assistantChunks.push(delta);
|
||||
} else if (text) {
|
||||
assistantChunks.push(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (stream === "error") {
|
||||
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (stream === "lifecycle") {
|
||||
const phase = nonEmpty(data.phase)?.toLowerCase();
|
||||
if (phase === "error" || phase === "failed" || phase === "cancelled") {
|
||||
lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const client = new GatewayWsClient({
|
||||
url: parsedUrl.toString(),
|
||||
headers,
|
||||
onEvent,
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
|
||||
latestResultPayload = acceptedPayload;
|
||||
try {
|
||||
deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
||||
if (deviceIdentity) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`,
|
||||
);
|
||||
} else {
|
||||
await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n");
|
||||
}
|
||||
|
||||
const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? "";
|
||||
const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId;
|
||||
trackedRunIds.add(acceptedRunId);
|
||||
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`,
|
||||
);
|
||||
const hello = await client.connect((nonce) => {
|
||||
const signedAtMs = Date.now();
|
||||
const connectParams: Record<string, unknown> = {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: clientId,
|
||||
version: clientVersion,
|
||||
platform: process.platform,
|
||||
...(deviceFamily ? { deviceFamily } : {}),
|
||||
mode: clientMode,
|
||||
},
|
||||
role,
|
||||
scopes,
|
||||
auth:
|
||||
authToken || password || deviceToken
|
||||
? {
|
||||
...(authToken ? { token: authToken } : {}),
|
||||
...(deviceToken ? { deviceToken } : {}),
|
||||
...(password ? { password } : {}),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (deviceIdentity) {
|
||||
const payload = buildDeviceAuthPayloadV3({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken,
|
||||
nonce,
|
||||
platform: process.platform,
|
||||
deviceFamily,
|
||||
});
|
||||
connectParams.device = {
|
||||
id: deviceIdentity.deviceId,
|
||||
publicKey: deviceIdentity.publicKeyRawBase64Url,
|
||||
signature: signDevicePayload(deviceIdentity.privateKeyPem, payload),
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
return connectParams;
|
||||
}, connectTimeoutMs);
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`,
|
||||
);
|
||||
|
||||
const acceptedPayload = await client.request<Record<string, unknown>>("agent", agentParams, {
|
||||
timeoutMs: connectTimeoutMs,
|
||||
});
|
||||
|
||||
latestResultPayload = acceptedPayload;
|
||||
|
||||
const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? "";
|
||||
const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId;
|
||||
trackedRunIds.add(acceptedRunId);
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`,
|
||||
);
|
||||
|
||||
if (acceptedStatus === "error") {
|
||||
const errorMessage =
|
||||
nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage,
|
||||
errorCode: "openclaw_gateway_agent_error",
|
||||
resultJson: acceptedPayload,
|
||||
};
|
||||
}
|
||||
|
||||
if (acceptedStatus !== "ok") {
|
||||
const waitPayload = await client.request<Record<string, unknown>>(
|
||||
"agent.wait",
|
||||
{ runId: acceptedRunId, timeoutMs: waitTimeoutMs },
|
||||
{ timeoutMs: waitTimeoutMs + connectTimeoutMs },
|
||||
);
|
||||
|
||||
latestResultPayload = waitPayload;
|
||||
|
||||
const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? "";
|
||||
if (waitStatus === "timeout") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`,
|
||||
errorCode: "openclaw_gateway_wait_timeout",
|
||||
resultJson: waitPayload,
|
||||
};
|
||||
}
|
||||
|
||||
if (waitStatus === "error") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
nonEmpty(waitPayload?.error) ??
|
||||
lifecycleError ??
|
||||
"OpenClaw gateway run failed",
|
||||
errorCode: "openclaw_gateway_wait_error",
|
||||
resultJson: waitPayload,
|
||||
};
|
||||
}
|
||||
|
||||
if (waitStatus && waitStatus !== "ok") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`,
|
||||
errorCode: "openclaw_gateway_wait_status_unexpected",
|
||||
resultJson: waitPayload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const summaryFromEvents = assistantChunks.join("").trim();
|
||||
const summaryFromPayload =
|
||||
extractResultText(asRecord(acceptedPayload?.result)) ??
|
||||
extractResultText(acceptedPayload) ??
|
||||
extractResultText(asRecord(latestResultPayload)) ??
|
||||
null;
|
||||
const summary = summaryFromEvents || summaryFromPayload || null;
|
||||
|
||||
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta);
|
||||
const agentMeta = asRecord(meta?.agentMeta);
|
||||
const usage = parseUsage(agentMeta?.usage ?? meta?.usage);
|
||||
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw";
|
||||
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
||||
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
provider,
|
||||
...(model ? { model } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(costUsd > 0 ? { costUsd } : {}),
|
||||
resultJson: asRecord(latestResultPayload),
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const lower = message.toLowerCase();
|
||||
const timedOut = lower.includes("timeout");
|
||||
const pairingRequired = lower.includes("pairing required");
|
||||
|
||||
if (
|
||||
pairingRequired &&
|
||||
!disableDeviceAuth &&
|
||||
autoPairOnFirstConnect &&
|
||||
!autoPairAttempted &&
|
||||
(authToken || password)
|
||||
) {
|
||||
autoPairAttempted = true;
|
||||
const pairResult = await autoApproveDevicePairing({
|
||||
url: parsedUrl.toString(),
|
||||
headers,
|
||||
connectTimeoutMs,
|
||||
clientId,
|
||||
clientMode,
|
||||
clientVersion,
|
||||
role,
|
||||
scopes,
|
||||
authToken,
|
||||
password,
|
||||
requestId: extractPairingRequestId(err),
|
||||
deviceId: deviceIdentity?.deviceId ?? null,
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
if (pairResult.ok) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await ctx.onLog(
|
||||
"stderr",
|
||||
`[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const detailedMessage = pairingRequired
|
||||
? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url <gateway-ws-url> --token <gateway-token>) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.`
|
||||
: message;
|
||||
|
||||
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
||||
|
||||
if (acceptedStatus === "error") {
|
||||
const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage,
|
||||
errorCode: "openclaw_gateway_agent_error",
|
||||
resultJson: acceptedPayload,
|
||||
timedOut,
|
||||
errorMessage: detailedMessage,
|
||||
errorCode: timedOut
|
||||
? "openclaw_gateway_timeout"
|
||||
: pairingRequired
|
||||
? "openclaw_gateway_pairing_required"
|
||||
: "openclaw_gateway_request_failed",
|
||||
resultJson: asRecord(latestResultPayload),
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
|
||||
if (acceptedStatus !== "ok") {
|
||||
const waitPayload = await client.request<Record<string, unknown>>(
|
||||
"agent.wait",
|
||||
{ runId: acceptedRunId, timeoutMs: waitTimeoutMs },
|
||||
{ timeoutMs: waitTimeoutMs + connectTimeoutMs },
|
||||
);
|
||||
|
||||
latestResultPayload = waitPayload;
|
||||
|
||||
const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? "";
|
||||
if (waitStatus === "timeout") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`,
|
||||
errorCode: "openclaw_gateway_wait_timeout",
|
||||
resultJson: waitPayload,
|
||||
};
|
||||
}
|
||||
|
||||
if (waitStatus === "error") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
nonEmpty(waitPayload?.error) ??
|
||||
lifecycleError ??
|
||||
"OpenClaw gateway run failed",
|
||||
errorCode: "openclaw_gateway_wait_error",
|
||||
resultJson: waitPayload,
|
||||
};
|
||||
}
|
||||
|
||||
if (waitStatus && waitStatus !== "ok") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`,
|
||||
errorCode: "openclaw_gateway_wait_status_unexpected",
|
||||
resultJson: waitPayload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const summaryFromEvents = assistantChunks.join("").trim();
|
||||
const summaryFromPayload =
|
||||
extractResultText(asRecord(acceptedPayload?.result)) ??
|
||||
extractResultText(acceptedPayload) ??
|
||||
extractResultText(asRecord(latestResultPayload)) ??
|
||||
null;
|
||||
const summary = summaryFromEvents || summaryFromPayload || null;
|
||||
|
||||
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta);
|
||||
const agentMeta = asRecord(meta?.agentMeta);
|
||||
const usage = parseUsage(agentMeta?.usage ?? meta?.usage);
|
||||
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw";
|
||||
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
||||
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
||||
|
||||
await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`);
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
provider,
|
||||
...(model ? { model } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(costUsd > 0 ? { costUsd } : {}),
|
||||
resultJson: asRecord(latestResultPayload),
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const lower = message.toLowerCase();
|
||||
const timedOut = lower.includes("timeout");
|
||||
const pairingRequired = lower.includes("pairing required");
|
||||
const detailedMessage = pairingRequired
|
||||
? `${message}. Configure adapterConfig.disableDeviceAuth=true for smoke/dev, or set adapterConfig.devicePrivateKeyPem so pairing persists across runs.`
|
||||
: message;
|
||||
|
||||
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
||||
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut,
|
||||
errorMessage: detailedMessage,
|
||||
errorCode: timedOut
|
||||
? "openclaw_gateway_timeout"
|
||||
: pairingRequired
|
||||
? "openclaw_gateway_pairing_required"
|
||||
: "openclaw_gateway_request_failed",
|
||||
resultJson: asRecord(latestResultPayload),
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string
|
||||
if (v.url) ac.url = v.url;
|
||||
ac.timeoutSec = 120;
|
||||
ac.waitTimeoutMs = 120000;
|
||||
ac.sessionKeyStrategy = "fixed";
|
||||
ac.sessionKey = "paperclip";
|
||||
ac.sessionKeyStrategy = "issue";
|
||||
ac.role = "operator";
|
||||
ac.scopes = ["operator.admin"];
|
||||
return ac;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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`: `fixed` (default), `issue`, `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): \`fixed\` (default), \`issue\`, 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, "fixed").trim().toLowerCase();
|
||||
if (normalized === "issue" || normalized === "run") return normalized;
|
||||
return "fixed";
|
||||
}
|
||||
|
||||
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,12 +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 = "fixed";
|
||||
ac.sessionKey = "paperclip";
|
||||
return ac;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { parseOpenClawStdoutLine } from "./parse-stdout.js";
|
||||
export { buildOpenClawConfig } from "./build-config.js";
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import { normalizeOpenClawStreamLine } from "../shared/stream.js";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function readErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const obj = asRecord(value);
|
||||
if (!obj) return stringifyUnknown(value);
|
||||
return (
|
||||
asString(obj.message).trim() ||
|
||||
asString(obj.error).trim() ||
|
||||
asString(obj.code).trim() ||
|
||||
stringifyUnknown(obj)
|
||||
);
|
||||
}
|
||||
|
||||
function readDeltaText(payload: Record<string, unknown> | null): string {
|
||||
if (!payload) return "";
|
||||
|
||||
if (typeof payload.delta === "string") return payload.delta;
|
||||
|
||||
const deltaObj = asRecord(payload.delta);
|
||||
if (deltaObj) {
|
||||
const nestedDelta =
|
||||
asString(deltaObj.text) ||
|
||||
asString(deltaObj.value) ||
|
||||
asString(deltaObj.delta);
|
||||
if (nestedDelta.length > 0) return nestedDelta;
|
||||
}
|
||||
|
||||
const part = asRecord(payload.part);
|
||||
if (part) {
|
||||
const partText = asString(part.text);
|
||||
if (partText.length > 0) return partText;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractResponseOutputText(response: Record<string, unknown> | null): string {
|
||||
if (!response) return "";
|
||||
|
||||
const output = Array.isArray(response.output) ? response.output : [];
|
||||
const parts: string[] = [];
|
||||
for (const itemRaw of output) {
|
||||
const item = asRecord(itemRaw);
|
||||
if (!item) continue;
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim().toLowerCase();
|
||||
if (type !== "output_text" && type !== "text" && type !== "refusal") continue;
|
||||
const text = asString(part.text).trim();
|
||||
if (text) parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const match = line.match(/^\[openclaw:sse\]\s+event=([^\s]+)\s+data=(.*)$/s);
|
||||
if (!match) return [{ kind: "stdout", ts, text: line }];
|
||||
|
||||
const eventType = (match[1] ?? "").trim();
|
||||
const dataText = (match[2] ?? "").trim();
|
||||
const parsed = asRecord(safeJsonParse(dataText));
|
||||
const normalizedEventType = eventType.toLowerCase();
|
||||
|
||||
if (dataText === "[DONE]") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const delta = readDeltaText(parsed);
|
||||
if (normalizedEventType.endsWith(".delta") && delta.length > 0) {
|
||||
return [{ kind: "assistant", ts, text: delta, delta: true }];
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedEventType.includes("error") ||
|
||||
normalizedEventType.includes("failed") ||
|
||||
normalizedEventType.includes("cancel")
|
||||
) {
|
||||
const message = readErrorText(parsed?.error) || readErrorText(parsed?.message) || dataText;
|
||||
return message ? [{ kind: "stderr", ts, text: message }] : [];
|
||||
}
|
||||
|
||||
if (normalizedEventType === "response.completed" || normalizedEventType.endsWith(".completed")) {
|
||||
const response = asRecord(parsed?.response);
|
||||
const usage = asRecord(response?.usage);
|
||||
const status = asString(response?.status, asString(parsed?.status, eventType));
|
||||
const statusLower = status.trim().toLowerCase();
|
||||
const errorText =
|
||||
readErrorText(response?.error).trim() ||
|
||||
readErrorText(parsed?.error).trim() ||
|
||||
readErrorText(parsed?.message).trim();
|
||||
const isError =
|
||||
statusLower === "failed" ||
|
||||
statusLower === "error" ||
|
||||
statusLower === "cancelled";
|
||||
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: extractResponseOutputText(response),
|
||||
inputTokens: asNumber(usage?.input_tokens),
|
||||
outputTokens: asNumber(usage?.output_tokens),
|
||||
cachedTokens: asNumber(usage?.cached_input_tokens),
|
||||
costUsd: asNumber(usage?.cost_usd, asNumber(usage?.total_cost_usd)),
|
||||
subtype: status || eventType,
|
||||
isError,
|
||||
errors: errorText ? [errorText] : [],
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const normalized = normalizeOpenClawStreamLine(line);
|
||||
if (normalized.stream === "stderr") {
|
||||
return [{ kind: "stderr", ts, text: normalized.line }];
|
||||
}
|
||||
|
||||
const trimmed = normalized.line.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (trimmed.startsWith("[openclaw:sse]")) {
|
||||
return parseOpenClawSseLine(trimmed, ts);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[openclaw]")) {
|
||||
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw\]\s*/, "") }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ Use when:
|
||||
- You want OpenCode session resume across heartbeats via --session
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw or http)
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- OpenCode CLI is not installed on the machine
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Use when:
|
||||
- You need Pi's tool set (read, bash, edit, write, grep, find, ls)
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw or http)
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- Pi CLI is not installed on the machine
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"openclaw",
|
||||
"openclaw_gateway",
|
||||
] as const;
|
||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||
|
||||
@@ -197,6 +197,7 @@ export {
|
||||
updateBudgetSchema,
|
||||
createAssetImageMetadataSchema,
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
@@ -206,6 +207,7 @@ export {
|
||||
type UpdateBudget,
|
||||
type CreateAssetImageMetadata,
|
||||
type CreateCompanyInvite,
|
||||
type CreateOpenClawInvitePrompt,
|
||||
type AcceptInvite,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
|
||||
@@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({
|
||||
|
||||
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
|
||||
|
||||
export const createOpenClawInvitePromptSchema = z.object({
|
||||
agentMessage: z.string().max(4000).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateOpenClawInvitePrompt = z.infer<
|
||||
typeof createOpenClawInvitePromptSchema
|
||||
>;
|
||||
|
||||
export const acceptInviteSchema = z.object({
|
||||
requestType: z.enum(JOIN_REQUEST_TYPES),
|
||||
agentName: z.string().min(1).max(120).optional(),
|
||||
|
||||
@@ -119,12 +119,14 @@ export {
|
||||
|
||||
export {
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCompanyInvite,
|
||||
type CreateOpenClawInvitePrompt,
|
||||
type AcceptInvite,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -132,6 +132,9 @@ importers:
|
||||
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
|
||||
|
||||
@@ -33,7 +33,7 @@ const workspacePaths = [
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/opencode-local",
|
||||
"packages/adapters/openclaw",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
// Workspace packages that are NOT bundled and must stay as npm dependencies.
|
||||
|
||||
@@ -115,7 +115,7 @@ const { readFileSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
const root = '$REPO_ROOT';
|
||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway',
|
||||
'server', 'cli'];
|
||||
const names = [];
|
||||
for (const d of dirs) {
|
||||
@@ -221,7 +221,7 @@ const { resolve } = require('path');
|
||||
const root = '$REPO_ROOT';
|
||||
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway',
|
||||
'server', 'cli'];
|
||||
const names = [];
|
||||
for (const d of dirs) {
|
||||
@@ -279,7 +279,7 @@ pnpm --filter @paperclipai/db build
|
||||
pnpm --filter @paperclipai/adapter-claude-local build
|
||||
pnpm --filter @paperclipai/adapter-codex-local build
|
||||
pnpm --filter @paperclipai/adapter-opencode-local build
|
||||
pnpm --filter @paperclipai/adapter-openclaw build
|
||||
pnpm --filter @paperclipai/adapter-openclaw-gateway build
|
||||
pnpm --filter @paperclipai/server build
|
||||
|
||||
# Build UI and bundle into server package for static serving
|
||||
@@ -314,7 +314,7 @@ if [ "$dry_run" = true ]; then
|
||||
echo ""
|
||||
echo " Preview what would be published:"
|
||||
for dir in packages/shared packages/adapter-utils packages/db \
|
||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \
|
||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \
|
||||
server cli; do
|
||||
echo " --- $dir ---"
|
||||
cd "$REPO_ROOT/$dir"
|
||||
|
||||
@@ -179,24 +179,32 @@ if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then
|
||||
fi
|
||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
||||
assert_status "200"
|
||||
if ! grep -q "Paperclip OpenClaw Onboarding" <<<"$RESPONSE_BODY"; then
|
||||
if ! grep -q "Paperclip OpenClaw Gateway Onboarding" <<<"$RESPONSE_BODY"; then
|
||||
fail "onboarding.txt response missing expected header"
|
||||
fi
|
||||
|
||||
log "submitting OpenClaw agent join request"
|
||||
OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}"
|
||||
OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-${OPENCLAW_WEBHOOK_AUTH#Bearer }}"
|
||||
if [[ -z "$OPENCLAW_GATEWAY_TOKEN" ]]; then
|
||||
fail "OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_AUTH) is required for gateway join"
|
||||
fi
|
||||
|
||||
log "submitting OpenClaw gateway agent join request"
|
||||
JOIN_PAYLOAD="$(jq -nc \
|
||||
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||
--arg url "$OPENCLAW_WEBHOOK_URL" \
|
||||
--arg auth "$OPENCLAW_WEBHOOK_AUTH" \
|
||||
--arg url "$OPENCLAW_GATEWAY_URL" \
|
||||
--arg token "$OPENCLAW_GATEWAY_TOKEN" \
|
||||
'{
|
||||
requestType: "agent",
|
||||
agentName: $name,
|
||||
adapterType: "openclaw",
|
||||
capabilities: "Automated OpenClaw smoke harness",
|
||||
agentDefaultsPayload: (
|
||||
{ url: $url, method: "POST", timeoutSec: 30 }
|
||||
+ (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end)
|
||||
)
|
||||
adapterType: "openclaw_gateway",
|
||||
capabilities: "Automated OpenClaw gateway smoke harness",
|
||||
agentDefaultsPayload: {
|
||||
url: $url,
|
||||
headers: { "x-openclaw-token": $token },
|
||||
sessionKeyStrategy: "issue",
|
||||
waitTimeoutMs: 120000
|
||||
}
|
||||
}')"
|
||||
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
||||
assert_status "202"
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
|
||||
@@ -40,7 +40,7 @@ afterEach(() => {
|
||||
describe("notifyHireApproved", () => {
|
||||
it("writes success activity when adapter hook returns ok", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw",
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||
} as any);
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("notifyHireApproved", () => {
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -65,7 +65,7 @@ describe("notifyHireApproved", () => {
|
||||
expect.objectContaining({
|
||||
action: "hire_hook.succeeded",
|
||||
entityId: "a1",
|
||||
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw" }),
|
||||
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe("notifyHireApproved", () => {
|
||||
|
||||
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw",
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
||||
} as any);
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("notifyHireApproved", () => {
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -148,7 +148,7 @@ describe("notifyHireApproved", () => {
|
||||
|
||||
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw",
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
} as any);
|
||||
|
||||
@@ -156,7 +156,7 @@ describe("notifyHireApproved", () => {
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
119
server/src/__tests__/invite-accept-gateway-defaults.test.ts
Normal file
119
server/src/__tests__/invite-accept-gateway-defaults.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
normalizeAgentDefaultsForJoin,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => {
|
||||
it("leaves non-gateway payloads unchanged", () => {
|
||||
const defaultsPayload = { command: "echo hello" };
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "process",
|
||||
defaultsPayload,
|
||||
inboundOpenClawAuthHeader: "ignored-token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(defaultsPayload);
|
||||
});
|
||||
|
||||
it("normalizes wrapped x-openclaw-token header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": {
|
||||
value: "gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts inbound x-openclaw-token for gateway joins", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
},
|
||||
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("derives x-openclaw-token from authorization header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
authorization: "Bearer gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
authorization: "Bearer gateway-token-1234567890",
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => {
|
||||
it("generates persistent device key when device auth is enabled", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: false,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
|
||||
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
|
||||
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
|
||||
});
|
||||
|
||||
it("does not generate device key when disableDeviceAuth=true", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: true,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
|
||||
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
normalizeAgentDefaultsForJoin,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("buildJoinDefaultsPayloadForAccept", () => {
|
||||
it("maps OpenClaw compatibility fields into agent defaults", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: null,
|
||||
responsesWebhookUrl: "http://localhost:18789/v1/responses",
|
||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||
inboundOpenClawAuthHeader: "gateway-token",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "http://localhost:18789/v1/responses",
|
||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
url: "https://example.com/v1/responses",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-openclaw-auth": "existing-token",
|
||||
},
|
||||
paperclipApiUrl: "https://paperclip.example.com",
|
||||
},
|
||||
responsesWebhookUrl: "https://legacy.example.com/v1/responses",
|
||||
responsesWebhookMethod: "PUT",
|
||||
paperclipApiUrl: "https://legacy-paperclip.example.com",
|
||||
inboundOpenClawAuthHeader: "legacy-token",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "https://example.com/v1/responses",
|
||||
method: "POST",
|
||||
paperclipApiUrl: "https://paperclip.example.com",
|
||||
webhookAuthHeader: "Bearer existing-token",
|
||||
headers: {
|
||||
"x-openclaw-auth": "existing-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit webhookAuthHeader when configured", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
url: "https://example.com/v1/responses",
|
||||
webhookAuthHeader: "Bearer explicit-token",
|
||||
headers: {
|
||||
"x-openclaw-auth": "existing-token",
|
||||
},
|
||||
},
|
||||
inboundOpenClawAuthHeader: "legacy-token",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
webhookAuthHeader: "Bearer explicit-token",
|
||||
headers: {
|
||||
"x-openclaw-auth": "existing-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth from agentDefaultsPayload.headers.x-openclaw-auth", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
url: "http://127.0.0.1:18789/v1/responses",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth from agentDefaultsPayload.headers.x-openclaw-token", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
url: "http://127.0.0.1:18789/hooks/agent",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts inbound x-openclaw-token compatibility header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: null,
|
||||
inboundOpenClawTokenHeader: "gateway-token",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts wrapped auth values in headers for compatibility", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
headers: {
|
||||
"x-openclaw-auth": {
|
||||
value: "gateway-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth headers provided as tuple entries", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
headers: [["x-openclaw-auth", "gateway-token"]],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth headers provided as name/value entries", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
headers: [{ name: "x-openclaw-auth", value: { authToken: "gateway-token" } }],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts auth headers wrapped in a single unknown key", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
defaultsPayload: {
|
||||
headers: {
|
||||
"x-openclaw-auth": {
|
||||
gatewayToken: "gateway-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-auth": "gateway-token",
|
||||
},
|
||||
webhookAuthHeader: "Bearer gateway-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-openclaw payloads unchanged", () => {
|
||||
const defaultsPayload = { command: "echo hello" };
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "process",
|
||||
defaultsPayload,
|
||||
responsesWebhookUrl: "https://ignored.example.com",
|
||||
inboundOpenClawAuthHeader: "ignored-token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(defaultsPayload);
|
||||
});
|
||||
|
||||
it("normalizes wrapped gateway token headers for openclaw_gateway", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": {
|
||||
value: "gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts inbound x-openclaw-token for openclaw_gateway", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
},
|
||||
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("generates persistent device key for openclaw_gateway when device auth is enabled", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: false,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
|
||||
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
|
||||
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
|
||||
});
|
||||
|
||||
it("does not generate device key when openclaw_gateway has disableDeviceAuth=true", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: true,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
|
||||
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
canReplayOpenClawInviteAccept,
|
||||
canReplayOpenClawGatewayInviteAccept,
|
||||
mergeJoinDefaultsPayloadForReplay,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("canReplayOpenClawInviteAccept", () => {
|
||||
it("allows replay only for openclaw agent joins in pending or approved state", () => {
|
||||
describe("canReplayOpenClawGatewayInviteAccept", () => {
|
||||
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "approved",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "rejected",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "human",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canReplayOpenClawInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "process",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
@@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => {
|
||||
});
|
||||
|
||||
describe("mergeJoinDefaultsPayloadForReplay", () => {
|
||||
it("merges replay payloads and preserves existing fields while allowing auth/header overrides", () => {
|
||||
it("merges replay payloads and allows gateway token override", () => {
|
||||
const merged = mergeJoinDefaultsPayloadForReplay(
|
||||
{
|
||||
url: "https://old.example/v1/responses",
|
||||
method: "POST",
|
||||
url: "ws://old.example:18789",
|
||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||
headers: {
|
||||
"x-openclaw-auth": "old-token",
|
||||
"x-openclaw-token": "old-token-1234567890",
|
||||
"x-custom": "keep-me",
|
||||
},
|
||||
},
|
||||
{
|
||||
paperclipApiUrl: "https://paperclip.example.com",
|
||||
headers: {
|
||||
"x-openclaw-auth": "new-token",
|
||||
"x-openclaw-token": "new-token-1234567890",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const normalized = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw",
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: merged,
|
||||
inboundOpenClawAuthHeader: null,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(normalized.url).toBe("https://old.example/v1/responses");
|
||||
expect(normalized.url).toBe("ws://old.example:18789");
|
||||
expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com");
|
||||
expect(normalized.webhookAuthHeader).toBe("Bearer new-token");
|
||||
expect(normalized.headers).toMatchObject({
|
||||
"x-openclaw-auth": "new-token",
|
||||
"x-openclaw-token": "new-token-1234567890",
|
||||
"x-custom": "keep-me",
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -167,6 +167,208 @@ async function createMockGatewayServer() {
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServerWithPairing() {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
let agentPayload: Record<string, unknown> | null = null;
|
||||
let approved = false;
|
||||
let pendingRequestId = "req-1";
|
||||
let lastSeenDeviceId: string | null = null;
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "nonce-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||
const frame = JSON.parse(text) as {
|
||||
type: string;
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (frame.type !== "req") return;
|
||||
|
||||
if (frame.method === "connect") {
|
||||
const device = frame.params?.device as Record<string, unknown> | undefined;
|
||||
const deviceId = typeof device?.id === "string" ? device.id : null;
|
||||
if (deviceId) {
|
||||
lastSeenDeviceId = deviceId;
|
||||
}
|
||||
|
||||
if (deviceId && !approved) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NOT_PAIRED",
|
||||
message: "pairing required",
|
||||
details: {
|
||||
code: "PAIRING_REQUIRED",
|
||||
requestId: pendingRequestId,
|
||||
reason: "not-paired",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.close(1008, "pairing required");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
server: { version: "test", connId: "conn-1" },
|
||||
features: {
|
||||
methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"],
|
||||
events: ["agent"],
|
||||
},
|
||||
snapshot: { version: 1, ts: Date.now() },
|
||||
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "device.pair.list") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
pending: approved
|
||||
? []
|
||||
: [
|
||||
{
|
||||
requestId: pendingRequestId,
|
||||
deviceId: lastSeenDeviceId ?? "device-unknown",
|
||||
},
|
||||
],
|
||||
paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "device.pair.approve") {
|
||||
const requestId = frame.params?.requestId;
|
||||
if (requestId !== pendingRequestId) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: "unknown requestId" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
approved = true;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
requestId: pendingRequestId,
|
||||
device: {
|
||||
deviceId: lastSeenDeviceId ?? "device-unknown",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent") {
|
||||
agentPayload = frame.params ?? null;
|
||||
const runId =
|
||||
typeof frame.params?.idempotencyKey === "string"
|
||||
? frame.params.idempotencyKey
|
||||
: "run-123";
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "ok" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent.wait") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve test server address");
|
||||
}
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
getAgentPayload: () => agentPayload,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// no global mocks
|
||||
});
|
||||
@@ -222,7 +424,7 @@ describe("openclaw gateway adapter execute", () => {
|
||||
const payload = gateway.getAgentPayload();
|
||||
expect(payload).toBeTruthy();
|
||||
expect(payload?.idempotencyKey).toBe("run-123");
|
||||
expect(payload?.sessionKey).toBe("paperclip");
|
||||
expect(payload?.sessionKey).toBe("paperclip:issue:issue-123");
|
||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||
@@ -238,6 +440,43 @@ describe("openclaw gateway adapter execute", () => {
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||
});
|
||||
|
||||
it("auto-approves pairing once and retries the run", async () => {
|
||||
const gateway = await createMockGatewayServerWithPairing();
|
||||
const logs: string[] = [];
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext(
|
||||
{
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
},
|
||||
{
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.summary).toContain("ok");
|
||||
expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true);
|
||||
expect(gateway.getAgentPayload()).toBeTruthy();
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway testEnvironment", () => {
|
||||
|
||||
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
isInstanceAdmin: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
listMembers: vi.fn(),
|
||||
setMemberPermissions: vi.fn(),
|
||||
promoteInstanceAdmin: vi.fn(),
|
||||
demoteInstanceAdmin: vi.fn(),
|
||||
listUserCompanyAccess: vi.fn(),
|
||||
setUserCompanyAccess: vi.fn(),
|
||||
setPrincipalGrants: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const createdInvite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
tokenHash: "hash",
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const returning = vi.fn().mockResolvedValue([createdInvite]);
|
||||
const values = vi.fn().mockReturnValue({ returning });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
return {
|
||||
insert,
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
beforeEach(() => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("rejects non-CEO agent callers", async () => {
|
||||
const db = createDbStub();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Only CEO agents");
|
||||
});
|
||||
|
||||
it("allows CEO agent callers and creates an agent-only invite", async () => {
|
||||
const db = createDbStub();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({ agentMessage: "Join and configure OpenClaw gateway." });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
expect(typeof res.body.token).toBe("string");
|
||||
expect(res.body.onboardingTextPath).toContain("/api/invites/");
|
||||
});
|
||||
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
});
|
||||
|
||||
it("rejects board callers without invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Permission denied");
|
||||
});
|
||||
});
|
||||
@@ -26,15 +26,6 @@ import {
|
||||
import {
|
||||
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
||||
} from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
execute as openclawExecute,
|
||||
testEnvironment as openclawTestEnvironment,
|
||||
onHireApproved as openclawOnHireApproved,
|
||||
} from "@paperclipai/adapter-openclaw/server";
|
||||
import {
|
||||
agentConfigurationDoc as openclawAgentConfigurationDoc,
|
||||
models as openclawModels,
|
||||
} from "@paperclipai/adapter-openclaw";
|
||||
import {
|
||||
execute as openclawGatewayExecute,
|
||||
testEnvironment as openclawGatewayTestEnvironment,
|
||||
@@ -89,16 +80,6 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openclawAdapter: ServerAdapterModule = {
|
||||
type: "openclaw",
|
||||
execute: openclawExecute,
|
||||
testEnvironment: openclawTestEnvironment,
|
||||
onHireApproved: openclawOnHireApproved,
|
||||
models: openclawModels,
|
||||
supportsLocalAgentJwt: false,
|
||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
execute: openclawGatewayExecute,
|
||||
@@ -137,7 +118,6 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
openCodeLocalAdapter,
|
||||
piLocalAdapter,
|
||||
cursorLocalAdapter,
|
||||
openclawAdapter,
|
||||
openclawGatewayAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { Router, type Request } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
||||
@@ -181,6 +181,40 @@ export function agentRoutes(db: Db) {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseBooleanLike(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateEd25519PrivateKeyPem(): string {
|
||||
const { privateKey } = generateKeyPairSync("ed25519");
|
||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||
}
|
||||
|
||||
function ensureGatewayDeviceKey(
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (adapterType !== "openclaw_gateway") return adapterConfig;
|
||||
const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true;
|
||||
if (disableDeviceAuth) return adapterConfig;
|
||||
if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig;
|
||||
return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() };
|
||||
}
|
||||
|
||||
function applyCreateDefaultsByAdapterType(
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
@@ -196,13 +230,13 @@ export function agentRoutes(db: Db) {
|
||||
if (!hasBypassFlag) {
|
||||
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
}
|
||||
return next;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
}
|
||||
return next;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
|
||||
async function assertAdapterConfigConstraints(
|
||||
@@ -930,11 +964,7 @@ export function agentRoutes(db: Db) {
|
||||
if (changingInstructionsPath) {
|
||||
await assertCanManageInstructionsPath(req, existing);
|
||||
}
|
||||
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
adapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
patchData.adapterConfig = adapterConfig;
|
||||
}
|
||||
|
||||
const requestedAdapterType =
|
||||
@@ -942,15 +972,23 @@ export function agentRoutes(db: Db) {
|
||||
const touchesAdapterConfiguration =
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||
if (touchesAdapterConfiguration) {
|
||||
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: (asRecord(existing.adapterConfig) ?? {});
|
||||
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
requestedAdapterType,
|
||||
rawEffectiveAdapterConfig,
|
||||
);
|
||||
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
effectiveAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
|
||||
}
|
||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
|
||||
await assertAdapterConfigConstraints(
|
||||
existing.companyId,
|
||||
requestedAdapterType,
|
||||
|
||||
@@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
{ path: ["maxTurnsPerRun"], value: 80 },
|
||||
],
|
||||
openclaw: [
|
||||
{ path: ["method"], value: "POST" },
|
||||
{ path: ["timeoutSec"], value: 30 },
|
||||
],
|
||||
openclaw_gateway: [
|
||||
{ path: ["timeoutSec"], value: 120 },
|
||||
{ path: ["waitTimeoutMs"], value: 120000 },
|
||||
|
||||
@@ -91,6 +91,30 @@ Workspace rules:
|
||||
- For repo-only setup, omit `cwd` and provide `repoUrl`.
|
||||
- Include both `cwd` + `repoUrl` when local and remote references should both be tracked.
|
||||
|
||||
## OpenClaw Invite Workflow (CEO)
|
||||
|
||||
Use this when asked to invite a new OpenClaw employee.
|
||||
|
||||
1. Generate a fresh OpenClaw invite prompt:
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/openclaw/invite-prompt
|
||||
{ "agentMessage": "optional onboarding note for OpenClaw" }
|
||||
```
|
||||
|
||||
Access control:
|
||||
- Board users with invite permission can call it.
|
||||
- Agent callers: only the company CEO agent can call it.
|
||||
|
||||
2. Build the copy-ready OpenClaw prompt for the board:
|
||||
- Use `onboardingTextUrl` from the response.
|
||||
- Ask the board to paste that prompt into OpenClaw.
|
||||
- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`.
|
||||
|
||||
3. Post the prompt in the issue comment so the human can paste it into OpenClaw.
|
||||
|
||||
4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install).
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **Always checkout** before working. Never PATCH to `in_progress` manually.
|
||||
@@ -206,6 +230,7 @@ PATCH /api/agents/{agentId}/instructions-path
|
||||
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||
| Add comment | `POST /api/issues/:issueId/comments` |
|
||||
| Create subtask | `POST /api/companies/:companyId/issues` |
|
||||
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
|
||||
| Create project | `POST /api/companies/:companyId/projects` |
|
||||
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
||||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||
|
||||
@@ -280,6 +280,23 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts,
|
||||
|
||||
Use the dashboard for situational awareness, especially if you're a manager or CEO.
|
||||
|
||||
## OpenClaw Invite Prompt (CEO)
|
||||
|
||||
Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt:
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/openclaw/invite-prompt
|
||||
{
|
||||
"agentMessage": "optional note for the joining OpenClaw agent"
|
||||
}
|
||||
```
|
||||
|
||||
Response includes invite token, onboarding text URL, and expiry metadata.
|
||||
|
||||
Access is intentionally constrained:
|
||||
- board users with invite permission
|
||||
- CEO agent only (non-CEO agents are rejected)
|
||||
|
||||
---
|
||||
|
||||
## Setting Agent Instructions Path
|
||||
@@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled`
|
||||
| GET | `/api/goals/:goalId` | Goal details |
|
||||
| POST | `/api/companies/:companyId/goals` | Create goal |
|
||||
| PATCH | `/api/goals/:goalId` | Update goal |
|
||||
| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) |
|
||||
|
||||
### Approvals, Costs, Activity, Dashboard
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ npm dist-tag add @paperclipai/db@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-utils@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-claude-local@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-codex-local@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-openclaw@{version} latest
|
||||
npm dist-tag add @paperclipai/adapter-openclaw-gateway@{version} latest
|
||||
```
|
||||
|
||||
**Script option:** Add `./scripts/release.sh --promote {version}` to automate
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
|
||||
@@ -204,15 +204,11 @@ export function OpenClawGatewayConfigFields({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Disable device auth">
|
||||
<select
|
||||
value={String(eff("adapterConfig", "disableDeviceAuth", Boolean(config.disableDeviceAuth ?? false)))}
|
||||
onChange={(e) => mark("adapterConfig", "disableDeviceAuth", e.target.value === "true")}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="false">No (recommended)</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
<Field label="Device auth">
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals
|
||||
remain stable across runs.
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
|
||||
function SecretField({
|
||||
label,
|
||||
value,
|
||||
onCommit,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<DraftInput
|
||||
value={value}
|
||||
onCommit={onCommit}
|
||||
immediate
|
||||
type={visible ? "text" : "password"}
|
||||
className={inputClass + " pl-8"}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenClawConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const configuredHeaders =
|
||||
config.headers && typeof config.headers === "object" && !Array.isArray(config.headers)
|
||||
? (config.headers as Record<string, unknown>)
|
||||
: {};
|
||||
const effectiveHeaders =
|
||||
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
|
||||
const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string"
|
||||
? String(effectiveHeaders["x-openclaw-auth"])
|
||||
: "";
|
||||
|
||||
const commitGatewayAuthHeader = (rawValue: string) => {
|
||||
const nextValue = rawValue.trim();
|
||||
const nextHeaders: Record<string, unknown> = { ...effectiveHeaders };
|
||||
if (nextValue) {
|
||||
nextHeaders["x-openclaw-auth"] = nextValue;
|
||||
} else {
|
||||
delete nextHeaders["x-openclaw-auth"];
|
||||
}
|
||||
mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined);
|
||||
};
|
||||
|
||||
const transport = eff(
|
||||
"adapterConfig",
|
||||
"streamTransport",
|
||||
String(config.streamTransport ?? "sse"),
|
||||
);
|
||||
const sessionStrategy = eff(
|
||||
"adapterConfig",
|
||||
"sessionKeyStrategy",
|
||||
String(config.sessionKeyStrategy ?? "fixed"),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Gateway URL" hint={help.webhookUrl}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.url
|
||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ url: v })
|
||||
: mark("adapterConfig", "url", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Field>
|
||||
{!isCreate && (
|
||||
<>
|
||||
<Field label="Paperclip API URL override">
|
||||
<DraftInput
|
||||
value={
|
||||
eff(
|
||||
"adapterConfig",
|
||||
"paperclipApiUrl",
|
||||
String(config.paperclipApiUrl ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://paperclip.example"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Transport">
|
||||
<select
|
||||
value={transport}
|
||||
onChange={(e) => mark("adapterConfig", "streamTransport", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="sse">SSE (recommended)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Session strategy">
|
||||
<select
|
||||
value={sessionStrategy}
|
||||
onChange={(e) => mark("adapterConfig", "sessionKeyStrategy", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="issue">Per issue</option>
|
||||
<option value="run">Per run</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{sessionStrategy === "fixed" && (
|
||||
<Field label="Session key">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))}
|
||||
onCommit={(v) => mark("adapterConfig", "sessionKey", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="paperclip"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<SecretField
|
||||
label="Webhook auth header (optional)"
|
||||
value={eff("adapterConfig", "webhookAuthHeader", String(config.webhookAuthHeader ?? ""))}
|
||||
onCommit={(v) => mark("adapterConfig", "webhookAuthHeader", v || undefined)}
|
||||
placeholder="Bearer <token>"
|
||||
/>
|
||||
|
||||
<SecretField
|
||||
label="Gateway auth token (x-openclaw-auth)"
|
||||
value={effectiveGatewayAuthHeader}
|
||||
onCommit={commitGatewayAuthHeader}
|
||||
placeholder="OpenClaw gateway token"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui";
|
||||
import { buildOpenClawConfig } from "@paperclipai/adapter-openclaw/ui";
|
||||
import { OpenClawConfigFields } from "./config-fields";
|
||||
|
||||
export const openClawUIAdapter: UIAdapterModule = {
|
||||
type: "openclaw",
|
||||
label: "OpenClaw",
|
||||
parseStdoutLine: parseOpenClawStdoutLine,
|
||||
ConfigFields: OpenClawConfigFields,
|
||||
buildAdapterConfig: buildOpenClawConfig,
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { piLocalUIAdapter } from "./pi-local";
|
||||
import { openClawUIAdapter } from "./openclaw";
|
||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
@@ -16,7 +15,6 @@ const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
openClawUIAdapter,
|
||||
openClawGatewayUIAdapter,
|
||||
processUIAdapter,
|
||||
httpUIAdapter,
|
||||
|
||||
@@ -64,6 +64,17 @@ type BoardClaimStatus = {
|
||||
claimedByUserId: string | null;
|
||||
};
|
||||
|
||||
type CompanyInviteCreated = {
|
||||
id: string;
|
||||
token: string;
|
||||
inviteUrl: string;
|
||||
expiresAt: string;
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
};
|
||||
|
||||
export const accessApi = {
|
||||
createCompanyInvite: (
|
||||
companyId: string,
|
||||
@@ -73,16 +84,18 @@ export const accessApi = {
|
||||
agentMessage?: string | null;
|
||||
} = {},
|
||||
) =>
|
||||
api.post<{
|
||||
id: string;
|
||||
token: string;
|
||||
inviteUrl: string;
|
||||
expiresAt: string;
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
}>(`/companies/${companyId}/invites`, input),
|
||||
api.post<CompanyInviteCreated>(`/companies/${companyId}/invites`, input),
|
||||
|
||||
createOpenClawInvitePrompt: (
|
||||
companyId: string,
|
||||
input: {
|
||||
agentMessage?: string | null;
|
||||
} = {},
|
||||
) =>
|
||||
api.post<CompanyInviteCreated>(
|
||||
`/companies/${companyId}/openclaw/invite-prompt`,
|
||||
input,
|
||||
),
|
||||
|
||||
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
||||
getInviteOnboarding: (token: string) =>
|
||||
|
||||
@@ -18,7 +18,6 @@ const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
|
||||
@@ -157,7 +157,7 @@ function parseStdoutChunk(
|
||||
if (!trimmed) continue;
|
||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
||||
if (parsed.length === 0) {
|
||||
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
|
||||
if (run.adapterType === "openclaw_gateway") {
|
||||
continue;
|
||||
}
|
||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, type ComponentType } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
@@ -9,12 +10,77 @@ import {
|
||||
DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, Sparkles } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Code,
|
||||
MousePointer2,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
|
||||
type AdvancedAdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "openclaw_gateway";
|
||||
|
||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||
value: AdvancedAdapterType;
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
recommended?: boolean;
|
||||
}> = [
|
||||
{
|
||||
value: "claude_local",
|
||||
label: "Claude Code",
|
||||
icon: Sparkles,
|
||||
desc: "Local Claude agent",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: "codex_local",
|
||||
label: "Codex",
|
||||
icon: Code,
|
||||
desc: "Local Codex agent",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: "opencode_local",
|
||||
label: "OpenCode",
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "pi_local",
|
||||
label: "Pi",
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent",
|
||||
},
|
||||
{
|
||||
value: "cursor",
|
||||
label: "Cursor",
|
||||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent",
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway",
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol",
|
||||
},
|
||||
];
|
||||
|
||||
export function NewAgentDialog() {
|
||||
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const navigate = useNavigate();
|
||||
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -34,15 +100,23 @@ export function NewAgentDialog() {
|
||||
}
|
||||
|
||||
function handleAdvancedConfig() {
|
||||
setShowAdvancedCards(true);
|
||||
}
|
||||
|
||||
function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) {
|
||||
closeNewAgent();
|
||||
navigate("/agents/new");
|
||||
setShowAdvancedCards(false);
|
||||
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={newAgentOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeNewAgent();
|
||||
if (!open) {
|
||||
setShowAdvancedCards(false);
|
||||
closeNewAgent();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
@@ -56,39 +130,84 @@ export function NewAgentDialog() {
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={closeNewAgent}
|
||||
onClick={() => {
|
||||
setShowAdvancedCards(false);
|
||||
closeNewAgent();
|
||||
}}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Recommendation */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||
<Sparkles className="h-6 w-6 text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We recommend letting your CEO handle agent setup — they know the
|
||||
org structure and can configure reporting, permissions, and
|
||||
adapters.
|
||||
</p>
|
||||
</div>
|
||||
{!showAdvancedCards ? (
|
||||
<>
|
||||
{/* Recommendation */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||
<Sparkles className="h-6 w-6 text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We recommend letting your CEO handle agent setup — they know the
|
||||
org structure and can configure reporting, permissions, and
|
||||
adapters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" size="lg" onClick={handleAskCeo}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
Ask the CEO to create a new agent
|
||||
</Button>
|
||||
<Button className="w-full" size="lg" onClick={handleAskCeo}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
Ask the CEO to create a new agent
|
||||
</Button>
|
||||
|
||||
{/* Advanced link */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||
onClick={handleAdvancedConfig}
|
||||
>
|
||||
I want advanced configuration myself
|
||||
</button>
|
||||
</div>
|
||||
{/* Advanced link */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||
onClick={handleAdvancedConfig}
|
||||
>
|
||||
I want advanced configuration myself
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowAdvancedCards(false)}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose your adapter type for advanced setup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ADVANCED_ADAPTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative"
|
||||
)}
|
||||
onClick={() => handleAdvancedAdapterPick(opt.value)}
|
||||
>
|
||||
{opt.recommended && (
|
||||
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Terminal,
|
||||
Globe,
|
||||
Sparkles,
|
||||
MousePointer2,
|
||||
Check,
|
||||
@@ -57,7 +56,6 @@ type AdapterType =
|
||||
| "cursor"
|
||||
| "process"
|
||||
| "http"
|
||||
| "openclaw"
|
||||
| "openclaw_gateway";
|
||||
|
||||
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md)
|
||||
@@ -673,38 +671,19 @@ export function OnboardingWizard() {
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw" as const,
|
||||
label: "OpenClaw",
|
||||
icon: Bot,
|
||||
desc: "Notify OpenClaw webhook",
|
||||
comingSoon: true
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol"
|
||||
desc: "Invoke OpenClaw via gateway protocol",
|
||||
comingSoon: true,
|
||||
disabledLabel: "Configure OpenClaw within the App"
|
||||
},
|
||||
{
|
||||
value: "cursor" as const,
|
||||
label: "Cursor",
|
||||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent"
|
||||
},
|
||||
{
|
||||
value: "process" as const,
|
||||
label: "Shell Command",
|
||||
icon: Terminal,
|
||||
desc: "Run a process",
|
||||
comingSoon: true
|
||||
},
|
||||
{
|
||||
value: "http" as const,
|
||||
label: "HTTP Webhook",
|
||||
icon: Globe,
|
||||
desc: "Call an endpoint",
|
||||
comingSoon: true
|
||||
}
|
||||
].map((opt) => (
|
||||
<button
|
||||
@@ -744,7 +723,10 @@ export function OnboardingWizard() {
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.comingSoon ? "Coming soon" : opt.desc}
|
||||
{opt.comingSoon
|
||||
? (opt as { disabledLabel?: string }).disabledLabel ??
|
||||
"Coming soon"
|
||||
: opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -988,7 +970,7 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
|
||||
{(adapterType === "http" || adapterType === "openclaw_gateway") && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
||||
role: "Organizational role. Determines position and capabilities.",
|
||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
|
||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||
model: "Override the default model used by the adapter.",
|
||||
@@ -53,7 +53,6 @@ export const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
|
||||
@@ -25,7 +25,6 @@ const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
@@ -77,9 +77,7 @@ export function CompanySettings() {
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
||||
allowedJoinTypes: "agent"
|
||||
}),
|
||||
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
||||
onSuccess: async (invite) => {
|
||||
setInviteError(null);
|
||||
const base = window.location.origin.replace(/\/+$/, "");
|
||||
@@ -317,9 +315,9 @@ export function CompanySettings() {
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Generate an agent snippet for join flows.
|
||||
Generate an OpenClaw agent invite snippet.
|
||||
</span>
|
||||
<HintIcon text="Creates an agent-only invite (10m) and renders a copy-ready snippet." />
|
||||
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
@@ -329,7 +327,7 @@ export function CompanySettings() {
|
||||
>
|
||||
{inviteMutation.isPending
|
||||
? "Generating..."
|
||||
: "Generate agent snippet"}
|
||||
: "Generate OpenClaw Invite Prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
{inviteError && (
|
||||
@@ -339,7 +337,7 @@ export function CompanySettings() {
|
||||
<div className="rounded-md border border-border bg-muted/30 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Agent Snippet
|
||||
OpenClaw Invite Prompt
|
||||
</div>
|
||||
{snippetCopied && (
|
||||
<span
|
||||
|
||||
@@ -10,16 +10,12 @@ import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
|
||||
type JoinType = "human" | "agent";
|
||||
const joinAdapterOptions: AgentAdapterType[] = [
|
||||
"openclaw",
|
||||
...AGENT_ADAPTER_TYPES.filter((type): type is Exclude<AgentAdapterType, "openclaw"> => type !== "openclaw"),
|
||||
];
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
process: "Process",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -19,12 +19,45 @@ import { AgentConfigForm, type CreateConfigValues } from "../components/AgentCon
|
||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
|
||||
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"openclaw_gateway",
|
||||
]);
|
||||
|
||||
function createValuesForAdapterType(
|
||||
adapterType: CreateConfigValues["adapterType"],
|
||||
): CreateConfigValues {
|
||||
const { adapterType: _discard, ...defaults } = defaultCreateValues;
|
||||
const nextValues: CreateConfigValues = { ...defaults, adapterType };
|
||||
if (adapterType === "codex_local") {
|
||||
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||
nextValues.dangerouslyBypassSandbox =
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
} else if (adapterType === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (adapterType === "opencode_local") {
|
||||
nextValues.model = "";
|
||||
}
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
export function NewAgent() {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const presetAdapterType = searchParams.get("adapterType");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -71,6 +104,18 @@ export function NewAgent() {
|
||||
}
|
||||
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const requested = presetAdapterType;
|
||||
if (!requested) return;
|
||||
if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) {
|
||||
return;
|
||||
}
|
||||
setConfigValues((prev) => {
|
||||
if (prev.adapterType === requested) return prev;
|
||||
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);
|
||||
});
|
||||
}, [presetAdapterType]);
|
||||
|
||||
const createAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
agentsApi.hire(selectedCompanyId!, data),
|
||||
|
||||
@@ -120,7 +120,6 @@ const adapterLabels: Record<string, string> = {
|
||||
codex_local: "Codex",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
||||
Reference in New Issue
Block a user