mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
committed by
GitHub
parent
51bb3edef9
commit
9b77c06bbd
@@ -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!",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -42,7 +42,9 @@
|
||||
"FEISHU_VERIFICATION_TOKEN",
|
||||
"FEISHU_ENCRYPT_KEY"
|
||||
]
|
||||
}
|
||||
},
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasConfiguredFeishuChannelState"
|
||||
},
|
||||
"label": "Feishu",
|
||||
"selectionLabel": "Feishu/Lark (飞书)",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"id": "line",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"allOf": [
|
||||
"LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"LINE_CHANNEL_SECRET"
|
||||
]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"],
|
||||
}),
|
||||
{},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }, {}),
|
||||
);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user