diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 9a7a67cf652..60640ada486 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -81,6 +81,129 @@ describe("createAccountListHelpers", () => { it("returns sorted ids", () => { expect(listAccountIds(cfg({ z: {}, a: {}, m: {} }))).toEqual(["a", "m", "z"]); }); + + it("includes default when ALL named accounts have their own tokens", () => { + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + appToken: "xapp-base", + accounts: { tank: { botToken: "xoxb-tank", appToken: "xapp-tank" } }, + }, + }, + } as unknown as OpenClawConfig; + expect(listAccountIds(config)).toEqual(["default", "tank"]); + }); + + it("includes default when normalized IDs differ from raw config keys", () => { + const normalizedHelpers = createAccountListHelpers("testchannel", { + normalizeAccountId: (id: string) => id.toLowerCase().replace(/\s+/g, "-"), + }); + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + accounts: { + "Router D": { botToken: "xoxb-router-d" }, + }, + }, + }, + } as unknown as OpenClawConfig; + // "Router D" normalizes to "router-d" — should still detect own token + expect(normalizedHelpers.listAccountIds(config)).toEqual(["default", "router-d"]); + }); + + it("does NOT inject default in mixed configs (some accounts inherit base tokens)", () => { + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + accounts: { teamA: { botToken: "xoxb-own" }, teamB: {} }, + }, + }, + } as unknown as OpenClawConfig; + // teamB inherits base tokens — injecting default would duplicate teamB + expect(listAccountIds(config)).toEqual(["teamA", "teamB"]); + }); + + it("does NOT inject default when account partially overrides (only appToken)", () => { + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + appToken: "xapp-base", + accounts: { teamA: { appToken: "xapp-own" } }, + }, + }, + } as unknown as OpenClawConfig; + // teamA overrides appToken but inherits botToken — still same bot identity + expect(listAccountIds(config)).toEqual(["teamA"]); + }); + + it("does NOT inject default when base token is whitespace-only", () => { + const config = { + channels: { + testchannel: { + botToken: " ", + accounts: { teamA: {} }, + }, + }, + } as unknown as OpenClawConfig; + expect(listAccountIds(config)).toEqual(["teamA"]); + }); + + it("does NOT inject default when named accounts inherit base tokens (avoids duplicates)", () => { + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + appToken: "xapp-base", + accounts: { tank: {} }, + }, + }, + } as unknown as OpenClawConfig; + expect(listAccountIds(config)).toEqual(["tank"]); + }); + + it("does NOT inject default when Discord named accounts inherit base token", () => { + const config = { + channels: { + testchannel: { + token: "discord-bot-token", + accounts: { teamA: {} }, + }, + }, + } as unknown as OpenClawConfig; + expect(listAccountIds(config)).toEqual(["teamA"]); + }); + + it("does not duplicate default when already in accounts (case-insensitive)", () => { + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + accounts: { Default: {}, tank: {} }, + }, + }, + } as unknown as OpenClawConfig; + expect(listAccountIds(config)).toEqual(["Default", "tank"]); + }); + + it("does not duplicate default when already in accounts", () => { + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + accounts: { default: {}, tank: {} }, + }, + }, + } as unknown as OpenClawConfig; + expect(listAccountIds(config)).toEqual(["default", "tank"]); + }); + + it("does not include default when base has no tokens", () => { + expect(listAccountIds(cfg({ tank: {} }))).toEqual(["tank"]); + }); }); describe("resolveDefaultAccountId", () => { diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 7f72b5e3c55..f6b3bff47ca 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -43,6 +43,66 @@ export function createAccountListHelpers( if (ids.length === 0) { return [DEFAULT_ACCOUNT_ID]; } + // Check whether any existing named account already normalizes to "default". + const normalizedIds = ids.map(normalizeAccountId); + if (normalizedIds.includes(DEFAULT_ACCOUNT_ID)) { + return ids.toSorted((a, b) => a.localeCompare(b)); + } + // If the base channel config has its own tokens (botToken/appToken/token), + // only inject a default account when at least one named account carries its + // own per-account auth. When every named account inherits the base tokens + // (i.e. has no per-account botToken/appToken/token override), injecting + // default would start a duplicate provider on the same credentials. + const channel = cfg.channels?.[channelKey]; + const base = channel as Record | undefined; + const isTruthy = (v: unknown): boolean => + typeof v === "string" ? v.trim().length > 0 : Boolean(v); + // Identify which token fields the base config provides. + const baseTokenFields = (["botToken", "appToken", "token"] as const).filter((f) => + isTruthy(base?.[f]), + ); + if (baseTokenFields.length > 0) { + const accounts = (base?.accounts ?? {}) as Record>; + // Use the caller-provided normalizer for the reverse map so it matches + // the IDs returned by listConfiguredAccountIds(). + const normalizeId = options?.normalizeAccountId ?? normalizeAccountId; + // Build reverse map from normalized ID → raw config key so we can look + // up the account object even when normalizeAccountId transforms the keys. + const rawKeys = Object.keys(accounts); + const normalizedToRaw = new Map(); + for (const key of rawKeys) { + const normalized = normalizeId(key); + if (normalized && !normalizedToRaw.has(normalized)) { + normalizedToRaw.set(normalized, key); + } + } + // Only consider enabled accounts — disabled accounts are skipped at + // startup and must not prevent default injection for enabled ones. + const enabledIds = ids.filter((id) => { + const rawKey = normalizedToRaw.get(id) ?? id; + const acct = accounts[rawKey]; + return !acct || acct["enabled"] !== false; + }); + const everyAccountHasOwnTokens = + enabledIds.length > 0 && + enabledIds.every((id) => { + const rawKey = normalizedToRaw.get(id) ?? id; + const acct = accounts[rawKey]; + if (!acct) { + return false; + } + // An account is only independent if it overrides *every* base token + // field. Partial overrides still inherit the remaining base tokens. + return baseTokenFields.every((f) => isTruthy(acct[f])); + }); + if (everyAccountHasOwnTokens) { + // Every named account has distinct credentials, so default won't + // collide with any of them — safe to inject. + return [DEFAULT_ACCOUNT_ID, ...ids].toSorted((a, b) => a.localeCompare(b)); + } + // At least one named account inherits base tokens — injecting default + // would duplicate that account's credentials. + } return ids.toSorted((a, b) => a.localeCompare(b)); }