feat: configure ClickClack accounts from setup codes

This commit is contained in:
Shakker
2026-07-16 23:56:29 +01:00
committed by Shakker
parent 204f4499e8
commit 4cd07e5e8c
7 changed files with 502 additions and 211 deletions
+1 -99
View File
@@ -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",
-111
View File
@@ -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");
});
});
+118
View File
@@ -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();
}
}
+148 -1
View File
@@ -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,
},
},
});
+126
View File
@@ -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({
+3
View File
@@ -140,6 +140,9 @@ export type ChannelSetupInput = {
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
workspace?: string;
defaultTo?: string;
allowFrom?: string[];
agentActivity?: boolean;
};
export type ChannelStatusIssue = {