Merge remote-tracking branch 'public-gh/master'

* public-gh/master:
  Revert lockfile changes from OpenClaw gateway cleanup
  Enhance plugin architecture by introducing agent tool contributions and plugin-to-plugin communication. Update workspace plugin definitions for direct OS access and refine UI extension surfaces. Document new capabilities for plugin settings UI and lifecycle management.
  Remove legacy OpenClaw adapter and keep gateway-only flow
  Fix CI typecheck and default OpenClaw sessions to issue scope
  fix(server): serve cached index.html in SPA catch-all to prevent 500
  Add CEO OpenClaw invite endpoint and update onboarding UX
  openclaw gateway: auto-approve first pairing and retry
  openclaw gateway: persist device keys on create/update and clarify pairing flow
  plugin spec draft ideas v0
  openclaw gateway: persist device keys and smoke pairing flow
  openclaw-gateway: document and surface pairing-mode requirements
  fix(smoke): disambiguate case C ack comment target
  fix(openclaw-gateway): enforce join token validation and add smoke preflight gates
  fix(openclaw): make invite snippet/onboarding gateway-first
This commit is contained in:
Dotta
2026-03-07 18:59:23 -06:00
79 changed files with 5376 additions and 5043 deletions

View File

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

View File

@@ -21,7 +21,6 @@ const workspacePaths = [
"packages/adapter-utils",
"packages/adapters/claude-local",
"packages/adapters/codex-local",
"packages/adapters/openclaw",
"packages/adapters/openclaw-gateway",
];

View File

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

View File

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

View File

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

View File

@@ -18,36 +18,75 @@ 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. Case A (manual issue test).
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:
- 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>"
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}'
```
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
Pairing handshake note:
- 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.
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
- Verify in UI: issue status becomes `done` and comment exists.
7. Case B (message tool test).
8. Case B (message tool test).
- Create another issue assigned to OpenClaw.
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
- Verify both:
- marker comment on issue
- marker text appears in OpenClaw main chat
8. Case C (new session memory/skills test).
9. Case C (new session memory/skills test).
- In OpenClaw, start `/new` session.
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
- Verify in Paperclip UI that new issue exists.
9. Watch logs during test (optional but helpful):
10. Watch logs during test (optional but helpful):
```bash
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
```
10. Expected pass criteria.
11. Expected pass criteria.
- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`).
- Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path).
- Case A: `done` + marker comment.
- Case B: `done` + marker comment + main-chat message visible.
- Case C: original task done and new issue created from `/new` session.

1617
doc/plugins/PLUGIN_SPEC.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View File

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

View File

@@ -2,7 +2,8 @@
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View File

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

View File

