From 3479ea6e80c707f962eda7f5e1589c3091d12446 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 17:34:38 -0600 Subject: [PATCH 1/6] openclaw gateway: persist device keys on create/update and clarify pairing flow --- doc/OPENCLAW_ONBOARDING.md | 5 ++ .../doc/ONBOARDING_AND_TEST_PLAN.md | 3 + .../openclaw-gateway/src/server/execute.ts | 13 +++- server/src/routes/agents.ts | 60 +++++++++++++++---- .../openclaw-gateway/config-fields.tsx | 14 ++--- 5 files changed, 74 insertions(+), 21 deletions(-) diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index b55e755a..c035c7e9 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -41,11 +41,16 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT Pairing handshake note: - The first gateway run may return `pairing required` once for a new device key. +- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - For local docker smoke, you can approve from host: ```bash docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"' ``` +- You can inspect pending vs paired devices: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"' +``` 7. Case A (manual issue test). - Create an issue assigned to the OpenClaw agent. diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index 042b8656..cdf7bfe3 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -267,8 +267,11 @@ POST /api/companies/$CLA_COMPANY_ID/invites - default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs - fallback path: `disableDeviceAuth=true` only for environments that cannot support pairing 5. Trigger one connectivity run. If it returns `pairing required`, approve the pending device request in OpenClaw and retry once. + - Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates. - Local docker automation path: - `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token ` + - Optional inspection: + - `openclaw devices list --json --url ws://127.0.0.1:18789 --token ` - After approval, retries should succeed using the persisted `devicePrivateKeyPem`. 6. Claim API key with `claimSecret`. 7. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context. diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index ceec0b91..851b28a5 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -22,6 +22,7 @@ type GatewayDeviceIdentity = { deviceId: string; publicKeyRawBase64Url: string; privateKeyPem: string; + source: "configured" | "ephemeral"; }; type GatewayRequestFrame = { @@ -486,6 +487,7 @@ function resolveDeviceIdentity(config: Record): GatewayDeviceId deviceId: crypto.createHash("sha256").update(raw).digest("hex"), publicKeyRawBase64Url: base64UrlEncode(raw), privateKeyPem: configuredPrivateKey, + source: "configured", }; } @@ -497,6 +499,7 @@ function resolveDeviceIdentity(config: Record): GatewayDeviceId deviceId: crypto.createHash("sha256").update(raw).digest("hex"), publicKeyRawBase64Url: base64UrlEncode(raw), privateKeyPem, + source: "ephemeral", }; } @@ -912,6 +915,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` : message; await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 008d9094..a57b63c2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,5 @@ import { Router, type Request } from "express"; -import { randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db"; @@ -181,6 +181,40 @@ export function agentRoutes(db: Db) { return trimmed.length > 0 ? trimmed : null; } + function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return null; + } + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return null; + } + + function generateEd25519PrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("ed25519"); + return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + } + + function ensureGatewayDeviceKey( + adapterType: string | null | undefined, + adapterConfig: Record, + ): Record { + if (adapterType !== "openclaw_gateway") return adapterConfig; + const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true; + if (disableDeviceAuth) return adapterConfig; + if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig; + return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() }; + } + function applyCreateDefaultsByAdapterType( adapterType: string | null | undefined, adapterConfig: Record, @@ -196,13 +230,13 @@ export function agentRoutes(db: Db) { if (!hasBypassFlag) { next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } - return next; + return ensureGatewayDeviceKey(adapterType, next); } // OpenCode requires explicit model selection — no default if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; } - return next; + return ensureGatewayDeviceKey(adapterType, next); } async function assertAdapterConfigConstraints( @@ -930,11 +964,7 @@ export function agentRoutes(db: Db) { if (changingInstructionsPath) { await assertCanManageInstructionsPath(req, existing); } - patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, - adapterConfig, - { strictMode: strictSecretsMode }, - ); + patchData.adapterConfig = adapterConfig; } const requestedAdapterType = @@ -942,15 +972,23 @@ export function agentRoutes(db: Db) { const touchesAdapterConfiguration = Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); - if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + if (touchesAdapterConfiguration) { const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) : (asRecord(existing.adapterConfig) ?? {}); - const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, + const effectiveAdapterConfig = applyCreateDefaultsByAdapterType( + requestedAdapterType, rawEffectiveAdapterConfig, + ); + const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + effectiveAdapterConfig, { strictMode: strictSecretsMode }, ); + patchData.adapterConfig = normalizedEffectiveAdapterConfig; + } + if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; await assertAdapterConfigConstraints( existing.companyId, requestedAdapterType, diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx index 5bcad80b..178f9f61 100644 --- a/ui/src/adapters/openclaw-gateway/config-fields.tsx +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -204,15 +204,11 @@ export function OpenClawGatewayConfigFields({ /> - - + +
+ Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals + remain stable across runs. +
)} From 2223afa0e9d680ce1ea92c5353f131190277956f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 17:46:55 -0600 Subject: [PATCH 2/6] openclaw gateway: auto-approve first pairing and retry --- doc/OPENCLAW_ONBOARDING.md | 3 +- packages/adapters/openclaw-gateway/README.md | 1 + .../doc/ONBOARDING_AND_TEST_PLAN.md | 4 +- .../adapters/openclaw-gateway/src/index.ts | 1 + .../openclaw-gateway/src/server/execute.ts | 636 +++++++++++------- .../openclaw-gateway-adapter.test.ts | 239 +++++++ 6 files changed, 648 insertions(+), 236 deletions(-) diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index c035c7e9..14a251de 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -40,7 +40,8 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT - Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. Pairing handshake note: -- The first gateway run may return `pairing required` once for a new device key. +- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key. - This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - For local docker smoke, you can approve from host: diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md index 61ebfaea..cadc8198 100644 --- a/packages/adapters/openclaw-gateway/README.md +++ b/packages/adapters/openclaw-gateway/README.md @@ -32,6 +32,7 @@ By default the adapter sends a signed `device` payload in `connect` params. - set `disableDeviceAuth=true` to omit device signing - set `devicePrivateKeyPem` to pin a stable signing key - without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run +- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once. ## Session Strategy diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index cdf7bfe3..f8cacedb 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -266,7 +266,9 @@ POST /api/companies/$CLA_COMPANY_ID/invites - pairing mode is explicit: - default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs - fallback path: `disableDeviceAuth=true` only for environments that cannot support pairing -5. Trigger one connectivity run. If it returns `pairing required`, approve the pending device request in OpenClaw and retry once. +5. Trigger one connectivity run. Adapter behavior on first pairing gate: + - default: auto-attempt `device.pair.list` + `device.pair.approve` over shared auth, then retry once + - if auto-pair fails, run returns `pairing required`; approve manually in OpenClaw and retry once - Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates. - Local docker automation path: - `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token ` diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index ca16cdc9..34f7201d 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -33,6 +33,7 @@ Request behavior fields: - payloadTemplate (object, optional): additional fields merged into gateway agent params - timeoutSec (number, optional): adapter timeout in seconds (default 120) - waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) +- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true) - paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text Session routing fields: diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 851b28a5..b92dccfa 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -57,6 +57,11 @@ type PendingRequest = { timer: ReturnType | null; }; +type GatewayResponseError = Error & { + gatewayCode?: string; + gatewayDetails?: Record; +}; + type GatewayClientOptions = { url: string; headers: Record; @@ -164,6 +169,10 @@ function normalizeScopes(value: unknown): string[] { return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES]; } +function uniqueScopes(scopes: string[]): string[] { + return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))); +} + function headerMapGetIgnoreCase(headers: Record, key: string): string | null { const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); return match ? match[1] : null; @@ -173,6 +182,21 @@ function headerMapHasIgnoreCase(headers: Record, key: string): b return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase()); } +function getGatewayErrorDetails(err: unknown): Record | null { + if (!err || typeof err !== "object") return null; + const candidate = (err as GatewayResponseError).gatewayDetails; + return asRecord(candidate); +} + +function extractPairingRequestId(err: unknown): string | null { + const details = getGatewayErrorDetails(err); + const fromDetails = nonEmpty(details?.requestId); + if (fromDetails) return fromDetails; + const message = err instanceof Error ? err.message : String(err); + const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i); + return match?.[1] ?? null; +} + function toAuthorizationHeaderValue(rawToken: string): string { const trimmed = rawToken.trim(); if (!trimmed) return trimmed; @@ -691,7 +715,101 @@ class GatewayWsClient { nonEmpty(errorRecord?.message) ?? nonEmpty(errorRecord?.code) ?? "gateway request failed"; - pending.reject(new Error(message)); + const err = new Error(message) as GatewayResponseError; + const code = nonEmpty(errorRecord?.code); + const details = asRecord(errorRecord?.details); + if (code) err.gatewayCode = code; + if (details) err.gatewayDetails = details; + pending.reject(err); + } +} + +async function autoApproveDevicePairing(params: { + url: string; + headers: Record; + connectTimeoutMs: number; + clientId: string; + clientMode: string; + clientVersion: string; + role: string; + scopes: string[]; + authToken: string | null; + password: string | null; + requestId: string | null; + deviceId: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> { + if (!params.authToken && !params.password) { + return { ok: false, reason: "shared auth token/password is missing" }; + } + + const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]); + const client = new GatewayWsClient({ + url: params.url, + headers: params.headers, + onEvent: () => {}, + onLog: params.onLog, + }); + + try { + await params.onLog( + "stdout", + "[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n", + ); + + await client.connect( + () => ({ + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: params.clientId, + version: params.clientVersion, + platform: process.platform, + mode: params.clientMode, + }, + role: params.role, + scopes: approvalScopes, + auth: { + ...(params.authToken ? { token: params.authToken } : {}), + ...(params.password ? { password: params.password } : {}), + }, + }), + params.connectTimeoutMs, + ); + + let requestId = params.requestId; + if (!requestId) { + const listPayload = await client.request>("device.pair.list", {}, { + timeoutMs: params.connectTimeoutMs, + }); + const pending = Array.isArray(listPayload.pending) ? listPayload.pending : []; + const pendingRecords = pending + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)); + const matching = + (params.deviceId + ? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId) + : null) ?? pendingRecords[pendingRecords.length - 1]; + requestId = nonEmpty(matching?.requestId); + } + + if (!requestId) { + return { ok: false, reason: "no pending device pairing request found" }; + } + + await client.request( + "device.pair.approve", + { requestId }, + { + timeoutMs: params.connectTimeoutMs, + }, + ); + + return { ok: true, requestId }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } finally { + client.close(); } } @@ -824,63 +942,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise([ctx.runId]); - const assistantChunks: string[] = []; - let lifecycleError: string | null = null; - let latestResultPayload: unknown = null; - - const onEvent = async (frame: GatewayEventFrame) => { - if (frame.event !== "agent") { - if (frame.event === "shutdown") { - await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`); - } - return; - } - - const payload = asRecord(frame.payload); - if (!payload) return; - - const runId = nonEmpty(payload.runId); - if (!runId || !trackedRunIds.has(runId)) return; - - const stream = nonEmpty(payload.stream) ?? "unknown"; - const data = asRecord(payload.data) ?? {}; - await ctx.onLog( - "stdout", - `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, - ); - - if (stream === "assistant") { - const delta = nonEmpty(data.delta); - const text = nonEmpty(data.text); - if (delta) { - assistantChunks.push(delta); - } else if (text) { - assistantChunks.push(text); - } - return; - } - - if (stream === "error") { - lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; - return; - } - - if (stream === "lifecycle") { - const phase = nonEmpty(data.phase)?.toLowerCase(); - if (phase === "error" || phase === "failed" || phase === "cancelled") { - lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; - } - } - }; - - const client = new GatewayWsClient({ - url: parsedUrl.toString(), - headers, - onEvent, - onLog: ctx.onLog, - }); - if (ctx.onMeta) { await ctx.onMeta({ adapterType: "openclaw_gateway", @@ -913,198 +974,305 @@ export async function execute(ctx: AdapterExecutionContext): Promise([ctx.runId]); + const assistantChunks: string[] = []; + let lifecycleError: string | null = null; + let deviceIdentity: GatewayDeviceIdentity | null = null; + + const onEvent = async (frame: GatewayEventFrame) => { + if (frame.event !== "agent") { + if (frame.event === "shutdown") { + await ctx.onLog( + "stdout", + `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`, + ); + } + return; + } + + const payload = asRecord(frame.payload); + if (!payload) return; + + const runId = nonEmpty(payload.runId); + if (!runId || !trackedRunIds.has(runId)) return; + + const stream = nonEmpty(payload.stream) ?? "unknown"; + const data = asRecord(payload.data) ?? {}; await ctx.onLog( "stdout", - `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, ); - } else { - await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); - } - await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); - - const hello = await client.connect((nonce) => { - const signedAtMs = Date.now(); - const connectParams: Record = { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: clientId, - version: clientVersion, - platform: process.platform, - ...(deviceFamily ? { deviceFamily } : {}), - mode: clientMode, - }, - role, - scopes, - auth: - authToken || password || deviceToken - ? { - ...(authToken ? { token: authToken } : {}), - ...(deviceToken ? { deviceToken } : {}), - ...(password ? { password } : {}), - } - : undefined, - }; - - if (deviceIdentity) { - const payload = buildDeviceAuthPayloadV3({ - deviceId: deviceIdentity.deviceId, - clientId, - clientMode, - role, - scopes, - signedAtMs, - token: authToken, - nonce, - platform: process.platform, - deviceFamily, - }); - connectParams.device = { - id: deviceIdentity.deviceId, - publicKey: deviceIdentity.publicKeyRawBase64Url, - signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; + if (stream === "assistant") { + const delta = nonEmpty(data.delta); + const text = nonEmpty(data.text); + if (delta) { + assistantChunks.push(delta); + } else if (text) { + assistantChunks.push(text); + } + return; } - return connectParams; - }, connectTimeoutMs); - await ctx.onLog( - "stdout", - `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, - ); + if (stream === "error") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + return; + } - const acceptedPayload = await client.request>("agent", agentParams, { - timeoutMs: connectTimeoutMs, + if (stream === "lifecycle") { + const phase = nonEmpty(data.phase)?.toLowerCase(); + if (phase === "error" || phase === "failed" || phase === "cancelled") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + } + } + }; + + const client = new GatewayWsClient({ + url: parsedUrl.toString(), + headers, + onEvent, + onLog: ctx.onLog, }); - latestResultPayload = acceptedPayload; + try { + deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); + if (deviceIdentity) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + ); + } else { + await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); + } - const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; - const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; - trackedRunIds.add(acceptedRunId); + await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); - await ctx.onLog( - "stdout", - `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, - ); + const hello = await client.connect((nonce) => { + const signedAtMs = Date.now(); + const connectParams: Record = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: clientId, + version: clientVersion, + platform: process.platform, + ...(deviceFamily ? { deviceFamily } : {}), + mode: clientMode, + }, + role, + scopes, + auth: + authToken || password || deviceToken + ? { + ...(authToken ? { token: authToken } : {}), + ...(deviceToken ? { deviceToken } : {}), + ...(password ? { password } : {}), + } + : undefined, + }; + + if (deviceIdentity) { + const payload = buildDeviceAuthPayloadV3({ + deviceId: deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken, + nonce, + platform: process.platform, + deviceFamily, + }); + connectParams.device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKeyRawBase64Url, + signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + } + return connectParams; + }, connectTimeoutMs); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, + ); + + const acceptedPayload = await client.request>("agent", agentParams, { + timeoutMs: connectTimeoutMs, + }); + + latestResultPayload = acceptedPayload; + + const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; + const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; + trackedRunIds.add(acceptedRunId); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, + ); + + if (acceptedStatus === "error") { + const errorMessage = + nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage, + errorCode: "openclaw_gateway_agent_error", + resultJson: acceptedPayload, + }; + } + + if (acceptedStatus !== "ok") { + const waitPayload = await client.request>( + "agent.wait", + { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, + { timeoutMs: waitTimeoutMs + connectTimeoutMs }, + ); + + latestResultPayload = waitPayload; + + const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; + if (waitStatus === "timeout") { + return { + exitCode: 1, + signal: null, + timedOut: true, + errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, + errorCode: "openclaw_gateway_wait_timeout", + resultJson: waitPayload, + }; + } + + if (waitStatus === "error") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + nonEmpty(waitPayload?.error) ?? + lifecycleError ?? + "OpenClaw gateway run failed", + errorCode: "openclaw_gateway_wait_error", + resultJson: waitPayload, + }; + } + + if (waitStatus && waitStatus !== "ok") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, + errorCode: "openclaw_gateway_wait_status_unexpected", + resultJson: waitPayload, + }; + } + } + + const summaryFromEvents = assistantChunks.join("").trim(); + const summaryFromPayload = + extractResultText(asRecord(acceptedPayload?.result)) ?? + extractResultText(acceptedPayload) ?? + extractResultText(asRecord(latestResultPayload)) ?? + null; + const summary = summaryFromEvents || summaryFromPayload || null; + + const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); + const agentMeta = asRecord(meta?.agentMeta); + const usage = parseUsage(agentMeta?.usage ?? meta?.usage); + const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; + const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; + const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`, + ); + + return { + exitCode: 0, + signal: null, + timedOut: false, + provider, + ...(model ? { model } : {}), + ...(usage ? { usage } : {}), + ...(costUsd > 0 ? { costUsd } : {}), + resultJson: asRecord(latestResultPayload), + ...(summary ? { summary } : {}), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + const timedOut = lower.includes("timeout"); + const pairingRequired = lower.includes("pairing required"); + + if ( + pairingRequired && + !disableDeviceAuth && + autoPairOnFirstConnect && + !autoPairAttempted && + (authToken || password) + ) { + autoPairAttempted = true; + const pairResult = await autoApproveDevicePairing({ + url: parsedUrl.toString(), + headers, + connectTimeoutMs, + clientId, + clientMode, + clientVersion, + role, + scopes, + authToken, + password, + requestId: extractPairingRequestId(err), + deviceId: deviceIdentity?.deviceId ?? null, + onLog: ctx.onLog, + }); + if (pairResult.ok) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`, + ); + continue; + } + await ctx.onLog( + "stderr", + `[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`, + ); + } + + const detailedMessage = pairingRequired + ? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` + : message; + + await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); - if (acceptedStatus === "error") { - const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; return { exitCode: 1, signal: null, - timedOut: false, - errorMessage, - errorCode: "openclaw_gateway_agent_error", - resultJson: acceptedPayload, + timedOut, + errorMessage: detailedMessage, + errorCode: timedOut + ? "openclaw_gateway_timeout" + : pairingRequired + ? "openclaw_gateway_pairing_required" + : "openclaw_gateway_request_failed", + resultJson: asRecord(latestResultPayload), }; + } finally { + client.close(); } - - if (acceptedStatus !== "ok") { - const waitPayload = await client.request>( - "agent.wait", - { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, - { timeoutMs: waitTimeoutMs + connectTimeoutMs }, - ); - - latestResultPayload = waitPayload; - - const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; - if (waitStatus === "timeout") { - return { - exitCode: 1, - signal: null, - timedOut: true, - errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, - errorCode: "openclaw_gateway_wait_timeout", - resultJson: waitPayload, - }; - } - - if (waitStatus === "error") { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - nonEmpty(waitPayload?.error) ?? - lifecycleError ?? - "OpenClaw gateway run failed", - errorCode: "openclaw_gateway_wait_error", - resultJson: waitPayload, - }; - } - - if (waitStatus && waitStatus !== "ok") { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, - errorCode: "openclaw_gateway_wait_status_unexpected", - resultJson: waitPayload, - }; - } - } - - const summaryFromEvents = assistantChunks.join("").trim(); - const summaryFromPayload = - extractResultText(asRecord(acceptedPayload?.result)) ?? - extractResultText(acceptedPayload) ?? - extractResultText(asRecord(latestResultPayload)) ?? - null; - const summary = summaryFromEvents || summaryFromPayload || null; - - const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); - const agentMeta = asRecord(meta?.agentMeta); - const usage = parseUsage(agentMeta?.usage ?? meta?.usage); - const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; - const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; - const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); - - await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`); - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider, - ...(model ? { model } : {}), - ...(usage ? { usage } : {}), - ...(costUsd > 0 ? { costUsd } : {}), - resultJson: asRecord(latestResultPayload), - ...(summary ? { summary } : {}), - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const lower = message.toLowerCase(); - const timedOut = lower.includes("timeout"); - const pairingRequired = lower.includes("pairing required"); - const detailedMessage = pairingRequired - ? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` - : message; - - await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); - - return { - exitCode: 1, - signal: null, - timedOut, - errorMessage: detailedMessage, - errorCode: timedOut - ? "openclaw_gateway_timeout" - : pairingRequired - ? "openclaw_gateway_pairing_required" - : "openclaw_gateway_request_failed", - resultJson: asRecord(latestResultPayload), - }; - } finally { - client.close(); } } diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index df57af32..3a4ac10e 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -167,6 +167,208 @@ async function createMockGatewayServer() { }; } +async function createMockGatewayServerWithPairing() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + + let agentPayload: Record | null = null; + let approved = false; + let pendingRequestId = "req-1"; + let lastSeenDeviceId: string | null = null; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123" }, + }), + ); + + socket.on("message", (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const frame = JSON.parse(text) as { + type: string; + id: string; + method: string; + params?: Record; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + const device = frame.params?.device as Record | undefined; + const deviceId = typeof device?.id === "string" ? device.id : null; + if (deviceId) { + lastSeenDeviceId = deviceId; + } + + if (deviceId && !approved) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { + code: "NOT_PAIRED", + message: "pairing required", + details: { + code: "PAIRING_REQUIRED", + requestId: pendingRequestId, + reason: "not-paired", + }, + }, + }), + ); + socket.close(1008, "pairing required"); + return; + } + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { + methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"], + events: ["agent"], + }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "device.pair.list") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + pending: approved + ? [] + : [ + { + requestId: pendingRequestId, + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + ], + paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [], + }, + }), + ); + return; + } + + if (frame.method === "device.pair.approve") { + const requestId = frame.params?.requestId; + if (requestId !== pendingRequestId) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { code: "INVALID_REQUEST", message: "unknown requestId" }, + }), + ); + return; + } + approved = true; + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + requestId: pendingRequestId, + device: { + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayload = frame.params ?? null; + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : "run-123"; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { delta: "ok" }, + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}`, + getAgentPayload: () => agentPayload, + close: async () => { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + afterEach(() => { // no global mocks }); @@ -238,6 +440,43 @@ describe("openclaw gateway adapter execute", () => { expect(result.exitCode).toBe(1); expect(result.errorCode).toBe("openclaw_gateway_url_missing"); }); + + it("auto-approves pairing once and retries the run", async () => { + const gateway = await createMockGatewayServerWithPairing(); + const logs: string[] = []; + + try { + const result = await execute( + buildContext( + { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2000, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + expect(result.summary).toContain("ok"); + expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe( + true, + ); + expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true); + expect(gateway.getAgentPayload()).toBeTruthy(); + } finally { + await gateway.close(); + } + }); }); describe("openclaw gateway testEnvironment", () => { From 0233525e99f8fae38cace0137a291d9c49854781 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 18:19:06 -0600 Subject: [PATCH 3/6] Add CEO OpenClaw invite endpoint and update onboarding UX --- doc/OPENCLAW_ONBOARDING.md | 21 +- packages/shared/src/index.ts | 2 + packages/shared/src/validators/access.ts | 8 + packages/shared/src/validators/index.ts | 2 + .../openclaw-invite-prompt-route.test.ts | 181 ++++++++++++++++++ server/src/routes/access.ts | 171 ++++++++++++----- skills/paperclip/SKILL.md | 25 +++ skills/paperclip/references/api-reference.md | 18 ++ ui/src/api/access.ts | 33 +++- ui/src/components/NewAgentDialog.tsx | 175 ++++++++++++++--- ui/src/components/OnboardingWizard.tsx | 31 +-- ui/src/pages/CompanySettings.tsx | 12 +- ui/src/pages/NewAgent.tsx | 49 ++++- 13 files changed, 608 insertions(+), 120 deletions(-) create mode 100644 server/src/__tests__/openclaw-invite-prompt-route.test.ts diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index 14a251de..bdb098b3 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -18,20 +18,28 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser. 3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`. -4. Use the agent snippet flow. -- Copy the snippet from company settings. +4. Use the OpenClaw invite prompt flow. +- In the Invites section, click `Generate OpenClaw Invite Prompt`. +- Copy the generated prompt from `OpenClaw Invite Prompt`. - Paste it into OpenClaw main chat as one message. - If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` +Security/control note: +- The OpenClaw invite prompt is created from a controlled endpoint: + - `POST /api/companies/{companyId}/openclaw/invite-prompt` + - board users with invite permission can call it + - agent callers are limited to the company CEO agent + 5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents. 6. Gateway preflight (required before task tests). - Confirm the created agent uses `openclaw_gateway` (not `openclaw`). - Confirm gateway URL is `ws://...` or `wss://...`. - Confirm gateway token is non-trivial (not empty / not 1-char placeholder). +- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding. - Confirm pairing mode is explicit: - - recommended default: `adapterConfig.disableDeviceAuth` is false/absent and `adapterConfig.devicePrivateKeyPem` is present - - fallback only: `adapterConfig.disableDeviceAuth=true` when pairing cannot be supported in that environment + - required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem` + - do not rely on `disableDeviceAuth` for normal onboarding - If you can run API checks with board auth: ```bash AGENT_ID="" @@ -40,8 +48,9 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT - Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. Pairing handshake note: -- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). -- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key. +- Clean run expectation: first task should succeed without manual pairing commands. +- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`. - This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. - Approve it in OpenClaw, then retry the task. - For local docker smoke, you can approve from host: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59ec9eb6..a91f8844 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -197,6 +197,7 @@ export { updateBudgetSchema, createAssetImageMetadataSchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, @@ -206,6 +207,7 @@ export { type UpdateBudget, type CreateAssetImageMetadata, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 614b302e..75b31709 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({ export type CreateCompanyInvite = z.infer; +export const createOpenClawInvitePromptSchema = z.object({ + agentMessage: z.string().max(4000).optional().nullable(), +}); + +export type CreateOpenClawInvitePrompt = z.infer< + typeof createOpenClawInvitePromptSchema +>; + export const acceptInviteSchema = z.object({ requestType: z.enum(JOIN_REQUEST_TYPES), agentName: z.string().min(1).max(120).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 12ad7ffb..f4130c67 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -119,12 +119,14 @@ export { export { createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts new file mode 100644 index 00000000..68cb8759 --- /dev/null +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -0,0 +1,181 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAccessService = vi.hoisted(() => ({ + hasPermission: vi.fn(), + canUser: vi.fn(), + isInstanceAdmin: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listMembers: vi.fn(), + setMemberPermissions: vi.fn(), + promoteInstanceAdmin: vi.fn(), + demoteInstanceAdmin: vi.fn(), + listUserCompanyAccess: vi.fn(), + setUserCompanyAccess: vi.fn(), + setPrincipalGrants: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + deduplicateAgentName: vi.fn(), + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + defaultsPayload: null, + expiresAt: new Date("2026-03-07T00:10:00.000Z"), + invitedByUserId: null, + tokenHash: "hash", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const returning = vi.fn().mockResolvedValue([createdInvite]); + const values = vi.fn().mockReturnValue({ returning }); + const insert = vi.fn().mockReturnValue({ values }); + return { + insert, + }; +} + +function createApp(actor: Record, db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/openclaw/invite-prompt", () => { + beforeEach(() => { + mockAccessService.canUser.mockResolvedValue(false); + mockAgentService.getById.mockReset(); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("rejects non-CEO agent callers", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + }); + + it("allows CEO agent callers and creates an agent-only invite", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "ceo", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({ agentMessage: "Join and configure OpenClaw gateway." }); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + expect(typeof res.body.token).toBe("string"); + expect(res.body.onboardingTextPath).toContain("/api/invites/"); + }); + + it("allows board callers with invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(true); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + }); + + it("rejects board callers without invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Permission denied"); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 406e4bd3..3e2ba527 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -21,6 +21,7 @@ import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, @@ -1942,6 +1943,80 @@ export function accessRoutes( if (!allowed) throw forbidden("Permission denied"); } + async function assertCanGenerateOpenClawInvitePrompt( + req: Request, + companyId: string + ) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "agent") { + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.role !== "ceo") { + throw forbidden("Only CEO agents can generate OpenClaw invite prompts"); + } + return; + } + if (req.actor.type !== "board") throw unauthorized(); + if (isLocalImplicit(req)) return; + const allowed = await access.canUser(companyId, req.actor.userId, "users:invite"); + if (!allowed) throw forbidden("Permission denied"); + } + + async function createCompanyInviteForCompany(input: { + req: Request; + companyId: string; + allowedJoinTypes: "human" | "agent" | "both"; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }) { + const normalizedAgentMessage = + typeof input.agentMessage === "string" + ? input.agentMessage.trim() || null + : null; + const insertValues = { + companyId: input.companyId, + inviteType: "company_join" as const, + allowedJoinTypes: input.allowedJoinTypes, + defaultsPayload: mergeInviteDefaults( + input.defaultsPayload ?? null, + normalizedAgentMessage + ), + expiresAt: companyInviteExpiresAt(), + invitedByUserId: input.req.actor.userId ?? null + }; + + let token: string | null = null; + let created: typeof invites.$inferSelect | null = null; + for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { + const candidateToken = createInviteToken(); + try { + const row = await db + .insert(invites) + .values({ + ...insertValues, + tokenHash: hashToken(candidateToken) + }) + .returning() + .then((rows) => rows[0]); + token = candidateToken; + created = row; + break; + } catch (error) { + if (!isInviteTokenHashCollisionError(error)) { + throw error; + } + } + } + if (!token || !created) { + throw conflict("Failed to generate a unique invite token. Please retry."); + } + + return { token, created, normalizedAgentMessage }; + } + router.get("/skills/index", (_req, res) => { res.json({ skills: [ @@ -1967,49 +2042,14 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); - const normalizedAgentMessage = - typeof req.body.agentMessage === "string" - ? req.body.agentMessage.trim() || null - : null; - const insertValues = { - companyId, - inviteType: "company_join" as const, - allowedJoinTypes: req.body.allowedJoinTypes, - defaultsPayload: mergeInviteDefaults( - req.body.defaultsPayload ?? null, - normalizedAgentMessage - ), - expiresAt: companyInviteExpiresAt(), - invitedByUserId: req.actor.userId ?? null - }; - - let token: string | null = null; - let created: typeof invites.$inferSelect | null = null; - for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { - const candidateToken = createInviteToken(); - try { - const row = await db - .insert(invites) - .values({ - ...insertValues, - tokenHash: hashToken(candidateToken) - }) - .returning() - .then((rows) => rows[0]); - token = candidateToken; - created = row; - break; - } catch (error) { - if (!isInviteTokenHashCollisionError(error)) { - throw error; - } - } - } - if (!token || !created) { - throw conflict( - "Failed to generate a unique invite token. Please retry." - ); - } + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: req.body.allowedJoinTypes, + defaultsPayload: req.body.defaultsPayload ?? null, + agentMessage: req.body.agentMessage ?? null + }); await logActivity(db, { companyId, @@ -2041,6 +2081,51 @@ export function accessRoutes( } ); + router.post( + "/companies/:companyId/openclaw/invite-prompt", + validate(createOpenClawInvitePromptSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanGenerateOpenClawInvitePrompt(req, companyId); + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: "agent", + defaultsPayload: null, + agentMessage: req.body.agentMessage ?? null + }); + + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", + action: "invite.openclaw_prompt_created", + entityType: "invite", + entityId: created.id, + details: { + inviteType: created.inviteType, + allowedJoinTypes: created.allowedJoinTypes, + expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage) + } + }); + + const inviteSummary = toInviteSummaryResponse(req, token, created); + res.status(201).json({ + ...created, + token, + inviteUrl: `/invite/${token}`, + onboardingTextPath: inviteSummary.onboardingTextPath, + onboardingTextUrl: inviteSummary.onboardingTextUrl, + inviteMessage: inviteSummary.inviteMessage + }); + } + ); + router.get("/invites/:token", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 92fe3ba4..bb3cbb04 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -91,6 +91,30 @@ Workspace rules: - For repo-only setup, omit `cwd` and provide `repoUrl`. - Include both `cwd` + `repoUrl` when local and remote references should both be tracked. +## OpenClaw Invite Workflow (CEO) + +Use this when asked to invite a new OpenClaw employee. + +1. Generate a fresh OpenClaw invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ "agentMessage": "optional onboarding note for OpenClaw" } +``` + +Access control: +- Board users with invite permission can call it. +- Agent callers: only the company CEO agent can call it. + +2. Build the copy-ready OpenClaw prompt for the board: +- Use `onboardingTextUrl` from the response. +- Ask the board to paste that prompt into OpenClaw. +- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`. + +3. Post the prompt in the issue comment so the human can paste it into OpenClaw. + +4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install). + ## Critical Rules - **Always checkout** before working. Never PATCH to `in_progress` manually. @@ -206,6 +230,7 @@ PATCH /api/agents/{agentId}/instructions-path | Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | | Add comment | `POST /api/issues/:issueId/comments` | | Create subtask | `POST /api/companies/:companyId/issues` | +| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` | | Create project | `POST /api/companies/:companyId/projects` | | Create project workspace | `POST /api/projects/:projectId/workspaces` | | Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index a88abb82..cbf5ef05 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -280,6 +280,23 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts, Use the dashboard for situational awareness, especially if you're a manager or CEO. +## OpenClaw Invite Prompt (CEO) + +Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ + "agentMessage": "optional note for the joining OpenClaw agent" +} +``` + +Response includes invite token, onboarding text URL, and expiry metadata. + +Access is intentionally constrained: +- board users with invite permission +- CEO agent only (non-CEO agents are rejected) + --- ## Setting Agent Instructions Path @@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled` | GET | `/api/goals/:goalId` | Goal details | | POST | `/api/companies/:companyId/goals` | Create goal | | PATCH | `/api/goals/:goalId` | Update goal | +| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) | ### Approvals, Costs, Activity, Dashboard diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 7e89afd6..ce565f6d 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -64,6 +64,17 @@ type BoardClaimStatus = { claimedByUserId: string | null; }; +type CompanyInviteCreated = { + id: string; + token: string; + inviteUrl: string; + expiresAt: string; + allowedJoinTypes: "human" | "agent" | "both"; + onboardingTextPath?: string; + onboardingTextUrl?: string; + inviteMessage?: string | null; +}; + export const accessApi = { createCompanyInvite: ( companyId: string, @@ -73,16 +84,18 @@ export const accessApi = { agentMessage?: string | null; } = {}, ) => - api.post<{ - id: string; - token: string; - inviteUrl: string; - expiresAt: string; - allowedJoinTypes: "human" | "agent" | "both"; - onboardingTextPath?: string; - onboardingTextUrl?: string; - inviteMessage?: string | null; - }>(`/companies/${companyId}/invites`, input), + api.post(`/companies/${companyId}/invites`, input), + + createOpenClawInvitePrompt: ( + companyId: string, + input: { + agentMessage?: string | null; + } = {}, + ) => + api.post( + `/companies/${companyId}/openclaw/invite-prompt`, + input, + ), getInvite: (token: string) => api.get(`/invites/${token}`), getInviteOnboarding: (token: string) => diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index b3ab9233..18830792 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,3 +1,4 @@ +import { useState, type ComponentType } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; @@ -9,12 +10,77 @@ import { DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Bot, Sparkles } from "lucide-react"; +import { + ArrowLeft, + Bot, + Code, + MousePointer2, + Sparkles, + Terminal, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; + +type AdvancedAdapterType = + | "claude_local" + | "codex_local" + | "opencode_local" + | "pi_local" + | "cursor" + | "openclaw_gateway"; + +const ADVANCED_ADAPTER_OPTIONS: Array<{ + value: AdvancedAdapterType; + label: string; + desc: string; + icon: ComponentType<{ className?: string }>; + recommended?: boolean; +}> = [ + { + value: "claude_local", + label: "Claude Code", + icon: Sparkles, + desc: "Local Claude agent", + recommended: true, + }, + { + value: "codex_local", + label: "Codex", + icon: Code, + desc: "Local Codex agent", + recommended: true, + }, + { + value: "opencode_local", + label: "OpenCode", + icon: OpenCodeLogoIcon, + desc: "Local multi-provider agent", + }, + { + value: "pi_local", + label: "Pi", + icon: Terminal, + desc: "Local Pi agent", + }, + { + value: "cursor", + label: "Cursor", + icon: MousePointer2, + desc: "Local Cursor agent", + }, + { + value: "openclaw_gateway", + label: "OpenClaw Gateway", + icon: Bot, + desc: "Invoke OpenClaw via gateway protocol", + }, +]; export function NewAgentDialog() { const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); + const [showAdvancedCards, setShowAdvancedCards] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -34,15 +100,23 @@ export function NewAgentDialog() { } function handleAdvancedConfig() { + setShowAdvancedCards(true); + } + + function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) { closeNewAgent(); - navigate("/agents/new"); + setShowAdvancedCards(false); + navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); } return ( { - if (!open) closeNewAgent(); + if (!open) { + setShowAdvancedCards(false); + closeNewAgent(); + } }} > { + setShowAdvancedCards(false); + closeNewAgent(); + }} > ×
- {/* Recommendation */} -
-
- -
-

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

-
+ {!showAdvancedCards ? ( + <> + {/* Recommendation */} +
+
+ +
+

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

+
- + - {/* Advanced link */} -
- -
+ {/* Advanced link */} +
+ +
+ + ) : ( + <> +
+ +

+ Choose your adapter type for advanced setup. +

+
+ +
+ {ADVANCED_ADAPTER_OPTIONS.map((opt) => ( + + ))} +
+ + )}
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 7a4bceeb..e1520356 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -38,7 +38,6 @@ import { ArrowLeft, ArrowRight, Terminal, - Globe, Sparkles, MousePointer2, Check, @@ -673,38 +672,19 @@ export function OnboardingWizard() { icon: Terminal, desc: "Local Pi agent" }, - { - value: "openclaw" as const, - label: "OpenClaw", - icon: Bot, - desc: "Notify OpenClaw webhook", - comingSoon: true - }, { value: "openclaw_gateway" as const, label: "OpenClaw Gateway", icon: Bot, - desc: "Invoke OpenClaw via gateway protocol" + desc: "Invoke OpenClaw via gateway protocol", + comingSoon: true, + disabledLabel: "Configure OpenClaw within the App" }, { value: "cursor" as const, label: "Cursor", icon: MousePointer2, desc: "Local Cursor agent" - }, - { - value: "process" as const, - label: "Shell Command", - icon: Terminal, - desc: "Run a process", - comingSoon: true - }, - { - value: "http" as const, - label: "HTTP Webhook", - icon: Globe, - desc: "Call an endpoint", - comingSoon: true } ].map((opt) => ( ))} diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index c11bd8b9..878c5193 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -77,9 +77,7 @@ export function CompanySettings() { const inviteMutation = useMutation({ mutationFn: () => - accessApi.createCompanyInvite(selectedCompanyId!, { - allowedJoinTypes: "agent" - }), + accessApi.createOpenClawInvitePrompt(selectedCompanyId!), onSuccess: async (invite) => { setInviteError(null); const base = window.location.origin.replace(/\/+$/, ""); @@ -317,9 +315,9 @@ export function CompanySettings() {
- Generate an agent snippet for join flows. + Generate an openclaw agent invite snippet. - +
{inviteError && ( @@ -339,7 +337,7 @@ export function CompanySettings() {
- Agent Snippet + OpenClaw Invite Prompt
{snippetCopied && ( ([ + "claude_local", + "codex_local", + "opencode_local", + "pi_local", + "cursor", + "openclaw_gateway", +]); + +function createValuesForAdapterType( + adapterType: CreateConfigValues["adapterType"], +): CreateConfigValues { + const { adapterType: _discard, ...defaults } = defaultCreateValues; + const nextValues: CreateConfigValues = { ...defaults, adapterType }; + if (adapterType === "codex_local") { + nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; + nextValues.dangerouslyBypassSandbox = + DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + } else if (adapterType === "cursor") { + nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; + } else if (adapterType === "opencode_local") { + nextValues.model = ""; + } + return nextValues; +} export function NewAgent() { - const { selectedCompanyId, selectedCompany } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const presetAdapterType = searchParams.get("adapterType"); const [name, setName] = useState(""); const [title, setTitle] = useState(""); @@ -71,6 +104,18 @@ export function NewAgent() { } }, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + const requested = presetAdapterType; + if (!requested) return; + if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) { + return; + } + setConfigValues((prev) => { + if (prev.adapterType === requested) return prev; + return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]); + }); + }, [presetAdapterType]); + const createAgent = useMutation({ mutationFn: (data: Record) => agentsApi.hire(selectedCompanyId!, data), From 5fae7d4de72c46e431b181fa059857e9004b6736 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 18:33:40 -0600 Subject: [PATCH 4/6] Fix CI typecheck and default OpenClaw sessions to issue scope --- cli/src/commands/client/agent.ts | 6 ++++++ packages/adapters/cursor-local/package.json | 1 + packages/adapters/cursor-local/tsconfig.json | 3 ++- packages/adapters/openclaw-gateway/README.md | 2 +- .../openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md | 3 +-- packages/adapters/openclaw-gateway/src/index.ts | 2 +- .../adapters/openclaw-gateway/src/server/execute.ts | 6 +++--- .../adapters/openclaw-gateway/src/ui/build-config.ts | 3 +-- packages/adapters/openclaw/README.md | 4 ++-- packages/adapters/openclaw/src/index.ts | 2 +- .../adapters/openclaw/src/server/execute-common.ts | 6 +++--- packages/adapters/openclaw/src/ui/build-config.ts | 3 +-- pnpm-lock.yaml | 3 +++ server/src/__tests__/openclaw-adapter.test.ts | 12 ++++++------ .../src/__tests__/openclaw-gateway-adapter.test.ts | 2 +- server/src/routes/access.ts | 6 ++---- 16 files changed, 35 insertions(+), 29 deletions(-) diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index c98ca158..36eb04e6 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -197,10 +197,16 @@ export function registerAgentCommands(program: Command): void { const agentRow = await ctx.api.get( `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, ); + if (!agentRow) { + throw new Error(`Agent not found: ${agentRef}`); + } const now = new Date().toISOString().replaceAll(":", "-"); const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + if (!key) { + throw new Error("Failed to create API key"); + } const installSummaries: SkillsInstallSummary[] = []; if (opts.installSkills !== false) { diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 575f9e1b..4ef66052 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/cursor-local/tsconfig.json b/packages/adapters/cursor-local/tsconfig.json index 2f355cfe..90314411 100644 --- a/packages/adapters/cursor-local/tsconfig.json +++ b/packages/adapters/cursor-local/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"] } diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md index cadc8198..ba3edde2 100644 --- a/packages/adapters/openclaw-gateway/README.md +++ b/packages/adapters/openclaw-gateway/README.md @@ -38,7 +38,7 @@ By default the adapter sends a signed `device` payload in `connect` params. The adapter supports the same session routing model as HTTP OpenClaw mode: -- `sessionKeyStrategy=fixed|issue|run` +- `sessionKeyStrategy=issue|fixed|run` - `sessionKey` is used when strategy is `fixed` Resolved session key is sent as `agent.sessionKey`. diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index f8cacedb..61c9b331 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -250,8 +250,7 @@ POST /api/companies/$CLA_COMPANY_ID/invites "headers": { "x-openclaw-token": "" }, "role": "operator", "scopes": ["operator.admin"], - "sessionKeyStrategy": "fixed", - "sessionKey": "paperclip", + "sessionKeyStrategy": "issue", "waitTimeoutMs": 120000 } } diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index 34f7201d..e15ca45c 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -37,6 +37,6 @@ Request behavior fields: - paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text Session routing fields: -- sessionKeyStrategy (string, optional): fixed (default), issue, or run +- sessionKeyStrategy (string, optional): issue (default), fixed, or run - sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) `; diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index b92dccfa..c8de510d 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -117,9 +117,9 @@ function parseBoolean(value: unknown, fallback = false): boolean { } function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { - const normalized = asString(value, "fixed").trim().toLowerCase(); - if (normalized === "issue" || normalized === "run") return normalized; - return "fixed"; + const normalized = asString(value, "issue").trim().toLowerCase(); + if (normalized === "fixed" || normalized === "run") return normalized; + return "issue"; } function resolveSessionKey(input: { diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts index fcbbbf4e..6a749f84 100644 --- a/packages/adapters/openclaw-gateway/src/ui/build-config.ts +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -5,8 +5,7 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record { const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; expect(body.foo).toBe("bar"); expect(body.stream).toBe(true); - expect(body.sessionKey).toBe("paperclip"); + expect(body.sessionKey).toBe("paperclip:issue:issue-123"); expect((body.paperclip as Record).streamTransport).toBe("sse"); expect((body.paperclip as Record).runId).toBe("run-123"); - expect((body.paperclip as Record).sessionKey).toBe("paperclip"); + expect((body.paperclip as Record).sessionKey).toBe("paperclip:issue:issue-123"); expect( ((body.paperclip as Record).env as Record).PAPERCLIP_RUN_ID, ).toBe("run-123"); @@ -414,7 +414,7 @@ describe("openclaw adapter execute", () => { expect(body.sessionKey).toBeUndefined(); const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-session-key"]).toBe("paperclip"); + expect(headers["x-openclaw-session-key"]).toBe("paperclip:issue:issue-123"); }); it("does not treat response.output_text.done as a terminal OpenResponses event", async () => { @@ -584,7 +584,7 @@ describe("openclaw adapter execute", () => { const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; expect(body.foo).toBe("bar"); expect(body.stream).toBe(false); - expect(body.sessionKey).toBe("paperclip"); + expect(body.sessionKey).toBe("paperclip:issue:issue-123"); expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect((body.paperclip as Record).streamTransport).toBe("webhook"); }); @@ -668,7 +668,7 @@ describe("openclaw adapter execute", () => { expect(String(secondBody.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); const secondHeaders = (fetchMock.mock.calls[1]?.[1]?.headers ?? {}) as Record; - expect(secondHeaders["x-openclaw-session-key"]).toBe("paperclip"); + expect(secondHeaders["x-openclaw-session-key"]).toBe("paperclip:issue:issue-123"); expect(result.resultJson).toEqual( expect.objectContaining({ usedLegacyResponsesFallback: true, @@ -766,7 +766,7 @@ describe("openclaw adapter execute", () => { expect(result.exitCode).toBe(0); expect(fetchMock).toHaveBeenCalledTimes(1); const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.sessionKey).toBe("paperclip"); + expect(body.sessionKey).toBe("paperclip:issue:issue-123"); }); it("retries webhook payloads with wake compatibility format on text-required errors", async () => { diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 3a4ac10e..364f5a97 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -424,7 +424,7 @@ describe("openclaw gateway adapter execute", () => { const payload = gateway.getAgentPayload(); expect(payload).toBeTruthy(); expect(payload?.idempotencyKey).toBe("run-123"); - expect(payload?.sessionKey).toBe("paperclip"); + expect(payload?.sessionKey).toBe("paperclip:issue:issue-123"); expect(String(payload?.message ?? "")).toContain("wake now"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 3e2ba527..9eaacf71 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1484,8 +1484,7 @@ export function buildInviteOnboardingTextDocument( paperclipApiUrl: "http://host.docker.internal:3100", headers: { "x-openclaw-token": token }, waitTimeoutMs: 120000, - sessionKeyStrategy: "fixed", - sessionKey: "paperclip", + sessionKeyStrategy: "issue", role: "operator", scopes: ["operator.admin"] } @@ -1518,8 +1517,7 @@ export function buildInviteOnboardingTextDocument( "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100", "headers": { "x-openclaw-token": "replace-me" }, "waitTimeoutMs": 120000, - "sessionKeyStrategy": "fixed", - "sessionKey": "paperclip", + "sessionKeyStrategy": "issue", "role": "operator", "scopes": ["operator.admin"] } From 048e2b1bfed872540bef79542047f0b22546f233 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 18:50:25 -0600 Subject: [PATCH 5/6] Remove legacy OpenClaw adapter and keep gateway-only flow --- Dockerfile | 2 +- cli/esbuild.config.mjs | 1 - cli/package.json | 1 - cli/src/adapters/registry.ts | 7 - packages/adapters/cursor-local/src/index.ts | 2 +- .../doc/ONBOARDING_AND_TEST_PLAN.md | 424 ++----- .../adapters/openclaw-gateway/src/index.ts | 2 +- packages/adapters/openclaw/CHANGELOG.md | 57 - packages/adapters/openclaw/README.md | 139 --- packages/adapters/openclaw/package.json | 50 - .../adapters/openclaw/src/cli/format-event.ts | 18 - packages/adapters/openclaw/src/cli/index.ts | 1 - packages/adapters/openclaw/src/index.ts | 42 - .../openclaw/src/server/execute-common.ts | 534 --------- .../openclaw/src/server/execute-sse.ts | 469 -------- .../openclaw/src/server/execute-webhook.ts | 463 ------- .../adapters/openclaw/src/server/execute.ts | 53 - .../adapters/openclaw/src/server/hire-hook.ts | 77 -- .../adapters/openclaw/src/server/index.ts | 4 - .../adapters/openclaw/src/server/parse.ts | 15 - packages/adapters/openclaw/src/server/test.ts | 247 ---- .../adapters/openclaw/src/shared/stream.ts | 16 - .../adapters/openclaw/src/ui/build-config.ts | 11 - packages/adapters/openclaw/src/ui/index.ts | 2 - .../adapters/openclaw/src/ui/parse-stdout.ts | 167 --- packages/adapters/openclaw/tsconfig.json | 8 - packages/adapters/opencode-local/src/index.ts | 2 +- packages/adapters/pi-local/src/index.ts | 2 +- packages/shared/src/constants.ts | 1 - pnpm-lock.yaml | 25 - scripts/generate-npm-package-json.mjs | 2 +- scripts/release.sh | 8 +- scripts/smoke/openclaw-join.sh | 28 +- server/package.json | 1 - server/src/__tests__/hire-hook.test.ts | 14 +- .../invite-accept-gateway-defaults.test.ts | 119 ++ .../invite-accept-openclaw-defaults.test.ts | 294 ----- .../__tests__/invite-accept-replay.test.ts | 60 +- server/src/__tests__/openclaw-adapter.test.ts | 1063 ----------------- server/src/adapters/registry.ts | 20 - server/src/routes/access.ts | 843 +++---------- server/src/services/company-portability.ts | 4 - skills/release/SKILL.md | 2 +- ui/package.json | 1 - ui/src/adapters/openclaw/config-fields.tsx | 177 --- ui/src/adapters/openclaw/index.ts | 12 - ui/src/adapters/registry.ts | 2 - ui/src/components/AgentProperties.tsx | 1 - ui/src/components/LiveRunWidget.tsx | 2 +- ui/src/components/OnboardingWizard.tsx | 3 +- ui/src/components/agent-config-primitives.tsx | 3 +- ui/src/pages/Agents.tsx | 1 - ui/src/pages/CompanySettings.tsx | 2 +- ui/src/pages/InviteLanding.tsx | 6 +- ui/src/pages/OrgChart.tsx | 1 - 55 files changed, 454 insertions(+), 5057 deletions(-) delete mode 100644 packages/adapters/openclaw/CHANGELOG.md delete mode 100644 packages/adapters/openclaw/README.md delete mode 100644 packages/adapters/openclaw/package.json delete mode 100644 packages/adapters/openclaw/src/cli/format-event.ts delete mode 100644 packages/adapters/openclaw/src/cli/index.ts delete mode 100644 packages/adapters/openclaw/src/index.ts delete mode 100644 packages/adapters/openclaw/src/server/execute-common.ts delete mode 100644 packages/adapters/openclaw/src/server/execute-sse.ts delete mode 100644 packages/adapters/openclaw/src/server/execute-webhook.ts delete mode 100644 packages/adapters/openclaw/src/server/execute.ts delete mode 100644 packages/adapters/openclaw/src/server/hire-hook.ts delete mode 100644 packages/adapters/openclaw/src/server/index.ts delete mode 100644 packages/adapters/openclaw/src/server/parse.ts delete mode 100644 packages/adapters/openclaw/src/server/test.ts delete mode 100644 packages/adapters/openclaw/src/shared/stream.ts delete mode 100644 packages/adapters/openclaw/src/ui/build-config.ts delete mode 100644 packages/adapters/openclaw/src/ui/index.ts delete mode 100644 packages/adapters/openclaw/src/ui/parse-stdout.ts delete mode 100644 packages/adapters/openclaw/tsconfig.json create mode 100644 server/src/__tests__/invite-accept-gateway-defaults.test.ts delete mode 100644 server/src/__tests__/invite-accept-openclaw-defaults.test.ts delete mode 100644 server/src/__tests__/openclaw-adapter.test.ts delete mode 100644 ui/src/adapters/openclaw/config-fields.tsx delete mode 100644 ui/src/adapters/openclaw/index.ts diff --git a/Dockerfile b/Dockerfile index 0fcc3216..e99f9323 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ -COPY packages/adapters/openclaw/package.json packages/adapters/openclaw/ +COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ RUN pnpm install --frozen-lockfile diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index 495fad99..7976b7c9 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -21,7 +21,6 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", - "packages/adapters/openclaw", "packages/adapters/openclaw-gateway", ]; diff --git a/cli/package.json b/cli/package.json index 1bddae42..9670d997 100644 --- a/cli/package.json +++ b/cli/package.json @@ -39,7 +39,6 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", - "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 41d95f77..21b915f5 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -4,7 +4,6 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; -import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -34,11 +33,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCursorStreamEvent, }; -const openclawCLIAdapter: CLIAdapterModule = { - type: "openclaw", - formatStdoutEvent: printOpenClawStreamEvent, -}; - const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -51,7 +45,6 @@ const adaptersByType = new Map( openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, - openclawCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 662bc8a7..5845fba8 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -56,7 +56,7 @@ Use when: - You want structured stream output in run logs via --output-format stream-json Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Cursor Agent CLI is not installed on the machine diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index 61c9b331..66ff2a4a 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -1,371 +1,109 @@ # OpenClaw Gateway Onboarding and Test Plan -## Objective -Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex. - -This plan covers: -- Current onboarding flow behavior and gaps. -- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol). -- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company. - -## Hard Requirements (Testing Contract) -These are mandatory for onboarding and smoke testing: - -1. **Stock/clean OpenClaw boot every run** -- Use a fresh, unmodified OpenClaw Docker image path each test cycle. -- Do not rely on persistent/manual in-UI tweaks from prior runs. -- Recreate runtime state each run so results represent first-time user experience. - -2. **One-command/prompt setup inside OpenClaw** -- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able). -- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”). -- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps. - -3. **Two-lane validation is required** -- Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate. -- Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path. - -## Execution Findings (2026-03-07) -Observed from running `scripts/smoke/openclaw-gateway-e2e.sh` against `CLA` in authenticated/private dev mode: - -1. **Baseline failure (before wake-text fix)** -- Stock lane had run-level success but failed functional assertions: - - connectivity run `64a72d8b-f5b3-4f62-9147-1c60932f50ad` succeeded - - case A run `fd29e361-a6bd-4bc6-9270-36ef96e3bd8e` succeeded - - issue `CLA-6` (`dad7b967-29d2-4317-8c9d-425b4421e098`) stayed `todo` with `0` comments -- Root symptom: OpenClaw reported missing concrete heartbeat procedure and guessed non-existent `/api/*heartbeat` endpoints. - -2. **Post-fix validation (stock-clean lane passes)** -- After updating adapter wake text to include explicit Paperclip API workflow steps and explicit endpoint bans: - - connectivity run `c297e2d0-020b-4b30-95d3-a4c04e1373bb`: `succeeded` - - case A run `baac403e-8d86-48e5-b7d5-239c4755ce7e`: `succeeded`, issue `CLA-7` done with marker - - case B run `521fc8ad-2f5a-4bd8-9ddd-c491401c9158`: `succeeded`, issue `CLA-8` done with marker - - case C run `a03d86b6-91a8-48b4-8813-758f6bf11aec`: `succeeded`, issue `CLA-9` done, created issue `CLA-10` -- Stock release-gate lane now passes scripted checks. - -3. **Instrumentation lane note** -- Prompt-augmented diagnostics lane previously timed out (`7537e5d2-a76a-44c5-bf9f-57f1b21f5fc3`) with missing tool runtime utilities (`jq`, `python`) inside the stock container. -- Keep this lane for diagnostics only; stock lane remains the acceptance gate. - -## External Protocol Constraints -OpenClaw docs to anchor behavior: -- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook -- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol -- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses - -Implication: -- `webhook` transport should target `/hooks/*` and requires hook server enablement. -- `sse` transport should target `/v1/responses`. -- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`. - -## Current Implementation Map (What Exists) - -### Invite + onboarding pipeline -- Invite create: `POST /api/companies/:companyId/invites` -- Invite onboarding manifest: `GET /api/invites/:token/onboarding` -- Agent-readable text: `GET /api/invites/:token/onboarding.txt` -- Accept join: `POST /api/invites/:token/accept` -- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve` -- Claim key: `POST /api/join-requests/:requestId/claim-api-key` - -### Adapter state -- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode. -- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`). - -### Existing smoke foundation -- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`. -- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`). - -## Deep Code Findings (Gaps) - -### 1) Onboarding manifest/text gateway path (resolved) -Resolved in `server/src/routes/access.ts`: -- `recommendedAdapterType` now points to `openclaw_gateway`. -- Onboarding examples now require `adapterType: "openclaw_gateway"` + `ws://`/`wss://` URL + gateway token header. -- Added fail-fast guidance for short/placeholder tokens. - -### 2) Company settings snippet gateway path (resolved) -Resolved in `ui/src/pages/CompanySettings.tsx`: -- Snippet now instructs OpenClaw Gateway onboarding. -- Snippet explicitly says not to use `/v1/responses` or `/hooks/*` for this flow. - -### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters (open) -`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI. - -### 4) Join normalization/replay logic parity (partially resolved) -Resolved: -- `buildJoinDefaultsPayloadForAccept` now normalizes wrapped gateway token headers for `openclaw_gateway`. -- `normalizeAgentDefaultsForJoin` now validates `openclaw_gateway` URL/token and rejects short placeholder tokens at invite-accept time. - -Still open: -- Invite replay path is still special-cased to legacy `openclaw` joins. - -### 5) Webhook confusion is expected in current setup -For `openclaw` + `streamTransport=webhook`: -- Adapter may remap `/v1/responses -> /hooks/agent`. -- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`. - -If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected. - -### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode -- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`). -- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`. -- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes. - -### 7) Gateway adapter lacks hire-approved callback parity -`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not. -Not a blocker for core routing, but creates inconsistent onboarding feedback behavior. - -## UX Intention (Target Experience) - -### Product goal -Users should pick one clear onboarding path: -- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs. -- `Invite OpenClaw Gateway` for gateway-native installs. - -### UX design requirements -- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route). -- Mode-specific generated snippet and mode-specific onboarding text. -- Clear compatibility checks before user copies anything. - -### Proposed UX structure -1. Add invite buttons: -- `Invite OpenClaw (SSE/Webhook)` -- `Invite OpenClaw Gateway` - -2. For HTTP invite: -- Require transport choice (`sse` or `webhook`). -- Validate endpoint expectations: - - `sse` with `/v1/responses`. - - `webhook` with `/hooks/*` and hooks enablement guidance. - -3. For Gateway invite: -- Ask only for `ws://`/`wss://` and token source guidance. -- No callback URL/paperclipApiUrl complexity in onboarding. - -4. Always show: -- Preflight diagnostics. -- Copy-ready command/snippet. -- Expected next steps (join -> approve -> claim -> skill install). - -## Why Gateway Improves Onboarding -Compared to webhook/SSE onboarding: -- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls. -- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion. -- Better run observability: gateway event frames stream lifecycle/delta events in one protocol. - -Tradeoff: -- Requires stable WS endpoint and gateway token handling. - -## Codex-Executable E2E Workflow - ## Scope -Run this full flow per test cycle against company `CLA`: -1. Assign task to OpenClaw agent -> agent executes -> task closes. -2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat. -3. OpenClaw in a fresh/new session can still create a Paperclip task. -4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup. +This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only. -## 0) Cleanup Before Each Run -Use deterministic reset to avoid stale agents/runs/state. +- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching) +- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`) -1. OpenClaw Docker cleanup: +## Requirements +1. OpenClaw test image must be stock/clean every run. +2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed). +3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`. +4. Invite/access flow must be secure: +- invite prompt endpoint is board-permission protected +- CEO agent is allowed to invoke the invite prompt endpoint for their own company +5. E2E pass criteria must include the 3 functional task cases. + +## Current Product Flow +1. Board/CEO opens company settings. +2. Click `Generate OpenClaw Invite Prompt`. +3. Paste generated prompt into OpenClaw chat. +4. OpenClaw submits invite acceptance with: +- `adapterType: "openclaw_gateway"` +- `agentDefaultsPayload.url: ws://... | wss://...` +- `agentDefaultsPayload.headers["x-openclaw-token"]` +5. Board approves join request. +6. OpenClaw claims API key and installs/uses Paperclip skill. +7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key. + +## Technical Contract (Gateway) +`agentDefaultsPayload` minimum: +```json +{ + "url": "ws://127.0.0.1:18789", + "headers": { "x-openclaw-token": "" } +} +``` + +Recommended fields: +```json +{ + "paperclipApiUrl": "http://host.docker.internal:3100", + "waitTimeoutMs": 120000, + "sessionKeyStrategy": "issue", + "role": "operator", + "scopes": ["operator.admin"] +} +``` + +Security/pairing defaults: +- `disableDeviceAuth`: default false +- `devicePrivateKeyPem`: generated during join if missing + +## Codex Automation Workflow + +### 0) Reset and boot ```bash -# stop/remove OpenClaw compose services OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker if [ -d "$OPENCLAW_DOCKER_DIR" ]; then docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true fi -# remove old image (as requested) docker image rm openclaw:local || true -``` - -2. Recreate OpenClaw cleanly: -```bash OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh ``` -This must remain a stock/clean image boot path, with no hidden manual state carried from prior runs. -3. Remove prior CLA OpenClaw agents: -- List `CLA` agents via API. -- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding. - -4. Reject/clear stale pending join requests for CLA (optional but recommended). - -## 1) Start Paperclip in Required Mode +### 1) Start Paperclip ```bash pnpm dev --tailscale-auth -``` -Verify: -```bash curl -fsS http://127.0.0.1:3100/api/health -# expect deploymentMode=authenticated, deploymentExposure=private ``` -## 2) Acquire Board Session for Automation -Board operations (create invite, approve join, terminate agents) require board session cookie. +### 2) Invite + join + approval +- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt` +- paste prompt to OpenClaw +- approve join request +- assert created agent: + - `adapterType == openclaw_gateway` + - token header exists and length >= 16 + - `devicePrivateKeyPem` exists -Short-term practical options: -1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`. -2. Scripted fallback: sign-up/sign-in via `/api/auth/*`, then use a dedicated admin promotion/bootstrap utility for dev (recommended to add as a small internal script). +### 3) Pairing stabilization +- if first run returns `pairing required`, approve pending device in OpenClaw +- rerun task and confirm success +- assert later runs do not require re-pairing for same agent -Note: -- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow. +### 4) Functional E2E assertions +1. Task assigned to OpenClaw is completed and closed. +2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat). +3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task. -## 3) Resolve CLA Company ID -With board cookie: +## Manual Smoke Checklist +Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook. + +## Regression Gates +Required before merge: ```bash -curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies +pnpm -r typecheck +pnpm test:run +pnpm build ``` -Pick company where identifier/code is `CLA` and store `CLA_COMPANY_ID`. -## 4) Preflight OpenClaw Endpoint Capability -From host (using current OpenClaw token): -- For HTTP SSE mode: confirm `/v1/responses` behavior. -- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled. -- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`. - -Expected in current docker smoke config: -- `/hooks/agent` likely `404` unless hooks explicitly enabled. -- WS gateway protocol works. - -## 5) Gateway Join Flow (Primary Path) - -1. Create agent-only invite in CLA: +If full suite is too heavy locally, run at least: ```bash -POST /api/companies/$CLA_COMPANY_ID/invites -{ "allowedJoinTypes": "agent" } +pnpm --filter @paperclipai/server test:run -- openclaw-gateway +pnpm --filter @paperclipai/server typecheck +pnpm --filter @paperclipai/ui typecheck +pnpm --filter paperclipai typecheck ``` - -2. Submit join request with gateway defaults: -```json -{ - "requestType": "agent", - "agentName": "OpenClaw Gateway", - "adapterType": "openclaw_gateway", - "capabilities": "OpenClaw gateway agent", - "agentDefaultsPayload": { - "url": "ws://127.0.0.1:18789", - "headers": { "x-openclaw-token": "" }, - "role": "operator", - "scopes": ["operator.admin"], - "sessionKeyStrategy": "issue", - "waitTimeoutMs": 120000 - } -} -``` - -3. Approve join request. -4. **Hard gate before any task run:** fetch created agent config and validate: -- `adapterType == "openclaw_gateway"` -- `adapterConfig.url` uses `ws://` or `wss://` -- `adapterConfig.headers.x-openclaw-token` exists and is not placeholder/too-short (`len >= 16`) -- token hash matches the OpenClaw `gateway.auth.token` used for join -- pairing mode is explicit: - - default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs - - fallback path: `disableDeviceAuth=true` only for environments that cannot support pairing -5. Trigger one connectivity run. Adapter behavior on first pairing gate: - - default: auto-attempt `device.pair.list` + `device.pair.approve` over shared auth, then retry once - - if auto-pair fails, run returns `pairing required`; approve manually in OpenClaw and retry once - - Note: Paperclip invite approval and OpenClaw device-pairing approval are separate gates. - - Local docker automation path: - - `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token ` - - Optional inspection: - - `openclaw devices list --json --url ws://127.0.0.1:18789 --token ` - - After approval, retries should succeed using the persisted `devicePrivateKeyPem`. -6. Claim API key with `claimSecret`. -7. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context. - - Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch. -8. Ensure Paperclip skill is installed for OpenClaw runtime. -9. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only. - -## 6) E2E Validation Cases - -### Case A: Assigned task execution/closure -1. Create issue in CLA assigned to joined OpenClaw agent. -2. Poll issue + heartbeat runs until terminal. -3. Pass criteria: -- At least one run invoked for that agent/issue. -- Run status `succeeded`. -- Issue reaches `done` (or documented expected terminal state if policy differs). - -### Case B: Message tool to main chat -1. Create issue instructing OpenClaw: “send a message to the user’s main chat session in webchat using message tool”. -2. Trigger/poll run completion. -3. Validate output: -- Automated minimum: run log/transcript confirms tool invocation success. -- UX-level validation: message visibly appears in main chat UI. - -Current recommendation: -- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification. - -### Case C: Fresh session still creates Paperclip task -1. Force fresh-session behavior for test: -- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key). -2. Create issue asking agent to create a new Paperclip task. -3. Pass criteria: -- New issue appears in CLA with expected title/body. -- Agent succeeds without re-onboarding. - -## 7) Observability and Assertions -Use these APIs for deterministic assertions: -- `GET /api/companies/:companyId/heartbeat-runs?agentId=...` -- `GET /api/heartbeat-runs/:runId/events` -- `GET /api/heartbeat-runs/:runId/log` -- `GET /api/issues/:id` -- `GET /api/companies/:companyId/issues?q=...` - -Include explicit timeout budgets per poll loop and hard failure reasons in output. - -## 8) Automation Artifact -Implemented smoke harness: -- `scripts/smoke/openclaw-gateway-e2e.sh` - -Responsibilities: -- OpenClaw docker cleanup/rebuild/start. -- Paperclip health/auth preflight. -- CLA company resolution. -- Old OpenClaw agent cleanup. -- Invite/join/approve/claim orchestration. -- Gateway agent config/token preflight validation before connectivity or case execution. -- Pairing-mode preflight (`disableDeviceAuth=false` + stable `devicePrivateKeyPem` by default). -- E2E case execution + assertions. -- Final summary with run IDs, issue IDs, agent ID. - -## 9) Required Product/Code Changes to Support This Plan Cleanly - -### Access/onboarding backend -- Make onboarding manifest/text adapter-aware (`openclaw` vs `openclaw_gateway`). -- Add gateway-specific required fields and examples. -- Add gateway-specific diagnostics (WS URL/token/role/scopes/device-auth hints). - -### Company settings UX -- Replace single generic snippet with mode-specific invite actions. -- Add “Invite OpenClaw Gateway” path with concise copy/paste onboarding. - -### Invite landing UX -- Enable OpenClaw adapter options when invite allows agent join. -- Allow `agentDefaultsPayload` entry for advanced joins where needed. - -### Adapter parity -- Consider `onHireApproved` support for `openclaw_gateway` for consistency. - -### Test coverage -- Add integration tests for adapter-aware onboarding manifest generation. -- Add route tests for gateway join/approve/claim path. -- Add smoke test target for gateway E2E flow. - -## 10) Execution Order -1. Implement onboarding manifest/text split by adapter mode. -2. Add company settings invite UX split (HTTP vs Gateway). -3. Add gateway E2E smoke script. -4. Run full CLA workflow in authenticated/private mode. -5. Iterate on message-tool verification automation. - -## Acceptance Criteria -- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal. -- Gateway onboarding is first-class and copy/pasteable from company settings. -- Gateway join fails fast if token is missing/placeholder, and smoke preflight verifies adapter/token parity before task runs. -- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup. -- All three validation cases are documented with pass/fail criteria and reproducible evidence paths. diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index e15ca45c..2af13f99 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -12,7 +12,7 @@ Use when: - You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*. Don't use when: -- You only expose OpenClaw HTTP endpoints (use openclaw adapter with sse/webhook transport). +- You only expose OpenClaw HTTP endpoints. - Your deployment does not permit outbound WebSocket access from the Paperclip server. Core fields: diff --git a/packages/adapters/openclaw/CHANGELOG.md b/packages/adapters/openclaw/CHANGELOG.md deleted file mode 100644 index 79174ae2..00000000 --- a/packages/adapters/openclaw/CHANGELOG.md +++ /dev/null @@ -1,57 +0,0 @@ -# @paperclipai/adapter-openclaw - -## 0.2.7 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.7 - -## 0.2.6 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.6 - -## 0.2.5 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.5 - -## 0.2.4 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.4 - -## 0.2.3 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.3 - -## 0.2.2 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.2 - -## 0.2.1 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.1 diff --git a/packages/adapters/openclaw/README.md b/packages/adapters/openclaw/README.md deleted file mode 100644 index 01dbc661..00000000 --- a/packages/adapters/openclaw/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# OpenClaw Adapter Modes - -This document describes how `@paperclipai/adapter-openclaw` selects request shape and endpoint behavior. - -## Transport Modes - -The adapter has two transport modes: - -- `sse` (default) -- `webhook` - -Configured via `adapterConfig.streamTransport` (or legacy `adapterConfig.transport`). - -## Mode Matrix - -| streamTransport | configured URL path | behavior | -| --- | --- | --- | -| `sse` | `/v1/responses` | Sends OpenResponses request with `stream: true`, expects `text/event-stream` response until terminal event. | -| `sse` | `/hooks/*` | Rejected (`openclaw_sse_incompatible_endpoint`). Hooks are not stream-capable. | -| `sse` | other endpoint | Sends generic streaming payload (`stream: true`, `text`, `paperclip`) and expects SSE response. | -| `webhook` | `/hooks/wake` | Sends wake payload `{ text, mode }`. | -| `webhook` | `/hooks/agent` | Sends agent payload `{ message, ...hook fields }`. | -| `webhook` | `/v1/responses` | Compatibility flow: tries `/hooks/agent` first, then falls back to original `/v1/responses` if hook endpoint returns `404`. | -| `webhook` | other endpoint | Sends legacy generic webhook payload (`stream: false`, `text`, `paperclip`). | - -## Webhook Payload Shapes - -### 1) Hook Wake (`/hooks/wake`) - -Payload: - -```json -{ - "text": "Paperclip wake event ...", - "mode": "now" -} -``` - -### 2) Hook Agent (`/hooks/agent`) - -Payload: - -```json -{ - "message": "Paperclip wake event ...", - "name": "Optional hook name", - "agentId": "Optional OpenClaw agent id", - "wakeMode": "now", - "deliver": true, - "channel": "last", - "to": "Optional channel recipient", - "model": "Optional model override", - "thinking": "Optional thinking override", - "timeoutSeconds": 120 -} -``` - -Notes: - -- `message` is always used (not `text`) for `/hooks/agent`. -- `sessionKey` is **not** sent by default for `/hooks/agent`. -- To include derived session keys in `/hooks/agent`, set: - - `hookIncludeSessionKey: true` - -### 3) OpenResponses (`/v1/responses`) - -When used directly (SSE mode or webhook fallback), payload uses OpenResponses shape: - -```json -{ - "stream": false, - "model": "openclaw", - "input": "...", - "metadata": { - "paperclip_session_key": "paperclip:issue:ISSUE_ID" - } -} -``` - -## Auth Header Behavior - -You can provide auth either explicitly or via token headers: - -- Explicit auth header: - - `webhookAuthHeader: "Bearer ..."` -- Token headers (adapter derives `Authorization` automatically when missing): - - `headers["x-openclaw-token"]` (preferred) - - `headers["x-openclaw-auth"]` (legacy compatibility) - -## Session Key Behavior - -Session keys are resolved from: - -- `sessionKeyStrategy`: `issue` (default), `fixed`, `run` -- `sessionKey`: used when strategy is `fixed` (default value `paperclip`) - -Where session keys are applied: - -- `/v1/responses`: sent via `x-openclaw-session-key` header + metadata. -- `/hooks/wake`: not sent as a dedicated field. -- `/hooks/agent`: only sent if `hookIncludeSessionKey=true`. -- Generic webhook fallback: sent as `sessionKey` field. - -## Recommended Config Examples - -### SSE (streaming endpoint) - -```json -{ - "url": "http://127.0.0.1:18789/v1/responses", - "streamTransport": "sse", - "method": "POST", - "headers": { - "x-openclaw-token": "replace-me" - } -} -``` - -### Webhook (hooks endpoint) - -```json -{ - "url": "http://127.0.0.1:18789/hooks/agent", - "streamTransport": "webhook", - "method": "POST", - "headers": { - "x-openclaw-token": "replace-me" - } -} -``` - -### Webhook with legacy URL retained - -If URL is still `/v1/responses` and `streamTransport=webhook`, the adapter will: - -1. try `.../hooks/agent` -2. fallback to original `.../v1/responses` when hook endpoint returns `404` - -This lets older OpenClaw setups continue working while migrating to hooks. diff --git a/packages/adapters/openclaw/package.json b/packages/adapters/openclaw/package.json deleted file mode 100644 index c8bd561d..00000000 --- a/packages/adapters/openclaw/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@paperclipai/adapter-openclaw", - "version": "0.2.7", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./server": "./src/server/index.ts", - "./ui": "./src/ui/index.ts", - "./cli": "./src/cli/index.ts" - }, - "publishConfig": { - "access": "public", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./server": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.js" - }, - "./ui": { - "types": "./dist/ui/index.d.ts", - "import": "./dist/ui/index.js" - }, - "./cli": { - "types": "./dist/cli/index.d.ts", - "import": "./dist/cli/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@paperclipai/adapter-utils": "workspace:*", - "picocolors": "^1.1.1" - }, - "devDependencies": { - "@types/node": "^24.6.0", - "typescript": "^5.7.3" - } -} diff --git a/packages/adapters/openclaw/src/cli/format-event.ts b/packages/adapters/openclaw/src/cli/format-event.ts deleted file mode 100644 index c0c0c910..00000000 --- a/packages/adapters/openclaw/src/cli/format-event.ts +++ /dev/null @@ -1,18 +0,0 @@ -import pc from "picocolors"; - -export function printOpenClawStreamEvent(raw: string, debug: boolean): void { - const line = raw.trim(); - if (!line) return; - - if (!debug) { - console.log(line); - return; - } - - if (line.startsWith("[openclaw]")) { - console.log(pc.cyan(line)); - return; - } - - console.log(pc.gray(line)); -} diff --git a/packages/adapters/openclaw/src/cli/index.ts b/packages/adapters/openclaw/src/cli/index.ts deleted file mode 100644 index 107ebf8b..00000000 --- a/packages/adapters/openclaw/src/cli/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { printOpenClawStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts deleted file mode 100644 index 940dbbc6..00000000 --- a/packages/adapters/openclaw/src/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -export const type = "openclaw"; -export const label = "OpenClaw"; - -export const models: { id: string; label: string }[] = []; - -export const agentConfigurationDoc = `# openclaw agent configuration - -Adapter: openclaw - -Use when: -- You run an OpenClaw agent remotely and wake it over HTTP. -- You want selectable transport: - - \`sse\` for streaming execution in one Paperclip run. - - \`webhook\` for wake-style callbacks (\`/hooks/wake\`, \`/hooks/agent\`, or compatibility webhooks). - -Don't use when: -- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process). -- The OpenClaw endpoint is not reachable from the Paperclip server. - -Core fields: -- url (string, required): OpenClaw endpoint URL -- streamTransport (string, optional): \`sse\` (default) or \`webhook\` -- method (string, optional): HTTP method, default POST -- headers (object, optional): extra HTTP headers for requests -- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth -- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload -- paperclipApiUrl (string, optional): absolute http(s) Paperclip base URL to advertise to OpenClaw as \`PAPERCLIP_API_URL\` -- hookIncludeSessionKey (boolean, optional): when true, include derived \`sessionKey\` in \`/hooks/agent\` webhook payloads (default false) - -Session routing fields: -- sessionKeyStrategy (string, optional): \`issue\` (default), \`fixed\`, or \`run\` -- sessionKey (string, optional): fixed session key value when strategy is \`fixed\` (default \`paperclip\`) - -Operational fields: -- timeoutSec (number, optional): SSE request timeout in seconds (default 0 = no adapter timeout) - -Hire-approved callback fields (optional): -- hireApprovedCallbackUrl (string): callback endpoint invoked when this agent is approved/hired -- hireApprovedCallbackMethod (string): HTTP method for the callback (default POST) -- hireApprovedCallbackAuthHeader (string): Authorization header value for callback requests -- hireApprovedCallbackHeaders (object): extra headers merged into callback requests -`; diff --git a/packages/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts deleted file mode 100644 index 427a6c86..00000000 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ /dev/null @@ -1,534 +0,0 @@ -import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; -import { createHash } from "node:crypto"; -import { parseOpenClawResponse } from "./parse.js"; - -export type OpenClawTransport = "sse" | "webhook"; -export type SessionKeyStrategy = "fixed" | "issue" | "run"; -export type OpenClawEndpointKind = "open_responses" | "hook_wake" | "hook_agent" | "generic"; - -export type WakePayload = { - runId: string; - agentId: string; - companyId: string; - taskId: string | null; - issueId: string | null; - wakeReason: string | null; - wakeCommentId: string | null; - approvalId: string | null; - approvalStatus: string | null; - issueIds: string[]; -}; - -export type OpenClawExecutionState = { - method: string; - timeoutSec: number; - headers: Record; - payloadTemplate: Record; - wakePayload: WakePayload; - sessionKey: string; - paperclipEnv: Record; - wakeText: string; -}; - -const SENSITIVE_LOG_KEY_PATTERN = - /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; - -export function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -export function toAuthorizationHeaderValue(rawToken: string): string { - const trimmed = rawToken.trim(); - if (!trimmed) return trimmed; - return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; -} - -export function resolvePaperclipApiUrlOverride(value: unknown): string | null { - const raw = nonEmpty(value); - if (!raw) return null; - try { - const parsed = new URL(raw); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - return parsed.toString(); - } catch { - return null; - } -} - -export function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { - const normalized = asString(value, "issue").trim().toLowerCase(); - if (normalized === "fixed" || normalized === "run") return normalized; - return "issue"; -} - -export function resolveSessionKey(input: { - strategy: SessionKeyStrategy; - configuredSessionKey: string | null; - runId: string; - issueId: string | null; -}): string { - const fallback = input.configuredSessionKey ?? "paperclip"; - if (input.strategy === "run") return `paperclip:run:${input.runId}`; - if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`; - return fallback; -} - -function normalizeUrlPath(pathname: string): string { - const trimmed = pathname.trim().toLowerCase(); - if (!trimmed) return "/"; - return trimmed.endsWith("/") && trimmed !== "/" ? trimmed.slice(0, -1) : trimmed; -} - -function isWakePath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return normalized === "/hooks/wake" || normalized.endsWith("/hooks/wake"); -} - -function isHookAgentPath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return normalized === "/hooks/agent" || normalized.endsWith("/hooks/agent"); -} - -function isHookPath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return ( - normalized === "/hooks" || - normalized.startsWith("/hooks/") || - normalized.endsWith("/hooks") || - normalized.includes("/hooks/") - ); -} - -export function isHookEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isHookPath(parsed.pathname); - } catch { - return false; - } -} - -export function isWakeCompatibilityEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isWakePath(parsed.pathname); - } catch { - return false; - } -} - -export function isHookAgentEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isHookAgentPath(parsed.pathname); - } catch { - return false; - } -} - -export function isOpenResponsesEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - const path = normalizeUrlPath(parsed.pathname); - return path === "/v1/responses" || path.endsWith("/v1/responses"); - } catch { - return false; - } -} - -export function resolveEndpointKind(url: string): OpenClawEndpointKind { - if (isOpenResponsesEndpoint(url)) return "open_responses"; - if (isWakeCompatibilityEndpoint(url)) return "hook_wake"; - if (isHookAgentEndpoint(url)) return "hook_agent"; - return "generic"; -} - -export function deriveHookAgentUrlFromResponses(url: string): string | null { - try { - const parsed = new URL(url); - const path = normalizeUrlPath(parsed.pathname); - if (path === "/v1/responses") { - parsed.pathname = "/hooks/agent"; - return parsed.toString(); - } - if (path.endsWith("/v1/responses")) { - parsed.pathname = `${path.slice(0, -"/v1/responses".length)}/hooks/agent`; - return parsed.toString(); - } - return null; - } catch { - return null; - } -} - -export function toStringRecord(value: unknown): Record { - const parsed = parseObject(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(parsed)) { - if (typeof entry === "string") { - out[key] = entry; - } - } - return out; -} - -function isSensitiveLogKey(key: string): boolean { - return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); -} - -function sha256Prefix(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, 12); -} - -function redactSecretForLog(value: string): string { - return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`; -} - -function truncateForLog(value: string, maxChars = 320): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`; -} - -export function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown { - const currentKey = keyPath[keyPath.length - 1] ?? ""; - if (typeof value === "string") { - if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value); - return truncateForLog(value); - } - if (typeof value === "number" || typeof value === "boolean" || value == null) { - return value; - } - if (Array.isArray(value)) { - if (depth >= 6) return "[array-truncated]"; - const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1)); - if (value.length > 20) out.push(`[+${value.length - 20} more items]`); - return out; - } - if (typeof value === "object") { - if (depth >= 6) return "[object-truncated]"; - const entries = Object.entries(value as Record); - const out: Record = {}; - for (const [key, entry] of entries.slice(0, 80)) { - out[key] = redactForLog(entry, [...keyPath, key], depth + 1); - } - if (entries.length > 80) { - out.__truncated__ = `+${entries.length - 80} keys`; - } - return out; - } - return String(value); -} - -export function stringifyForLog(value: unknown, maxChars: number): string { - const text = JSON.stringify(value); - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; -} - -export function buildWakePayload(ctx: AdapterExecutionContext): WakePayload { - const { runId, agent, context } = ctx; - return { - runId, - agentId: agent.id, - companyId: agent.companyId, - taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId), - issueId: nonEmpty(context.issueId), - wakeReason: nonEmpty(context.wakeReason), - wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId), - approvalId: nonEmpty(context.approvalId), - approvalStatus: nonEmpty(context.approvalStatus), - issueIds: Array.isArray(context.issueIds) - ? context.issueIds.filter( - (value): value is string => typeof value === "string" && value.trim().length > 0, - ) - : [], - }; -} - -export function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record { - const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl); - const paperclipEnv: Record = { - ...buildPaperclipEnv(ctx.agent), - PAPERCLIP_RUN_ID: ctx.runId, - }; - - if (paperclipApiUrlOverride) { - paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; - } - if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; - if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; - if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; - if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId; - if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus; - if (wakePayload.issueIds.length > 0) { - paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(","); - } - - return paperclipEnv; -} - -export function buildWakeText(payload: WakePayload, paperclipEnv: Record): string { - const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; - const orderedKeys = [ - "PAPERCLIP_RUN_ID", - "PAPERCLIP_AGENT_ID", - "PAPERCLIP_COMPANY_ID", - "PAPERCLIP_API_URL", - "PAPERCLIP_TASK_ID", - "PAPERCLIP_WAKE_REASON", - "PAPERCLIP_WAKE_COMMENT_ID", - "PAPERCLIP_APPROVAL_ID", - "PAPERCLIP_APPROVAL_STATUS", - "PAPERCLIP_LINKED_ISSUE_IDS", - ]; - - const envLines: string[] = []; - for (const key of orderedKeys) { - const value = paperclipEnv[key]; - if (!value) continue; - envLines.push(`${key}=${value}`); - } - - const issueIdHint = payload.taskId ?? payload.issueId ?? ""; - const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? ""; - - const lines = [ - "Paperclip wake event for a cloud adapter.", - "", - "Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.", - "", - "Set these values in your run context:", - ...envLines, - `PAPERCLIP_API_KEY=`, - "", - `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, - "", - `api_base=${apiBaseHint}`, - `task_id=${payload.taskId ?? ""}`, - `issue_id=${payload.issueId ?? ""}`, - `wake_reason=${payload.wakeReason ?? ""}`, - `wake_comment_id=${payload.wakeCommentId ?? ""}`, - `approval_id=${payload.approvalId ?? ""}`, - `approval_status=${payload.approvalStatus ?? ""}`, - `linked_issue_ids=${payload.issueIds.join(",")}`, - "", - "HTTP rules:", - "- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.", - "- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.", - "- Use only /api endpoints listed below.", - "- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.", - "", - "Workflow:", - "1) GET /api/agents/me", - `2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`, - "3) If issueId exists:", - " - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}", - " - GET /api/issues/{issueId}", - " - GET /api/issues/{issueId}/comments", - " - Execute the issue instructions exactly.", - " - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.", - " - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.", - "4) If issueId does not exist:", - " - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked", - " - Pick in_progress first, then todo, then blocked, then execute step 3.", - "", - "Useful endpoints for issue work:", - "- POST /api/issues/{issueId}/comments", - "- PATCH /api/issues/{issueId}", - "- POST /api/companies/{companyId}/issues (when asked to create a new issue)", - "", - "Complete the workflow in this run.", - ]; - return lines.join("\n"); -} - -export function appendWakeText(baseText: string, wakeText: string): string { - const trimmedBase = baseText.trim(); - return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; -} - -function buildOpenResponsesWakeInputMessage(wakeText: string): Record { - return { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: wakeText, - }, - ], - }; -} - -export function appendWakeTextToOpenResponsesInput(input: unknown, wakeText: string): unknown { - if (typeof input === "string") { - return appendWakeText(input, wakeText); - } - - if (Array.isArray(input)) { - return [...input, buildOpenResponsesWakeInputMessage(wakeText)]; - } - - if (typeof input === "object" && input !== null) { - const parsed = parseObject(input); - const content = parsed.content; - if (typeof content === "string") { - return { - ...parsed, - content: appendWakeText(content, wakeText), - }; - } - if (Array.isArray(content)) { - return { - ...parsed, - content: [ - ...content, - { - type: "input_text", - text: wakeText, - }, - ], - }; - } - return [parsed, buildOpenResponsesWakeInputMessage(wakeText)]; - } - - return wakeText; -} - -export function isTextRequiredResponse(responseText: string): boolean { - const parsed = parseOpenClawResponse(responseText); - const parsedError = parsed && typeof parsed.error === "string" ? parsed.error : null; - if (parsedError && parsedError.toLowerCase().includes("text required")) { - return true; - } - return responseText.toLowerCase().includes("text required"); -} - -function extractResponseErrorMessage(responseText: string): string { - const parsed = parseOpenClawResponse(responseText); - if (!parsed) return responseText; - - const directError = parsed.error; - if (typeof directError === "string") return directError; - if (directError && typeof directError === "object") { - const nestedMessage = (directError as Record).message; - if (typeof nestedMessage === "string") return nestedMessage; - } - - const directMessage = parsed.message; - if (typeof directMessage === "string") return directMessage; - - return responseText; -} - -export function isWakeCompatibilityRetryableResponse(responseText: string): boolean { - if (isTextRequiredResponse(responseText)) return true; - - const normalized = extractResponseErrorMessage(responseText).toLowerCase(); - const expectsStringInput = - normalized.includes("invalid input") && - normalized.includes("expected string") && - normalized.includes("undefined"); - if (expectsStringInput) return true; - - const missingInputField = - normalized.includes("input") && - (normalized.includes("required") || normalized.includes("missing")); - if (missingInputField) return true; - - return false; -} - -export async function sendJsonRequest(params: { - url: string; - method: string; - headers: Record; - payload: Record; - signal: AbortSignal; -}): Promise { - return fetch(params.url, { - method: params.method, - headers: params.headers, - body: JSON.stringify(params.payload), - signal: params.signal, - }); -} - -export async function readAndLogResponseText(params: { - response: Response; - onLog: AdapterExecutionContext["onLog"]; -}): Promise { - const responseText = await params.response.text(); - if (responseText.trim().length > 0) { - await params.onLog( - "stdout", - `[openclaw] response (${params.response.status}) ${responseText.slice(0, 2000)}\n`, - ); - } else { - await params.onLog("stdout", `[openclaw] response (${params.response.status}) \n`); - } - return responseText; -} - -export function buildExecutionState(ctx: AdapterExecutionContext): OpenClawExecutionState { - const method = asString(ctx.config.method, "POST").trim().toUpperCase() || "POST"; - const timeoutSecRaw = asNumber(ctx.config.timeoutSec, 0); - const timeoutSec = timeoutSecRaw > 0 ? Math.max(1, Math.floor(timeoutSecRaw)) : 0; - const headersConfig = parseObject(ctx.config.headers) as Record; - const payloadTemplate = parseObject(ctx.config.payloadTemplate); - const webhookAuthHeader = nonEmpty(ctx.config.webhookAuthHeader); - const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); - - 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; - } - } - - const openClawAuthHeader = nonEmpty( - headers["x-openclaw-token"] ?? - headers["X-OpenClaw-Token"] ?? - headers["x-openclaw-auth"] ?? - headers["X-OpenClaw-Auth"], - ); - if (openClawAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = toAuthorizationHeaderValue(openClawAuthHeader); - } - if (webhookAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = webhookAuthHeader; - } - - const wakePayload = buildWakePayload(ctx); - const sessionKey = resolveSessionKey({ - strategy: sessionKeyStrategy, - configuredSessionKey: nonEmpty(ctx.config.sessionKey), - runId: ctx.runId, - issueId: wakePayload.issueId ?? wakePayload.taskId, - }); - - const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload); - const wakeText = buildWakeText(wakePayload, paperclipEnv); - - return { - method, - timeoutSec, - headers, - payloadTemplate, - wakePayload, - sessionKey, - paperclipEnv, - wakeText, - }; -} - -export function buildWakeCompatibilityPayload(wakeText: string): Record { - return { - text: wakeText, - mode: "now", - }; -} diff --git a/packages/adapters/openclaw/src/server/execute-sse.ts b/packages/adapters/openclaw/src/server/execute-sse.ts deleted file mode 100644 index 2729f466..00000000 --- a/packages/adapters/openclaw/src/server/execute-sse.ts +++ /dev/null @@ -1,469 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { - appendWakeTextToOpenResponsesInput, - buildExecutionState, - isOpenResponsesEndpoint, - isTextRequiredResponse, - readAndLogResponseText, - redactForLog, - sendJsonRequest, - stringifyForLog, - toStringRecord, - type OpenClawExecutionState, -} from "./execute-common.js"; -import { parseOpenClawResponse } from "./parse.js"; - -type ConsumedSse = { - eventCount: number; - lastEventType: string | null; - lastData: string | null; - lastPayload: Record | null; - terminal: boolean; - failed: boolean; - errorMessage: string | null; -}; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function inferSseTerminal(input: { - eventType: string; - data: string; - parsedPayload: Record | null; -}): { terminal: boolean; failed: boolean; errorMessage: string | null } { - const normalizedType = input.eventType.trim().toLowerCase(); - const trimmedData = input.data.trim(); - const payload = input.parsedPayload; - const payloadType = nonEmpty(payload?.type)?.toLowerCase() ?? null; - const payloadStatus = nonEmpty(payload?.status)?.toLowerCase() ?? null; - - if (trimmedData === "[DONE]") { - return { terminal: true, failed: false, errorMessage: null }; - } - - const failType = - normalizedType.includes("error") || - normalizedType.includes("failed") || - normalizedType.includes("cancel"); - if (failType) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - (trimmedData.length > 0 ? trimmedData : "OpenClaw SSE error"), - }; - } - - const doneType = - normalizedType === "done" || - normalizedType.endsWith(".completed") || - normalizedType === "completed"; - if (doneType) { - return { terminal: true, failed: false, errorMessage: null }; - } - - if (payloadStatus) { - if ( - payloadStatus === "completed" || - payloadStatus === "succeeded" || - payloadStatus === "done" - ) { - return { terminal: true, failed: false, errorMessage: null }; - } - if ( - payloadStatus === "failed" || - payloadStatus === "cancelled" || - payloadStatus === "error" - ) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - `OpenClaw SSE status ${payloadStatus}`, - }; - } - } - - if (payloadType) { - if (payloadType.endsWith(".completed")) { - return { terminal: true, failed: false, errorMessage: null }; - } - if ( - payloadType.endsWith(".failed") || - payloadType.endsWith(".cancelled") || - payloadType.endsWith(".error") - ) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - `OpenClaw SSE type ${payloadType}`, - }; - } - } - - if (payload?.done === true) { - return { terminal: true, failed: false, errorMessage: null }; - } - - return { terminal: false, failed: false, errorMessage: null }; -} - -async function consumeSseResponse(params: { - response: Response; - onLog: AdapterExecutionContext["onLog"]; -}): Promise { - const reader = params.response.body?.getReader(); - if (!reader) { - throw new Error("OpenClaw SSE response body is missing"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - let eventType = "message"; - let dataLines: string[] = []; - let eventCount = 0; - let lastEventType: string | null = null; - let lastData: string | null = null; - let lastPayload: Record | null = null; - let terminal = false; - let failed = false; - let errorMessage: string | null = null; - - const dispatchEvent = async (): Promise => { - if (dataLines.length === 0) { - eventType = "message"; - return false; - } - - const data = dataLines.join("\n"); - const trimmedData = data.trim(); - const parsedPayload = parseOpenClawResponse(trimmedData); - - eventCount += 1; - lastEventType = eventType; - lastData = data; - if (parsedPayload) lastPayload = parsedPayload; - - const preview = - trimmedData.length > 1000 ? `${trimmedData.slice(0, 1000)}...` : trimmedData; - await params.onLog("stdout", `[openclaw:sse] event=${eventType} data=${preview}\n`); - - const resolution = inferSseTerminal({ - eventType, - data, - parsedPayload, - }); - - dataLines = []; - eventType = "message"; - - if (resolution.terminal) { - terminal = true; - failed = resolution.failed; - errorMessage = resolution.errorMessage; - return true; - } - - return false; - }; - - let shouldStop = false; - while (!shouldStop) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - while (!shouldStop) { - const newlineIndex = buffer.indexOf("\n"); - if (newlineIndex === -1) break; - - let line = buffer.slice(0, newlineIndex); - buffer = buffer.slice(newlineIndex + 1); - if (line.endsWith("\r")) line = line.slice(0, -1); - - if (line.length === 0) { - shouldStop = await dispatchEvent(); - continue; - } - - if (line.startsWith(":")) continue; - - const colonIndex = line.indexOf(":"); - const field = colonIndex === -1 ? line : line.slice(0, colonIndex); - const rawValue = - colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, ""); - - if (field === "event") { - eventType = rawValue || "message"; - } else if (field === "data") { - dataLines.push(rawValue); - } - } - } - - buffer += decoder.decode(); - if (!shouldStop && buffer.trim().length > 0) { - for (const rawLine of buffer.split(/\r?\n/)) { - const line = rawLine.trimEnd(); - if (line.length === 0) { - shouldStop = await dispatchEvent(); - if (shouldStop) break; - continue; - } - if (line.startsWith(":")) continue; - - const colonIndex = line.indexOf(":"); - const field = colonIndex === -1 ? line : line.slice(0, colonIndex); - const rawValue = - colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, ""); - - if (field === "event") { - eventType = rawValue || "message"; - } else if (field === "data") { - dataLines.push(rawValue); - } - } - } - - if (!shouldStop && dataLines.length > 0) { - await dispatchEvent(); - } - - return { - eventCount, - lastEventType, - lastData, - lastPayload, - terminal, - failed, - errorMessage, - }; -} - -function buildSseBody(input: { - url: string; - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; - configModel: unknown; -}): { headers: Record; body: Record } { - const { url, state, context, configModel } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? `${templateText}\n\n${state.wakeText}` : state.wakeText; - - const isOpenResponses = isOpenResponsesEndpoint(url); - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; - - const body: Record = isOpenResponses - ? { - ...state.payloadTemplate, - stream: true, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - }, - } - : { - ...state.payloadTemplate, - stream: true, - sessionKey: state.sessionKey, - text: payloadText, - paperclip: { - ...state.wakePayload, - sessionKey: state.sessionKey, - streamTransport: "sse", - env: state.paperclipEnv, - context, - }, - }; - - const headers: Record = { - ...state.headers, - accept: "text/event-stream", - }; - - if (isOpenResponses && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - - return { headers, body }; -} - -export async function executeSse(ctx: AdapterExecutionContext, url: string): Promise { - const { onLog, onMeta, context } = ctx; - const state = buildExecutionState(ctx); - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "sse", - commandArgs: [state.method, url], - context, - }); - } - - const { headers, body } = buildSseBody({ - url, - state, - context, - configModel: ctx.config.model, - }); - - const outboundHeaderKeys = Object.keys(headers).sort(); - await onLog( - "stdout", - `[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(body), 12_000)}\n`, - ); - await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${url} (transport=sse)\n`); - - const controller = new AbortController(); - const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null; - - try { - const response = await sendJsonRequest({ - url, - method: state.method, - headers, - payload: body, - signal: controller.signal, - }); - - if (!response.ok) { - const responseText = await readAndLogResponseText({ response, onLog }); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw SSE request failed with status ${response.status}`, - errorCode: isTextRequiredResponse(responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: response.status, - statusText: response.statusText, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); - if (!contentType.includes("text/event-stream")) { - const responseText = await readAndLogResponseText({ response, onLog }); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw SSE endpoint did not return text/event-stream", - errorCode: "openclaw_sse_expected_event_stream", - resultJson: { - status: response.status, - statusText: response.statusText, - contentType, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - const consumed = await consumeSseResponse({ response, onLog }); - if (consumed.failed) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: consumed.errorMessage ?? "OpenClaw SSE stream failed", - errorCode: "openclaw_sse_stream_failed", - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } - - if (!consumed.terminal) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw SSE stream closed without a terminal event", - errorCode: "openclaw_sse_stream_incomplete", - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw SSE ${state.method} ${url}`, - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - const timeoutMessage = - state.timeoutSec > 0 - ? `[openclaw] SSE request timed out after ${state.timeoutSec}s\n` - : "[openclaw] SSE request aborted\n"; - await onLog("stderr", timeoutMessage); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted", - errorCode: "openclaw_sse_timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/execute-webhook.ts b/packages/adapters/openclaw/src/server/execute-webhook.ts deleted file mode 100644 index a4f55989..00000000 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ /dev/null @@ -1,463 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { - appendWakeText, - appendWakeTextToOpenResponsesInput, - buildExecutionState, - buildWakeCompatibilityPayload, - deriveHookAgentUrlFromResponses, - isTextRequiredResponse, - isWakeCompatibilityRetryableResponse, - readAndLogResponseText, - redactForLog, - resolveEndpointKind, - sendJsonRequest, - stringifyForLog, - toStringRecord, - type OpenClawEndpointKind, - type OpenClawExecutionState, -} from "./execute-common.js"; -import { parseOpenClawResponse } from "./parse.js"; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function asBooleanFlag(value: unknown, fallback = false): boolean { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true" || normalized === "1") return true; - if (normalized === "false" || normalized === "0") return false; - } - return fallback; -} - -function normalizeWakeMode(value: unknown): "now" | "next-heartbeat" | null { - if (typeof value !== "string") return null; - const normalized = value.trim().toLowerCase(); - if (normalized === "now" || normalized === "next-heartbeat") return normalized; - return null; -} - -function parseOptionalPositiveInteger(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - const normalized = Math.max(1, Math.floor(value)); - return Number.isFinite(normalized) ? normalized : null; - } - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number.parseInt(value.trim(), 10); - if (Number.isFinite(parsed)) { - const normalized = Math.max(1, Math.floor(parsed)); - return Number.isFinite(normalized) ? normalized : null; - } - } - return null; -} - -function buildOpenResponsesWebhookBody(input: { - state: OpenClawExecutionState; - configModel: unknown; -}): Record { - const { state, configModel } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; - - return { - ...state.payloadTemplate, - stream: false, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - paperclip_stream_transport: "webhook", - }, - }; -} - -function buildHookWakeBody(state: OpenClawExecutionState): Record { - const templateText = nonEmpty(state.payloadTemplate.text) ?? nonEmpty(state.payloadTemplate.message); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const wakeMode = normalizeWakeMode(state.payloadTemplate.mode ?? state.payloadTemplate.wakeMode) ?? "now"; - - return { - text: payloadText, - mode: wakeMode, - }; -} - -function buildHookAgentBody(input: { - state: OpenClawExecutionState; - includeSessionKey: boolean; -}): Record { - const { state, includeSessionKey } = input; - const templateMessage = nonEmpty(state.payloadTemplate.message) ?? nonEmpty(state.payloadTemplate.text); - const message = templateMessage ? appendWakeText(templateMessage, state.wakeText) : state.wakeText; - const payload: Record = { - message, - }; - - const name = nonEmpty(state.payloadTemplate.name); - if (name) payload.name = name; - - const agentId = nonEmpty(state.payloadTemplate.agentId); - if (agentId) payload.agentId = agentId; - - const wakeMode = normalizeWakeMode(state.payloadTemplate.wakeMode ?? state.payloadTemplate.mode); - if (wakeMode) payload.wakeMode = wakeMode; - - const deliver = state.payloadTemplate.deliver; - if (typeof deliver === "boolean") payload.deliver = deliver; - - const channel = nonEmpty(state.payloadTemplate.channel); - if (channel) payload.channel = channel; - - const to = nonEmpty(state.payloadTemplate.to); - if (to) payload.to = to; - - const model = nonEmpty(state.payloadTemplate.model); - if (model) payload.model = model; - - const thinking = nonEmpty(state.payloadTemplate.thinking); - if (thinking) payload.thinking = thinking; - - const timeoutSeconds = parseOptionalPositiveInteger(state.payloadTemplate.timeoutSeconds); - if (timeoutSeconds != null) payload.timeoutSeconds = timeoutSeconds; - - const explicitSessionKey = nonEmpty(state.payloadTemplate.sessionKey); - if (explicitSessionKey) { - payload.sessionKey = explicitSessionKey; - } else if (includeSessionKey) { - payload.sessionKey = state.sessionKey; - } - - return payload; -} - -function buildLegacyWebhookBody(input: { - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; -}): Record { - const { state, context } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - return { - ...state.payloadTemplate, - stream: false, - sessionKey: state.sessionKey, - text: payloadText, - paperclip: { - ...state.wakePayload, - sessionKey: state.sessionKey, - streamTransport: "webhook", - env: state.paperclipEnv, - context, - }, - }; -} - -function buildWebhookBody(input: { - endpointKind: OpenClawEndpointKind; - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; - configModel: unknown; - includeHookSessionKey: boolean; -}): Record { - const { endpointKind, state, context, configModel, includeHookSessionKey } = input; - if (endpointKind === "open_responses") { - return buildOpenResponsesWebhookBody({ state, configModel }); - } - if (endpointKind === "hook_wake") { - return buildHookWakeBody(state); - } - if (endpointKind === "hook_agent") { - return buildHookAgentBody({ state, includeSessionKey: includeHookSessionKey }); - } - - return buildLegacyWebhookBody({ state, context }); -} - -async function sendWebhookRequest(params: { - url: string; - method: string; - headers: Record; - payload: Record; - onLog: AdapterExecutionContext["onLog"]; - signal: AbortSignal; -}): Promise<{ response: Response; responseText: string }> { - const response = await sendJsonRequest({ - url: params.url, - method: params.method, - headers: params.headers, - payload: params.payload, - signal: params.signal, - }); - - const responseText = await readAndLogResponseText({ response, onLog: params.onLog }); - return { response, responseText }; -} - -export async function executeWebhook(ctx: AdapterExecutionContext, url: string): Promise { - const { onLog, onMeta, context } = ctx; - const state = buildExecutionState(ctx); - const originalUrl = url; - const originalEndpointKind = resolveEndpointKind(originalUrl); - let targetUrl = originalUrl; - let endpointKind = resolveEndpointKind(targetUrl); - const remappedFromResponses = originalEndpointKind === "open_responses"; - - // In webhook mode, /v1/responses is legacy wiring. Prefer hooks/agent. - if (remappedFromResponses) { - const rewritten = deriveHookAgentUrlFromResponses(targetUrl); - if (rewritten) { - await onLog( - "stdout", - `[openclaw] webhook transport selected; remapping ${targetUrl} -> ${rewritten}\n`, - ); - targetUrl = rewritten; - endpointKind = resolveEndpointKind(targetUrl); - } - } - - const headers = { ...state.headers }; - if (endpointKind === "open_responses" && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "webhook", - commandArgs: [state.method, targetUrl], - context, - }); - } - - const includeHookSessionKey = asBooleanFlag(ctx.config.hookIncludeSessionKey, false); - const webhookBody = buildWebhookBody({ - endpointKind, - state, - context, - configModel: ctx.config.model, - includeHookSessionKey, - }); - const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); - const preferWakeCompatibilityBody = endpointKind === "hook_wake"; - const initialBody = webhookBody; - - const outboundHeaderKeys = Object.keys(headers).sort(); - await onLog( - "stdout", - `[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(initialBody), 12_000)}\n`, - ); - await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${targetUrl} (transport=webhook kind=${endpointKind})\n`); - - if (preferWakeCompatibilityBody) { - await onLog("stdout", "[openclaw] using webhook wake payload for /hooks/wake\n"); - } - - const controller = new AbortController(); - const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null; - - try { - const initialResponse = await sendWebhookRequest({ - url: targetUrl, - method: state.method, - headers, - payload: initialBody, - onLog, - signal: controller.signal, - }); - - let activeResponse = initialResponse; - let activeEndpointKind = endpointKind; - let activeUrl = targetUrl; - let activeHeaders = headers; - let usedLegacyResponsesFallback = false; - - if ( - remappedFromResponses && - targetUrl !== originalUrl && - initialResponse.response.status === 404 - ) { - await onLog( - "stdout", - `[openclaw] remapped hook endpoint returned 404; retrying legacy endpoint ${originalUrl}\n`, - ); - - activeEndpointKind = originalEndpointKind; - activeUrl = originalUrl; - usedLegacyResponsesFallback = true; - const fallbackHeaders = { ...state.headers }; - if ( - activeEndpointKind === "open_responses" && - !fallbackHeaders["x-openclaw-session-key"] && - !fallbackHeaders["X-OpenClaw-Session-Key"] - ) { - fallbackHeaders["x-openclaw-session-key"] = state.sessionKey; - } - - const fallbackBody = buildWebhookBody({ - endpointKind: activeEndpointKind, - state, - context, - configModel: ctx.config.model, - includeHookSessionKey, - }); - - await onLog( - "stdout", - `[openclaw] fallback headers (redacted): ${stringifyForLog(redactForLog(fallbackHeaders), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] fallback payload (redacted): ${stringifyForLog(redactForLog(fallbackBody), 12_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] invoking fallback ${state.method} ${activeUrl} (transport=webhook kind=${activeEndpointKind})\n`, - ); - - activeResponse = await sendWebhookRequest({ - url: activeUrl, - method: state.method, - headers: fallbackHeaders, - payload: fallbackBody, - onLog, - signal: controller.signal, - }); - activeHeaders = fallbackHeaders; - } - - if (!activeResponse.response.ok) { - const canRetryWithWakeCompatibility = - (activeEndpointKind === "open_responses" || activeEndpointKind === "generic") && - isWakeCompatibilityRetryableResponse(activeResponse.responseText); - - if (canRetryWithWakeCompatibility) { - await onLog( - "stdout", - "[openclaw] endpoint requires text payload; retrying with wake compatibility format\n", - ); - - const retryResponse = await sendWebhookRequest({ - url: activeUrl, - method: state.method, - headers: activeHeaders, - payload: wakeCompatibilityBody, - onLog, - signal: controller.signal, - }); - - if (retryResponse.response.ok) { - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${state.method} ${activeUrl} (wake compatibility)`, - resultJson: { - status: retryResponse.response.status, - statusText: retryResponse.response.statusText, - compatibilityMode: "wake_text", - usedLegacyResponsesFallback, - response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, - }, - }; - } - - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(retryResponse.responseText) - ? "OpenClaw endpoint rejected the wake compatibility payload as text-required." - : `OpenClaw webhook failed with status ${retryResponse.response.status}`, - errorCode: isTextRequiredResponse(retryResponse.responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: retryResponse.response.status, - statusText: retryResponse.response.statusText, - compatibilityMode: "wake_text", - response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, - }, - }; - } - - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(activeResponse.responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw webhook failed with status ${activeResponse.response.status}`, - errorCode: isTextRequiredResponse(activeResponse.responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: activeResponse.response.status, - statusText: activeResponse.response.statusText, - response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${state.method} ${activeUrl}`, - resultJson: { - status: activeResponse.response.status, - statusText: activeResponse.response.statusText, - usedLegacyResponsesFallback, - response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - const timeoutMessage = - state.timeoutSec > 0 - ? `[openclaw] webhook request timed out after ${state.timeoutSec}s\n` - : "[openclaw] webhook request aborted\n"; - await onLog("stderr", timeoutMessage); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted", - errorCode: "openclaw_webhook_timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts deleted file mode 100644 index c560a067..00000000 --- a/packages/adapters/openclaw/src/server/execute.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { asString } from "@paperclipai/adapter-utils/server-utils"; -import { isHookEndpoint } from "./execute-common.js"; -import { executeSse } from "./execute-sse.js"; -import { executeWebhook } from "./execute-webhook.js"; - -function normalizeTransport(value: unknown): "sse" | "webhook" | null { - const normalized = asString(value, "sse").trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - -export async function execute(ctx: AdapterExecutionContext): Promise { - const url = asString(ctx.config.url, "").trim(); - if (!url) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw adapter missing url", - errorCode: "openclaw_url_missing", - }; - } - - const transportInput = ctx.config.streamTransport ?? ctx.config.transport; - const transport = normalizeTransport(transportInput); - if (!transport) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `OpenClaw adapter does not support transport: ${String(transportInput)}`, - errorCode: "openclaw_stream_transport_unsupported", - }; - } - - if (transport === "sse" && isHookEndpoint(url)) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw /hooks/* endpoints are not stream-capable. Use webhook transport for hooks.", - errorCode: "openclaw_sse_incompatible_endpoint", - }; - } - - if (transport === "webhook") { - return executeWebhook(ctx, url); - } - - return executeSse(ctx, url); -} diff --git a/packages/adapters/openclaw/src/server/hire-hook.ts b/packages/adapters/openclaw/src/server/hire-hook.ts deleted file mode 100644 index 2b6262c9..00000000 --- a/packages/adapters/openclaw/src/server/hire-hook.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { HireApprovedPayload, HireApprovedHookResult } from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -const HIRE_CALLBACK_TIMEOUT_MS = 10_000; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -/** - * OpenClaw adapter lifecycle hook: when an agent is approved/hired, POST the payload to a - * configured callback URL so the cloud operator can notify the user (e.g. "you're hired"). - * Best-effort; failures are non-fatal to the approval flow. - */ -export async function onHireApproved( - payload: HireApprovedPayload, - adapterConfig: Record, -): Promise { - const config = parseObject(adapterConfig); - const url = nonEmpty(config.hireApprovedCallbackUrl); - if (!url) { - return { ok: true }; - } - - const method = (asString(config.hireApprovedCallbackMethod, "POST").trim().toUpperCase()) || "POST"; - const authHeader = nonEmpty(config.hireApprovedCallbackAuthHeader) ?? nonEmpty(config.webhookAuthHeader); - - const headers: Record = { - "content-type": "application/json", - }; - if (authHeader && !headers.authorization && !headers.Authorization) { - headers.Authorization = authHeader; - } - const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record; - for (const [key, value] of Object.entries(extraHeaders)) { - if (typeof value === "string" && value.trim().length > 0) { - headers[key] = value; - } - } - - const body = JSON.stringify({ - ...payload, - event: "hire_approved", - }); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HIRE_CALLBACK_TIMEOUT_MS); - - try { - const response = await fetch(url, { - method, - headers, - body, - signal: controller.signal, - }); - clearTimeout(timeout); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - ok: false, - error: `HTTP ${response.status} ${response.statusText}`, - detail: { status: response.status, statusText: response.statusText, body: text.slice(0, 500) }, - }; - } - return { ok: true }; - } catch (err) { - clearTimeout(timeout); - const message = err instanceof Error ? err.message : String(err); - const cause = err instanceof Error ? err.cause : undefined; - return { - ok: false, - error: message, - detail: cause != null ? { cause: String(cause) } : undefined, - }; - } -} diff --git a/packages/adapters/openclaw/src/server/index.ts b/packages/adapters/openclaw/src/server/index.ts deleted file mode 100644 index 05c4b355..00000000 --- a/packages/adapters/openclaw/src/server/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { execute } from "./execute.js"; -export { testEnvironment } from "./test.js"; -export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js"; -export { onHireApproved } from "./hire-hook.js"; diff --git a/packages/adapters/openclaw/src/server/parse.ts b/packages/adapters/openclaw/src/server/parse.ts deleted file mode 100644 index 5045c202..00000000 --- a/packages/adapters/openclaw/src/server/parse.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index ea5bcd85..00000000 --- a/packages/adapters/openclaw/src/server/test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { - AdapterEnvironmentCheck, - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, -} from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { - if (checks.some((check) => check.level === "error")) return "fail"; - if (checks.some((check) => check.level === "warn")) return "warn"; - return "pass"; -} - -function isLoopbackHost(hostname: string): boolean { - const value = hostname.trim().toLowerCase(); - return value === "localhost" || value === "127.0.0.1" || value === "::1"; -} - -function normalizeHostname(value: string | null | undefined): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (!trimmed) return null; - if (trimmed.startsWith("[")) { - const end = trimmed.indexOf("]"); - return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); - } - const firstColon = trimmed.indexOf(":"); - if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); - return trimmed.toLowerCase(); -} - -function isWakePath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return value === "/hooks/wake" || value.endsWith("/hooks/wake"); -} - -function isHooksPath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return ( - value === "/hooks" || - value.startsWith("/hooks/") || - value.endsWith("/hooks") || - value.includes("/hooks/") - ); -} - -function normalizeTransport(value: unknown): "sse" | "webhook" | null { - const normalized = asString(value, "sse").trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - -function pushDeploymentDiagnostics( - checks: AdapterEnvironmentCheck[], - ctx: AdapterEnvironmentTestContext, - endpointUrl: URL | null, -) { - const mode = ctx.deployment?.mode; - const exposure = ctx.deployment?.exposure; - const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null); - const allowSet = new Set( - (ctx.deployment?.allowedHostnames ?? []) - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), - ); - const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null; - - if (!mode) return; - - checks.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`, - }); - - if (mode === "authenticated" && exposure === "private") { - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - checks.push({ - code: "openclaw_private_bind_hostname_not_allowed", - level: "warn", - message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`, - }); - } - - if (!bindHost || isLoopbackHost(bindHost)) { - checks.push({ - code: "openclaw_private_bind_loopback", - level: "warn", - message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.", - }); - } - - if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) { - checks.push({ - code: "openclaw_private_no_allowed_hostnames", - level: "warn", - message: "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname 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, ""); - const streamTransportValue = config.streamTransport ?? config.transport; - const streamTransport = normalizeTransport(streamTransportValue); - - if (!urlValue) { - checks.push({ - code: "openclaw_url_missing", - level: "error", - message: "OpenClaw adapter requires an endpoint URL.", - hint: "Set adapterConfig.url to your OpenClaw transport endpoint.", - }); - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; - } - - let url: URL | null = null; - try { - url = new URL(urlValue); - } catch { - checks.push({ - code: "openclaw_url_invalid", - level: "error", - message: `Invalid URL: ${urlValue}`, - }); - } - - if (url && url.protocol !== "http:" && url.protocol !== "https:") { - checks.push({ - code: "openclaw_url_protocol_invalid", - level: "error", - message: `Unsupported URL protocol: ${url.protocol}`, - hint: "Use an http:// or https:// endpoint.", - }); - } - - if (url) { - checks.push({ - code: "openclaw_url_valid", - level: "info", - message: `Configured endpoint: ${url.toString()}`, - }); - - if (isLoopbackHost(url.hostname)) { - checks.push({ - code: "openclaw_loopback_endpoint", - level: "warn", - message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).", - }); - } - - if (streamTransport === "sse" && (isWakePath(url.pathname) || isHooksPath(url.pathname))) { - checks.push({ - code: "openclaw_wake_endpoint_incompatible", - level: "error", - message: "Endpoint targets /hooks/*, which is not stream-capable for SSE transport.", - hint: "Use webhook transport for /hooks/* endpoints.", - }); - } - } - - if (!streamTransport) { - checks.push({ - code: "openclaw_stream_transport_unsupported", - level: "error", - message: `Unsupported streamTransport: ${String(streamTransportValue)}`, - hint: "Use streamTransport=sse or streamTransport=webhook.", - }); - } else { - checks.push({ - code: "openclaw_stream_transport_configured", - level: "info", - message: `Configured stream transport: ${streamTransport}`, - }); - } - - pushDeploymentDiagnostics(checks, ctx, url); - - const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; - checks.push({ - code: "openclaw_method_configured", - level: "info", - message: `Configured method: ${method}`, - }); - - if (url && (url.protocol === "http:" || url.protocol === "https:")) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - try { - const response = await fetch(url, { method: "HEAD", signal: controller.signal }); - if (!response.ok && response.status !== 405 && response.status !== 501) { - checks.push({ - code: "openclaw_endpoint_probe_unexpected_status", - level: "warn", - message: `Endpoint probe returned HTTP ${response.status}.`, - hint: "Verify OpenClaw endpoint reachability and auth/network settings.", - }); - } else { - checks.push({ - code: "openclaw_endpoint_probe_ok", - level: "info", - message: "Endpoint responded to a HEAD probe.", - }); - } - } catch (err) { - checks.push({ - code: "openclaw_endpoint_probe_failed", - level: "warn", - message: err instanceof Error ? err.message : "Endpoint probe failed", - hint: "This may be expected in restricted networks; validate from the Paperclip server host.", - }); - } finally { - clearTimeout(timeout); - } - } - - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; -} diff --git a/packages/adapters/openclaw/src/shared/stream.ts b/packages/adapters/openclaw/src/shared/stream.ts deleted file mode 100644 index a2e84357..00000000 --- a/packages/adapters/openclaw/src/shared/stream.ts +++ /dev/null @@ -1,16 +0,0 @@ -export function normalizeOpenClawStreamLine(rawLine: string): { - stream: "stdout" | "stderr" | null; - line: string; -} { - const trimmed = rawLine.trim(); - if (!trimmed) return { stream: null, line: "" }; - - const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i); - if (!prefixed) { - return { stream: null, line: trimmed }; - } - - const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout"; - const line = (prefixed[2] ?? "").trim(); - return { stream, line }; -} diff --git a/packages/adapters/openclaw/src/ui/build-config.ts b/packages/adapters/openclaw/src/ui/build-config.ts deleted file mode 100644 index ca8c98e3..00000000 --- a/packages/adapters/openclaw/src/ui/build-config.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 = 0; - ac.streamTransport = "sse"; - ac.sessionKeyStrategy = "issue"; - return ac; -} diff --git a/packages/adapters/openclaw/src/ui/index.ts b/packages/adapters/openclaw/src/ui/index.ts deleted file mode 100644 index f3f1905e..00000000 --- a/packages/adapters/openclaw/src/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 55c7f3fe..00000000 --- a/packages/adapters/openclaw/src/ui/parse-stdout.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { TranscriptEntry } from "@paperclipai/adapter-utils"; -import { normalizeOpenClawStreamLine } from "../shared/stream.js"; - -function safeJsonParse(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asNumber(value: unknown, fallback = 0): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function stringifyUnknown(value: unknown): string { - if (typeof value === "string") return value; - if (value === null || value === undefined) return ""; - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function readErrorText(value: unknown): string { - if (typeof value === "string") return value; - const obj = asRecord(value); - if (!obj) return stringifyUnknown(value); - return ( - asString(obj.message).trim() || - asString(obj.error).trim() || - asString(obj.code).trim() || - stringifyUnknown(obj) - ); -} - -function readDeltaText(payload: Record | null): string { - if (!payload) return ""; - - if (typeof payload.delta === "string") return payload.delta; - - const deltaObj = asRecord(payload.delta); - if (deltaObj) { - const nestedDelta = - asString(deltaObj.text) || - asString(deltaObj.value) || - asString(deltaObj.delta); - if (nestedDelta.length > 0) return nestedDelta; - } - - const part = asRecord(payload.part); - if (part) { - const partText = asString(part.text); - if (partText.length > 0) return partText; - } - - return ""; -} - -function extractResponseOutputText(response: Record | null): string { - if (!response) return ""; - - const output = Array.isArray(response.output) ? response.output : []; - const parts: string[] = []; - for (const itemRaw of output) { - const item = asRecord(itemRaw); - if (!item) continue; - const content = Array.isArray(item.content) ? item.content : []; - for (const partRaw of content) { - const part = asRecord(partRaw); - if (!part) continue; - const type = asString(part.type).trim().toLowerCase(); - if (type !== "output_text" && type !== "text" && type !== "refusal") continue; - const text = asString(part.text).trim(); - if (text) parts.push(text); - } - } - return parts.join("\n\n").trim(); -} - -function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] { - const match = line.match(/^\[openclaw:sse\]\s+event=([^\s]+)\s+data=(.*)$/s); - if (!match) return [{ kind: "stdout", ts, text: line }]; - - const eventType = (match[1] ?? "").trim(); - const dataText = (match[2] ?? "").trim(); - const parsed = asRecord(safeJsonParse(dataText)); - const normalizedEventType = eventType.toLowerCase(); - - if (dataText === "[DONE]") { - return []; - } - - const delta = readDeltaText(parsed); - if (normalizedEventType.endsWith(".delta") && delta.length > 0) { - return [{ kind: "assistant", ts, text: delta, delta: true }]; - } - - if ( - normalizedEventType.includes("error") || - normalizedEventType.includes("failed") || - normalizedEventType.includes("cancel") - ) { - const message = readErrorText(parsed?.error) || readErrorText(parsed?.message) || dataText; - return message ? [{ kind: "stderr", ts, text: message }] : []; - } - - if (normalizedEventType === "response.completed" || normalizedEventType.endsWith(".completed")) { - const response = asRecord(parsed?.response); - const usage = asRecord(response?.usage); - const status = asString(response?.status, asString(parsed?.status, eventType)); - const statusLower = status.trim().toLowerCase(); - const errorText = - readErrorText(response?.error).trim() || - readErrorText(parsed?.error).trim() || - readErrorText(parsed?.message).trim(); - const isError = - statusLower === "failed" || - statusLower === "error" || - statusLower === "cancelled"; - - return [{ - kind: "result", - ts, - text: extractResponseOutputText(response), - inputTokens: asNumber(usage?.input_tokens), - outputTokens: asNumber(usage?.output_tokens), - cachedTokens: asNumber(usage?.cached_input_tokens), - costUsd: asNumber(usage?.cost_usd, asNumber(usage?.total_cost_usd)), - subtype: status || eventType, - isError, - errors: errorText ? [errorText] : [], - }]; - } - - return []; -} - -export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] { - const normalized = normalizeOpenClawStreamLine(line); - if (normalized.stream === "stderr") { - return [{ kind: "stderr", ts, text: normalized.line }]; - } - - const trimmed = normalized.line.trim(); - if (!trimmed) return []; - - if (trimmed.startsWith("[openclaw:sse]")) { - return parseOpenClawSseLine(trimmed, ts); - } - - if (trimmed.startsWith("[openclaw]")) { - return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw\]\s*/, "") }]; - } - - return [{ kind: "stdout", ts, text: normalized.line }]; -} diff --git a/packages/adapters/openclaw/tsconfig.json b/packages/adapters/openclaw/tsconfig.json deleted file mode 100644 index 2f355cfe..00000000 --- a/packages/adapters/openclaw/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 1661a85b..0c16e2d8 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -13,7 +13,7 @@ Use when: - You want OpenCode session resume across heartbeats via --session Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - OpenCode CLI is not installed on the machine diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index 3794426f..a81750c3 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -14,7 +14,7 @@ Use when: - You need Pi's tool set (read, bash, edit, write, grep, find, ls) Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Pi CLI is not installed on the machine diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c7f85b57..252c3690 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -29,7 +29,6 @@ export const AGENT_ADAPTER_TYPES = [ "opencode_local", "pi_local", "cursor", - "openclaw", "openclaw_gateway", ] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..9536ff75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ 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,22 +136,6 @@ 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': @@ -261,9 +242,6 @@ 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 @@ -379,9 +357,6 @@ 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 diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index 635a3e15..c18bce72 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -33,7 +33,7 @@ const workspacePaths = [ "packages/adapters/claude-local", "packages/adapters/codex-local", "packages/adapters/opencode-local", - "packages/adapters/openclaw", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that are NOT bundled and must stay as npm dependencies. diff --git a/scripts/release.sh b/scripts/release.sh index 769b5f47..6827e0fa 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -115,7 +115,7 @@ const { readFileSync } = require('fs'); const { resolve } = require('path'); const root = '$REPO_ROOT'; const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -221,7 +221,7 @@ const { resolve } = require('path'); const root = '$REPO_ROOT'; const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -279,7 +279,7 @@ pnpm --filter @paperclipai/db build pnpm --filter @paperclipai/adapter-claude-local build pnpm --filter @paperclipai/adapter-codex-local build pnpm --filter @paperclipai/adapter-opencode-local build -pnpm --filter @paperclipai/adapter-openclaw build +pnpm --filter @paperclipai/adapter-openclaw-gateway build pnpm --filter @paperclipai/server build # Build UI and bundle into server package for static serving @@ -314,7 +314,7 @@ if [ "$dry_run" = true ]; then echo "" echo " Preview what would be published:" for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \ + packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \ server cli; do echo " --- $dir ---" cd "$REPO_ROOT/$dir" diff --git a/scripts/smoke/openclaw-join.sh b/scripts/smoke/openclaw-join.sh index 151ae277..23896e8a 100755 --- a/scripts/smoke/openclaw-join.sh +++ b/scripts/smoke/openclaw-join.sh @@ -179,24 +179,32 @@ if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then fi api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt" assert_status "200" -if ! grep -q "Paperclip OpenClaw Onboarding" <<<"$RESPONSE_BODY"; then +if ! grep -q "Paperclip OpenClaw Gateway Onboarding" <<<"$RESPONSE_BODY"; then fail "onboarding.txt response missing expected header" fi -log "submitting OpenClaw agent join request" +OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}" +OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-${OPENCLAW_WEBHOOK_AUTH#Bearer }}" +if [[ -z "$OPENCLAW_GATEWAY_TOKEN" ]]; then + fail "OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_AUTH) is required for gateway join" +fi + +log "submitting OpenClaw gateway agent join request" JOIN_PAYLOAD="$(jq -nc \ --arg name "$OPENCLAW_AGENT_NAME" \ - --arg url "$OPENCLAW_WEBHOOK_URL" \ - --arg auth "$OPENCLAW_WEBHOOK_AUTH" \ + --arg url "$OPENCLAW_GATEWAY_URL" \ + --arg token "$OPENCLAW_GATEWAY_TOKEN" \ '{ requestType: "agent", agentName: $name, - adapterType: "openclaw", - capabilities: "Automated OpenClaw smoke harness", - agentDefaultsPayload: ( - { url: $url, method: "POST", timeoutSec: 30 } - + (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end) - ) + adapterType: "openclaw_gateway", + capabilities: "Automated OpenClaw gateway smoke harness", + agentDefaultsPayload: { + url: $url, + headers: { "x-openclaw-token": $token }, + sessionKeyStrategy: "issue", + waitTimeoutMs: 120000 + } }')" api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD" assert_status "202" diff --git a/server/package.json b/server/package.json index eaf73505..3e74286b 100644 --- a/server/package.json +++ b/server/package.json @@ -36,7 +36,6 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", - "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", diff --git a/server/src/__tests__/hire-hook.test.ts b/server/src/__tests__/hire-hook.test.ts index 3161949a..0a2cbbfd 100644 --- a/server/src/__tests__/hire-hook.test.ts +++ b/server/src/__tests__/hire-hook.test.ts @@ -40,7 +40,7 @@ afterEach(() => { describe("notifyHireApproved", () => { it("writes success activity when adapter hook returns ok", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: true }), } as any); @@ -48,7 +48,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( @@ -65,7 +65,7 @@ describe("notifyHireApproved", () => { expect.objectContaining({ action: "hire_hook.succeeded", entityId: "a1", - details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw" }), + details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }), }), ); }); @@ -116,7 +116,7 @@ describe("notifyHireApproved", () => { it("logs failed result when adapter onHireApproved returns ok=false", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }), } as any); @@ -124,7 +124,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( @@ -148,7 +148,7 @@ describe("notifyHireApproved", () => { it("does not throw when adapter onHireApproved throws (non-fatal)", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")), } as any); @@ -156,7 +156,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( diff --git a/server/src/__tests__/invite-accept-gateway-defaults.test.ts b/server/src/__tests__/invite-accept-gateway-defaults.test.ts new file mode 100644 index 00000000..3ff239f6 --- /dev/null +++ b/server/src/__tests__/invite-accept-gateway-defaults.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + buildJoinDefaultsPayloadForAccept, + normalizeAgentDefaultsForJoin, +} from "../routes/access.js"; + +describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => { + it("leaves non-gateway payloads unchanged", () => { + const defaultsPayload = { command: "echo hello" }; + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "process", + defaultsPayload, + inboundOpenClawAuthHeader: "ignored-token", + }); + + expect(result).toEqual(defaultsPayload); + }); + + it("normalizes wrapped x-openclaw-token header", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": { + value: "gateway-token-1234567890", + }, + }, + }, + }) as Record; + + expect(result).toMatchObject({ + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); + + it("accepts inbound x-openclaw-token for gateway joins", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + }, + inboundOpenClawTokenHeader: "gateway-token-1234567890", + }) as Record; + + expect(result).toMatchObject({ + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); + + it("derives x-openclaw-token from authorization header", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + authorization: "Bearer gateway-token-1234567890", + }, + }, + }) as Record; + + expect(result).toMatchObject({ + headers: { + authorization: "Bearer gateway-token-1234567890", + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); +}); + +describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => { + it("generates persistent device key when device auth is enabled", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + disableDeviceAuth: false, + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors).toEqual([]); + expect(normalized.normalized?.disableDeviceAuth).toBe(false); + expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string"); + expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64); + }); + + it("does not generate device key when disableDeviceAuth=true", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + disableDeviceAuth: true, + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors).toEqual([]); + expect(normalized.normalized?.disableDeviceAuth).toBe(true); + expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts deleted file mode 100644 index dc7b58e1..00000000 --- a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildJoinDefaultsPayloadForAccept, - normalizeAgentDefaultsForJoin, -} from "../routes/access.js"; - -describe("buildJoinDefaultsPayloadForAccept", () => { - it("maps OpenClaw compatibility fields into agent defaults", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: null, - responsesWebhookUrl: "http://localhost:18789/v1/responses", - paperclipApiUrl: "http://host.docker.internal:3100", - inboundOpenClawAuthHeader: "gateway-token", - }) as Record; - - expect(result).toMatchObject({ - url: "http://localhost:18789/v1/responses", - paperclipApiUrl: "http://host.docker.internal:3100", - webhookAuthHeader: "Bearer gateway-token", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }); - }); - - it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "https://example.com/v1/responses", - method: "POST", - headers: { - "x-openclaw-auth": "existing-token", - }, - paperclipApiUrl: "https://paperclip.example.com", - }, - responsesWebhookUrl: "https://legacy.example.com/v1/responses", - responsesWebhookMethod: "PUT", - paperclipApiUrl: "https://legacy-paperclip.example.com", - inboundOpenClawAuthHeader: "legacy-token", - }) as Record; - - expect(result).toMatchObject({ - url: "https://example.com/v1/responses", - method: "POST", - paperclipApiUrl: "https://paperclip.example.com", - webhookAuthHeader: "Bearer existing-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }); - }); - - it("preserves explicit webhookAuthHeader when configured", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "https://example.com/v1/responses", - webhookAuthHeader: "Bearer explicit-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }, - inboundOpenClawAuthHeader: "legacy-token", - }) as Record; - - expect(result).toMatchObject({ - webhookAuthHeader: "Bearer explicit-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }); - }); - - it("accepts auth from agentDefaultsPayload.headers.x-openclaw-auth", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "http://127.0.0.1:18789/v1/responses", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth from agentDefaultsPayload.headers.x-openclaw-token", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "http://127.0.0.1:18789/hooks/agent", - method: "POST", - headers: { - "x-openclaw-token": "gateway-token", - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-token": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts inbound x-openclaw-token compatibility header", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: null, - inboundOpenClawTokenHeader: "gateway-token", - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-token": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts wrapped auth values in headers for compatibility", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: { - "x-openclaw-auth": { - value: "gateway-token", - }, - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers provided as tuple entries", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: [["x-openclaw-auth", "gateway-token"]], - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers provided as name/value entries", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: [{ name: "x-openclaw-auth", value: { authToken: "gateway-token" } }], - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers wrapped in a single unknown key", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: { - "x-openclaw-auth": { - gatewayToken: "gateway-token", - }, - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("leaves non-openclaw payloads unchanged", () => { - const defaultsPayload = { command: "echo hello" }; - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "process", - defaultsPayload, - responsesWebhookUrl: "https://ignored.example.com", - inboundOpenClawAuthHeader: "ignored-token", - }); - - expect(result).toEqual(defaultsPayload); - }); - - it("normalizes wrapped gateway token headers for openclaw_gateway", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw_gateway", - defaultsPayload: { - url: "ws://127.0.0.1:18789", - headers: { - "x-openclaw-token": { - value: "gateway-token-1234567890", - }, - }, - }, - }) as Record; - - expect(result).toMatchObject({ - url: "ws://127.0.0.1:18789", - headers: { - "x-openclaw-token": "gateway-token-1234567890", - }, - }); - }); - - it("accepts inbound x-openclaw-token for openclaw_gateway", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw_gateway", - defaultsPayload: { - url: "ws://127.0.0.1:18789", - }, - inboundOpenClawTokenHeader: "gateway-token-1234567890", - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-token": "gateway-token-1234567890", - }, - }); - }); - - it("generates persistent device key for openclaw_gateway when device auth is enabled", () => { - const normalized = normalizeAgentDefaultsForJoin({ - adapterType: "openclaw_gateway", - defaultsPayload: { - url: "ws://127.0.0.1:18789", - headers: { - "x-openclaw-token": "gateway-token-1234567890", - }, - disableDeviceAuth: false, - }, - deploymentMode: "authenticated", - deploymentExposure: "private", - bindHost: "127.0.0.1", - allowedHostnames: [], - }); - - expect(normalized.fatalErrors).toEqual([]); - expect(normalized.normalized?.disableDeviceAuth).toBe(false); - expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string"); - expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64); - }); - - it("does not generate device key when openclaw_gateway has disableDeviceAuth=true", () => { - const normalized = normalizeAgentDefaultsForJoin({ - adapterType: "openclaw_gateway", - defaultsPayload: { - url: "ws://127.0.0.1:18789", - headers: { - "x-openclaw-token": "gateway-token-1234567890", - }, - disableDeviceAuth: true, - }, - deploymentMode: "authenticated", - deploymentExposure: "private", - bindHost: "127.0.0.1", - allowedHostnames: [], - }); - - expect(normalized.fatalErrors).toEqual([]); - expect(normalized.normalized?.disableDeviceAuth).toBe(true); - expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined(); - }); -}); diff --git a/server/src/__tests__/invite-accept-replay.test.ts b/server/src/__tests__/invite-accept-replay.test.ts index 78a2bb1c..dba43dbd 100644 --- a/server/src/__tests__/invite-accept-replay.test.ts +++ b/server/src/__tests__/invite-accept-replay.test.ts @@ -1,63 +1,55 @@ import { describe, expect, it } from "vitest"; import { buildJoinDefaultsPayloadForAccept, - canReplayOpenClawInviteAccept, + canReplayOpenClawGatewayInviteAccept, mergeJoinDefaultsPayloadForReplay, } from "../routes/access.js"; -describe("canReplayOpenClawInviteAccept", () => { - it("allows replay only for openclaw agent joins in pending or approved state", () => { +describe("canReplayOpenClawGatewayInviteAccept", () => { + it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => { expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "pending_approval", }, }), ).toBe(true); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "approved", }, }), ).toBe(true); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "rejected", }, }), ).toBe(false); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "human", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", - status: "pending_approval", - }, - }), - ).toBe(false); - expect( - canReplayOpenClawInviteAccept({ - requestType: "agent", - adapterType: "process", - existingJoinRequest: { - requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "pending_approval", }, }), @@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => { }); describe("mergeJoinDefaultsPayloadForReplay", () => { - it("merges replay payloads and preserves existing fields while allowing auth/header overrides", () => { + it("merges replay payloads and allows gateway token override", () => { const merged = mergeJoinDefaultsPayloadForReplay( { - url: "https://old.example/v1/responses", - method: "POST", + url: "ws://old.example:18789", paperclipApiUrl: "http://host.docker.internal:3100", headers: { - "x-openclaw-auth": "old-token", + "x-openclaw-token": "old-token-1234567890", "x-custom": "keep-me", }, }, { paperclipApiUrl: "https://paperclip.example.com", headers: { - "x-openclaw-auth": "new-token", + "x-openclaw-token": "new-token-1234567890", }, }, ); const normalized = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", + adapterType: "openclaw_gateway", defaultsPayload: merged, inboundOpenClawAuthHeader: null, }) as Record; - expect(normalized.url).toBe("https://old.example/v1/responses"); + expect(normalized.url).toBe("ws://old.example:18789"); expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com"); - expect(normalized.webhookAuthHeader).toBe("Bearer new-token"); expect(normalized.headers).toMatchObject({ - "x-openclaw-auth": "new-token", + "x-openclaw-token": "new-token-1234567890", "x-custom": "keep-me", }); }); diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts deleted file mode 100644 index a77b21bb..00000000 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ /dev/null @@ -1,1063 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { execute, testEnvironment, onHireApproved } from "@paperclipai/adapter-openclaw/server"; -import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; -import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; - -function buildContext( - config: Record, - overrides?: Partial, -): AdapterExecutionContext { - return { - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "OpenClaw Agent", - adapterType: "openclaw", - adapterConfig: {}, - }, - runtime: { - sessionId: null, - sessionParams: null, - sessionDisplayId: null, - taskKey: null, - }, - config, - context: { - taskId: "task-123", - issueId: "issue-123", - wakeReason: "issue_assigned", - issueIds: ["issue-123"], - }, - onLog: async () => {}, - ...overrides, - }; -} - -function sseResponse(lines: string[]) { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - for (const line of lines) { - controller.enqueue(encoder.encode(line)); - } - controller.close(); - }, - }); - return new Response(stream, { - status: 200, - statusText: "OK", - headers: { - "content-type": "text/event-stream", - }, - }); -} - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("openclaw ui stdout parser", () => { - it("parses SSE deltas into assistant streaming entries", () => { - const ts = "2026-03-05T23:07:16.296Z"; - const line = - '[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":"hello"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "assistant", - ts, - text: "hello", - delta: true, - }, - ]); - }); - - it("parses stdout-prefixed SSE deltas and preserves spacing", () => { - const ts = "2026-03-05T23:07:16.296Z"; - const line = - 'stdout[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":" can"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "assistant", - ts, - text: " can", - delta: true, - }, - ]); - }); - - it("parses response.completed into usage-aware result entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = JSON.stringify({ - type: "response.completed", - response: { - status: "completed", - usage: { - input_tokens: 12, - output_tokens: 34, - cached_input_tokens: 5, - }, - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "All done", - }, - ], - }, - ], - }, - }); - - expect(parseOpenClawStdoutLine(`[openclaw:sse] event=response.completed data=${line}`, ts)).toEqual([ - { - kind: "result", - ts, - text: "All done", - inputTokens: 12, - outputTokens: 34, - cachedTokens: 5, - costUsd: 0, - subtype: "completed", - isError: false, - errors: [], - }, - ]); - }); - - it("maps SSE errors to stderr entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = - '[openclaw:sse] event=response.failed data={"type":"response.failed","error":"timeout"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "stderr", - ts, - text: "timeout", - }, - ]); - }); - - it("maps stderr-prefixed lines to stderr transcript entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = "stderr OpenClaw transport error"; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "stderr", - ts, - text: "OpenClaw transport error", - }, - ]); - }); -}); - -describe("openclaw adapter execute", () => { - it("uses SSE transport and includes canonical PAPERCLIP context in text payload", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - payloadTemplate: { foo: "bar", text: "OpenClaw task prompt" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.foo).toBe("bar"); - expect(body.stream).toBe(true); - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - expect((body.paperclip as Record).streamTransport).toBe("sse"); - expect((body.paperclip as Record).runId).toBe("run-123"); - expect((body.paperclip as Record).sessionKey).toBe("paperclip:issue:issue-123"); - expect( - ((body.paperclip as Record).env as Record).PAPERCLIP_RUN_ID, - ).toBe("run-123"); - const text = String(body.text ?? ""); - expect(text).toContain("OpenClaw task prompt"); - expect(text).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(text).toContain("PAPERCLIP_AGENT_ID=agent-123"); - expect(text).toContain("PAPERCLIP_COMPANY_ID=company-123"); - expect(text).toContain("PAPERCLIP_TASK_ID=task-123"); - expect(text).toContain("PAPERCLIP_WAKE_REASON=issue_assigned"); - expect(text).toContain("PAPERCLIP_LINKED_ISSUE_IDS=issue-123"); - expect(text).toContain("PAPERCLIP_API_KEY="); - expect(text).toContain("Load PAPERCLIP_API_KEY from ~/.openclaw/workspace/paperclip-claimed-api-key.json"); - }); - - it("uses paperclipApiUrl override when provided", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - paperclipApiUrl: "http://dotta-macbook-pro:3100", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const paperclip = body.paperclip as Record; - const env = paperclip.env as Record; - expect(env.PAPERCLIP_API_URL).toBe("http://dotta-macbook-pro:3100/"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_API_URL=http://dotta-macbook-pro:3100/"); - }); - - it("logs outbound header keys for auth debugging", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const logs: string[] = []; - const result = await execute( - buildContext( - { - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }, - { - onLog: async (_stream, chunk) => { - logs.push(chunk); - }, - }, - ), - ); - - expect(result.exitCode).toBe(0); - expect( - logs.some((line) => line.includes("[openclaw] outbound header keys:") && line.includes("x-openclaw-auth")), - ).toBe(true); - }); - - it("logs outbound payload with sensitive fields redacted", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const logs: string[] = []; - const result = await execute( - buildContext( - { - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - payloadTemplate: { - text: "task prompt", - nested: { - token: "secret-token", - visible: "keep-me", - }, - }, - }, - { - onLog: async (_stream, chunk) => { - logs.push(chunk); - }, - }, - ), - ); - - expect(result.exitCode).toBe(0); - - const headerLog = logs.find((line) => line.includes("[openclaw] outbound headers (redacted):")); - expect(headerLog).toBeDefined(); - expect(headerLog).toContain("\"x-openclaw-auth\":\"[redacted"); - expect(headerLog).toContain("\"authorization\":\"[redacted"); - expect(headerLog).not.toContain("gateway-token"); - - const payloadLog = logs.find((line) => line.includes("[openclaw] outbound payload (redacted):")); - expect(payloadLog).toBeDefined(); - expect(payloadLog).toContain("\"token\":\"[redacted"); - expect(payloadLog).not.toContain("secret-token"); - expect(payloadLog).toContain("\"visible\":\"keep-me\""); - }); - - it("derives Authorization header from x-openclaw-auth when webhookAuthHeader is unset", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-auth"]).toBe("gateway-token"); - expect(headers.authorization).toBe("Bearer gateway-token"); - }); - - it("derives Authorization header from x-openclaw-token when webhookAuthHeader is unset", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-token": "gateway-token", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-token"]).toBe("gateway-token"); - expect(headers.authorization).toBe("Bearer gateway-token"); - }); - - it("derives issue session keys when configured", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: done\n", - "data: [DONE]\n\n", - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - sessionKeyStrategy: "issue", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - expect((body.paperclip as Record).sessionKey).toBe("paperclip:issue:issue-123"); - }); - - it("maps requests to OpenResponses schema for /v1/responses endpoints", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - payloadTemplate: { - model: "openclaw", - user: "paperclip", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.stream).toBe(true); - expect(body.model).toBe("openclaw"); - expect(typeof body.input).toBe("string"); - expect(String(body.input)).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(String(body.input)).toContain("PAPERCLIP_API_KEY="); - expect(body.metadata).toBeTypeOf("object"); - expect((body.metadata as Record).PAPERCLIP_RUN_ID).toBe("run-123"); - expect(body.text).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - expect(body.sessionKey).toBeUndefined(); - - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-session-key"]).toBe("paperclip:issue:issue-123"); - }); - - it("does not treat response.output_text.done as a terminal OpenResponses event", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.output_text.done\n", - 'data: {"type":"response.output_text.done","text":"partial"}\n\n', - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - }), - ); - - expect(result.exitCode).toBe(0); - expect(result.resultJson).toEqual( - expect.objectContaining({ - terminal: true, - eventCount: 2, - lastEventType: "response.completed", - }), - ); - }); - - it("appends wake text when OpenResponses input is provided as a message object", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - payloadTemplate: { - model: "openclaw", - input: { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: "start with this context", - }, - ], - }, - }, - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const input = body.input as Record; - expect(input.type).toBe("message"); - expect(input.role).toBe("user"); - expect(Array.isArray(input.content)).toBe(true); - - const content = input.content as Record[]; - expect(content).toHaveLength(2); - expect(content[0]).toEqual({ - type: "input_text", - text: "start with this context", - }); - expect(content[1]).toEqual( - expect.objectContaining({ - type: "input_text", - }), - ); - expect(String(content[1]?.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("fails when SSE endpoint does not return text/event-stream", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: false, error: "unexpected payload" }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_expected_event_stream"); - }); - - it("fails when SSE stream closes without a terminal event", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.delta\n", - 'data: {"type":"response.delta","delta":"partial"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_stream_incomplete"); - }); - - it("fails with explicit text-required error when endpoint rejects payload", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "text required" }), { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_text_required"); - }); - - it("supports webhook transport and sends Paperclip webhook payloads", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - payloadTemplate: { foo: "bar" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.foo).toBe("bar"); - expect(body.stream).toBe(false); - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect((body.paperclip as Record).streamTransport).toBe("webhook"); - }); - - it("remaps legacy /v1/responses URLs to /hooks/agent in webhook transport", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - streamTransport: "webhook", - payloadTemplate: { foo: "bar" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("https://agent.example/hooks/agent"); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof body.message).toBe("string"); - expect(String(body.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.stream).toBeUndefined(); - expect(body.input).toBeUndefined(); - expect(body.metadata).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-session-key"]).toBeUndefined(); - }); - - it("falls back to legacy /v1/responses when remapped /hooks/agent returns 404", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response("Not Found", { - status: 404, - statusText: "Not Found", - headers: { - "content-type": "text/plain", - }, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("https://agent.example/hooks/agent"); - expect(String(fetchMock.mock.calls[1]?.[0] ?? "")).toBe("https://agent.example/v1/responses"); - - const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof firstBody.message).toBe("string"); - expect(String(firstBody.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(secondBody.stream).toBe(false); - expect(typeof secondBody.input).toBe("string"); - expect(String(secondBody.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - - const secondHeaders = (fetchMock.mock.calls[1]?.[1]?.headers ?? {}) as Record; - expect(secondHeaders["x-openclaw-session-key"]).toBe("paperclip:issue:issue-123"); - expect(result.resultJson).toEqual( - expect.objectContaining({ - usedLegacyResponsesFallback: true, - }), - ); - }); - - it("uses wake compatibility payloads for /hooks/wake when transport=webhook", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/wake", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.mode).toBe("now"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.paperclip).toBeUndefined(); - }); - - it("uses /hooks/agent payloads for webhook transport and omits sessionKey by default", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - streamTransport: "webhook", - payloadTemplate: { - name: "Paperclip Hook", - wakeMode: "next-heartbeat", - deliver: true, - channel: "last", - model: "openai/gpt-5.2-mini", - }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof body.message).toBe("string"); - expect(String(body.message)).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.name).toBe("Paperclip Hook"); - expect(body.wakeMode).toBe("next-heartbeat"); - expect(body.deliver).toBe(true); - expect(body.channel).toBe("last"); - expect(body.model).toBe("openai/gpt-5.2-mini"); - expect(body.sessionKey).toBeUndefined(); - expect(body.text).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - }); - - it("includes sessionKey for /hooks/agent payloads only when hookIncludeSessionKey=true", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - streamTransport: "webhook", - hookIncludeSessionKey: true, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - }); - - it("retries webhook payloads with wake compatibility format on text-required errors", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "text required" }), { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(String(firstBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(firstBody.paperclip).toBeTypeOf("object"); - expect(secondBody.mode).toBe("now"); - expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("retries webhook payloads when /v1/responses reports missing string input", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - message: "model: Invalid input: expected string, received undefined", - type: "invalid_request_error", - }, - }), - { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }, - ), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(secondBody.mode).toBe("now"); - expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("rejects unsupported transport configuration", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - streamTransport: "invalid", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_stream_transport_unsupported"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("rejects /hooks/wake compatibility endpoints in SSE mode", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/wake", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("rejects /hooks/agent endpoints in SSE mode", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint"); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - -describe("openclaw adapter environment checks", () => { - it("reports /hooks/wake endpoints as incompatible for SSE mode", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/wake", - }, - deployment: { - mode: "authenticated", - exposure: "private", - bindHost: "paperclip.internal", - allowedHostnames: ["paperclip.internal"], - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(check?.level).toBe("error"); - }); - - it("reports /hooks/agent endpoints as incompatible for SSE mode", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/agent", - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(check?.level).toBe("error"); - }); - - it("reports unsupported streamTransport settings", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/sse", - streamTransport: "invalid", - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_stream_transport_unsupported"); - expect(check?.level).toBe("error"); - }); - - it("accepts webhook streamTransport settings", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/wake", - streamTransport: "webhook", - }, - }); - - const unsupported = result.checks.find((entry) => entry.code === "openclaw_stream_transport_unsupported"); - const configured = result.checks.find((entry) => entry.code === "openclaw_stream_transport_configured"); - const wakeIncompatible = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(unsupported).toBeUndefined(); - expect(configured?.level).toBe("info"); - expect(wakeIncompatible).toBeUndefined(); - }); -}); - -describe("onHireApproved", () => { - it("returns ok when hireApprovedCallbackUrl is not set (no-op)", async () => { - const result = await onHireApproved( - { - companyId: "c1", - agentId: "a1", - agentName: "Test Agent", - adapterType: "openclaw", - source: "join_request", - sourceId: "jr1", - approvedAt: "2026-03-06T00:00:00.000Z", - message: "You're hired.", - }, - {}, - ); - expect(result).toEqual({ ok: true }); - }); - - it("POSTs payload to hireApprovedCallbackUrl with correct headers and body", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - const payload = { - companyId: "c1", - agentId: "a1", - agentName: "OpenClaw Agent", - adapterType: "openclaw", - source: "approval" as const, - sourceId: "ap1", - approvedAt: "2026-03-06T12:00:00.000Z", - message: "Tell your user that your hire was approved.", - }; - - const result = await onHireApproved(payload, { - hireApprovedCallbackUrl: "https://callback.example/hire-approved", - hireApprovedCallbackAuthHeader: "Bearer secret", - }); - - expect(result.ok).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe("https://callback.example/hire-approved"); - expect(init?.method).toBe("POST"); - expect((init?.headers as Record)["content-type"]).toBe("application/json"); - expect((init?.headers as Record)["Authorization"]).toBe("Bearer secret"); - const body = JSON.parse(init?.body as string); - expect(body.event).toBe("hire_approved"); - expect(body.companyId).toBe(payload.companyId); - expect(body.agentId).toBe(payload.agentId); - expect(body.message).toBe(payload.message); - }); - - it("returns failure when callback returns non-2xx", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response("Server Error", { status: 500 })); - vi.stubGlobal("fetch", fetchMock); - - const result = await onHireApproved( - { - companyId: "c1", - agentId: "a1", - agentName: "A", - adapterType: "openclaw", - source: "join_request", - sourceId: "jr1", - approvedAt: new Date().toISOString(), - message: "Hired", - }, - { hireApprovedCallbackUrl: "https://example.com/hook" }, - ); - - expect(result.ok).toBe(false); - expect(result.error).toContain("500"); - }); -}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index d9e153ed..9fe536a0 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -26,15 +26,6 @@ import { import { agentConfigurationDoc as openCodeAgentConfigurationDoc, } from "@paperclipai/adapter-opencode-local"; -import { - execute as openclawExecute, - testEnvironment as openclawTestEnvironment, - onHireApproved as openclawOnHireApproved, -} from "@paperclipai/adapter-openclaw/server"; -import { - agentConfigurationDoc as openclawAgentConfigurationDoc, - models as openclawModels, -} from "@paperclipai/adapter-openclaw"; import { execute as openclawGatewayExecute, testEnvironment as openclawGatewayTestEnvironment, @@ -89,16 +80,6 @@ const cursorLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: cursorAgentConfigurationDoc, }; -const openclawAdapter: ServerAdapterModule = { - type: "openclaw", - execute: openclawExecute, - testEnvironment: openclawTestEnvironment, - onHireApproved: openclawOnHireApproved, - models: openclawModels, - supportsLocalAgentJwt: false, - agentConfigurationDoc: openclawAgentConfigurationDoc, -}; - const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -137,7 +118,6 @@ const adaptersByType = new Map( openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, - openclawAdapter, openclawGatewayAdapter, processAdapter, httpAdapter, diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 9eaacf71..c13366ff 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -136,19 +136,6 @@ function isLoopbackHost(hostname: string): boolean { return value === "localhost" || value === "127.0.0.1" || value === "::1"; } -function isWakePath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return value === "/hooks/wake" || value.endsWith("/hooks/wake"); -} - -function normalizeOpenClawTransport(value: unknown): "sse" | "webhook" | null { - if (typeof value !== "string") return "sse"; - const normalized = value.trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - function normalizeHostname(value: string | null | undefined): string | null { if (!value) return null; const trimmed = value.trim(); @@ -311,12 +298,6 @@ function headerMapGetIgnoreCase( return typeof value === "string" ? value : null; } -function toAuthorizationHeaderValue(rawToken: string): string { - const trimmed = rawToken.trim(); - if (!trimmed) return trimmed; - return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; -} - function tokenFromAuthorizationHeader(rawHeader: string | null): string | null { const trimmed = nonEmptyTrimmedString(rawHeader); if (!trimmed) return null; @@ -346,68 +327,11 @@ function generateEd25519PrivateKeyPem(): string { export function buildJoinDefaultsPayloadForAccept(input: { adapterType: string | null; defaultsPayload: unknown; - responsesWebhookUrl?: unknown; - responsesWebhookMethod?: unknown; - responsesWebhookHeaders?: unknown; paperclipApiUrl?: unknown; - webhookAuthHeader?: unknown; inboundOpenClawAuthHeader?: string | null; inboundOpenClawTokenHeader?: string | null; }): unknown { - if (input.adapterType === "openclaw_gateway") { - const merged = isPlainObject(input.defaultsPayload) - ? { ...(input.defaultsPayload as Record) } - : ({} as Record); - - if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) { - const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl); - if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl; - } - - const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {}; - - const inboundOpenClawAuthHeader = nonEmptyTrimmedString( - input.inboundOpenClawAuthHeader - ); - const inboundOpenClawTokenHeader = nonEmptyTrimmedString( - input.inboundOpenClawTokenHeader - ); - if ( - inboundOpenClawTokenHeader && - !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token") - ) { - mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader; - } - if ( - inboundOpenClawAuthHeader && - !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth") - ) { - mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader; - } - - const discoveredToken = - headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? - headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ?? - tokenFromAuthorizationHeader( - headerMapGetIgnoreCase(mergedHeaders, "authorization") - ); - if ( - discoveredToken && - !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token") - ) { - mergedHeaders["x-openclaw-token"] = discoveredToken; - } - - if (Object.keys(mergedHeaders).length > 0) { - merged.headers = mergedHeaders; - } else { - delete merged.headers; - } - - return Object.keys(merged).length > 0 ? merged : null; - } - - if (input.adapterType !== "openclaw") { + if (input.adapterType !== "openclaw_gateway") { return input.defaultsPayload; } @@ -415,40 +339,11 @@ export function buildJoinDefaultsPayloadForAccept(input: { ? { ...(input.defaultsPayload as Record) } : ({} as Record); - if (!nonEmptyTrimmedString(merged.url)) { - const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl); - if (legacyUrl) merged.url = legacyUrl; - } - - if (!nonEmptyTrimmedString(merged.method)) { - const legacyMethod = nonEmptyTrimmedString(input.responsesWebhookMethod); - if (legacyMethod) merged.method = legacyMethod.toUpperCase(); - } - if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) { const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl); if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl; } - - if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) { - const providedWebhookAuthHeader = nonEmptyTrimmedString( - input.webhookAuthHeader - ); - if (providedWebhookAuthHeader) - merged.webhookAuthHeader = providedWebhookAuthHeader; - } - const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {}; - const compatibilityHeaders = normalizeHeaderMap( - input.responsesWebhookHeaders - ); - if (compatibilityHeaders) { - for (const [key, value] of Object.entries(compatibilityHeaders)) { - if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) { - mergedHeaders[key] = value; - } - } - } const inboundOpenClawAuthHeader = nonEmptyTrimmedString( input.inboundOpenClawAuthHeader @@ -475,23 +370,17 @@ export function buildJoinDefaultsPayloadForAccept(input: { delete merged.headers; } - const hasAuthorizationHeader = headerMapHasKeyIgnoreCase( - mergedHeaders, - "authorization" - ); - const hasWebhookAuthHeader = Boolean( - nonEmptyTrimmedString(merged.webhookAuthHeader) - ); - if (!hasAuthorizationHeader && !hasWebhookAuthHeader) { - const openClawAuthToken = - headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? - headerMapGetIgnoreCase( - mergedHeaders, - "x-openclaw-auth" + const discoveredToken = + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader( + headerMapGetIgnoreCase(mergedHeaders, "authorization") ); - if (openClawAuthToken) { - merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken); - } + if ( + discoveredToken && + !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token") + ) { + mergedHeaders["x-openclaw-token"] = discoveredToken; } return Object.keys(merged).length > 0 ? merged : null; @@ -537,7 +426,7 @@ export function mergeJoinDefaultsPayloadForReplay( return merged; } -export function canReplayOpenClawInviteAccept(input: { +export function canReplayOpenClawGatewayInviteAccept(input: { requestType: "human" | "agent"; adapterType: string | null; existingJoinRequest: Pick< @@ -545,7 +434,10 @@ export function canReplayOpenClawInviteAccept(input: { "requestType" | "adapterType" | "status" > | null; }): boolean { - if (input.requestType !== "agent" || input.adapterType !== "openclaw") { + if ( + input.requestType !== "agent" || + input.adapterType !== "openclaw_gateway" + ) { return false; } if (!input.existingJoinRequest) { @@ -553,7 +445,7 @@ export function canReplayOpenClawInviteAccept(input: { } if ( input.existingJoinRequest.requestType !== "agent" || - input.existingJoinRequest.adapterType !== "openclaw" + input.existingJoinRequest.adapterType !== "openclaw_gateway" ) { return false; } @@ -575,32 +467,6 @@ function summarizeSecretForLog( }; } -function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) { - const defaults = isPlainObject(defaultsPayload) - ? (defaultsPayload as Record) - : null; - const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined; - const openClawAuthHeaderValue = headers - ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? - headerMapGetIgnoreCase(headers, "x-openclaw-auth") - : null; - - return { - present: Boolean(defaults), - keys: defaults ? Object.keys(defaults).sort() : [], - url: defaults ? nonEmptyTrimmedString(defaults.url) : null, - method: defaults ? nonEmptyTrimmedString(defaults.method) : null, - paperclipApiUrl: defaults - ? nonEmptyTrimmedString(defaults.paperclipApiUrl) - : null, - headerKeys: headers ? Object.keys(headers).sort() : [], - webhookAuthHeader: defaults - ? summarizeSecretForLog(defaults.webhookAuthHeader) - : null, - openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue) - }; -} - function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) { const defaults = isPlainObject(defaultsPayload) ? (defaultsPayload as Record) @@ -638,79 +504,6 @@ function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) { }; } -function buildJoinConnectivityDiagnostics(input: { - deploymentMode: DeploymentMode; - deploymentExposure: DeploymentExposure; - bindHost: string; - allowedHostnames: string[]; - callbackUrl: URL | null; -}): JoinDiagnostic[] { - const diagnostics: JoinDiagnostic[] = []; - const bindHost = normalizeHostname(input.bindHost); - const callbackHost = input.callbackUrl - ? normalizeHostname(input.callbackUrl.hostname) - : null; - const allowSet = new Set( - input.allowedHostnames - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)) - ); - - diagnostics.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.` - }); - - if ( - input.deploymentMode === "authenticated" && - input.deploymentExposure === "private" - ) { - if (!bindHost || isLoopbackHost(bindHost)) { - diagnostics.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 for remote OpenClaw callbacks." - }); - } - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - diagnostics.push({ - code: "openclaw_private_bind_not_allowed", - level: "warn", - message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost}` - }); - } - if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) { - diagnostics.push({ - code: "openclaw_private_allowed_hostnames_empty", - level: "warn", - message: - "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs off-host." - }); - } - } - - if ( - input.deploymentMode === "authenticated" && - input.deploymentExposure === "public" && - input.callbackUrl && - input.callbackUrl.protocol !== "https:" - ) { - diagnostics.push({ - code: "openclaw_public_http_callback", - level: "warn", - message: "OpenClaw callback URL uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments." - }); - } - - return diagnostics; -} - export function normalizeAgentDefaultsForJoin(input: { adapterType: string | null; defaultsPayload: unknown; @@ -721,267 +514,25 @@ export function normalizeAgentDefaultsForJoin(input: { }) { const fatalErrors: string[] = []; const diagnostics: JoinDiagnostic[] = []; - if ( - input.adapterType !== "openclaw" && - input.adapterType !== "openclaw_gateway" - ) { + if (input.adapterType !== "openclaw_gateway") { const normalized = isPlainObject(input.defaultsPayload) ? (input.defaultsPayload as Record) : null; return { normalized, diagnostics, fatalErrors }; } - if (input.adapterType === "openclaw_gateway") { - if (!isPlainObject(input.defaultsPayload)) { - diagnostics.push({ - code: "openclaw_gateway_defaults_missing", - level: "warn", - message: - "No OpenClaw gateway config was provided in agentDefaultsPayload.", - hint: - "Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins." - }); - fatalErrors.push( - "agentDefaultsPayload is required for adapterType=openclaw_gateway" - ); - return { - normalized: null as Record | null, - diagnostics, - fatalErrors - }; - } - - const defaults = input.defaultsPayload as Record; - const normalized: Record = {}; - - let gatewayUrl: URL | null = null; - const rawGatewayUrl = nonEmptyTrimmedString(defaults.url); - if (!rawGatewayUrl) { - diagnostics.push({ - code: "openclaw_gateway_url_missing", - level: "warn", - message: "OpenClaw gateway URL is missing.", - hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL." - }); - fatalErrors.push("agentDefaultsPayload.url is required"); - } else { - try { - gatewayUrl = new URL(rawGatewayUrl); - if ( - gatewayUrl.protocol !== "ws:" && - gatewayUrl.protocol !== "wss:" - ) { - diagnostics.push({ - code: "openclaw_gateway_url_protocol", - level: "warn", - message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).` - }); - fatalErrors.push( - "agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway" - ); - } else { - normalized.url = gatewayUrl.toString(); - diagnostics.push({ - code: "openclaw_gateway_url_configured", - level: "info", - message: `Gateway endpoint set to ${gatewayUrl.toString()}` - }); - } - } catch { - diagnostics.push({ - code: "openclaw_gateway_url_invalid", - level: "warn", - message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}` - }); - fatalErrors.push("agentDefaultsPayload.url is not a valid URL"); - } - } - - const headers = normalizeHeaderMap(defaults.headers) ?? {}; - const gatewayToken = - headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? - headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? - tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization")); - if ( - gatewayToken && - !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token") - ) { - headers["x-openclaw-token"] = gatewayToken; - } - if (Object.keys(headers).length > 0) { - normalized.headers = headers; - } - - if (!gatewayToken) { - diagnostics.push({ - code: "openclaw_gateway_auth_header_missing", - level: "warn", - message: "Gateway auth token is missing from agent defaults.", - hint: - "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)." - }); - fatalErrors.push( - "agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required" - ); - } else if (gatewayToken.trim().length < 16) { - diagnostics.push({ - code: "openclaw_gateway_auth_header_too_short", - level: "warn", - message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`, - hint: - "Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)." - }); - fatalErrors.push( - "agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token" - ); - } else { - diagnostics.push({ - code: "openclaw_gateway_auth_header_configured", - level: "info", - message: "Gateway auth token configured." - }); - } - - if (isPlainObject(defaults.payloadTemplate)) { - normalized.payloadTemplate = defaults.payloadTemplate; - } - - const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth); - const disableDeviceAuth = parsedDisableDeviceAuth === true; - if (parsedDisableDeviceAuth !== null) { - normalized.disableDeviceAuth = parsedDisableDeviceAuth; - } - - const configuredDevicePrivateKeyPem = nonEmptyTrimmedString( - defaults.devicePrivateKeyPem - ); - if (configuredDevicePrivateKeyPem) { - normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem; - diagnostics.push({ - code: "openclaw_gateway_device_key_configured", - level: "info", - message: - "Gateway device key configured. Pairing approvals should persist for this agent." - }); - } else if (!disableDeviceAuth) { - try { - normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem(); - diagnostics.push({ - code: "openclaw_gateway_device_key_generated", - level: "info", - message: - "Generated persistent gateway device key for this join. Pairing approvals should persist for this agent." - }); - } catch (err) { - diagnostics.push({ - code: "openclaw_gateway_device_key_generate_failed", - level: "warn", - message: `Failed to generate gateway device key: ${ - err instanceof Error ? err.message : String(err) - }`, - hint: - "Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true." - }); - fatalErrors.push( - "Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true." - ); - } - } - - const waitTimeoutMs = - typeof defaults.waitTimeoutMs === "number" && - Number.isFinite(defaults.waitTimeoutMs) - ? Math.floor(defaults.waitTimeoutMs) - : typeof defaults.waitTimeoutMs === "string" - ? Number.parseInt(defaults.waitTimeoutMs.trim(), 10) - : NaN; - if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) { - normalized.waitTimeoutMs = waitTimeoutMs; - } - - const timeoutSec = - typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec) - ? Math.floor(defaults.timeoutSec) - : typeof defaults.timeoutSec === "string" - ? Number.parseInt(defaults.timeoutSec.trim(), 10) - : NaN; - if (Number.isFinite(timeoutSec) && timeoutSec > 0) { - normalized.timeoutSec = timeoutSec; - } - - const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy); - if ( - sessionKeyStrategy === "fixed" || - sessionKeyStrategy === "issue" || - sessionKeyStrategy === "run" - ) { - normalized.sessionKeyStrategy = sessionKeyStrategy; - } - - const sessionKey = nonEmptyTrimmedString(defaults.sessionKey); - if (sessionKey) { - normalized.sessionKey = sessionKey; - } - - const role = nonEmptyTrimmedString(defaults.role); - if (role) { - normalized.role = role; - } - - if (Array.isArray(defaults.scopes)) { - const scopes = defaults.scopes - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => entry.trim()) - .filter(Boolean); - if (scopes.length > 0) { - normalized.scopes = scopes; - } - } - - const rawPaperclipApiUrl = - typeof defaults.paperclipApiUrl === "string" - ? defaults.paperclipApiUrl.trim() - : ""; - if (rawPaperclipApiUrl) { - try { - const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl); - if ( - parsedPaperclipApiUrl.protocol !== "http:" && - parsedPaperclipApiUrl.protocol !== "https:" - ) { - diagnostics.push({ - code: "openclaw_gateway_paperclip_api_url_protocol", - level: "warn", - message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).` - }); - } else { - normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString(); - diagnostics.push({ - code: "openclaw_gateway_paperclip_api_url_configured", - level: "info", - message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}` - }); - } - } catch { - diagnostics.push({ - code: "openclaw_gateway_paperclip_api_url_invalid", - level: "warn", - message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}` - }); - } - } - - return { normalized, diagnostics, fatalErrors }; - } - if (!isPlainObject(input.defaultsPayload)) { diagnostics.push({ - code: "openclaw_callback_config_missing", + code: "openclaw_gateway_defaults_missing", level: "warn", message: - "No OpenClaw callback config was provided in agentDefaultsPayload.", - hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval." + "No OpenClaw gateway config was provided in agentDefaultsPayload.", + hint: + "Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins." }); + fatalErrors.push( + "agentDefaultsPayload is required for adapterType=openclaw_gateway" + ); return { normalized: null as Record | null, diagnostics, @@ -990,120 +541,87 @@ export function normalizeAgentDefaultsForJoin(input: { } const defaults = input.defaultsPayload as Record; - const streamTransportInput = defaults.streamTransport ?? defaults.transport; - const streamTransport = normalizeOpenClawTransport(streamTransportInput); - const normalized: Record = { streamTransport: "sse" }; - if (!streamTransport) { - diagnostics.push({ - code: "openclaw_stream_transport_unsupported", - level: "warn", - message: `Unsupported streamTransport: ${String(streamTransportInput)}`, - hint: "Use streamTransport=sse or streamTransport=webhook." - }); - } else { - normalized.streamTransport = streamTransport; - } + const normalized: Record = {}; - let callbackUrl: URL | null = null; - const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : ""; - if (!rawUrl) { + let gatewayUrl: URL | null = null; + const rawGatewayUrl = nonEmptyTrimmedString(defaults.url); + if (!rawGatewayUrl) { diagnostics.push({ - code: "openclaw_callback_url_missing", + code: "openclaw_gateway_url_missing", level: "warn", - message: "OpenClaw callback URL is missing.", - hint: "Set agentDefaultsPayload.url to your OpenClaw endpoint." + message: "OpenClaw gateway URL is missing.", + hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL." }); + fatalErrors.push("agentDefaultsPayload.url is required"); } else { try { - callbackUrl = new URL(rawUrl); - if ( - callbackUrl.protocol !== "http:" && - callbackUrl.protocol !== "https:" - ) { + gatewayUrl = new URL(rawGatewayUrl); + if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") { diagnostics.push({ - code: "openclaw_callback_url_protocol", + code: "openclaw_gateway_url_protocol", level: "warn", - message: `Unsupported callback protocol: ${callbackUrl.protocol}`, - hint: "Use http:// or https://." + message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).` }); + fatalErrors.push( + "agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway" + ); } else { - normalized.url = callbackUrl.toString(); + normalized.url = gatewayUrl.toString(); diagnostics.push({ - code: "openclaw_callback_url_configured", + code: "openclaw_gateway_url_configured", level: "info", - message: `Callback endpoint set to ${callbackUrl.toString()}` - }); - } - if ((streamTransport ?? "sse") === "sse" && isWakePath(callbackUrl.pathname)) { - diagnostics.push({ - code: "openclaw_callback_wake_path_incompatible", - level: "warn", - message: - "Configured callback path targets /hooks/wake, which is not stream-capable for SSE transport.", - hint: "Use an endpoint that returns text/event-stream for the full run duration." - }); - } - if (isLoopbackHost(callbackUrl.hostname)) { - diagnostics.push({ - code: "openclaw_callback_loopback", - level: "warn", - message: "OpenClaw callback endpoint uses loopback hostname.", - hint: "Use a reachable hostname/IP when OpenClaw runs on another machine." + message: `Gateway endpoint set to ${gatewayUrl.toString()}` }); } } catch { diagnostics.push({ - code: "openclaw_callback_url_invalid", + code: "openclaw_gateway_url_invalid", level: "warn", - message: `Invalid callback URL: ${rawUrl}` + message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}` }); + fatalErrors.push("agentDefaultsPayload.url is not a valid URL"); } } - const rawMethod = - typeof defaults.method === "string" - ? defaults.method.trim().toUpperCase() - : ""; - normalized.method = rawMethod || "POST"; - - if ( - typeof defaults.timeoutSec === "number" && - Number.isFinite(defaults.timeoutSec) - ) { - normalized.timeoutSec = Math.max( - 0, - Math.min(7200, Math.floor(defaults.timeoutSec)) - ); + const headers = normalizeHeaderMap(defaults.headers) ?? {}; + const gatewayToken = + headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization")); + if (gatewayToken && !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")) { + headers["x-openclaw-token"] = gatewayToken; + } + if (Object.keys(headers).length > 0) { + normalized.headers = headers; } - const headers = normalizeHeaderMap(defaults.headers); - if (headers) normalized.headers = headers; - - if ( - typeof defaults.webhookAuthHeader === "string" && - defaults.webhookAuthHeader.trim() - ) { - normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim(); - } - - const openClawAuthHeader = headers - ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? - headerMapGetIgnoreCase(headers, "x-openclaw-auth") - : null; - if (openClawAuthHeader) { + if (!gatewayToken) { diagnostics.push({ - code: "openclaw_auth_header_configured", - level: "info", - message: - "Gateway auth token received via headers.x-openclaw-token (or legacy x-openclaw-auth)." - }); - } else { - diagnostics.push({ - code: "openclaw_auth_header_missing", + code: "openclaw_gateway_auth_header_missing", level: "warn", message: "Gateway auth token is missing from agent defaults.", hint: - "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth) to the token your OpenClaw endpoint requires." + "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)." + }); + fatalErrors.push( + "agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required" + ); + } else if (gatewayToken.trim().length < 16) { + diagnostics.push({ + code: "openclaw_gateway_auth_header_too_short", + level: "warn", + message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`, + hint: + "Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)." + }); + fatalErrors.push( + "agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token" + ); + } else { + diagnostics.push({ + code: "openclaw_gateway_auth_header_configured", + level: "info", + message: "Gateway auth token configured." }); } @@ -1111,6 +629,98 @@ export function normalizeAgentDefaultsForJoin(input: { normalized.payloadTemplate = defaults.payloadTemplate; } + const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth); + const disableDeviceAuth = parsedDisableDeviceAuth === true; + if (parsedDisableDeviceAuth !== null) { + normalized.disableDeviceAuth = parsedDisableDeviceAuth; + } + + const configuredDevicePrivateKeyPem = nonEmptyTrimmedString( + defaults.devicePrivateKeyPem + ); + if (configuredDevicePrivateKeyPem) { + normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem; + diagnostics.push({ + code: "openclaw_gateway_device_key_configured", + level: "info", + message: + "Gateway device key configured. Pairing approvals should persist for this agent." + }); + } else if (!disableDeviceAuth) { + try { + normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem(); + diagnostics.push({ + code: "openclaw_gateway_device_key_generated", + level: "info", + message: + "Generated persistent gateway device key for this join. Pairing approvals should persist for this agent." + }); + } catch (err) { + diagnostics.push({ + code: "openclaw_gateway_device_key_generate_failed", + level: "warn", + message: `Failed to generate gateway device key: ${ + err instanceof Error ? err.message : String(err) + }`, + hint: + "Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true." + }); + fatalErrors.push( + "Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true." + ); + } + } + + const waitTimeoutMs = + typeof defaults.waitTimeoutMs === "number" && + Number.isFinite(defaults.waitTimeoutMs) + ? Math.floor(defaults.waitTimeoutMs) + : typeof defaults.waitTimeoutMs === "string" + ? Number.parseInt(defaults.waitTimeoutMs.trim(), 10) + : NaN; + if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) { + normalized.waitTimeoutMs = waitTimeoutMs; + } + + const timeoutSec = + typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec) + ? Math.floor(defaults.timeoutSec) + : typeof defaults.timeoutSec === "string" + ? Number.parseInt(defaults.timeoutSec.trim(), 10) + : NaN; + if (Number.isFinite(timeoutSec) && timeoutSec > 0) { + normalized.timeoutSec = timeoutSec; + } + + const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy); + if ( + sessionKeyStrategy === "fixed" || + sessionKeyStrategy === "issue" || + sessionKeyStrategy === "run" + ) { + normalized.sessionKeyStrategy = sessionKeyStrategy; + } + + const sessionKey = nonEmptyTrimmedString(defaults.sessionKey); + if (sessionKey) { + normalized.sessionKey = sessionKey; + } + + const role = nonEmptyTrimmedString(defaults.role); + if (role) { + normalized.role = role; + } + + if (Array.isArray(defaults.scopes)) { + const scopes = defaults.scopes + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + if (scopes.length > 0) { + normalized.scopes = scopes; + } + } + const rawPaperclipApiUrl = typeof defaults.paperclipApiUrl === "string" ? defaults.paperclipApiUrl.trim() @@ -1123,46 +733,27 @@ export function normalizeAgentDefaultsForJoin(input: { parsedPaperclipApiUrl.protocol !== "https:" ) { diagnostics.push({ - code: "openclaw_paperclip_api_url_protocol", + code: "openclaw_gateway_paperclip_api_url_protocol", level: "warn", message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).` }); } else { normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString(); diagnostics.push({ - code: "openclaw_paperclip_api_url_configured", + code: "openclaw_gateway_paperclip_api_url_configured", level: "info", message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}` }); - if (isLoopbackHost(parsedPaperclipApiUrl.hostname)) { - diagnostics.push({ - code: "openclaw_paperclip_api_url_loopback", - level: "warn", - message: - "paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments." - }); - } } } catch { diagnostics.push({ - code: "openclaw_paperclip_api_url_invalid", + code: "openclaw_gateway_paperclip_api_url_invalid", level: "warn", message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}` }); } } - diagnostics.push( - ...buildJoinConnectivityDiagnostics({ - deploymentMode: input.deploymentMode, - deploymentExposure: input.deploymentExposure, - bindHost: input.bindHost, - allowedHostnames: input.allowedHostnames, - callbackUrl - }) - ); - return { normalized, diagnostics, fatalErrors }; } @@ -2309,7 +1900,7 @@ export function accessRoutes( const adapterType = req.body.adapterType ?? null; if ( inviteAlreadyAccepted && - !canReplayOpenClawInviteAccept({ + !canReplayOpenClawGatewayInviteAccept({ requestType, adapterType, existingJoinRequest: existingJoinRequestForInvite @@ -2331,59 +1922,22 @@ export function accessRoutes( ) : req.body.agentDefaultsPayload ?? null; - const openClawDefaultsPayload = + const gatewayDefaultsPayload = requestType === "agent" ? buildJoinDefaultsPayloadForAccept({ adapterType, defaultsPayload: replayMergedDefaults, - responsesWebhookUrl: req.body.responsesWebhookUrl ?? null, - responsesWebhookMethod: req.body.responsesWebhookMethod ?? null, - responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null, paperclipApiUrl: req.body.paperclipApiUrl ?? null, - webhookAuthHeader: req.body.webhookAuthHeader ?? null, inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null }) : null; - if (requestType === "agent" && adapterType === "openclaw") { - logger.info( - { - inviteId: invite.id, - requestType, - adapterType, - bodyKeys: isPlainObject(req.body) - ? Object.keys(req.body).sort() - : [], - responsesWebhookUrl: nonEmptyTrimmedString( - req.body.responsesWebhookUrl - ), - paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl), - webhookAuthHeader: summarizeSecretForLog( - req.body.webhookAuthHeader - ), - inboundOpenClawAuthHeader: summarizeSecretForLog( - req.header("x-openclaw-auth") ?? null - ), - inboundOpenClawTokenHeader: summarizeSecretForLog( - req.header("x-openclaw-token") ?? null - ), - rawAgentDefaults: summarizeOpenClawDefaultsForLog( - req.body.agentDefaultsPayload ?? null - ), - mergedAgentDefaults: summarizeOpenClawDefaultsForLog( - openClawDefaultsPayload - ) - }, - "invite accept received OpenClaw join payload" - ); - } - const joinDefaults = requestType === "agent" ? normalizeAgentDefaultsForJoin({ adapterType, - defaultsPayload: openClawDefaultsPayload, + defaultsPayload: gatewayDefaultsPayload, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, @@ -2399,22 +1953,6 @@ export function accessRoutes( throw badRequest(joinDefaults.fatalErrors.join("; ")); } - if (requestType === "agent" && adapterType === "openclaw") { - logger.info( - { - inviteId: invite.id, - joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({ - code: diag.code, - level: diag.level - })), - normalizedAgentDefaults: summarizeOpenClawDefaultsForLog( - joinDefaults.normalized - ) - }, - "invite accept normalized OpenClaw defaults" - ); - } - if (requestType === "agent" && adapterType === "openclaw_gateway") { logger.info( { @@ -2516,7 +2054,7 @@ export function accessRoutes( if ( inviteAlreadyAccepted && requestType === "agent" && - adapterType === "openclaw" && + adapterType === "openclaw_gateway" && created.status === "approved" && created.createdAgentId ) { @@ -2552,11 +2090,11 @@ export function accessRoutes( }); } - if (requestType === "agent" && adapterType === "openclaw") { - const expectedDefaults = summarizeOpenClawDefaultsForLog( + if (requestType === "agent" && adapterType === "openclaw_gateway") { + const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog( joinDefaults.normalized ); - const persistedDefaults = summarizeOpenClawDefaultsForLog( + const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog( created.agentDefaultsPayload ); const missingPersistedFields: string[] = []; @@ -2569,19 +2107,14 @@ export function accessRoutes( ) { missingPersistedFields.push("paperclipApiUrl"); } - if ( - expectedDefaults.webhookAuthHeader && - !persistedDefaults.webhookAuthHeader - ) { - missingPersistedFields.push("webhookAuthHeader"); + if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) { + missingPersistedFields.push("headers.x-openclaw-token"); } if ( - expectedDefaults.openClawAuthHeader && - !persistedDefaults.openClawAuthHeader + expectedDefaults.devicePrivateKeyPem && + !persistedDefaults.devicePrivateKeyPem ) { - missingPersistedFields.push( - "headers.x-openclaw-token|headers.x-openclaw-auth" - ); + missingPersistedFields.push("devicePrivateKeyPem"); } if ( expectedDefaults.headerKeys.length > 0 && @@ -2604,7 +2137,7 @@ export function accessRoutes( hint: diag.hint ?? null })) }, - "invite accept persisted OpenClaw join request" + "invite accept persisted OpenClaw gateway join request" ); if (missingPersistedFields.length > 0) { @@ -2614,7 +2147,7 @@ export function accessRoutes( joinRequestId: created.id, missingPersistedFields }, - "invite accept detected missing persisted OpenClaw defaults" + "invite accept detected missing persisted OpenClaw gateway defaults" ); } } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index afe54ffc..ac6de363 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record void; - placeholder?: string; -}) { - const [visible, setVisible] = useState(false); - return ( - -
- - -
-
- ); -} - -export function OpenClawConfigFields({ - isCreate, - values, - set, - config, - eff, - mark, -}: AdapterConfigFieldsProps) { - const configuredHeaders = - config.headers && typeof config.headers === "object" && !Array.isArray(config.headers) - ? (config.headers as Record) - : {}; - const effectiveHeaders = - (eff("adapterConfig", "headers", configuredHeaders) as Record) ?? {}; - const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string" - ? String(effectiveHeaders["x-openclaw-auth"]) - : ""; - - const commitGatewayAuthHeader = (rawValue: string) => { - const nextValue = rawValue.trim(); - const nextHeaders: Record = { ...effectiveHeaders }; - if (nextValue) { - nextHeaders["x-openclaw-auth"] = nextValue; - } else { - delete nextHeaders["x-openclaw-auth"]; - } - mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined); - }; - - const transport = eff( - "adapterConfig", - "streamTransport", - String(config.streamTransport ?? "sse"), - ); - const sessionStrategy = eff( - "adapterConfig", - "sessionKeyStrategy", - String(config.sessionKeyStrategy ?? "fixed"), - ); - - return ( - <> - - - isCreate - ? set!({ url: v }) - : mark("adapterConfig", "url", v || undefined) - } - immediate - className={inputClass} - placeholder="https://..." - /> - - {!isCreate && ( - <> - - mark("adapterConfig", "paperclipApiUrl", v || undefined)} - immediate - className={inputClass} - placeholder="https://paperclip.example" - /> - - - - - - - - - - - {sessionStrategy === "fixed" && ( - - mark("adapterConfig", "sessionKey", v || undefined)} - immediate - className={inputClass} - placeholder="paperclip" - /> - - )} - - mark("adapterConfig", "webhookAuthHeader", v || undefined)} - placeholder="Bearer " - /> - - - - )} - - ); -} diff --git a/ui/src/adapters/openclaw/index.ts b/ui/src/adapters/openclaw/index.ts deleted file mode 100644 index 890d83bc..00000000 --- a/ui/src/adapters/openclaw/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { UIAdapterModule } from "../types"; -import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; -import { buildOpenClawConfig } from "@paperclipai/adapter-openclaw/ui"; -import { OpenClawConfigFields } from "./config-fields"; - -export const openClawUIAdapter: UIAdapterModule = { - type: "openclaw", - label: "OpenClaw", - parseStdoutLine: parseOpenClawStdoutLine, - ConfigFields: OpenClawConfigFields, - buildAdapterConfig: buildOpenClawConfig, -}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index a641b265..1a36af6b 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -4,7 +4,6 @@ import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; -import { openClawUIAdapter } from "./openclaw"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; @@ -16,7 +15,6 @@ const adaptersByType = new Map( openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, - openClawUIAdapter, openClawGatewayUIAdapter, processUIAdapter, httpUIAdapter, diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 4e1bc76e..6ff2dfeb 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -18,7 +18,6 @@ const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", opencode_local: "OpenCode (local)", - openclaw: "OpenClaw", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 02bdf74c..9d176179 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -157,7 +157,7 @@ function parseStdoutChunk( if (!trimmed) continue; const parsed = adapter.parseStdoutLine(trimmed, ts); if (parsed.length === 0) { - if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") { + if (run.adapterType === "openclaw_gateway") { continue; } const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index e1520356..fbcbc7bf 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -56,7 +56,6 @@ type AdapterType = | "cursor" | "process" | "http" - | "openclaw" | "openclaw_gateway"; const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md) @@ -971,7 +970,7 @@ export function OnboardingWizard() {
)} - {(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && ( + {(adapterType === "http" || adapterType === "openclaw_gateway") && (