mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user