diff --git a/extensions/clickclack/src/http-client.test.ts b/extensions/clickclack/src/http-client.test.ts index 4e1315e934db..d93974c3a648 100644 --- a/extensions/clickclack/src/http-client.test.ts +++ b/extensions/clickclack/src/http-client.test.ts @@ -1,7 +1,11 @@ import { createServer, type Server } from "node:http"; import { describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; -import { createClickClackClient, normalizeClickClackCorrelationId } from "./http-client.js"; +import { + claimClickClackSetupCode, + createClickClackClient, + normalizeClickClackCorrelationId, +} from "./http-client.js"; const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024; const CLICKCLACK_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024; @@ -125,6 +129,100 @@ function streamedErrorResponse(body: string, limit: number) { } describe("ClickClack HTTP client", () => { + it("claims setup codes over guarded HTTPS without bearer authentication", async () => { + const fetchMock = vi.fn(async () => + Response.json({ + token: "ccb_claimed", + bot: { + id: "usr_bot", + handle: "openclaw", + display_name: "OpenClaw", + }, + workspace: { + id: "wsp_1", + route_id: "clickclack", + slug: "default", + name: "ClickClack", + }, + defaults: { + defaultTo: "channel:general", + allowFrom: ["*"], + agentActivity: true, + }, + }), + ); + + await expect( + claimClickClackSetupCode({ + baseUrl: "https://clickclack.example", + code: "ABCD-EFGH-JKMP", + fetch: fetchMock as unknown as typeof fetch, + }), + ).resolves.toEqual({ + token: "ccb_claimed", + bot: { + id: "usr_bot", + handle: "openclaw", + display_name: "OpenClaw", + }, + workspace: { + id: "wsp_1", + route_id: "clickclack", + slug: "default", + name: "ClickClack", + }, + defaults: { + defaultTo: "channel:general", + allowFrom: ["*"], + agentActivity: true, + }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "https://clickclack.example/api/bot-setup-codes/claim", + ); + const init = fetchMock.mock.calls[0]?.[1]; + expect(init).toMatchObject({ method: "POST", redirect: "manual" }); + expect(requestBodyJson(init)).toEqual({ code: "ABCD-EFGH-JKMP" }); + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBeNull(); + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it("rejects non-HTTPS setup-code claims before sending a request", async () => { + const fetchMock = vi.fn(); + + await expect( + claimClickClackSetupCode({ + baseUrl: "http://clickclack.example", + code: "ABCD-EFGH-JKMP", + fetch: fetchMock as unknown as typeof fetch, + }), + ).rejects.toThrow("URL must use https"); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects malformed setup-code claim responses", async () => { + const fetchMock = vi.fn(async () => + Response.json({ + token: "ccb_claimed", + bot: { id: "usr_bot", handle: "openclaw", display_name: "OpenClaw" }, + workspace: { id: "wsp_1", route_id: "clickclack", slug: "default" }, + defaults: {}, + }), + ); + + await expect( + claimClickClackSetupCode({ + baseUrl: "https://clickclack.example", + code: "ABCD-EFGH-JKMP", + fetch: fetchMock as unknown as typeof fetch, + }), + ).rejects.toThrow("invalid workspace.name"); + }); + it("replaces the authenticated bot command menu", async () => { const botCommand = { id: "botcmd_1", diff --git a/extensions/clickclack/src/http-client.ts b/extensions/clickclack/src/http-client.ts index 66c533c085c7..5634a92c3fd8 100644 --- a/extensions/clickclack/src/http-client.ts +++ b/extensions/clickclack/src/http-client.ts @@ -6,6 +6,10 @@ import { readProviderJsonResponse, readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; +import { + fetchWithSsrFGuard, + ssrfPolicyFromHttpBaseUrlAllowedHostname, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { WebSocket } from "ws"; import type { ClickClackBotCommand, @@ -13,6 +17,7 @@ import type { ClickClackEvent, ClickClackMessage, ClickClackMessageProvenance, + ClickClackSetupCodeClaim, ClickClackUser, ClickClackWorkspace, } from "./types.js"; @@ -58,6 +63,8 @@ type ClientOptions = { }; const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024; +const CLICKCLACK_SETUP_CODE_CLAIM_JSON_LIMIT_BYTES = 64 * 1024; +const CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS = 30_000; const CLICKCLACK_CORRELATION_ID_MAX_LENGTH = 128; const CLICKCLACK_CORRELATION_ID_PATTERN = /^[A-Za-z0-9._:-]+$/u; const CLICKCLACK_CORRELATION_ID_HEADER = "X-Correlation-ID"; @@ -80,6 +87,110 @@ class ClickClackHttpError extends Error { } } +export class ClickClackSetupCodeClaimError extends Error { + constructor( + readonly status: number, + detail: string, + ) { + super(`ClickClack setup code claim failed (${status}): ${detail}`); + } +} + +function requireRecord(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`ClickClack setup code claim returned invalid ${label}`); + } + return value as Record; +} + +function requireString(record: Record, key: string, label: string): string { + const value = record[key]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`ClickClack setup code claim returned invalid ${label}.${key}`); + } + return value; +} + +function parseClickClackSetupCodeClaim(value: unknown): ClickClackSetupCodeClaim { + const claim = requireRecord(value, "response"); + const bot = requireRecord(claim.bot, "bot"); + const workspace = requireRecord(claim.workspace, "workspace"); + const defaults = requireRecord(claim.defaults, "defaults"); + const defaultTo = defaults.defaultTo; + const allowFrom = defaults.allowFrom; + const agentActivity = defaults.agentActivity; + if (defaultTo !== undefined && typeof defaultTo !== "string") { + throw new Error("ClickClack setup code claim returned invalid defaults.defaultTo"); + } + if ( + allowFrom !== undefined && + (!Array.isArray(allowFrom) || !allowFrom.every((entry) => typeof entry === "string")) + ) { + throw new Error("ClickClack setup code claim returned invalid defaults.allowFrom"); + } + if (agentActivity !== undefined && typeof agentActivity !== "boolean") { + throw new Error("ClickClack setup code claim returned invalid defaults.agentActivity"); + } + return { + token: requireString(claim, "token", "response"), + bot: { + id: requireString(bot, "id", "bot"), + handle: requireString(bot, "handle", "bot"), + display_name: requireString(bot, "display_name", "bot"), + }, + workspace: { + id: requireString(workspace, "id", "workspace"), + route_id: requireString(workspace, "route_id", "workspace"), + slug: requireString(workspace, "slug", "workspace"), + name: requireString(workspace, "name", "workspace"), + }, + defaults: { + ...(defaultTo !== undefined ? { defaultTo } : {}), + ...(allowFrom !== undefined ? { allowFrom } : {}), + ...(agentActivity !== undefined ? { agentActivity } : {}), + }, + }; +} + +/** Claims a one-time setup code without sending any existing bot credential. */ +export async function claimClickClackSetupCode(params: { + baseUrl: string; + code: string; + fetch?: typeof fetch; +}): Promise { + const baseUrl = params.baseUrl.replace(/\/+$/, ""); + const url = `${baseUrl}/api/bot-setup-codes/claim`; + const { response, release } = await fetchWithSsrFGuard({ + url, + fetchImpl: params.fetch, + init: { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: params.code }), + }, + maxRedirects: 0, + timeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS, + requireHttps: true, + policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl), + auditContext: "ClickClack setup code claim", + }); + try { + if (!response.ok) { + const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES); + throw new ClickClackSetupCodeClaimError(response.status, detail); + } + const value = await readProviderJsonResponse(response, "ClickClack setup code claim", { + maxBytes: CLICKCLACK_SETUP_CODE_CLAIM_JSON_LIMIT_BYTES, + }); + return parseClickClackSetupCodeClaim(value); + } finally { + await release(); + } +} + /** Accepts the same bounded request-correlation shape as the ClickClack API. */ export function normalizeClickClackCorrelationId(value: unknown): string | undefined { if (typeof value !== "string") { diff --git a/extensions/clickclack/src/types.ts b/extensions/clickclack/src/types.ts index 5cf15e2207ef..fb36eda8b732 100644 --- a/extensions/clickclack/src/types.ts +++ b/extensions/clickclack/src/types.ts @@ -87,6 +87,27 @@ export type ClickClackBotCommand = { updated_at: string; }; +/** One-time bot token and installer context returned by setup-code claim. */ +export type ClickClackSetupCodeClaim = { + token: string; + bot: { + id: string; + handle: string; + display_name: string; + }; + workspace: { + id: string; + route_id: string; + slug: string; + name: string; + }; + defaults: { + defaultTo?: string; + allowFrom?: string[]; + agentActivity?: boolean; + }; +}; + /** Workspace object returned by the ClickClack API. */ export type ClickClackWorkspace = { id: string;