feat: add secure ClickClack setup code claim client

This commit is contained in:
Shakker
2026-07-16 23:56:29 +01:00
committed by Shakker
parent 13306d66e3
commit 204f4499e8
3 changed files with 231 additions and 1 deletions
+99 -1
View File
@@ -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",
+111
View File
@@ -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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`ClickClack setup code claim returned invalid ${label}`);
}
return value as Record<string, unknown>;
}
function requireString(record: Record<string, unknown>, 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<ClickClackSetupCodeClaim> {
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<unknown>(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") {
+21
View File
@@ -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;