diff --git a/.env.example b/.env.example index 8842418b..1c32fad8 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789 # CLAW3D_GATEWAY_URL=ws://localhost:18789 # CLAW3D_GATEWAY_TOKEN= # Optional: tell Studio which backend that runtime gateway URL represents. -# Valid values: openclaw, hermes, demo, custom +# Valid values: openclaw, hermes, paperclip, demo, custom # CLAW3D_GATEWAY_ADAPTER_TYPE=openclaw @@ -44,6 +44,17 @@ DEBUG=true # DEMO_ADAPTER_PORT=18789 # If CLAW3D_GATEWAY_URL is unset, Studio can still detect this local adapter port. +# Paperclip adapter +# Run `npm run paperclip-adapter` and connect Claw3D to ws://localhost:18791 +# PAPERCLIP_API_URL=http://localhost:3001 +# PAPERCLIP_API_KEY= +# PAPERCLIP_COMPANY_ID= +# PAPERCLIP_ADAPTER_PORT=18791 +# PAPERCLIP_DEFAULT_MODEL=paperclip/default +# PAPERCLIP_AGENT_POLL_LIMIT=100 +# PAPERCLIP_AGENT_LABEL=Paperclip Agent +# If CLAW3D_GATEWAY_URL is unset, Studio can still detect this local adapter port. + # Optional: voice features # ELEVENLABS_API_KEY= # ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM diff --git a/README.md b/README.md index 6eb81935..555c0f01 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Today it can sit on top of: - OpenClaw through the existing gateway flow - Hermes through the bundled WebSocket adapter +- Paperclip through the bundled WebSocket adapter - a direct HTTP `custom` runtime provider for orchestrator-backed stacks - a built-in demo gateway for office exploration without a real agent framework @@ -100,7 +101,7 @@ npm run dev ``` Then open `http://localhost:3000` and configure the gateway URL and token in Studio. -Studio now also persists the selected backend mode (`OpenClaw`, `Hermes`, `Demo`, or `Custom`) and +Studio now also persists the selected backend mode (`OpenClaw`, `Hermes`, `Paperclip`, `Demo`, or `Custom`) and shows the active backend reported by the connected gateway. ### Custom runtime mode @@ -168,6 +169,25 @@ ws://localhost:18789 In the connect screen, choose `Hermes backend`, then connect. +### Paperclip adapter + +If you want to use Paperclip instead of OpenClaw: + +```bash +npm run paperclip-adapter +npm run dev +``` + +For a local adapter on the same machine, the default upstream URL is: + +```text +ws://localhost:18791 +``` + +In the connect screen, choose `Paperclip backend`, then connect. + +See [`docs/paperclip-gateway.md`](docs/paperclip-gateway.md) for setup details and adapter scope. + ## How It Connects Claw3D uses two separate network hops: @@ -242,8 +262,8 @@ Common environment variables: - `CUSTOM_RUNTIME_ALLOWLIST` restricts which hosts `/api/runtime/custom` may fetch. If unset, it falls back to `UPSTREAM_ALLOWLIST`. - `NEXT_PUBLIC_GATEWAY_URL` provides the default upstream gateway URL when Studio settings are empty. **Note:** this is a build-time variable — changes require `npm run build` to take effect. - `CLAW3D_GATEWAY_URL` and `CLAW3D_GATEWAY_TOKEN` provide a runtime alternative to `NEXT_PUBLIC_GATEWAY_URL` that takes effect on server restart without a rebuild. -- `CLAW3D_GATEWAY_ADAPTER_TYPE` can pair with `CLAW3D_GATEWAY_URL` to mark those runtime defaults as `openclaw`, `hermes`, `demo`, or `custom`. -- If `CLAW3D_GATEWAY_URL` is not set, Studio can still surface local Hermes or demo adapter defaults from `HERMES_ADAPTER_PORT` / `DEMO_ADAPTER_PORT`. +- `CLAW3D_GATEWAY_ADAPTER_TYPE` can pair with `CLAW3D_GATEWAY_URL` to mark those runtime defaults as `openclaw`, `hermes`, `paperclip`, `demo`, or `custom`. +- If `CLAW3D_GATEWAY_URL` is not set, Studio can still surface local Hermes, Paperclip, or demo adapter defaults from `HERMES_ADAPTER_PORT` / `PAPERCLIP_ADAPTER_PORT` / `DEMO_ADAPTER_PORT`. - OpenClaw file defaults still come from `~/.openclaw/openclaw.json` when present. - `OPENCLAW_STATE_DIR` and `OPENCLAW_CONFIG_PATH` override the default OpenClaw paths. - `OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`, `OPENCLAW_GATEWAY_SSH_PORT`, and `OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING` support advanced gateway-host operations over SSH when needed. @@ -255,6 +275,7 @@ See [`.env.example`](.env.example) for the full local development template. - `npm run dev` starts the Studio dev server. - `npm run hermes-adapter` starts the Hermes WebSocket adapter. +- `npm run paperclip-adapter` starts the Paperclip WebSocket adapter. - `npm run demo-gateway` starts the built-in mock gateway for demo mode. - `npm run build` builds the production Next.js app. - `npm run start` starts the production server. @@ -278,6 +299,7 @@ See [`.env.example`](.env.example) for the full local development template. - [`docs/pi-chat-streaming.md`](docs/pi-chat-streaming.md): gateway runtime streaming and transcript rendering. - [`docs/permissions-sandboxing.md`](docs/permissions-sandboxing.md): Studio permissions and OpenClaw behavior. - [`docs/hermes-gateway.md`](docs/hermes-gateway.md): Hermes adapter setup, capabilities, and current limitations. +- [`docs/paperclip-gateway.md`](docs/paperclip-gateway.md): Paperclip adapter setup, event mapping, and current limitations. ## Current Limitations @@ -291,7 +313,7 @@ If the UI loads but Connect fails, the problem is usually on the Studio -> Gatew - Confirm the upstream URL and token in Studio settings. - `EPROTO` or `wrong version number` usually means `wss://` was used against a non-TLS endpoint. -- `INVALID_REQUEST` errors mentioning `minProtocol` or `maxProtocol` usually mean the gateway is too old for Claw3D protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway`. +- `INVALID_REQUEST` errors mentioning `minProtocol` or `maxProtocol` usually mean the gateway is too old for Claw3D protocol v3. Upgrade OpenClaw, use the Hermes/Paperclip adapters, or run `npm run demo-gateway`. - `401 Studio access token required` usually means `STUDIO_ACCESS_TOKEN` is enabled and the request is missing the expected `studio_access` cookie. - If `/api/runtime/custom` returns a blocked-host error in production, set `CUSTOM_RUNTIME_ALLOWLIST` or include the runtime host in `UPSTREAM_ALLOWLIST`. - Helpful proxy error codes include `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, and `studio.upstream_closed`. diff --git a/docs/hermes-gateway.md b/docs/hermes-gateway.md index 2890606d..43d30052 100644 --- a/docs/hermes-gateway.md +++ b/docs/hermes-gateway.md @@ -121,6 +121,8 @@ This branch does not replace the adapter with ACP yet. The current production-ready path uses the adapter because it works with the existing Claw3D gateway contract today and is ready for upstream testing now. +For the Paperclip adapter path, see [`docs/paperclip-gateway.md`](./paperclip-gateway.md). + The runtime seam added in Studio is what makes an ACP-backed Hermes provider feasible as a follow-up without reworking the whole UI again. diff --git a/docs/paperclip-gateway.md b/docs/paperclip-gateway.md new file mode 100644 index 00000000..3ed01fd5 --- /dev/null +++ b/docs/paperclip-gateway.md @@ -0,0 +1,59 @@ +# Paperclip Gateway Adapter + +Claw3D can run against Paperclip through the bundled gateway adapter in +[`server/paperclip-gateway-adapter.js`](../server/paperclip-gateway-adapter.js). + +## Architecture + +```text +Browser UI <-> Studio runtime/client <-> Paperclip gateway adapter <-> Paperclip REST API +``` + +The frontend keeps speaking the Claw3D gateway protocol while the adapter +translates into Paperclip REST calls and emits compatible `chat` and +`presence` events. + +## Quick start + +### 1. Configure environment + +Copy `.env.example` to `.env` and set Paperclip values: + +```env +PAPERCLIP_API_URL=http://localhost:3001 +PAPERCLIP_API_KEY= +PAPERCLIP_COMPANY_ID= +PAPERCLIP_ADAPTER_PORT=18791 +PAPERCLIP_DEFAULT_MODEL=paperclip/default +``` + +### 2. Start Claw3D and adapter + +In separate terminals: + +```bash +npm run paperclip-adapter +npm run dev +``` + +Then open `[REDACTED]`, choose `Paperclip backend`, and connect to: + +```text +ws://localhost:18791 +``` + +## Supported gateway surfaces + +The adapter currently provides: + +- `agents.list` +- `sessions.list` +- `status` +- `chat.send` +- `chat.abort` +- `chat.history` (session-local memory) +- `models.list` + +Write-heavy config/task/cron operations are currently returned as +`not_implemented` so the UI can show graceful errors rather than silently +failing. diff --git a/package.json b/package.json index c16d970f..b1d6d0f1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "node server/index.js --dev", "dev:https": "node server/index.js --dev --https", "hermes-adapter": "node server/hermes-gateway-adapter.js", + "paperclip-adapter": "node server/paperclip-gateway-adapter.js", "demo-gateway": "node server/demo-gateway-adapter.js", "build": "next build", "start": "node server/index.js", diff --git a/server/paperclip-gateway-adapter.js b/server/paperclip-gateway-adapter.js new file mode 100644 index 00000000..5a338e11 --- /dev/null +++ b/server/paperclip-gateway-adapter.js @@ -0,0 +1,687 @@ +"use strict"; + +const fs = require("node:fs"); +const http = require("node:http"); +const https = require("node:https"); +const path = require("node:path"); +const { randomUUID } = require("node:crypto"); +const { WebSocketServer } = require("ws"); + +function loadDotenvFile(filePath) { + if (!fs.existsSync(filePath)) return; + const content = fs.readFileSync(filePath, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (!match) continue; + const [, key, rawValue] = match; + if (process.env[key] !== undefined) continue; + let value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} + +function loadRuntimeEnv() { + const cwd = process.cwd(); + loadDotenvFile(path.join(cwd, ".env.local")); + loadDotenvFile(path.join(cwd, ".env")); +} + +loadRuntimeEnv(); + +const PAPERCLIP_API_URL = (process.env.PAPERCLIP_API_URL || "http://localhost:3011").replace(/\/$/, ""); +const PAPERCLIP_API_KEY = process.env.PAPERCLIP_API_KEY || ""; +const PAPERCLIP_COMPANY_ID = process.env.PAPERCLIP_COMPANY_ID || ""; +const PAPERCLIP_DEFAULT_MODEL = process.env.PAPERCLIP_DEFAULT_MODEL || "paperclip/default"; +const PAPERCLIP_AGENT_POLL_LIMIT = Number.parseInt(process.env.PAPERCLIP_AGENT_POLL_LIMIT || "12", 10); +const PAPERCLIP_AGENT_LABEL = process.env.PAPERCLIP_AGENT_LABEL || "Paperclip"; +const ADAPTER_PORT = Number.parseInt(process.env.PAPERCLIP_ADAPTER_PORT || "18791", 10); + +const MAIN_AGENT_ID = "paperclip"; +const MAIN_KEY = "main"; +const MAIN_SESSION_KEY = `agent:${MAIN_AGENT_ID}:${MAIN_KEY}`; + +const activeSendEventFns = new Set(); +const activeRuns = new Map(); +const recentMessagesBySession = new Map(); +const lastRunSnapshotBySession = new Map(); +let globalSeq = 0; + +function randomId() { + return randomUUID().replace(/-/g, ""); +} + +function sanitizeErrorMessage(error) { + if (!error) return "Unknown error"; + const message = typeof error === "string" ? error : error.message || String(error); + if (!PAPERCLIP_API_KEY) return message; + return message.split(PAPERCLIP_API_KEY).join("[REDACTED]"); +} + +function readJsonBody(res) { + return new Promise((resolve, reject) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + res.on("error", reject); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8").trim(); + if (!raw) { + resolve({}); + return; + } + try { + resolve(JSON.parse(raw)); + } catch (error) { + reject(error); + } + }); + }); +} + +function paperclipRequest(method, routePath, body = null) { + return new Promise((resolve, reject) => { + const url = new URL(`${PAPERCLIP_API_URL}${routePath}`); + const transport = url.protocol === "https:" ? https : http; + const headers = { + Accept: "application/json", + }; + let bodyString = ""; + if (body !== null) { + bodyString = JSON.stringify(body); + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = Buffer.byteLength(bodyString); + } + if (PAPERCLIP_API_KEY) { + headers.Authorization = `Bearer ${PAPERCLIP_API_KEY}`; + } + + const req = transport.request( + { + hostname: url.hostname, + port: url.port + ? Number.parseInt(url.port, 10) + : url.protocol === "https:" + ? 443 + : 80, + path: `${url.pathname}${url.search}`, + method, + headers, + }, + async (res) => { + try { + const payload = await readJsonBody(res); + if ((res.statusCode || 500) >= 400) { + const message = + typeof payload?.error?.message === "string" + ? payload.error.message + : `Paperclip API HTTP ${res.statusCode}`; + reject(new Error(message)); + return; + } + resolve(payload); + } catch (error) { + reject(error); + } + } + ); + req.on("error", reject); + if (bodyString) { + req.write(bodyString); + } + req.end(); + }); +} + +async function resolveCompanyContext() { + if (PAPERCLIP_COMPANY_ID) { + return PAPERCLIP_COMPANY_ID; + } + const payload = await paperclipRequest("GET", "/api/companies"); + const companies = Array.isArray(payload?.companies) + ? payload.companies + : Array.isArray(payload?.data) + ? payload.data + : []; + const first = companies.find((entry) => typeof entry?.id === "string"); + if (!first) throw new Error("No Paperclip company available."); + return first.id; +} + +function normalizeAgent(agent) { + const id = typeof agent?.id === "string" ? agent.id : ""; + const name = typeof agent?.name === "string" && agent.name.trim() ? agent.name.trim() : id || "Agent"; + const role = typeof agent?.role === "string" ? agent.role.trim() : "Paperclip Agent"; + return { id, name, role }; +} + +async function fetchCompanyAgents(companyId) { + const payload = await paperclipRequest("GET", `/api/companies/${encodeURIComponent(companyId)}/agents`); + const items = Array.isArray(payload?.agents) + ? payload.agents + : Array.isArray(payload?.data) + ? payload.data + : []; + return items.map(normalizeAgent).filter((entry) => entry.id); +} + +async function listHeartbeatRuns(companyId) { + const payload = await paperclipRequest( + "GET", + `/api/companies/${encodeURIComponent(companyId)}/heartbeats?limit=${encodeURIComponent(String(PAPERCLIP_AGENT_POLL_LIMIT))}` + ); + const items = Array.isArray(payload?.runs) + ? payload.runs + : Array.isArray(payload?.data) + ? payload.data + : []; + return items; +} + +async function getRunById(companyId, runId) { + return await paperclipRequest( + "GET", + `/api/companies/${encodeURIComponent(companyId)}/heartbeats/${encodeURIComponent(runId)}` + ); +} + +function summarizeRunText(run) { + const summary = + typeof run?.summary === "string" + ? run.summary + : typeof run?.output === "string" + ? run.output + : typeof run?.message === "string" + ? run.message + : ""; + return summary.trim(); +} + +function ensureSessionHistory(sessionKey) { + if (!recentMessagesBySession.has(sessionKey)) { + recentMessagesBySession.set(sessionKey, []); + } + return recentMessagesBySession.get(sessionKey); +} + +function pushSessionMessage(sessionKey, message) { + const history = ensureSessionHistory(sessionKey); + history.push(message); + if (history.length > 120) { + history.splice(0, history.length - 120); + } +} + +function resOk(id, payload) { + return { type: "res", id, ok: true, payload: payload ?? {} }; +} + +function resErr(id, code, message) { + return { type: "res", id, ok: false, error: { code, message } }; +} + +function broadcastEvent(frame) { + for (const send of activeSendEventFns) { + try { + send(frame); + } catch {} + } +} + +function buildStatusSnapshot(agentIds) { + const now = Date.now(); + const recent = []; + const byAgent = []; + for (const agentId of agentIds) { + const sessionKey = `agent:${agentId}:${MAIN_KEY}`; + const snapshot = lastRunSnapshotBySession.get(sessionKey); + if (!snapshot) { + byAgent.push({ agentId, recent: [] }); + continue; + } + const item = { key: sessionKey, updatedAt: snapshot.updatedAtMs || now }; + recent.push(item); + byAgent.push({ agentId, recent: [item] }); + } + return { + sessions: { + recent, + byAgent, + }, + }; +} + +async function handleMethod(method, params, id, sendEvent, context) { + const p = params || {}; + const companyId = context.companyId; + + switch (method) { + case "agents.list": { + const agents = await fetchCompanyAgents(companyId); + const defaultAgent = agents[0] || { id: MAIN_AGENT_ID, name: PAPERCLIP_AGENT_LABEL, role: "Paperclip" }; + return resOk(id, { + defaultId: defaultAgent.id, + mainKey: MAIN_KEY, + agents: agents.map((agent) => ({ + id: agent.id, + name: agent.name, + workspace: `/paperclip/${agent.id}`, + identity: { name: agent.name, emoji: "📎" }, + role: agent.role || "Paperclip Agent", + })), + }); + } + + case "agents.create": + case "agents.update": + case "agents.delete": + return resErr(id, "not_implemented", `${method} is not implemented for Paperclip adapter.`); + + case "sessions.list": { + const agents = await fetchCompanyAgents(companyId); + const sessions = agents.map((agent) => { + const key = `agent:${agent.id}:${MAIN_KEY}`; + const snapshot = lastRunSnapshotBySession.get(key); + return { + key, + agentId: agent.id, + updatedAt: snapshot?.updatedAtMs ?? null, + displayName: "Main", + origin: { label: agent.name, provider: "paperclip" }, + model: snapshot?.model ?? PAPERCLIP_DEFAULT_MODEL, + modelProvider: "paperclip", + }; + }); + return resOk(id, { sessions }); + } + + case "sessions.preview": { + const keys = Array.isArray(p.keys) ? p.keys : []; + const limit = typeof p.limit === "number" ? p.limit : 8; + const maxChars = typeof p.maxChars === "number" ? p.maxChars : 240; + const previews = keys.map((key) => { + const history = ensureSessionHistory(key); + if (history.length === 0) return { key, status: "empty", items: [] }; + const items = history.slice(-limit).map((entry) => ({ + role: entry.role === "assistant" ? "assistant" : "user", + text: String(entry.content || "").slice(0, maxChars), + timestamp: entry.timestamp || Date.now(), + })); + return { key, status: "ok", items }; + }); + return resOk(id, { ts: Date.now(), previews }); + } + + case "sessions.patch": { + const key = typeof p.key === "string" ? p.key : MAIN_SESSION_KEY; + const snapshot = lastRunSnapshotBySession.get(key) || {}; + if (p.model !== undefined) { + snapshot.model = p.model || PAPERCLIP_DEFAULT_MODEL; + } + if (p.thinkingLevel !== undefined) { + snapshot.thinkingLevel = p.thinkingLevel || null; + } + snapshot.updatedAtMs = Date.now(); + lastRunSnapshotBySession.set(key, snapshot); + return resOk(id, { + ok: true, + key, + entry: { thinkingLevel: snapshot.thinkingLevel ?? null }, + resolved: { model: snapshot.model || PAPERCLIP_DEFAULT_MODEL, modelProvider: "paperclip" }, + }); + } + + case "sessions.reset": { + const key = typeof p.key === "string" ? p.key : MAIN_SESSION_KEY; + recentMessagesBySession.set(key, []); + return resOk(id, { ok: true }); + } + + case "chat.send": { + const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey : MAIN_SESSION_KEY; + const parts = sessionKey.split(":"); + const agentId = parts.length >= 2 ? parts[1] : MAIN_AGENT_ID; + const runId = typeof p.idempotencyKey === "string" && p.idempotencyKey ? p.idempotencyKey : randomId(); + const message = typeof p.message === "string" ? p.message.trim() : String(p.message || "").trim(); + if (!message) return resOk(id, { status: "no-op", runId }); + + pushSessionMessage(sessionKey, { role: "user", content: message, timestamp: Date.now() }); + + let aborted = false; + activeRuns.set(runId, { + runId, + sessionKey, + abort() { + aborted = true; + }, + }); + + setImmediate(async () => { + let seq = 0; + const emitChat = (state, extra) => { + sendEvent({ + type: "event", + event: "chat", + seq: seq++, + payload: { + runId, + sessionKey, + state, + ...extra, + }, + }); + }; + + emitChat("delta", { message: { role: "assistant", content: "Working with Paperclip..." } }); + + try { + const created = await paperclipRequest( + "POST", + `/api/companies/${encodeURIComponent(companyId)}/heartbeats`, + { agentId, message } + ); + const createdRunId = + typeof created?.run?.id === "string" + ? created.run.id + : typeof created?.id === "string" + ? created.id + : null; + const runPayload = createdRunId + ? await getRunById(companyId, createdRunId) + : { run: { summary: `Paperclip accepted: ${message}` } }; + const run = runPayload?.run || runPayload; + const output = summarizeRunText(run) || "Paperclip run finished."; + if (aborted) { + emitChat("aborted", {}); + return; + } + + pushSessionMessage(sessionKey, { role: "assistant", content: output, timestamp: Date.now() }); + lastRunSnapshotBySession.set(sessionKey, { + model: PAPERCLIP_DEFAULT_MODEL, + thinkingLevel: null, + updatedAtMs: Date.now(), + runId: createdRunId || runId, + }); + + emitChat("final", { + stopReason: "end_turn", + message: { role: "assistant", content: output }, + }); + + sendEvent({ + type: "event", + event: "presence", + seq: seq++, + payload: { + sessions: { + recent: [{ key: sessionKey, updatedAt: Date.now() }], + byAgent: [{ agentId, recent: [{ key: sessionKey, updatedAt: Date.now() }] }], + }, + }, + }); + } catch (error) { + if (aborted) { + emitChat("aborted", {}); + return; + } + emitChat("error", { + errorMessage: sanitizeErrorMessage(error), + }); + } finally { + activeRuns.delete(runId); + } + }); + + return resOk(id, { status: "started", runId }); + } + + case "chat.abort": { + const runId = typeof p.runId === "string" ? p.runId.trim() : ""; + const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey.trim() : ""; + let aborted = 0; + if (runId) { + const handle = activeRuns.get(runId); + if (handle) { + handle.abort(); + activeRuns.delete(runId); + aborted += 1; + } + } else if (sessionKey) { + for (const [activeRunId, handle] of activeRuns.entries()) { + if (handle.sessionKey !== sessionKey) continue; + handle.abort(); + activeRuns.delete(activeRunId); + aborted += 1; + } + } + return resOk(id, { ok: true, aborted }); + } + + case "chat.history": { + const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey : MAIN_SESSION_KEY; + const messages = ensureSessionHistory(sessionKey).map((entry) => ({ + role: entry.role, + content: entry.content, + })); + return resOk(id, { sessionKey, messages }); + } + + case "agent.wait": { + const runId = typeof p.runId === "string" ? p.runId : ""; + const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 30000; + const start = Date.now(); + while (activeRuns.has(runId) && Date.now() - start < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 80)); + } + return resOk(id, { status: activeRuns.has(runId) ? "running" : "done" }); + } + + case "status": { + const agents = await fetchCompanyAgents(companyId); + return resOk(id, buildStatusSnapshot(agents.map((agent) => agent.id))); + } + + case "models.list": + return resOk(id, { + models: [{ id: PAPERCLIP_DEFAULT_MODEL, name: PAPERCLIP_DEFAULT_MODEL, provider: "paperclip" }], + }); + + case "skills.status": + return resOk(id, { skills: [] }); + + case "config.get": + return resOk(id, { + config: { gateway: { reload: { mode: "hot" } } }, + hash: "paperclip-adapter", + exists: true, + path: "/paperclip/config.json", + }); + + case "config.set": + case "config.patch": + return resOk(id, { hash: "paperclip-adapter" }); + + case "exec.approvals.get": + return resOk(id, { + path: "", + exists: true, + hash: "paperclip-approvals", + file: { version: 1, defaults: { security: "full", ask: "off", autoAllowSkills: true }, agents: {} }, + }); + + case "exec.approvals.set": + case "exec.approval.resolve": + return resOk(id, { ok: true }); + + case "cron.list": + return resOk(id, { jobs: [] }); + + case "cron.add": + case "cron.patch": + case "cron.remove": + case "cron.run": + case "tasks.list": + return resErr(id, "not_implemented", `${method} is not implemented for Paperclip adapter.`); + + case "wake": { + const runs = await listHeartbeatRuns(companyId); + const latest = runs[0] || null; + return resOk(id, { + ok: true, + latestRunId: typeof latest?.id === "string" ? latest.id : null, + }); + } + + default: + return resOk(id, {}); + } +} + +async function startAdapter() { + const companyId = await resolveCompanyContext(); + const httpServer = http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Claw3D Paperclip Gateway Adapter\n"); + }); + + const wss = new WebSocketServer({ server: httpServer }); + wss.on("connection", (ws) => { + let connected = false; + const send = (frame) => { + if (ws.readyState !== ws.OPEN) return; + ws.send(JSON.stringify(frame)); + }; + + const sendEventFn = (frame) => { + if (frame.type === "event" && typeof frame.seq !== "number") { + frame.seq = globalSeq++; + } + send(frame); + }; + + activeSendEventFns.add(sendEventFn); + send({ type: "event", event: "connect.challenge", payload: { nonce: randomId() } }); + + ws.on("message", async (raw) => { + let frame; + try { + frame = JSON.parse(raw.toString("utf8")); + } catch { + return; + } + if (!frame || typeof frame !== "object" || frame.type !== "req") return; + const { id, method, params } = frame; + if (typeof id !== "string" || typeof method !== "string") return; + + try { + if (method === "connect") { + const agents = await fetchCompanyAgents(companyId); + const defaultAgent = agents[0] || { id: MAIN_AGENT_ID, name: PAPERCLIP_AGENT_LABEL }; + connected = true; + send({ + type: "res", + id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + adapterType: "paperclip", + features: { + methods: [ + "agents.list", + "sessions.list", + "sessions.preview", + "sessions.patch", + "sessions.reset", + "chat.send", + "chat.abort", + "chat.history", + "agent.wait", + "status", + "models.list", + "config.get", + "config.set", + "config.patch", + "skills.status", + "exec.approvals.get", + "exec.approvals.set", + "exec.approval.resolve", + "wake", + "cron.list", + ], + events: ["chat", "presence", "heartbeat"], + }, + snapshot: { + health: { + agents: agents.map((agent) => ({ + agentId: agent.id, + name: agent.name, + isDefault: agent.id === defaultAgent.id, + })), + defaultAgentId: defaultAgent.id, + }, + sessionDefaults: { mainKey: MAIN_KEY }, + }, + auth: { role: "operator", scopes: ["operator.admin"] }, + policy: { tickIntervalMs: 30000 }, + }, + }); + return; + } + + if (!connected) { + send(resErr(id, "not_connected", "Send connect first.")); + return; + } + + const response = await handleMethod( + method, + params, + id, + sendEventFn, + { companyId } + ); + send(response); + } catch (error) { + if (method === "connect") { + connected = false; + } + send( + resErr( + id, + "internal_error", + sanitizeErrorMessage(error) + ) + ); + } + }); + + ws.on("close", () => activeSendEventFns.delete(sendEventFn)); + ws.on("error", () => activeSendEventFns.delete(sendEventFn)); + }); + + httpServer.listen(ADAPTER_PORT, "127.0.0.1", () => { + console.log(`[paperclip-adapter] Listening on ws://localhost:${ADAPTER_PORT}`); + console.log(`[paperclip-adapter] Forwarding to ${PAPERCLIP_API_URL}`); + console.log(`[paperclip-adapter] Company: ${companyId}`); + }); +} + +if (require.main === module) { + startAdapter().catch((error) => { + console.error("[paperclip-adapter] Failed to start:", sanitizeErrorMessage(error)); + process.exit(1); + }); +} + +module.exports = { + handleMethod, + startAdapter, +}; diff --git a/server/studio-settings.js b/server/studio-settings.js index 7de379be..141757ec 100644 --- a/server/studio-settings.js +++ b/server/studio-settings.js @@ -61,6 +61,20 @@ const OPENCLAW_CONFIG_FILENAME = "openclaw.json"; const isRecord = (value) => Boolean(value && typeof value === "object"); +const normalizeAdapterType = (value) => { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if ( + normalized === "openclaw" || + normalized === "hermes" || + normalized === "demo" || + normalized === "paperclip" || + normalized === "custom" + ) { + return normalized; + } + return "openclaw"; +}; + const readOpenclawGatewayDefaults = (env = process.env) => { try { const stateDir = resolveStateDir(env); @@ -88,10 +102,7 @@ const loadUpstreamGatewaySettings = (env = process.env) => { const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null; const url = typeof gateway?.url === "string" ? gateway.url.trim() : ""; const token = typeof gateway?.token === "string" ? gateway.token.trim() : ""; - const adapterType = - typeof gateway?.adapterType === "string" && gateway.adapterType.trim() - ? gateway.adapterType.trim() - : "openclaw"; + const adapterType = normalizeAdapterType(gateway?.adapterType); if (!token && adapterType === "openclaw") { const defaults = readOpenclawGatewayDefaults(env); if (defaults) { diff --git a/src/features/agents/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx index 3cef0341..d133085a 100644 --- a/src/features/agents/components/ConnectionPanel.tsx +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -41,6 +41,7 @@ export const ConnectionPanel = ({ const tokenOptional = selectedAdapterType === "hermes" || selectedAdapterType === "demo" || + selectedAdapterType === "paperclip" || selectedAdapterType === "custom"; const applyDemoPreset = () => { onAdapterTypeChange("demo"); @@ -48,6 +49,9 @@ export const ConnectionPanel = ({ const applyHermesPreset = () => { onAdapterTypeChange("hermes"); }; + const applyPaperclipPreset = () => { + onAdapterTypeChange("paperclip"); + }; const applyCustomPreset = () => { onAdapterTypeChange("custom"); }; @@ -95,7 +99,13 @@ export const ConnectionPanel = ({ type="text" value={gatewayUrl} onChange={(event) => onGatewayUrlChange(event.target.value)} - placeholder="ws://localhost:18789" + placeholder={ + selectedAdapterType === "custom" + ? "http://localhost:7770" + : selectedAdapterType === "paperclip" + ? "ws://localhost:18791" + : "[REDACTED]" + } spellCheck={false} /> @@ -131,6 +141,13 @@ export const ConnectionPanel = ({ > Hermes backend + +