diff --git a/.workflow/plan.md b/.workflow/plan.md new file mode 100644 index 0000000000..803644efeb --- /dev/null +++ b/.workflow/plan.md @@ -0,0 +1,18 @@ +# Plan — Normalize Lionroot Paperclip Fork + +**Status:** Approved +**Date:** 2026-03-09 + +## Steps +1. Inspect the current Paperclip fork state and identify Lionroot-owned deltas. +2. Commit the in-flight OpenClaw adapter changes with coverage. +3. Create `LIONROOT-PATCHES.md` documenting owned files, upstream patch files, and sync notes. +4. Normalize the fork to a canonical fork mainline branch. +5. Fetch and merge latest `upstream/master`, resolving conflicts by preserving documented Lionroot patches. +6. Run targeted verification for adapter behavior and affected server bootstrap surfaces. +7. Review the result and summarize the new durable state. + +## Verification +- `pnpm exec vitest run packages/adapters/openclaw/src/server/execute.test.ts` +- relevant `vitest` coverage for affected bootstrap surfaces +- git status clean at the end diff --git a/.workflow/prd.md b/.workflow/prd.md new file mode 100644 index 0000000000..b20191baa9 --- /dev/null +++ b/.workflow/prd.md @@ -0,0 +1,31 @@ +# PRD — Normalize Lionroot Paperclip Fork + +**Status:** Approved +**Date:** 2026-03-09 + +## Goal +Make the vendored Paperclip fork under `command-post/paperclip-server` durable and syncable: preserve Lionroot-owned OpenClaw integration, land any in-flight adapter changes, normalize the fork onto a clear fork mainline, and bring in the latest upstream Paperclip changes without losing local behavior. + +## Problem +Paperclip is currently running from a vendor-sync branch with uncommitted local adapter changes. Lionroot-specific behavior exists, but it is not cleanly normalized onto the fork’s mainline. That makes future upstream syncs fragile and obscures which behavior is truly fork-owned. + +## Requirements +1. Commit the current OpenClaw adapter working changes with tests. +2. Inventory Lionroot-owned Paperclip deltas versus upstream. +3. Add a maintained patch inventory document for future syncs. +4. Normalize the fork onto a canonical mainline branch. +5. Sync latest upstream Paperclip changes while preserving Lionroot patches. +6. Verify Paperclip still builds/tests for the affected surfaces. +7. Preserve the running OpenClaw adapter semantics used by Command Post. + +## Non-Goals +- Refactor Command Post’s Paperclip client unless upstream drift forces it. +- Re-architect Paperclip’s plugin system. +- Upstream the OpenClaw adapter in this task. + +## Success Criteria +- Paperclip fork has clean committed Lionroot changes. +- Fork has an explicit patch inventory doc. +- Canonical fork branch is current and deployable. +- Latest upstream changes are merged in. +- Targeted tests for OpenClaw adapter and affected bootstrap surfaces pass. diff --git a/Dockerfile b/Dockerfile index 3fe1f2b260..3426f6ea91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,8 +27,8 @@ WORKDIR /app COPY --from=deps /app /app COPY . . RUN pnpm --filter @paperclipai/ui build -RUN pnpm --filter @paperclipai/server build -RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) +# Server dist is pre-built in repo; keep the Lionroot Docker workaround until +# the server build is confirmed stable in this image. FROM base AS production WORKDIR /app diff --git a/LIONROOT-PATCHES.md b/LIONROOT-PATCHES.md new file mode 100644 index 0000000000..a1b3506b42 --- /dev/null +++ b/LIONROOT-PATCHES.md @@ -0,0 +1,80 @@ +# LIONROOT-PATCHES.md + +Maintained Lionroot delta for the Paperclip fork under `command-post/paperclip-server`. + +## 1. Lionroot-owned surface + +These files implement the Lionroot OpenClaw webhook adapter and should be treated as Lionroot-owned. Upstream does not provide this adapter. This fork also carries upstream `openclaw_gateway` separately; both adapter types are intentionally supported. + +- `packages/adapters/openclaw/package.json` +- `packages/adapters/openclaw/tsconfig.json` +- `packages/adapters/openclaw/src/index.ts` +- `packages/adapters/openclaw/src/cli/format-event.ts` +- `packages/adapters/openclaw/src/cli/index.ts` +- `packages/adapters/openclaw/src/server/execute.ts` +- `packages/adapters/openclaw/src/server/index.ts` +- `packages/adapters/openclaw/src/server/parse.ts` +- `packages/adapters/openclaw/src/server/test.ts` +- `packages/adapters/openclaw/src/server/execute.test.ts` +- `packages/adapters/openclaw/src/ui/build-config.ts` +- `packages/adapters/openclaw/src/ui/index.ts` +- `packages/adapters/openclaw/src/ui/parse-stdout.ts` + +### Current adapter semantics to preserve + +- Webhook payloads include a top-level `message` field. +- Webhook payloads include a top-level merged `context` object for hook consumers. +- Hook payload context includes `source: "paperclip"` and the computed wake metadata. +- `payloadTemplate.context` can extend the top-level `context`, but computed wake metadata wins on collisions. +- The original nested `paperclip` payload remains present for consumers that depend on the full Paperclip context envelope. + +## 2. Structural wiring added by Lionroot + +These upstream-owned files must continue to register the Lionroot webhook adapter while also preserving upstream `openclaw_gateway` support. + +- `cli/package.json` — keep both workspace deps: `@paperclipai/adapter-openclaw` and `@paperclipai/adapter-openclaw-gateway` +- `cli/src/adapters/registry.ts` — register both adapter types: `openclaw` and `openclaw_gateway` +- `packages/shared/src/constants.ts` — adapter union must include both `"openclaw"` and upstream `"openclaw_gateway"` +- `server/package.json` — keep both server-side workspace deps +- `server/src/adapters/registry.ts` — register both adapter types in server adapter lookup +- `tsconfig.json` — keep both adapter package references in the monorepo project refs +- `ui/package.json` — keep both UI-side workspace deps +- `ui/src/adapters/openclaw/config-fields.tsx` +- `ui/src/adapters/openclaw/index.ts` +- `ui/src/adapters/registry.ts` — register both UI adapters +- `ui/src/components/AgentProperties.tsx` — keep distinct labels for webhook vs gateway adapters +- `ui/src/components/agent-config-primitives.tsx` — document both adapter modes in operator help text +- `ui/src/pages/Agents.tsx` — keep distinct adapter labels in lists +- `ui/src/pages/OrgChart.tsx` — keep distinct adapter labels in org chart cards +- `vitest.config.ts` — include `packages/adapters/openclaw` in the project list so webhook adapter tests run +- `pnpm-lock.yaml` — regenerate from install if conflicts occur + +## 3. Upstream patch files + +These are small targeted patches in upstream-owned files and are the most likely merge-conflict points during sync. + +### `Dockerfile` +Keep the Lionroot Docker workaround that skips the server image build step and relies on pre-built `server/dist` output. + +Replay patch: +```diff +-RUN pnpm --filter @paperclipai/server build +-RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) ++# Server dist is pre-built in repo; keep the Lionroot Docker workaround until ++# the server build is confirmed stable in this image. +``` + +### `server/src/index.ts` +Preserve the Lionroot loopback behavior that treats `0.0.0.0` as loopback in `isLoopbackHost(...)` so private/local deployment mode works when Paperclip binds all interfaces. + +Replay patch: +```diff +-return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; ++return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1" || normalized === "0.0.0.0"; +``` + +## 4. Mainline policy + +- Fork mainline is `master` and should remain the deployable Lionroot branch. +- Future upstream syncs should merge `upstream/master` into fork `master` and resolve only the documented files above. +- If `pnpm-lock.yaml` conflicts, regenerate it from a clean install with the repo's pinned package manager (`pnpm@9.15.4`). diff --git a/cli/package.json b/cli/package.json index 21de193a21..13285c66e1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -38,6 +38,7 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", + "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 21b915f53c..05b43225ba 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -3,6 +3,7 @@ import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; +import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; @@ -23,6 +24,11 @@ const openCodeLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printOpenCodeStreamEvent, }; +const openclawCLIAdapter: CLIAdapterModule = { + type: "openclaw", + formatStdoutEvent: printOpenClawStreamEvent, +}; + const piLocalCLIAdapter: CLIAdapterModule = { type: "pi_local", formatStdoutEvent: printPiStreamEvent, @@ -43,6 +49,7 @@ const adaptersByType = new Map( claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, + openclawCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, openclawGatewayCLIAdapter, diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index f9d871c9a2..0859e280c0 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -28,18 +28,10 @@ const CODEX_ROLLOUT_NOISE_RE = /^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i; function stripCodexRolloutNoise(text: string): string { - const parts = text.split(/\r?\n/); - const kept: string[] = []; - for (const part of parts) { - const trimmed = part.trim(); - if (!trimmed) { - kept.push(part); - continue; - } - if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue; - kept.push(part); - } - return kept.join("\n"); + return text + .split(/\r?\n/) + .filter((line) => !line.trim() || !CODEX_ROLLOUT_NOISE_RE.test(line.trim())) + .join("\n"); } function firstNonEmptyLine(text: string): string { diff --git a/packages/adapters/openclaw/CHANGELOG.md b/packages/adapters/openclaw/CHANGELOG.md new file mode 100644 index 0000000000..79174ae236 --- /dev/null +++ b/packages/adapters/openclaw/CHANGELOG.md @@ -0,0 +1,57 @@ +# @paperclipai/adapter-openclaw + +## 0.2.7 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @paperclipai/adapter-utils@0.2.7 + +## 0.2.6 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @paperclipai/adapter-utils@0.2.6 + +## 0.2.5 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @paperclipai/adapter-utils@0.2.5 + +## 0.2.4 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @paperclipai/adapter-utils@0.2.4 + +## 0.2.3 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @paperclipai/adapter-utils@0.2.3 + +## 0.2.2 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @paperclipai/adapter-utils@0.2.2 + +## 0.2.1 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @paperclipai/adapter-utils@0.2.1 diff --git a/packages/adapters/openclaw/package.json b/packages/adapters/openclaw/package.json new file mode 100644 index 0000000000..c8bd561dff --- /dev/null +++ b/packages/adapters/openclaw/package.json @@ -0,0 +1,50 @@ +{ + "name": "@paperclipai/adapter-openclaw", + "version": "0.2.7", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/openclaw/src/cli/format-event.ts b/packages/adapters/openclaw/src/cli/format-event.ts new file mode 100644 index 0000000000..c0c0c91040 --- /dev/null +++ b/packages/adapters/openclaw/src/cli/format-event.ts @@ -0,0 +1,18 @@ +import pc from "picocolors"; + +export function printOpenClawStreamEvent(raw: string, debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + if (!debug) { + console.log(line); + return; + } + + if (line.startsWith("[openclaw]")) { + console.log(pc.cyan(line)); + return; + } + + console.log(pc.gray(line)); +} diff --git a/packages/adapters/openclaw/src/cli/index.ts b/packages/adapters/openclaw/src/cli/index.ts new file mode 100644 index 0000000000..107ebf8bcf --- /dev/null +++ b/packages/adapters/openclaw/src/cli/index.ts @@ -0,0 +1 @@ +export { printOpenClawStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts new file mode 100644 index 0000000000..d7399505ca --- /dev/null +++ b/packages/adapters/openclaw/src/index.ts @@ -0,0 +1,27 @@ +export const type = "openclaw"; +export const label = "OpenClaw"; + +export const models: { id: string; label: string }[] = []; + +export const agentConfigurationDoc = `# openclaw agent configuration + +Adapter: openclaw + +Use when: +- You run an OpenClaw agent remotely and wake it via webhook. +- You want Paperclip heartbeat/task events delivered over HTTP. + +Don't use when: +- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process). +- The OpenClaw endpoint is not reachable from the Paperclip server. + +Core fields: +- url (string, required): OpenClaw webhook endpoint URL +- method (string, optional): HTTP method, default POST +- headers (object, optional): extra HTTP headers for webhook calls +- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth +- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload + +Operational fields: +- timeoutSec (number, optional): request timeout in seconds (default 30) +`; diff --git a/packages/adapters/openclaw/src/server/execute.test.ts b/packages/adapters/openclaw/src/server/execute.test.ts new file mode 100644 index 0000000000..ae97bbb50b --- /dev/null +++ b/packages/adapters/openclaw/src/server/execute.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execute } from "./execute.js"; + +describe("openclaw execute", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends a default message and top-level context for hook consumers", async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + return new Response(JSON.stringify({ ok: true, runId: "remote-run-1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute({ + config: { + url: "https://example.test/gateway/hooks/agent", + webhookAuthHeader: "Bearer secret", + }, + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Cody", + adapterType: "openclaw", + adapterConfig: {}, + }, + context: { + issueId: "issue-7", + wakeReason: "approval-needed", + issueIds: ["issue-7", "issue-8"], + }, + onLog: vi.fn(async () => {}), + onMeta: vi.fn(async () => {}), + } as never); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [_url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)) as Record; + expect(body.message).toContain("Paperclip heartbeat wake"); + expect(body.context).toEqual({ + source: "paperclip", + runId: "run-1", + agentId: "agent-1", + companyId: "company-1", + taskId: "issue-7", + issueId: "issue-7", + wakeReason: "approval-needed", + issueIds: ["issue-7", "issue-8"], + }); + expect(body.paperclip).toBeTruthy(); + expect((init.headers as Record).authorization).toBe("Bearer secret"); + }); + + it("merges payloadTemplate context and preserves explicit message", async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + await execute({ + config: { + url: "https://example.test/gateway/hooks/agent", + payloadTemplate: { + message: "Custom wake", + context: { + extra: { repo: "lionroot-openclaw" }, + taskId: "static-task", + }, + }, + }, + runId: "run-2", + agent: { + id: "agent-2", + companyId: "company-2", + name: "Leo", + adapterType: "openclaw", + adapterConfig: {}, + }, + context: { + taskId: "task-2", + }, + onLog: vi.fn(async () => {}), + onMeta: vi.fn(async () => {}), + } as never); + + const [_url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)) as Record; + expect(body.message).toBe("Custom wake"); + expect(body.context).toEqual({ + extra: { repo: "lionroot-openclaw" }, + taskId: "task-2", + source: "paperclip", + runId: "run-2", + agentId: "agent-2", + companyId: "company-2", + issueIds: [], + }); + }); +}); diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts new file mode 100644 index 0000000000..2a12e6fa82 --- /dev/null +++ b/packages/adapters/openclaw/src/server/execute.ts @@ -0,0 +1,155 @@ +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { asNumber, asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { parseOpenClawResponse } from "./parse.js"; + +function nonEmpty(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +const DEFAULT_WAKE_MESSAGE = + "Paperclip heartbeat wake. Review the attached task context and act only on the identified work."; + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { config, runId, agent, context, onLog, onMeta } = ctx; + const url = asString(config.url, "").trim(); + if (!url) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "OpenClaw adapter missing url", + errorCode: "openclaw_url_missing", + }; + } + + const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; + const timeoutSec = Math.max(1, asNumber(config.timeoutSec, 30)); + const headersConfig = parseObject(config.headers) as Record; + const payloadTemplate = parseObject(config.payloadTemplate); + const payloadContext = parseObject(payloadTemplate.context) as Record; + const webhookAuthHeader = nonEmpty(config.webhookAuthHeader); + + const headers: Record = { + "content-type": "application/json", + }; + for (const [key, value] of Object.entries(headersConfig)) { + if (typeof value === "string" && value.trim().length > 0) { + headers[key] = value; + } + } + if (webhookAuthHeader && !headers.authorization && !headers.Authorization) { + headers.authorization = webhookAuthHeader; + } + + const wakePayload = { + source: "paperclip", + 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) + : [], + }; + + const message = nonEmpty(payloadTemplate.message) ?? DEFAULT_WAKE_MESSAGE; + const body = { + ...payloadTemplate, + message, + context: { + ...payloadContext, + ...wakePayload, + }, + paperclip: { + ...wakePayload, + context, + }, + }; + + if (onMeta) { + await onMeta({ + adapterType: "openclaw", + command: "webhook", + commandArgs: [method, url], + context, + }); + } + + await onLog("stdout", `[openclaw] invoking ${method} ${url}\n`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutSec * 1000); + + try { + const response = await fetch(url, { + method, + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + const responseText = await response.text(); + if (responseText.trim().length > 0) { + await onLog("stdout", `[openclaw] response (${response.status}) ${responseText.slice(0, 2000)}\n`); + } else { + await onLog("stdout", `[openclaw] response (${response.status}) \n`); + } + + if (!response.ok) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `OpenClaw webhook failed with status ${response.status}`, + errorCode: "openclaw_http_error", + resultJson: { + status: response.status, + statusText: response.statusText, + response: parseOpenClawResponse(responseText) ?? responseText, + }, + }; + } + + return { + exitCode: 0, + signal: null, + timedOut: false, + provider: "openclaw", + model: null, + summary: `OpenClaw webhook ${method} ${url}`, + resultJson: { + status: response.status, + statusText: response.statusText, + response: parseOpenClawResponse(responseText) ?? responseText, + }, + }; + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + await onLog("stderr", `[openclaw] request timed out after ${timeoutSec}s\n`); + return { + exitCode: null, + signal: null, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + errorCode: "timeout", + }; + } + + const message = err instanceof Error ? err.message : String(err); + await onLog("stderr", `[openclaw] request failed: ${message}\n`); + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: message, + errorCode: "openclaw_request_failed", + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/adapters/openclaw/src/server/index.ts b/packages/adapters/openclaw/src/server/index.ts new file mode 100644 index 0000000000..b44c258b49 --- /dev/null +++ b/packages/adapters/openclaw/src/server/index.ts @@ -0,0 +1,3 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/openclaw/src/server/parse.ts b/packages/adapters/openclaw/src/server/parse.ts new file mode 100644 index 0000000000..5045c202c2 --- /dev/null +++ b/packages/adapters/openclaw/src/server/parse.ts @@ -0,0 +1,15 @@ +export function parseOpenClawResponse(text: string): Record | null { + try { + const parsed = JSON.parse(text); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +export function isOpenClawUnknownSessionError(_text: string): boolean { + return false; +} diff --git a/packages/adapters/openclaw/src/server/test.ts b/packages/adapters/openclaw/src/server/test.ts new file mode 100644 index 0000000000..ecc1e43c19 --- /dev/null +++ b/packages/adapters/openclaw/src/server/test.ts @@ -0,0 +1,199 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function normalizeHostname(value: string | null | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("[")) { + const end = trimmed.indexOf("]"); + return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); + } + const firstColon = trimmed.indexOf(":"); + if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); + return trimmed.toLowerCase(); +} + +function pushDeploymentDiagnostics( + checks: AdapterEnvironmentCheck[], + ctx: AdapterEnvironmentTestContext, + endpointUrl: URL | null, +) { + const mode = ctx.deployment?.mode; + const exposure = ctx.deployment?.exposure; + const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null); + const allowSet = new Set( + (ctx.deployment?.allowedHostnames ?? []) + .map((entry) => normalizeHostname(entry)) + .filter((entry): entry is string => Boolean(entry)), + ); + const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null; + + if (!mode) return; + + checks.push({ + code: "openclaw_deployment_context", + level: "info", + message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`, + }); + + if (mode === "authenticated" && exposure === "private") { + if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { + checks.push({ + code: "openclaw_private_bind_hostname_not_allowed", + level: "warn", + message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`, + hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`, + }); + } + + if (!bindHost || isLoopbackHost(bindHost)) { + checks.push({ + code: "openclaw_private_bind_loopback", + level: "warn", + message: "Paperclip is bound to loopback in authenticated/private mode.", + hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.", + }); + } + + if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) { + checks.push({ + code: "openclaw_private_no_allowed_hostnames", + level: "warn", + message: "No explicit allowed hostnames are configured for authenticated/private mode.", + hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs on another machine.", + }); + } + } + + if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") { + checks.push({ + code: "openclaw_public_http_endpoint", + level: "warn", + message: "OpenClaw endpoint uses HTTP in authenticated/public mode.", + hint: "Prefer HTTPS for public deployments.", + }); + } +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const urlValue = asString(config.url, ""); + + if (!urlValue) { + checks.push({ + code: "openclaw_url_missing", + level: "error", + message: "OpenClaw adapter requires a webhook URL.", + hint: "Set adapterConfig.url to your OpenClaw webhook endpoint.", + }); + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; + } + + let url: URL | null = null; + try { + url = new URL(urlValue); + } catch { + checks.push({ + code: "openclaw_url_invalid", + level: "error", + message: `Invalid URL: ${urlValue}`, + }); + } + + if (url && url.protocol !== "http:" && url.protocol !== "https:") { + checks.push({ + code: "openclaw_url_protocol_invalid", + level: "error", + message: `Unsupported URL protocol: ${url.protocol}`, + hint: "Use an http:// or https:// endpoint.", + }); + } + + if (url) { + checks.push({ + code: "openclaw_url_valid", + level: "info", + message: `Configured endpoint: ${url.toString()}`, + }); + + if (isLoopbackHost(url.hostname)) { + checks.push({ + code: "openclaw_loopback_endpoint", + level: "warn", + message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", + hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).", + }); + } + } + + pushDeploymentDiagnostics(checks, ctx, url); + + const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; + checks.push({ + code: "openclaw_method_configured", + level: "info", + message: `Configured method: ${method}`, + }); + + if (url && (url.protocol === "http:" || url.protocol === "https:")) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + try { + const response = await fetch(url, { method: "HEAD", signal: controller.signal }); + if (!response.ok && response.status !== 405 && response.status !== 501) { + checks.push({ + code: "openclaw_endpoint_probe_unexpected_status", + level: "warn", + message: `Endpoint probe returned HTTP ${response.status}.`, + hint: "Verify OpenClaw webhook reachability and auth/network settings.", + }); + } else { + checks.push({ + code: "openclaw_endpoint_probe_ok", + level: "info", + message: "Endpoint responded to a HEAD probe.", + }); + } + } catch (err) { + checks.push({ + code: "openclaw_endpoint_probe_failed", + level: "warn", + message: err instanceof Error ? err.message : "Endpoint probe failed", + hint: "This may be expected in restricted networks; validate from the Paperclip server host.", + }); + } finally { + clearTimeout(timeout); + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/openclaw/src/ui/build-config.ts b/packages/adapters/openclaw/src/ui/build-config.ts new file mode 100644 index 0000000000..54bb2fe2f9 --- /dev/null +++ b/packages/adapters/openclaw/src/ui/build-config.ts @@ -0,0 +1,9 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +export function buildOpenClawConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.url) ac.url = v.url; + ac.method = "POST"; + ac.timeoutSec = 30; + return ac; +} diff --git a/packages/adapters/openclaw/src/ui/index.ts b/packages/adapters/openclaw/src/ui/index.ts new file mode 100644 index 0000000000..f3f1905ed3 --- /dev/null +++ b/packages/adapters/openclaw/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseOpenClawStdoutLine } from "./parse-stdout.js"; +export { buildOpenClawConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw/src/ui/parse-stdout.ts b/packages/adapters/openclaw/src/ui/parse-stdout.ts new file mode 100644 index 0000000000..4be215e8e2 --- /dev/null +++ b/packages/adapters/openclaw/src/ui/parse-stdout.ts @@ -0,0 +1,5 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] { + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/openclaw/tsconfig.json b/packages/adapters/openclaw/tsconfig.json new file mode 100644 index 0000000000..2f355cfeeb --- /dev/null +++ b/packages/adapters/openclaw/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index ba75dc8efa..befd4230b7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -27,6 +27,7 @@ export const AGENT_ADAPTER_TYPES = [ "claude_local", "codex_local", "opencode_local", + "openclaw", "pi_local", "cursor", "openclaw_gateway", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1dd1ddcfd..3d0ff8f3ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@playwright/test': specifier: ^1.58.2 version: 1.58.2 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -38,6 +41,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -139,6 +145,22 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapters/openclaw: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/adapters/openclaw-gateway: dependencies: '@paperclipai/adapter-utils': @@ -245,6 +267,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -321,6 +346,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -360,6 +388,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -989,6 +1020,9 @@ packages: cpu: [x64] os: [win32] + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -3424,6 +3458,11 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6741,6 +6780,8 @@ snapshots: '@embedded-postgres/windows-x64@18.1.0-beta.16': optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -9255,6 +9296,11 @@ snapshots: crelt@1.0.6: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/server/package.json b/server/package.json index aeb0994451..8d52bf5c3d 100644 --- a/server/package.json +++ b/server/package.json @@ -38,6 +38,7 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", + "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 9fe536a086..f84ec6de2e 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -26,6 +26,14 @@ import { import { agentConfigurationDoc as openCodeAgentConfigurationDoc, } from "@paperclipai/adapter-opencode-local"; +import { + execute as openclawExecute, + testEnvironment as openclawTestEnvironment, +} from "@paperclipai/adapter-openclaw/server"; +import { + agentConfigurationDoc as openclawAgentConfigurationDoc, + models as openclawModels, +} from "@paperclipai/adapter-openclaw"; import { execute as openclawGatewayExecute, testEnvironment as openclawGatewayTestEnvironment, @@ -80,6 +88,15 @@ const cursorLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: cursorAgentConfigurationDoc, }; +const openclawAdapter: ServerAdapterModule = { + type: "openclaw", + execute: openclawExecute, + testEnvironment: openclawTestEnvironment, + models: openclawModels, + supportsLocalAgentJwt: false, + agentConfigurationDoc: openclawAgentConfigurationDoc, +}; + const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -116,6 +133,7 @@ const adaptersByType = new Map( claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, + openclawAdapter, piLocalAdapter, cursorLocalAdapter, openclawGatewayAdapter, diff --git a/server/src/index.ts b/server/src/index.ts index 71992ce265..47dae4676b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,6 +20,7 @@ import { companyMemberships, instanceUserRoles, } from "@paperclipai/db"; +import type { Db } from "@paperclipai/db"; import detectPort from "detect-port"; import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; @@ -166,14 +167,14 @@ export async function startServer(): Promise { function isLoopbackHost(host: string): boolean { const normalized = host.trim().toLowerCase(); - return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; + return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1" || normalized === "0.0.0.0"; } const LOCAL_BOARD_USER_ID = "local-board"; const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; const LOCAL_BOARD_USER_NAME = "Board"; - async function ensureLocalTrustedBoardPrincipal(db: any): Promise { + async function ensureLocalTrustedBoardPrincipal(db: Db): Promise { const now = new Date(); const existingUser = await db .select({ id: authUsers.id }) diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 7f86dfd08e..83fe18efa4 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -11,13 +11,20 @@ export interface ErrorContext { reqQuery?: unknown; } +/** Extended Response with error context attached by the error handler */ +export interface ResponseWithErrorContext extends Response { + __errorContext?: ErrorContext; + err?: Error; +} + function attachErrorContext( req: Request, res: Response, payload: ErrorContext["error"], rawError?: Error, ) { - (res as any).__errorContext = { + const extRes = res as ResponseWithErrorContext; + extRes.__errorContext = { error: payload, method: req.method, url: req.originalUrl, @@ -26,7 +33,7 @@ function attachErrorContext( reqQuery: req.query, } satisfies ErrorContext; if (rawError) { - (res as any).err = rawError; + extRes.err = rawError; } } diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index be47e3c5ce..7e56c65dff 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -4,6 +4,7 @@ import pino from "pino"; import { pinoHttp } from "pino-http"; import { readConfigFile } from "../config-file.js"; import { resolveDefaultLogsDir, resolveHomeAwarePath } from "../home-paths.js"; +import type { ResponseWithErrorContext } from "./error-handler.js"; function resolveServerLogDir(): string { const envOverride = process.env.PAPERCLIP_LOG_DIR?.trim(); @@ -53,13 +54,15 @@ export const httpLogger = pinoHttp({ return `${req.method} ${req.url} ${res.statusCode}`; }, customErrorMessage(req, res, err) { - const ctx = (res as any).__errorContext; - const errMsg = ctx?.error?.message || err?.message || (res as any).err?.message || "unknown error"; + const extRes = res as ResponseWithErrorContext; + const ctx = extRes.__errorContext; + const errMsg = ctx?.error?.message || err?.message || extRes.err?.message || "unknown error"; return `${req.method} ${req.url} ${res.statusCode} — ${errMsg}`; }, customProps(req, res) { if (res.statusCode >= 400) { - const ctx = (res as any).__errorContext; + const extRes = res as ResponseWithErrorContext; + const ctx = extRes.__errorContext; if (ctx) { return { errorContext: ctx.error, @@ -69,8 +72,8 @@ export const httpLogger = pinoHttp({ }; } const props: Record = {}; - const { body, params, query } = req as any; - if (body && typeof body === "object" && Object.keys(body).length > 0) { + const { body, params, query } = req; + if (body && typeof body === "object" && Object.keys(body as Record).length > 0) { props.reqBody = body; } if (params && typeof params === "object" && Object.keys(params).length > 0) { @@ -79,8 +82,9 @@ export const httpLogger = pinoHttp({ if (query && typeof query === "object" && Object.keys(query).length > 0) { props.reqQuery = query; } - if ((req as any).route?.path) { - props.routePath = (req as any).route.path; + const routeReq = req as typeof req & { route?: { path?: string } }; + if (routeReq.route?.path) { + props.routePath = routeReq.route.path; } return props; } diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index c13366ffb6..4e0902726f 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -434,25 +434,11 @@ export function canReplayOpenClawGatewayInviteAccept(input: { "requestType" | "adapterType" | "status" > | null; }): boolean { - if ( - input.requestType !== "agent" || - input.adapterType !== "openclaw_gateway" - ) { - return false; - } - if (!input.existingJoinRequest) { - return false; - } - if ( - input.existingJoinRequest.requestType !== "agent" || - input.existingJoinRequest.adapterType !== "openclaw_gateway" - ) { - return false; - } - return ( - input.existingJoinRequest.status === "pending_approval" || - input.existingJoinRequest.status === "approved" - ); + if (input.requestType !== "agent" || input.adapterType !== "openclaw_gateway") return false; + if (!input.existingJoinRequest) return false; + const { requestType, adapterType, status } = input.existingJoinRequest; + if (requestType !== "agent" || adapterType !== "openclaw_gateway") return false; + return status === "pending_approval" || status === "approved"; } function summarizeSecretForLog( @@ -1508,7 +1494,7 @@ export function accessRoutes( async function assertCompanyPermission( req: Request, companyId: string, - permissionKey: any + permissionKey: (typeof PERMISSION_KEYS)[number] ) { assertCompanyAccess(req, companyId); if (req.actor.type === "agent") { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d150bb10ff..3dffa6467b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -340,22 +340,15 @@ export function agentRoutes(db: Db) { function redactRevisionSnapshot(snapshot: unknown): Record { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return {}; const record = snapshot as Record; + const safeRecord = (value: unknown): Record => + typeof value === "object" && value !== null ? (value as Record) : {}; return { ...record, - adapterConfig: redactEventPayload( - typeof record.adapterConfig === "object" && record.adapterConfig !== null - ? (record.adapterConfig as Record) - : {}, - ), - runtimeConfig: redactEventPayload( - typeof record.runtimeConfig === "object" && record.runtimeConfig !== null - ? (record.runtimeConfig as Record) - : {}, - ), - metadata: - typeof record.metadata === "object" && record.metadata !== null - ? redactEventPayload(record.metadata as Record) - : record.metadata ?? null, + adapterConfig: redactEventPayload(safeRecord(record.adapterConfig)), + runtimeConfig: redactEventPayload(safeRecord(record.runtimeConfig)), + metadata: record.metadata != null && typeof record.metadata === "object" + ? redactEventPayload(record.metadata as Record) + : record.metadata ?? null, }; } diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index fa65c7e43d..6e870cd917 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -69,21 +69,13 @@ function jsonEqual(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } +function sanitizeRecordField(value: unknown, fallback: Record | null = {}): Record | null { + return isPlainRecord(value) ? sanitizeRecord(value as Record) : fallback; +} + function buildConfigSnapshot( row: Pick, ): AgentConfigSnapshot { - const adapterConfig = - typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig) - ? sanitizeRecord(row.adapterConfig as Record) - : {}; - const runtimeConfig = - typeof row.runtimeConfig === "object" && row.runtimeConfig !== null && !Array.isArray(row.runtimeConfig) - ? sanitizeRecord(row.runtimeConfig as Record) - : {}; - const metadata = - typeof row.metadata === "object" && row.metadata !== null && !Array.isArray(row.metadata) - ? sanitizeRecord(row.metadata as Record) - : row.metadata ?? null; return { name: row.name, role: row.role, @@ -91,10 +83,10 @@ function buildConfigSnapshot( reportsTo: row.reportsTo, capabilities: row.capabilities, adapterType: row.adapterType, - adapterConfig, - runtimeConfig, + adapterConfig: sanitizeRecordField(row.adapterConfig) ?? {}, + runtimeConfig: sanitizeRecordField(row.runtimeConfig) ?? {}, budgetMonthlyCents: row.budgetMonthlyCents, - metadata, + metadata: sanitizeRecordField(row.metadata, row.metadata ?? null), }; } @@ -116,33 +108,36 @@ function diffConfigSnapshot( return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field])); } +function assertNonEmptyString(snapshot: Record, key: string): string { + const value = snapshot[key]; + if (typeof value !== "string" || value.length === 0) { + throw unprocessable(`Invalid revision snapshot: ${key}`); + } + return value; +} + +function asNullableString(value: unknown): string | null { + return typeof value === "string" || value === null ? (value as string | null) : null; +} + function configPatchFromSnapshot(snapshot: unknown): Partial { if (!isPlainRecord(snapshot)) throw unprocessable("Invalid revision snapshot"); - if (typeof snapshot.name !== "string" || snapshot.name.length === 0) { - throw unprocessable("Invalid revision snapshot: name"); - } - if (typeof snapshot.role !== "string" || snapshot.role.length === 0) { - throw unprocessable("Invalid revision snapshot: role"); - } - if (typeof snapshot.adapterType !== "string" || snapshot.adapterType.length === 0) { - throw unprocessable("Invalid revision snapshot: adapterType"); - } + const name = assertNonEmptyString(snapshot, "name"); + const role = assertNonEmptyString(snapshot, "role"); + const adapterType = assertNonEmptyString(snapshot, "adapterType"); + if (typeof snapshot.budgetMonthlyCents !== "number" || !Number.isFinite(snapshot.budgetMonthlyCents)) { throw unprocessable("Invalid revision snapshot: budgetMonthlyCents"); } return { - name: snapshot.name, - role: snapshot.role, - title: typeof snapshot.title === "string" || snapshot.title === null ? snapshot.title : null, - reportsTo: - typeof snapshot.reportsTo === "string" || snapshot.reportsTo === null ? snapshot.reportsTo : null, - capabilities: - typeof snapshot.capabilities === "string" || snapshot.capabilities === null - ? snapshot.capabilities - : null, - adapterType: snapshot.adapterType, + name, + role, + title: asNullableString(snapshot.title), + reportsTo: asNullableString(snapshot.reportsTo), + capabilities: asNullableString(snapshot.capabilities), + adapterType, adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {}, runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {}, budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)), diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index ac6de3639d..177e6da84c 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -246,6 +246,17 @@ function isEmptyObject(value: unknown): boolean { return isPlainRecord(value) && Object.keys(value).length === 0; } +function isYamlScalar(value: unknown): boolean { + return ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" || + (Array.isArray(value) && value.length === 0) || + isEmptyObject(value) + ); +} + function renderYamlBlock(value: unknown, indentLevel: number): string[] { const indent = " ".repeat(indentLevel); @@ -253,19 +264,12 @@ function renderYamlBlock(value: unknown, indentLevel: number): string[] { if (value.length === 0) return [`${indent}[]`]; const lines: string[] = []; for (const entry of value) { - const scalar = - entry === null || - typeof entry === "string" || - typeof entry === "boolean" || - typeof entry === "number" || - Array.isArray(entry) && entry.length === 0 || - isEmptyObject(entry); - if (scalar) { + if (isYamlScalar(entry)) { lines.push(`${indent}- ${renderYamlScalar(entry)}`); - continue; + } else { + lines.push(`${indent}-`); + lines.push(...renderYamlBlock(entry, indentLevel + 1)); } - lines.push(`${indent}-`); - lines.push(...renderYamlBlock(entry, indentLevel + 1)); } return lines; } @@ -275,19 +279,12 @@ function renderYamlBlock(value: unknown, indentLevel: number): string[] { if (entries.length === 0) return [`${indent}{}`]; const lines: string[] = []; for (const [key, entry] of entries) { - const scalar = - entry === null || - typeof entry === "string" || - typeof entry === "boolean" || - typeof entry === "number" || - Array.isArray(entry) && entry.length === 0 || - isEmptyObject(entry); - if (scalar) { + if (isYamlScalar(entry)) { lines.push(`${indent}${key}: ${renderYamlScalar(entry)}`); - continue; + } else { + lines.push(`${indent}${key}:`); + lines.push(...renderYamlBlock(entry, indentLevel + 1)); } - lines.push(`${indent}${key}:`); - lines.push(...renderYamlBlock(entry, indentLevel + 1)); } return lines; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index dbba40b2e1..b9b0047b10 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -100,50 +100,27 @@ export function resolveRuntimeSessionParamsForWorkspace(input: { resolvedWorkspace: ResolvedWorkspaceForRun; }) { const { agentId, previousSessionParams, resolvedWorkspace } = input; + const unchanged = { sessionParams: previousSessionParams, warning: null as string | null }; + const previousSessionId = readNonEmptyString(previousSessionParams?.sessionId); const previousCwd = readNonEmptyString(previousSessionParams?.cwd); - if (!previousSessionId || !previousCwd) { - return { - sessionParams: previousSessionParams, - warning: null as string | null, - }; - } - if (resolvedWorkspace.source !== "project_primary") { - return { - sessionParams: previousSessionParams, - warning: null as string | null, - }; - } + if (!previousSessionId || !previousCwd) return unchanged; + if (resolvedWorkspace.source !== "project_primary") return unchanged; + const projectCwd = readNonEmptyString(resolvedWorkspace.cwd); - if (!projectCwd) { - return { - sessionParams: previousSessionParams, - warning: null as string | null, - }; - } + if (!projectCwd) return unchanged; + const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId); - if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) { - return { - sessionParams: previousSessionParams, - warning: null as string | null, - }; - } - if (path.resolve(projectCwd) === path.resolve(previousCwd)) { - return { - sessionParams: previousSessionParams, - warning: null as string | null, - }; - } + if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) return unchanged; + if (path.resolve(projectCwd) === path.resolve(previousCwd)) return unchanged; + const previousWorkspaceId = readNonEmptyString(previousSessionParams?.workspaceId); if ( previousWorkspaceId && resolvedWorkspace.workspaceId && previousWorkspaceId !== resolvedWorkspace.workspaceId ) { - return { - sessionParams: previousSessionParams, - warning: null as string | null, - }; + return unchanged; } const migratedSessionParams: Record = { @@ -236,6 +213,17 @@ function deriveCommentId( ); } +/** Set a key on contextSnapshot only if it has no existing non-empty value. */ +function setIfMissing( + ctx: Record, + key: string, + value: string | null | undefined, +) { + if (value && !readNonEmptyString(ctx[key])) { + ctx[key] = value; + } +} + function enrichWakeContextSnapshot(input: { contextSnapshot: Record; reason: string | null; @@ -249,30 +237,14 @@ function enrichWakeContextSnapshot(input: { const taskKey = deriveTaskKey(contextSnapshot, payload); const wakeCommentId = deriveCommentId(contextSnapshot, payload); - if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) { - contextSnapshot.wakeReason = reason; - } - if (!readNonEmptyString(contextSnapshot["issueId"]) && issueIdFromPayload) { - contextSnapshot.issueId = issueIdFromPayload; - } - if (!readNonEmptyString(contextSnapshot["taskId"]) && issueIdFromPayload) { - contextSnapshot.taskId = issueIdFromPayload; - } - if (!readNonEmptyString(contextSnapshot["taskKey"]) && taskKey) { - contextSnapshot.taskKey = taskKey; - } - if (!readNonEmptyString(contextSnapshot["commentId"]) && commentIdFromPayload) { - contextSnapshot.commentId = commentIdFromPayload; - } - if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) { - contextSnapshot.wakeCommentId = wakeCommentId; - } - if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) { - contextSnapshot.wakeSource = source; - } - if (!readNonEmptyString(contextSnapshot["wakeTriggerDetail"]) && triggerDetail) { - contextSnapshot.wakeTriggerDetail = triggerDetail; - } + setIfMissing(contextSnapshot, "wakeReason", reason); + setIfMissing(contextSnapshot, "issueId", issueIdFromPayload); + setIfMissing(contextSnapshot, "taskId", issueIdFromPayload); + setIfMissing(contextSnapshot, "taskKey", taskKey); + setIfMissing(contextSnapshot, "commentId", commentIdFromPayload); + setIfMissing(contextSnapshot, "wakeCommentId", wakeCommentId); + setIfMissing(contextSnapshot, "wakeSource", source); + setIfMissing(contextSnapshot, "wakeTriggerDetail", triggerDetail); return { contextSnapshot, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index cb258e23f2..68c979f193 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -215,7 +215,7 @@ export function deriveIssueUserContext( }; } -async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise> { +async function labelMapForIssues(dbOrTx: Db, issueIds: string[]): Promise> { const map = new Map(); if (issueIds.length === 0) return map; const rows = await dbOrTx @@ -236,7 +236,7 @@ async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise { +async function withIssueLabels(dbOrTx: Db, rows: IssueRow[]): Promise { if (rows.length === 0) return []; const labelsByIssueId = await labelMapForIssues(dbOrTx, rows.map((row) => row.id)); return rows.map((row) => { @@ -252,7 +252,7 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise> { const map = new Map(); @@ -309,15 +309,13 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (!assignee) throw notFound("Assignee agent not found"); - if (assignee.companyId !== companyId) { - throw unprocessable("Assignee must belong to same company"); - } - if (assignee.status === "pending_approval") { - throw conflict("Cannot assign work to pending approval agents"); - } - if (assignee.status === "terminated") { - throw conflict("Cannot assign work to terminated agents"); - } + if (assignee.companyId !== companyId) throw unprocessable("Assignee must belong to same company"); + const blockedStatuses: Record = { + pending_approval: "Cannot assign work to pending approval agents", + terminated: "Cannot assign work to terminated agents", + }; + const blockedMsg = blockedStatuses[assignee.status]; + if (blockedMsg) throw conflict(blockedMsg); } async function assertAssignableUser(companyId: string, userId: string) { @@ -338,7 +336,7 @@ export function issueService(db: Db) { } } - async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) { + async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: Db = db) { if (labelIds.length === 0) return; const existing = await dbOrTx .select({ id: labels.id }) @@ -353,7 +351,7 @@ export function issueService(db: Db) { issueId: string, companyId: string, labelIds: string[], - dbOrTx: any = db, + dbOrTx: Db = db, ) { const deduped = [...new Set(labelIds)]; await assertValidLabelIds(companyId, deduped, dbOrTx); @@ -644,16 +642,10 @@ export function issueService(db: Db) { const issueNumber = company.issueCounter; const identifier = `${company.issuePrefix}-${issueNumber}`; - const values = { ...issueData, companyId, issueNumber, identifier } as typeof issues.$inferInsert; - if (values.status === "in_progress" && !values.startedAt) { - values.startedAt = new Date(); - } - if (values.status === "done") { - values.completedAt = new Date(); - } - if (values.status === "cancelled") { - values.cancelledAt = new Date(); - } + const values = applyStatusSideEffects( + issueData.status, + { ...issueData, companyId, issueNumber, identifier } as Partial, + ) as typeof issues.$inferInsert; const [issue] = await tx.insert(issues).values(values).returning(); if (inputLabelIds) { diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 54d5cd8230..b91cd69dfb 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -230,7 +230,7 @@ export function resolveProjectNameForUniqueShortname( } async function ensureSinglePrimaryWorkspace( - dbOrTx: any, + dbOrTx: Db, input: { companyId: string; projectId: string; diff --git a/tsconfig.json b/tsconfig.json index 3a989f389d..38890b2670 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "./packages/adapters/claude-local" }, { "path": "./packages/adapters/codex-local" }, { "path": "./packages/adapters/cursor-local" }, + { "path": "./packages/adapters/openclaw" }, { "path": "./packages/adapters/openclaw-gateway" }, { "path": "./packages/adapters/opencode-local" }, { "path": "./packages/adapters/pi-local" }, diff --git a/ui/package.json b/ui/package.json index e34b0d501f..5be1e3d204 100644 --- a/ui/package.json +++ b/ui/package.json @@ -18,6 +18,7 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", + "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", diff --git a/ui/src/adapters/openclaw/config-fields.tsx b/ui/src/adapters/openclaw/config-fields.tsx new file mode 100644 index 0000000000..abad6b12b3 --- /dev/null +++ b/ui/src/adapters/openclaw/config-fields.tsx @@ -0,0 +1,53 @@ +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"; + +export function OpenClawConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + return ( + <> + + + isCreate + ? set!({ url: v }) + : mark("adapterConfig", "url", v || undefined) + } + immediate + className={inputClass} + placeholder="https://..." + /> + + {!isCreate && ( + + mark("adapterConfig", "webhookAuthHeader", v || undefined)} + immediate + className={inputClass} + placeholder="Bearer " + /> + + )} + + ); +} diff --git a/ui/src/adapters/openclaw/index.ts b/ui/src/adapters/openclaw/index.ts new file mode 100644 index 0000000000..890d83bc89 --- /dev/null +++ b/ui/src/adapters/openclaw/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; +import { buildOpenClawConfig } from "@paperclipai/adapter-openclaw/ui"; +import { OpenClawConfigFields } from "./config-fields"; + +export const openClawUIAdapter: UIAdapterModule = { + type: "openclaw", + label: "OpenClaw", + parseStdoutLine: parseOpenClawStdoutLine, + ConfigFields: OpenClawConfigFields, + buildAdapterConfig: buildOpenClawConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 1a36af6b86..36f15f554b 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { openCodeLocalUIAdapter } from "./opencode-local"; +import { openClawUIAdapter } from "./openclaw"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; @@ -13,6 +14,7 @@ const adaptersByType = new Map( claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, + openClawUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, openClawGatewayUIAdapter, diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index b49b2b1034..635d6b3e16 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -18,6 +18,7 @@ const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", opencode_local: "OpenCode (local)", + openclaw: "OpenClaw (webhook)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx index 2a1e6e8227..313827f875 100644 --- a/ui/src/components/ApprovalCard.tsx +++ b/ui/src/components/ApprovalCard.tsx @@ -6,12 +6,18 @@ import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from ". import { timeAgo } from "../lib/timeAgo"; import type { Approval, Agent } from "@paperclipai/shared"; +const STATUS_ICON_MAP: Record = { + approved: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" }, + rejected: { icon: XCircle, color: "text-red-600 dark:text-red-400" }, + revision_requested: { icon: Clock, color: "text-amber-600 dark:text-amber-400" }, + pending: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" }, +}; + function statusIcon(status: string) { - if (status === "approved") return ; - if (status === "rejected") return ; - if (status === "revision_requested") return ; - if (status === "pending") return ; - return null; + const entry = STATUS_ICON_MAP[status]; + if (!entry) return null; + const Icon = entry.icon; + return ; } export function ApprovalCard({ diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 10d0709b9d..f860ffd52a 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -119,12 +119,8 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { } function countActiveFilters(state: IssueViewState): number { - let count = 0; - if (state.statuses.length > 0) count++; - if (state.priorities.length > 0) count++; - if (state.assignees.length > 0) count++; - if (state.labels.length > 0) count++; - return count; + return [state.statuses, state.priorities, state.assignees, state.labels] + .filter((arr) => arr.length > 0).length; } /* ── Component ── */ diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 8e5c7050da..c4f5572717 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -24,7 +24,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 Gateway, spawned process, or generic HTTP webhook.", + adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, Lionroot OpenClaw webhook, 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 = { claude_local: "Claude (local)", codex_local: "Codex (local)", opencode_local: "OpenCode (local)", + openclaw: "OpenClaw (webhook)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", diff --git a/vitest.config.ts b/vitest.config.ts index 9bf83928f5..4a0de6b08c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"], + projects: ["packages/db", "packages/adapters/openclaw", "packages/adapters/opencode-local", "server", "ui", "cli"], }, });