@@ -1,351 +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 content is still OpenClaw-HTTP specific
`server/src/routes/access.ts` hardcodes onboarding to:
- `recommendedAdapterType: "openclaw"`
- Required `agentDefaultsPayload.headers.x-openclaw-auth`
- HTTP callback URL guidance and `/v1/responses` examples.
There is no adapter-specific onboarding manifest/text for `openclaw_gateway`.
### 2) Company settings snippet is OpenClaw HTTP-first
`ui/src/pages/CompanySettings.tsx` generates one snippet that:
- Assumes OpenClaw HTTP callback setup.
- Instructs enabling `gateway.http.endpoints.responses.enabled=true`.
- Does not provide a dedicated gateway onboarding path.
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"`
`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific.
No equivalent normalization/diagnostics for gateway defaults.
### 5) Webhook confusion is expected in current setup
For `openclaw` + `streamTransport=webhook`:
- Adapter may remap `/v1/responses -> /hooks/agent`.
- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`.
If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected.
### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode
- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`).
- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`.
- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes.
### 7) Gateway adapter lacks hire-approved callback parity
`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not.
Not a blocker for core routing, but creates inconsistent onboarding feedback behavior.
## UX Intention (Target Experience)
### Product goal
Users should pick one clear onboarding path:
- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs.
- `Invite OpenClaw Gateway` for gateway-native installs.
### UX design requirements
- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route).
- Mode-specific generated snippet and mode-specific onboarding text.
- Clear compatibility checks before user copies anything.
### Proposed UX structure
1. Add invite buttons:
- `Invite OpenClaw (SSE/Webhook)`
- `Invite OpenClaw Gateway`
2. For HTTP invite:
- Require transport choice (`sse` or `webhook`).
- Validate endpoint expectations:
- `sse` with `/v1/responses`.
- `webhook` with `/hooks/*` and hooks enablement guidance.
3. For Gateway invite:
- Ask only for `ws://`/`wss://` and token source guidance.
- No callback URL/paperclipApiUrl complexity in onboarding.
4. Always show:
- Preflight diagnostics.
- Copy-ready command/snippet.
- Expected next steps (join -> approve -> claim -> skill install).
## Why Gateway Improves Onboarding
Compared to webhook/SSE onboarding:
- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls.
- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion.
- Better run observability: gateway event frames stream lifecycle/delta events in one protocol.
Tradeoff:
- Requires stable WS endpoint and gateway token handling.
## Codex-Executable E2E Workflow
## Scope
Run this full flow per test cycle against company `CLA`:
1. Assign task to OpenClaw agent -> agent executes -> task closes.
2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat.
3. OpenClaw in a fresh/new session can still create a Paperclip task.
4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup.
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. Claim API key with `claimSecret`.
5. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
6. Ensure Paperclip skill is installed for OpenClaw runtime.
7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
## 6) E2E Validation Cases
### Case A: Assigned task execution/closure
1. Create issue in CLA assigned to joined OpenClaw agent.
2. Poll issue + heartbeat runs until terminal.
3. Pass criteria:
- At least one run invoked for that agent/issue.
- Run status `succeeded`.
- Issue reaches `done` (or documented expected terminal state if policy differs).
### Case B: Message tool to main chat
1. Create issue instructing OpenClaw: “send a message to the users main chat session in webchat using message tool”.
2. Trigger/poll run completion.
3. Validate output:
- Automated minimum: run log/transcript confirms tool invocation success.
- UX-level validation: message visibly appears in main chat UI.
Current recommendation:
- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification.
### Case C: Fresh session still creates Paperclip task
1. Force fresh-session behavior for test:
- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key).
2. Create issue asking agent to create a new Paperclip task.
3. Pass criteria:
- New issue appears in CLA with expected title/body.
- Agent succeeds without re-onboarding.
## 7) Observability and Assertions
Use these APIs for deterministic assertions:
- `GET /api/companies/:companyId/heartbeat-runs?agentId=...`
- `GET /api/heartbeat-runs/:runId/events`
- `GET /api/heartbeat-runs/:runId/log`
- `GET /api/issues/:id`
- `GET /api/companies/:companyId/issues?q=...`
Include explicit timeout budgets per poll loop and hard failure reasons in output.
## 8) Automation Artifact
Implemented smoke harness:
- `scripts/smoke/openclaw-gateway-e2e.sh`
Responsibilities:
- OpenClaw docker cleanup/rebuild/start.
- Paperclip health/auth preflight.
- CLA company resolution.
- Old OpenClaw agent cleanup.
- Invite/join/approve/claim orchestration.
- E2E case execution + assertions.
- Final summary with run IDs, issue IDs, agent ID.
## 9) Required Product/Code Changes to Support This Plan Cleanly
### Access/onboarding backend
- Make onboarding manifest/text adapter-aware (`openclaw` vs `openclaw_gateway`).
- Add gateway-specific required fields and examples.
- Add gateway-specific diagnostics (WS URL/token/role/scopes/device-auth hints).
### Company settings UX
- Replace single generic snippet with mode-specific invite actions.
- Add “Invite OpenClaw Gateway” path with concise copy/paste onboarding.
### Invite landing UX
- Enable OpenClaw adapter options when invite allows agent join.
- Allow `agentDefaultsPayload` entry for advanced joins where needed.
### Adapter parity
- Consider `onHireApproved` support for `openclaw_gateway` for consistency.
### Test coverage
- Add integration tests for adapter-aware onboarding manifest generation.
- Add route tests for gateway join/approve/claim path.
- Add smoke test target for gateway E2E flow.
## 10) Execution Order
1. Implement onboarding manifest/text split by adapter mode.
2. Add company settings invite UX split (HTTP vs Gateway).
3. Add gateway E2E smoke script.
4. Run full CLA workflow in authenticated/private mode.
5. Iterate on message-tool verification automation.
## Acceptance Criteria
- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal.
- Gateway onboarding is first-class and copy/pasteable from company settings.
- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup.
- All three validation cases are documented with pass/fail criteria and reproducible evidence paths.

View File

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

View File

@@ -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,182 +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");
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${message}\n`);
return {
exitCode: 1,
signal: null,
timedOut,
errorMessage: message,
errorCode: timedOut ? "openclaw_gateway_timeout" : "openclaw_gateway_request_failed",
resultJson: asRecord(latestResultPayload),
};
} finally {
client.close();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

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

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}"
OPENCLAW_DIAG_DIR="${OPENCLAW_DIAG_DIR:-/tmp/openclaw-gateway-e2e-diag-$(date +%Y%m%d-%H%M%S)}"
OPENCLAW_ADAPTER_TIMEOUT_SEC="${OPENCLAW_ADAPTER_TIMEOUT_SEC:-120}"
OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS="${OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS:-120000}"
PAIRING_AUTO_APPROVE="${PAIRING_AUTO_APPROVE:-1}"
PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}"
AUTH_HEADERS=()
@@ -418,7 +419,6 @@ create_and_approve_gateway_join() {
headers: { "x-openclaw-token": $token },
role: "operator",
scopes: ["operator.admin"],
disableDeviceAuth: true,
sessionKeyStrategy: "fixed",
sessionKey: "paperclip",
timeoutSec: $timeoutSec,
@@ -524,6 +524,73 @@ inject_agent_api_key_payload_template() {
assert_status "200"
}
validate_joined_gateway_agent() {
local expected_gateway_token="$1"
api_request "GET" "/agents/${AGENT_ID}"
assert_status "200"
local adapter_type gateway_url configured_token disable_device_auth device_key_len
adapter_type="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")"
gateway_url="$(jq -r '.adapterConfig.url // empty' <<<"$RESPONSE_BODY")"
configured_token="$(jq -r '.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // empty' <<<"$RESPONSE_BODY")"
disable_device_auth="$(jq -r 'if .adapterConfig.disableDeviceAuth == true then "true" else "false" end' <<<"$RESPONSE_BODY")"
device_key_len="$(jq -r '(.adapterConfig.devicePrivateKeyPem // "" | length)' <<<"$RESPONSE_BODY")"
[[ "$adapter_type" == "openclaw_gateway" ]] || fail "joined agent adapterType is '${adapter_type}', expected 'openclaw_gateway'"
[[ "$gateway_url" =~ ^wss?:// ]] || fail "joined agent gateway url is invalid: '${gateway_url}'"
[[ -n "$configured_token" ]] || fail "joined agent missing adapterConfig.headers.x-openclaw-token"
if (( ${#configured_token} < 16 )); then
fail "joined agent gateway token looks too short (${#configured_token} chars)"
fi
local expected_hash configured_hash
expected_hash="$(hash_prefix "$expected_gateway_token")"
configured_hash="$(hash_prefix "$configured_token")"
if [[ "$expected_hash" != "$configured_hash" ]]; then
fail "joined agent gateway token hash mismatch (expected ${expected_hash}, got ${configured_hash})"
fi
[[ "$disable_device_auth" == "false" ]] || fail "joined agent has disableDeviceAuth=true; smoke requires device auth enabled with persistent key"
if (( device_key_len < 32 )); then
fail "joined agent missing persistent devicePrivateKeyPem (length=${device_key_len})"
fi
log "validated joined gateway agent config (token sha256 prefix ${configured_hash})"
}
run_log_contains_pairing_required() {
local run_id="$1"
api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=262144"
if [[ "$RESPONSE_CODE" != "200" ]]; then
return 1
fi
local content
content="$(jq -r '.content // ""' <<<"$RESPONSE_BODY")"
grep -qi "pairing required" <<<"$content"
}
approve_latest_pairing_request() {
local gateway_token="$1"
local container
container="$(detect_openclaw_container || true)"
[[ -n "$container" ]] || return 1
log "approving latest gateway pairing request in ${container}"
local output
if output="$(docker exec \
-e OPENCLAW_GATEWAY_URL="$OPENCLAW_GATEWAY_URL" \
-e OPENCLAW_GATEWAY_TOKEN="$gateway_token" \
"$container" \
sh -lc 'openclaw devices approve --latest --json --url "$OPENCLAW_GATEWAY_URL" --token "$OPENCLAW_GATEWAY_TOKEN"' 2>&1)"; then
log "pairing approval response: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)"
return 0
fi
warn "pairing auto-approve failed: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)"
return 1
}
trigger_wakeup() {
local reason="$1"
local issue_id="${2:-}"
@@ -764,8 +831,9 @@ run_case_c() {
local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)"
local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)"
local original_issue_reference="the original case issue you are currently reading"
local description
description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on this issue containing exactly: ${ack_marker}\nThen mark this issue done."
description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on ${original_issue_reference} containing exactly: ${ack_marker}\nDo NOT post the ACK comment on the newly created issue.\nThen mark the original case issue done."
local created
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")"
@@ -840,14 +908,32 @@ main() {
create_and_approve_gateway_join "$gateway_token"
log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}"
validate_joined_gateway_agent "$gateway_token"
trigger_wakeup "openclaw_gateway_smoke_connectivity"
if [[ -n "$RUN_ID" ]]; then
local connect_status
local connect_status="unknown"
local connect_attempt
for connect_attempt in 1 2; do
trigger_wakeup "openclaw_gateway_smoke_connectivity_attempt_${connect_attempt}"
if [[ -z "$RUN_ID" ]]; then
connect_status="unknown"
break
fi
connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
[[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run failed: ${connect_status}"
log "connectivity wake run ${RUN_ID} succeeded"
fi
if [[ "$connect_status" == "succeeded" ]]; then
log "connectivity wake run ${RUN_ID} succeeded (attempt=${connect_attempt})"
break
fi
if [[ "$PAIRING_AUTO_APPROVE" == "1" && "$connect_attempt" -eq 1 ]] && run_log_contains_pairing_required "$RUN_ID"; then
log "connectivity run hit pairing gate; attempting one-time pairing approval"
approve_latest_pairing_request "$gateway_token" || fail "pairing approval failed after pairing-required run ${RUN_ID}"
sleep 2
continue
fi
fail "connectivity wake run failed: ${connect_status} (attempt=${connect_attempt}, runId=${RUN_ID})"
done
[[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run did not succeed after retries"
run_case_a
run_case_b

View File

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

View File

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

View File

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

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

View File

@@ -1,211 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildJoinDefaultsPayloadForAccept } 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);
});
});

View File

@@ -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",
});
});

View File

@@ -37,21 +37,22 @@ describe("buildInviteOnboardingTextDocument", () => {
allowedHostnames: [],
});
expect(text).toContain("Paperclip OpenClaw Onboarding");
expect(text).toContain("Paperclip OpenClaw Gateway Onboarding");
expect(text).toContain("/api/invites/token-123/accept");
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
expect(text).toContain("/api/invites/token-123/onboarding.txt");
expect(text).toContain("/api/invites/token-123/test-resolution");
expect(text).toContain("Suggested Paperclip base URLs to try");
expect(text).toContain("http://localhost:3100");
expect(text).toContain("host.docker.internal");
expect(text).toContain("paperclipApiUrl");
expect(text).toContain("You MUST include agentDefaultsPayload.headers.x-openclaw-auth");
expect(text).toContain("will fail with 401 Unauthorized");
expect(text).toContain("adapterType \"openclaw_gateway\"");
expect(text).toContain("headers.x-openclaw-token");
expect(text).toContain("Do NOT use /v1/responses or /hooks/*");
expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
expect(text).toContain("PAPERCLIP_API_KEY");
expect(text).toContain("saved token field");
expect(text).toContain("Gateway token unexpectedly short");
});
it("includes loopback diagnostics for authenticated/private onboarding", () => {

File diff suppressed because it is too large Load Diff

View File

@@ -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", () => {

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

View File

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

View File

@@ -134,9 +134,10 @@ export async function createApp(
];
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
if (uiDist) {
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
app.use(express.static(uiDist));
app.get(/.*/, (_req, res) => {
res.sendFile("index.html", { root: uiDist });
res.status(200).set("Content-Type", "text/html").end(indexHtml);
});
} else {
console.warn("[paperclip] UI dist not found; running in API-only mode");

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&times;</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>

View File

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

View File

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

View File

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

View File

@@ -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
@@ -461,7 +459,7 @@ Suggested command:
Then verify with: curl -fsS <base-url>/api/health`;
const resolutionLine = resolutionTestUrl
? `\nYou MUST test callback reachability, call: ${resolutionTestUrl}?url=<urlencoded-webhook-url> (well using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
? `\nYou MUST test Paperclip-to-gateway reachability, call: ${resolutionTestUrl}?url=<urlencoded-gateway-url> (using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
: "";
return `You're invited to join a Paperclip organization.
@@ -476,13 +474,20 @@ Verify the hostname works from your runtime with: GET <base-url>/api/health
${connectivityBlock}
For your "agentName", just use your own agent name (OpenClaw or whatever your name is) and send it directly.
You also need to send a webhook URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
You also need to send a gateway URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
---
In order for Paperclip to work smoothly, you need to enable \`gateway.http.endpoints.responses.enabled=true\` in your OpenClaw config.
This onboarding flow is for OpenClaw Gateway.
When you submit the join request, use:
- \`adapterType: "openclaw_gateway"\`
- \`agentDefaultsPayload.url\` as your \`ws://\` or \`wss://\` gateway URL
- \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token
- (legacy accepted) \`agentDefaultsPayload.headers["x-openclaw-auth"]\`
- Keep device auth enabled (recommended). If \`devicePrivateKeyPem\` is omitted, Paperclip will generate and persist one during join so pairing approvals remain stable.
- Only use \`disableDeviceAuth=true\` for special environments where pairing cannot be completed.
Please enable that setting and notify your user that we did so.
Do NOT use \`/v1/responses\` or \`/hooks/*\` in this join flow.
Before you do anything, please respond to your user that you understand the instructions and you're going to work on them. Then do the step above in another session called "paperclip-onboarding" and then tell your user when you're done. Update your user in intermediate steps along the way so they know what's going on.

View File

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

View File

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

View File

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