From 4e183da984d25a38ed2c0afaa4d81a7b5f0a5fcb Mon Sep 17 00:00:00 2001 From: zeroaltitude Date: Fri, 27 Feb 2026 21:20:04 -0700 Subject: [PATCH 1/7] fix(channels): include default account when base config has tokens When a channel config has top-level tokens (botToken/appToken/token) AND named accounts in the accounts section, listAccountIds() omitted the default account. This meant only named accounts started, leaving the base-config bot disconnected. Now detects base-level tokens and includes 'default' alongside named accounts so both providers start. Includes 3 test cases: base tokens present, default already in accounts (no duplicate), and no base tokens (unchanged behavior). Co-authored-by: Eddie Abrams --- src/channels/plugins/account-helpers.test.ts | 29 ++++++++++++++++++++ src/channels/plugins/account-helpers.ts | 10 +++++++ 2 files changed, 39 insertions(+) diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 9a7a67cf652..b82625dd0c2 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -81,6 +81,35 @@ describe("createAccountListHelpers", () => { it("returns sorted ids", () => { expect(listAccountIds(cfg({ z: {}, a: {}, m: {} }))).toEqual(["a", "m", "z"]); }); + + it("includes default when base config has botToken and named accounts exist", () => { + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + appToken: "xapp-base", + accounts: { 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..03fbf9a9725 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -43,6 +43,16 @@ export function createAccountListHelpers( if (ids.length === 0) { return [DEFAULT_ACCOUNT_ID]; } + // If the base channel config has its own tokens (botToken/appToken/token), + // include the default account alongside named accounts so both providers start. + if (!ids.includes(DEFAULT_ACCOUNT_ID)) { + const channel = cfg.channels?.[channelKey]; + const base = channel as Record | undefined; + const hasBaseTokens = Boolean(base?.botToken || base?.appToken || base?.token); + if (hasBaseTokens) { + return [DEFAULT_ACCOUNT_ID, ...ids].toSorted((a, b) => a.localeCompare(b)); + } + } return ids.toSorted((a, b) => a.localeCompare(b)); } From 4b67092c2b5ffd104eefb5f2258be84cdef36d8f Mon Sep 17 00:00:00 2001 From: zeroaltitude Date: Tue, 3 Mar 2026 09:45:57 -0700 Subject: [PATCH 2/7] fix(channels): skip default account injection when named accounts inherit base tokens - Don't inject 'default' when all named accounts lack per-account auth overrides (they'd use the same base credentials, causing duplicate provider connections and event processing) - Use normalizeAccountId for case-insensitive default detection - Applies to Slack (botToken/appToken) and Discord (token) alike Addresses review feedback from codex-connector on PR #30310. --- src/channels/plugins/account-helpers.test.ts | 41 +++++++++++++++++++- src/channels/plugins/account-helpers.ts | 26 ++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index b82625dd0c2..82a4122c178 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -82,7 +82,20 @@ describe("createAccountListHelpers", () => { expect(listAccountIds(cfg({ z: {}, a: {}, m: {} }))).toEqual(["a", "m", "z"]); }); - it("includes default when base config has botToken and named accounts exist", () => { + it("includes default when base has tokens AND a named account has its own tokens", () => { + const config = { + channels: { + testchannel: { + botToken: "xoxb-base", + appToken: "xapp-base", + accounts: { tank: { botToken: "xoxb-tank" } }, + }, + }, + } as unknown as OpenClawConfig; + expect(listAccountIds(config)).toEqual(["default", "tank"]); + }); + + it("does NOT inject default when named accounts inherit base tokens (avoids duplicates)", () => { const config = { channels: { testchannel: { @@ -92,7 +105,31 @@ describe("createAccountListHelpers", () => { }, }, } as unknown as OpenClawConfig; - expect(listAccountIds(config)).toEqual(["default", "tank"]); + 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", () => { diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 03fbf9a9725..48297ae0f83 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -43,15 +43,29 @@ 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), - // include the default account alongside named accounts so both providers start. - if (!ids.includes(DEFAULT_ACCOUNT_ID)) { - const channel = cfg.channels?.[channelKey]; - const base = channel as Record | undefined; - const hasBaseTokens = Boolean(base?.botToken || base?.appToken || base?.token); - if (hasBaseTokens) { + // 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 hasBaseTokens = Boolean(base?.botToken || base?.appToken || base?.token); + if (hasBaseTokens) { + const accounts = (base?.accounts ?? {}) as Record>; + const someAccountHasOwnTokens = ids.some((id) => { + const acct = accounts[id]; + return acct && Boolean(acct.botToken || acct.appToken || acct.token); + }); + if (someAccountHasOwnTokens) { return [DEFAULT_ACCOUNT_ID, ...ids].toSorted((a, b) => a.localeCompare(b)); } + // All named accounts inherit base tokens — don't inject default. } return ids.toSorted((a, b) => a.localeCompare(b)); } From 3800f859d2a279c298a34579fd3f265563a4949c Mon Sep 17 00:00:00 2001 From: zeroaltitude Date: Tue, 3 Mar 2026 20:16:43 -0700 Subject: [PATCH 3/7] fix(channels): only inject default when ALL named accounts have own tokens Mixed configs (some accounts with own tokens, some inheriting base) would still duplicate credentials between default and inheriting accounts. Now requires every named account to carry per-account auth before injecting default. Addresses codex-connector P1 on PR #30310. --- src/channels/plugins/account-helpers.test.ts | 15 ++++++++++++++- src/channels/plugins/account-helpers.ts | 9 ++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 82a4122c178..8262c8795c6 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -82,7 +82,7 @@ describe("createAccountListHelpers", () => { expect(listAccountIds(cfg({ z: {}, a: {}, m: {} }))).toEqual(["a", "m", "z"]); }); - it("includes default when base has tokens AND a named account has its own tokens", () => { + it("includes default when ALL named accounts have their own tokens", () => { const config = { channels: { testchannel: { @@ -95,6 +95,19 @@ describe("createAccountListHelpers", () => { expect(listAccountIds(config)).toEqual(["default", "tank"]); }); + 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 named accounts inherit base tokens (avoids duplicates)", () => { const config = { channels: { diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 48297ae0f83..210ca7a230f 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -58,14 +58,17 @@ export function createAccountListHelpers( const hasBaseTokens = Boolean(base?.botToken || base?.appToken || base?.token); if (hasBaseTokens) { const accounts = (base?.accounts ?? {}) as Record>; - const someAccountHasOwnTokens = ids.some((id) => { + const everyAccountHasOwnTokens = ids.every((id) => { const acct = accounts[id]; return acct && Boolean(acct.botToken || acct.appToken || acct.token); }); - if (someAccountHasOwnTokens) { + 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)); } - // All named accounts inherit base tokens — don't inject default. + // At least one named account inherits base tokens — injecting default + // would duplicate that account's credentials. } return ids.toSorted((a, b) => a.localeCompare(b)); } From 4e626e91dbcae2996295b484ce0052e013bf13e7 Mon Sep 17 00:00:00 2001 From: zeroaltitude Date: Tue, 3 Mar 2026 21:46:59 -0700 Subject: [PATCH 4/7] fix(channels): require full token override for independence, trim whitespace tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Named accounts must now override ALL base token fields (not just any one) to be considered independent. Partial overrides (e.g. only appToken when base has both botToken+appToken) are correctly treated as inheriting the base identity. - Base token check now trims whitespace — ' ' is not a valid token. - Added 2 test cases: partial override, whitespace-only tokens. Addresses codex-connector P1+P2 on PR #30310. --- src/channels/plugins/account-helpers.test.ts | 28 +++++++++++++++++++- src/channels/plugins/account-helpers.ts | 16 ++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 8262c8795c6..2d4a40088e9 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -88,7 +88,7 @@ describe("createAccountListHelpers", () => { testchannel: { botToken: "xoxb-base", appToken: "xapp-base", - accounts: { tank: { botToken: "xoxb-tank" } }, + accounts: { tank: { botToken: "xoxb-tank", appToken: "xapp-tank" } }, }, }, } as unknown as OpenClawConfig; @@ -108,6 +108,32 @@ describe("createAccountListHelpers", () => { 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: { diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 210ca7a230f..f3e66537b82 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -55,12 +55,22 @@ export function createAccountListHelpers( // default would start a duplicate provider on the same credentials. const channel = cfg.channels?.[channelKey]; const base = channel as Record | undefined; - const hasBaseTokens = Boolean(base?.botToken || base?.appToken || base?.token); - if (hasBaseTokens) { + 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>; const everyAccountHasOwnTokens = ids.every((id) => { const acct = accounts[id]; - return acct && Boolean(acct.botToken || acct.appToken || acct.token); + 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 From 86cc78d593b207cc7fc149b02c301d6d1fa9d678 Mon Sep 17 00:00:00 2001 From: zeroaltitude Date: Sun, 8 Mar 2026 00:29:12 -0700 Subject: [PATCH 5/7] fix: resolve normalized account IDs before token-override checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When normalizeAccountId transforms config keys (e.g. 'Router D' → 'router-d'), listAccountIds returns normalized IDs but the everyAccountHasOwnTokens check used accounts[normalizedId], which misses the raw config key. Added a reverse map from normalized ID to raw key for the lookup. Regression test: non-canonical key normalizes correctly and default is still injected when the account has its own tokens. --- src/channels/plugins/account-helpers.test.ts | 18 ++++++++++++++++++ src/channels/plugins/account-helpers.ts | 13 ++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 2d4a40088e9..60640ada486 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -95,6 +95,24 @@ describe("createAccountListHelpers", () => { 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: { diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index f3e66537b82..35403728c1a 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -63,8 +63,19 @@ export function createAccountListHelpers( ); if (baseTokenFields.length > 0) { const accounts = (base?.accounts ?? {}) as Record>; + // 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 = normalizeAccountId(key); + if (normalized && !normalizedToRaw.has(normalized)) { + normalizedToRaw.set(normalized, key); + } + } const everyAccountHasOwnTokens = ids.every((id) => { - const acct = accounts[id]; + const rawKey = normalizedToRaw.get(id) ?? id; + const acct = accounts[rawKey]; if (!acct) { return false; } From 18fa62651b951f2ac9d740ef7737aa567b721417 Mon Sep 17 00:00:00 2001 From: zeroaltitude Date: Mon, 9 Mar 2026 12:49:30 -0700 Subject: [PATCH 6/7] ci: retrigger checks (prior run hit GitHub API rate limit) From f086588599e86f8b5441e41e71adf409aeabb82a Mon Sep 17 00:00:00 2001 From: zeroaltitude Date: Wed, 18 Mar 2026 19:57:50 -0700 Subject: [PATCH 7/7] fix(channels): use caller normalizer in reverse map; skip disabled accounts Two fixes in response to review: 1. Use options.normalizeAccountId (caller-provided) instead of the imported normalizeAccountId when building the reverse map in everyAccountHasOwnTokens. Ensures the reverse map keys match the IDs returned by listConfiguredAccountIds() when a custom normalizer is used. 2. Filter disabled accounts before the everyAccountHasOwnTokens gate. Disabled accounts are skipped at channel startup; allowing them to block default injection would prevent the base account from starting in mixed enabled/disabled configs where all enabled accounts are independent. --- src/channels/plugins/account-helpers.ts | 28 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 35403728c1a..f6b3bff47ca 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -63,26 +63,38 @@ export function createAccountListHelpers( ); 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 = normalizeAccountId(key); + const normalized = normalizeId(key); if (normalized && !normalizedToRaw.has(normalized)) { normalizedToRaw.set(normalized, key); } } - const everyAccountHasOwnTokens = ids.every((id) => { + // 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]; - 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])); + 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.