diff --git a/extensions/clickclack/src/http-client.test.ts b/extensions/clickclack/src/http-client.test.ts index d93974c3a648..4e1315e934db 100644 --- a/extensions/clickclack/src/http-client.test.ts +++ b/extensions/clickclack/src/http-client.test.ts @@ -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", diff --git a/extensions/clickclack/src/http-client.ts b/extensions/clickclack/src/http-client.ts index 5634a92c3fd8..66c533c085c7 100644 --- a/extensions/clickclack/src/http-client.ts +++ b/extensions/clickclack/src/http-client.ts @@ -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 { - 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/setup-claim.test.ts b/extensions/clickclack/src/setup-claim.test.ts new file mode 100644 index 000000000000..7f0e4e2fa005 --- /dev/null +++ b/extensions/clickclack/src/setup-claim.test.ts @@ -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"); + }); +}); diff --git a/extensions/clickclack/src/setup-claim.ts b/extensions/clickclack/src/setup-claim.ts new file mode 100644 index 000000000000..9bc4ff3de031 --- /dev/null +++ b/extensions/clickclack/src/setup-claim.ts @@ -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 { + 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(); + } +} diff --git a/extensions/clickclack/src/setup-core.test.ts b/extensions/clickclack/src/setup-core.test.ts index ff6ca6b5fb1f..f0027c7e8ccd 100644 --- a/extensions/clickclack/src/setup-core.test.ts +++ b/extensions/clickclack/src/setup-core.test.ts @@ -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()), + 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 + >[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, }, }, }); diff --git a/extensions/clickclack/src/setup-core.ts b/extensions/clickclack/src/setup-core.ts index c6e07c047db0..0b6ee1ac53bc 100644 --- a/extensions/clickclack/src/setup-core.ts +++ b/extensions/clickclack/src/setup-core.ts @@ -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({ diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 2f15af801019..952252dc2081 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -140,6 +140,9 @@ export type ChannelSetupInput = { dmAllowlist?: string[]; autoDiscoverChannels?: boolean; workspace?: string; + defaultTo?: string; + allowFrom?: string[]; + agentActivity?: boolean; }; export type ChannelStatusIssue = {