fix(channels): honor trusted plugin activation contracts (#114492)

* fix(channels): validate activation through trusted channel owners

* refactor(channels): simplify credential contract detection

* test(channels): require complete Slack activation credentials

* test(channels): type trusted installed Slack owner fixture

* test(channels): preserve literal types in owner state fixtures
This commit is contained in:
Peter Steinberger
2026-08-25 22:55:17 -07:00
committed by GitHub
parent 51bb3edef9
commit 9b77c06bbd
22 changed files with 592 additions and 86 deletions
+1
View File
@@ -226,6 +226,7 @@ const bundledPluginEntries = [
"*-api.ts!",
"cli-metadata.ts!",
"channel-entry.ts!",
"configured-state.ts!",
// Manifest and SDK loaders resolve these public artifacts by basename.
"auth-presence.ts!",
"thread-bindings-runtime.ts!",
+37
View File
@@ -0,0 +1,37 @@
import {
DEFAULT_ACCOUNT_ID,
hasConfiguredAccountValue,
mergeAccountConfig,
} from "openclaw/plugin-sdk/account-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { FeishuConfig } from "./src/types.js";
/** Feishu owns configured account credentials; ambient variables alone are not an account. */
export function hasConfiguredFeishuChannelState(params: { cfg: OpenClawConfig }): boolean {
// SAFETY: Feishu's registered channel schema owns the shape of its config entry.
const channel = params.cfg.channels?.feishu as FeishuConfig | undefined;
if (!channel || channel.enabled === false) {
return false;
}
const defaultAccount = channel.accounts?.[DEFAULT_ACCOUNT_ID];
if (defaultAccount?.enabled !== false) {
const account = defaultAccount
? mergeAccountConfig({
channelConfig: channel,
accountConfig: defaultAccount,
omitKeys: ["defaultAccount"],
})
: channel;
if (hasConfiguredAccountValue(account.appId) && hasConfiguredAccountValue(account.appSecret)) {
return true;
}
}
return Object.entries(channel.accounts ?? {}).some(([accountId, account]) => {
if (accountId === DEFAULT_ACCOUNT_ID || !account || account.enabled === false) {
return false;
}
const appId = Object.hasOwn(account, "appId") ? account.appId : channel.appId;
const appSecret = Object.hasOwn(account, "appSecret") ? account.appSecret : channel.appSecret;
return hasConfiguredAccountValue(appId) && hasConfiguredAccountValue(appSecret);
});
}
+3 -1
View File
@@ -42,7 +42,9 @@
"FEISHU_VERIFICATION_TOKEN",
"FEISHU_ENCRYPT_KEY"
]
}
},
"specifier": "./configured-state",
"exportName": "hasConfiguredFeishuChannelState"
},
"label": "Feishu",
"selectionLabel": "Feishu/Lark (飞书)",
+1 -1
View File
@@ -33,7 +33,7 @@
"id": "line",
"configuredState": {
"env": {
"anyOf": [
"allOf": [
"LINE_CHANNEL_ACCESS_TOKEN",
"LINE_CHANNEL_SECRET"
]
+38
View File
@@ -0,0 +1,38 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./src/secret-input.js";
/** Mirror Teams auth-mode requirements without loading the Azure SDK or full channel. */
export function hasConfiguredMSTeamsChannelState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const config = params.cfg.channels?.msteams;
if (config?.enabled === false) {
return false;
}
const env = params.env ?? process.env;
const appId = normalizeSecretInputString(
config && Object.hasOwn(config, "appId") ? config.appId : env.MSTEAMS_APP_ID,
);
const tenantId = normalizeSecretInputString(
config && Object.hasOwn(config, "tenantId") ? config.tenantId : env.MSTEAMS_TENANT_ID,
);
if (!appId || !tenantId) {
return false;
}
const authType = config?.authType ?? env.MSTEAMS_AUTH_TYPE ?? "secret";
if (authType === "federated") {
const certificatePath = normalizeSecretInputString(
config && Object.hasOwn(config, "certificatePath")
? config.certificatePath
: env.MSTEAMS_CERTIFICATE_PATH,
);
return Boolean(
certificatePath ||
(config?.useManagedIdentity ?? env.MSTEAMS_USE_MANAGED_IDENTITY === "true"),
);
}
return config && Object.hasOwn(config, "appPassword")
? hasConfiguredSecretInput(config.appPassword)
: Boolean(normalizeSecretInputString(env.MSTEAMS_APP_PASSWORD));
}
+4 -6
View File
@@ -38,12 +38,10 @@
"id": "msteams",
"configuredState": {
"env": {
"anyOf": [
"MSTEAMS_APP_ID",
"MSTEAMS_APP_PASSWORD",
"MSTEAMS_TENANT_ID"
]
}
"anyOf": ["MSTEAMS_APP_ID", "MSTEAMS_APP_PASSWORD", "MSTEAMS_TENANT_ID"]
},
"specifier": "./configured-state",
"exportName": "hasConfiguredMSTeamsChannelState"
},
"label": "Microsoft Teams",
"selectionLabel": "Microsoft Teams (Teams SDK)",
@@ -0,0 +1,59 @@
import {
DEFAULT_ACCOUNT_ID,
hasConfiguredAccountValue,
mergeAccountConfig,
} from "openclaw/plugin-sdk/account-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { CoreConfig } from "./src/types.js";
type NextcloudAccount = NonNullable<NonNullable<CoreConfig["channels"]>["nextcloud-talk"]>;
function hasConfiguredNextcloudAccount(
account: NextcloudAccount | undefined,
env: NodeJS.ProcessEnv,
) {
return Boolean(
account?.baseUrl?.trim() &&
(hasConfiguredAccountValue(account.botSecret) ||
hasConfiguredAccountValue(account.botSecretFile) ||
hasConfiguredAccountValue(env.NEXTCLOUD_TALK_BOT_SECRET)),
);
}
/** Require a Nextcloud server plus its account-owned bot credential. */
export function hasConfiguredNextcloudTalkChannelState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
// SAFETY: Nextcloud Talk's registered channel schema owns its account-config shape.
const channel = params.cfg.channels?.["nextcloud-talk"] as NextcloudAccount | undefined;
if (channel?.enabled === false) {
return false;
}
const defaultAccount = channel?.accounts?.[DEFAULT_ACCOUNT_ID];
if (defaultAccount?.enabled !== false) {
const account = defaultAccount
? mergeAccountConfig({
channelConfig: channel,
accountConfig: defaultAccount,
omitKeys: ["defaultAccount"],
})
: channel;
if (hasConfiguredNextcloudAccount(account, params.env ?? process.env)) {
return true;
}
}
return Object.entries(channel?.accounts ?? {}).some(
([accountId, account]) =>
accountId !== DEFAULT_ACCOUNT_ID &&
account.enabled !== false &&
hasConfiguredNextcloudAccount(
mergeAccountConfig({
channelConfig: channel,
accountConfig: account,
omitKeys: ["defaultAccount"],
}),
{},
),
);
}
+4 -5
View File
@@ -31,11 +31,10 @@
"id": "nextcloud-talk",
"configuredState": {
"env": {
"anyOf": [
"NEXTCLOUD_TALK_BOT_SECRET",
"NEXTCLOUD_TALK_API_PASSWORD"
]
}
"anyOf": ["NEXTCLOUD_TALK_BOT_SECRET", "NEXTCLOUD_TALK_API_PASSWORD"]
},
"specifier": "./configured-state",
"exportName": "hasConfiguredNextcloudTalkChannelState"
},
"label": "Nextcloud Talk",
"selectionLabel": "Nextcloud Talk (self-hosted)",
+60
View File
@@ -0,0 +1,60 @@
import {
DEFAULT_ACCOUNT_ID,
hasConfiguredAccountValue,
mergeAccountConfig,
} from "openclaw/plugin-sdk/account-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { hasSlackAccountCredentials } from "./src/account-configured.js";
type SlackAccount = NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>;
function hasConfiguredSlackAccount(account: SlackAccount | undefined, env: NodeJS.ProcessEnv) {
const userIdentity = account?.postAs === "user";
return hasSlackAccountCredentials({
config: account ?? {},
identityTokenConfigured:
hasConfiguredAccountValue(userIdentity ? account?.userToken : account?.botToken) ||
hasConfiguredAccountValue(userIdentity ? env.SLACK_USER_TOKEN : env.SLACK_BOT_TOKEN),
appTokenConfigured:
hasConfiguredAccountValue(account?.appToken) ||
hasConfiguredAccountValue(env.SLACK_APP_TOKEN),
});
}
/** Resolve Slack activation through its account owner's real transport credential contract. */
export function hasConfiguredSlackChannelState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const channel = params.cfg.channels?.slack;
if (channel?.enabled === false) {
return false;
}
const defaultAccount = channel?.accounts?.[DEFAULT_ACCOUNT_ID];
if (defaultAccount?.enabled !== false) {
const account = defaultAccount
? mergeAccountConfig({
channelConfig: channel,
accountConfig: defaultAccount,
nestedObjectKeys: ["relay"],
})
: channel;
if (hasConfiguredSlackAccount(account, params.env ?? process.env)) {
return true;
}
}
return Object.entries(channel?.accounts ?? {}).some(([accountId, account]) => {
if (accountId === DEFAULT_ACCOUNT_ID || account.enabled === false) {
return false;
}
// Ambient credentials belong only to the default account, never a named tenant.
return hasConfiguredSlackAccount(
mergeAccountConfig({
channelConfig: channel,
accountConfig: account,
nestedObjectKeys: ["relay"],
}),
{},
);
});
}
+4 -6
View File
@@ -42,12 +42,10 @@
"id": "slack",
"configuredState": {
"env": {
"anyOf": [
"SLACK_BOT_TOKEN",
"SLACK_APP_TOKEN",
"SLACK_USER_TOKEN"
]
}
"anyOf": ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"]
},
"specifier": "./configured-state",
"exportName": "hasConfiguredSlackChannelState"
},
"approvalFlags": [
"native"
+44
View File
@@ -0,0 +1,44 @@
import { DEFAULT_ACCOUNT_ID, hasConfiguredAccountValue } from "openclaw/plugin-sdk/account-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { SmsChannelConfig } from "./src/types.js";
function hasConfiguredSmsAccount(account: SmsChannelConfig | undefined, env: NodeJS.ProcessEnv) {
const hasAccount = hasConfiguredAccountValue(account?.accountSid ?? env.TWILIO_ACCOUNT_SID);
const hasToken = hasConfiguredAccountValue(account?.authToken ?? env.TWILIO_AUTH_TOKEN);
const fromNumber = [env.TWILIO_PHONE_NUMBER, env.TWILIO_SMS_FROM].find((value) =>
hasConfiguredAccountValue(value),
);
const hasSender =
hasConfiguredAccountValue(account?.fromNumber ?? fromNumber) ||
hasConfiguredAccountValue(account?.messagingServiceSid ?? env.TWILIO_MESSAGING_SERVICE_SID);
return hasAccount && hasToken && hasSender;
}
/** Require a complete Twilio identity and sender, scoped to each enabled account. */
export function hasConfiguredSmsChannelState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
// SAFETY: The SMS plugin's registered schema owns the shape of its config entry.
const channel = params.cfg.channels?.sms as SmsChannelConfig | undefined;
if (channel?.enabled === false) {
return false;
}
const defaultAccount = channel?.accounts?.[DEFAULT_ACCOUNT_ID];
const { accounts: _accounts, defaultAccount: _defaultAccount, ...defaults } = channel ?? {};
if (
defaultAccount?.enabled !== false &&
hasConfiguredSmsAccount(
defaultAccount ? { ...defaults, ...defaultAccount } : channel,
params.env ?? process.env,
)
) {
return true;
}
return Object.entries(channel?.accounts ?? {}).some(
([accountId, account]) =>
accountId !== DEFAULT_ACCOUNT_ID &&
account.enabled !== false &&
hasConfiguredSmsAccount({ ...defaults, ...account }, {}),
);
}
+4 -5
View File
@@ -26,12 +26,11 @@
"TWILIO_AUTH_TOKEN",
"TWILIO_PHONE_NUMBER",
"TWILIO_SMS_FROM",
"TWILIO_MESSAGING_SERVICE_SID",
"SMS_PUBLIC_WEBHOOK_URL",
"SMS_WEBHOOK_PATH",
"SMS_ALLOWED_USERS"
"TWILIO_MESSAGING_SERVICE_SID"
]
}
},
"specifier": "./configured-state",
"exportName": "hasConfiguredSmsChannelState"
},
"label": "SMS",
"selectionLabel": "SMS (Twilio)",
+2 -6
View File
@@ -19,13 +19,9 @@
"id": "synology-chat",
"configuredState": {
"env": {
"anyOf": [
"allOf": [
"SYNOLOGY_CHAT_TOKEN",
"SYNOLOGY_CHAT_INCOMING_URL",
"SYNOLOGY_NAS_HOST",
"SYNOLOGY_ALLOWED_USER_IDS",
"SYNOLOGY_RATE_LIMIT",
"OPENCLAW_BOT_NAME"
"SYNOLOGY_CHAT_INCOMING_URL"
]
}
},
+1 -2
View File
@@ -32,8 +32,7 @@
"configuredState": {
"env": {
"anyOf": [
"ZALO_BOT_TOKEN",
"ZALO_WEBHOOK_SECRET"
"ZALO_BOT_TOKEN"
]
}
},
@@ -776,7 +776,9 @@
"FEISHU_VERIFICATION_TOKEN",
"FEISHU_ENCRYPT_KEY"
]
}
},
"specifier": "./configured-state",
"exportName": "hasConfiguredFeishuChannelState"
},
"label": "Feishu",
"selectionLabel": "Feishu/Lark (飞书)",
@@ -1119,7 +1121,7 @@
"id": "line",
"configuredState": {
"env": {
"anyOf": [
"allOf": [
"LINE_CHANNEL_ACCESS_TOKEN",
"LINE_CHANNEL_SECRET"
]
@@ -1436,7 +1438,9 @@
"MSTEAMS_APP_PASSWORD",
"MSTEAMS_TENANT_ID"
]
}
},
"specifier": "./configured-state",
"exportName": "hasConfiguredMSTeamsChannelState"
},
"label": "Microsoft Teams",
"selectionLabel": "Microsoft Teams (Teams SDK)",
@@ -1479,7 +1483,9 @@
"NEXTCLOUD_TALK_BOT_SECRET",
"NEXTCLOUD_TALK_API_PASSWORD"
]
}
},
"specifier": "./configured-state",
"exportName": "hasConfiguredNextcloudTalkChannelState"
},
"label": "Nextcloud Talk",
"selectionLabel": "Nextcloud Talk (self-hosted)",
@@ -2077,7 +2083,9 @@
"SLACK_APP_TOKEN",
"SLACK_USER_TOKEN"
]
}
},
"specifier": "./configured-state",
"exportName": "hasConfiguredSlackChannelState"
},
"approvalFlags": [
"native"
@@ -2200,12 +2208,11 @@
"TWILIO_AUTH_TOKEN",
"TWILIO_PHONE_NUMBER",
"TWILIO_SMS_FROM",
"TWILIO_MESSAGING_SERVICE_SID",
"SMS_PUBLIC_WEBHOOK_URL",
"SMS_WEBHOOK_PATH",
"SMS_ALLOWED_USERS"
"TWILIO_MESSAGING_SERVICE_SID"
]
}
},
"specifier": "./configured-state",
"exportName": "hasConfiguredSmsChannelState"
},
"label": "SMS",
"selectionLabel": "SMS (Twilio)",
@@ -2320,13 +2327,9 @@
"id": "synology-chat",
"configuredState": {
"env": {
"anyOf": [
"allOf": [
"SYNOLOGY_CHAT_TOKEN",
"SYNOLOGY_CHAT_INCOMING_URL",
"SYNOLOGY_NAS_HOST",
"SYNOLOGY_ALLOWED_USER_IDS",
"SYNOLOGY_RATE_LIMIT",
"OPENCLAW_BOT_NAME"
"SYNOLOGY_CHAT_INCOMING_URL"
]
}
},
@@ -2681,8 +2684,7 @@
"configuredState": {
"env": {
"anyOf": [
"ZALO_BOT_TOKEN",
"ZALO_WEBHOOK_SECRET"
"ZALO_BOT_TOKEN"
]
}
},
+107 -1
View File
@@ -1,6 +1,7 @@
// Configured state tests cover channel plugin configured-state detection and summaries.
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
hasBundledChannelConfiguredState,
listBundledChannelIdsWithConfiguredState,
@@ -46,7 +47,7 @@ describe("bundled channel configured-state metadata", () => {
hasBundledChannelConfiguredState({
channelId: "slack",
cfg: {},
env: { SLACK_BOT_TOKEN: "xoxb-test" },
env: { SLACK_BOT_TOKEN: "xoxb-test", SLACK_APP_TOKEN: "xapp-test" },
}),
).toBe(true);
expect(
@@ -65,6 +66,111 @@ describe("bundled channel configured-state metadata", () => {
).toBe(true);
});
it.each([
{ channelId: "slack", env: { SLACK_BOT_TOKEN: "xoxb-test" } },
{ channelId: "slack", env: { SLACK_APP_TOKEN: "xapp-test" } },
{ channelId: "msteams", env: { MSTEAMS_APP_ID: "app" } },
{ channelId: "msteams", env: { MSTEAMS_APP_ID: "app", MSTEAMS_TENANT_ID: "tenant" } },
{ channelId: "sms", env: { TWILIO_ACCOUNT_SID: "account" } },
{ channelId: "sms", env: { TWILIO_ACCOUNT_SID: "account", TWILIO_AUTH_TOKEN: "token" } },
{ channelId: "line", env: { LINE_CHANNEL_ACCESS_TOKEN: "token" } },
{ channelId: "synology-chat", env: { SYNOLOGY_CHAT_TOKEN: "token" } },
{ channelId: "feishu", env: { FEISHU_APP_ID: "app", FEISHU_APP_SECRET: "secret" } },
{ channelId: "nextcloud-talk", env: { NEXTCLOUD_TALK_BOT_SECRET: "secret" } },
{ channelId: "zalo", env: { ZALO_WEBHOOK_SECRET: "secret" } },
])("rejects incomplete $channelId environment credentials", ({ channelId, env }) => {
expect(hasBundledChannelConfiguredState({ channelId, cfg: {}, env })).toBe(false);
});
it.each([
{
channelId: "msteams",
env: {
MSTEAMS_APP_ID: "app",
MSTEAMS_APP_PASSWORD: "password",
MSTEAMS_TENANT_ID: "tenant",
},
},
{
channelId: "sms",
env: {
TWILIO_ACCOUNT_SID: "account",
TWILIO_AUTH_TOKEN: "token",
TWILIO_MESSAGING_SERVICE_SID: "service",
},
},
{
channelId: "line",
env: { LINE_CHANNEL_ACCESS_TOKEN: "token", LINE_CHANNEL_SECRET: "secret" },
},
{ channelId: "zalo", env: { ZALO_BOT_TOKEN: "token" } },
{
channelId: "synology-chat",
env: { SYNOLOGY_CHAT_TOKEN: "token", SYNOLOGY_CHAT_INCOMING_URL: "https://example.test" },
},
])("accepts complete $channelId environment credentials", ({ channelId, env }) => {
expect(hasBundledChannelConfiguredState({ channelId, cfg: {}, env })).toBe(true);
});
it("keeps explicit blank Teams credentials authoritative over ambient credentials", () => {
expect(
hasBundledChannelConfiguredState({
channelId: "msteams",
cfg: { channels: { msteams: { appId: "", appPassword: "", tenantId: "" } } },
env: {
MSTEAMS_APP_ID: "ambient-app",
MSTEAMS_APP_PASSWORD: "ambient-password",
MSTEAMS_TENANT_ID: "ambient-tenant",
},
}),
).toBe(false);
});
it.each([
{
name: "Slack HTTP credentials",
channelId: "slack",
cfg: { channels: { slack: { mode: "http", signingSecret: "signed" } } },
env: { SLACK_BOT_TOKEN: "xoxb-test" },
},
{
name: "Slack user identity",
channelId: "slack",
cfg: { channels: { slack: { postAs: "user" } } },
env: { SLACK_USER_TOKEN: "xoxp-test", SLACK_APP_TOKEN: "xapp-test" },
},
{
name: "Teams managed identity",
channelId: "msteams",
cfg: {},
env: {
MSTEAMS_APP_ID: "app",
MSTEAMS_TENANT_ID: "tenant",
MSTEAMS_AUTH_TYPE: "federated",
MSTEAMS_USE_MANAGED_IDENTITY: "true",
},
},
{
name: "configured Feishu account",
channelId: "feishu",
cfg: { channels: { feishu: { appId: "app", appSecret: "secret" } } },
env: {},
},
{
name: "configured Nextcloud account",
channelId: "nextcloud-talk",
cfg: { channels: { "nextcloud-talk": { baseUrl: "https://cloud.example.test" } } },
env: { NEXTCLOUD_TALK_BOT_SECRET: "secret" },
},
] satisfies Array<{
name: string;
channelId: string;
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}>)("accepts the owner-specific $name contract", ({ channelId, cfg, env }) => {
expect(hasBundledChannelConfiguredState({ channelId, cfg, env })).toBe(true);
});
it("uses declarative env metadata without a TypeScript source require hook", () => {
const previousTsHook = nodeRequire.extensions[".ts"];
delete nodeRequire.extensions[".ts"];
@@ -110,8 +110,6 @@ describe("channel package-state probes", () => {
id: "env-chat",
configuredState: {
env: { allOf: ["ENV_CHAT_TOKEN"] },
specifier: "./missing-configured-state",
exportName: "missingConfiguredState",
},
},
} satisfies PluginChannelCatalogEntry,
+17 -2
View File
@@ -209,7 +209,7 @@ function resolveChannelPackageStateChecker(params: {
return null;
}
if (metadata.env) {
if (metadata.env && (!metadata.specifier || !metadata.exportName)) {
return ({ env }) => {
const allOf = metadata.env?.allOf ?? [];
const anyOf = metadata.env?.anyOf ?? [];
@@ -306,9 +306,24 @@ export function hasBundledChannelPackageState(params: {
if (!entry) {
return false;
}
const checker = resolveChannelPackageStateChecker({
return hasChannelPackageState({
entry,
metadataKey: params.metadataKey,
cfg: params.cfg,
env: params.env,
});
}
/** Evaluates the exact channel package owner already selected and trusted by its caller. */
export function hasChannelPackageState(params: {
entry: PluginChannelCatalogEntry;
metadataKey: ChannelPackageStateMetadataKey;
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const checker = resolveChannelPackageStateChecker({
entry: params.entry,
metadataKey: params.metadataKey,
});
return checker ? checker({ cfg: params.cfg, env: params.env }) : false;
}
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -15,8 +15,14 @@ describe("isChannelConfigured", () => {
expect(isChannelConfigured({}, "discord", { DISCORD_BOT_TOKEN: "token" })).toBe(true);
});
it("detects Slack env configuration through the package metadata seam", () => {
expect(isChannelConfigured({}, "slack", { SLACK_BOT_TOKEN: "xoxb-test" })).toBe(true);
it("requires both Slack identity and transport tokens through the package metadata seam", () => {
expect(isChannelConfigured({}, "slack", { SLACK_BOT_TOKEN: "xoxb-test" })).toBe(false);
expect(
isChannelConfigured({}, "slack", {
SLACK_BOT_TOKEN: "xoxb-test",
SLACK_APP_TOKEN: "xapp-test",
}),
).toBe(true);
});
it("requires both IRC host and nick env vars through the package metadata seam", () => {
+96
View File
@@ -1,4 +1,5 @@
/** Tests channel plugin id resolution from config, manifests, and installed state. */
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
@@ -3424,6 +3425,101 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
).toContain("external-env-channel");
});
it("does not let namespace discovery bypass an incomplete trusted channel contract", () => {
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
{ channelId: "external-env-channel", source: "env" },
]);
expect(
resolveConfiguredChannelPresencePolicy({
config: { plugins: { allow: ["external-env-channel-plugin"] } } as OpenClawConfig,
workspaceDir: "/tmp",
env: { EXTERNAL_ENV_CHANNEL_HOST: "irc.example.com" },
includePersistedAuthState: false,
}),
).toStrictEqual([]);
});
it("preserves explicit channel intent when ambient credentials are incomplete", () => {
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
{ channelId: "external-env-channel", source: "env" },
]);
expect(
resolveConfiguredChannelPresencePolicy({
config: {
channels: { "external-env-channel": { token: "configured" } },
plugins: { allow: ["external-env-channel-plugin"] },
} as OpenClawConfig,
workspaceDir: "/tmp",
env: { EXTERNAL_ENV_CHANNEL_HOST: "irc.example.com" },
includePersistedAuthState: false,
}),
).toStrictEqual([
{
channelId: "external-env-channel",
sources: ["explicit-config"],
effective: true,
pluginIds: ["external-env-channel-plugin"],
blockedReasons: [],
},
]);
});
it.each(["global", "config"] as const)(
"evaluates the trusted %s installed Slack owner's credential contract",
(origin) => {
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
{ channelId: "slack", source: "env" },
]);
const slackRoot = fileURLToPath(new URL("../../extensions/slack/", import.meta.url));
const record = {
...withManifestLoadPaths({
id: "slack",
origin,
channels: ["slack"],
providers: [],
cliBackends: [],
packageChannel: {
id: "slack",
configuredState: {
env: { anyOf: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] },
specifier: "./configured-state",
exportName: "hasConfiguredSlackChannelState",
},
},
}),
rootDir: slackRoot,
} satisfies PluginManifestRecord;
const config = { plugins: { allow: ["slack"] } } as OpenClawConfig;
expect(
resolveConfiguredChannelPresencePolicy({
config,
env: { SLACK_BOT_TOKEN: "xoxb-test" },
manifestRecords: [record],
includePersistedAuthState: false,
}),
).toStrictEqual([]);
expect(
resolveConfiguredChannelPresencePolicy({
config,
env: { SLACK_BOT_TOKEN: "xoxb-test", SLACK_APP_TOKEN: "xapp-test" },
manifestRecords: [record],
includePersistedAuthState: false,
}),
).toStrictEqual([
{
channelId: "slack",
sources: ["env", "manifest-env"],
effective: true,
pluginIds: ["slack"],
blockedReasons: [],
},
]);
},
);
it("lets explicit bundled channel config bypass restrictive allowlists", () => {
const config = {
channels: {
+76 -23
View File
@@ -9,6 +9,7 @@ import {
type AmbientEnvTriggerPolicy,
type ChannelPresenceSignalSource,
} from "../channels/config-presence.js";
import { hasChannelPackageState } from "../channels/plugins/package-state-probes.js";
import { resolveConfigWidePluginManifestRegistry } from "../config/io.plugin-metadata.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js";
@@ -136,8 +137,13 @@ function listManifestEnvConfiguredChannelSignals(params: {
activationSourceConfig?: OpenClawConfig;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Array<{ channelId: string; source: "manifest-env" }> {
envSignalChannelIds: ReadonlySet<string>;
}): {
contractChannelIds: Set<string>;
signals: Array<{ channelId: string; source: "manifest-env" }>;
} {
const signals: Array<{ channelId: string; source: "manifest-env" }> = [];
const contractChannelIds = new Set<string>();
const seen = new Set<string>();
const trustConfig = params.activationSourceConfig ?? params.config;
const normalizedConfig = normalizePluginsConfig(trustConfig.plugins);
@@ -153,16 +159,41 @@ function listManifestEnvConfiguredChannelSignals(params: {
}
for (const channelId of record.channels) {
const packageChannel = record.packageChannel;
const configuredStateEnv =
const configuredState =
normalizeOptionalLowercaseString(packageChannel?.id) ===
normalizeOptionalLowercaseString(channelId)
? packageChannel?.configuredState?.env
? packageChannel?.configuredState
: undefined;
const allOf = configuredStateEnv?.allOf ?? [];
const anyOf = configuredStateEnv?.anyOf ?? [];
const hasEnvContract = allOf.length > 0 || anyOf.length > 0;
if (
!hasEnvContract ||
const allOf = configuredState?.env?.allOf ?? [];
const anyOf = configuredState?.env?.anyOf ?? [];
const hasModuleContract = Boolean(configuredState?.specifier && configuredState.exportName);
if (allOf.length === 0 && anyOf.length === 0 && !hasModuleContract) {
continue;
}
const normalizedChannelId = normalizeOptionalLowercaseString(channelId);
if (!normalizedChannelId) {
continue;
}
contractChannelIds.add(normalizedChannelId);
if (hasModuleContract) {
if (
!params.envSignalChannelIds.has(normalizedChannelId) ||
!packageChannel ||
!hasChannelPackageState({
entry: {
pluginId: record.id,
origin: record.origin,
rootDir: record.rootDir,
channel: packageChannel,
},
metadataKey: "configuredState",
cfg: params.config,
env: params.env,
})
) {
continue;
}
} else if (
!allOf.every((envVar) => hasNonEmptyEnvValue(params.env, envVar)) ||
(anyOf.length > 0 && !anyOf.some((envVar) => hasNonEmptyEnvValue(params.env, envVar)))
) {
@@ -175,7 +206,10 @@ function listManifestEnvConfiguredChannelSignals(params: {
signals.push({ channelId, source: "manifest-env" });
}
}
return signals.toSorted((left, right) => left.channelId.localeCompare(right.channelId));
return {
contractChannelIds,
signals: signals.toSorted((left, right) => left.channelId.localeCompare(right.channelId)),
};
}
function normalizeActivationBlockedReason(reason?: string): ConfiguredChannelBlockedReason {
@@ -378,27 +412,46 @@ export function resolveConfiguredChannelPresencePolicy(params: {
const disabledChannelIds = new Set(listExplicitlyDisabledChannelIdsForConfig(params.config));
const entrySources = new Map<string, Set<ConfiguredChannelPresenceSource>>();
const potentialSignals = listPotentialConfiguredChannelPresenceSignals(params.config, env, {
includePersistedAuthState: params.includePersistedAuthState,
ambientEnvTriggers: params.ambientEnvTriggers,
});
const manifestEnv =
params.ambientEnvTriggers === "suppress"
? undefined
: listManifestEnvConfiguredChannelSignals({
records,
config: params.config,
activationSourceConfig: params.activationSourceConfig,
env,
envSignalChannelIds: new Set(
potentialSignals
.filter((signal) => signal.source === "env")
.map((signal) => normalizeOptionalLowercaseString(signal.channelId))
.filter((channelId): channelId is string => Boolean(channelId)),
),
});
const configuredManifestEnvChannelIds = new Set(
manifestEnv?.signals.map((signal) => normalizeOptionalLowercaseString(signal.channelId)),
);
for (const channelId of listExplicitConfiguredChannelIdsForConfig(params.config)) {
addPolicySignal(entrySources, channelId, "explicit-config");
}
for (const signal of listPotentialConfiguredChannelPresenceSignals(params.config, env, {
includePersistedAuthState: params.includePersistedAuthState,
ambientEnvTriggers: params.ambientEnvTriggers,
})) {
if (signal.source === "config") {
for (const signal of potentialSignals) {
const channelId = normalizeOptionalLowercaseString(signal.channelId);
if (
signal.source === "config" ||
(signal.source === "env" &&
channelId &&
manifestEnv?.contractChannelIds.has(channelId) &&
!configuredManifestEnvChannelIds.has(channelId))
) {
continue;
}
addPolicySignal(entrySources, signal.channelId, signal.source);
}
if (params.ambientEnvTriggers !== "suppress") {
for (const signal of listManifestEnvConfiguredChannelSignals({
records,
config: params.config,
activationSourceConfig: params.activationSourceConfig,
env,
})) {
addPolicySignal(entrySources, signal.channelId, signal.source);
}
for (const signal of manifestEnv?.signals ?? []) {
addPolicySignal(entrySources, signal.channelId, signal.source);
}
for (const channelId of disabledChannelIds) {
entrySources.delete(channelId);