Channels: move single-account config into accounts.default (#27334)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 50b5771808
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana 2026-02-26 04:06:03 -05:00 committed by GitHub
parent da6a96ed33
commit dfa0b5b4fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 639 additions and 7 deletions

View File

@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
- Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn.
- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.
- CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant.

View File

@ -45,6 +45,16 @@ If you confirm bind now, the wizard asks which agent should own each configured
You can also manage the same routing rules later with `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` (see [agents](/cli/agents)).
When you add a non-default account to a channel that is still using single-account top-level settings (no `channels.<channel>.accounts` entries yet), OpenClaw moves account-scoped single-account top-level values into `channels.<channel>.accounts.default`, then writes the new account. This preserves the original account behavior while moving to the multi-account shape.
Routing behavior stays consistent:
- Existing channel-only bindings (no `accountId`) continue to match the default account.
- `channels add` does not auto-create or rewrite bindings in non-interactive mode.
- Interactive setup can optionally add account-scoped bindings.
If your config was already in a mixed state (named accounts present, missing `default`, and top-level single-account values still set), run `openclaw doctor --fix` to move account-scoped values into `accounts.default`.
## Login / logout (interactive)
```bash

View File

@ -400,6 +400,8 @@ Subcommands:
- Tip: `channels status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `openclaw doctor`).
- `channels logs`: show recent channel logs from the gateway log file.
- `channels add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode.
- When adding a non-default account to a channel still using single-account top-level config, OpenClaw moves account-scoped values into `channels.<channel>.accounts.default` before writing the new account.
- Non-interactive `channels add` does not auto-create/upgrade bindings; channel-only bindings continue to match the default account.
- `channels remove`: disable by default; pass `--delete` to remove config entries without prompts.
- `channels login`: interactive channel login (WhatsApp Web only).
- `channels logout`: log out of a channel session (if supported).

View File

@ -505,6 +505,9 @@ Run multiple accounts per channel (each with its own `accountId`):
- Env tokens only apply to the **default** account.
- Base channel settings apply to all accounts unless overridden per account.
- Use `bindings[].match.accountId` to route each account to a different agent.
- If you add a non-default account via `openclaw channels add` (or channel onboarding) while still on a single-account top-level channel config, OpenClaw moves account-scoped top-level single-account values into `channels.<channel>.accounts.default` first so the original account keeps working.
- Existing channel-only bindings (no `accountId`) keep matching the default account; account-scoped bindings remain optional.
- `openclaw doctor --fix` also repairs mixed shapes by moving account-scoped top-level single-account values into `accounts.default` when named accounts exist but `default` is missing.
### Group chat mention gating

View File

@ -121,6 +121,7 @@ Current migrations:
- `routing.agentToAgent``tools.agentToAgent`
- `routing.transcribeAudio``tools.media.audio.models`
- `bindings[].match.accountID``bindings[].match.accountId`
- For channels with named `accounts` but missing `accounts.default`, move account-scoped top-level single-account channel values into `channels.<channel>.accounts.default` when present
- `identity``agents.list[].identity`
- `agent.*``agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`

View File

@ -554,6 +554,39 @@ describe("patchChannelConfigForAccount", () => {
expect(next.channels?.slack?.accounts?.work?.appToken).toBe("new-app");
});
it("moves single-account config into default account when patching non-default", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
enabled: true,
botToken: "legacy-token",
allowFrom: ["100"],
groupPolicy: "allowlist",
streaming: "partial",
},
},
};
const next = patchChannelConfigForAccount({
cfg,
channel: "telegram",
accountId: "work",
patch: { botToken: "work-token" },
});
expect(next.channels?.telegram?.accounts?.default).toEqual({
botToken: "legacy-token",
allowFrom: ["100"],
groupPolicy: "allowlist",
streaming: "partial",
});
expect(next.channels?.telegram?.botToken).toBeUndefined();
expect(next.channels?.telegram?.allowFrom).toBeUndefined();
expect(next.channels?.telegram?.groupPolicy).toBeUndefined();
expect(next.channels?.telegram?.streaming).toBeUndefined();
expect(next.channels?.telegram?.accounts?.work?.botToken).toBe("work-token");
});
it("supports imessage/signal account-scoped channel patches", () => {
const cfg: OpenClawConfig = {
channels: {

View File

@ -4,6 +4,7 @@ import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboa
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js";
import { moveSingleAccountChannelSectionToDefaultAccount } from "../setup-helpers.js";
export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => {
return await promptAccountIdSdk(params);
@ -282,13 +283,21 @@ function patchConfigForScopedAccount(params: {
ensureEnabled: boolean;
}): OpenClawConfig {
const { cfg, channel, accountId, patch, ensureEnabled } = params;
const channelConfig = (cfg.channels?.[channel] as Record<string, unknown> | undefined) ?? {};
const seededCfg =
accountId === DEFAULT_ACCOUNT_ID
? cfg
: moveSingleAccountChannelSectionToDefaultAccount({
cfg,
channelKey: channel,
});
const channelConfig =
(seededCfg.channels?.[channel] as Record<string, unknown> | undefined) ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
...seededCfg,
channels: {
...cfg.channels,
...seededCfg.channels,
[channel]: {
...channelConfig,
...(ensureEnabled ? { enabled: true } : {}),
@ -303,9 +312,9 @@ function patchConfigForScopedAccount(params: {
const existingAccount = accounts[accountId] ?? {};
return {
...cfg,
...seededCfg,
channels: {
...cfg.channels,
...seededCfg.channels,
[channel]: {
...channelConfig,
...(ensureEnabled ? { enabled: true } : {}),

View File

@ -119,3 +119,115 @@ export function migrateBaseNameToDefaultAccount(params: {
},
} as OpenClawConfig;
}
type ChannelSectionRecord = Record<string, unknown> & {
accounts?: Record<string, Record<string, unknown>>;
};
const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([
"name",
"token",
"tokenFile",
"botToken",
"appToken",
"account",
"signalNumber",
"authDir",
"cliPath",
"dbPath",
"httpUrl",
"httpHost",
"httpPort",
"webhookPath",
"webhookUrl",
"webhookSecret",
"service",
"region",
"homeserver",
"userId",
"accessToken",
"password",
"deviceName",
"url",
"code",
"dmPolicy",
"allowFrom",
"groupPolicy",
"groupAllowFrom",
"defaultTo",
]);
const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record<string, ReadonlySet<string>> = {
telegram: new Set(["streaming"]),
};
export function shouldMoveSingleAccountChannelKey(params: {
channelKey: string;
key: string;
}): boolean {
if (COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE.has(params.key)) {
return true;
}
return SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL[params.channelKey]?.has(params.key) ?? false;
}
function cloneIfObject<T>(value: T): T {
if (value && typeof value === "object") {
return structuredClone(value);
}
return value;
}
// When promoting a single-account channel config to multi-account,
// move top-level account settings into accounts.default so the original
// account keeps working without duplicate account values at channel root.
export function moveSingleAccountChannelSectionToDefaultAccount(params: {
cfg: OpenClawConfig;
channelKey: string;
}): OpenClawConfig {
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const baseConfig = channels?.[params.channelKey];
const base =
typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionRecord) : undefined;
if (!base) {
return params.cfg;
}
const accounts = base.accounts ?? {};
if (Object.keys(accounts).length > 0) {
return params.cfg;
}
const keysToMove = Object.entries(base)
.filter(
([key, value]) =>
key !== "accounts" &&
key !== "enabled" &&
value !== undefined &&
shouldMoveSingleAccountChannelKey({ channelKey: params.channelKey, key }),
)
.map(([key]) => key);
const defaultAccount: Record<string, unknown> = {};
for (const key of keysToMove) {
const value = base[key];
defaultAccount[key] = cloneIfObject(value);
}
const nextChannel: ChannelSectionRecord = { ...base };
for (const key of keysToMove) {
delete nextChannel[key];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...nextChannel,
accounts: {
...accounts,
[DEFAULT_ACCOUNT_ID]: defaultAccount,
},
},
},
} as OpenClawConfig;
}

View File

@ -66,6 +66,96 @@ describe("channels command", () => {
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
});
it("moves single-account telegram config into accounts.default when adding non-default", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
telegram: {
enabled: true,
botToken: "legacy-token",
dmPolicy: "allowlist",
allowFrom: ["111"],
groupPolicy: "allowlist",
streaming: "partial",
},
},
},
});
await channelsAddCommand(
{ channel: "telegram", account: "alerts", token: "alerts-token" },
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
channels?: {
telegram?: {
botToken?: string;
dmPolicy?: string;
allowFrom?: string[];
groupPolicy?: string;
streaming?: string;
accounts?: Record<
string,
{
botToken?: string;
dmPolicy?: string;
allowFrom?: string[];
groupPolicy?: string;
streaming?: string;
}
>;
};
};
};
expect(next.channels?.telegram?.accounts?.default).toEqual({
botToken: "legacy-token",
dmPolicy: "allowlist",
allowFrom: ["111"],
groupPolicy: "allowlist",
streaming: "partial",
});
expect(next.channels?.telegram?.botToken).toBeUndefined();
expect(next.channels?.telegram?.dmPolicy).toBeUndefined();
expect(next.channels?.telegram?.allowFrom).toBeUndefined();
expect(next.channels?.telegram?.groupPolicy).toBeUndefined();
expect(next.channels?.telegram?.streaming).toBeUndefined();
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
});
it("seeds accounts.default for env-only single-account telegram config when adding non-default", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
telegram: {
enabled: true,
},
},
},
});
await channelsAddCommand(
{ channel: "telegram", account: "alerts", token: "alerts-token" },
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
channels?: {
telegram?: {
enabled?: boolean;
accounts?: Record<string, { botToken?: string }>;
};
};
};
expect(next.channels?.telegram?.enabled).toBe(true);
expect(next.channels?.telegram?.accounts?.default).toEqual({});
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
});
it("adds a default slack account with tokens", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await channelsAddCommand(

View File

@ -1,6 +1,7 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js";
import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js";
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
@ -283,6 +284,13 @@ export async function channelsAddCommand(
? resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim()
: "";
if (accountId !== DEFAULT_ACCOUNT_ID) {
nextConfig = moveSingleAccountChannelSectionToDefaultAccount({
cfg: nextConfig,
channelKey: channel,
});
}
nextConfig = applyChannelAccountConfig({
cfg: nextConfig,
channel,

View File

@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
const { noteSpy } = vi.hoisted(() => ({
noteSpy: vi.fn(),
}));
vi.mock("../terminal/note.js", () => ({
note: noteSpy,
}));
vi.mock("./doctor-legacy-config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./doctor-legacy-config.js")>();
return {
...actual,
normalizeLegacyConfigValues: (cfg: unknown) => ({
config: cfg,
changes: [],
}),
};
});
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
describe("doctor missing default account binding warning", () => {
it("emits a doctor warning when named accounts have no valid account-scoped bindings", async () => {
await withEnvAsync(
{
TELEGRAM_BOT_TOKEN: undefined,
TELEGRAM_BOT_TOKEN_FILE: undefined,
},
async () => {
await runDoctorConfigWithInput({
config: {
channels: {
telegram: {
accounts: {
alerts: {},
work: {},
},
},
},
bindings: [{ agentId: "ops", match: { channel: "telegram" } }],
},
run: loadAndMaybeMigrateDoctorConfig,
});
},
);
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("channels.telegram: accounts.default is missing"),
"Doctor warnings",
);
});
});

View File

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectMissingDefaultAccountBindingWarnings } from "./doctor-config-flow.js";
describe("collectMissingDefaultAccountBindingWarnings", () => {
it("warns when named accounts exist without default and no valid binding exists", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: {
alerts: { botToken: "a" },
work: { botToken: "w" },
},
},
},
bindings: [{ agentId: "ops", match: { channel: "telegram" } }],
};
const warnings = collectMissingDefaultAccountBindingWarnings(cfg);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("channels.telegram");
expect(warnings[0]).toContain("alerts, work");
});
it("does not warn when an explicit account binding exists", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: {
alerts: { botToken: "a" },
},
},
},
bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "alerts" } }],
};
expect(collectMissingDefaultAccountBindingWarnings(cfg)).toEqual([]);
});
it("warns when bindings cover only a subset of configured accounts", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: {
alerts: { botToken: "a" },
work: { botToken: "w" },
},
},
},
bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "alerts" } }],
};
const warnings = collectMissingDefaultAccountBindingWarnings(cfg);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("subset");
expect(warnings[0]).toContain("Uncovered accounts: work");
});
it("does not warn when wildcard account binding exists", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: {
alerts: { botToken: "a" },
},
},
},
bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "*" } }],
};
expect(collectMissingDefaultAccountBindingWarnings(cfg)).toEqual([]);
});
it("does not warn when default account is present", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: {
default: { botToken: "d" },
alerts: { botToken: "a" },
},
},
},
bindings: [{ agentId: "ops", match: { channel: "telegram" } }],
};
expect(collectMissingDefaultAccountBindingWarnings(cfg)).toEqual([]);
});
});

View File

@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ZodIssue } from "zod";
import { normalizeChatChannelId } from "../channels/registry.js";
import {
isNumericTelegramUserId,
normalizeTelegramAllowFromEntry,
@ -27,6 +28,7 @@ import {
isTrustedSafeBinPath,
normalizeTrustedSafeBinDirs,
} from "../infra/exec-safe-bin-trust.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import {
isDiscordMutableAllowEntry,
isGoogleChatMutableAllowEntry,
@ -207,6 +209,103 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function normalizeBindingChannelKey(raw?: string | null): string {
const normalized = normalizeChatChannelId(raw);
if (normalized) {
return normalized;
}
return (raw ?? "").trim().toLowerCase();
}
export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] {
const channels = asObjectRecord(cfg.channels);
if (!channels) {
return [];
}
const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
const warnings: string[] = [];
for (const [channelKey, rawChannel] of Object.entries(channels)) {
const channel = asObjectRecord(rawChannel);
if (!channel) {
continue;
}
const accounts = asObjectRecord(channel.accounts);
if (!accounts) {
continue;
}
const normalizedAccountIds = Array.from(
new Set(
Object.keys(accounts)
.map((accountId) => normalizeAccountId(accountId))
.filter(Boolean),
),
);
if (normalizedAccountIds.length === 0 || normalizedAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
continue;
}
const accountIdSet = new Set(normalizedAccountIds);
const channelPattern = normalizeBindingChannelKey(channelKey);
let hasWildcardBinding = false;
const coveredAccountIds = new Set<string>();
for (const binding of bindings) {
const bindingRecord = asObjectRecord(binding);
if (!bindingRecord) {
continue;
}
const match = asObjectRecord(bindingRecord.match);
if (!match) {
continue;
}
const matchChannel =
typeof match.channel === "string" ? normalizeBindingChannelKey(match.channel) : "";
if (!matchChannel || matchChannel !== channelPattern) {
continue;
}
const rawAccountId = typeof match.accountId === "string" ? match.accountId.trim() : "";
if (!rawAccountId) {
continue;
}
if (rawAccountId === "*") {
hasWildcardBinding = true;
continue;
}
const normalizedBindingAccountId = normalizeAccountId(rawAccountId);
if (accountIdSet.has(normalizedBindingAccountId)) {
coveredAccountIds.add(normalizedBindingAccountId);
}
}
if (hasWildcardBinding) {
continue;
}
const uncoveredAccountIds = normalizedAccountIds.filter(
(accountId) => !coveredAccountIds.has(accountId),
);
if (uncoveredAccountIds.length === 0) {
continue;
}
if (coveredAccountIds.size > 0) {
warnings.push(
`- channels.${channelKey}: accounts.default is missing and account bindings only cover a subset of configured accounts. Uncovered accounts: ${uncoveredAccountIds.join(", ")}. Add bindings[].match.accountId for uncovered accounts (or "*"), or add channels.${channelKey}.accounts.default.`,
);
continue;
}
warnings.push(
`- channels.${channelKey}: accounts.default is missing and no valid account-scoped binding exists for configured accounts (${normalizedAccountIds.join(", ")}). Channel-only bindings (no accountId) match only default. Add bindings[].match.accountId for one of these accounts (or "*"), or add channels.${channelKey}.accounts.default.`,
);
}
return warnings;
}
function collectTelegramAccountScopes(
cfg: OpenClawConfig,
): Array<{ prefix: string; account: Record<string, unknown> }> {
@ -1421,6 +1520,12 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}
}
const missingDefaultAccountBindingWarnings =
collectMissingDefaultAccountBindingWarnings(candidate);
if (missingDefaultAccountBindingWarnings.length > 0) {
note(missingDefaultAccountBindingWarnings.join("\n"), "Doctor warnings");
}
if (shouldRepair) {
const repair = await maybeRepairTelegramAllowFromUsernames(candidate);
if (repair.changes.length > 0) {

View File

@ -164,10 +164,12 @@ describe("normalizeLegacyConfigValues", () => {
expect(res.config.channels?.discord?.streamMode).toBeUndefined();
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("off");
expect(res.config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined();
expect(res.changes).toEqual([
expect(res.changes).toContain(
"Normalized channels.discord.streaming boolean → enum (partial).",
);
expect(res.changes).toContain(
"Normalized channels.discord.accounts.work.streaming boolean → enum (off).",
]);
);
});
it("migrates Discord legacy streamMode into streaming enum", () => {
@ -223,6 +225,44 @@ describe("normalizeLegacyConfigValues", () => {
]);
});
it("moves missing default account from single-account top-level config when named accounts already exist", () => {
const res = normalizeLegacyConfigValues({
channels: {
telegram: {
enabled: true,
botToken: "legacy-token",
dmPolicy: "allowlist",
allowFrom: ["123"],
groupPolicy: "allowlist",
streaming: "partial",
accounts: {
alerts: {
enabled: true,
botToken: "alerts-token",
},
},
},
},
});
expect(res.config.channels?.telegram?.accounts?.default).toEqual({
botToken: "legacy-token",
dmPolicy: "allowlist",
allowFrom: ["123"],
groupPolicy: "allowlist",
streaming: "partial",
});
expect(res.config.channels?.telegram?.botToken).toBeUndefined();
expect(res.config.channels?.telegram?.dmPolicy).toBeUndefined();
expect(res.config.channels?.telegram?.allowFrom).toBeUndefined();
expect(res.config.channels?.telegram?.groupPolicy).toBeUndefined();
expect(res.config.channels?.telegram?.streaming).toBeUndefined();
expect(res.config.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
expect(res.changes).toContain(
"Moved channels.telegram single-account top-level values into channels.telegram.accounts.default.",
);
});
it("migrates browser ssrfPolicy allowPrivateNetwork to dangerouslyAllowPrivateNetwork", () => {
const res = normalizeLegacyConfigValues({
browser: {

View File

@ -1,3 +1,4 @@
import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveDiscordPreviewStreamMode,
@ -5,6 +6,7 @@ import {
resolveSlackStreamingMode,
resolveTelegramPreviewStreamMode,
} from "../config/discord-preview-streaming.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
config: OpenClawConfig;
@ -289,9 +291,80 @@ export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
}
};
const seedMissingDefaultAccountsFromSingleAccountBase = () => {
const channels = next.channels as Record<string, unknown> | undefined;
if (!channels) {
return;
}
let channelsChanged = false;
const nextChannels = { ...channels };
for (const [channelId, rawChannel] of Object.entries(channels)) {
if (!isRecord(rawChannel)) {
continue;
}
const rawAccounts = rawChannel.accounts;
if (!isRecord(rawAccounts)) {
continue;
}
const accountKeys = Object.keys(rawAccounts);
if (accountKeys.length === 0) {
continue;
}
const hasDefault = accountKeys.some((key) => key.trim().toLowerCase() === DEFAULT_ACCOUNT_ID);
if (hasDefault) {
continue;
}
const keysToMove = Object.entries(rawChannel)
.filter(
([key, value]) =>
key !== "accounts" &&
key !== "enabled" &&
value !== undefined &&
shouldMoveSingleAccountChannelKey({ channelKey: channelId, key }),
)
.map(([key]) => key);
if (keysToMove.length === 0) {
continue;
}
const defaultAccount: Record<string, unknown> = {};
for (const key of keysToMove) {
const value = rawChannel[key];
defaultAccount[key] = value && typeof value === "object" ? structuredClone(value) : value;
}
const nextChannel: Record<string, unknown> = {
...rawChannel,
};
for (const key of keysToMove) {
delete nextChannel[key];
}
nextChannel.accounts = {
...rawAccounts,
[DEFAULT_ACCOUNT_ID]: defaultAccount,
};
nextChannels[channelId] = nextChannel;
channelsChanged = true;
changes.push(
`Moved channels.${channelId} single-account top-level values into channels.${channelId}.accounts.default.`,
);
}
if (!channelsChanged) {
return;
}
next = {
...next,
channels: nextChannels as OpenClawConfig["channels"],
};
};
normalizeProvider("telegram");
normalizeProvider("slack");
normalizeProvider("discord");
seedMissingDefaultAccountsFromSingleAccountBase();
const normalizeBrowserSsrFPolicyAlias = () => {
const rawBrowser = next.browser;