mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat: configure ClickClack accounts from setup codes
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import {
|
||||
claimClickClackSetupCode,
|
||||
createClickClackClient,
|
||||
normalizeClickClackCorrelationId,
|
||||
} from "./http-client.js";
|
||||
import { createClickClackClient, normalizeClickClackCorrelationId } from "./http-client.js";
|
||||
|
||||
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
|
||||
const CLICKCLACK_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||
@@ -129,100 +125,6 @@ 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",
|
||||
|
||||
@@ -6,10 +6,6 @@ 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,
|
||||
@@ -17,7 +13,6 @@ import type {
|
||||
ClickClackEvent,
|
||||
ClickClackMessage,
|
||||
ClickClackMessageProvenance,
|
||||
ClickClackSetupCodeClaim,
|
||||
ClickClackUser,
|
||||
ClickClackWorkspace,
|
||||
} from "./types.js";
|
||||
@@ -63,8 +58,6 @@ 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";
|
||||
@@ -87,110 +80,6 @@ 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") {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { claimClickClackSetupCode } from "./setup-claim.js";
|
||||
|
||||
function requestBodyJson(init: RequestInit | undefined): unknown {
|
||||
const body = init?.body;
|
||||
if (typeof body !== "string") {
|
||||
throw new Error("expected string request body");
|
||||
}
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
describe("ClickClack setup-code claim", () => {
|
||||
it("claims 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 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 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// ClickClack plugin module claims short-lived setup codes without loading gateway clients.
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ClickClackSetupCodeClaim } from "./types.js";
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,19 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const claimClickClackSetupCode = vi.hoisted(() => vi.fn());
|
||||
const verifyClickClackAccountAfterSetup = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./setup-claim.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./setup-claim.js")>()),
|
||||
claimClickClackSetupCode,
|
||||
}));
|
||||
vi.mock("./setup-verify.js", () => ({
|
||||
verifyClickClackAccountAfterSetup,
|
||||
}));
|
||||
import { ClickClackSetupCodeClaimError } from "./setup-claim.js";
|
||||
import {
|
||||
applyClickClackCredentialConfig,
|
||||
clickClackSetupAdapter,
|
||||
@@ -27,7 +33,24 @@ function validate(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function prepare(
|
||||
input: Parameters<
|
||||
NonNullable<typeof clickClackSetupAdapter.prepareAccountConfigInput>
|
||||
>[0]["input"],
|
||||
) {
|
||||
return await clickClackSetupAdapter.prepareAccountConfigInput?.({
|
||||
cfg: {},
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
runtime: createNonExitingRuntimeEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("ClickClack setup adapter", () => {
|
||||
beforeEach(() => {
|
||||
claimClickClackSetupCode.mockReset();
|
||||
});
|
||||
|
||||
it("normalizes http(s) base URLs and rejects other schemes", () => {
|
||||
expect(normalizeClickClackBaseUrl(" https://clickclack.example.com/// ")).toBe(
|
||||
"https://clickclack.example.com",
|
||||
@@ -37,6 +60,124 @@ describe("ClickClack setup adapter", () => {
|
||||
expect(normalizeClickClackBaseUrl("not-a-url")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("claims a full setup URL and prepares the token, workspace, and defaults", async () => {
|
||||
claimClickClackSetupCode.mockResolvedValue({
|
||||
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(
|
||||
prepare({
|
||||
code: "https://clickclack.example/#abcd-efgh-jkmn",
|
||||
name: "Primary",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
name: "Primary",
|
||||
baseUrl: "https://clickclack.example",
|
||||
token: "ccb_claimed",
|
||||
workspace: "wsp_1",
|
||||
defaultTo: "channel:general",
|
||||
allowFrom: ["*"],
|
||||
agentActivity: true,
|
||||
});
|
||||
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
|
||||
baseUrl: "https://clickclack.example",
|
||||
code: "ABCDEFGHJKMN",
|
||||
});
|
||||
});
|
||||
|
||||
it("claims a bare setup code with an explicit HTTPS base URL", async () => {
|
||||
claimClickClackSetupCode.mockResolvedValue({
|
||||
token: "ccb_claimed",
|
||||
bot: { id: "usr_bot", handle: "openclaw", display_name: "OpenClaw" },
|
||||
workspace: {
|
||||
id: "wsp_1",
|
||||
route_id: "clickclack",
|
||||
slug: "default",
|
||||
name: "ClickClack",
|
||||
},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepare({
|
||||
code: "abcd efgh jkmn",
|
||||
baseUrl: "https://clickclack.example/",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
baseUrl: "https://clickclack.example",
|
||||
token: "ccb_claimed",
|
||||
workspace: "wsp_1",
|
||||
});
|
||||
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
|
||||
baseUrl: "https://clickclack.example",
|
||||
code: "ABCDEFGHJKMN",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects conflicting credentials before claiming a setup code", async () => {
|
||||
for (const input of [
|
||||
{ code: "ABCD-EFGH-JKMN", baseUrl: "https://clickclack.example", token: "ccb_old" },
|
||||
{
|
||||
code: "ABCD-EFGH-JKMN",
|
||||
baseUrl: "https://clickclack.example",
|
||||
tokenFile: "/run/secrets/clickclack",
|
||||
},
|
||||
{ code: "ABCD-EFGH-JKMN", baseUrl: "https://clickclack.example", useEnv: true },
|
||||
]) {
|
||||
await expect(prepare(input)).rejects.toThrow(
|
||||
"ClickClack --code cannot be combined with --token, --token-file, or --use-env.",
|
||||
);
|
||||
}
|
||||
expect(claimClickClackSetupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects insecure, mismatched, and malformed setup-code inputs before claiming", async () => {
|
||||
await expect(prepare({ code: "http://clickclack.example/#ABCD-EFGH-JKMN" })).rejects.toThrow(
|
||||
"ClickClack setup codes require HTTPS.",
|
||||
);
|
||||
await expect(
|
||||
prepare({
|
||||
code: "https://clickclack.example/#ABCD-EFGH-JKMN",
|
||||
baseUrl: "https://other.example",
|
||||
}),
|
||||
).rejects.toThrow("does not match");
|
||||
await expect(
|
||||
prepare({ code: "ABCD-EFGH-JKMN", baseUrl: "http://clickclack.example" }),
|
||||
).rejects.toThrow("require an HTTPS base URL");
|
||||
await expect(
|
||||
prepare({ code: "not-a-code", baseUrl: "https://clickclack.example" }),
|
||||
).rejects.toThrow("12 valid base32 characters");
|
||||
expect(claimClickClackSetupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps invalid and rate-limited claims to actionable errors", async () => {
|
||||
claimClickClackSetupCode.mockRejectedValueOnce(
|
||||
new ClickClackSetupCodeClaimError(404, "not found"),
|
||||
);
|
||||
await expect(
|
||||
prepare({ code: "ABCD-EFGH-JKMN", baseUrl: "https://clickclack.example" }),
|
||||
).rejects.toThrow("invalid, expired, or already used");
|
||||
|
||||
claimClickClackSetupCode.mockRejectedValueOnce(
|
||||
new ClickClackSetupCodeClaimError(429, "retry later"),
|
||||
);
|
||||
await expect(
|
||||
prepare({ code: "ABCD-EFGH-JKMN", baseUrl: "https://clickclack.example" }),
|
||||
).rejects.toThrow("Too many ClickClack setup code attempts");
|
||||
});
|
||||
|
||||
it("requires token, base URL, and workspace for explicit setup", () => {
|
||||
const message = "ClickClack requires --token, --base-url, and --workspace (or --use-env).";
|
||||
expect(
|
||||
@@ -121,6 +262,9 @@ describe("ClickClack setup adapter", () => {
|
||||
token: "ccb_default",
|
||||
baseUrl: "https://clickclack.example/",
|
||||
workspace: " default ",
|
||||
defaultTo: " channel:general ",
|
||||
allowFrom: ["*"],
|
||||
agentActivity: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -131,6 +275,9 @@ describe("ClickClack setup adapter", () => {
|
||||
token: "ccb_default",
|
||||
baseUrl: "https://clickclack.example",
|
||||
workspace: "default",
|
||||
defaultTo: "channel:general",
|
||||
allowFrom: ["*"],
|
||||
agentActivity: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
applySetupAccountConfigPatch,
|
||||
@@ -10,12 +11,17 @@ import {
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime";
|
||||
import { resolveClickClackAccountConfig } from "./accounts.js";
|
||||
import { claimClickClackSetupCode, ClickClackSetupCodeClaimError } from "./setup-claim.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "clickclack" as const;
|
||||
const SETUP_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
const SETUP_CODE_LENGTH = 12;
|
||||
const REQUIRED_INPUT_ERROR =
|
||||
"ClickClack requires --token, --base-url, and --workspace (or --use-env).";
|
||||
const INVALID_BASE_URL_ERROR = "ClickClack base URL must be a valid http(s) URL.";
|
||||
const SETUP_CODE_CONFLICT_ERROR =
|
||||
"ClickClack --code cannot be combined with --token, --token-file, or --use-env.";
|
||||
|
||||
export function normalizeClickClackBaseUrl(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
@@ -33,6 +39,91 @@ export function normalizeClickClackBaseUrl(value: string | undefined): string |
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeClickClackSetupCode(value: string): string | undefined {
|
||||
const normalized = value.trim().toUpperCase().replaceAll("-", "").replaceAll(" ", "");
|
||||
if (
|
||||
normalized.length !== SETUP_CODE_LENGTH ||
|
||||
[...normalized].some((character) => !SETUP_CODE_ALPHABET.includes(character))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireHttpsClickClackBaseUrl(value: string | undefined): string {
|
||||
const baseUrl = normalizeClickClackBaseUrl(value);
|
||||
if (!baseUrl || new URL(baseUrl).protocol !== "https:") {
|
||||
throw new Error("ClickClack setup codes require an HTTPS base URL.");
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
export function parseClickClackSetupCodeInput(params: { code: string; baseUrl?: string }): {
|
||||
code: string;
|
||||
baseUrl: string;
|
||||
} {
|
||||
const rawCode = params.code.trim();
|
||||
if (!rawCode) {
|
||||
throw new Error("ClickClack --code must not be empty.");
|
||||
}
|
||||
|
||||
let code = rawCode;
|
||||
let baseUrl: string;
|
||||
if (/^[a-z][a-z\d+.-]*:\/\//iu.test(rawCode)) {
|
||||
let setupUrl: URL;
|
||||
try {
|
||||
setupUrl = new URL(rawCode);
|
||||
} catch {
|
||||
throw new Error("ClickClack --code must be a valid HTTPS setup URL or a bare setup code.");
|
||||
}
|
||||
if (setupUrl.protocol !== "https:") {
|
||||
throw new Error("ClickClack setup codes require HTTPS.");
|
||||
}
|
||||
if (setupUrl.username || setupUrl.password) {
|
||||
throw new Error("ClickClack setup URLs must not include credentials.");
|
||||
}
|
||||
code = setupUrl.hash.slice(1);
|
||||
if (!code) {
|
||||
throw new Error("ClickClack setup URL is missing its #CODE fragment.");
|
||||
}
|
||||
setupUrl.hash = "";
|
||||
setupUrl.search = "";
|
||||
baseUrl = requireHttpsClickClackBaseUrl(setupUrl.toString());
|
||||
if (params.baseUrl) {
|
||||
const suppliedBaseUrl = requireHttpsClickClackBaseUrl(params.baseUrl);
|
||||
if (suppliedBaseUrl !== baseUrl) {
|
||||
throw new Error("ClickClack --base-url does not match the server in the setup-code URL.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
code = code.startsWith("#") ? code.slice(1) : code;
|
||||
if (!params.baseUrl) {
|
||||
throw new Error("A bare ClickClack setup code requires --base-url.");
|
||||
}
|
||||
baseUrl = requireHttpsClickClackBaseUrl(params.baseUrl);
|
||||
}
|
||||
|
||||
const normalizedCode = normalizeClickClackSetupCode(code);
|
||||
if (!normalizedCode) {
|
||||
throw new Error("ClickClack setup code must contain 12 valid base32 characters.");
|
||||
}
|
||||
return { code: normalizedCode, baseUrl };
|
||||
}
|
||||
|
||||
function formatClickClackSetupCodeClaimError(error: unknown): Error {
|
||||
if (error instanceof ClickClackSetupCodeClaimError) {
|
||||
if (error.status === 404) {
|
||||
return new Error(
|
||||
"ClickClack setup code is invalid, expired, or already used. Generate a new code and try again.",
|
||||
);
|
||||
}
|
||||
if (error.status === 429) {
|
||||
return new Error("Too many ClickClack setup code attempts. Wait and try again.");
|
||||
}
|
||||
}
|
||||
return new Error(`Could not claim ClickClack setup code: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
export function applyClickClackSetupConfigPatch(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
@@ -150,6 +241,38 @@ export function applyClickClackCredentialConfig(params: {
|
||||
|
||||
export const clickClackSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
prepareAccountConfigInput: async ({ input }) => {
|
||||
if (!input.code?.trim()) {
|
||||
return input;
|
||||
}
|
||||
if (input.token?.trim() || input.tokenFile?.trim() || input.useEnv) {
|
||||
throw new Error(SETUP_CODE_CONFLICT_ERROR);
|
||||
}
|
||||
const setup = parseClickClackSetupCodeInput({
|
||||
code: input.code,
|
||||
baseUrl: input.baseUrl,
|
||||
});
|
||||
let claim;
|
||||
try {
|
||||
claim = await claimClickClackSetupCode(setup);
|
||||
} catch (error) {
|
||||
throw formatClickClackSetupCodeClaimError(error);
|
||||
}
|
||||
const { code: _code, tokenFile: _tokenFile, useEnv: _useEnv, ...remainingInput } = input;
|
||||
return {
|
||||
...remainingInput,
|
||||
baseUrl: setup.baseUrl,
|
||||
token: claim.token,
|
||||
workspace: claim.workspace.id,
|
||||
...(claim.defaults.defaultTo !== undefined ? { defaultTo: claim.defaults.defaultTo } : {}),
|
||||
...(claim.defaults.allowFrom !== undefined
|
||||
? { allowFrom: [...claim.defaults.allowFrom] }
|
||||
: {}),
|
||||
...(claim.defaults.agentActivity !== undefined
|
||||
? { agentActivity: claim.defaults.agentActivity }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
@@ -201,6 +324,9 @@ export const clickClackSetupAdapter: ChannelSetupAdapter = {
|
||||
patch: {
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(workspace ? { workspace } : {}),
|
||||
...(input.defaultTo?.trim() ? { defaultTo: input.defaultTo.trim() } : {}),
|
||||
...(input.allowFrom ? { allowFrom: [...input.allowFrom] } : {}),
|
||||
...(input.agentActivity !== undefined ? { agentActivity: input.agentActivity } : {}),
|
||||
},
|
||||
});
|
||||
return applyClickClackCredentialConfig({
|
||||
|
||||
@@ -140,6 +140,9 @@ export type ChannelSetupInput = {
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
workspace?: string;
|
||||
defaultTo?: string;
|
||||
allowFrom?: string[];
|
||||
agentActivity?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelStatusIssue = {
|
||||
|
||||
Reference in New Issue
Block a user