diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index c116047c..495fad99 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -22,6 +22,7 @@ const workspacePaths = [ "packages/adapters/claude-local", "packages/adapters/codex-local", "packages/adapters/openclaw", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that should NOT be bundled — they'll be published diff --git a/cli/package.json b/cli/package.json index 4126d93b..bd0ac340 100644 --- a/cli/package.json +++ b/cli/package.json @@ -39,6 +39,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/server": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 818bc6e6..82762ab3 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -4,6 +4,7 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; +import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -32,8 +33,22 @@ const openclawCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printOpenClawStreamEvent, }; +const openclawGatewayCLIAdapter: CLIAdapterModule = { + type: "openclaw_gateway", + formatStdoutEvent: printOpenClawGatewayStreamEvent, +}; + const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [ + claudeLocalCLIAdapter, + codexLocalCLIAdapter, + openCodeLocalCLIAdapter, + cursorLocalCLIAdapter, + openclawCLIAdapter, + openclawGatewayCLIAdapter, + processCLIAdapter, + httpCLIAdapter, + ].map((a) => [a.type, a]), ); export function getCLIAdapter(type: string): CLIAdapterModule { diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md new file mode 100644 index 00000000..61ebfaea --- /dev/null +++ b/packages/adapters/openclaw-gateway/README.md @@ -0,0 +1,71 @@ +# OpenClaw Gateway Adapter + +This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol. + +## Transport + +This adapter always uses WebSocket gateway transport. + +- URL must be `ws://` or `wss://` +- Connect flow follows gateway protocol: +1. receive `connect.challenge` +2. send `req connect` (protocol/client/auth/device payload) +3. send `req agent` +4. wait for completion via `req agent.wait` +5. stream `event agent` frames into Paperclip logs/transcript parsing + +## Auth Modes + +Gateway credentials can be provided in any of these ways: + +- `authToken` / `token` in adapter config +- `headers.x-openclaw-token` +- `headers.x-openclaw-auth` (legacy) +- `password` (shared password mode) + +When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer `. + +## Device Auth + +By default the adapter sends a signed `device` payload in `connect` params. + +- set `disableDeviceAuth=true` to omit device signing +- set `devicePrivateKeyPem` to pin a stable signing key +- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run + +## Session Strategy + +The adapter supports the same session routing model as HTTP OpenClaw mode: + +- `sessionKeyStrategy=fixed|issue|run` +- `sessionKey` is used when strategy is `fixed` + +Resolved session key is sent as `agent.sessionKey`. + +## Payload Mapping + +The agent request is built as: + +- required fields: + - `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix) + - `idempotencyKey` (Paperclip `runId`) + - `sessionKey` (resolved strategy) +- optional additions: + - all `payloadTemplate` fields merged in + - `agentId` from config if set and not already in template + +## Timeouts + +- `timeoutSec` controls adapter-level request budget +- `waitTimeoutMs` controls `agent.wait.timeoutMs` + +If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`. + +## Log Format + +Structured gateway event logs use: + +- `[openclaw-gateway] ...` for lifecycle/system logs +- `[openclaw-gateway:event] run= stream= data=` for `event agent` frames + +UI/CLI parsers consume these lines to render transcript updates. diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md new file mode 100644 index 00000000..965d8179 --- /dev/null +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -0,0 +1,324 @@ +# OpenClaw Gateway Onboarding and Test Plan + +## Objective +Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex. + +This plan covers: +- Current onboarding flow behavior and gaps. +- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol). +- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company. + +## Hard Requirements (Testing Contract) +These are mandatory for onboarding and smoke testing: + +1. **Stock/clean OpenClaw boot every run** +- Use a fresh, unmodified OpenClaw Docker image path each test cycle. +- Do not rely on persistent/manual in-UI tweaks from prior runs. +- Recreate runtime state each run so results represent first-time user experience. + +2. **One-command/prompt setup inside OpenClaw** +- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able). +- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”). +- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps. + +## External Protocol Constraints +OpenClaw docs to anchor behavior: +- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook +- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol +- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses + +Implication: +- `webhook` transport should target `/hooks/*` and requires hook server enablement. +- `sse` transport should target `/v1/responses`. +- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`. + +## Current Implementation Map (What Exists) + +### Invite + onboarding pipeline +- Invite create: `POST /api/companies/:companyId/invites` +- Invite onboarding manifest: `GET /api/invites/:token/onboarding` +- Agent-readable text: `GET /api/invites/:token/onboarding.txt` +- Accept join: `POST /api/invites/:token/accept` +- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve` +- Claim key: `POST /api/join-requests/:requestId/claim-api-key` + +### Adapter state +- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode. +- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`). + +### Existing smoke foundation +- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`. +- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`). + +## Deep Code Findings (Gaps) + +### 1) Onboarding content is still OpenClaw-HTTP specific +`server/src/routes/access.ts` hardcodes onboarding to: +- `recommendedAdapterType: "openclaw"` +- Required `agentDefaultsPayload.headers.x-openclaw-auth` +- HTTP callback URL guidance and `/v1/responses` examples. + +There is no adapter-specific onboarding manifest/text for `openclaw_gateway`. + +### 2) Company settings snippet is OpenClaw HTTP-first +`ui/src/pages/CompanySettings.tsx` generates one snippet that: +- Assumes OpenClaw HTTP callback setup. +- Instructs enabling `gateway.http.endpoints.responses.enabled=true`. +- Does not provide a dedicated gateway onboarding path. + +### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters +`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI. + +### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"` +`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific. +No equivalent normalization/diagnostics for gateway defaults. + +### 5) Webhook confusion is expected in current setup +For `openclaw` + `streamTransport=webhook`: +- Adapter may remap `/v1/responses -> /hooks/agent`. +- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`. + +If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected. + +### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode +- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`). +- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`. +- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes. + +### 7) Gateway adapter lacks hire-approved callback parity +`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not. +Not a blocker for core routing, but creates inconsistent onboarding feedback behavior. + +## UX Intention (Target Experience) + +### Product goal +Users should pick one clear onboarding path: +- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs. +- `Invite OpenClaw Gateway` for gateway-native installs. + +### UX design requirements +- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route). +- Mode-specific generated snippet and mode-specific onboarding text. +- Clear compatibility checks before user copies anything. + +### Proposed UX structure +1. Add invite buttons: +- `Invite OpenClaw (SSE/Webhook)` +- `Invite OpenClaw Gateway` + +2. For HTTP invite: +- Require transport choice (`sse` or `webhook`). +- Validate endpoint expectations: + - `sse` with `/v1/responses`. + - `webhook` with `/hooks/*` and hooks enablement guidance. + +3. For Gateway invite: +- Ask only for `ws://`/`wss://` and token source guidance. +- No callback URL/paperclipApiUrl complexity in onboarding. + +4. Always show: +- Preflight diagnostics. +- Copy-ready command/snippet. +- Expected next steps (join -> approve -> claim -> skill install). + +## Why Gateway Improves Onboarding +Compared to webhook/SSE onboarding: +- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls. +- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion. +- Better run observability: gateway event frames stream lifecycle/delta events in one protocol. + +Tradeoff: +- Requires stable WS endpoint and gateway token handling. + +## Codex-Executable E2E Workflow + +## Scope +Run this full flow per test cycle against company `CLA`: +1. Assign task to OpenClaw agent -> agent executes -> task closes. +2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat. +3. OpenClaw in a fresh/new session can still create a Paperclip task. +4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup. + +## 0) Cleanup Before Each Run +Use deterministic reset to avoid stale agents/runs/state. + +1. OpenClaw Docker cleanup: +```bash +# stop/remove OpenClaw compose services +OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker +if [ -d "$OPENCLAW_DOCKER_DIR" ]; then + docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true +fi + +# remove old image (as requested) +docker image rm openclaw:local || true +``` + +2. Recreate OpenClaw cleanly: +```bash +OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh +``` +This must remain a stock/clean image boot path, with no hidden manual state carried from prior runs. + +3. Remove prior CLA OpenClaw agents: +- List `CLA` agents via API. +- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding. + +4. Reject/clear stale pending join requests for CLA (optional but recommended). + +## 1) Start Paperclip in Required Mode +```bash +pnpm dev --tailscale-auth +``` +Verify: +```bash +curl -fsS http://127.0.0.1:3100/api/health +# expect deploymentMode=authenticated, deploymentExposure=private +``` + +## 2) Acquire Board Session for Automation +Board operations (create invite, approve join, terminate agents) require board session cookie. + +Short-term practical options: +1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`. +2. Scripted fallback: sign-up/sign-in via `/api/auth/*`, then use a dedicated admin promotion/bootstrap utility for dev (recommended to add as a small internal script). + +Note: +- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow. + +## 3) Resolve CLA Company ID +With board cookie: +```bash +curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies +``` +Pick company where identifier/code is `CLA` and store `CLA_COMPANY_ID`. + +## 4) Preflight OpenClaw Endpoint Capability +From host (using current OpenClaw token): +- For HTTP SSE mode: confirm `/v1/responses` behavior. +- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled. +- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`. + +Expected in current docker smoke config: +- `/hooks/agent` likely `404` unless hooks explicitly enabled. +- WS gateway protocol works. + +## 5) Gateway Join Flow (Primary Path) + +1. Create agent-only invite in CLA: +```bash +POST /api/companies/$CLA_COMPANY_ID/invites +{ "allowedJoinTypes": "agent" } +``` + +2. Submit join request with gateway defaults: +```json +{ + "requestType": "agent", + "agentName": "OpenClaw Gateway", + "adapterType": "openclaw_gateway", + "capabilities": "OpenClaw gateway agent", + "agentDefaultsPayload": { + "url": "ws://127.0.0.1:18789", + "headers": { "x-openclaw-token": "" }, + "role": "operator", + "scopes": ["operator.admin"], + "sessionKeyStrategy": "fixed", + "sessionKey": "paperclip", + "waitTimeoutMs": 120000 + } +} +``` + +3. Approve join request. +4. Claim API key with `claimSecret`. +5. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context. +6. Ensure Paperclip skill is installed for OpenClaw runtime. +7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only. + +## 6) E2E Validation Cases + +### Case A: Assigned task execution/closure +1. Create issue in CLA assigned to joined OpenClaw agent. +2. Poll issue + heartbeat runs until terminal. +3. Pass criteria: +- At least one run invoked for that agent/issue. +- Run status `succeeded`. +- Issue reaches `done` (or documented expected terminal state if policy differs). + +### Case B: Message tool to main chat +1. Create issue instructing OpenClaw: “send a message to the user’s main chat session in webchat using message tool”. +2. Trigger/poll run completion. +3. Validate output: +- Automated minimum: run log/transcript confirms tool invocation success. +- UX-level validation: message visibly appears in main chat UI. + +Current recommendation: +- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification. + +### Case C: Fresh session still creates Paperclip task +1. Force fresh-session behavior for test: +- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key). +2. Create issue asking agent to create a new Paperclip task. +3. Pass criteria: +- New issue appears in CLA with expected title/body. +- Agent succeeds without re-onboarding. + +## 7) Observability and Assertions +Use these APIs for deterministic assertions: +- `GET /api/companies/:companyId/heartbeat-runs?agentId=...` +- `GET /api/heartbeat-runs/:runId/events` +- `GET /api/heartbeat-runs/:runId/log` +- `GET /api/issues/:id` +- `GET /api/companies/:companyId/issues?q=...` + +Include explicit timeout budgets per poll loop and hard failure reasons in output. + +## 8) Automation Artifact +Implemented smoke harness: +- `scripts/smoke/openclaw-gateway-e2e.sh` + +Responsibilities: +- OpenClaw docker cleanup/rebuild/start. +- Paperclip health/auth preflight. +- CLA company resolution. +- Old OpenClaw agent cleanup. +- Invite/join/approve/claim orchestration. +- 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. diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json new file mode 100644 index 00000000..0999b220 --- /dev/null +++ b/packages/adapters/openclaw-gateway/package.json @@ -0,0 +1,52 @@ +{ + "name": "@paperclipai/adapter-openclaw-gateway", + "version": "0.2.7", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/ws": "^8.18.1", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/openclaw-gateway/src/cli/format-event.ts b/packages/adapters/openclaw-gateway/src/cli/format-event.ts new file mode 100644 index 00000000..55814317 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/cli/format-event.ts @@ -0,0 +1,23 @@ +import pc from "picocolors"; + +export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + if (!debug) { + console.log(line); + return; + } + + if (line.startsWith("[openclaw-gateway:event]")) { + console.log(pc.cyan(line)); + return; + } + + if (line.startsWith("[openclaw-gateway]")) { + console.log(pc.blue(line)); + return; + } + + console.log(pc.gray(line)); +} diff --git a/packages/adapters/openclaw-gateway/src/cli/index.ts b/packages/adapters/openclaw-gateway/src/cli/index.ts new file mode 100644 index 00000000..9c621bcb --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/cli/index.ts @@ -0,0 +1 @@ +export { printOpenClawGatewayStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts new file mode 100644 index 00000000..ca16cdc9 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -0,0 +1,41 @@ +export const type = "openclaw_gateway"; +export const label = "OpenClaw Gateway"; + +export const models: { id: string; label: string }[] = []; + +export const agentConfigurationDoc = `# openclaw_gateway agent configuration + +Adapter: openclaw_gateway + +Use when: +- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol. +- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*. + +Don't use when: +- You only expose OpenClaw HTTP endpoints (use openclaw adapter with sse/webhook transport). +- Your deployment does not permit outbound WebSocket access from the Paperclip server. + +Core fields: +- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://) +- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth +- authToken (string, optional): shared gateway token override +- password (string, optional): gateway shared password, if configured + +Gateway connect identity fields: +- clientId (string, optional): gateway client id (default gateway-client) +- clientMode (string, optional): gateway client mode (default backend) +- clientVersion (string, optional): client version string +- role (string, optional): gateway role (default operator) +- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"]) +- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false) + +Request behavior fields: +- payloadTemplate (object, optional): additional fields merged into gateway agent params +- timeoutSec (number, optional): adapter timeout in seconds (default 120) +- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) +- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text + +Session routing fields: +- sessionKeyStrategy (string, optional): fixed (default), issue, or run +- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) +`; diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts new file mode 100644 index 00000000..3cc20533 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -0,0 +1,1060 @@ +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import crypto, { randomUUID } from "node:crypto"; +import { WebSocket } from "ws"; + +type SessionKeyStrategy = "fixed" | "issue" | "run"; + +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[]; +}; + +type GatewayDeviceIdentity = { + deviceId: string; + publicKeyRawBase64Url: string; + privateKeyPem: string; +}; + +type GatewayRequestFrame = { + type: "req"; + id: string; + method: string; + params?: unknown; +}; + +type GatewayResponseFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { + code?: unknown; + message?: unknown; + }; +}; + +type GatewayEventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + expectFinal: boolean; + timer: ReturnType | null; +}; + +type GatewayClientOptions = { + url: string; + headers: Record; + onEvent: (frame: GatewayEventFrame) => Promise | void; + onLog: AdapterExecutionContext["onLog"]; +}; + +type GatewayClientRequestOptions = { + timeoutMs: number; + expectFinal?: boolean; +}; + +const PROTOCOL_VERSION = 3; +const DEFAULT_SCOPES = ["operator.admin"]; +const DEFAULT_CLIENT_ID = "gateway-client"; +const DEFAULT_CLIENT_MODE = "backend"; +const DEFAULT_CLIENT_VERSION = "paperclip"; +const DEFAULT_ROLE = "operator"; + +const SENSITIVE_LOG_KEY_PATTERN = + /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseOptionalPositiveInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(1, Math.floor(value)); + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed)); + } + return null; +} + +function parseBoolean(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 normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { + const normalized = asString(value, "fixed").trim().toLowerCase(); + if (normalized === "issue" || normalized === "run") return normalized; + return "fixed"; +} + +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 isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function toStringRecord(value: unknown): Record { + const parsed = parseObject(value); + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeScopes(value: unknown): string[] { + const parsed = toStringArray(value); + return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES]; +} + +function headerMapGetIgnoreCase(headers: Record, key: string): string | null { + const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); + return match ? match[1] : null; +} + +function headerMapHasIgnoreCase(headers: Record, key: string): boolean { + return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase()); +} + +function toAuthorizationHeaderValue(rawToken: string): string { + const trimmed = rawToken.trim(); + if (!trimmed) return trimmed; + return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; +} + +function tokenFromAuthHeader(rawHeader: string | null): string | null { + if (!rawHeader) return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^bearer\s+(.+)$/i); + return match ? nonEmpty(match[1]) : trimmed; +} + +function resolveAuthToken(config: Record, headers: Record): string | null { + const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token); + if (explicit) return explicit; + + const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token"); + if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader); + + const authHeader = + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + headerMapGetIgnoreCase(headers, "authorization"); + return tokenFromAuthHeader(authHeader); +} + +function isSensitiveLogKey(key: string): boolean { + return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); +} + +function sha256Prefix(value: string): string { + return crypto.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]`; +} + +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); + const out: Record = {}; + 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); +} + +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]`; +} + +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, + ) + : [], + }; +} + +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; + } +} + +function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record { + const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl); + const paperclipEnv: Record = { + ...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; +} + +function buildWakeText(payload: WakePayload, paperclipEnv: Record): 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 lines = [ + "Paperclip wake event for a cloud adapter.", + "", + "Set these values in your run context:", + ...envLines, + `PAPERCLIP_API_KEY=`, + "", + `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, + "", + `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(",")}`, + ]; + + lines.push("", "Run your Paperclip heartbeat procedure now."); + return lines.join("\n"); +} + +function appendWakeText(baseText: string, wakeText: string): string { + const trimmedBase = baseText.trim(); + return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; +} + +function normalizeUrl(input: string): URL | null { + try { + return new URL(input); + } catch { + return null; + } +} + +function rawDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))), + ).toString("utf8"); + } + return String(data ?? ""); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function derivePublicKeyRaw(publicKeyPem: string): Buffer { + const key = crypto.createPublicKey(publicKeyPem); + const spki = key.export({ type: "spki", format: "der" }) as Buffer; + if ( + spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + return spki.subarray(ED25519_SPKI_PREFIX.length); + } + return spki; +} + +function base64UrlEncode(buf: Buffer): string { + return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function signDevicePayload(privateKeyPem: string, payload: string): string { + const key = crypto.createPrivateKey(privateKeyPem); + const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key); + return base64UrlEncode(sig); +} + +function buildDeviceAuthPayloadV3(params: { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token?: string | null; + nonce: string; + platform?: string | null; + deviceFamily?: string | null; +}): string { + const scopes = params.scopes.join(","); + const token = params.token ?? ""; + const platform = params.platform?.trim() ?? ""; + const deviceFamily = params.deviceFamily?.trim() ?? ""; + return [ + "v3", + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + params.nonce, + platform, + deviceFamily, + ].join("|"); +} + +function resolveDeviceIdentity(config: Record): GatewayDeviceIdentity { + const configuredPrivateKey = nonEmpty(config.devicePrivateKeyPem); + if (configuredPrivateKey) { + const privateKey = crypto.createPrivateKey(configuredPrivateKey); + const publicKey = crypto.createPublicKey(privateKey); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const raw = derivePublicKeyRaw(publicKeyPem); + return { + deviceId: crypto.createHash("sha256").update(raw).digest("hex"), + publicKeyRawBase64Url: base64UrlEncode(raw), + privateKeyPem: configuredPrivateKey, + }; + } + + const generated = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = generated.publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = generated.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const raw = derivePublicKeyRaw(publicKeyPem); + return { + deviceId: crypto.createHash("sha256").update(raw).digest("hex"), + publicKeyRawBase64Url: base64UrlEncode(raw), + privateKeyPem, + }; +} + +function isResponseFrame(value: unknown): value is GatewayResponseFrame { + const record = asRecord(value); + return Boolean(record && record.type === "res" && typeof record.id === "string" && typeof record.ok === "boolean"); +} + +function isEventFrame(value: unknown): value is GatewayEventFrame { + const record = asRecord(value); + return Boolean(record && record.type === "event" && typeof record.event === "string"); +} + +class GatewayWsClient { + private ws: WebSocket | null = null; + private pending = new Map(); + private challengePromise: Promise; + private resolveChallenge!: (nonce: string) => void; + private rejectChallenge!: (err: Error) => void; + + constructor(private readonly opts: GatewayClientOptions) { + this.challengePromise = new Promise((resolve, reject) => { + this.resolveChallenge = resolve; + this.rejectChallenge = reject; + }); + } + + async connect( + buildConnectParams: (nonce: string) => Record, + timeoutMs: number, + ): Promise | null> { + this.ws = new WebSocket(this.opts.url, { + headers: this.opts.headers, + maxPayload: 25 * 1024 * 1024, + }); + + const ws = this.ws; + + ws.on("message", (data) => { + this.handleMessage(rawDataToString(data)); + }); + + ws.on("close", (code, reason) => { + const reasonText = rawDataToString(reason); + const err = new Error(`gateway closed (${code}): ${reasonText}`); + this.failPending(err); + this.rejectChallenge(err); + }); + + ws.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + void this.opts.onLog("stderr", `[openclaw-gateway] websocket error: ${message}\n`); + }); + + await withTimeout( + new Promise((resolve, reject) => { + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject(new Error(`gateway closed before open (${code}): ${rawDataToString(reason)}`)); + }; + const cleanup = () => { + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }), + timeoutMs, + "gateway websocket open timeout", + ); + + const nonce = await withTimeout(this.challengePromise, timeoutMs, "gateway connect challenge timeout"); + const signedConnectParams = buildConnectParams(nonce); + + const hello = await this.request | null>("connect", signedConnectParams, { + timeoutMs, + }); + + return hello; + } + + async request( + method: string, + params: unknown, + opts: GatewayClientRequestOptions, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("gateway not connected"); + } + + const id = randomUUID(); + const frame: GatewayRequestFrame = { + type: "req", + id, + method, + params, + }; + + const payload = JSON.stringify(frame); + const requestPromise = new Promise((resolve, reject) => { + const timer = + opts.timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new Error(`gateway request timeout (${method})`)); + }, opts.timeoutMs) + : null; + + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + expectFinal: opts.expectFinal === true, + timer, + }); + }); + + this.ws.send(payload); + return requestPromise; + } + + close() { + if (!this.ws) return; + this.ws.close(1000, "paperclip-complete"); + this.ws = null; + } + + private failPending(err: Error) { + for (const [, pending] of this.pending) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(err); + } + this.pending.clear(); + } + + private handleMessage(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + if (isEventFrame(parsed)) { + if (parsed.event === "connect.challenge") { + const payload = asRecord(parsed.payload); + const nonce = nonEmpty(payload?.nonce); + if (nonce) { + this.resolveChallenge(nonce); + return; + } + } + void Promise.resolve(this.opts.onEvent(parsed)).catch(() => { + // Ignore event callback failures and keep stream active. + }); + return; + } + + if (!isResponseFrame(parsed)) return; + + const pending = this.pending.get(parsed.id); + if (!pending) return; + + const payload = asRecord(parsed.payload); + const status = nonEmpty(payload?.status)?.toLowerCase(); + if (pending.expectFinal && status === "accepted") { + return; + } + + if (pending.timer) clearTimeout(pending.timer); + this.pending.delete(parsed.id); + + if (parsed.ok) { + pending.resolve(parsed.payload ?? null); + return; + } + + const errorRecord = asRecord(parsed.error); + const message = + nonEmpty(errorRecord?.message) ?? + nonEmpty(errorRecord?.code) ?? + "gateway request failed"; + pending.reject(new Error(message)); + } +} + +function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const inputTokens = asNumber(record.inputTokens ?? record.input, 0); + const outputTokens = asNumber(record.outputTokens ?? record.output, 0); + const cachedInputTokens = asNumber( + record.cachedInputTokens ?? record.cached_input_tokens ?? record.cacheRead ?? record.cache_read, + 0, + ); + + if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) { + return undefined; + } + + return { + inputTokens, + outputTokens, + ...(cachedInputTokens > 0 ? { cachedInputTokens } : {}), + }; +} + +function extractResultText(value: unknown): string | null { + const record = asRecord(value); + if (!record) return null; + + const payloads = Array.isArray(record.payloads) ? record.payloads : []; + const texts = payloads + .map((entry) => { + const payload = asRecord(entry); + return nonEmpty(payload?.text); + }) + .filter((entry): entry is string => Boolean(entry)); + + if (texts.length > 0) return texts.join("\n\n"); + return nonEmpty(record.text) ?? nonEmpty(record.summary) ?? null; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const urlValue = asString(ctx.config.url, "").trim(); + if (!urlValue) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "OpenClaw gateway adapter missing url", + errorCode: "openclaw_gateway_url_missing", + }; + } + + const parsedUrl = normalizeUrl(urlValue); + if (!parsedUrl) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Invalid gateway URL: ${urlValue}`, + errorCode: "openclaw_gateway_url_invalid", + }; + } + + if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unsupported gateway URL protocol: ${parsedUrl.protocol}`, + errorCode: "openclaw_gateway_url_protocol", + }; + } + + const timeoutSec = Math.max(0, Math.floor(asNumber(ctx.config.timeoutSec, 120))); + const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0; + const connectTimeoutMs = timeoutMs > 0 ? Math.min(timeoutMs, 15_000) : 10_000; + const waitTimeoutMs = parseOptionalPositiveInteger(ctx.config.waitTimeoutMs) ?? (timeoutMs > 0 ? timeoutMs : 30_000); + + const payloadTemplate = parseObject(ctx.config.payloadTemplate); + const transportHint = nonEmpty(ctx.config.streamTransport) ?? nonEmpty(ctx.config.transport); + + const headers = toStringRecord(ctx.config.headers); + const authToken = resolveAuthToken(parseObject(ctx.config), headers); + const password = nonEmpty(ctx.config.password); + const deviceToken = nonEmpty(ctx.config.deviceToken); + + if (authToken && !headerMapHasIgnoreCase(headers, "authorization")) { + headers.authorization = toAuthorizationHeaderValue(authToken); + } + + const clientId = nonEmpty(ctx.config.clientId) ?? DEFAULT_CLIENT_ID; + const clientMode = nonEmpty(ctx.config.clientMode) ?? DEFAULT_CLIENT_MODE; + const clientVersion = nonEmpty(ctx.config.clientVersion) ?? DEFAULT_CLIENT_VERSION; + const role = nonEmpty(ctx.config.role) ?? DEFAULT_ROLE; + const scopes = normalizeScopes(ctx.config.scopes); + const deviceFamily = nonEmpty(ctx.config.deviceFamily); + const disableDeviceAuth = parseBoolean(ctx.config.disableDeviceAuth, false); + + const wakePayload = buildWakePayload(ctx); + const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload); + const wakeText = buildWakeText(wakePayload, paperclipEnv); + + const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); + const configuredSessionKey = nonEmpty(ctx.config.sessionKey); + const sessionKey = resolveSessionKey({ + strategy: sessionKeyStrategy, + configuredSessionKey, + runId: ctx.runId, + issueId: wakePayload.issueId, + }); + + const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text); + const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText; + + const agentParams: Record = { + ...payloadTemplate, + message, + sessionKey, + idempotencyKey: ctx.runId, + }; + delete agentParams.text; + + const configuredAgentId = nonEmpty(ctx.config.agentId); + if (configuredAgentId && !nonEmpty(agentParams.agentId)) { + agentParams.agentId = configuredAgentId; + } + + if (typeof agentParams.timeout !== "number") { + agentParams.timeout = waitTimeoutMs; + } + + const trackedRunIds = new Set([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", + command: "gateway", + commandArgs: ["ws", parsedUrl.toString(), "agent"], + context: ctx.context, + }); + } + + const outboundHeaderKeys = Object.keys(headers).sort(); + await ctx.onLog( + "stdout", + `[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, + ); + await ctx.onLog( + "stdout", + `[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`, + ); + await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); + if (transportHint) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`, + ); + } + if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) { + await ctx.onLog( + "stdout", + "[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n", + ); + } + + try { + const deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); + + await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); + + const hello = await client.connect((nonce) => { + const signedAtMs = Date.now(); + const connectParams: Record = { + 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>("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>( + "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(); + } +} diff --git a/packages/adapters/openclaw-gateway/src/server/index.ts b/packages/adapters/openclaw-gateway/src/server/index.ts new file mode 100644 index 00000000..04036438 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/index.ts @@ -0,0 +1,2 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; diff --git a/packages/adapters/openclaw-gateway/src/server/test.ts b/packages/adapters/openclaw-gateway/src/server/test.ts new file mode 100644 index 00000000..af4c74d1 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/test.ts @@ -0,0 +1,317 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { randomUUID } from "node:crypto"; +import { WebSocket } from "ws"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function toStringRecord(value: unknown): Record { + const parsed = parseObject(value); + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function headerMapGetIgnoreCase(headers: Record, key: string): string | null { + const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); + return match ? match[1] : null; +} + +function tokenFromAuthHeader(rawHeader: string | null): string | null { + if (!rawHeader) return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^bearer\s+(.+)$/i); + return match ? nonEmpty(match[1]) : trimmed; +} + +function resolveAuthToken(config: Record, headers: Record): string | null { + const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token); + if (explicit) return explicit; + + const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token"); + if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader); + + const authHeader = + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + headerMapGetIgnoreCase(headers, "authorization"); + return tokenFromAuthHeader(authHeader); +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function rawDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))), + ).toString("utf8"); + } + return String(data ?? ""); +} + +async function probeGateway(input: { + url: string; + headers: Record; + authToken: string | null; + role: string; + scopes: string[]; + timeoutMs: number; +}): Promise<"ok" | "challenge_only" | "failed"> { + return await new Promise((resolve) => { + const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 }); + const timeout = setTimeout(() => { + try { + ws.close(); + } catch { + // ignore + } + resolve("failed"); + }, input.timeoutMs); + + let completed = false; + + const finish = (status: "ok" | "challenge_only" | "failed") => { + if (completed) return; + completed = true; + clearTimeout(timeout); + try { + ws.close(); + } catch { + // ignore + } + resolve(status); + }; + + ws.on("message", (raw) => { + let parsed: unknown; + try { + parsed = JSON.parse(rawDataToString(raw)); + } catch { + return; + } + const event = asRecord(parsed); + if (event?.type === "event" && event.event === "connect.challenge") { + const nonce = nonEmpty(asRecord(event.payload)?.nonce); + if (!nonce) { + finish("failed"); + return; + } + + const connectId = randomUUID(); + ws.send( + JSON.stringify({ + type: "req", + id: connectId, + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "gateway-client", + version: "paperclip-probe", + platform: process.platform, + mode: "probe", + }, + role: input.role, + scopes: input.scopes, + ...(input.authToken + ? { + auth: { + token: input.authToken, + }, + } + : {}), + }, + }), + ); + return; + } + + if (event?.type === "res") { + if (event.ok === true) { + finish("ok"); + } else { + finish("challenge_only"); + } + } + }); + + ws.on("error", () => { + finish("failed"); + }); + + ws.on("close", () => { + if (!completed) finish("failed"); + }); + }); +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const urlValue = asString(config.url, "").trim(); + + if (!urlValue) { + checks.push({ + code: "openclaw_gateway_url_missing", + level: "error", + message: "OpenClaw gateway adapter requires a WebSocket URL.", + hint: "Set adapterConfig.url to ws://host:port (or wss://).", + }); + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; + } + + let url: URL | null = null; + try { + url = new URL(urlValue); + } catch { + checks.push({ + code: "openclaw_gateway_url_invalid", + level: "error", + message: `Invalid URL: ${urlValue}`, + }); + } + + if (url && url.protocol !== "ws:" && url.protocol !== "wss:") { + checks.push({ + code: "openclaw_gateway_url_protocol_invalid", + level: "error", + message: `Unsupported URL protocol: ${url.protocol}`, + hint: "Use ws:// or wss://.", + }); + } + + if (url) { + checks.push({ + code: "openclaw_gateway_url_valid", + level: "info", + message: `Configured gateway URL: ${url.toString()}`, + }); + + if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) { + checks.push({ + code: "openclaw_gateway_plaintext_remote_ws", + level: "warn", + message: "Gateway URL uses plaintext ws:// on a non-loopback host.", + hint: "Prefer wss:// for remote gateways.", + }); + } + } + + const headers = toStringRecord(config.headers); + const authToken = resolveAuthToken(config, headers); + const password = nonEmpty(config.password); + const role = nonEmpty(config.role) ?? "operator"; + const scopes = toStringArray(config.scopes); + + if (authToken || password) { + checks.push({ + code: "openclaw_gateway_auth_present", + level: "info", + message: "Gateway credentials are configured.", + }); + } else { + checks.push({ + code: "openclaw_gateway_auth_missing", + level: "warn", + message: "No gateway credentials detected in adapter config.", + hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.", + }); + } + + if (url && (url.protocol === "ws:" || url.protocol === "wss:")) { + try { + const probeResult = await probeGateway({ + url: url.toString(), + headers, + authToken, + role, + scopes: scopes.length > 0 ? scopes : ["operator.admin"], + timeoutMs: 3_000, + }); + + if (probeResult === "ok") { + checks.push({ + code: "openclaw_gateway_probe_ok", + level: "info", + message: "Gateway connect probe succeeded.", + }); + } else if (probeResult === "challenge_only") { + checks.push({ + code: "openclaw_gateway_probe_challenge_only", + level: "warn", + message: "Gateway challenge was received, but connect probe was rejected.", + hint: "Check gateway credentials, scopes, role, and device-auth requirements.", + }); + } else { + checks.push({ + code: "openclaw_gateway_probe_failed", + level: "warn", + message: "Gateway probe failed.", + hint: "Verify network reachability and gateway URL from the Paperclip server host.", + }); + } + } catch (err) { + checks.push({ + code: "openclaw_gateway_probe_error", + level: "warn", + message: err instanceof Error ? err.message : "Gateway probe failed", + }); + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/openclaw-gateway/src/shared/stream.ts b/packages/adapters/openclaw-gateway/src/shared/stream.ts new file mode 100644 index 00000000..860fc367 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/shared/stream.ts @@ -0,0 +1,16 @@ +export function normalizeOpenClawGatewayStreamLine(rawLine: string): { + stream: "stdout" | "stderr" | null; + line: string; +} { + const trimmed = rawLine.trim(); + if (!trimmed) return { stream: null, line: "" }; + + const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i); + if (!prefixed) { + return { stream: null, line: trimmed }; + } + + const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout"; + const line = (prefixed[2] ?? "").trim(); + return { stream, line }; +} diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts new file mode 100644 index 00000000..fcbbbf4e --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -0,0 +1,13 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.url) ac.url = v.url; + ac.timeoutSec = 120; + ac.waitTimeoutMs = 120000; + ac.sessionKeyStrategy = "fixed"; + ac.sessionKey = "paperclip"; + ac.role = "operator"; + ac.scopes = ["operator.admin"]; + return ac; +} diff --git a/packages/adapters/openclaw-gateway/src/ui/index.ts b/packages/adapters/openclaw-gateway/src/ui/index.ts new file mode 100644 index 00000000..c2ec0bcf --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js"; +export { buildOpenClawGatewayConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts b/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts new file mode 100644 index 00000000..c8cb48ae --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts @@ -0,0 +1,75 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.js"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] { + const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s); + if (!match) return [{ kind: "stdout", ts, text: line }]; + + const stream = asString(match[2]).toLowerCase(); + const data = asRecord(safeJsonParse(asString(match[3]).trim())); + + if (stream === "assistant") { + const delta = asString(data?.delta); + if (delta.length > 0) { + return [{ kind: "assistant", ts, text: delta, delta: true }]; + } + + const text = asString(data?.text); + if (text.length > 0) { + return [{ kind: "assistant", ts, text }]; + } + return []; + } + + if (stream === "error") { + const message = asString(data?.error) || asString(data?.message); + return message ? [{ kind: "stderr", ts, text: message }] : []; + } + + if (stream === "lifecycle") { + const phase = asString(data?.phase).toLowerCase(); + const message = asString(data?.error) || asString(data?.message); + if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) { + return [{ kind: "stderr", ts, text: message }]; + } + } + + return []; +} + +export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { + const normalized = normalizeOpenClawGatewayStreamLine(line); + if (normalized.stream === "stderr") { + return [{ kind: "stderr", ts, text: normalized.line }]; + } + + const trimmed = normalized.line.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("[openclaw-gateway:event]")) { + return parseAgentEventLine(trimmed, ts); + } + + if (trimmed.startsWith("[openclaw-gateway]")) { + return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }]; + } + + return [{ kind: "stdout", ts, text: normalized.line }]; +} diff --git a/packages/adapters/openclaw-gateway/tsconfig.json b/packages/adapters/openclaw-gateway/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/openclaw-gateway/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 4f6b75b9..5a35a78a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -29,6 +29,7 @@ export const AGENT_ADAPTER_TYPES = [ "opencode_local", "cursor", "openclaw", + "openclaw_gateway", ] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 492cd35a..80ef3264 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -146,6 +149,28 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapters/openclaw-gateway: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + ws: + specifier: ^8.19.0 + version: 8.19.0 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/adapters/opencode-local: dependencies: '@paperclipai/adapter-utils': @@ -156,8 +181,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -217,6 +242,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -329,6 +357,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local @@ -359,6 +390,9 @@ importers: lucide-react: specifier: ^0.574.0 version: 0.574.0(react@19.2.4) + mermaid: + specifier: ^11.12.0 + version: 11.12.3 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -411,6 +445,9 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -686,6 +723,9 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -741,6 +781,21 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} + + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} + + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + '@clack/core@0.4.2': resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==} @@ -1401,6 +1456,12 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1571,6 +1632,9 @@ packages: react: '>= 18 || >= 19' react-dom: '>= 18 || >= 19' + '@mermaid-js/parser@1.0.0': + resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -2798,6 +2862,99 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2816,6 +2973,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2872,6 +3032,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3142,6 +3305,14 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3186,6 +3357,14 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -3196,6 +3375,9 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -3222,6 +3404,12 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3237,13 +3425,172 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3275,6 +3622,9 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3317,6 +3667,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3676,6 +4030,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -3708,6 +4065,10 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3725,6 +4086,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intersection-observer@0.10.0: resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==} deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. @@ -3834,6 +4202,13 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + katex@0.16.37: + resolution: {integrity: sha512-TIGjO2cCGYono+uUzgkE7RFF329mLLWGuHUlSr6cwIVj9O8f0VQZ783rsanmJpFUo32vvtj7XT04NGRPh+SZFg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -3842,6 +4217,16 @@ packages: resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} + langium@4.2.1: + resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lexical@0.35.0: resolution: {integrity: sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==} @@ -3924,6 +4309,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -3955,6 +4343,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4032,6 +4425,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.12.3: + resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -4182,6 +4578,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4264,6 +4663,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -4271,6 +4673,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4362,6 +4767,15 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -4575,6 +4989,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4583,6 +5000,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4594,6 +5014,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4755,6 +5178,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -4789,6 +5215,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -4819,6 +5249,10 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4846,6 +5280,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4918,6 +5355,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'} @@ -5046,6 +5487,26 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -5100,6 +5561,11 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -5728,6 +6194,8 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.2': {} + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -5871,6 +6339,23 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@chevrotain/cst-dts-gen@11.1.2': + dependencies: + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.2': + dependencies: + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@chevrotain/utils@11.1.2': {} + '@clack/core@0.4.2': dependencies: picocolors: 1.1.1 @@ -6487,6 +6972,14 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + '@inquirer/external-editor@1.0.3(@types/node@25.2.3)': dependencies: chardet: 2.1.1 @@ -6843,6 +7336,10 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@mermaid-js/parser@1.0.0': + dependencies: + langium: 4.2.1 + '@noble/ciphers@2.1.1': {} '@noble/hashes@1.8.0': {} @@ -8162,7 +8659,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/chai@5.2.3': dependencies: @@ -8171,10 +8668,127 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/cookiejar@2.1.5': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -8189,7 +8803,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -8200,6 +8814,8 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -8246,18 +8862,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 24.12.0 + '@types/node': 25.2.3 form-data: 4.0.5 '@types/supertest@6.0.3': @@ -8265,13 +8881,16 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} '@types/ws@8.18.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@ungap/structured-clone@1.3.0': {} @@ -8502,6 +9121,20 @@ snapshots: check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.1.2): + dependencies: + chevrotain: 11.1.2 + lodash-es: 4.17.23 + + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -8551,6 +9184,10 @@ snapshots: commander@13.1.0: {} + commander@7.2.0: {} + + commander@8.3.0: {} + component-emitter@1.3.1: {} compute-scroll-into-view@2.0.4: {} @@ -8562,6 +9199,8 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.1.8: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -8576,6 +9215,14 @@ snapshots: cookiejar@2.1.4: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -8588,13 +9235,199 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + d@1.0.2: dependencies: es5-ext: 0.10.64 type: 2.7.3 + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + dateformat@4.6.3: {} + dayjs@1.11.19: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -8616,6 +9449,10 @@ snapshots: defu@6.1.4: {} + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -8647,6 +9484,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -9053,6 +9894,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -9101,6 +9944,10 @@ snapshots: human-id@4.1.3: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -9113,6 +9960,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + intersection-observer@0.10.0: {} ipaddr.js@1.9.1: {} @@ -9189,10 +10040,28 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + katex@0.16.37: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + kleur@4.1.5: {} kysely@0.28.11: {} + langium@4.2.1: + dependencies: + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lexical@0.35.0: {} lib0@0.2.117: @@ -9252,6 +10121,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash-es@4.17.23: {} + lodash.startcase@4.4.0: {} longest-streak@3.1.0: {} @@ -9278,6 +10149,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: @@ -9480,6 +10353,29 @@ snapshots: merge2@1.4.1: {} + mermaid@11.12.3: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 1.0.0 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.2 + katex: 0.16.37 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + methods@1.1.2: {} micromark-core-commonmark@2.0.3: @@ -9797,6 +10693,13 @@ snapshots: dependencies: minimist: 1.2.8 + mlly@1.8.1: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + mri@1.2.0: {} ms@2.1.3: {} @@ -9868,6 +10771,8 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.6.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -9880,6 +10785,8 @@ snapshots: parseurl@1.3.3: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -9982,6 +10889,19 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -10253,6 +11173,8 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.2: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -10286,6 +11208,13 @@ snapshots: rou3@0.7.12: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -10302,6 +11231,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + sade@1.8.1: dependencies: mri: 1.2.0 @@ -10465,6 +11396,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.3.6: {} + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -10505,6 +11438,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -10526,6 +11461,8 @@ snapshots: trough@2.2.0: {} + ts-dedent@2.2.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -10552,6 +11489,8 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.3: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -10628,6 +11567,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + uvu@0.5.6: dependencies: dequal: 2.0.3 @@ -10833,6 +11774,23 @@ snapshots: - tsx - yaml + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} which@2.0.2: diff --git a/scripts/smoke/openclaw-gateway-e2e.sh b/scripts/smoke/openclaw-gateway-e2e.sh new file mode 100755 index 00000000..e20db35d --- /dev/null +++ b/scripts/smoke/openclaw-gateway-e2e.sh @@ -0,0 +1,752 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + echo "[openclaw-gateway-e2e] $*" +} + +warn() { + echo "[openclaw-gateway-e2e] WARN: $*" >&2 +} + +fail() { + echo "[openclaw-gateway-e2e] ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd" +} + +require_cmd curl +require_cmd jq +require_cmd docker +require_cmd node +require_cmd shasum + +PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://127.0.0.1:3100}" +API_BASE="${PAPERCLIP_API_URL%/}/api" + +COMPANY_SELECTOR="${COMPANY_SELECTOR:-CLA}" +OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Gateway Smoke Agent}" +OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}" +OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}" +OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${OPENCLAW_TMP_DIR}/openclaw-paperclip-smoke}" +OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${OPENCLAW_CONFIG_DIR}/workspace}" +OPENCLAW_CONTAINER_NAME="${OPENCLAW_CONTAINER_NAME:-openclaw-docker-openclaw-gateway-1}" +OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}" +OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}" +OPENCLAW_RESET_DOCKER="${OPENCLAW_RESET_DOCKER:-1}" +OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}" +OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-60}" +OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}" + +PAPERCLIP_API_URL_FOR_OPENCLAW="${PAPERCLIP_API_URL_FOR_OPENCLAW:-http://host.docker.internal:3100}" +CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}" +RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-300}" +STRICT_CASES="${STRICT_CASES:-1}" +AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}" + +AUTH_HEADERS=() +if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then + AUTH_HEADERS+=( -H "Authorization: ${PAPERCLIP_AUTH_HEADER}" ) +fi +if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then + AUTH_HEADERS+=( -H "Cookie: ${PAPERCLIP_COOKIE}" ) + PAPERCLIP_BROWSER_ORIGIN="${PAPERCLIP_BROWSER_ORIGIN:-${PAPERCLIP_API_URL%/}}" + AUTH_HEADERS+=( -H "Origin: ${PAPERCLIP_BROWSER_ORIGIN}" -H "Referer: ${PAPERCLIP_BROWSER_ORIGIN}/" ) +fi + +RESPONSE_CODE="" +RESPONSE_BODY="" +COMPANY_ID="" +AGENT_ID="" +AGENT_API_KEY="" +JOIN_REQUEST_ID="" +INVITE_ID="" +RUN_ID="" + +CASE_A_ISSUE_ID="" +CASE_B_ISSUE_ID="" +CASE_C_ISSUE_ID="" +CASE_C_CREATED_ISSUE_ID="" + +api_request() { + local method="$1" + local path="$2" + local data="${3-}" + local tmp + tmp="$(mktemp)" + + local url + if [[ "$path" == http://* || "$path" == https://* ]]; then + url="$path" + elif [[ "$path" == /api/* ]]; then + url="${PAPERCLIP_API_URL%/}${path}" + else + url="${API_BASE}${path}" + fi + + if [[ -n "$data" ]]; then + if (( ${#AUTH_HEADERS[@]} > 0 )); then + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")" + else + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" -H "Content-Type: application/json" "$url" --data "$data")" + fi + else + if (( ${#AUTH_HEADERS[@]} > 0 )); then + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")" + else + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url")" + fi + fi + + RESPONSE_BODY="$(cat "$tmp")" + rm -f "$tmp" +} + +assert_status() { + local expected="$1" + if [[ "$RESPONSE_CODE" != "$expected" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "expected HTTP ${expected}, got ${RESPONSE_CODE}" + fi +} + +require_board_auth() { + if [[ ${#AUTH_HEADERS[@]} -eq 0 ]]; then + fail "board auth required. Set PAPERCLIP_COOKIE or PAPERCLIP_AUTH_HEADER." + fi + api_request "GET" "/companies" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "board auth invalid for /api/companies (HTTP ${RESPONSE_CODE})" + fi +} + +maybe_cleanup_openclaw_docker() { + if [[ "$OPENCLAW_RESET_DOCKER" != "1" ]]; then + log "OPENCLAW_RESET_DOCKER=${OPENCLAW_RESET_DOCKER}; skipping docker cleanup" + return + fi + + log "cleaning OpenClaw docker state" + if [[ -d "$OPENCLAW_DOCKER_DIR" ]]; then + docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true + fi + if docker ps -a --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then + docker rm -f "$OPENCLAW_CONTAINER_NAME" >/dev/null 2>&1 || true + fi + docker image rm "$OPENCLAW_IMAGE" >/dev/null 2>&1 || true +} + +start_openclaw_docker() { + log "starting clean OpenClaw docker" + OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \ + OPENCLAW_RESET_STATE="$OPENCLAW_RESET_STATE" OPENCLAW_BUILD="$OPENCLAW_BUILD" OPENCLAW_WAIT_SECONDS="$OPENCLAW_WAIT_SECONDS" \ + ./scripts/smoke/openclaw-docker-ui.sh +} + +wait_http_ready() { + local url="$1" + local timeout_sec="$2" + local started_at now code + started_at="$(date +%s)" + while true; do + code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)" + if [[ "$code" == "200" ]]; then + return 0 + fi + now="$(date +%s)" + if (( now - started_at >= timeout_sec )); then + return 1 + fi + sleep 1 + done +} + +detect_openclaw_container() { + if docker ps --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then + echo "$OPENCLAW_CONTAINER_NAME" + return 0 + fi + + local detected + detected="$(docker ps --format '{{.Names}}' | grep 'openclaw-gateway' | head -n1 || true)" + if [[ -n "$detected" ]]; then + echo "$detected" + return 0 + fi + + return 1 +} + +detect_gateway_token() { + if [[ -n "$OPENCLAW_GATEWAY_TOKEN" ]]; then + echo "$OPENCLAW_GATEWAY_TOKEN" + return 0 + fi + + local config_path + config_path="${OPENCLAW_CONFIG_DIR%/}/openclaw.json" + if [[ -f "$config_path" ]]; then + local token + token="$(jq -r '.gateway.auth.token // empty' "$config_path")" + if [[ -n "$token" ]]; then + echo "$token" + return 0 + fi + fi + + local container + container="$(detect_openclaw_container || true)" + if [[ -n "$container" ]]; then + local token_from_container + token_from_container="$(docker exec "$container" sh -lc "node -e 'const fs=require(\"fs\");const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\"));process.stdout.write(c.gateway?.auth?.token||\"\");'" 2>/dev/null || true)" + if [[ -n "$token_from_container" ]]; then + echo "$token_from_container" + return 0 + fi + fi + + return 1 +} + +hash_prefix() { + local value="$1" + printf "%s" "$value" | shasum -a 256 | awk '{print $1}' | cut -c1-12 +} + +probe_gateway_ws() { + local url="$1" + local token="$2" + + node - "$url" "$token" <<'NODE' +const WebSocket = require("ws"); +const url = process.argv[2]; +const token = process.argv[3]; + +const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } }); +const timeout = setTimeout(() => { + console.error("gateway probe timed out"); + process.exit(2); +}, 8000); + +ws.on("message", (raw) => { + try { + const message = JSON.parse(String(raw)); + if (message?.type === "event" && message?.event === "connect.challenge") { + clearTimeout(timeout); + ws.close(); + process.exit(0); + } + } catch { + // ignore + } +}); + +ws.on("error", (err) => { + clearTimeout(timeout); + console.error(err?.message || String(err)); + process.exit(1); +}); +NODE +} + +resolve_company_id() { + api_request "GET" "/companies" + assert_status "200" + + local selector + selector="$(printf "%s" "$COMPANY_SELECTOR" | tr '[:lower:]' '[:upper:]')" + + COMPANY_ID="$(jq -r --arg sel "$selector" ' + map(select( + ((.id // "") | ascii_upcase) == $sel or + ((.name // "") | ascii_upcase) == $sel or + ((.issuePrefix // "") | ascii_upcase) == $sel + )) + | .[0].id // empty + ' <<<"$RESPONSE_BODY")" + + if [[ -z "$COMPANY_ID" ]]; then + local available + available="$(jq -r '.[] | "- id=\(.id) issuePrefix=\(.issuePrefix // "") name=\(.name // "")"' <<<"$RESPONSE_BODY")" + echo "$available" >&2 + fail "could not find company for selector '${COMPANY_SELECTOR}'" + fi + + log "resolved company ${COMPANY_ID} from selector ${COMPANY_SELECTOR}" +} + +cleanup_openclaw_agents() { + api_request "GET" "/companies/${COMPANY_ID}/agents" + assert_status "200" + + local ids + ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")" + if [[ -z "$ids" ]]; then + log "no prior OpenClaw agents to cleanup" + return + fi + + while IFS= read -r id; do + [[ -n "$id" ]] || continue + log "terminating prior OpenClaw agent ${id}" + api_request "POST" "/agents/${id}/terminate" "{}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then + warn "terminate ${id} returned HTTP ${RESPONSE_CODE}" + fi + + api_request "DELETE" "/agents/${id}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then + warn "delete ${id} returned HTTP ${RESPONSE_CODE}" + fi + done <<<"$ids" +} + +cleanup_pending_join_requests() { + api_request "GET" "/companies/${COMPANY_ID}/join-requests?status=pending_approval" + if [[ "$RESPONSE_CODE" != "200" ]]; then + warn "join-request cleanup skipped (HTTP ${RESPONSE_CODE})" + return + fi + + local ids + ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")" + if [[ -z "$ids" ]]; then + return + fi + + while IFS= read -r request_id; do + [[ -n "$request_id" ]] || continue + log "rejecting stale pending join request ${request_id}" + api_request "POST" "/companies/${COMPANY_ID}/join-requests/${request_id}/reject" "{}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "409" ]]; then + warn "reject ${request_id} returned HTTP ${RESPONSE_CODE}" + fi + done <<<"$ids" +} + +create_and_approve_gateway_join() { + local gateway_token="$1" + + local invite_payload + invite_payload="$(jq -nc '{allowedJoinTypes:"agent"}')" + api_request "POST" "/companies/${COMPANY_ID}/invites" "$invite_payload" + assert_status "201" + + local invite_token + invite_token="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")" + INVITE_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + [[ -n "$invite_token" && -n "$INVITE_ID" ]] || fail "invite creation missing token/id" + + local join_payload + join_payload="$(jq -nc \ + --arg name "$OPENCLAW_AGENT_NAME" \ + --arg url "$OPENCLAW_GATEWAY_URL" \ + --arg token "$gateway_token" \ + --arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_OPENCLAW" \ + '{ + requestType: "agent", + agentName: $name, + adapterType: "openclaw_gateway", + capabilities: "OpenClaw gateway smoke harness", + agentDefaultsPayload: { + url: $url, + headers: { "x-openclaw-token": $token }, + role: "operator", + scopes: ["operator.admin"], + disableDeviceAuth: true, + sessionKeyStrategy: "fixed", + sessionKey: "paperclip", + waitTimeoutMs: 120000, + paperclipApiUrl: $paperclipApiUrl + } + }')" + + api_request "POST" "/invites/${invite_token}/accept" "$join_payload" + assert_status "202" + + JOIN_REQUEST_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + local claim_secret + claim_secret="$(jq -r '.claimSecret // empty' <<<"$RESPONSE_BODY")" + local claim_path + claim_path="$(jq -r '.claimApiKeyPath // empty' <<<"$RESPONSE_BODY")" + [[ -n "$JOIN_REQUEST_ID" && -n "$claim_secret" && -n "$claim_path" ]] || fail "join accept missing claim metadata" + + log "approving join request ${JOIN_REQUEST_ID}" + api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}" + assert_status "200" + + AGENT_ID="$(jq -r '.createdAgentId // empty' <<<"$RESPONSE_BODY")" + [[ -n "$AGENT_ID" ]] || fail "join approval missing createdAgentId" + + log "claiming one-time agent API key" + local claim_payload + claim_payload="$(jq -nc --arg secret "$claim_secret" '{claimSecret:$secret}')" + api_request "POST" "$claim_path" "$claim_payload" + assert_status "201" + + AGENT_API_KEY="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")" + [[ -n "$AGENT_API_KEY" ]] || fail "claim response missing token" + + persist_claimed_key_artifacts "$RESPONSE_BODY" + inject_agent_api_key_payload_template +} + +persist_claimed_key_artifacts() { + local claim_json="$1" + local workspace_dir="${OPENCLAW_CONFIG_DIR%/}/workspace" + local skill_dir="${OPENCLAW_CONFIG_DIR%/}/skills/paperclip" + local claimed_file="${workspace_dir}/paperclip-claimed-api-key.json" + + mkdir -p "$workspace_dir" "$skill_dir" + printf "%s\n" "$claim_json" > "$claimed_file" + chmod 600 "$claimed_file" + + if [[ "$AUTO_INSTALL_SKILL" == "1" ]]; then + api_request "GET" "/skills/paperclip" + assert_status "200" + { + echo "PAPERCLIP_API_URL=${PAPERCLIP_API_URL_FOR_OPENCLAW}" + echo + printf "%s\n" "$RESPONSE_BODY" + } > "${skill_dir}/SKILL.md" + chmod 600 "${skill_dir}/SKILL.md" + fi + + log "wrote claimed key to ${claimed_file}" +} + +inject_agent_api_key_payload_template() { + [[ -n "$AGENT_ID" ]] || fail "inject_agent_api_key_payload_template requires AGENT_ID" + [[ -n "$AGENT_API_KEY" ]] || fail "inject_agent_api_key_payload_template requires AGENT_API_KEY" + + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local patch_payload + patch_payload="$(jq -c --arg token "$AGENT_API_KEY" ' + {adapterConfig: ((.adapterConfig // {}) + { + payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + { + message: ("Set PAPERCLIP_API_KEY=" + $token + " in your run context before running Paperclip heartbeat steps.") + }) + })} + ' <<<"$RESPONSE_BODY")" + + api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload" + assert_status "200" +} + +trigger_wakeup() { + local reason="$1" + local issue_id="${2:-}" + + local payload + if [[ -n "$issue_id" ]]; then + payload="$(jq -nc --arg issueId "$issue_id" --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason,payload:{issueId:$issueId,taskId:$issueId}}')" + else + payload="$(jq -nc --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason}')" + fi + + api_request "POST" "/agents/${AGENT_ID}/wakeup" "$payload" + if [[ "$RESPONSE_CODE" != "202" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "wakeup failed (HTTP ${RESPONSE_CODE})" + fi + + RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + if [[ -z "$RUN_ID" ]]; then + warn "wakeup response did not include run id; body: ${RESPONSE_BODY}" + fi +} + +get_run_status() { + local run_id="$1" + api_request "GET" "/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=200" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r --arg runId "$run_id" '.[] | select(.id == $runId) | .status' <<<"$RESPONSE_BODY" | head -n1 +} + +wait_for_run_terminal() { + local run_id="$1" + local timeout_sec="$2" + local started now status + + [[ -n "$run_id" ]] || fail "wait_for_run_terminal requires run id" + started="$(date +%s)" + + while true; do + status="$(get_run_status "$run_id")" + if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then + echo "$status" + return 0 + fi + + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + echo "timeout" + return 0 + fi + sleep 2 + done +} + +get_issue_status() { + local issue_id="$1" + api_request "GET" "/issues/${issue_id}" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r '.status // empty' <<<"$RESPONSE_BODY" +} + +wait_for_issue_terminal() { + local issue_id="$1" + local timeout_sec="$2" + local started now status + started="$(date +%s)" + + while true; do + status="$(get_issue_status "$issue_id")" + if [[ "$status" == "done" || "$status" == "blocked" || "$status" == "cancelled" ]]; then + echo "$status" + return 0 + fi + + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + echo "timeout" + return 0 + fi + sleep 3 + done +} + +issue_comments_contain() { + local issue_id="$1" + local marker="$2" + api_request "GET" "/issues/${issue_id}/comments" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "false" + return 0 + fi + jq -r --arg marker "$marker" '[.[] | (.body // "") | contains($marker)] | any' <<<"$RESPONSE_BODY" +} + +create_issue_for_case() { + local title="$1" + local description="$2" + local priority="${3:-high}" + + local payload + payload="$(jq -nc \ + --arg title "$title" \ + --arg description "$description" \ + --arg assignee "$AGENT_ID" \ + --arg priority "$priority" \ + '{title:$title,description:$description,status:"todo",priority:$priority,assigneeAgentId:$assignee}')" + + api_request "POST" "/companies/${COMPANY_ID}/issues" "$payload" + assert_status "201" + + local issue_id issue_identifier + issue_id="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + issue_identifier="$(jq -r '.identifier // empty' <<<"$RESPONSE_BODY")" + [[ -n "$issue_id" ]] || fail "issue create missing id" + + echo "${issue_id}|${issue_identifier}" +} + +patch_agent_session_strategy_run() { + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local patch_payload + patch_payload="$(jq -c '{adapterConfig: ((.adapterConfig // {}) + {sessionKeyStrategy:"run"})}' <<<"$RESPONSE_BODY")" + api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload" + assert_status "200" +} + +find_issue_by_query() { + local query="$1" + local encoded_query + encoded_query="$(jq -rn --arg q "$query" '$q|@uri')" + api_request "GET" "/companies/${COMPANY_ID}/issues?q=${encoded_query}" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r '.[] | .id' <<<"$RESPONSE_BODY" | head -n1 +} + +run_case_a() { + local marker="OPENCLAW_CASE_A_OK_$(date +%s)" + local description + description="Case A validation.\n\n1) Read this issue.\n2) Post a comment containing exactly: ${marker}\n3) Mark this issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case A" "$description")" + CASE_A_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case A issue ${CASE_A_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_a" "$CASE_A_ISSUE_ID" + + local run_status issue_status marker_found + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case A run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_A_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_A_ISSUE_ID" "$marker")" + log "case A issue_status=${issue_status} marker_found=${marker_found}" + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case A run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case A issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case A marker not found in comments" + fi +} + +run_case_b() { + local marker="OPENCLAW_CASE_B_OK_$(date +%s)" + local message_text="${marker}" + local description + description="Case B validation.\n\nUse the message tool to send this exact text to the user's main chat session in webchat:\n${message_text}\n\nAfter sending, post a Paperclip issue comment containing exactly: ${marker}\nThen mark this issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case B" "$description")" + CASE_B_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case B issue ${CASE_B_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_b" "$CASE_B_ISSUE_ID" + + local run_status issue_status marker_found + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case B run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_B_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_B_ISSUE_ID" "$marker")" + log "case B issue_status=${issue_status} marker_found=${marker_found}" + + warn "case B requires manual UX confirmation in OpenClaw main webchat: message '${message_text}' appears in main chat" + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case B run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case B issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case B marker not found in comments" + fi +} + +run_case_c() { + patch_agent_session_strategy_run + + local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)" + local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)" + local description + description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on this issue containing exactly: ${ack_marker}\nThen mark this issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")" + CASE_C_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case C issue ${CASE_C_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_c" "$CASE_C_ISSUE_ID" + + local run_status issue_status marker_found created_issue + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case C run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_C_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_C_ISSUE_ID" "$ack_marker")" + created_issue="$(find_issue_by_query "$marker")" + if [[ "$created_issue" == "$CASE_C_ISSUE_ID" ]]; then + created_issue="" + fi + CASE_C_CREATED_ISSUE_ID="$created_issue" + log "case C issue_status=${issue_status} marker_found=${marker_found} created_issue_id=${CASE_C_CREATED_ISSUE_ID:-none}" + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case C run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case C issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case C ack marker not found in comments" + [[ -n "$CASE_C_CREATED_ISSUE_ID" ]] || fail "case C did not create the expected new issue" + fi +} + +main() { + log "starting OpenClaw gateway E2E smoke" + + wait_http_ready "${PAPERCLIP_API_URL%/}/api/health" 15 || fail "Paperclip API health endpoint not reachable" + api_request "GET" "/health" + assert_status "200" + log "paperclip health deploymentMode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")" + + require_board_auth + resolve_company_id + cleanup_openclaw_agents + cleanup_pending_join_requests + + maybe_cleanup_openclaw_docker + start_openclaw_docker + wait_http_ready "http://127.0.0.1:18789/" "$OPENCLAW_WAIT_SECONDS" || fail "OpenClaw HTTP health not reachable" + + local gateway_token + gateway_token="$(detect_gateway_token || true)" + [[ -n "$gateway_token" ]] || fail "could not resolve OpenClaw gateway token" + log "resolved gateway token (sha256 prefix $(hash_prefix "$gateway_token"))" + + log "probing gateway websocket challenge at ${OPENCLAW_GATEWAY_URL}" + probe_gateway_ws "$OPENCLAW_GATEWAY_URL" "$gateway_token" + + create_and_approve_gateway_join "$gateway_token" + log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}" + + trigger_wakeup "openclaw_gateway_smoke_connectivity" + if [[ -n "$RUN_ID" ]]; then + local connect_status + connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + [[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run failed: ${connect_status}" + log "connectivity wake run ${RUN_ID} succeeded" + fi + + run_case_a + run_case_b + run_case_c + + log "success" + log "companyId=${COMPANY_ID}" + log "agentId=${AGENT_ID}" + log "inviteId=${INVITE_ID}" + log "joinRequestId=${JOIN_REQUEST_ID}" + log "caseA_issueId=${CASE_A_ISSUE_ID}" + log "caseB_issueId=${CASE_B_ISSUE_ID}" + log "caseC_issueId=${CASE_C_ISSUE_ID}" + log "caseC_createdIssueId=${CASE_C_CREATED_ISSUE_ID:-none}" + log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..." +} + +main "$@" diff --git a/server/package.json b/server/package.json index 2e470111..9ec0c494 100644 --- a/server/package.json +++ b/server/package.json @@ -36,6 +36,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts new file mode 100644 index 00000000..df57af32 --- /dev/null +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer } from "node:http"; +import { WebSocketServer } from "ws"; +import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server"; +import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +function buildContext( + config: Record, + overrides?: Partial, +): AdapterExecutionContext { + return { + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "OpenClaw Gateway Agent", + adapterType: "openclaw_gateway", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config, + context: { + taskId: "task-123", + issueId: "issue-123", + wakeReason: "issue_assigned", + issueIds: ["issue-123"], + }, + onLog: async () => {}, + ...overrides, + }; +} + +async function createMockGatewayServer() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + + let agentPayload: Record | 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; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayload = frame.params ?? null; + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : "run-123"; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { delta: "cha" }, + }, + }), + ); + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 2, + stream: "assistant", + ts: Date.now(), + data: { delta: "chacha" }, + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((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((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +afterEach(() => { + // no global mocks +}); + +describe("openclaw gateway ui stdout parser", () => { + it("parses assistant deltas from gateway event lines", () => { + const ts = "2026-03-06T15:00:00.000Z"; + const line = + '[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}'; + + expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([ + { + kind: "assistant", + ts, + text: "hello", + delta: true, + }, + ]); + }); +}); + +describe("openclaw gateway adapter execute", () => { + it("runs connect -> agent -> agent.wait and forwards wake payload", async () => { + const gateway = await createMockGatewayServer(); + const logs: string[] = []; + + try { + const result = await execute( + buildContext( + { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2000, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.summary).toContain("chachacha"); + expect(result.provider).toBe("openclaw"); + + const payload = gateway.getAgentPayload(); + expect(payload).toBeTruthy(); + expect(payload?.idempotencyKey).toBe("run-123"); + expect(payload?.sessionKey).toBe("paperclip"); + expect(String(payload?.message ?? "")).toContain("wake now"); + expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); + + expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); + } finally { + await gateway.close(); + } + }); + + it("fails fast when url is missing", async () => { + const result = await execute(buildContext({})); + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("openclaw_gateway_url_missing"); + }); +}); + +describe("openclaw gateway testEnvironment", () => { + it("reports missing url as failure", async () => { + const result = await testEnvironment({ + companyId: "company-123", + adapterType: "openclaw_gateway", + config: {}, + }); + + expect(result.status).toBe("fail"); + expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index cc8c040c..b58712dd 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -35,6 +35,14 @@ import { agentConfigurationDoc as openclawAgentConfigurationDoc, models as openclawModels, } from "@paperclipai/adapter-openclaw"; +import { + execute as openclawGatewayExecute, + testEnvironment as openclawGatewayTestEnvironment, +} from "@paperclipai/adapter-openclaw-gateway/server"; +import { + agentConfigurationDoc as openclawGatewayAgentConfigurationDoc, + models as openclawGatewayModels, +} from "@paperclipai/adapter-openclaw-gateway"; import { listCodexModels } from "./codex-models.js"; import { listCursorModels } from "./cursor-models.js"; import { processAdapter } from "./process/index.js"; @@ -82,6 +90,15 @@ const openclawAdapter: ServerAdapterModule = { agentConfigurationDoc: openclawAgentConfigurationDoc, }; +const openclawGatewayAdapter: ServerAdapterModule = { + type: "openclaw_gateway", + execute: openclawGatewayExecute, + testEnvironment: openclawGatewayTestEnvironment, + models: openclawGatewayModels, + supportsLocalAgentJwt: false, + agentConfigurationDoc: openclawGatewayAgentConfigurationDoc, +}; + const openCodeLocalAdapter: ServerAdapterModule = { type: "opencode_local", execute: openCodeExecute, @@ -94,7 +111,16 @@ const openCodeLocalAdapter: ServerAdapterModule = { }; const adaptersByType = new Map( - [claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), + [ + claudeLocalAdapter, + codexLocalAdapter, + openCodeLocalAdapter, + cursorLocalAdapter, + openclawAdapter, + openclawGatewayAdapter, + processAdapter, + httpAdapter, + ].map((a) => [a.type, a]), ); export function getServerAdapter(type: string): ServerAdapterModule { diff --git a/ui/package.json b/ui/package.json index ccd40dd7..e265209f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,8 +17,9 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", - "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", "@radix-ui/react-slot": "^1.2.4", @@ -28,6 +29,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.574.0", + "mermaid": "^11.12.0", "radix-ui": "^1.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx new file mode 100644 index 00000000..5bcad80b --- /dev/null +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, + help, +} from "../../components/agent-config-primitives"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +function SecretField({ + label, + value, + onCommit, + placeholder, +}: { + label: string; + value: string; + onCommit: (v: string) => void; + placeholder?: string; +}) { + const [visible, setVisible] = useState(false); + return ( + +
+ + +
+
+ ); +} + +function parseScopes(value: unknown): string { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string").join(", "); + } + return typeof value === "string" ? value : ""; +} + +export function OpenClawGatewayConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + const configuredHeaders = + config.headers && typeof config.headers === "object" && !Array.isArray(config.headers) + ? (config.headers as Record) + : {}; + const effectiveHeaders = + (eff("adapterConfig", "headers", configuredHeaders) as Record) ?? {}; + + const effectiveGatewayToken = typeof effectiveHeaders["x-openclaw-token"] === "string" + ? String(effectiveHeaders["x-openclaw-token"]) + : typeof effectiveHeaders["x-openclaw-auth"] === "string" + ? String(effectiveHeaders["x-openclaw-auth"]) + : ""; + + const commitGatewayToken = (rawValue: string) => { + const nextValue = rawValue.trim(); + const nextHeaders: Record = { ...effectiveHeaders }; + if (nextValue) { + nextHeaders["x-openclaw-token"] = nextValue; + delete nextHeaders["x-openclaw-auth"]; + } else { + delete nextHeaders["x-openclaw-token"]; + delete nextHeaders["x-openclaw-auth"]; + } + mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined); + }; + + const sessionStrategy = eff( + "adapterConfig", + "sessionKeyStrategy", + String(config.sessionKeyStrategy ?? "fixed"), + ); + + return ( + <> + + + isCreate + ? set!({ url: v }) + : mark("adapterConfig", "url", v || undefined) + } + immediate + className={inputClass} + placeholder="ws://127.0.0.1:18789" + /> + + + {!isCreate && ( + <> + + mark("adapterConfig", "paperclipApiUrl", v || undefined)} + immediate + className={inputClass} + placeholder="https://paperclip.example" + /> + + + + + + + {sessionStrategy === "fixed" && ( + + mark("adapterConfig", "sessionKey", v || undefined)} + immediate + className={inputClass} + placeholder="paperclip" + /> + + )} + + + + + mark("adapterConfig", "role", v || undefined)} + immediate + className={inputClass} + placeholder="operator" + /> + + + + { + const parsed = v + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined); + }} + immediate + className={inputClass} + placeholder="operator.admin" + /> + + + + { + const parsed = Number.parseInt(v.trim(), 10); + mark( + "adapterConfig", + "waitTimeoutMs", + Number.isFinite(parsed) && parsed > 0 ? parsed : undefined, + ); + }} + immediate + className={inputClass} + placeholder="120000" + /> + + + + + + + )} + + ); +} diff --git a/ui/src/adapters/openclaw-gateway/index.ts b/ui/src/adapters/openclaw-gateway/index.ts new file mode 100644 index 00000000..812f7de0 --- /dev/null +++ b/ui/src/adapters/openclaw-gateway/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui"; +import { buildOpenClawGatewayConfig } from "@paperclipai/adapter-openclaw-gateway/ui"; +import { OpenClawGatewayConfigFields } from "./config-fields"; + +export const openClawGatewayUIAdapter: UIAdapterModule = { + type: "openclaw_gateway", + label: "OpenClaw Gateway", + parseStdoutLine: parseOpenClawGatewayStdoutLine, + ConfigFields: OpenClawGatewayConfigFields, + buildAdapterConfig: buildOpenClawGatewayConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 2ce643f0..b2b69052 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -4,11 +4,21 @@ import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { openClawUIAdapter } from "./openclaw"; +import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; const adaptersByType = new Map( - [claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), + [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + openCodeLocalUIAdapter, + cursorLocalUIAdapter, + openClawUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, + ].map((a) => [a.type, a]), ); export function getUIAdapter(type: string): UIAdapterModule { diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index c7fa3647..4e1bc76e 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -19,6 +19,7 @@ const adapterLabels: Record = { codex_local: "Codex (local)", opencode_local: "OpenCode (local)", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", http: "HTTP", diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 9f15a1fc..02bdf74c 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -157,7 +157,7 @@ function parseStdoutChunk( if (!trimmed) continue; const parsed = adapter.parseStdoutLine(trimmed, ts); if (parsed.length === 0) { - if (run.adapterType === "openclaw") { + if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") { continue; } const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 77fb4db8..5b1667ce 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -56,7 +56,8 @@ type AdapterType = | "cursor" | "process" | "http" - | "openclaw"; + | "openclaw" + | "openclaw_gateway"; const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md) @@ -672,6 +673,12 @@ export function OnboardingWizard() { desc: "Notify OpenClaw webhook", comingSoon: true }, + { + value: "openclaw_gateway" as const, + label: "OpenClaw Gateway", + icon: Bot, + desc: "Invoke OpenClaw via gateway protocol" + }, { value: "cursor" as const, label: "Cursor", @@ -973,14 +980,14 @@ export function OnboardingWizard() { )} - {(adapterType === "http" || adapterType === "openclaw") && ( + {(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
setUrl(e.target.value)} /> diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 5d1a3539..9f1b3585 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -23,7 +23,7 @@ export const help: Record = { role: "Organizational role. Determines position and capabilities.", reportsTo: "The agent this one reports to in the org hierarchy.", capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", - adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.", + adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.", cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.", promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", model: "Override the default model used by the adapter.", @@ -54,6 +54,7 @@ export const adapterLabels: Record = { codex_local: "Codex (local)", opencode_local: "OpenCode (local)", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", http: "HTTP", diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 51621ac3..40913804 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -26,6 +26,7 @@ const adapterLabels: Record = { opencode_local: "OpenCode", cursor: "Cursor", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", }; diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index b5487c37..81a13d80 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -20,6 +20,7 @@ const adapterLabels: Record = { codex_local: "Codex (local)", opencode_local: "OpenCode (local)", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", http: "HTTP", diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 6b02c7fb..786b1f87 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -121,6 +121,7 @@ const adapterLabels: Record = { opencode_local: "OpenCode", cursor: "Cursor", openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", };