fix(channels): keep status accessors config-only

This commit is contained in:
Peter Steinberger
2026-04-30 05:08:21 +01:00
parent 2a6809467a
commit d7396d4ffa
13 changed files with 209 additions and 43 deletions

View File

@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Channels/status: keep Telegram, Slack, and Google Chat read-only allowlist/default-target accessors on config-only paths, so status and channel summaries do not resolve SecretRef-backed runtime credentials. Thanks @eusine.
- Channels/Discord: keep read-only allowlist/default-target accessors from resolving SecretRef-backed bot tokens, so status and channel summaries no longer fail when tokens are only available in gateway runtime. (#74737) Thanks @eusine.
- Gateway/sessions: align session abort wait semantics across `chat`, `agent`, and `sessions` server methods so abort RPCs return after the targeted sessions actually halt instead of resolving early while runs are still draining. (#74751) Thanks @BunsDev.
- Agents/output: drop copied inbound metadata-only assistant replay turns before provider replay instead of synthesizing a placeholder, so Telegram and other channels cannot receive `[assistant copied inbound metadata omitted]` as model output. Fixes #74745. Thanks @adamwdear and @Marvae.

View File

@@ -24,6 +24,10 @@ export type ResolvedGoogleChatAccount = {
credentialsFile?: string;
};
export type GoogleChatConfigAccessorAccount = {
config: GoogleChatAccountConfig;
};
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const JsonRecordSchema = z.record(z.string(), z.unknown());
@@ -34,7 +38,7 @@ const {
} = createAccountListHelpers("googlechat");
export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId };
function mergeGoogleChatAccountConfig(
export function mergeGoogleChatAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): GoogleChatAccountConfig {
@@ -62,6 +66,16 @@ function mergeGoogleChatAccountConfig(
return { ...defaultAccountShared, ...base } as GoogleChatAccountConfig;
}
export function resolveGoogleChatConfigAccessorAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): GoogleChatConfigAccessorAccount {
const accountId = normalizeAccountId(
params.accountId ?? params.cfg.channels?.googlechat?.defaultAccount,
);
return { config: mergeGoogleChatAccountConfig(params.cfg, accountId) };
}
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
if (isSecretRef(value)) {
return null;

View File

@@ -0,0 +1,39 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest";
import { googlechatPlugin } from "./channel.js";
describe("googlechatPlugin config adapter", () => {
it("keeps read-only accessors from resolving service account SecretRefs", () => {
const cfg = {
secrets: {
providers: {
google_chat_service_account: {
source: "file",
path: "/tmp/openclaw-missing-google-chat-service-account",
mode: "singleValue",
},
},
},
channels: {
googlechat: {
serviceAccount: {
source: "file",
provider: "google_chat_service_account",
id: "value",
},
dm: {
allowFrom: ["users/123"],
},
defaultTo: "spaces/AAA",
},
},
} as OpenClawConfig;
expect(googlechatPlugin.config.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([
"users/123",
]);
expect(googlechatPlugin.config.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
"spaces/AAA",
);
});
});

View File

@@ -15,7 +15,9 @@ export {
type OpenClawConfig,
} from "../runtime-api.js";
export {
type GoogleChatConfigAccessorAccount,
listGoogleChatAccountIds,
resolveGoogleChatConfigAccessorAccount,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
type ResolvedGoogleChatAccount,

View File

@@ -7,7 +7,9 @@ import {
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
type GoogleChatConfigAccessorAccount,
listGoogleChatAccountIds,
resolveGoogleChatConfigAccessorAccount,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
type ResolvedGoogleChatAccount,
@@ -24,10 +26,14 @@ const formatGoogleChatAllowFromEntry = (entry: string) =>
.replace(/^users\//i, ""),
);
const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleChatAccount>({
const googleChatConfigAdapter = createScopedChannelConfigAdapter<
ResolvedGoogleChatAccount,
GoogleChatConfigAccessorAccount
>({
sectionKey: "googlechat",
listAccountIds: listGoogleChatAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
resolveAccessorAccount: resolveGoogleChatConfigAccessorAccount,
defaultAccountId: resolveDefaultGoogleChatAccountId,
clearBaseFields: [
"serviceAccount",

View File

@@ -30,6 +30,8 @@ import {
isGoogleChatUserTarget,
listGoogleChatAccountIds,
normalizeGoogleChatTarget,
type GoogleChatConfigAccessorAccount,
resolveGoogleChatConfigAccessorAccount,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
type ChannelMessageActionAdapter,
@@ -65,10 +67,14 @@ const meta = {
markdownCapable: true,
};
const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleChatAccount>({
const googleChatConfigAdapter = createScopedChannelConfigAdapter<
ResolvedGoogleChatAccount,
GoogleChatConfigAccessorAccount
>({
sectionKey: "googlechat",
listAccountIds: listGoogleChatAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
resolveAccessorAccount: resolveGoogleChatConfigAccessorAccount,
defaultAccountId: resolveDefaultGoogleChatAccountId,
clearBaseFields: [
"serviceAccount",
@@ -80,13 +86,13 @@ const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleC
"botUser",
"name",
],
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: formatAllowFromEntry,
}),
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
resolveDefaultTo: (account) => account.config.defaultTo,
});
const googlechatActions: ChannelMessageActionAdapter = {

View File

@@ -35,6 +35,11 @@ export type ResolvedSlackAccount = {
config: SlackAccountConfig;
} & SlackAccountSurfaceFields;
export type SlackConfigAccessorAccount = {
allowFrom: string[] | undefined;
defaultTo: string | undefined;
};
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("slack");
export const listSlackAccountIds = listAccountIds;
export const resolveDefaultSlackAccountId = resolveDefaultAccountId;
@@ -73,6 +78,20 @@ export function resolveSlackAccountAllowFrom(params: {
return allowFrom ? mapAllowFromEntries(allowFrom) : undefined;
}
export function resolveSlackConfigAccessorAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): SlackConfigAccessorAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
);
const config = mergeSlackAccountConfig(params.cfg, accountId);
return {
allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId }),
defaultTo: config.defaultTo,
};
}
export function resolveSlackAccountDmPolicy(params: {
cfg: OpenClawConfig;
accountId?: string | null;

View File

@@ -6,9 +6,10 @@ import {
import { type ResolvedSlackAccount } from "./accounts.js";
import {
listSlackAccountIds,
resolveSlackConfigAccessorAccount,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackAccountAllowFrom,
type SlackConfigAccessorAccount,
} from "./accounts.js";
import { type ChannelPlugin } from "./channel-api.js";
import { SlackChannelConfigSchema } from "./config-schema.js";
@@ -23,25 +24,14 @@ const slackSetupWizard = createSlackSetupWizardProxy(async () => ({
slackSetupWizard: (await import("./setup-surface.js")).slackSetupWizard,
}));
type SlackSetupConfigAccessorAccount = {
allowFrom: string[] | undefined;
defaultTo: string | undefined;
};
const slackSetupConfigAdapter = createScopedChannelConfigAdapter<
ResolvedSlackAccount,
SlackSetupConfigAccessorAccount
SlackConfigAccessorAccount
>({
sectionKey: SLACK_CHANNEL,
listAccountIds: listSlackAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),
resolveAccessorAccount: (params) => {
const account = resolveSlackAccount(params);
return {
allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId: account.accountId }),
defaultTo: account.config.defaultTo,
};
},
resolveAccessorAccount: resolveSlackConfigAccessorAccount,
defaultAccountId: resolveDefaultSlackAccountId,
clearBaseFields: ["botToken", "appToken", "name"],
resolveAllowFrom: (account) => account.allowFrom,

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest";
import { createSlackPluginBase, setSlackChannelAllowlist } from "./shared.js";
import { createSlackPluginBase, setSlackChannelAllowlist, slackConfigAdapter } from "./shared.js";
describe("createSlackPluginBase", () => {
it("owns Slack native command name overrides", () => {
@@ -56,3 +57,35 @@ describe("setSlackChannelAllowlist", () => {
});
});
});
describe("slackConfigAdapter", () => {
it("keeps read-only accessors from resolving token SecretRefs", () => {
const cfg = {
secrets: {
providers: {
slack_bot: {
source: "file",
path: "/tmp/openclaw-missing-slack-bot-token",
mode: "singleValue",
},
slack_app: {
source: "file",
path: "/tmp/openclaw-missing-slack-app-token",
mode: "singleValue",
},
},
},
channels: {
slack: {
botToken: { source: "file", provider: "slack_bot", id: "value" },
appToken: { source: "file", provider: "slack_app", id: "value" },
allowFrom: ["U123"],
defaultTo: "C123",
},
},
} as unknown as OpenClawConfig;
expect(slackConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["U123"]);
expect(slackConfigAdapter.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe("C123");
});
});

View File

@@ -7,16 +7,16 @@ import {
import { inspectSlackAccount } from "./account-inspect.js";
import {
listSlackAccountIds,
resolveSlackConfigAccessorAccount,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackAccountAllowFrom,
type SlackConfigAccessorAccount,
type ResolvedSlackAccount,
} from "./accounts.js";
import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js";
import { SlackChannelConfigSchema } from "./config-schema.js";
import { slackDoctor } from "./doctor.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { slackSecurityAdapter } from "./security.js";
import { SLACK_CHANNEL } from "./setup-shared.js";
@@ -40,22 +40,6 @@ export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): b
return Boolean(account.appToken?.trim());
}
type SlackConfigAccessorAccount = {
allowFrom: string[] | undefined;
defaultTo: string | undefined;
};
function resolveSlackConfigAccessorAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): SlackConfigAccessorAccount {
const account = resolveSlackAccount(params);
return {
allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId: account.accountId }),
defaultTo: account.config.defaultTo,
};
}
export const slackConfigAdapter = createScopedChannelConfigAdapter<
ResolvedSlackAccount,
SlackConfigAccessorAccount

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest";
import type { ResolvedTelegramAccount } from "./accounts.js";
import { createTelegramPluginBase } from "./shared.js";
import { createTelegramPluginBase, telegramConfigAdapter } from "./shared.js";
const telegramPluginBase = createTelegramPluginBase({
setupWizard: {} as never,
@@ -166,4 +166,32 @@ describe("createTelegramPluginBase config duplicate token guard", () => {
expect(await telegramPluginBase.config.isConfigured!(account, cfg)).toBe(false);
expect(telegramPluginBase.config.unconfiguredReason?.(account, cfg)).toContain("unavailable");
});
it("keeps read-only accessors from resolving bot token SecretRefs", () => {
const cfg = {
secrets: {
providers: {
telegram_token: {
source: "file",
path: "/tmp/openclaw-missing-telegram-token",
mode: "singleValue",
},
},
},
channels: {
telegram: {
botToken: { source: "file", provider: "telegram_token", id: "value" },
allowFrom: ["1128540374256849009"],
defaultTo: "1498959610751750304",
},
},
} as unknown as OpenClawConfig;
expect(telegramConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([
"1128540374256849009",
]);
expect(telegramConfigAdapter.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
"1498959610751750304",
);
});
});

View File

@@ -7,11 +7,12 @@ import {
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createChannelPluginBase, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-types";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { inspectTelegramAccount } from "./account-inspect.js";
import {
listTelegramAccountIds,
mergeTelegramAccountConfig,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
type ResolvedTelegramAccount,
@@ -32,6 +33,10 @@ import { namedAccountPromotionKeys, singleAccountKeysToMove } from "./setup-cont
export const TELEGRAM_CHANNEL = "telegram" as const;
type TelegramConfigAccessorAccount = {
config: TelegramAccountConfig;
};
export function findTelegramTokenOwnerAccountId(params: {
cfg: OpenClawConfig;
accountId: string;
@@ -99,17 +104,31 @@ function isBlockedByMultiBotGuard(cfg: OpenClawConfig, accountId: string): boole
return !resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
}
export const telegramConfigAdapter = createScopedChannelConfigAdapter<ResolvedTelegramAccount>({
function resolveTelegramConfigAccessorAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): TelegramConfigAccessorAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultTelegramAccountId(params.cfg),
);
return { config: mergeTelegramAccountConfig(params.cfg, accountId) };
}
export const telegramConfigAdapter = createScopedChannelConfigAdapter<
ResolvedTelegramAccount,
TelegramConfigAccessorAccount
>({
sectionKey: TELEGRAM_CHANNEL,
listAccountIds: listTelegramAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveTelegramAccount),
resolveAccessorAccount: resolveTelegramConfigAccessorAccount,
inspectAccount: adaptScopedAccountAccessor(inspectTelegramAccount),
defaultAccountId: resolveDefaultTelegramAccountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }),
resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo,
resolveDefaultTo: (account) => account.config.defaultTo,
});
export function createTelegramPluginBase(params: {

View File

@@ -413,6 +413,31 @@ describe("createScopedChannelConfigAdapter", () => {
});
expectAdapterAllowFromAndDefaultTo(adapter);
});
it("keeps read-only accessors on the accessor resolver", () => {
const adapter = createScopedChannelConfigAdapter<
{ accountId: string; token: string },
{ allowFrom: string[]; defaultTo: string }
>({
sectionKey: "demo",
listAccountIds: () => ["default"],
resolveAccount: () => {
throw new Error("runtime account resolver should not run for read-only accessors");
},
resolveAccessorAccount: ({ accountId }) => ({
allowFrom: [accountId ?? "default"],
defaultTo: " room:123 ",
}),
defaultAccountId: resolveDefaultAccountId,
clearBaseFields: ["token"],
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry)),
resolveDefaultTo: (account) => account.defaultTo,
});
expect(adapter.resolveAllowFrom?.({ cfg: {}, accountId: "default" })).toEqual(["default"]);
expect(adapter.resolveDefaultTo?.({ cfg: {}, accountId: "default" })).toBe("room:123");
});
});
describe("createScopedDmSecurityResolver", () => {