diff --git a/extensions/clickclack/src/setup-core.test.ts b/extensions/clickclack/src/setup-core.test.ts index aeb88063f7a7..986f83e24861 100644 --- a/extensions/clickclack/src/setup-core.test.ts +++ b/extensions/clickclack/src/setup-core.test.ts @@ -2,7 +2,11 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { describe, expect, it } from "vitest"; -import { clickClackSetupAdapter, normalizeClickClackBaseUrl } from "./setup-core.js"; +import { + applyClickClackCredentialConfig, + clickClackSetupAdapter, + normalizeClickClackBaseUrl, +} from "./setup-core.js"; function validate(params: { cfg?: OpenClawConfig; @@ -162,4 +166,91 @@ describe("ClickClack setup adapter", () => { }, }); }); + + it("clears stale competing credentials when the auth mode changes", () => { + const base = { + channels: { + clickclack: { + baseUrl: "https://clickclack.example", + workspace: "default", + }, + }, + } as OpenClawConfig; + + const withToken = clickClackSetupAdapter.applyAccountConfig({ + cfg: { + channels: { + clickclack: { + ...base.channels?.clickclack, + tokenFile: "/run/secrets/old-token", + }, + }, + } as OpenClawConfig, + accountId: DEFAULT_ACCOUNT_ID, + input: { + token: "ccb_new", + baseUrl: "https://clickclack.example", + workspace: "default", + }, + }); + expect(withToken.channels?.clickclack).toMatchObject({ token: "ccb_new" }); + expect(withToken.channels?.clickclack).not.toHaveProperty("tokenFile"); + + const withFile = clickClackSetupAdapter.applyAccountConfig({ + cfg: { + channels: { + clickclack: { + ...base.channels?.clickclack, + token: "ccb_old", + }, + }, + } as OpenClawConfig, + accountId: DEFAULT_ACCOUNT_ID, + input: { + tokenFile: "/run/secrets/new-token", + baseUrl: "https://clickclack.example", + workspace: "default", + }, + }); + expect(withFile.channels?.clickclack).toMatchObject({ + tokenFile: "/run/secrets/new-token", + }); + expect(withFile.channels?.clickclack).not.toHaveProperty("token"); + + const withEnv = clickClackSetupAdapter.applyAccountConfig({ + cfg: { + channels: { + clickclack: { + ...base.channels?.clickclack, + token: "ccb_old", + tokenFile: "/run/secrets/old-token", + }, + }, + } as OpenClawConfig, + accountId: DEFAULT_ACCOUNT_ID, + input: { useEnv: true }, + }); + expect(withEnv.channels?.clickclack).not.toHaveProperty("token"); + expect(withEnv.channels?.clickclack).not.toHaveProperty("tokenFile"); + }); + + it("preserves credentials when a partial patch does not select an auth mode", () => { + const cfg = { + channels: { + clickclack: { + tokenFile: "/run/secrets/clickclack", + }, + }, + } as OpenClawConfig; + + expect( + applyClickClackCredentialConfig({ + cfg, + accountId: DEFAULT_ACCOUNT_ID, + }).channels?.clickclack, + ).toMatchObject({ + enabled: true, + tokenFile: "/run/secrets/clickclack", + }); + }); }); diff --git a/extensions/clickclack/src/setup-core.ts b/extensions/clickclack/src/setup-core.ts index b514ef55dd23..0933ac08fb6a 100644 --- a/extensions/clickclack/src/setup-core.ts +++ b/extensions/clickclack/src/setup-core.ts @@ -58,6 +58,86 @@ export function applyClickClackSetupConfigPatch(params: { }); } +function clearClickClackSetupConfigFields(params: { + cfg: OpenClawConfig; + accountId: string; + fields: string[]; +}): OpenClawConfig { + const clickclack = (params.cfg.channels as Record | undefined)?.clickclack as + | (Record & { accounts?: Record> }) + | undefined; + if (!clickclack) { + return params.cfg; + } + const accountId = normalizeAccountId(params.accountId); + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextClickClack = { ...clickclack }; + for (const field of params.fields) { + delete nextClickClack[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + clickclack: nextClickClack, + }, + } as OpenClawConfig; + } + const currentAccount = clickclack.accounts?.[accountId]; + if (!currentAccount) { + return params.cfg; + } + const nextAccount = { ...currentAccount }; + for (const field of params.fields) { + delete nextAccount[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + clickclack: { + ...clickclack, + accounts: { + ...clickclack.accounts, + [accountId]: nextAccount, + }, + }, + }, + } as OpenClawConfig; +} + +export function applyClickClackCredentialConfig(params: { + cfg: OpenClawConfig; + accountId: string; + token?: unknown; + tokenFile?: string; + useEnv?: boolean; +}): OpenClawConfig { + const fieldsToClear = params.useEnv + ? ["token", "tokenFile"] + : params.tokenFile + ? ["token"] + : params.token !== undefined + ? ["tokenFile"] + : []; + const next = applyClickClackSetupConfigPatch({ + cfg: params.cfg, + accountId: params.accountId, + patch: params.useEnv + ? {} + : params.tokenFile + ? { tokenFile: params.tokenFile } + : params.token !== undefined + ? { token: params.token } + : {}, + }); + return clearClickClackSetupConfigFields({ + cfg: next, + accountId: params.accountId, + fields: fieldsToClear, + }); +} + export const clickClackSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -100,15 +180,21 @@ export const clickClackSetupAdapter: ChannelSetupAdapter = { const workspace = input.workspace?.trim(); const tokenFile = input.tokenFile?.trim(); const token = input.token?.trim(); - return applyClickClackSetupConfigPatch({ + const next = applyClickClackSetupConfigPatch({ cfg, accountId, name: input.name, patch: { ...(baseUrl ? { baseUrl } : {}), ...(workspace ? { workspace } : {}), - ...(!input.useEnv && tokenFile ? { tokenFile } : !input.useEnv && token ? { token } : {}), }, }); + return applyClickClackCredentialConfig({ + cfg: next, + accountId, + token, + tokenFile, + useEnv: input.useEnv, + }); }, };