mirror of https://github.com/openclaw/openclaw.git
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:
parent
da6a96ed33
commit
dfa0b5b4fc
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue