mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 10:57:14 +00:00
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:
committed by
Peter Steinberger
parent
640df8608a
commit
8bc56095ed
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- 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.
|
- 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)).
|
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)
|
## Login / logout (interactive)
|
||||||
|
|
||||||
```bash
|
```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`).
|
- 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 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.
|
- `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 remove`: disable by default; pass `--delete` to remove config entries without prompts.
|
||||||
- `channels login`: interactive channel login (WhatsApp Web only).
|
- `channels login`: interactive channel login (WhatsApp Web only).
|
||||||
- `channels logout`: log out of a channel session (if supported).
|
- `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.
|
- Env tokens only apply to the **default** account.
|
||||||
- Base channel settings apply to all accounts unless overridden per account.
|
- Base channel settings apply to all accounts unless overridden per account.
|
||||||
- Use `bindings[].match.accountId` to route each account to a different agent.
|
- 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
|
### Group chat mention gating
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ Current migrations:
|
|||||||
- `routing.agentToAgent` → `tools.agentToAgent`
|
- `routing.agentToAgent` → `tools.agentToAgent`
|
||||||
- `routing.transcribeAudio` → `tools.media.audio.models`
|
- `routing.transcribeAudio` → `tools.media.audio.models`
|
||||||
- `bindings[].match.accountID` → `bindings[].match.accountId`
|
- `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`
|
- `identity` → `agents.list[].identity`
|
||||||
- `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)
|
- `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)
|
||||||
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
|
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
|
||||||
|
|||||||
@@ -554,6 +554,39 @@ describe("patchChannelConfigForAccount", () => {
|
|||||||
expect(next.channels?.slack?.accounts?.work?.appToken).toBe("new-app");
|
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", () => {
|
it("supports imessage/signal account-scoped channel patches", () => {
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboa
|
|||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
|
||||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||||
import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js";
|
import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js";
|
||||||
|
import { moveSingleAccountChannelSectionToDefaultAccount } from "../setup-helpers.js";
|
||||||
|
|
||||||
export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => {
|
export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => {
|
||||||
return await promptAccountIdSdk(params);
|
return await promptAccountIdSdk(params);
|
||||||
@@ -282,13 +283,21 @@ function patchConfigForScopedAccount(params: {
|
|||||||
ensureEnabled: boolean;
|
ensureEnabled: boolean;
|
||||||
}): OpenClawConfig {
|
}): OpenClawConfig {
|
||||||
const { cfg, channel, accountId, patch, ensureEnabled } = params;
|
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) {
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...seededCfg,
|
||||||
channels: {
|
channels: {
|
||||||
...cfg.channels,
|
...seededCfg.channels,
|
||||||
[channel]: {
|
[channel]: {
|
||||||
...channelConfig,
|
...channelConfig,
|
||||||
...(ensureEnabled ? { enabled: true } : {}),
|
...(ensureEnabled ? { enabled: true } : {}),
|
||||||
@@ -303,9 +312,9 @@ function patchConfigForScopedAccount(params: {
|
|||||||
const existingAccount = accounts[accountId] ?? {};
|
const existingAccount = accounts[accountId] ?? {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...seededCfg,
|
||||||
channels: {
|
channels: {
|
||||||
...cfg.channels,
|
...seededCfg.channels,
|
||||||
[channel]: {
|
[channel]: {
|
||||||
...channelConfig,
|
...channelConfig,
|
||||||
...(ensureEnabled ? { enabled: true } : {}),
|
...(ensureEnabled ? { enabled: true } : {}),
|
||||||
|
|||||||
@@ -119,3 +119,115 @@ export function migrateBaseNameToDefaultAccount(params: {
|
|||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
} 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");
|
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 () => {
|
it("adds a default slack account with tokens", async () => {
|
||||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||||
await channelsAddCommand(
|
await channelsAddCommand(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||||
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
|
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
|
||||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.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 type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js";
|
||||||
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
|
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.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()
|
? resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
if (accountId !== DEFAULT_ACCOUNT_ID) {
|
||||||
|
nextConfig = moveSingleAccountChannelSectionToDefaultAccount({
|
||||||
|
cfg: nextConfig,
|
||||||
|
channelKey: channel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
nextConfig = applyChannelAccountConfig({
|
nextConfig = applyChannelAccountConfig({
|
||||||
cfg: nextConfig,
|
cfg: nextConfig,
|
||||||
channel,
|
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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ZodIssue } from "zod";
|
import type { ZodIssue } from "zod";
|
||||||
|
import { normalizeChatChannelId } from "../channels/registry.js";
|
||||||
import {
|
import {
|
||||||
isNumericTelegramUserId,
|
isNumericTelegramUserId,
|
||||||
normalizeTelegramAllowFromEntry,
|
normalizeTelegramAllowFromEntry,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
isTrustedSafeBinPath,
|
isTrustedSafeBinPath,
|
||||||
normalizeTrustedSafeBinDirs,
|
normalizeTrustedSafeBinDirs,
|
||||||
} from "../infra/exec-safe-bin-trust.js";
|
} from "../infra/exec-safe-bin-trust.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
isDiscordMutableAllowEntry,
|
isDiscordMutableAllowEntry,
|
||||||
isGoogleChatMutableAllowEntry,
|
isGoogleChatMutableAllowEntry,
|
||||||
@@ -207,6 +209,103 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value as Record<string, unknown>;
|
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(
|
function collectTelegramAccountScopes(
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
): Array<{ prefix: string; account: Record<string, unknown> }> {
|
): 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) {
|
if (shouldRepair) {
|
||||||
const repair = await maybeRepairTelegramAllowFromUsernames(candidate);
|
const repair = await maybeRepairTelegramAllowFromUsernames(candidate);
|
||||||
if (repair.changes.length > 0) {
|
if (repair.changes.length > 0) {
|
||||||
|
|||||||
@@ -164,10 +164,12 @@ describe("normalizeLegacyConfigValues", () => {
|
|||||||
expect(res.config.channels?.discord?.streamMode).toBeUndefined();
|
expect(res.config.channels?.discord?.streamMode).toBeUndefined();
|
||||||
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("off");
|
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("off");
|
||||||
expect(res.config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined();
|
expect(res.config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined();
|
||||||
expect(res.changes).toEqual([
|
expect(res.changes).toContain(
|
||||||
"Normalized channels.discord.streaming boolean → enum (partial).",
|
"Normalized channels.discord.streaming boolean → enum (partial).",
|
||||||
|
);
|
||||||
|
expect(res.changes).toContain(
|
||||||
"Normalized channels.discord.accounts.work.streaming boolean → enum (off).",
|
"Normalized channels.discord.accounts.work.streaming boolean → enum (off).",
|
||||||
]);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migrates Discord legacy streamMode into streaming enum", () => {
|
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", () => {
|
it("migrates browser ssrfPolicy allowPrivateNetwork to dangerouslyAllowPrivateNetwork", () => {
|
||||||
const res = normalizeLegacyConfigValues({
|
const res = normalizeLegacyConfigValues({
|
||||||
browser: {
|
browser: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveDiscordPreviewStreamMode,
|
resolveDiscordPreviewStreamMode,
|
||||||
@@ -5,6 +6,7 @@ import {
|
|||||||
resolveSlackStreamingMode,
|
resolveSlackStreamingMode,
|
||||||
resolveTelegramPreviewStreamMode,
|
resolveTelegramPreviewStreamMode,
|
||||||
} from "../config/discord-preview-streaming.js";
|
} from "../config/discord-preview-streaming.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||||
|
|
||||||
export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
|
export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
|
||||||
config: 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("telegram");
|
||||||
normalizeProvider("slack");
|
normalizeProvider("slack");
|
||||||
normalizeProvider("discord");
|
normalizeProvider("discord");
|
||||||
|
seedMissingDefaultAccountsFromSingleAccountBase();
|
||||||
|
|
||||||
const normalizeBrowserSsrFPolicyAlias = () => {
|
const normalizeBrowserSsrFPolicyAlias = () => {
|
||||||
const rawBrowser = next.browser;
|
const rawBrowser = next.browser;
|
||||||
|
|||||||
Reference in New Issue
Block a user