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 84edcb58..1bddae42 100644 --- a/cli/package.json +++ b/cli/package.json @@ -40,6 +40,7 @@ "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/server": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 66829b2c..41d95f77 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; +import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -38,8 +39,23 @@ const openclawCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printOpenClawStreamEvent, }; +const openclawGatewayCLIAdapter: CLIAdapterModule = { + type: "openclaw_gateway", + formatStdoutEvent: printOpenClawGatewayStreamEvent, +}; + const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [ + claudeLocalCLIAdapter, + codexLocalCLIAdapter, + openCodeLocalCLIAdapter, + piLocalCLIAdapter, + cursorLocalCLIAdapter, + openclawCLIAdapter, + openclawGatewayCLIAdapter, + processCLIAdapter, + httpCLIAdapter, + ].map((a) => [a.type, a]), ); export function getCLIAdapter(type: string): CLIAdapterModule { diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 2a1b4243..c98ca158 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -1,5 +1,9 @@ import { Command } from "commander"; import type { Agent } from "@paperclipai/shared"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { addCommonClientOptions, formatInlineRecord, @@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions { companyId?: string; } +interface AgentLocalCliOptions extends BaseClientOptions { + companyId?: string; + keyName?: string; + installSkills?: boolean; +} + +interface CreatedAgentKey { + id: string; + name: string; + token: string; + createdAt: string; +} + +interface SkillsInstallSummary { + tool: "codex" | "claude"; + target: string; + linked: string[]; + skipped: string[]; + failed: Array<{ name: string; error: string }>; +} + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const PAPERCLIP_SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills + path.resolve(process.cwd(), "skills"), +]; + +function codexSkillsHome(): string { + const fromEnv = process.env.CODEX_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex"); + return path.join(base, "skills"); +} + +function claudeSkillsHome(): string { + const fromEnv = process.env.CLAUDE_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude"); + return path.join(base, "skills"); +} + +async function resolvePaperclipSkillsDir(): Promise { + for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +async function installSkillsForTarget( + sourceSkillsDir: string, + targetSkillsDir: string, + tool: "codex" | "claude", +): Promise { + const summary: SkillsInstallSummary = { + tool, + target: targetSkillsDir, + linked: [], + skipped: [], + failed: [], + }; + + await fs.mkdir(targetSkillsDir, { recursive: true }); + const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(sourceSkillsDir, entry.name); + const target = path.join(targetSkillsDir, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) { + summary.skipped.push(entry.name); + continue; + } + + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + } catch (err) { + summary.failed.push({ + name: entry.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return summary; +} + +function buildAgentEnvExports(input: { + apiBase: string; + companyId: string; + agentId: string; + apiKey: string; +}): string { + const escaped = (value: string) => value.replace(/'/g, "'\"'\"'"); + return [ + `export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`, + `export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`, + `export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`, + `export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`, + ].join("\n"); +} + export function registerAgentCommands(program: Command): void { const agent = program.command("agent").description("Agent operations"); @@ -71,4 +176,96 @@ export function registerAgentCommands(program: Command): void { } }), ); + + addCommonClientOptions( + agent + .command("local-cli") + .description( + "Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports", + ) + .argument("", "Agent ID or shortname/url-key") + .requiredOption("-C, --company-id ", "Company ID") + .option("--key-name ", "API key label", "local-cli") + .option( + "--no-install-skills", + "Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills", + ) + .action(async (agentRef: string, opts: AgentLocalCliOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const query = new URLSearchParams({ companyId: ctx.companyId ?? "" }); + const agentRow = await ctx.api.get( + `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, + ); + + const now = new Date().toISOString().replaceAll(":", "-"); + const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; + const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + + const installSummaries: SkillsInstallSummary[] = []; + if (opts.installSkills !== false) { + const skillsDir = await resolvePaperclipSkillsDir(); + if (!skillsDir) { + throw new Error( + "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", + ); + } + + installSummaries.push( + await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"), + await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"), + ); + } + + const exportsText = buildAgentEnvExports({ + apiBase: ctx.api.apiBase, + companyId: agentRow.companyId, + agentId: agentRow.id, + apiKey: key.token, + }); + + if (ctx.json) { + printOutput( + { + agent: { + id: agentRow.id, + name: agentRow.name, + urlKey: agentRow.urlKey, + companyId: agentRow.companyId, + }, + key: { + id: key.id, + name: key.name, + createdAt: key.createdAt, + token: key.token, + }, + skills: installSummaries, + exports: exportsText, + }, + { json: true }, + ); + return; + } + + console.log(`Agent: ${agentRow.name} (${agentRow.id})`); + console.log(`API key created: ${key.name} (${key.id})`); + if (installSummaries.length > 0) { + for (const summary of installSummaries) { + console.log( + `${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, + ); + for (const failed of summary.failed) { + console.log(` failed ${failed.name}: ${failed.error}`); + } + } + } + console.log(""); + console.log("# Run this in your shell before launching codex/claude:"); + console.log(exportsText); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); } diff --git a/doc/CLI.md b/doc/CLI.md index b56abf75..6f945656 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -116,6 +116,20 @@ pnpm paperclipai issue release ```sh pnpm paperclipai agent list --company-id pnpm paperclipai agent get +pnpm paperclipai agent local-cli --company-id +``` + +`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent: + +- creates a new long-lived agent API key +- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills` +- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY` + +Example for shortname-based local setup: + +```sh +pnpm paperclipai agent local-cli codexcoder --company-id +pnpm paperclipai agent local-cli claudecoder --company-id ``` ## Approval Commands diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md new file mode 100644 index 00000000..e31a6f8b --- /dev/null +++ b/doc/OPENCLAW_ONBOARDING.md @@ -0,0 +1,55 @@ +Use this exact checklist. + +1. Start Paperclip in auth mode. +```bash +cd +pnpm dev --tailscale-auth +``` +Then verify: +```bash +curl -sS http://127.0.0.1:3100/api/health | jq +``` + +2. Start a clean/stock OpenClaw Docker. +```bash +OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh +``` +Open the printed `Dashboard URL` (includes `#token=...`) in your browser. + +3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`. + +4. Use the agent snippet flow. +- Copy the snippet from company settings. +- Paste it into OpenClaw main chat as one message. +- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` + +5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents. + +6. Case A (manual issue test). +- Create an issue assigned to the OpenClaw agent. +- Put instructions: “post comment `OPENCLAW_CASE_A_OK_` and mark done.” +- Verify in UI: issue status becomes `done` and comment exists. + +7. Case B (message tool test). +- Create another issue assigned to OpenClaw. +- Instructions: “send `OPENCLAW_CASE_B_OK_` to main webchat via message tool, then comment same marker on issue, then mark done.” +- Verify both: + - marker comment on issue + - marker text appears in OpenClaw main chat + +8. Case C (new session memory/skills test). +- In OpenClaw, start `/new` session. +- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_`. +- Verify in Paperclip UI that new issue exists. + +9. Watch logs during test (optional but helpful): +```bash +docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway +``` + +10. Expected pass criteria. +- Case A: `done` + marker comment. +- Case B: `done` + marker comment + main-chat message visible. +- Case C: original task done and new issue created from `/new` session. + +If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI. diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 254689a2..3b80f288 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory. +For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use: + +```sh +pnpm paperclipai agent local-cli claudecoder --company-id +``` + +This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent. + ## Environment Test Use the "Test Environment" button in the UI to validate the adapter config. It checks: diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index d87172f8..60725a49 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten. +For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use: + +```sh +pnpm paperclipai agent local-cli codexcoder --company-id +``` + +This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent. + ## Environment Test The environment test checks: 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..6c804d22 --- /dev/null +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -0,0 +1,351 @@ +# OpenClaw Gateway Onboarding and Test Plan + +## Objective +Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex. + +This plan covers: +- Current onboarding flow behavior and gaps. +- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol). +- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company. + +## Hard Requirements (Testing Contract) +These are mandatory for onboarding and smoke testing: + +1. **Stock/clean OpenClaw boot every run** +- Use a fresh, unmodified OpenClaw Docker image path each test cycle. +- Do not rely on persistent/manual in-UI tweaks from prior runs. +- Recreate runtime state each run so results represent first-time user experience. + +2. **One-command/prompt setup inside OpenClaw** +- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able). +- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”). +- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps. + +3. **Two-lane validation is required** +- Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate. +- Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path. + +## Execution Findings (2026-03-07) +Observed from running `scripts/smoke/openclaw-gateway-e2e.sh` against `CLA` in authenticated/private dev mode: + +1. **Baseline failure (before wake-text fix)** +- Stock lane had run-level success but failed functional assertions: + - connectivity run `64a72d8b-f5b3-4f62-9147-1c60932f50ad` succeeded + - case A run `fd29e361-a6bd-4bc6-9270-36ef96e3bd8e` succeeded + - issue `CLA-6` (`dad7b967-29d2-4317-8c9d-425b4421e098`) stayed `todo` with `0` comments +- Root symptom: OpenClaw reported missing concrete heartbeat procedure and guessed non-existent `/api/*heartbeat` endpoints. + +2. **Post-fix validation (stock-clean lane passes)** +- After updating adapter wake text to include explicit Paperclip API workflow steps and explicit endpoint bans: + - connectivity run `c297e2d0-020b-4b30-95d3-a4c04e1373bb`: `succeeded` + - case A run `baac403e-8d86-48e5-b7d5-239c4755ce7e`: `succeeded`, issue `CLA-7` done with marker + - case B run `521fc8ad-2f5a-4bd8-9ddd-c491401c9158`: `succeeded`, issue `CLA-8` done with marker + - case C run `a03d86b6-91a8-48b4-8813-758f6bf11aec`: `succeeded`, issue `CLA-9` done, created issue `CLA-10` +- Stock release-gate lane now passes scripted checks. + +3. **Instrumentation lane note** +- Prompt-augmented diagnostics lane previously timed out (`7537e5d2-a76a-44c5-bf9f-57f1b21f5fc3`) with missing tool runtime utilities (`jq`, `python`) inside the stock container. +- Keep this lane for diagnostics only; stock lane remains the acceptance gate. + +## External Protocol Constraints +OpenClaw docs to anchor behavior: +- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook +- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol +- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses + +Implication: +- `webhook` transport should target `/hooks/*` and requires hook server enablement. +- `sse` transport should target `/v1/responses`. +- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`. + +## Current Implementation Map (What Exists) + +### Invite + onboarding pipeline +- Invite create: `POST /api/companies/:companyId/invites` +- Invite onboarding manifest: `GET /api/invites/:token/onboarding` +- Agent-readable text: `GET /api/invites/:token/onboarding.txt` +- Accept join: `POST /api/invites/:token/accept` +- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve` +- Claim key: `POST /api/join-requests/:requestId/claim-api-key` + +### Adapter state +- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode. +- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`). + +### Existing smoke foundation +- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`. +- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`). + +## Deep Code Findings (Gaps) + +### 1) Onboarding content is still OpenClaw-HTTP specific +`server/src/routes/access.ts` hardcodes onboarding to: +- `recommendedAdapterType: "openclaw"` +- Required `agentDefaultsPayload.headers.x-openclaw-auth` +- HTTP callback URL guidance and `/v1/responses` examples. + +There is no adapter-specific onboarding manifest/text for `openclaw_gateway`. + +### 2) Company settings snippet is OpenClaw HTTP-first +`ui/src/pages/CompanySettings.tsx` generates one snippet that: +- Assumes OpenClaw HTTP callback setup. +- Instructs enabling `gateway.http.endpoints.responses.enabled=true`. +- Does not provide a dedicated gateway onboarding path. + +### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters +`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI. + +### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"` +`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific. +No equivalent normalization/diagnostics for gateway defaults. + +### 5) Webhook confusion is expected in current setup +For `openclaw` + `streamTransport=webhook`: +- Adapter may remap `/v1/responses -> /hooks/agent`. +- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`. + +If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected. + +### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode +- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`). +- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`. +- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes. + +### 7) Gateway adapter lacks hire-approved callback parity +`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not. +Not a blocker for core routing, but creates inconsistent onboarding feedback behavior. + +## UX Intention (Target Experience) + +### Product goal +Users should pick one clear onboarding path: +- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs. +- `Invite OpenClaw Gateway` for gateway-native installs. + +### UX design requirements +- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route). +- Mode-specific generated snippet and mode-specific onboarding text. +- Clear compatibility checks before user copies anything. + +### Proposed UX structure +1. Add invite buttons: +- `Invite OpenClaw (SSE/Webhook)` +- `Invite OpenClaw Gateway` + +2. For HTTP invite: +- Require transport choice (`sse` or `webhook`). +- Validate endpoint expectations: + - `sse` with `/v1/responses`. + - `webhook` with `/hooks/*` and hooks enablement guidance. + +3. For Gateway invite: +- Ask only for `ws://`/`wss://` and token source guidance. +- No callback URL/paperclipApiUrl complexity in onboarding. + +4. Always show: +- Preflight diagnostics. +- Copy-ready command/snippet. +- Expected next steps (join -> approve -> claim -> skill install). + +## Why Gateway Improves Onboarding +Compared to webhook/SSE onboarding: +- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls. +- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion. +- Better run observability: gateway event frames stream lifecycle/delta events in one protocol. + +Tradeoff: +- Requires stable WS endpoint and gateway token handling. + +## Codex-Executable E2E Workflow + +## Scope +Run this full flow per test cycle against company `CLA`: +1. Assign task to OpenClaw agent -> agent executes -> task closes. +2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat. +3. OpenClaw in a fresh/new session can still create a Paperclip task. +4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup. + +## 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. + - Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch. +6. Ensure Paperclip skill is installed for OpenClaw runtime. +7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only. + +## 6) E2E Validation Cases + +### Case A: Assigned task execution/closure +1. Create issue in CLA assigned to joined OpenClaw agent. +2. Poll issue + heartbeat runs until terminal. +3. Pass criteria: +- At least one run invoked for that agent/issue. +- Run status `succeeded`. +- Issue reaches `done` (or documented expected terminal state if policy differs). + +### Case B: Message tool to main chat +1. Create issue instructing OpenClaw: “send a message to the 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..407e455b --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -0,0 +1,1091 @@ +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 issueIdHint = payload.taskId ?? payload.issueId ?? ""; + const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? ""; + + const lines = [ + "Paperclip wake event for a cloud adapter.", + "", + "Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.", + "", + "Set these values in your run context:", + ...envLines, + `PAPERCLIP_API_KEY=`, + "", + `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, + "", + `api_base=${apiBaseHint}`, + `task_id=${payload.taskId ?? ""}`, + `issue_id=${payload.issueId ?? ""}`, + `wake_reason=${payload.wakeReason ?? ""}`, + `wake_comment_id=${payload.wakeCommentId ?? ""}`, + `approval_id=${payload.approvalId ?? ""}`, + `approval_status=${payload.approvalStatus ?? ""}`, + `linked_issue_ids=${payload.issueIds.join(",")}`, + "", + "HTTP rules:", + "- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.", + "- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.", + "- Use only /api endpoints listed below.", + "- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.", + "", + "Workflow:", + "1) GET /api/agents/me", + `2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`, + "3) If issueId exists:", + " - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}", + " - GET /api/issues/{issueId}", + " - GET /api/issues/{issueId}/comments", + " - Execute the issue instructions exactly.", + " - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.", + " - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.", + "4) If issueId does not exist:", + " - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked", + " - Pick in_progress first, then todo, then blocked, then execute step 3.", + "", + "Useful endpoints for issue work:", + "- POST /api/issues/{issueId}/comments", + "- PATCH /api/issues/{issueId}", + "- POST /api/companies/{companyId}/issues (when asked to create a new issue)", + "", + "Complete the workflow in this run.", + ]; + return lines.join("\n"); +} + +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/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts index d2c71583..63deee6e 100644 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ b/packages/adapters/openclaw/src/server/execute-common.ts @@ -290,15 +290,21 @@ export function buildWakeText(payload: WakePayload, paperclipEnv: Record"; + const lines = [ "Paperclip wake event for a cloud adapter.", "", + "Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.", + "", "Set these values in your run context:", ...envLines, `PAPERCLIP_API_KEY=`, "", `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, "", + `api_base=${apiBaseHint}`, `task_id=${payload.taskId ?? ""}`, `issue_id=${payload.issueId ?? ""}`, `wake_reason=${payload.wakeReason ?? ""}`, @@ -306,9 +312,34 @@ export function buildWakeText(payload: WakePayload, paperclipEnv: Record=16.0.0'} @@ -711,6 +748,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==} @@ -766,6 +806,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==} @@ -1426,6 +1481,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'} @@ -1596,6 +1657,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'} @@ -2823,6 +2887,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==} @@ -2841,6 +2998,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==} @@ -2897,6 +3057,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==} @@ -3167,6 +3330,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==} @@ -3211,6 +3382,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==} @@ -3221,6 +3400,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'} @@ -3247,6 +3429,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==} @@ -3262,13 +3450,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'} @@ -3300,6 +3647,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'} @@ -3342,6 +3692,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'} @@ -3701,6 +4055,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'} @@ -3733,6 +4090,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'} @@ -3750,6 +4111,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. @@ -3859,6 +4227,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'} @@ -3867,6 +4242,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==} @@ -3949,6 +4334,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==} @@ -3980,6 +4368,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'} @@ -4057,6 +4450,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'} @@ -4207,6 +4603,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'} @@ -4289,6 +4688,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==} @@ -4296,6 +4698,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'} @@ -4387,6 +4792,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'} @@ -4600,6 +5014,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'} @@ -4608,6 +5025,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'} @@ -4619,6 +5039,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'} @@ -4780,6 +5203,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'} @@ -4814,6 +5240,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'} @@ -4844,6 +5274,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==} @@ -4871,6 +5305,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==} @@ -4943,6 +5380,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'} @@ -5071,6 +5512,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==} @@ -5125,6 +5586,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 @@ -5753,6 +6219,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 @@ -5896,6 +6364,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 @@ -6512,6 +6997,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 @@ -6868,6 +7361,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': {} @@ -8200,6 +8697,123 @@ snapshots: '@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 @@ -8225,6 +8839,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 @@ -8290,6 +8906,9 @@ 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': {} @@ -8527,6 +9146,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 @@ -8576,6 +9209,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: {} @@ -8587,6 +9224,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: {} @@ -8601,6 +9240,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: @@ -8613,13 +9260,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 @@ -8641,6 +9474,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: {} @@ -8672,6 +9509,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: {} @@ -9078,6 +9919,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -9126,6 +9969,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 @@ -9138,6 +9985,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: {} @@ -9214,10 +10065,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: @@ -9277,6 +10146,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash-es@4.17.23: {} + lodash.startcase@4.4.0: {} longest-streak@3.1.0: {} @@ -9303,6 +10174,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: @@ -9505,6 +10378,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: @@ -9822,6 +10718,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: {} @@ -9893,6 +10796,8 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.6.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -9905,6 +10810,8 @@ snapshots: parseurl@1.3.3: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -10007,6 +10914,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 @@ -10278,6 +11198,8 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.2: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -10311,6 +11233,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 @@ -10327,6 +11256,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + sade@1.8.1: dependencies: mri: 1.2.0 @@ -10490,6 +11421,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.3.6: {} + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -10530,6 +11463,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -10551,6 +11486,8 @@ snapshots: trough@2.2.0: {} + ts-dedent@2.2.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -10577,6 +11514,8 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.3: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -10653,6 +11592,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + uvu@0.5.6: dependencies: dequal: 2.0.3 @@ -10858,6 +11799,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-docker-ui.sh b/scripts/smoke/openclaw-docker-ui.sh index c8d32068..0ce522e9 100755 --- a/scripts/smoke/openclaw-docker-ui.sh +++ b/scripts/smoke/openclaw-docker-ui.sh @@ -56,6 +56,7 @@ require_cmd grep OPENCLAW_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}" OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}" +OPENCLAW_REPO_REF="${OPENCLAW_REPO_REF:-v2026.3.2}" OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}" OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}" OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}" @@ -101,14 +102,23 @@ fi log "preparing OpenClaw repo at $OPENCLAW_DOCKER_DIR" if [[ -d "$OPENCLAW_DOCKER_DIR/.git" ]]; then - git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet origin || true - git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet main || true - git -C "$OPENCLAW_DOCKER_DIR" pull --ff-only --quiet origin main || true + git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true else rm -rf "$OPENCLAW_DOCKER_DIR" git clone "$OPENCLAW_REPO_URL" "$OPENCLAW_DOCKER_DIR" + git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true fi +resolved_openclaw_ref="" +if git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "origin/$OPENCLAW_REPO_REF" >/dev/null; then + resolved_openclaw_ref="origin/$OPENCLAW_REPO_REF" +elif git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "$OPENCLAW_REPO_REF" >/dev/null; then + resolved_openclaw_ref="$OPENCLAW_REPO_REF" +fi +[[ -n "$resolved_openclaw_ref" ]] || fail "unable to resolve OPENCLAW_REPO_REF=$OPENCLAW_REPO_REF in $OPENCLAW_DOCKER_DIR" +git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet "$resolved_openclaw_ref" +log "using OpenClaw ref $resolved_openclaw_ref ($(git -C "$OPENCLAW_DOCKER_DIR" rev-parse --short HEAD))" + if [[ "$OPENCLAW_BUILD" == "1" ]]; then log "building Docker image $OPENCLAW_IMAGE" docker build -t "$OPENCLAW_IMAGE" -f "$OPENCLAW_DOCKER_DIR/Dockerfile" "$OPENCLAW_DOCKER_DIR" diff --git a/scripts/smoke/openclaw-gateway-e2e.sh b/scripts/smoke/openclaw-gateway-e2e.sh new file mode 100755 index 00000000..e45df9f9 --- /dev/null +++ b/scripts/smoke/openclaw-gateway-e2e.sh @@ -0,0 +1,868 @@ +#!/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}" +OPENCLAW_DIAG_DIR="${OPENCLAW_DIAG_DIR:-/tmp/openclaw-gateway-e2e-diag-$(date +%Y%m%d-%H%M%S)}" +OPENCLAW_ADAPTER_TIMEOUT_SEC="${OPENCLAW_ADAPTER_TIMEOUT_SEC:-120}" +OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS="${OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS:-120000}" +PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}" + +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" +} + +capture_run_diagnostics() { + local run_id="$1" + local label="${2:-run}" + [[ -n "$run_id" ]] || return 0 + + mkdir -p "$OPENCLAW_DIAG_DIR" + + api_request "GET" "/heartbeat-runs/${run_id}/events?limit=1000" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-events.json" + else + warn "could not fetch events for run ${run_id} (HTTP ${RESPONSE_CODE})" + fi + + api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=524288" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.json" + jq -r '.content // ""' <<<"$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.txt" 2>/dev/null || true + else + warn "could not fetch log for run ${run_id} (HTTP ${RESPONSE_CODE})" + fi +} + +capture_issue_diagnostics() { + local issue_id="$1" + local label="${2:-issue}" + [[ -n "$issue_id" ]] || return 0 + mkdir -p "$OPENCLAW_DIAG_DIR" + + api_request "GET" "/issues/${issue_id}" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}.json" + fi + + api_request "GET" "/issues/${issue_id}/comments" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}-comments.json" + fi +} + +capture_openclaw_container_logs() { + mkdir -p "$OPENCLAW_DIAG_DIR" + local container + container="$(detect_openclaw_container || true)" + if [[ -z "$container" ]]; then + warn "could not detect OpenClaw container for diagnostics" + return 0 + fi + docker logs --tail=1200 "$container" > "${OPENCLAW_DIAG_DIR}/openclaw-container.log" 2>&1 || true +} + +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" \ + --argjson timeoutSec "$OPENCLAW_ADAPTER_TIMEOUT_SEC" \ + --argjson waitTimeoutMs "$OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS" \ + '{ + 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", + timeoutSec: $timeoutSec, + waitTimeoutMs: $waitTimeoutMs, + 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" + local claimed_raw_file="${workspace_dir}/paperclip-claimed-api-key.raw.json" + + mkdir -p "$workspace_dir" "$skill_dir" + local token + token="$(jq -r '.token // .apiKey // empty' <<<"$claim_json")" + [[ -n "$token" ]] || fail "claim response missing token/apiKey" + + printf "%s\n" "$claim_json" > "$claimed_raw_file" + chmod 600 "$claimed_raw_file" + + jq -nc --arg token "$token" '{ token: $token, apiKey: $token }' > "$claimed_file" + # Keep this readable for OpenClaw runtime users across sandbox/container contexts. + chmod 644 "$claimed_file" + + local container + container="$(detect_openclaw_container || true)" + if [[ -n "$container" ]]; then + docker exec "$container" sh -lc "mkdir -p /home/node/.openclaw/workspace" >/dev/null 2>&1 || true + docker cp "$claimed_file" "${container}:/home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true + docker exec "$container" sh -lc "chmod 644 /home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true + fi + + 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 artifacts to ${claimed_file} and ${claimed_raw_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 base_message + base_message="Set PAPERCLIP_API_KEY=${AGENT_API_KEY} in your run context before running Paperclip heartbeat steps." + if [[ -n "$PAYLOAD_TEMPLATE_MESSAGE_APPEND" ]]; then + base_message="${base_message}\n\n${PAYLOAD_TEMPLATE_MESSAGE_APPEND}" + fi + + local patch_payload + patch_payload="$(jq -c --arg message "$base_message" ' + {adapterConfig: ((.adapterConfig // {}) + { + payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + { + message: $message + }) + })} + ' <<<"$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 + if [[ "$status" != "succeeded" ]]; then + capture_run_diagnostics "$run_id" "run-nonsuccess" + capture_openclaw_container_logs + fi + echo "$status" + return 0 + fi + + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + capture_run_diagnostics "$run_id" "run-timeout" + capture_openclaw_container_logs + 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 [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then + capture_issue_diagnostics "$CASE_A_ISSUE_ID" "case-a" + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-a" + fi + capture_openclaw_container_logs + fi + + 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}" + + if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then + capture_issue_diagnostics "$CASE_B_ISSUE_ID" "case-b" + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-b" + fi + capture_openclaw_container_logs + fi + + 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 [[ "$issue_status" != "done" || "$marker_found" != "true" || -z "$CASE_C_CREATED_ISSUE_ID" ]]; then + capture_issue_diagnostics "$CASE_C_ISSUE_ID" "case-c" + if [[ -n "$CASE_C_CREATED_ISSUE_ID" ]]; then + capture_issue_diagnostics "$CASE_C_CREATED_ISSUE_ID" "case-c-created" + fi + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-c" + fi + capture_openclaw_container_logs + fi + + 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" + mkdir -p "$OPENCLAW_DIAG_DIR" + log "diagnostics dir: ${OPENCLAW_DIAG_DIR}" + + 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 ae540789..eaf73505 100644 --- a/server/package.json +++ b/server/package.json @@ -37,6 +37,7 @@ "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/server/src/__tests__/error-handler.test.ts b/server/src/__tests__/error-handler.test.ts new file mode 100644 index 00000000..d01a8c3c --- /dev/null +++ b/server/src/__tests__/error-handler.test.ts @@ -0,0 +1,53 @@ +import type { NextFunction, Request, Response } from "express"; +import { describe, expect, it, vi } from "vitest"; +import { HttpError } from "../errors.js"; +import { errorHandler } from "../middleware/error-handler.js"; + +function makeReq(): Request { + return { + method: "GET", + originalUrl: "/api/test", + body: { a: 1 }, + params: { id: "123" }, + query: { q: "x" }, + } as unknown as Request; +} + +function makeRes(): Response { + const res = { + status: vi.fn(), + json: vi.fn(), + } as unknown as Response; + (res.status as unknown as ReturnType).mockReturnValue(res); + return res; +} + +describe("errorHandler", () => { + it("attaches the original Error to res.err for 500s", () => { + const req = makeReq(); + const res = makeRes() as any; + const next = vi.fn() as unknown as NextFunction; + const err = new Error("boom"); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" }); + expect(res.err).toBe(err); + expect(res.__errorContext?.error?.message).toBe("boom"); + }); + + it("attaches HttpError instances for 500 responses", () => { + const req = makeReq(); + const res = makeRes() as any; + const next = vi.fn() as unknown as NextFunction; + const err = new HttpError(500, "db exploded"); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "db exploded" }); + expect(res.err).toBe(err); + expect(res.__errorContext?.error?.message).toBe("db exploded"); + }); +}); 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/__tests__/project-shortname-resolution.test.ts b/server/src/__tests__/project-shortname-resolution.test.ts new file mode 100644 index 00000000..5b0ab728 --- /dev/null +++ b/server/src/__tests__/project-shortname-resolution.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { resolveProjectNameForUniqueShortname } from "../services/projects.ts"; + +describe("resolveProjectNameForUniqueShortname", () => { + it("keeps name when shortname is not used", () => { + const resolved = resolveProjectNameForUniqueShortname("Platform", [ + { id: "p1", name: "Growth" }, + ]); + expect(resolved).toBe("Platform"); + }); + + it("appends numeric suffix when shortname collides", () => { + const resolved = resolveProjectNameForUniqueShortname("Growth Team", [ + { id: "p1", name: "growth-team" }, + ]); + expect(resolved).toBe("Growth Team 2"); + }); + + it("increments suffix until unique", () => { + const resolved = resolveProjectNameForUniqueShortname("Growth Team", [ + { id: "p1", name: "growth-team" }, + { id: "p2", name: "growth-team-2" }, + ]); + expect(resolved).toBe("Growth Team 3"); + }); + + it("ignores excluded project id", () => { + const resolved = resolveProjectNameForUniqueShortname( + "Growth Team", + [ + { id: "p1", name: "growth-team" }, + { id: "p2", name: "platform" }, + ], + { excludeProjectId: "p1" }, + ); + expect(resolved).toBe("Growth Team"); + }); + + it("keeps non-normalizable names unchanged", () => { + const resolved = resolveProjectNameForUniqueShortname("!!!", [ + { id: "p1", name: "growth" }, + ]); + expect(resolved).toBe("!!!"); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 3d1b98d8..d9e153ed 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 { @@ -91,6 +99,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, @@ -114,7 +131,17 @@ const piLocalAdapter: ServerAdapterModule = { }; const adaptersByType = new Map( - [claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), + [ + claudeLocalAdapter, + codexLocalAdapter, + openCodeLocalAdapter, + piLocalAdapter, + cursorLocalAdapter, + openclawAdapter, + openclawGatewayAdapter, + processAdapter, + httpAdapter, + ].map((a) => [a.type, a]), ); export function getServerAdapter(type: string): ServerAdapterModule { diff --git a/server/src/app.ts b/server/src/app.ts index ab6dcfad..d663654f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -121,6 +121,9 @@ export async function createApp( }), ); app.use("/api", api); + app.use("/api", (_req, res) => { + res.status(404).json({ error: "API route not found" }); + }); const __dirname = path.dirname(fileURLToPath(import.meta.url)); if (opts.uiMode === "static") { diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 293c42ab..7f86dfd0 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -11,6 +11,25 @@ export interface ErrorContext { reqQuery?: unknown; } +function attachErrorContext( + req: Request, + res: Response, + payload: ErrorContext["error"], + rawError?: Error, +) { + (res as any).__errorContext = { + error: payload, + method: req.method, + url: req.originalUrl, + reqBody: req.body, + reqParams: req.params, + reqQuery: req.query, + } satisfies ErrorContext; + if (rawError) { + (res as any).err = rawError; + } +} + export function errorHandler( err: unknown, req: Request, @@ -19,14 +38,12 @@ export function errorHandler( ) { if (err instanceof HttpError) { if (err.status >= 500) { - (res as any).__errorContext = { - error: { message: err.message, stack: err.stack, name: err.name, details: err.details }, - method: req.method, - url: req.originalUrl, - reqBody: req.body, - reqParams: req.params, - reqQuery: req.query, - } satisfies ErrorContext; + attachErrorContext( + req, + res, + { message: err.message, stack: err.stack, name: err.name, details: err.details }, + err, + ); } res.status(err.status).json({ error: err.message, @@ -40,16 +57,15 @@ export function errorHandler( return; } - (res as any).__errorContext = { - error: err instanceof Error + const rootError = err instanceof Error ? err : new Error(String(err)); + attachErrorContext( + req, + res, + err instanceof Error ? { message: err.message, stack: err.stack, name: err.name } - : { message: String(err), raw: err }, - method: req.method, - url: req.originalUrl, - reqBody: req.body, - reqParams: req.params, - reqQuery: req.query, - } satisfies ErrorContext; + : { message: String(err), raw: err, stack: rootError.stack, name: rootError.name }, + rootError, + ); res.status(500).json({ error: "Internal server error" }); } diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index dd826c06..be47e3c5 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -62,7 +62,7 @@ export const httpLogger = pinoHttp({ const ctx = (res as any).__errorContext; if (ctx) { return { - err: ctx.error, + errorContext: ctx.error, reqBody: ctx.reqBody, reqParams: ctx.reqParams, reqQuery: ctx.reqQuery, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 113bbdb8..8d21fe65 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -184,6 +184,13 @@ export function issueRoutes(db: Db, storage: StorageService) { } }); + // Common malformed path when companyId is empty in "/api/companies/{companyId}/issues". + router.get("/issues", (_req, res) => { + res.status(400).json({ + error: "Missing companyId in path. Use /api/companies/{companyId}/issues.", + }); + }); + router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -522,6 +529,7 @@ export function issueRoutes(db: Db, storage: StorageService) { } const actor = getActorInfo(req); + const hasFieldChanges = Object.keys(previous).length > 0; await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, @@ -531,7 +539,12 @@ export function issueRoutes(db: Db, storage: StorageService) { action: "issue.updated", entityType: "issue", entityId: issue.id, - details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined }, + details: { + ...updateFields, + identifier: issue.identifier, + ...(commentBody ? { source: "comment" } : {}), + _previous: hasFieldChanges ? previous : undefined, + }, }); let comment = null; @@ -555,6 +568,7 @@ export function issueRoutes(db: Db, storage: StorageService) { bodySnippet: comment.body.slice(0, 120), identifier: issue.identifier, issueTitle: issue.title, + ...(hasFieldChanges ? { updated: true } : {}), }, }); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index cc59063c..afe54ffc 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -87,6 +87,14 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record { diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 3ff3b53b..54d5cd82 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow { primaryWorkspace: ProjectWorkspace | null; } +interface ProjectShortnameRow { + id: string; + name: string; +} + +interface ResolveProjectNameOptions { + excludeProjectId?: string | null; +} + /** Batch-load goal refs for a set of projects. */ async function attachGoals(db: Db, rows: ProjectRow[]): Promise { if (rows.length === 0) return []; @@ -192,6 +201,34 @@ function deriveWorkspaceName(input: { return "Workspace"; } +export function resolveProjectNameForUniqueShortname( + requestedName: string, + existingProjects: ProjectShortnameRow[], + options?: ResolveProjectNameOptions, +): string { + const requestedShortname = normalizeProjectUrlKey(requestedName); + if (!requestedShortname) return requestedName; + + const usedShortnames = new Set( + existingProjects + .filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId)) + .map((project) => normalizeProjectUrlKey(project.name)) + .filter((value): value is string => value !== null), + ); + if (!usedShortnames.has(requestedShortname)) return requestedName; + + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const candidateName = `${requestedName} ${suffix}`; + const candidateShortname = normalizeProjectUrlKey(candidateName); + if (candidateShortname && !usedShortnames.has(candidateShortname)) { + return candidateName; + } + } + + // Fallback guard for pathological naming collisions. + return `${requestedName} ${Date.now()}`; +} + async function ensureSinglePrimaryWorkspace( dbOrTx: any, input: { @@ -271,6 +308,12 @@ export function projectService(db: Db) { projectData.color = nextColor; } + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects); + // Also write goalId to the legacy column (first goal or null) const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; @@ -295,6 +338,26 @@ export function projectService(db: Db) { ): Promise => { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); + const existingProject = await db + .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) + .from(projects) + .where(eq(projects.id, id)) + .then((rows) => rows[0] ?? null); + if (!existingProject) return null; + + if (projectData.name !== undefined) { + const existingShortname = normalizeProjectUrlKey(existingProject.name); + const nextShortname = normalizeProjectUrlKey(projectData.name); + if (existingShortname !== nextShortname) { + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, existingProject.companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, { + excludeProjectId: id, + }); + } + } // Keep legacy goalId column in sync const updates: Partial = { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index b42355d9..92fe3ba4 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -16,6 +16,8 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. +Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli --company-id ` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity. + **Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability. ## The Heartbeat Procedure @@ -222,6 +224,43 @@ GET /api/companies/{companyId}/issues?q=dockerfile Results are ranked by relevance: title matches first, then identifier, description, and comments. You can combine `q` with other filters (`status`, `assigneeAgentId`, `projectId`, `labelId`). +## Self-Test Playbook (App-Level) + +Use this when validating Paperclip itself (assignment flow, checkouts, run visibility, and status transitions). + +1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`): + +```bash +pnpm paperclipai issue create \ + --company-id "$PAPERCLIP_COMPANY_ID" \ + --title "Self-test: assignment/watch flow" \ + --description "Temporary validation issue" \ + --status todo \ + --assignee-agent-id "$PAPERCLIP_AGENT_ID" +``` + +2. Trigger and watch a heartbeat for that assignee: + +```bash +pnpm paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID" +``` + +3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted: + +```bash +pnpm paperclipai issue get +``` + +4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior: + +```bash +pnpm paperclipai issue update --assignee-agent-id --status todo +``` + +5. Cleanup: mark temporary issues done/cancelled with a clear note. + +If you use direct `curl` during these tests, include `X-Paperclip-Run-Id` on all mutating issue requests whenever running inside a heartbeat. + ## Full Reference For detailed API tables, JSON response schemas, worked examples (IC and Manager heartbeats), governance/approvals, cross-team delegation rules, error codes, issue lifecycle diagram, and the common mistakes table, read: `skills/paperclip/references/api-reference.md` diff --git a/ui/package.json b/ui/package.json index d6cb1113..eee73a40 100644 --- a/ui/package.json +++ b/ui/package.json @@ -20,6 +20,7 @@ "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", "@radix-ui/react-slot": "^1.2.4", @@ -29,6 +30,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/App.tsx b/ui/src/App.tsx index 02baefb6..18df83d8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,6 +24,7 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; import { OrgChart } from "./pages/OrgChart"; +import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; @@ -101,6 +102,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -214,6 +216,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> 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 f9e0080c..a641b265 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -5,11 +5,22 @@ import { cursorLocalUIAdapter } from "./cursor"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawUIAdapter } from "./openclaw"; +import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; const adaptersByType = new Map( - [claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), + [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + openCodeLocalUIAdapter, + piLocalUIAdapter, + cursorLocalUIAdapter, + openClawUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, + ].map((a) => [a.type, a]), ); export function getUIAdapter(type: string): UIAdapterModule { diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 2910b68d..2c382a9e 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -461,7 +461,7 @@ function AgentRunCard({ = { 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/CommentThread.tsx b/ui/src/components/CommentThread.tsx index f529382b..fa123d31 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -2,7 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re import { Link, useLocation } from "react-router-dom"; import type { IssueComment, Agent } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; -import { Paperclip } from "lucide-react"; +import { Check, Copy, Paperclip } from "lucide-react"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { MarkdownBody } from "./MarkdownBody"; @@ -92,6 +92,25 @@ function parseReassignment(target: string): CommentReassignment | null { return null; } +function CopyMarkdownButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; @@ -160,12 +179,15 @@ const TimelineList = memo(function TimelineList({ ) : ( )} - - {formatDateTime(comment.createdAt)} - + + + {formatDateTime(comment.createdAt)} + + + {comment.body} {comment.runId && ( 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/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index c1eb59ac..b996629a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "react"; +import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { parseProjectMentionHref } from "@paperclipai/shared"; @@ -10,6 +10,30 @@ interface MarkdownBodyProps { className?: string; } +let mermaidLoaderPromise: Promise | null = null; + +function loadMermaid() { + if (!mermaidLoaderPromise) { + mermaidLoaderPromise = import("mermaid").then((module) => module.default); + } + return mermaidLoaderPromise; +} + +function flattenText(value: ReactNode): string { + if (value == null) return ""; + if (typeof value === "string" || typeof value === "number") return String(value); + if (Array.isArray(value)) return value.map((item) => flattenText(item)).join(""); + return ""; +} + +function extractMermaidSource(children: ReactNode): string | null { + if (!isValidElement(children)) return null; + const childProps = children.props as { className?: unknown; children?: ReactNode }; + if (typeof childProps.className !== "string") return null; + if (!/\blanguage-mermaid\b/i.test(childProps.className)) return null; + return flattenText(childProps.children).replace(/\n$/, ""); +} + function hexToRgb(hex: string): { r: number; g: number; b: number } | null { const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); if (!match) return null; @@ -33,6 +57,61 @@ function mentionChipStyle(color: string | null): CSSProperties | undefined { }; } +function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { + const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const [svg, setSvg] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + setSvg(null); + setError(null); + + loadMermaid() + .then(async (mermaid) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: darkMode ? "dark" : "default", + fontFamily: "inherit", + suppressErrorRendering: true, + }); + const rendered = await mermaid.render(`paperclip-mermaid-${renderId}`, source); + if (!active) return; + setSvg(rendered.svg); + }) + .catch((err) => { + if (!active) return; + const message = + err instanceof Error && err.message + ? err.message + : "Failed to render Mermaid diagram."; + setError(message); + }); + + return () => { + active = false; + }; + }, [darkMode, renderId, source]); + + return ( +
+ {svg ? ( +
+ ) : ( + <> +

+ {error ? `Unable to render Mermaid diagram: ${error}` : "Rendering Mermaid diagram..."} +

+
+            {source}
+          
+ + )} +
+ ); +} + export function MarkdownBody({ children, className }: MarkdownBodyProps) { const { theme } = useTheme(); return ( @@ -46,6 +125,13 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) { { + const mermaidSource = extractMermaidSource(preChildren); + if (mermaidSource) { + return ; + } + return
{preChildren}
; + }, a: ({ href, children: linkChildren }) => { const parsed = href ? parseProjectMentionHref(href) : null; if (parsed) { diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index a5392716..b3ab9233 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,53 +1,20 @@ -import { useState, useEffect } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { agentsApi } from "../api/agents"; import { queryKeys } from "../lib/queryKeys"; -import { AGENT_ROLES } from "@paperclipai/shared"; import { Dialog, DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Minimize2, - Maximize2, - Shield, - User, -} from "lucide-react"; -import { cn, agentUrl } from "../lib/utils"; -import { roleLabels } from "./agent-config-primitives"; -import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm"; -import { defaultCreateValues } from "./agent-config-defaults"; -import { getUIAdapter } from "../adapters"; -import { AgentIcon } from "./AgentIconPicker"; +import { Bot, Sparkles } from "lucide-react"; export function NewAgentDialog() { - const { newAgentOpen, closeNewAgent } = useDialog(); - const { selectedCompanyId, selectedCompany } = useCompany(); - const queryClient = useQueryClient(); + const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); + const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); - const [expanded, setExpanded] = useState(true); - - // Identity - const [name, setName] = useState(""); - const [title, setTitle] = useState(""); - const [role, setRole] = useState("general"); - const [reportsTo, setReportsTo] = useState(""); - - // Config values (managed by AgentConfigForm) - const [configValues, setConfigValues] = useState(defaultCreateValues); - - // Popover states - const [roleOpen, setRoleOpen] = useState(false); - const [reportsToOpen, setReportsToOpen] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -55,287 +22,74 @@ export function NewAgentDialog() { enabled: !!selectedCompanyId && newAgentOpen, }); - const { - data: adapterModels, - error: adapterModelsError, - isLoading: adapterModelsLoading, - isFetching: adapterModelsFetching, - } = useQuery({ - queryKey: - selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) - : ["agents", "none", "adapter-models", configValues.adapterType], - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), - enabled: Boolean(selectedCompanyId) && newAgentOpen, - }); + const ceoAgent = (agents ?? []).find((a) => a.role === "ceo"); - const isFirstAgent = !agents || agents.length === 0; - const effectiveRole = isFirstAgent ? "ceo" : role; - const [formError, setFormError] = useState(null); - - // Auto-fill for CEO - useEffect(() => { - if (newAgentOpen && isFirstAgent) { - if (!name) setName("CEO"); - if (!title) setTitle("CEO"); - } - }, [newAgentOpen, isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps - - const createAgent = useMutation({ - mutationFn: (data: Record) => - agentsApi.hire(selectedCompanyId!, data), - onSuccess: (result) => { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); - reset(); - closeNewAgent(); - navigate(agentUrl(result.agent)); - }, - onError: (error) => { - setFormError(error instanceof Error ? error.message : "Failed to create agent"); - }, - }); - - function reset() { - setName(""); - setTitle(""); - setRole("general"); - setReportsTo(""); - setConfigValues(defaultCreateValues); - setExpanded(true); - setFormError(null); - } - - function buildAdapterConfig() { - const adapter = getUIAdapter(configValues.adapterType); - return adapter.buildAdapterConfig(configValues); - } - - function handleSubmit() { - if (!selectedCompanyId || !name.trim()) return; - setFormError(null); - if (configValues.adapterType === "opencode_local") { - const selectedModel = configValues.model.trim(); - if (!selectedModel) { - setFormError("OpenCode requires an explicit model in provider/model format."); - return; - } - if (adapterModelsError) { - setFormError( - adapterModelsError instanceof Error - ? adapterModelsError.message - : "Failed to load OpenCode models.", - ); - return; - } - if (adapterModelsLoading || adapterModelsFetching) { - setFormError("OpenCode models are still loading. Please wait and try again."); - return; - } - const discovered = adapterModels ?? []; - if (!discovered.some((entry) => entry.id === selectedModel)) { - setFormError( - discovered.length === 0 - ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." - : `Configured OpenCode model is unavailable: ${selectedModel}`, - ); - return; - } - } - createAgent.mutate({ - name: name.trim(), - role: effectiveRole, - ...(title.trim() ? { title: title.trim() } : {}), - ...(reportsTo ? { reportsTo } : {}), - adapterType: configValues.adapterType, - adapterConfig: buildAdapterConfig(), - runtimeConfig: { - heartbeat: { - enabled: configValues.heartbeatEnabled, - intervalSec: configValues.intervalSec, - wakeOnDemand: true, - cooldownSec: 10, - maxConcurrentRuns: 1, - }, - }, - budgetMonthlyCents: 0, + function handleAskCeo() { + closeNewAgent(); + openNewIssue({ + assigneeAgentId: ceoAgent?.id, + title: "Create a new agent", + description: "(type in what kind of agent you want here)", }); } - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(); - } + function handleAdvancedConfig() { + closeNewAgent(); + navigate("/agents/new"); } - const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); - return ( { - if (!open) { reset(); closeNewAgent(); } + if (!open) closeNewAgent(); }} > {/* Header */}
-
- {selectedCompany && ( - - {selectedCompany.name.slice(0, 3).toUpperCase()} - - )} - - New agent -
-
- - -
-
- -
- {/* Name */} -
- setName(e.target.value)} - autoFocus - /> -
- - {/* Title */} -
- setTitle(e.target.value)} - /> -
- - {/* Property chips: Role + Reports To */} -
- {/* Role */} - - - - - - {AGENT_ROLES.map((r) => ( - - ))} - - - - {/* Reports To */} - - - - - - - {(agents ?? []).map((a) => ( - - ))} - - -
- - {/* Shared config form (adapter + heartbeat) */} - setConfigValues((prev) => ({ ...prev, ...patch }))} - adapterModels={adapterModels} - /> -
- - {/* Footer */} -
- - {isFirstAgent ? "This will be the CEO" : ""} - -
- {formError && ( -
{formError}
- )} -
+ Add a new agent
+ +
+ {/* Recommendation */} +
+
+ +
+

+ We recommend letting your CEO handle agent setup — they know the + org structure and can configure reporting, permissions, and + adapters. +

+
+ + + + {/* Advanced link */} +
+ +
+
); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 35fab02e..99106f9f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } f import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; @@ -170,7 +169,6 @@ const priorities = [ export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); const { companies, selectedCompanyId, selectedCompany } = useCompany(); - const { pushToast } = useToast(); const queryClient = useQueryClient(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -262,19 +260,12 @@ export function NewIssueDialog() { const createIssue = useMutation({ mutationFn: ({ companyId, ...data }: { companyId: string } & Record) => issuesApi.create(companyId, data), - onSuccess: (issue) => { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) }); if (draftTimer.current) clearTimeout(draftTimer.current); clearDraft(); reset(); closeNewIssue(); - pushToast({ - dedupeKey: `activity:issue.created:${issue.id}`, - title: `${issue.identifier ?? "Issue"} created`, - body: issue.title, - tone: "success", - action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.identifier ?? issue.id}` }, - }); }, }); @@ -332,7 +323,18 @@ export function NewIssueDialog() { setDialogCompanyId(selectedCompanyId); const draft = loadDraft(); - if (draft && draft.title.trim()) { + if (newIssueDefaults.title) { + setTitle(newIssueDefaults.title); + setDescription(newIssueDefaults.description ?? ""); + setStatus(newIssueDefaults.status ?? "todo"); + setPriority(newIssueDefaults.priority ?? ""); + setProjectId(newIssueDefaults.projectId ?? ""); + setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeModelOverride(""); + setAssigneeThinkingEffort(""); + setAssigneeChrome(false); + setAssigneeUseProjectWorkspace(true); + } else if (draft && draft.title.trim()) { setTitle(draft.title); setDescription(draft.description); setStatus(draft.status || "todo"); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 12c7695e..7a4bceeb 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -57,7 +57,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) @@ -679,6 +680,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", @@ -981,14 +988,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/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 25b59814..9d36377f 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,8 +1,9 @@ import { useMemo, useState } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { ChevronRight } from "lucide-react"; +import { ChevronRight, Plus } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; +import { useDialog } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; @@ -40,6 +41,7 @@ function sortByHierarchy(agents: Agent[]): Agent[] { export function SidebarAgents() { const [open, setOpen] = useState(true); const { selectedCompanyId } = useCompany(); + const { openNewAgent } = useDialog(); const { isMobile, setSidebarOpen } = useSidebar(); const location = useLocation(); @@ -89,6 +91,16 @@ export function SidebarAgents() { Agents +
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/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index b21e6b8a..ef7b12b8 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,8 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + title?: string; + description?: string; } interface NewGoalDefaults { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 6a1403af..25d0381e 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, type ReactNode } from "react"; -import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { Agent, Issue, LiveEvent } from "@paperclipai/shared"; +import { authApi } from "../api/auth"; import { useCompany } from "./CompanyContext"; import type { ToastInput } from "./ToastContext"; import { useToast } from "./ToastContext"; @@ -152,6 +153,7 @@ function buildActivityToast( queryClient: QueryClient, companyId: string, payload: Record, + currentActor: { userId: string | null; agentId: string | null }, ): ToastInput | null { const entityType = readString(payload.entityType); const entityId = readString(payload.entityId); @@ -166,6 +168,10 @@ function buildActivityToast( const issue = resolveIssueToastContext(queryClient, companyId, entityId, details); const actor = resolveActorLabel(queryClient, companyId, actorType, actorId); + const isSelfActivity = + (actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) || + (actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId); + if (isSelfActivity) return null; if (action === "issue.created") { return { @@ -178,8 +184,8 @@ function buildActivityToast( } if (action === "issue.updated") { - if (details?.reopened === true && readString(details.source) === "comment") { - // Reopen-via-comment emits a paired comment event; show one combined toast on the comment event. + if (readString(details?.source) === "comment") { + // Comment-driven updates emit a paired comment event; show one combined toast on the comment event. return null; } const changeDesc = describeIssueUpdate(details); @@ -202,13 +208,18 @@ function buildActivityToast( const commentId = readString(details?.commentId); const bodySnippet = readString(details?.bodySnippet); const reopened = details?.reopened === true; + const updated = details?.updated === true; const reopenedFrom = readString(details?.reopenedFrom); const reopenedLabel = reopened ? reopenedFrom ? `reopened from ${reopenedFrom.replace(/_/g, " ")}` : "reopened" : null; - const title = reopened ? `${actor} reopened and commented on ${issue.ref}` : `${actor} commented on ${issue.ref}`; + const title = reopened + ? `${actor} reopened and commented on ${issue.ref}` + : updated + ? `${actor} commented and updated ${issue.ref}` + : `${actor} commented on ${issue.ref}`; const body = bodySnippet ? reopenedLabel ? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}` @@ -448,6 +459,7 @@ function handleLiveEvent( event: LiveEvent, pushToast: (toast: ToastInput) => string | null, gate: ToastGate, + currentActor: { userId: string | null; agentId: string | null }, ) { if (event.companyId !== expectedCompanyId) return; @@ -485,7 +497,7 @@ function handleLiveEvent( invalidateActivityQueries(queryClient, expectedCompanyId, payload); const action = readString(payload.action); const toast = - buildActivityToast(queryClient, expectedCompanyId, payload) ?? + buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ?? buildJoinRequestToast(payload); if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast); } @@ -496,6 +508,12 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const { pushToast } = useToast(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; useEffect(() => { if (!selectedCompanyId) return; @@ -541,7 +559,10 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current); + handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, { + userId: currentUserId, + agentId: null, + }); } catch { // Ignore non-JSON payloads. } @@ -570,7 +591,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { socket.close(1000, "provider_unmount"); } }; - }, [queryClient, selectedCompanyId, pushToast]); + }, [queryClient, selectedCompanyId, pushToast, currentUserId]); return <>{children}; } diff --git a/ui/src/index.css b/ui/src/index.css index 0fa33136..63a8c3dc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -426,6 +426,40 @@ font-weight: 500; } +.paperclip-mermaid { + margin: 0.5rem 0; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 3px); + background-color: color-mix(in oklab, var(--accent) 35%, transparent); + overflow-x: auto; +} + +.paperclip-mermaid svg { + display: block; + width: max-content; + max-width: none; + min-width: 100%; + height: auto; +} + +.paperclip-mermaid-status { + margin: 0 0 0.45rem; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.paperclip-mermaid-status-error { + color: var(--destructive); +} + +.paperclip-mermaid-source { + margin: 0; + padding: 0; + border: 0; + background: transparent; +} + /* Project mention chips rendered inside MarkdownBody */ a.paperclip-project-mention-chip { display: inline-flex; 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/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 84f07ed1..90c94888 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -8,7 +8,6 @@ import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; import { usePanel } from "../context/PanelContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; @@ -146,7 +145,6 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map(); const { selectedCompanyId } = useCompany(); - const { pushToast } = useToast(); const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); @@ -403,33 +401,17 @@ export function IssueDetail() { const updateIssue = useMutation({ mutationFn: (data: Record) => issuesApi.update(issueId!, data), - onSuccess: (updated) => { + onSuccess: () => { invalidateIssue(); - const issueRef = updated.identifier ?? `Issue ${updated.id.slice(0, 8)}`; - pushToast({ - dedupeKey: `activity:issue.updated:${updated.id}`, - title: `${issueRef} updated`, - body: truncate(updated.title, 96), - tone: "success", - action: { label: `View ${issueRef}`, href: `/issues/${updated.identifier ?? updated.id}` }, - }); }, }); const addComment = useMutation({ mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => issuesApi.addComment(issueId!, body, reopen), - onSuccess: (comment) => { + onSuccess: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); - const issueRef = issue?.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue"); - pushToast({ - dedupeKey: `activity:issue.comment_added:${issueId}:${comment.id}`, - title: `Comment posted on ${issueRef}`, - body: issue?.title ? truncate(issue.title, 96) : undefined, - tone: "success", - action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined, - }); }, }); @@ -449,17 +431,9 @@ export function IssueDetail() { assigneeUserId: reassignment.assigneeUserId, ...(reopen ? { status: "todo" } : {}), }), - onSuccess: (updated) => { + onSuccess: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); - const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue"); - pushToast({ - dedupeKey: `activity:issue.reassigned:${updated.id}`, - title: `${issueRef} reassigned`, - body: issue?.title ? truncate(issue.title, 96) : undefined, - tone: "success", - action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined, - }); }, }); diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx new file mode 100644 index 00000000..a583ece2 --- /dev/null +++ b/ui/src/pages/NewAgent.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@/lib/router"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { agentsApi } from "../api/agents"; +import { queryKeys } from "../lib/queryKeys"; +import { AGENT_ROLES } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Shield, User } from "lucide-react"; +import { cn, agentUrl } from "../lib/utils"; +import { roleLabels } from "../components/agent-config-primitives"; +import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm"; +import { defaultCreateValues } from "../components/agent-config-defaults"; +import { getUIAdapter } from "../adapters"; +import { AgentIcon } from "../components/AgentIconPicker"; + +export function NewAgent() { + const { selectedCompanyId, selectedCompany } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const [name, setName] = useState(""); + const [title, setTitle] = useState(""); + const [role, setRole] = useState("general"); + const [reportsTo, setReportsTo] = useState(""); + const [configValues, setConfigValues] = useState(defaultCreateValues); + const [roleOpen, setRoleOpen] = useState(false); + const [reportsToOpen, setReportsToOpen] = useState(false); + const [formError, setFormError] = useState(null); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { + data: adapterModels, + error: adapterModelsError, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) + : ["agents", "none", "adapter-models", configValues.adapterType], + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), + enabled: Boolean(selectedCompanyId), + }); + + const isFirstAgent = !agents || agents.length === 0; + const effectiveRole = isFirstAgent ? "ceo" : role; + + useEffect(() => { + setBreadcrumbs([ + { label: "Agents", href: "/agents" }, + { label: "New Agent" }, + ]); + }, [setBreadcrumbs]); + + useEffect(() => { + if (isFirstAgent) { + if (!name) setName("CEO"); + if (!title) setTitle("CEO"); + } + }, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps + + const createAgent = useMutation({ + mutationFn: (data: Record) => + agentsApi.hire(selectedCompanyId!, data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); + navigate(agentUrl(result.agent)); + }, + onError: (error) => { + setFormError(error instanceof Error ? error.message : "Failed to create agent"); + }, + }); + + function buildAdapterConfig() { + const adapter = getUIAdapter(configValues.adapterType); + return adapter.buildAdapterConfig(configValues); + } + + function handleSubmit() { + if (!selectedCompanyId || !name.trim()) return; + setFormError(null); + if (configValues.adapterType === "opencode_local") { + const selectedModel = configValues.model.trim(); + if (!selectedModel) { + setFormError("OpenCode requires an explicit model in provider/model format."); + return; + } + if (adapterModelsError) { + setFormError( + adapterModelsError instanceof Error + ? adapterModelsError.message + : "Failed to load OpenCode models.", + ); + return; + } + if (adapterModelsLoading || adapterModelsFetching) { + setFormError("OpenCode models are still loading. Please wait and try again."); + return; + } + const discovered = adapterModels ?? []; + if (!discovered.some((entry) => entry.id === selectedModel)) { + setFormError( + discovered.length === 0 + ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." + : `Configured OpenCode model is unavailable: ${selectedModel}`, + ); + return; + } + } + createAgent.mutate({ + name: name.trim(), + role: effectiveRole, + ...(title.trim() ? { title: title.trim() } : {}), + ...(reportsTo ? { reportsTo } : {}), + adapterType: configValues.adapterType, + adapterConfig: buildAdapterConfig(), + runtimeConfig: { + heartbeat: { + enabled: configValues.heartbeatEnabled, + intervalSec: configValues.intervalSec, + wakeOnDemand: true, + cooldownSec: 10, + maxConcurrentRuns: 1, + }, + }, + budgetMonthlyCents: 0, + }); + } + + const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); + + return ( +
+
+

New Agent

+

+ Advanced agent configuration +

+
+ +
+ {/* Name */} +
+ setName(e.target.value)} + autoFocus + /> +
+ + {/* Title */} +
+ setTitle(e.target.value)} + /> +
+ + {/* Property chips: Role + Reports To */} +
+ + + + + + {AGENT_ROLES.map((r) => ( + + ))} + + + + + + + + + + {(agents ?? []).map((a) => ( + + ))} + + +
+ + {/* Shared config form */} + setConfigValues((prev) => ({ ...prev, ...patch }))} + adapterModels={adapterModels} + /> + + {/* Footer */} +
+ {isFirstAgent && ( +

This will be the CEO

+ )} + {formError && ( +

{formError}

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