fix: harden ClickClack setup credentials

This commit is contained in:
Shakker
2026-07-15 13:05:10 +01:00
committed by Shakker
parent fa6776b9b2
commit b09c99dfee
4 changed files with 103 additions and 22 deletions
+8 -2
View File
@@ -148,7 +148,7 @@ describe("ClickClack account resolution", () => {
expect(resolveClickClackAccount({ cfg, accountId: "work", env }).token).toBe("");
});
it("reads tokenFile credentials and treats them as an implicit default account", () => {
it("reads tokenFile credentials without overriding a named account token", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clickclack-token-"));
const tokenFile = path.join(tempDir, "token");
fs.writeFileSync(tokenFile, " file-token \n", "utf8");
@@ -160,12 +160,18 @@ describe("ClickClack account resolution", () => {
baseUrl: "https://app.clickclack.chat",
workspace: "wsp_1",
tokenFile,
accounts: {
work: {
token: "work-token",
},
},
},
},
} satisfies CoreConfig;
expect(listClickClackAccountIds(cfg)).toEqual(["default"]);
expect(listClickClackAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveClickClackAccount({ cfg }).token).toBe("file-token");
expect(resolveClickClackAccount({ cfg, accountId: "work" }).token).toBe("work-token");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
+23 -4
View File
@@ -8,6 +8,7 @@ import {
} from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution-runtime";
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
@@ -42,17 +43,35 @@ const {
export { DEFAULT_ACCOUNT_ID, listClickClackAccountIds, resolveDefaultClickClackAccountId };
function resolveMergedClickClackAccountConfig(
export function resolveClickClackAccountConfig(
cfg: CoreConfig,
accountId: string,
): ClickClackAccountConfig {
return resolveMergedAccountConfig<ClickClackAccountConfig>({
const channel = cfg.channels?.clickclack;
const merged = resolveMergedAccountConfig<ClickClackAccountConfig>({
channelConfig: cfg.channels?.clickclack as ClickClackAccountConfig | undefined,
accounts: cfg.channels?.clickclack?.accounts,
accounts: channel?.accounts,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
const account = resolveNormalizedAccountEntry(channel?.accounts, accountId, normalizeAccountId);
const accountTokenFile = account?.tokenFile?.trim();
if (accountTokenFile) {
return {
...merged,
token: account.token,
tokenFile: accountTokenFile,
};
}
if (hasConfiguredAccountValue(account?.token)) {
return {
...merged,
token: account?.token,
tokenFile: undefined,
};
}
return merged;
}
function resolveClickClackToken(params: {
@@ -130,7 +149,7 @@ export function resolveClickClackAccount(params: {
env?: NodeJS.ProcessEnv;
}): ResolvedClickClackAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = resolveMergedClickClackAccountConfig(params.cfg, accountId);
const merged = resolveClickClackAccountConfig(params.cfg, accountId);
const baseEnabled = params.cfg.channels?.clickclack?.enabled !== false;
const enabled = baseEnabled && merged.enabled !== false;
const baseUrl = merged.baseUrl?.trim().replace(/\/$/, "") ?? "";
+44 -2
View File
@@ -77,6 +77,19 @@ describe("ClickClack setup adapter", () => {
input: { useEnv: true },
}),
).toBeNull();
expect(
validate({
cfg: {
channels: {
clickclack: {
baseUrl: "ssh://clickclack.example",
workspace: "default",
},
},
} as OpenClawConfig,
input: { useEnv: true },
}),
).toBe("ClickClack base URL must be a valid http(s) URL.");
});
it("rejects malformed base URLs before writing config", () => {
@@ -88,7 +101,7 @@ describe("ClickClack setup adapter", () => {
workspace: "default",
},
}),
).toBe("ClickClack --base-url must be a valid http(s) URL.");
).toBe("ClickClack base URL must be a valid http(s) URL.");
});
it("writes normalized default and named account config", () => {
@@ -221,7 +234,8 @@ describe("ClickClack setup adapter", () => {
cfg: {
channels: {
clickclack: {
...base.channels?.clickclack,
baseUrl: "https://clickclack.example/",
workspace: " default ",
token: "ccb_old",
tokenFile: "/run/secrets/old-token",
},
@@ -232,6 +246,34 @@ describe("ClickClack setup adapter", () => {
});
expect(withEnv.channels?.clickclack).not.toHaveProperty("token");
expect(withEnv.channels?.clickclack).not.toHaveProperty("tokenFile");
expect(withEnv.channels?.clickclack).toMatchObject({
baseUrl: "https://clickclack.example",
workspace: "default",
});
const namedWithToken = clickClackSetupAdapter.applyAccountConfig({
cfg: {
channels: {
clickclack: {
baseUrl: "https://clickclack.example",
workspace: "default",
tokenFile: "/run/secrets/default-token",
},
},
} as OpenClawConfig,
accountId: "work",
input: {
token: "ccb_work",
baseUrl: "https://clickclack.example",
workspace: "work",
},
});
expect(namedWithToken.channels?.clickclack).not.toHaveProperty("tokenFile");
expect(namedWithToken.channels?.clickclack?.accounts).toMatchObject({
default: { tokenFile: "/run/secrets/default-token" },
work: { token: "ccb_work" },
});
expect(namedWithToken.channels?.clickclack?.accounts?.work).not.toHaveProperty("tokenFile");
});
it("preserves credentials when a partial patch does not select an auth mode", () => {
+28 -14
View File
@@ -6,14 +6,16 @@ import {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
moveSingleAccountChannelSectionToDefaultAccount,
} from "openclaw/plugin-sdk/setup";
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime";
import { resolveClickClackAccount } from "./accounts.js";
import { resolveClickClackAccountConfig } from "./accounts.js";
import type { CoreConfig } from "./types.js";
const channel = "clickclack" as const;
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.";
export function normalizeClickClackBaseUrl(value: string | undefined): string | undefined {
const trimmed = value?.trim();
@@ -37,14 +39,22 @@ export function applyClickClackSetupConfigPatch(params: {
name?: string;
patch: Record<string, unknown>;
}): OpenClawConfig {
const accountId = normalizeAccountId(params.accountId);
const scopedConfig =
accountId === DEFAULT_ACCOUNT_ID
? params.cfg
: moveSingleAccountChannelSectionToDefaultAccount({
cfg: params.cfg,
channelKey: channel,
});
const namedConfig = applyAccountNameToChannelSection({
cfg: params.cfg,
cfg: scopedConfig,
channelKey: channel,
accountId: params.accountId,
accountId,
name: params.name,
});
const next =
params.accountId !== DEFAULT_ACCOUNT_ID
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
@@ -53,7 +63,7 @@ export function applyClickClackSetupConfigPatch(params: {
return applySetupAccountConfigPatch({
cfg: next,
channelKey: channel,
accountId: params.accountId,
accountId,
patch: params.patch,
});
}
@@ -157,27 +167,31 @@ export const clickClackSetupAdapter: ChannelSetupAdapter = {
validate: ({ cfg, accountId, input }) => {
const baseUrl = normalizeClickClackBaseUrl(input.baseUrl);
if (input.baseUrl && !baseUrl) {
return "ClickClack --base-url must be a valid http(s) URL.";
return INVALID_BASE_URL_ERROR;
}
if (!input.useEnv) {
return null;
}
const resolved = resolveClickClackAccount({
cfg: cfg as CoreConfig,
accountId,
});
if (!baseUrl && !resolved.baseUrl) {
const existing = resolveClickClackAccountConfig(cfg as CoreConfig, accountId);
const existingBaseUrl = normalizeClickClackBaseUrl(existing.baseUrl);
if (!baseUrl && existing.baseUrl?.trim() && !existingBaseUrl) {
return INVALID_BASE_URL_ERROR;
}
if (!baseUrl && !existingBaseUrl) {
return REQUIRED_INPUT_ERROR;
}
if (!input.workspace?.trim() && !resolved.workspace) {
if (!input.workspace?.trim() && !existing.workspace?.trim()) {
return REQUIRED_INPUT_ERROR;
}
return null;
},
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const baseUrl = normalizeClickClackBaseUrl(input.baseUrl);
const workspace = input.workspace?.trim();
const existing = input.useEnv
? resolveClickClackAccountConfig(cfg as CoreConfig, accountId)
: undefined;
const baseUrl = normalizeClickClackBaseUrl(input.baseUrl ?? existing?.baseUrl);
const workspace = input.workspace?.trim() || existing?.workspace?.trim();
const tokenFile = input.tokenFile?.trim();
const token = input.token?.trim();
const next = applyClickClackSetupConfigPatch({