fix: consume ClickClack v1 setup claim contract (#111927)

Support ClickClack v1 setup claim URLs for split-origin and path-mounted deployments while preserving legacy setup flows and private transport overrides.

Refs #111919.
Prepared head SHA: c13ce82e38
Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
This commit is contained in:
Shakker
2026-07-20 18:50:30 +01:00
committed by GitHub
parent bf6961d33e
commit 49aa383dd7
10 changed files with 485 additions and 45 deletions
+1
View File
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **ClickClack split-origin setup codes:** consume versioned exact claim endpoints without appending a second claim path, validate the returned canonical API base, preserve private API transport overrides, and keep legacy setup URLs working. Fixes #111919. Thanks @shakkernerd.
- **Standalone plugin files:** let manifestless files explicitly listed in `plugins.load.paths` pass config validation and load independently when several files share a directory.
- **Control UI terminal error messages:** preserve message-only assistant output beginning with `Error:` or a warning marker instead of treating text prefixes as synthetic failures. Thanks @shakkernerd.
- **Channel outbound echo suppression:** drop recently emitted platform message and source identities at shared inbound admission and migrate Discord thread unbinds off channel-local expiry state, preventing delayed webhook copies from re-entering agents.
+11 -4
View File
@@ -19,15 +19,22 @@ bot using **Setup code (recommended)**, and copy the generated command:
openclaw channels add clickclack --code 'https://clickclack.example.com/#XXXX-XXXX-XXXX'
```
For separate frontend and API origins or a path-mounted API, ClickClack emits an
exact claim endpoint instead:
```bash
openclaw channels add clickclack --code 'https://api.example.com/services/clickclack/api/bot-setup-codes/claim#XXXX-XXXX-XXXX'
```
The setup code is single-use and expires after 10 minutes. OpenClaw claims it,
receives the newly minted bot token and workspace settings, saves the account,
verifies the connection, and reports whether the running gateway picked it up.
The setup code itself is not stored in OpenClaw config.
For versioned exact endpoints, OpenClaw validates and saves the canonical API
base returned by ClickClack, including any path prefix. The setup code itself is
not stored in OpenClaw config.
Setup-code claims use HTTPS for public servers. Plain HTTP is also supported for
local installations on loopback or private networks, including `localhost`,
private IP addresses, and internal hostnames that resolve only to private
addresses.
local installations on loopback addresses such as `localhost` and `127.0.0.1`.
If OpenClaw is already running, ClickClack connects automatically and no second
command is needed. Otherwise, start it with:
+12
View File
@@ -10,6 +10,18 @@ openclaw plugins install @openclaw/clickclack
## Setup
The recommended setup path uses the one-time command generated by ClickClack:
```sh
openclaw channels add clickclack --code 'https://clickclack.example.com/#XXXX-XXXX-XXXX'
```
Split-origin and path-mounted ClickClack deployments generate an exact
`/api/bot-setup-codes/claim#CODE` endpoint. OpenClaw validates the versioned
claim response and saves its canonical API base.
For manual token setup:
```sh
openclaw channels add clickclack \
--base-url https://clickclack.example.com \
+108 -11
View File
@@ -28,6 +28,29 @@ function requestBodyJson(init: RequestInit | undefined): unknown {
return JSON.parse(body);
}
function claimResponse(extra: Record<string, unknown> = {}): Record<string, unknown> {
return {
token: "test-token",
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,
},
...extra,
};
}
describe("ClickClack setup-code claim", () => {
it("claims over guarded HTTPS without bearer authentication", async () => {
const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) =>
@@ -54,7 +77,7 @@ describe("ClickClack setup-code claim", () => {
await expect(
claimClickClackSetupCode({
baseUrl: "https://clickclack.example",
claimUrl: "https://clickclack.example/api/bot-setup-codes/claim",
code: "ABCD-EFGH-JKMP",
fetch: fetchMock as unknown as typeof fetch,
}),
@@ -90,7 +113,83 @@ describe("ClickClack setup-code claim", () => {
expect(headers.get("Content-Type")).toBe("application/json");
});
it("pins the validated private address when claiming over HTTP", async () => {
it("accepts a matching v1 contract with a path-mounted API base", async () => {
const apiBaseUrl = "https://api.clickclack.example/services/clickclack";
const claimUrl = `${apiBaseUrl}/api/bot-setup-codes/claim`;
const fetchMock = vi.fn(async () =>
Response.json(
claimResponse({
contract_version: 1,
api_base_url: apiBaseUrl,
}),
),
);
await expect(
claimClickClackSetupCode({
claimUrl,
expectedClaimUrl: claimUrl,
code: "ABCD-EFGH-JKMP",
fetch: fetchMock as unknown as typeof fetch,
}),
).resolves.toMatchObject({
contract_version: 1,
api_base_url: apiBaseUrl,
token: "test-token",
});
expect(fetchMock).toHaveBeenCalledWith(
claimUrl,
expect.objectContaining({ method: "POST", redirect: "manual" }),
);
});
it("rejects incomplete, unsupported, and mismatched v1 metadata", async () => {
const claimUrl = "https://api.clickclack.example/services/clickclack/api/bot-setup-codes/claim";
const fetchMock = vi.fn();
for (const [response, message] of [
[claimResponse(), "legacy response"],
[
claimResponse({
contract_version: 2,
api_base_url: "https://api.clickclack.example/services/clickclack",
}),
"invalid v1 contract metadata",
],
[
claimResponse({
contract_version: 1,
api_base_url: "https://other.example/services/clickclack",
}),
"does not match the claim URL",
],
[
claimResponse({
contract_version: 1,
api_base_url: "https://api.clickclack.example/services/clickclack?invalid=1",
}),
"response.api_base_url is invalid",
],
[
claimResponse({
contract_version: 1,
api_base_url: "http://10.0.0.5/services/clickclack",
}),
"must use HTTPS unless it is on loopback",
],
] as const) {
fetchMock.mockResolvedValueOnce(Response.json(response));
await expect(
claimClickClackSetupCode({
claimUrl,
expectedClaimUrl: claimUrl,
code: "ABCD-EFGH-JKMP",
fetch: fetchMock as unknown as typeof fetch,
}),
).rejects.toThrow(message);
}
});
it("pins the validated loopback address when claiming over HTTP", async () => {
const server = createServer((_request, response) => {
response.setHeader("Content-Type", "application/json");
response.end(
@@ -116,7 +215,7 @@ describe("ClickClack setup-code claim", () => {
try {
await expect(
claimClickClackSetupCode({
baseUrl: `http://localhost:${port}`,
claimUrl: `http://localhost:${port}/api/bot-setup-codes/claim`,
code: "ABCD-EFGH-JKMP",
lookupFn,
}),
@@ -132,20 +231,18 @@ describe("ClickClack setup-code claim", () => {
}
});
it("rejects public HTTP claims before sending a request", async () => {
it("rejects non-loopback HTTP claims before sending a request", async () => {
const fetchMock = vi.fn();
for (const address of ["93.184.216.34", "198.18.0.1"]) {
for (const address of ["10.0.0.5", "93.184.216.34", "198.18.0.1"]) {
await expect(
claimClickClackSetupCode({
baseUrl: "http://clickclack.example",
claimUrl: "http://clickclack.example/api/bot-setup-codes/claim",
code: "ABCD-EFGH-JKMP",
fetch: fetchMock as unknown as typeof fetch,
lookupFn: createLookupFn(address),
}),
).rejects.toThrow(
"ClickClack setup codes require HTTPS unless the server is on a private or loopback network.",
);
).rejects.toThrow("ClickClack setup codes require HTTPS unless the server is on loopback.");
}
expect(fetchMock).not.toHaveBeenCalled();
@@ -159,7 +256,7 @@ describe("ClickClack setup-code claim", () => {
try {
const claim = expect(
claimClickClackSetupCode({
baseUrl: "http://clickclack.internal",
claimUrl: "http://clickclack.internal/api/bot-setup-codes/claim",
code: "ABCD-EFGH-JKMP",
fetch: fetchMock as unknown as typeof fetch,
lookupFn,
@@ -186,7 +283,7 @@ describe("ClickClack setup-code claim", () => {
await expect(
claimClickClackSetupCode({
baseUrl: "https://clickclack.example",
claimUrl: "https://clickclack.example/api/bot-setup-codes/claim",
code: "ABCD-EFGH-JKMP",
fetch: fetchMock as unknown as typeof fetch,
}),
+60 -16
View File
@@ -7,12 +7,17 @@ import {
} from "openclaw/plugin-sdk/provider-http";
import {
fetchWithSsrFGuard,
isPrivateOrLoopbackHost,
resolvePinnedHostnameWithPolicy,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
type LookupFn,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
import {
buildClickClackSetupClaimUrl,
CLICKCLACK_SETUP_CODE_CLAIM_PATH,
isClickClackSetupLoopbackHost,
requireClickClackSetupApiBaseUrl,
} from "./setup-contract.js";
import type { ClickClackSetupCodeClaim } from "./types.js";
const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
@@ -43,7 +48,10 @@ function requireString(record: Record<string, unknown>, key: string, label: stri
return value;
}
function parseClickClackSetupCodeClaim(value: unknown): ClickClackSetupCodeClaim {
function parseClickClackSetupCodeClaim(
value: unknown,
expectedClaimUrl?: string,
): ClickClackSetupCodeClaim {
const claim = requireRecord(value, "response");
const bot = requireRecord(claim.bot, "bot");
const workspace = requireRecord(claim.workspace, "workspace");
@@ -63,7 +71,31 @@ function parseClickClackSetupCodeClaim(value: unknown): ClickClackSetupCodeClaim
if (agentActivity !== undefined && typeof agentActivity !== "boolean") {
throw new Error("ClickClack setup code claim returned invalid defaults.agentActivity");
}
const contractVersion = claim.contract_version;
const apiBaseUrlValue = claim.api_base_url;
const hasContractMetadata = contractVersion !== undefined || apiBaseUrlValue !== undefined;
if (expectedClaimUrl && !hasContractMetadata) {
throw new Error("ClickClack setup code claim returned a legacy response for an exact endpoint");
}
let contract: Pick<ClickClackSetupCodeClaim, "contract_version" | "api_base_url"> = {};
if (hasContractMetadata) {
if (contractVersion !== 1 || typeof apiBaseUrlValue !== "string") {
throw new Error("ClickClack setup code claim returned invalid v1 contract metadata");
}
const apiBaseUrl = requireClickClackSetupApiBaseUrl(
apiBaseUrlValue,
"setup code claim response.api_base_url",
);
const canonicalClaimUrl = buildClickClackSetupClaimUrl(apiBaseUrl);
if (expectedClaimUrl && expectedClaimUrl !== canonicalClaimUrl) {
throw new Error(
"ClickClack setup code claim returned an API base that does not match the claim URL",
);
}
contract = { contract_version: 1, api_base_url: apiBaseUrl };
}
return {
...contract,
token: requireString(claim, "token", "response"),
bot: {
id: requireString(bot, "id", "bot"),
@@ -86,25 +118,40 @@ function parseClickClackSetupCodeClaim(value: unknown): ClickClackSetupCodeClaim
/** Claims a one-time setup code without sending any existing bot credential. */
export async function claimClickClackSetupCode(params: {
baseUrl: string;
claimUrl: string;
expectedClaimUrl?: string;
code: string;
fetch?: typeof fetch;
lookupFn?: LookupFn;
}): Promise<ClickClackSetupCodeClaim> {
const baseUrl = params.baseUrl.replace(/\/+$/, "");
const parsedBaseUrl = new URL(baseUrl);
let parsedClaimUrl: URL;
try {
parsedClaimUrl = new URL(params.claimUrl);
} catch {
throw new Error("ClickClack setup code claim URL must be a valid HTTP(S) endpoint.");
}
if (
(parsedClaimUrl.protocol !== "http:" && parsedClaimUrl.protocol !== "https:") ||
parsedClaimUrl.username ||
parsedClaimUrl.password ||
parsedClaimUrl.search ||
parsedClaimUrl.hash ||
!parsedClaimUrl.pathname.endsWith(CLICKCLACK_SETUP_CODE_CLAIM_PATH)
) {
throw new Error("ClickClack setup code claim URL must be a valid HTTP(S) endpoint.");
}
const deadline = createProviderOperationDeadline({
label: "ClickClack setup code claim",
timeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS,
});
let pinnedHttpTarget: { hostname: string; addresses: string[] } | undefined;
if (parsedBaseUrl.protocol === "http:") {
if (parsedClaimUrl.protocol === "http:") {
const resolveTimeoutMs = resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS,
});
const pinned = await withTimeout(
resolvePinnedHostnameWithPolicy(parsedBaseUrl.hostname, {
resolvePinnedHostnameWithPolicy(parsedClaimUrl.hostname, {
lookupFn: params.lookupFn,
policy: { dangerouslyAllowPrivateNetwork: true },
}),
@@ -113,16 +160,13 @@ export async function claimClickClackSetupCode(params: {
message: `ClickClack setup code claim timed out after ${CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS}ms`,
},
);
if (!pinned.addresses.every((address) => isPrivateOrLoopbackHost(address))) {
throw new Error(
"ClickClack setup codes require HTTPS unless the server is on a private or loopback network.",
);
if (!pinned.addresses.every((address) => isClickClackSetupLoopbackHost(address))) {
throw new Error("ClickClack setup codes require HTTPS unless the server is on loopback.");
}
pinnedHttpTarget = { hostname: pinned.hostname, addresses: pinned.addresses };
}
const url = `${baseUrl}/api/bot-setup-codes/claim`;
const { response, release } = await fetchWithSsrFGuard({
url,
url: params.claimUrl,
fetchImpl: params.fetch,
init: {
method: "POST",
@@ -137,8 +181,8 @@ export async function claimClickClackSetupCode(params: {
deadline,
defaultTimeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS,
}),
requireHttps: parsedBaseUrl.protocol === "https:",
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
requireHttps: parsedClaimUrl.protocol === "https:",
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(params.claimUrl),
lookupFn: params.lookupFn,
...(pinnedHttpTarget
? { dispatcherPolicy: { mode: "direct", pinnedHostname: pinnedHttpTarget } }
@@ -153,7 +197,7 @@ export async function claimClickClackSetupCode(params: {
const value = await readProviderJsonResponse<unknown>(response, "ClickClack setup code claim", {
maxBytes: CLICKCLACK_SETUP_CODE_CLAIM_JSON_LIMIT_BYTES,
});
return parseClickClackSetupCodeClaim(value);
return parseClickClackSetupCodeClaim(value, params.expectedClaimUrl);
} finally {
await release();
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
buildClickClackSetupClaimUrl,
isClickClackSetupLoopbackHost,
requireClickClackSetupApiBaseUrl,
requireClickClackSetupClaimUrl,
} from "./setup-contract.js";
describe("ClickClack setup contract", () => {
it("accepts canonical public API bases and exact claim endpoints", () => {
for (const apiBaseUrl of [
"https://api.clickclack.example",
"https://api.clickclack.example/services/clickclack",
"http://localhost:8484",
"http://127.0.0.1:8484/services/clickclack",
"http://[::1]:8484",
]) {
expect(requireClickClackSetupApiBaseUrl(apiBaseUrl, "API base")).toBe(apiBaseUrl);
const claimUrl = buildClickClackSetupClaimUrl(apiBaseUrl);
expect(requireClickClackSetupClaimUrl(claimUrl)).toEqual({ apiBaseUrl, claimUrl });
}
});
it("matches ClickClack's loopback-only HTTP policy", () => {
for (const hostname of [
"localhost",
"LOCALHOST",
"127.0.0.1",
"127.255.255.254",
"::1",
"::ffff:127.0.0.1",
]) {
expect(isClickClackSetupLoopbackHost(hostname)).toBe(true);
}
for (const hostname of ["10.0.0.5", "172.16.0.1", "192.168.1.1", "example.com"]) {
expect(isClickClackSetupLoopbackHost(hostname)).toBe(false);
}
});
it("rejects non-canonical or unsafe public API bases", () => {
for (const apiBaseUrl of [
"http://10.0.0.5",
"https://api.clickclack.example/",
"https://api.clickclack.example:443",
"https://user@api.clickclack.example",
"https://api.clickclack.example/services//clickclack",
"https://api.clickclack.example/services/clickclack/",
"https://api.clickclack.example/services/click%2Fclack",
"https://api.clickclack.example?next=claim",
"https://api.clickclack.example#fragment",
]) {
expect(() => requireClickClackSetupApiBaseUrl(apiBaseUrl, "API base")).toThrow();
}
});
it("rejects claim URLs that are not the exact canonical endpoint", () => {
for (const claimUrl of [
"https://api.clickclack.example/api/bot-setup-codes/claim/",
"https://api.clickclack.example/api/bot-setup-codes/claim?next=1",
"https://api.clickclack.example/api/bot-setup-codes/other",
"http://10.0.0.5/api/bot-setup-codes/claim",
]) {
expect(() => requireClickClackSetupClaimUrl(claimUrl)).toThrow();
}
});
});
+101
View File
@@ -0,0 +1,101 @@
import net from "node:net";
export const CLICKCLACK_SETUP_CODE_CLAIM_PATH = "/api/bot-setup-codes/claim";
const LOOPBACK_ADDRESSES = new net.BlockList();
LOOPBACK_ADDRESSES.addSubnet("127.0.0.0", 8, "ipv4");
LOOPBACK_ADDRESSES.addAddress("::1", "ipv6");
const BASE_PATH_SEGMENT = /^[A-Za-z0-9._~-]+$/u;
export function isClickClackSetupLoopbackHost(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
if (normalized === "localhost") {
return true;
}
const family = net.isIP(normalized);
return family !== 0 && LOOPBACK_ADDRESSES.check(normalized, family === 4 ? "ipv4" : "ipv6");
}
export function requireClickClackSetupApiBaseUrl(value: string, label: string): string {
if (!value || value !== value.trim()) {
throw new Error(`ClickClack ${label} is invalid`);
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`ClickClack ${label} is invalid`);
}
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
!parsed.hostname ||
parsed.hostname.endsWith(".") ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
) {
throw new Error(`ClickClack ${label} is invalid`);
}
if (parsed.protocol === "http:" && !isClickClackSetupLoopbackHost(parsed.hostname)) {
throw new Error(`ClickClack ${label} must use HTTPS unless it is on loopback`);
}
const pathname = parsed.pathname;
let basePath = "";
if (pathname !== "/") {
if (
pathname.endsWith("/") ||
pathname.includes("//") ||
pathname.includes("\\") ||
pathname
.slice(1)
.split("/")
.some((segment) => !BASE_PATH_SEGMENT.test(segment))
) {
throw new Error(`ClickClack ${label} is invalid`);
}
basePath = pathname;
}
const canonical = parsed.origin + basePath;
if (value !== canonical) {
throw new Error(`ClickClack ${label} is not canonical`);
}
return canonical;
}
export function requireClickClackSetupClaimUrl(value: string): {
claimUrl: string;
apiBaseUrl: string;
} {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error("ClickClack setup URL has an invalid claim endpoint.");
}
if (
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash ||
!parsed.pathname.endsWith(CLICKCLACK_SETUP_CODE_CLAIM_PATH)
) {
throw new Error("ClickClack setup URL has an invalid claim endpoint.");
}
const basePath = parsed.pathname.slice(0, -CLICKCLACK_SETUP_CODE_CLAIM_PATH.length);
const apiBaseUrl = requireClickClackSetupApiBaseUrl(
parsed.origin + basePath,
"setup URL API base",
);
const claimUrl = apiBaseUrl + CLICKCLACK_SETUP_CODE_CLAIM_PATH;
if (value !== claimUrl) {
throw new Error("ClickClack setup URL has a non-canonical claim endpoint.");
}
return { claimUrl, apiBaseUrl };
}
export function buildClickClackSetupClaimUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/u, "") + CLICKCLACK_SETUP_CODE_CLAIM_PATH;
}
+85 -4
View File
@@ -98,7 +98,40 @@ describe("ClickClack setup adapter", () => {
agentActivity: true,
});
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
baseUrl: "https://clickclack.example",
claimUrl: "https://clickclack.example/api/bot-setup-codes/claim",
code: "ABCDEFGHJKMN",
});
});
it("claims an exact v1 endpoint and keeps the returned API base path", async () => {
claimClickClackSetupCode.mockResolvedValue({
contract_version: 1,
api_base_url: "https://api.clickclack.example/services/clickclack",
token: "test-token",
bot: { id: "usr_bot", handle: "openclaw", display_name: "OpenClaw" },
workspace: {
id: "wsp_1",
route_id: "clickclack",
slug: "default",
name: "ClickClack",
},
defaults: {},
});
const exactClaimUrl =
"https://api.clickclack.example/services/clickclack/api/bot-setup-codes/claim";
await expect(
prepare({
code: `${exactClaimUrl}#abcd-efgh-jkmn`,
}),
).resolves.toMatchObject({
baseUrl: "https://api.clickclack.example/services/clickclack",
token: "test-token",
workspace: "wsp_1",
});
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
claimUrl: exactClaimUrl,
expectedClaimUrl: exactClaimUrl,
code: "ABCDEFGHJKMN",
});
});
@@ -127,7 +160,7 @@ describe("ClickClack setup adapter", () => {
workspace: "wsp_1",
});
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
baseUrl: "https://clickclack.example",
claimUrl: "https://clickclack.example/api/bot-setup-codes/claim",
code: "ABCDEFGHJKMN",
});
});
@@ -160,7 +193,42 @@ describe("ClickClack setup adapter", () => {
);
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
baseUrl: "http://127.0.0.1:8484",
claimUrl: "http://127.0.0.1:8484/api/bot-setup-codes/claim",
code: "ABCDEFGHJKMN",
});
});
it("uses a private API transport while validating the public exact endpoint", async () => {
claimClickClackSetupCode.mockResolvedValue({
contract_version: 1,
api_base_url: "https://api.clickclack.example/services/clickclack",
token: "test-token",
bot: { id: "usr_bot", handle: "openclaw", display_name: "OpenClaw" },
workspace: {
id: "wsp_1",
route_id: "clickclack",
slug: "default",
name: "ClickClack",
},
defaults: {},
});
const exactClaimUrl =
"https://api.clickclack.example/services/clickclack/api/bot-setup-codes/claim";
await expect(
prepare({ code: `${exactClaimUrl}#ABCD-EFGH-JKMN` }, {
channels: {
clickclack: {
apiBaseUrl: "http://127.0.0.1:8484",
},
},
} as OpenClawConfig),
).resolves.toMatchObject({
baseUrl: "https://api.clickclack.example/services/clickclack",
});
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
claimUrl: "http://127.0.0.1:8484/api/bot-setup-codes/claim",
expectedClaimUrl: exactClaimUrl,
code: "ABCDEFGHJKMN",
});
});
@@ -188,7 +256,7 @@ describe("ClickClack setup adapter", () => {
workspace: "wsp_1",
});
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
baseUrl: "http://localhost:3000",
claimUrl: "http://localhost:3000/api/bot-setup-codes/claim",
code: "ABCDEFGHJKMN",
});
});
@@ -223,6 +291,19 @@ describe("ClickClack setup adapter", () => {
await expect(
prepare({ code: "not-a-code", baseUrl: "https://clickclack.example" }),
).rejects.toThrow("12 valid base32 characters");
await expect(
prepare({
code: "https://clickclack.example/?next=api#ABCD-EFGH-JKMN",
}),
).rejects.toThrow("must not include a query");
await expect(
prepare({
code:
"https://api.clickclack.example/services/clickclack/api/bot-setup-codes/claim" +
"#ABCD-EFGH-JKMN",
baseUrl: "https://api.clickclack.example",
}),
).rejects.toThrow("does not match");
expect(claimClickClackSetupCode).not.toHaveBeenCalled();
});
+39 -10
View File
@@ -11,6 +11,11 @@ import {
} from "openclaw/plugin-sdk/setup";
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime";
import { resolveClickClackAccountConfig } from "./accounts.js";
import {
buildClickClackSetupClaimUrl,
CLICKCLACK_SETUP_CODE_CLAIM_PATH,
requireClickClackSetupClaimUrl,
} from "./setup-contract.js";
import type { CoreConfig } from "./types.js";
const channel = "clickclack" as const;
@@ -60,6 +65,7 @@ function requireClickClackSetupCodeBaseUrl(value: string | undefined): string {
function parseClickClackSetupCodeInput(params: { code: string; baseUrl?: string }): {
code: string;
baseUrl: string;
exactClaimUrl?: string;
} {
const rawCode = params.code.trim();
if (!rawCode) {
@@ -81,27 +87,41 @@ function parseClickClackSetupCodeInput(params: { code: string; baseUrl?: string
if (setupUrl.username || setupUrl.password) {
throw new Error("ClickClack setup URLs must not include credentials.");
}
if (setupUrl.search) {
throw new Error("ClickClack setup URLs must not include a query.");
}
code = setupUrl.hash.slice(1);
if (!code) {
throw new Error("ClickClack setup URL is missing its #CODE fragment.");
}
setupUrl.hash = "";
setupUrl.search = "";
baseUrl = requireClickClackSetupCodeBaseUrl(setupUrl.toString());
let exactClaimUrl: string | undefined;
if (setupUrl.pathname.endsWith(CLICKCLACK_SETUP_CODE_CLAIM_PATH)) {
const exactEndpoint = requireClickClackSetupClaimUrl(setupUrl.toString());
baseUrl = exactEndpoint.apiBaseUrl;
exactClaimUrl = exactEndpoint.claimUrl;
} else {
baseUrl = requireClickClackSetupCodeBaseUrl(setupUrl.toString());
}
if (params.baseUrl) {
const suppliedBaseUrl = requireClickClackSetupCodeBaseUrl(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.");
const normalizedCode = normalizeClickClackSetupCode(code);
if (!normalizedCode) {
throw new Error("ClickClack setup code must contain 12 valid base32 characters.");
}
baseUrl = requireClickClackSetupCodeBaseUrl(params.baseUrl);
return { code: normalizedCode, baseUrl, ...(exactClaimUrl ? { exactClaimUrl } : {}) };
}
code = code.startsWith("#") ? code.slice(1) : code;
if (!params.baseUrl) {
throw new Error("A bare ClickClack setup code requires --base-url.");
}
baseUrl = requireClickClackSetupCodeBaseUrl(params.baseUrl);
const normalizedCode = normalizeClickClackSetupCode(code);
if (!normalizedCode) {
throw new Error("ClickClack setup code must contain 12 valid base32 characters.");
@@ -248,19 +268,28 @@ export const clickClackSetupAdapter: ChannelSetupAdapter = {
if (input.token?.trim() || input.tokenFile?.trim() || input.useEnv) {
throw new Error(SETUP_CODE_CONFLICT_ERROR);
}
const setup = parseClickClackSetupCodeInput({
let setup = parseClickClackSetupCodeInput({
code: input.code,
baseUrl: input.baseUrl,
});
const existing = resolveClickClackAccountConfig(cfg as CoreConfig, accountId);
const apiEndpoint = normalizeClickClackBaseUrl(existing.apiBaseUrl) ?? setup.baseUrl;
const privateApiBaseUrl = normalizeClickClackBaseUrl(existing.apiBaseUrl);
const claimUrl =
setup.exactClaimUrl && !privateApiBaseUrl
? setup.exactClaimUrl
: buildClickClackSetupClaimUrl(privateApiBaseUrl ?? setup.baseUrl);
let claim;
try {
const { claimClickClackSetupCode } = await import("./setup-claim.js");
claim = await claimClickClackSetupCode({ ...setup, baseUrl: apiEndpoint });
claim = await claimClickClackSetupCode({
claimUrl,
code: setup.code,
...(setup.exactClaimUrl ? { expectedClaimUrl: setup.exactClaimUrl } : {}),
});
} catch (error) {
throw formatClickClackSetupCodeClaimError(error);
}
setup = { ...setup, baseUrl: claim.api_base_url ?? setup.baseUrl };
const { code: _code, tokenFile: _tokenFile, useEnv: _useEnv, ...remainingInput } = input;
return {
...remainingInput,
+2
View File
@@ -105,6 +105,8 @@ export type ClickClackBotCommand = {
/** One-time bot token and installer context returned by setup-code claim. */
export type ClickClackSetupCodeClaim = {
contract_version?: 1;
api_base_url?: string;
token: string;
bot: {
id: string;