mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 06:03:39 -06:00
fix: honor bundled channel activation contracts
This commit is contained in:
committed by
Dallin Romney
parent
7c8f08a88c
commit
a05fa58a90
@@ -218,6 +218,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,264 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasConfiguredFeishuChannelState } from "./configured-state.js";
|
||||
|
||||
describe("Feishu lightweight configured-state", () => {
|
||||
it("declares the account owner as the package configured-state checker", () => {
|
||||
const manifest = createRequire(import.meta.url)("./package.json") as {
|
||||
openclaw: { channel: { configuredState: unknown } };
|
||||
};
|
||||
|
||||
expect(manifest.openclaw.channel.configuredState).toEqual({
|
||||
specifier: "./configured-state",
|
||||
exportName: "hasConfiguredFeishuChannelState",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects bare environment credentials without configured account references", () => {
|
||||
expect(
|
||||
hasConfiguredFeishuChannelState({
|
||||
cfg: {},
|
||||
env: {
|
||||
FEISHU_APP_ID: "feishu-app",
|
||||
FEISHU_APP_SECRET: "feishu-secret",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes explicitly configured top-level app credentials", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: { appId: "feishu-app", appSecret: "feishu-secret" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes explicitly configured environment secret references", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: { source: "env", provider: "default", id: "FEISHU_APP_ID" },
|
||||
appSecret: { source: "env", provider: "default", id: "FEISHU_APP_SECRET" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredFeishuChannelState({
|
||||
cfg,
|
||||
env: {
|
||||
FEISHU_APP_ID: "feishu-app",
|
||||
FEISHU_APP_SECRET: "feishu-secret",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a configured app ID without an app secret", () => {
|
||||
const cfg: OpenClawConfig = { channels: { feishu: { appId: "feishu-app" } } };
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a configured app secret without an app ID", () => {
|
||||
const cfg: OpenClawConfig = { channels: { feishu: { appSecret: "feishu-secret" } } };
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only configured app credentials", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { feishu: { appId: " ", appSecret: "feishu-secret" } },
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes credentials on a named Feishu account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
work: { appId: "feishu-work", appSecret: "feishu-work-secret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes named-account credentials inherited from the base config", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "feishu-app",
|
||||
accounts: {
|
||||
work: { appSecret: "feishu-work-secret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects complete credentials belonging only to a disabled named account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: false,
|
||||
appId: "feishu-work",
|
||||
appSecret: "feishu-work-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not activate when only a disabled named account completes base credentials", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "feishu-base",
|
||||
accounts: {
|
||||
work: { enabled: false, appSecret: "feishu-work-secret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes an enabled named account when another named account is disabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
disabled: {
|
||||
enabled: false,
|
||||
appId: "feishu-disabled",
|
||||
appSecret: "feishu-disabled-secret",
|
||||
},
|
||||
enabled: { appId: "feishu-active", appSecret: "feishu-active-secret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects root credentials shadowed by an explicitly disabled default account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "feishu-root",
|
||||
appSecret: "feishu-root-secret",
|
||||
accounts: { default: { enabled: false } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects direct credentials on an explicitly disabled default account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: false,
|
||||
appId: "feishu-default",
|
||||
appSecret: "feishu-default-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes active named credentials beside an explicitly disabled default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "feishu-shared",
|
||||
accounts: {
|
||||
default: { enabled: false },
|
||||
work: { appSecret: "feishu-work-secret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an explicitly disabled channel with complete credentials", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: { enabled: false, appId: "feishu-app", appSecret: "feishu-secret" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an explicit default that clears its inherited Feishu app ID", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "feishu-root",
|
||||
appSecret: "feishu-root-secret",
|
||||
accounts: { default: { appId: "" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an explicit default that clears its inherited Feishu app secret", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "feishu-root",
|
||||
appSecret: "feishu-root-secret",
|
||||
accounts: { default: { appSecret: "" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes an enabled named account beside an invalid merged Feishu default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "feishu-root",
|
||||
appSecret: "feishu-root-secret",
|
||||
accounts: {
|
||||
default: { appSecret: "" },
|
||||
work: { appSecret: "feishu-work-secret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredFeishuChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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 reads configured credentials or SecretRefs; bare env is not an account. */
|
||||
export function hasConfiguredFeishuChannelState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const config = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!config || config.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const defaultAccount = config.accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccount?.enabled !== false) {
|
||||
const defaultConfig = defaultAccount
|
||||
? mergeAccountConfig({
|
||||
channelConfig: config,
|
||||
accountConfig: defaultAccount,
|
||||
omitKeys: ["defaultAccount"],
|
||||
})
|
||||
: config;
|
||||
if (
|
||||
hasConfiguredAccountValue(defaultConfig.appId) &&
|
||||
hasConfiguredAccountValue(defaultConfig.appSecret)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(config.accounts ?? {}).some(([accountId, account]) => {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID || !account || account.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const appId = Object.hasOwn(account, "appId") ? account.appId : config.appId;
|
||||
const appSecret = Object.hasOwn(account, "appSecret") ? account.appSecret : config.appSecret;
|
||||
return hasConfiguredAccountValue(appId) && hasConfiguredAccountValue(appSecret);
|
||||
});
|
||||
}
|
||||
@@ -35,14 +35,8 @@
|
||||
"channel": {
|
||||
"id": "feishu",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"FEISHU_APP_ID",
|
||||
"FEISHU_APP_SECRET",
|
||||
"FEISHU_VERIFICATION_TOKEN",
|
||||
"FEISHU_ENCRYPT_KEY"
|
||||
]
|
||||
}
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasConfiguredFeishuChannelState"
|
||||
},
|
||||
"label": "Feishu",
|
||||
"selectionLabel": "Feishu/Lark (飞书)",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"id": "line",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"allOf": [
|
||||
"LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"LINE_CHANNEL_SECRET"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasConfiguredMSTeamsChannelState } from "./configured-state.js";
|
||||
|
||||
const requiredEnv = {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
};
|
||||
|
||||
describe("Microsoft Teams lightweight configured-state", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "default client-secret credentials",
|
||||
env: { ...requiredEnv, MSTEAMS_APP_PASSWORD: "teams-secret" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a certificate without federated mode",
|
||||
env: { ...requiredEnv, MSTEAMS_CERTIFICATE_PATH: "/teams.pem" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a managed identity without federated mode",
|
||||
env: { ...requiredEnv, MSTEAMS_USE_MANAGED_IDENTITY: "true" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a federated certificate",
|
||||
env: {
|
||||
...requiredEnv,
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_CERTIFICATE_PATH: "/teams.pem",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a whitespace-only federated certificate",
|
||||
env: {
|
||||
...requiredEnv,
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_CERTIFICATE_PATH: " ",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an enabled federated managed identity",
|
||||
env: {
|
||||
...requiredEnv,
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "true",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a disabled federated managed identity",
|
||||
env: {
|
||||
...requiredEnv,
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "false",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "federated mode without an authentication mechanism",
|
||||
env: { ...requiredEnv, MSTEAMS_AUTH_TYPE: "federated" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a missing tenant",
|
||||
env: { MSTEAMS_APP_ID: "teams-app", MSTEAMS_APP_PASSWORD: "teams-secret" },
|
||||
configured: false,
|
||||
},
|
||||
])("recognizes $label", ({ env, configured }) => {
|
||||
expect(hasConfiguredMSTeamsChannelState({ cfg: {}, env })).toBe(configured);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "ambient client-secret credentials",
|
||||
cfg: { channels: { msteams: { enabled: false } } } satisfies OpenClawConfig,
|
||||
env: { ...requiredEnv, MSTEAMS_APP_PASSWORD: "teams-secret" },
|
||||
},
|
||||
{
|
||||
label: "configured client-secret credentials",
|
||||
cfg: {
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: false,
|
||||
appId: "teams-app",
|
||||
tenantId: "teams-tenant",
|
||||
appPassword: "teams-secret",
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
env: {},
|
||||
},
|
||||
{
|
||||
label: "ambient federated managed-identity credentials",
|
||||
cfg: { channels: { msteams: { enabled: false } } } satisfies OpenClawConfig,
|
||||
env: {
|
||||
...requiredEnv,
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "true",
|
||||
},
|
||||
},
|
||||
])("rejects $label when the sole Teams account is disabled", ({ cfg, env }) => {
|
||||
expect(hasConfiguredMSTeamsChannelState({ cfg, env })).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes configured client-secret references", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "teams-app",
|
||||
tenantId: "teams-tenant",
|
||||
appPassword: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "TEAMS_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredMSTeamsChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ field: "appId", value: "" },
|
||||
{ field: "tenantId", value: " " },
|
||||
{ field: "appPassword", value: "" },
|
||||
] as const)(
|
||||
"does not replace an explicitly blank $field with ambient credentials",
|
||||
({ field, value }) => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "teams-app",
|
||||
tenantId: "teams-tenant",
|
||||
appPassword: "teams-secret",
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
expect(
|
||||
hasConfiguredMSTeamsChannelState({
|
||||
cfg,
|
||||
env: { ...requiredEnv, MSTEAMS_APP_PASSWORD: "ambient-secret" },
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("lets an explicit disabled identity override an ambient enabled identity", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "teams-app",
|
||||
tenantId: "teams-tenant",
|
||||
authType: "federated",
|
||||
useManagedIdentity: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredMSTeamsChannelState({
|
||||
cfg,
|
||||
env: { MSTEAMS_USE_MANAGED_IDENTITY: "true" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only configured certificate path", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "teams-app",
|
||||
tenantId: "teams-tenant",
|
||||
authType: "federated",
|
||||
certificatePath: " ",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredMSTeamsChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Keep Teams activation independent of the full channel and Azure SDK runtimes.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./src/secret-input.js";
|
||||
|
||||
/** Checks the same auth-mode and credential requirements as the Teams runtime. */
|
||||
export function hasConfiguredMSTeamsChannelState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const env = params.env ?? process.env;
|
||||
const config = params.cfg.channels?.msteams;
|
||||
if (config?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
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 === "secret" || config?.authType === "federated"
|
||||
? config.authType
|
||||
: env.MSTEAMS_AUTH_TYPE === "federated"
|
||||
? "federated"
|
||||
: "secret";
|
||||
if (authType === "federated") {
|
||||
const hasCertificate = Boolean(
|
||||
normalizeSecretInputString(
|
||||
config && Object.hasOwn(config, "certificatePath")
|
||||
? config.certificatePath
|
||||
: env.MSTEAMS_CERTIFICATE_PATH,
|
||||
),
|
||||
);
|
||||
const hasManagedIdentity =
|
||||
config?.useManagedIdentity ?? env.MSTEAMS_USE_MANAGED_IDENTITY === "true";
|
||||
return hasCertificate || hasManagedIdentity;
|
||||
}
|
||||
|
||||
return config && Object.hasOwn(config, "appPassword")
|
||||
? hasConfiguredSecretInput(config.appPassword)
|
||||
: Boolean(normalizeSecretInputString(env.MSTEAMS_APP_PASSWORD));
|
||||
}
|
||||
@@ -37,13 +37,8 @@
|
||||
"channel": {
|
||||
"id": "msteams",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"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,393 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasConfiguredNextcloudTalkChannelState } from "./configured-state.js";
|
||||
|
||||
describe("Nextcloud Talk lightweight configured-state", () => {
|
||||
it("declares the account owner as the package configured-state checker", () => {
|
||||
const manifest = createRequire(import.meta.url)("./package.json") as {
|
||||
openclaw: { channel: { configuredState: unknown } };
|
||||
};
|
||||
|
||||
expect(manifest.openclaw.channel.configuredState).toEqual({
|
||||
specifier: "./configured-state",
|
||||
exportName: "hasConfiguredNextcloudTalkChannelState",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an environment secret without a configured Nextcloud URL", () => {
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg: {},
|
||||
env: { NEXTCLOUD_TALK_BOT_SECRET: "nextcloud-secret" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes an environment secret when the Nextcloud URL is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { "nextcloud-talk": { baseUrl: "https://cloud.example.com" } },
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: { NEXTCLOUD_TALK_BOT_SECRET: "nextcloud-secret" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat the optional API password as the required webhook bot secret", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
apiUser: "nextcloud-user",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: { NEXTCLOUD_TALK_API_PASSWORD: "optional-api-password" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the historical API-password environment alongside valid bot credentials", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
apiUser: "nextcloud-user",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: {
|
||||
NEXTCLOUD_TALK_API_PASSWORD: "optional-api-password",
|
||||
NEXTCLOUD_TALK_BOT_SECRET: "required-bot-secret",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a bot secret even when an inline API user and password are configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
apiUser: "nextcloud-user",
|
||||
apiPassword: "optional-api-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only Nextcloud URL", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { "nextcloud-talk": { baseUrl: " " } },
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: { NEXTCLOUD_TALK_BOT_SECRET: "nextcloud-secret" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only environment secret", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { "nextcloud-talk": { baseUrl: "https://cloud.example.com" } },
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: { NEXTCLOUD_TALK_BOT_SECRET: " " },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes a configured inline bot secret", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "nextcloud-secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes a configured bot-secret reference", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "NEXTCLOUD_OWNER_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes a configured bot-secret file", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecretFile: "/run/secrets/nextcloud-talk",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a configured Nextcloud URL without any bot secret", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { "nextcloud-talk": { baseUrl: "https://cloud.example.com" } },
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes inline credentials on a named Nextcloud account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: {
|
||||
work: {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "nextcloud-work-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes a named account inheriting the configured Nextcloud URL", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
accounts: { work: { botSecret: "nextcloud-work-secret" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes a named account bot-secret reference", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: {
|
||||
work: {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_WORK_SECRET" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes a configured named account bot-secret file", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: {
|
||||
work: {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecretFile: "/run/secrets/nextcloud-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not assign a default-only environment secret to named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: { work: { baseUrl: "https://cloud.example.com" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: { NEXTCLOUD_TALK_BOT_SECRET: "default-only-secret" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects credentials belonging only to a disabled named account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: false,
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "nextcloud-work-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("allows environment secrets on an explicitly mapped default account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: { default: { baseUrl: "https://cloud.example.com" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: { NEXTCLOUD_TALK_BOT_SECRET: "default-only-secret" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects ambient credentials for an explicitly disabled default account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
accounts: { default: { enabled: false } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: { NEXTCLOUD_TALK_BOT_SECRET: "default-only-secret" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects root credentials for an explicitly disabled default account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "root-bot-secret",
|
||||
accounts: { default: { enabled: false } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes an active named account beside an explicitly disabled default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
accounts: {
|
||||
default: { enabled: false },
|
||||
work: { botSecret: "work-bot-secret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a disabled channel with complete ambient bot credentials", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": { enabled: false, baseUrl: "https://cloud.example.com" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasConfiguredNextcloudTalkChannelState({
|
||||
cfg,
|
||||
env: { NEXTCLOUD_TALK_BOT_SECRET: "default-only-secret" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an explicit default that clears its inherited Nextcloud URL", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "root-bot-secret",
|
||||
accounts: { default: { baseUrl: "" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an explicit default that clears its inherited webhook secret", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "root-bot-secret",
|
||||
accounts: { default: { botSecret: "" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes an enabled named account beside an invalid merged Nextcloud default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "root-bot-secret",
|
||||
accounts: {
|
||||
default: { botSecret: "" },
|
||||
work: { botSecret: "work-bot-secret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasConfiguredNextcloudTalkChannelState({ cfg, env: {} })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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 NextcloudTalkConfiguredAccount = NonNullable<
|
||||
NonNullable<CoreConfig["channels"]>["nextcloud-talk"]
|
||||
>;
|
||||
|
||||
function hasConfiguredNextcloudTalkAccountState(
|
||||
config: NextcloudTalkConfiguredAccount | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
if (typeof config?.baseUrl !== "string" || !config.baseUrl.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
hasConfiguredAccountValue(config.botSecret) ||
|
||||
hasConfiguredAccountValue(config.botSecretFile) ||
|
||||
hasConfiguredAccountValue(env.NEXTCLOUD_TALK_BOT_SECRET)
|
||||
);
|
||||
}
|
||||
|
||||
/** Match Nextcloud Talk's URL, account inheritance, and default-only env secret. */
|
||||
export function hasConfiguredNextcloudTalkChannelState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const config = params.cfg.channels?.["nextcloud-talk"] as
|
||||
| NextcloudTalkConfiguredAccount
|
||||
| undefined;
|
||||
if (config?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const env = params.env ?? process.env;
|
||||
const defaultAccount = config?.accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccount?.enabled !== false) {
|
||||
const defaultConfig = defaultAccount
|
||||
? mergeAccountConfig({
|
||||
channelConfig: config,
|
||||
accountConfig: defaultAccount,
|
||||
omitKeys: ["defaultAccount"],
|
||||
})
|
||||
: config;
|
||||
if (hasConfiguredNextcloudTalkAccountState(defaultConfig, env)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(config?.accounts ?? {}).some(([accountId, account]) => {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID || account.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const merged = mergeAccountConfig({
|
||||
channelConfig: config,
|
||||
accountConfig: account,
|
||||
omitKeys: ["defaultAccount"],
|
||||
});
|
||||
// Nextcloud env secrets are valid only for the canonical default account.
|
||||
return hasConfiguredNextcloudTalkAccountState(merged, {});
|
||||
});
|
||||
}
|
||||
@@ -30,12 +30,8 @@
|
||||
"channel": {
|
||||
"id": "nextcloud-talk",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"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,475 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasConfiguredSlackChannelState } from "./configured-state.js";
|
||||
|
||||
type SlackStateCase = {
|
||||
label: string;
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
describe("Slack lightweight configured-state", () => {
|
||||
it("declares the Slack owner as its identity-aware configured-state checker", () => {
|
||||
const manifest = createRequire(import.meta.url)("./package.json") as {
|
||||
openclaw: { channel: { configuredState: unknown } };
|
||||
};
|
||||
|
||||
expect(manifest.openclaw.channel.configuredState).toEqual({
|
||||
specifier: "./configured-state",
|
||||
exportName: "hasConfiguredSlackChannelState",
|
||||
});
|
||||
});
|
||||
|
||||
it.each<SlackStateCase>([
|
||||
{
|
||||
label: "default socket-mode bot identity with its app and bot tokens",
|
||||
cfg: {},
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a user token without explicit user identity",
|
||||
cfg: {},
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_USER_TOKEN: "xoxp-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "explicit user identity with its app and user tokens",
|
||||
cfg: { channels: { slack: { postAs: "user" } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_USER_TOKEN: "xoxp-test" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a retired identity alias without the canonical user postAs",
|
||||
cfg: { channels: { slack: { identity: "user" } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_USER_TOKEN: "xoxp-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "explicit user identity without its required user token",
|
||||
cfg: { channels: { slack: { postAs: "user" } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an app token without an identity token",
|
||||
cfg: {},
|
||||
env: { SLACK_APP_TOKEN: "xapp-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a bot token without its socket-mode app token",
|
||||
cfg: {},
|
||||
env: { SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a whitespace-only app token",
|
||||
cfg: {},
|
||||
env: { SLACK_APP_TOKEN: " ", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "configured socket-mode bot credentials",
|
||||
cfg: {
|
||||
channels: { slack: { appToken: "xapp-test", botToken: "xoxb-test" } },
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "configured socket-mode user credentials",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: { postAs: "user", appToken: "xapp-test", userToken: "xoxp-test" },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an HTTP bot identity with its signing secret",
|
||||
cfg: {
|
||||
channels: { slack: { mode: "http", signingSecret: "signing-secret" } },
|
||||
},
|
||||
env: { SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an HTTP bot identity without its signing secret",
|
||||
cfg: { channels: { slack: { mode: "http" } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an HTTP user identity with its signing secret",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: { mode: "http", postAs: "user", signingSecret: "signing-secret" },
|
||||
},
|
||||
},
|
||||
env: { SLACK_USER_TOKEN: "xoxp-test" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an HTTP user identity without its signing secret",
|
||||
cfg: { channels: { slack: { mode: "http", postAs: "user" } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_USER_TOKEN: "xoxp-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a relay bot identity with its complete relay transport",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
relay: {
|
||||
url: "https://relay.example.com",
|
||||
authToken: "relay-token",
|
||||
gatewayId: "relay-gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a relay bot identity without relay authentication",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
relay: { url: "https://relay.example.com", gatewayId: "relay-gateway" },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a relay user identity without the required companion bot token",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
postAs: "user",
|
||||
relay: {
|
||||
url: "https://relay.example.com",
|
||||
authToken: "relay-token",
|
||||
gatewayId: "relay-gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { SLACK_USER_TOKEN: "xoxp-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a relay user identity with its user and companion bot tokens",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
postAs: "user",
|
||||
relay: {
|
||||
url: "https://relay.example.com",
|
||||
authToken: "relay-token",
|
||||
gatewayId: "relay-gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { SLACK_USER_TOKEN: "xoxp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "configured credential references",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named socket-mode bot account",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
work: { appToken: "xapp-work", botToken: "xoxb-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named socket-mode user account",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
work: { postAs: "user", appToken: "xapp-work", userToken: "xoxp-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named account using only the retired identity alias",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
work: { identity: "user", appToken: "xapp-work", userToken: "xoxp-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a named bot account inheriting its root app token",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: "xapp-shared",
|
||||
accounts: { work: { botToken: "xoxb-work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named account inheriting explicit user identity",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
postAs: "user",
|
||||
appToken: "xapp-shared",
|
||||
accounts: { work: { userToken: "xoxp-work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named HTTP account with its inherited signing secret",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "http",
|
||||
signingSecret: "signing-secret",
|
||||
accounts: { work: { botToken: "xoxb-work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named relay account with merged relay configuration",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
relay: { url: "https://relay.example.com", gatewayId: "relay-gateway" },
|
||||
accounts: {
|
||||
work: { botToken: "xoxb-work", relay: { authToken: "relay-token" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named relay user account with its own user and companion bot tokens",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
relay: { url: "https://relay.example.com", gatewayId: "relay-gateway" },
|
||||
accounts: {
|
||||
work: {
|
||||
postAs: "user",
|
||||
userToken: "xoxp-work",
|
||||
botToken: "xoxb-work",
|
||||
relay: { authToken: "relay-token" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named relay user account that cannot borrow a default bot token",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
relay: { url: "https://relay.example.com", gatewayId: "relay-gateway" },
|
||||
accounts: {
|
||||
work: {
|
||||
postAs: "user",
|
||||
userToken: "xoxp-work",
|
||||
relay: { authToken: "relay-token" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { SLACK_BOT_TOKEN: "xoxb-default" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a named account that cannot inherit default-only ambient tokens",
|
||||
cfg: {
|
||||
channels: { slack: { accounts: { work: {} } } },
|
||||
},
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_USER_TOKEN: "xoxp-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a disabled named account",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
work: { enabled: false, appToken: "xapp-work", botToken: "xoxb-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicitly mapped default account using default-only ambient tokens",
|
||||
cfg: {
|
||||
channels: { slack: { accounts: { default: {} } } },
|
||||
},
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a disabled default account with ambient socket credentials",
|
||||
cfg: { channels: { slack: { accounts: { default: { enabled: false } } } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a disabled default account with root socket credentials",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: "xapp-test",
|
||||
botToken: "xoxb-test",
|
||||
accounts: { default: { enabled: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an active named account beside an explicitly disabled default",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: "xapp-shared",
|
||||
accounts: {
|
||||
default: { enabled: false },
|
||||
work: { botToken: "xoxb-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a disabled Slack channel with complete ambient credentials",
|
||||
cfg: { channels: { slack: { enabled: false } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default HTTP override without its required signing secret",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: "xapp-root",
|
||||
botToken: "xoxb-root",
|
||||
accounts: { default: { mode: "http" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default HTTP override with its signing secret",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: "xapp-root",
|
||||
botToken: "xoxb-root",
|
||||
accounts: { default: { mode: "http", signingSecret: "signing-secret" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an explicit default that clears its inherited bot credential",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: "xapp-root",
|
||||
botToken: "xoxb-root",
|
||||
accounts: { default: { botToken: "" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an enabled named account beside an invalid explicitly merged default",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
appToken: "xapp-root",
|
||||
botToken: "xoxb-root",
|
||||
accounts: {
|
||||
default: { mode: "http" },
|
||||
work: { botToken: "xoxb-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
])("recognizes $label", ({ cfg, env, configured }) => {
|
||||
expect(hasConfiguredSlackChannelState({ cfg, env })).toBe(configured);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
hasConfiguredAccountValue,
|
||||
mergeAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
type SlackConfiguredAccount = NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>;
|
||||
|
||||
function hasConfiguredSlackAccountState(
|
||||
config: SlackConfiguredAccount | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
const hasBotToken =
|
||||
hasConfiguredAccountValue(config?.botToken) || hasConfiguredAccountValue(env.SLACK_BOT_TOKEN);
|
||||
const hasIdentityToken =
|
||||
config?.postAs === "user"
|
||||
? hasConfiguredAccountValue(config.userToken) ||
|
||||
hasConfiguredAccountValue(env.SLACK_USER_TOKEN)
|
||||
: hasBotToken;
|
||||
if (!hasIdentityToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config?.mode === "http") {
|
||||
return hasConfiguredAccountValue(config.signingSecret);
|
||||
}
|
||||
if (config?.mode === "relay") {
|
||||
return (
|
||||
hasBotToken &&
|
||||
hasConfiguredAccountValue(config.relay?.url) &&
|
||||
hasConfiguredAccountValue(config.relay?.authToken) &&
|
||||
hasConfiguredAccountValue(config.relay?.gatewayId)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
hasConfiguredAccountValue(config?.appToken) || hasConfiguredAccountValue(env.SLACK_APP_TOKEN)
|
||||
);
|
||||
}
|
||||
|
||||
/** Match Slack's account identity, inherited config, and transport requirements. */
|
||||
export function hasConfiguredSlackChannelState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const config = params.cfg.channels?.slack;
|
||||
if (config?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const env = params.env ?? process.env;
|
||||
const defaultAccount = config?.accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccount?.enabled !== false) {
|
||||
const defaultConfig = defaultAccount
|
||||
? mergeAccountConfig({
|
||||
channelConfig: config,
|
||||
accountConfig: defaultAccount,
|
||||
nestedObjectKeys: ["relay"],
|
||||
})
|
||||
: config;
|
||||
if (hasConfiguredSlackAccountState(defaultConfig, env)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(config?.accounts ?? {}).some(([accountId, account]) => {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID || account.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const merged = mergeAccountConfig({
|
||||
channelConfig: config,
|
||||
accountConfig: account,
|
||||
nestedObjectKeys: ["relay"],
|
||||
});
|
||||
// Ambient Slack tokens belong to the default account, never named tenants.
|
||||
return hasConfiguredSlackAccountState(merged, {});
|
||||
});
|
||||
}
|
||||
@@ -42,13 +42,8 @@
|
||||
"channel": {
|
||||
"id": "slack",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"SLACK_BOT_TOKEN",
|
||||
"SLACK_APP_TOKEN",
|
||||
"SLACK_USER_TOKEN"
|
||||
]
|
||||
}
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasConfiguredSlackChannelState"
|
||||
},
|
||||
"approvalFlags": [
|
||||
"native"
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasConfiguredSmsChannelState } from "./configured-state.js";
|
||||
|
||||
type SmsStateCase = {
|
||||
label: string;
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
const requiredPhoneEnv = {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_PHONE_NUMBER: "+15550001111",
|
||||
};
|
||||
|
||||
describe("SMS lightweight configured-state", () => {
|
||||
it("declares the SMS owner as its outbound-ready configured-state checker", () => {
|
||||
const manifest = createRequire(import.meta.url)("./package.json") as {
|
||||
openclaw: { channel: { configuredState: unknown } };
|
||||
};
|
||||
|
||||
expect(manifest.openclaw.channel.configuredState).toEqual({
|
||||
specifier: "./configured-state",
|
||||
exportName: "hasConfiguredSmsChannelState",
|
||||
});
|
||||
});
|
||||
|
||||
it.each<SmsStateCase>([
|
||||
{
|
||||
label: "a signed phone-number account",
|
||||
cfg: {},
|
||||
env: {
|
||||
...requiredPhoneEnv,
|
||||
SMS_PUBLIC_WEBHOOK_URL: "https://sms.example.com/webhook",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a signed legacy sender-number account",
|
||||
cfg: {},
|
||||
env: {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_SMS_FROM: "+15550001111",
|
||||
SMS_PUBLIC_WEBHOOK_URL: "https://sms.example.com/webhook",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a signed messaging-service account",
|
||||
cfg: {},
|
||||
env: {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_MESSAGING_SERVICE_SID: "MG-test",
|
||||
SMS_PUBLIC_WEBHOOK_URL: "https://sms.example.com/webhook",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "the existing explicit local-only signature-validation opt-out",
|
||||
cfg: {},
|
||||
env: {
|
||||
...requiredPhoneEnv,
|
||||
SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "true",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an outbound phone-number account without an inbound webhook or opt-out",
|
||||
cfg: {},
|
||||
env: requiredPhoneEnv,
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an explicitly false signature-validation opt-out",
|
||||
cfg: {},
|
||||
env: {
|
||||
...requiredPhoneEnv,
|
||||
SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "false",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a whitespace-padded signature-validation opt-out",
|
||||
cfg: {},
|
||||
env: {
|
||||
...requiredPhoneEnv,
|
||||
SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: " true ",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an incorrectly capitalized signature-validation opt-out",
|
||||
cfg: {},
|
||||
env: {
|
||||
...requiredPhoneEnv,
|
||||
SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "TRUE",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a whitespace-only public webhook URL",
|
||||
cfg: {},
|
||||
env: { ...requiredPhoneEnv, SMS_PUBLIC_WEBHOOK_URL: " " },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an opt-out without an account SID",
|
||||
cfg: {},
|
||||
env: {
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_PHONE_NUMBER: "+15550001111",
|
||||
SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "true",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an opt-out without an auth token",
|
||||
cfg: {},
|
||||
env: {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_PHONE_NUMBER: "+15550001111",
|
||||
SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "true",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an opt-out without any supported sender",
|
||||
cfg: {},
|
||||
env: {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "true",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "explicit configured signed credentials",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC-test",
|
||||
authToken: "twilio-test-token",
|
||||
fromNumber: "+15550001111",
|
||||
publicWebhookUrl: "https://sms.example.com/webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an outbound account with signature validation securely enabled by default",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC-test",
|
||||
authToken: "twilio-test-token",
|
||||
fromNumber: "+15550001111",
|
||||
dangerouslyDisableSignatureValidation: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an explicitly configured boolean local-only signature opt-out",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC-test",
|
||||
authToken: "twilio-test-token",
|
||||
fromNumber: "+15550001111",
|
||||
dangerouslyDisableSignatureValidation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a configured auth-token reference",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC-test",
|
||||
authToken: { source: "env", provider: "default", id: "TWILIO_AUTH_TOKEN" },
|
||||
fromNumber: "+15550001111",
|
||||
publicWebhookUrl: "https://sms.example.com/webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an explicitly blank root account SID shadows ambient Twilio credentials",
|
||||
cfg: { channels: { sms: { accountSid: " ", authToken: "root-token" } } },
|
||||
env: requiredPhoneEnv,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicitly blank root auth token shadows ambient Twilio credentials",
|
||||
cfg: { channels: { sms: { authToken: " " } } },
|
||||
env: requiredPhoneEnv,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicitly blank root phone sender shadows both ambient phone senders",
|
||||
cfg: { channels: { sms: { fromNumber: " " } } },
|
||||
env: { ...requiredPhoneEnv, TWILIO_SMS_FROM: "+15550002222" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicitly blank root messaging sender shadows its ambient sender",
|
||||
cfg: { channels: { sms: { messagingServiceSid: " " } } },
|
||||
env: {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_MESSAGING_SERVICE_SID: "MG-test",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "the legacy phone sender after a blank primary environment sender",
|
||||
cfg: {},
|
||||
env: {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_PHONE_NUMBER: " ",
|
||||
TWILIO_SMS_FROM: "+15550002222",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a configured messaging sender beside an explicitly cleared phone sender",
|
||||
cfg: { channels: { sms: { fromNumber: "", messagingServiceSid: "MG-configured" } } },
|
||||
env: requiredPhoneEnv,
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a configured phone sender beside an explicitly cleared messaging sender",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: { fromNumber: "+15550003333", messagingServiceSid: "" },
|
||||
},
|
||||
},
|
||||
env: {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_MESSAGING_SERVICE_SID: "MG-env",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a configured auth-token reference takes precedence over ambient credentials",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
authToken: { source: "env", provider: "default", id: "OWNER_SMS_AUTH_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: requiredPhoneEnv,
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a signed named account",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accounts: {
|
||||
work: {
|
||||
accountSid: "AC-work",
|
||||
authToken: "work-token",
|
||||
fromNumber: "+15550002222",
|
||||
publicWebhookUrl: "https://sms.example.com/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named account inheriting a signed root webhook",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
publicWebhookUrl: "https://sms.example.com/work",
|
||||
accounts: {
|
||||
work: {
|
||||
accountSid: "AC-work",
|
||||
authToken: "work-token",
|
||||
fromNumber: "+15550002222",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named account with its explicitly configured local-only opt-out",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accounts: {
|
||||
work: {
|
||||
accountSid: "AC-work",
|
||||
authToken: "work-token",
|
||||
fromNumber: "+15550002222",
|
||||
dangerouslyDisableSignatureValidation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an outbound named account without an inbound webhook or opt-out",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accounts: {
|
||||
work: {
|
||||
accountSid: "AC-work",
|
||||
authToken: "work-token",
|
||||
fromNumber: "+15550002222",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named account that cannot borrow default-only environment credentials",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accounts: {
|
||||
work: {
|
||||
accountSid: "AC-work",
|
||||
fromNumber: "+15550002222",
|
||||
publicWebhookUrl: "https://sms.example.com/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { TWILIO_AUTH_TOKEN: "default-only-token" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a complete named outbound account ignores a default-only signature opt-out",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accounts: {
|
||||
work: {
|
||||
accountSid: "AC-work",
|
||||
authToken: "work-token",
|
||||
fromNumber: "+15550002222",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "true" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a disabled named account",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: false,
|
||||
accountSid: "AC-work",
|
||||
authToken: "work-token",
|
||||
fromNumber: "+15550002222",
|
||||
publicWebhookUrl: "https://sms.example.com/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a disabled default account with ambient Twilio credentials",
|
||||
cfg: { channels: { sms: { accounts: { default: { enabled: false } } } } },
|
||||
env: requiredPhoneEnv,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a disabled default account with root Twilio credentials",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC-test",
|
||||
authToken: "twilio-test-token",
|
||||
fromNumber: "+15550001111",
|
||||
accounts: { default: { enabled: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an active named SMS account beside an explicitly disabled default",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accounts: {
|
||||
default: { enabled: false },
|
||||
work: {
|
||||
accountSid: "AC-work",
|
||||
authToken: "work-token",
|
||||
fromNumber: "+15550002222",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a disabled SMS channel with complete ambient credentials",
|
||||
cfg: { channels: { sms: { enabled: false } } },
|
||||
env: requiredPhoneEnv,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default that clears its inherited Twilio auth token",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC-root",
|
||||
authToken: "root-token",
|
||||
fromNumber: "+15550001111",
|
||||
accounts: { default: { authToken: "" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default blank account SID shadows ambient credentials",
|
||||
cfg: { channels: { sms: { accounts: { default: { accountSid: " " } } } } },
|
||||
env: requiredPhoneEnv,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default blank auth token shadows ambient credentials",
|
||||
cfg: { channels: { sms: { accounts: { default: { authToken: " " } } } } },
|
||||
env: requiredPhoneEnv,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default blank phone sender shadows ambient phone senders",
|
||||
cfg: { channels: { sms: { accounts: { default: { fromNumber: " " } } } } },
|
||||
env: { ...requiredPhoneEnv, TWILIO_SMS_FROM: "+15550002222" },
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default blank messaging sender shadows its ambient sender",
|
||||
cfg: { channels: { sms: { accounts: { default: { messagingServiceSid: " " } } } } },
|
||||
env: {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_MESSAGING_SERVICE_SID: "MG-test",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default blank auth token overrides an inherited SecretRef",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
authToken: { source: "env", provider: "default", id: "OWNER_SMS_AUTH_TOKEN" },
|
||||
accounts: { default: { authToken: "" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: requiredPhoneEnv,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an explicit default SecretRef overrides a cleared root auth token",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
authToken: "",
|
||||
accounts: {
|
||||
default: {
|
||||
authToken: { source: "env", provider: "default", id: "OWNER_SMS_AUTH_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: requiredPhoneEnv,
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "an explicit default that clears its inherited Twilio sender",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC-root",
|
||||
authToken: "root-token",
|
||||
fromNumber: "+15550001111",
|
||||
accounts: { default: { fromNumber: "" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an enabled named account beside an invalid explicitly merged SMS default",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
accountSid: "AC-root",
|
||||
authToken: "root-token",
|
||||
fromNumber: "+15550001111",
|
||||
accounts: {
|
||||
default: { authToken: "" },
|
||||
work: { authToken: "work-token" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a named blank auth override cannot borrow ambient credentials",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
authToken: { source: "env", provider: "default", id: "OWNER_SMS_AUTH_TOKEN" },
|
||||
accounts: {
|
||||
default: { enabled: false },
|
||||
work: { accountSid: "AC-work", authToken: "", fromNumber: "+15550002222" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: requiredPhoneEnv,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a named SecretRef overrides a cleared inherited auth token",
|
||||
cfg: {
|
||||
channels: {
|
||||
sms: {
|
||||
authToken: "",
|
||||
accounts: {
|
||||
default: { enabled: false },
|
||||
work: {
|
||||
accountSid: "AC-work",
|
||||
authToken: { source: "env", provider: "default", id: "OWNER_SMS_AUTH_TOKEN" },
|
||||
fromNumber: "+15550002222",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: requiredPhoneEnv,
|
||||
configured: true,
|
||||
},
|
||||
])("recognizes $label", ({ cfg, env, configured }) => {
|
||||
expect(hasConfiguredSmsChannelState({ cfg, env })).toBe(configured);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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";
|
||||
|
||||
type SmsConfiguredAccount = SmsChannelConfig;
|
||||
|
||||
function hasConfiguredSmsAccountState(
|
||||
config: SmsConfiguredAccount | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
const hasAccountSid = hasConfiguredAccountValue(config?.accountSid ?? env.TWILIO_ACCOUNT_SID);
|
||||
const hasAuthToken = hasConfiguredAccountValue(config?.authToken ?? env.TWILIO_AUTH_TOKEN);
|
||||
const envFromNumber = [env.TWILIO_PHONE_NUMBER, env.TWILIO_SMS_FROM].find((value) =>
|
||||
hasConfiguredAccountValue(value),
|
||||
);
|
||||
const hasSender =
|
||||
hasConfiguredAccountValue(config?.fromNumber ?? envFromNumber) ||
|
||||
hasConfiguredAccountValue(config?.messagingServiceSid ?? env.TWILIO_MESSAGING_SERVICE_SID);
|
||||
// Outbound Twilio delivery requires credentials and a sender; webhook
|
||||
// signature readiness belongs only to the inbound gateway.
|
||||
return hasAccountSid && hasAuthToken && hasSender;
|
||||
}
|
||||
|
||||
/** Match outbound SMS credentials, senders, and account-owned env fallbacks. */
|
||||
export function hasConfiguredSmsChannelState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const config = params.cfg.channels?.sms as SmsChannelConfig | undefined;
|
||||
if (config?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const env = params.env ?? process.env;
|
||||
const defaultAccount = config?.accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccount?.enabled !== false) {
|
||||
const {
|
||||
accounts: _accounts,
|
||||
defaultAccount: _defaultAccount,
|
||||
...channelDefaults
|
||||
} = config ?? {};
|
||||
const defaultConfig = defaultAccount ? { ...channelDefaults, ...defaultAccount } : config;
|
||||
if (hasConfiguredSmsAccountState(defaultConfig, env)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(config?.accounts ?? {}).some(([accountId, account]) => {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID || account.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const {
|
||||
accounts: _accounts,
|
||||
defaultAccount: _defaultAccount,
|
||||
...channelDefaults
|
||||
} = config ?? {};
|
||||
const merged = { ...channelDefaults, ...account };
|
||||
// Twilio environment credentials are restricted to the default account.
|
||||
return hasConfiguredSmsAccountState(merged, {});
|
||||
});
|
||||
}
|
||||
@@ -20,18 +20,8 @@
|
||||
"channel": {
|
||||
"id": "sms",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"TWILIO_ACCOUNT_SID",
|
||||
"TWILIO_AUTH_TOKEN",
|
||||
"TWILIO_PHONE_NUMBER",
|
||||
"TWILIO_SMS_FROM",
|
||||
"TWILIO_MESSAGING_SERVICE_SID",
|
||||
"SMS_PUBLIC_WEBHOOK_URL",
|
||||
"SMS_WEBHOOK_PATH",
|
||||
"SMS_ALLOWED_USERS"
|
||||
]
|
||||
}
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -752,14 +752,8 @@
|
||||
"channel": {
|
||||
"id": "feishu",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"FEISHU_APP_ID",
|
||||
"FEISHU_APP_SECRET",
|
||||
"FEISHU_VERIFICATION_TOKEN",
|
||||
"FEISHU_ENCRYPT_KEY"
|
||||
]
|
||||
}
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasConfiguredFeishuChannelState"
|
||||
},
|
||||
"label": "Feishu",
|
||||
"selectionLabel": "Feishu/Lark (飞书)",
|
||||
@@ -1102,7 +1096,7 @@
|
||||
"id": "line",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"allOf": [
|
||||
"LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"LINE_CHANNEL_SECRET"
|
||||
]
|
||||
@@ -1413,13 +1407,8 @@
|
||||
"channel": {
|
||||
"id": "msteams",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"MSTEAMS_APP_ID",
|
||||
"MSTEAMS_APP_PASSWORD",
|
||||
"MSTEAMS_TENANT_ID"
|
||||
]
|
||||
}
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasConfiguredMSTeamsChannelState"
|
||||
},
|
||||
"label": "Microsoft Teams",
|
||||
"selectionLabel": "Microsoft Teams (Teams SDK)",
|
||||
@@ -1457,12 +1446,8 @@
|
||||
"channel": {
|
||||
"id": "nextcloud-talk",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"NEXTCLOUD_TALK_BOT_SECRET",
|
||||
"NEXTCLOUD_TALK_API_PASSWORD"
|
||||
]
|
||||
}
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasConfiguredNextcloudTalkChannelState"
|
||||
},
|
||||
"label": "Nextcloud Talk",
|
||||
"selectionLabel": "Nextcloud Talk (self-hosted)",
|
||||
@@ -2054,13 +2039,8 @@
|
||||
"channel": {
|
||||
"id": "slack",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"SLACK_BOT_TOKEN",
|
||||
"SLACK_APP_TOKEN",
|
||||
"SLACK_USER_TOKEN"
|
||||
]
|
||||
}
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasConfiguredSlackChannelState"
|
||||
},
|
||||
"approvalFlags": [
|
||||
"native"
|
||||
@@ -2177,18 +2157,8 @@
|
||||
"channel": {
|
||||
"id": "sms",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"TWILIO_ACCOUNT_SID",
|
||||
"TWILIO_AUTH_TOKEN",
|
||||
"TWILIO_PHONE_NUMBER",
|
||||
"TWILIO_SMS_FROM",
|
||||
"TWILIO_MESSAGING_SERVICE_SID",
|
||||
"SMS_PUBLIC_WEBHOOK_URL",
|
||||
"SMS_WEBHOOK_PATH",
|
||||
"SMS_ALLOWED_USERS"
|
||||
]
|
||||
}
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasConfiguredSmsChannelState"
|
||||
},
|
||||
"label": "SMS",
|
||||
"selectionLabel": "SMS (Twilio)",
|
||||
@@ -2303,13 +2273,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"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -2653,8 +2619,7 @@
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"anyOf": [
|
||||
"ZALO_BOT_TOKEN",
|
||||
"ZALO_WEBHOOK_SECRET"
|
||||
"ZALO_BOT_TOKEN"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,12 +57,40 @@ function readBundledExtensionCatalogEntriesSync(): ChannelCatalogEntryLike[] {
|
||||
return cached ?? [];
|
||||
}
|
||||
try {
|
||||
let sourceMetadataDir: string | undefined;
|
||||
for (const packageRoot of listPackageRoots()) {
|
||||
const sourceExtensionsDir = path.join(packageRoot, "extensions");
|
||||
if (
|
||||
pluginsDir !== path.join(packageRoot, "dist", "extensions") &&
|
||||
pluginsDir !== path.join(packageRoot, "dist-runtime", "extensions")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) &&
|
||||
fs.existsSync(path.join(packageRoot, "src")) &&
|
||||
fs.existsSync(sourceExtensionsDir)
|
||||
) {
|
||||
// Only plugins present in the runtime tree are loadable. Their source
|
||||
// manifests still own capabilities when the runtime build is stale.
|
||||
sourceMetadataDir = sourceExtensionsDir;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const entries = fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.flatMap((entry): ChannelCatalogEntryLike[] => {
|
||||
const packageJsonPath = path.join(pluginsDir, entry.name, "package.json");
|
||||
const parsed = tryReadJsonSync<ChannelCatalogEntryLike>(packageJsonPath);
|
||||
const sourcePackageJsonPath = sourceMetadataDir
|
||||
? path.join(sourceMetadataDir, entry.name, "package.json")
|
||||
: undefined;
|
||||
const parsed =
|
||||
(sourcePackageJsonPath
|
||||
? tryReadJsonSync<ChannelCatalogEntryLike>(sourcePackageJsonPath)
|
||||
: undefined) ??
|
||||
tryReadJsonSync<ChannelCatalogEntryLike>(
|
||||
path.join(pluginsDir, entry.name, "package.json"),
|
||||
);
|
||||
return parsed ? [parsed] : [];
|
||||
});
|
||||
bundledPackageCatalogCache.set(pluginsDir, entries);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasBundledChannelConfiguredState,
|
||||
listBundledChannelIdsWithConfiguredState,
|
||||
listBundledChannelIdsWithModuleConfiguredState,
|
||||
} from "./configured-state.js";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
@@ -34,6 +35,16 @@ describe("bundled channel configured-state metadata", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists configured-state channels whose owner module resolves config precedence", () => {
|
||||
expect(listBundledChannelIdsWithModuleConfiguredState()).toEqual([
|
||||
"feishu",
|
||||
"msteams",
|
||||
"nextcloud-talk",
|
||||
"slack",
|
||||
"sms",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves Discord, Slack, Telegram, and IRC env probes without full plugin loads", () => {
|
||||
expect(
|
||||
hasBundledChannelConfiguredState({
|
||||
@@ -46,9 +57,37 @@ describe("bundled channel configured-state metadata", () => {
|
||||
hasBundledChannelConfiguredState({
|
||||
channelId: "slack",
|
||||
cfg: {},
|
||||
env: { SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasBundledChannelConfiguredState({
|
||||
channelId: "slack",
|
||||
cfg: { channels: { slack: { identity: "user" } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_USER_TOKEN: "xoxp-test" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasBundledChannelConfiguredState({
|
||||
channelId: "slack",
|
||||
cfg: { channels: { slack: { postAs: "user" } } },
|
||||
env: { SLACK_APP_TOKEN: "xapp-test", SLACK_USER_TOKEN: "xoxp-test" },
|
||||
}),
|
||||
).toBe(true);
|
||||
for (const env of [
|
||||
{ SLACK_APP_TOKEN: "xapp-test" },
|
||||
{ SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
{ SLACK_USER_TOKEN: "xoxp-test" },
|
||||
{ SLACK_APP_TOKEN: "xapp-test", SLACK_USER_TOKEN: "xoxp-test" },
|
||||
]) {
|
||||
expect(
|
||||
hasBundledChannelConfiguredState({
|
||||
channelId: "slack",
|
||||
cfg: {},
|
||||
env,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
hasBundledChannelConfiguredState({
|
||||
channelId: "telegram",
|
||||
@@ -65,6 +104,154 @@ describe("bundled channel configured-state metadata", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a Slack bot token for relay transport even with user identity", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay" as const,
|
||||
postAs: "user" as const,
|
||||
relay: {
|
||||
url: "https://relay.example.com",
|
||||
authToken: "relay-token",
|
||||
gatewayId: "relay-gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
hasBundledChannelConfiguredState({
|
||||
channelId: "slack",
|
||||
cfg,
|
||||
env: { SLACK_USER_TOKEN: "xoxp-test" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasBundledChannelConfiguredState({
|
||||
channelId: "slack",
|
||||
cfg,
|
||||
env: { SLACK_USER_TOKEN: "xoxp-test", SLACK_BOT_TOKEN: "xoxb-test" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes outbound-ready SMS without requiring an inbound webhook or opt-out", () => {
|
||||
const requiredEnv = {
|
||||
TWILIO_ACCOUNT_SID: "AC-test",
|
||||
TWILIO_AUTH_TOKEN: "twilio-test-token",
|
||||
TWILIO_PHONE_NUMBER: "+15550001111",
|
||||
};
|
||||
|
||||
for (const { env, configured } of [
|
||||
{
|
||||
env: { ...requiredEnv, SMS_PUBLIC_WEBHOOK_URL: "https://sms.example.com/webhook" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
env: { ...requiredEnv, SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "true" },
|
||||
configured: true,
|
||||
},
|
||||
{ env: requiredEnv, configured: true },
|
||||
{
|
||||
env: { ...requiredEnv, SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: "false" },
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
env: { ...requiredEnv, SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION: " true " },
|
||||
configured: true,
|
||||
},
|
||||
]) {
|
||||
expect(hasBundledChannelConfiguredState({ channelId: "sms", cfg: {}, env })).toBe(configured);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "default client-secret authentication",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_APP_PASSWORD: "teams-secret",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a federated certificate",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_CERTIFICATE_PATH: "/teams.pem",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a whitespace-only federated certificate",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_CERTIFICATE_PATH: " ",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an enabled federated managed identity",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "true",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a certificate without federated mode",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_CERTIFICATE_PATH: "/teams.pem",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a managed identity without federated mode",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "true",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a disabled federated managed identity",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "false",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "federated mode without an authentication mechanism",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
])("checks Teams $label through its lightweight owner module", ({ env, configured }) => {
|
||||
expect(
|
||||
hasBundledChannelConfiguredState({
|
||||
channelId: "msteams",
|
||||
cfg: {},
|
||||
env,
|
||||
}),
|
||||
).toBe(configured);
|
||||
});
|
||||
|
||||
it("uses declarative env metadata without a TypeScript source require hook", () => {
|
||||
const previousTsHook = nodeRequire.extensions[".ts"];
|
||||
delete nodeRequire.extensions[".ts"];
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PluginDiscoveryResult } from "../../plugins/discovery.js";
|
||||
import {
|
||||
hasBundledChannelPackageState,
|
||||
listBundledChannelIdsForPackageState,
|
||||
listBundledChannelIdsForModulePackageState,
|
||||
} from "./package-state-probes.js";
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,13 @@ export function listBundledChannelIdsWithConfiguredState(
|
||||
return listBundledChannelIdsForPackageState("configuredState", discovery);
|
||||
}
|
||||
|
||||
/** Lists bundled channels whose configured-state decision reads canonical config. */
|
||||
export function listBundledChannelIdsWithModuleConfiguredState(
|
||||
discovery?: PluginDiscoveryResult,
|
||||
): string[] {
|
||||
return listBundledChannelIdsForModulePackageState("configuredState", discovery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a bundled channel reports configured state for the current config.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PluginChannelCatalogEntry } from "../../plugins/channel-catalog-re
|
||||
import {
|
||||
hasBundledChannelPackageState,
|
||||
listBundledChannelIdsForPackageState,
|
||||
listBundledChannelIdsForModulePackageState,
|
||||
} from "./package-state-probes.js";
|
||||
|
||||
const listChannelCatalogEntriesMock = vi.hoisted(() => vi.fn());
|
||||
@@ -134,6 +135,26 @@ describe("channel package-state probes", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("distinguishes module-backed package state from env metadata", () => {
|
||||
listChannelCatalogEntriesMock.mockReturnValue([
|
||||
makeBundledChannelCatalogEntry({ pluginId: "env-chat", channelId: "env-chat" }),
|
||||
{
|
||||
pluginId: "module-chat",
|
||||
origin: "bundled",
|
||||
rootDir: "/tmp/openclaw-channel-plugin",
|
||||
channel: {
|
||||
id: "module-chat",
|
||||
configuredState: {
|
||||
specifier: "./configured-state",
|
||||
exportName: "hasConfiguredModuleChatState",
|
||||
},
|
||||
},
|
||||
} satisfies PluginChannelCatalogEntry,
|
||||
]);
|
||||
|
||||
expect(listBundledChannelIdsForModulePackageState("configuredState")).toEqual(["module-chat"]);
|
||||
});
|
||||
|
||||
it("prefers built bundled package-state probes when the catalog root is source", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-state-probe-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -259,6 +259,21 @@ export function listBundledChannelIdsForPackageState(
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
/** Lists channel ids whose package state is computed by a manifest-owned module. */
|
||||
export function listBundledChannelIdsForModulePackageState(
|
||||
metadataKey: ChannelPackageStateMetadataKey,
|
||||
discovery?: PluginDiscoveryResult,
|
||||
): string[] {
|
||||
return listChannelPackageStateCatalog(metadataKey, discovery)
|
||||
.filter((entry) => {
|
||||
const metadata = resolveChannelPackageStateMetadata(entry, metadataKey);
|
||||
return Boolean(metadata?.specifier && metadata.exportName && !metadata.env);
|
||||
})
|
||||
.map((entry) => resolvePackageStateChannelId(entry))
|
||||
.filter((channelId): channelId is string => Boolean(channelId))
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a bundled channel reports configured/auth package state.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,8 +15,13 @@ 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("detects complete Slack socket env configuration through the package metadata seam", () => {
|
||||
expect(
|
||||
isChannelConfigured({}, "slack", {
|
||||
SLACK_APP_TOKEN: "xapp-test",
|
||||
SLACK_BOT_TOKEN: "xoxb-test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires both IRC host and nick env vars through the package metadata seam", () => {
|
||||
|
||||
@@ -365,6 +365,29 @@ describe("applyPluginAutoEnable core", () => {
|
||||
expect(result.changes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not auto-enable Teams when explicit blank credentials override ambient values", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "",
|
||||
tenantId: "",
|
||||
appPassword: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
env: makeIsolatedEnv({
|
||||
MSTEAMS_APP_ID: "ambient-app",
|
||||
MSTEAMS_TENANT_ID: "ambient-tenant",
|
||||
MSTEAMS_APP_PASSWORD: "ambient-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.config.channels?.msteams?.enabled).toBeUndefined();
|
||||
expect(result.config.plugins?.entries?.msteams).toBeUndefined();
|
||||
expect(result.changes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("stores auto-enable reasons in a null-prototype dictionary", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
hasBundledChannelConfiguredState,
|
||||
listBundledChannelIdsWithConfiguredState,
|
||||
listBundledChannelIdsWithModuleConfiguredState,
|
||||
} from "../channels/plugins/configured-state.js";
|
||||
import { findChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
@@ -299,15 +300,29 @@ function collectConfiguredChannelIds(
|
||||
ambientEnvTriggers: AmbientEnvTriggerPolicy = "allow",
|
||||
): string[] {
|
||||
const configuredStateChannelIds = new Set(listBundledChannelIdsWithConfiguredState(discovery));
|
||||
return listPotentialConfiguredChannelPresenceSignals(cfg, env, {
|
||||
const moduleConfiguredStateChannelIds = new Set(
|
||||
listBundledChannelIdsWithModuleConfiguredState(discovery),
|
||||
);
|
||||
const signals = listPotentialConfiguredChannelPresenceSignals(cfg, env, {
|
||||
includePersistedAuthState: false,
|
||||
discovery,
|
||||
ambientEnvTriggers,
|
||||
})
|
||||
.map((signal) => ({
|
||||
source: signal.source,
|
||||
channelId: normalizeChatChannelId(signal.channelId) ?? signal.channelId,
|
||||
}))
|
||||
}).map((signal) => ({
|
||||
source: signal.source,
|
||||
channelId: normalizeChatChannelId(signal.channelId) ?? signal.channelId,
|
||||
}));
|
||||
const rejectedModuleConfiguredStateChannelIds = new Set(
|
||||
signals
|
||||
.filter(
|
||||
({ channelId, source }) =>
|
||||
source === "env" &&
|
||||
moduleConfiguredStateChannelIds.has(channelId) &&
|
||||
!hasBundledChannelConfiguredState({ channelId, cfg, env, discovery }),
|
||||
)
|
||||
.map(({ channelId }) => channelId),
|
||||
);
|
||||
return signals
|
||||
.filter(({ channelId }) => !rejectedModuleConfiguredStateChannelIds.has(channelId))
|
||||
.filter(({ channelId, source }) =>
|
||||
isAutoEnableConfiguredChannelSignal({
|
||||
cfg,
|
||||
|
||||
@@ -514,7 +514,7 @@ describe("bundled plugin metadata", () => {
|
||||
|
||||
it("keeps bundled configured-state env metadata on channel package manifests", () => {
|
||||
const configuredChannels = listRepoBundledPluginMetadata()
|
||||
.filter((entry) => ["discord", "irc", "slack", "telegram"].includes(entry.dirName))
|
||||
.filter((entry) => ["discord", "irc", "telegram"].includes(entry.dirName))
|
||||
.map((entry) => ({
|
||||
dir: entry.dirName,
|
||||
configuredState: entry.packageManifest?.channel?.configuredState,
|
||||
@@ -536,14 +536,6 @@ describe("bundled plugin metadata", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
dir: "slack",
|
||||
configuredState: {
|
||||
env: {
|
||||
anyOf: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
dir: "telegram",
|
||||
configuredState: {
|
||||
@@ -555,6 +547,62 @@ describe("bundled plugin metadata", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps bundled channel env activation aligned with required credentials", () => {
|
||||
const configuredChannels = Object.fromEntries(
|
||||
listRepoBundledPluginMetadata()
|
||||
.filter((entry) => ["line", "synology-chat", "zalo"].includes(entry.dirName))
|
||||
.map((entry) => [entry.dirName, entry.packageManifest?.channel?.configuredState?.env]),
|
||||
);
|
||||
|
||||
expect(configuredChannels).toEqual({
|
||||
line: {
|
||||
allOf: ["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"],
|
||||
},
|
||||
"synology-chat": {
|
||||
allOf: ["SYNOLOGY_CHAT_TOKEN", "SYNOLOGY_CHAT_INCOMING_URL"],
|
||||
},
|
||||
zalo: {
|
||||
anyOf: ["ZALO_BOT_TOKEN"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps config-dependent channel activation on lightweight bundled owner surfaces", () => {
|
||||
for (const { channelId, exportName } of [
|
||||
{ channelId: "feishu", exportName: "hasConfiguredFeishuChannelState" },
|
||||
{
|
||||
channelId: "nextcloud-talk",
|
||||
exportName: "hasConfiguredNextcloudTalkChannelState",
|
||||
},
|
||||
{ channelId: "slack", exportName: "hasConfiguredSlackChannelState" },
|
||||
{ channelId: "sms", exportName: "hasConfiguredSmsChannelState" },
|
||||
]) {
|
||||
const entry = listRepoBundledPluginMetadata().find(
|
||||
(candidate) => candidate.dirName === channelId,
|
||||
);
|
||||
|
||||
expect(entry?.packageManifest?.channel?.configuredState).toEqual({
|
||||
specifier: "./configured-state",
|
||||
exportName,
|
||||
});
|
||||
expectArtifactPresence(entry?.publicSurfaceArtifacts, {
|
||||
contains: ["configured-state.js"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Teams auth-mode detection on its lightweight bundled owner surface", () => {
|
||||
const msteams = listRepoBundledPluginMetadata().find((entry) => entry.dirName === "msteams");
|
||||
|
||||
expect(msteams?.packageManifest?.channel?.configuredState).toEqual({
|
||||
specifier: "./configured-state",
|
||||
exportName: "hasConfiguredMSTeamsChannelState",
|
||||
});
|
||||
expectArtifactPresence(msteams?.publicSurfaceArtifacts, {
|
||||
contains: ["configured-state.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes test-only public surface artifacts", () => {
|
||||
listRepoBundledPluginMetadata().forEach((entry) =>
|
||||
expectTestOnlyArtifactsExcluded(entry.publicSurfaceArtifacts ?? []),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Tests channel plugin id resolution from config, manifests, and installed state. */
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
@@ -3370,6 +3371,320 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
).toContain("external-env-channel");
|
||||
});
|
||||
|
||||
it("does not let namespace env discovery bypass required package credentials", () => {
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "external-env-channel", source: "env" },
|
||||
]);
|
||||
|
||||
const config = {
|
||||
plugins: {
|
||||
allow: ["external-env-channel-plugin"],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
resolveConfiguredChannelPresencePolicy({
|
||||
config,
|
||||
workspaceDir: "/tmp",
|
||||
env: {
|
||||
EXTERNAL_ENV_CHANNEL_HOST: "irc.example.com",
|
||||
} as NodeJS.ProcessEnv,
|
||||
includePersistedAuthState: false,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
|
||||
expect(
|
||||
resolveConfiguredChannelPresencePolicy({
|
||||
config,
|
||||
workspaceDir: "/tmp",
|
||||
env: {
|
||||
EXTERNAL_ENV_CHANNEL_HOST: "irc.example.com",
|
||||
EXTERNAL_ENV_CHANNEL_NICK: "openclaw",
|
||||
} as NodeJS.ProcessEnv,
|
||||
includePersistedAuthState: false,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
channelId: "external-env-channel",
|
||||
sources: ["env", "manifest-env"],
|
||||
effective: true,
|
||||
pluginIds: ["external-env-channel-plugin"],
|
||||
blockedReasons: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "the first alternative",
|
||||
env: {
|
||||
EXTERNAL_ALTERNATIVE_ENV_CHANNEL_HOST: "chat.example.com",
|
||||
EXTERNAL_ALTERNATIVE_ENV_CHANNEL_BOT_TOKEN: "bot-token",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "the second alternative",
|
||||
env: {
|
||||
EXTERNAL_ALTERNATIVE_ENV_CHANNEL_HOST: "chat.example.com",
|
||||
EXTERNAL_ALTERNATIVE_ENV_CHANNEL_APP_TOKEN: "app-token",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a required credential without an alternative",
|
||||
env: {
|
||||
EXTERNAL_ALTERNATIVE_ENV_CHANNEL_HOST: "chat.example.com",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an alternative without the required credential",
|
||||
env: {
|
||||
EXTERNAL_ALTERNATIVE_ENV_CHANNEL_BOT_TOKEN: "bot-token",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
])("honors package env contracts with $label", ({ env, configured }) => {
|
||||
const channelId = "external-alternative-env-channel";
|
||||
const pluginId = "external-alternative-env-channel-plugin";
|
||||
const manifestRecords: PluginManifestRecord[] = [
|
||||
withManifestLoadPaths({
|
||||
id: pluginId,
|
||||
channels: [channelId],
|
||||
packageChannel: {
|
||||
id: channelId,
|
||||
configuredState: {
|
||||
env: {
|
||||
allOf: ["EXTERNAL_ALTERNATIVE_ENV_CHANNEL_HOST"],
|
||||
anyOf: [
|
||||
"EXTERNAL_ALTERNATIVE_ENV_CHANNEL_BOT_TOKEN",
|
||||
"EXTERNAL_ALTERNATIVE_ENV_CHANNEL_APP_TOKEN",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
origin: "config" as const,
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
}),
|
||||
];
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([{ channelId, source: "env" }]);
|
||||
|
||||
expect(
|
||||
resolveConfiguredChannelPresencePolicy({
|
||||
config: {
|
||||
plugins: { allow: [pluginId] },
|
||||
} as OpenClawConfig,
|
||||
workspaceDir: "/tmp",
|
||||
env,
|
||||
includePersistedAuthState: false,
|
||||
manifestRecords,
|
||||
}),
|
||||
).toStrictEqual(
|
||||
configured
|
||||
? [
|
||||
{
|
||||
channelId,
|
||||
sources: ["env", "manifest-env"],
|
||||
effective: true,
|
||||
pluginIds: [pluginId],
|
||||
blockedReasons: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "default client-secret credentials",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_APP_PASSWORD: "teams-secret",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a federated certificate",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_CERTIFICATE_PATH: "/teams.pem",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a whitespace-only federated certificate",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_CERTIFICATE_PATH: " ",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "an enabled federated managed identity",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "true",
|
||||
},
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
label: "a certificate without federated mode",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_CERTIFICATE_PATH: "/teams.pem",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a managed identity without federated mode",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "true",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "a disabled federated managed identity",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
MSTEAMS_USE_MANAGED_IDENTITY: "false",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
label: "federated mode without an authentication mechanism",
|
||||
env: {
|
||||
MSTEAMS_APP_ID: "teams-app",
|
||||
MSTEAMS_TENANT_ID: "teams-tenant",
|
||||
MSTEAMS_AUTH_TYPE: "federated",
|
||||
},
|
||||
configured: false,
|
||||
},
|
||||
])("routes Teams $label through its actual owner state probe", ({ env, configured }) => {
|
||||
const channelId = "msteams";
|
||||
const rootDir = path.resolve("extensions/msteams");
|
||||
const manifestRecords: PluginManifestRecord[] = [
|
||||
withManifestLoadPaths({
|
||||
id: channelId,
|
||||
channels: [channelId],
|
||||
packageChannel: {
|
||||
id: channelId,
|
||||
configuredState: {
|
||||
specifier: "./configured-state",
|
||||
exportName: "hasConfiguredMSTeamsChannelState",
|
||||
},
|
||||
},
|
||||
rootDir,
|
||||
source: path.join(rootDir, "index.ts"),
|
||||
origin: "bundled" as const,
|
||||
enabledByDefault: true,
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
}),
|
||||
];
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([{ channelId, source: "env" }]);
|
||||
|
||||
expect(
|
||||
resolveConfiguredChannelPresencePolicy({
|
||||
config: {
|
||||
plugins: { allow: [channelId] },
|
||||
} as OpenClawConfig,
|
||||
workspaceDir: "/tmp",
|
||||
env,
|
||||
includePersistedAuthState: false,
|
||||
manifestRecords,
|
||||
}),
|
||||
).toStrictEqual(
|
||||
configured
|
||||
? [
|
||||
{
|
||||
channelId,
|
||||
sources: ["env", "manifest-env"],
|
||||
effective: true,
|
||||
pluginIds: [channelId],
|
||||
blockedReasons: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
|
||||
it("retains explicit channel config 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",
|
||||
} as NodeJS.ProcessEnv,
|
||||
includePersistedAuthState: false,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
channelId: "external-env-channel",
|
||||
sources: ["explicit-config"],
|
||||
effective: true,
|
||||
pluginIds: ["external-env-channel-plugin"],
|
||||
blockedReasons: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains persisted channel auth when ambient credentials are incomplete", () => {
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "external-env-channel", source: "env" },
|
||||
{ channelId: "external-env-channel", source: "persisted-auth" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveConfiguredChannelPresencePolicy({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["external-env-channel-plugin"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
workspaceDir: "/tmp",
|
||||
env: {
|
||||
EXTERNAL_ENV_CHANNEL_HOST: "irc.example.com",
|
||||
} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
channelId: "external-env-channel",
|
||||
sources: ["persisted-auth"],
|
||||
effective: true,
|
||||
pluginIds: ["external-env-channel-plugin"],
|
||||
blockedReasons: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets explicit bundled channel config bypass restrictive allowlists", () => {
|
||||
const config = {
|
||||
channels: {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { isChannelConfigMetadataKey } from "../channels/config-metadata.js";
|
||||
import { hasMeaningfulChannelConfig } from "../channels/config-presence.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
/** True when config contains meaningful enabled channel settings. */
|
||||
export function hasExplicitChannelConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
channelId: string;
|
||||
}): boolean {
|
||||
const channels = params.config.channels;
|
||||
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
|
||||
return false;
|
||||
}
|
||||
const entry = (channels as Record<string, unknown>)[params.channelId];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return false;
|
||||
}
|
||||
const enabled = (entry as { enabled?: unknown }).enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return enabled === true || hasMeaningfulChannelConfig(entry);
|
||||
}
|
||||
|
||||
/** Lists explicitly configured channel ids, excluding global channel config keys. */
|
||||
export function listExplicitConfiguredChannelIdsForConfig(config: OpenClawConfig): string[] {
|
||||
const channels = config.channels;
|
||||
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(channels)
|
||||
.flatMap((rawChannelId) => {
|
||||
const channelId = rawChannelId.trim();
|
||||
return channelId &&
|
||||
!isChannelConfigMetadataKey(channelId) &&
|
||||
hasExplicitChannelConfig({ config, channelId: rawChannelId })
|
||||
? [channelId]
|
||||
: [];
|
||||
})
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
// Resolves channel presence policy advertised by plugin metadata.
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { isChannelConfigMetadataKey } from "../channels/config-metadata.js";
|
||||
import {
|
||||
hasMeaningfulChannelConfig,
|
||||
listExplicitlyDisabledChannelIdsForConfig,
|
||||
listPotentialConfiguredChannelPresenceSignals,
|
||||
type AmbientEnvTriggerPolicy,
|
||||
type ChannelPresenceSignalSource,
|
||||
} from "../channels/config-presence.js";
|
||||
import { hasBundledChannelConfiguredState } from "../channels/plugins/configured-state.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";
|
||||
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
|
||||
import {
|
||||
hasExplicitChannelConfig,
|
||||
listExplicitConfiguredChannelIdsForConfig,
|
||||
} from "./channel-presence-explicit-config.js";
|
||||
import {
|
||||
createPluginActivationSource,
|
||||
normalizePluginsConfig,
|
||||
resolveEffectivePluginActivationState,
|
||||
} from "./config-state.js";
|
||||
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
|
||||
import type { PluginDiscoveryResult } from "./discovery.js";
|
||||
import {
|
||||
hasExplicitManifestOwnerTrust,
|
||||
isActivatedManifestOwner,
|
||||
@@ -29,6 +33,8 @@ import {
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js";
|
||||
|
||||
export { hasExplicitChannelConfig, listExplicitConfiguredChannelIdsForConfig };
|
||||
|
||||
/** Source classes that can make a channel appear configured for read-only scopes. */
|
||||
export type ConfiguredChannelPresenceSource =
|
||||
| "explicit-config"
|
||||
@@ -82,44 +88,6 @@ function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/** True when config contains meaningful enabled channel settings. */
|
||||
export function hasExplicitChannelConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
channelId: string;
|
||||
}): boolean {
|
||||
const channels = params.config.channels;
|
||||
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
|
||||
return false;
|
||||
}
|
||||
const entry = (channels as Record<string, unknown>)[params.channelId];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return false;
|
||||
}
|
||||
const enabled = (entry as { enabled?: unknown }).enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return enabled === true || hasMeaningfulChannelConfig(entry);
|
||||
}
|
||||
|
||||
/** Lists explicitly configured channel ids, excluding global channel config keys. */
|
||||
export function listExplicitConfiguredChannelIdsForConfig(config: OpenClawConfig): string[] {
|
||||
const channels = config.channels;
|
||||
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(channels)
|
||||
.flatMap((rawChannelId) => {
|
||||
const channelId = rawChannelId.trim();
|
||||
return channelId &&
|
||||
!isChannelConfigMetadataKey(channelId) &&
|
||||
hasExplicitChannelConfig({ config, channelId: rawChannelId })
|
||||
? [channelId]
|
||||
: [];
|
||||
})
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function recordDeclaresChannel(record: PluginManifestRecord, channelId: string): boolean {
|
||||
const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? "";
|
||||
if (!normalizedChannelId) {
|
||||
@@ -136,7 +104,12 @@ 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 contractChannelIds = new Set<string>();
|
||||
const signals: Array<{ channelId: string; source: "manifest-env" }> = [];
|
||||
const seen = new Set<string>();
|
||||
const trustConfig = params.activationSourceConfig ?? params.config;
|
||||
@@ -153,16 +126,61 @@ function listManifestEnvConfiguredChannelSignals(params: {
|
||||
}
|
||||
for (const channelId of record.channels) {
|
||||
const packageChannel = record.packageChannel;
|
||||
const configuredStateEnv =
|
||||
normalizeOptionalLowercaseString(packageChannel?.id) ===
|
||||
normalizeOptionalLowercaseString(channelId)
|
||||
? packageChannel?.configuredState?.env
|
||||
: undefined;
|
||||
if (
|
||||
!packageChannel ||
|
||||
normalizeOptionalLowercaseString(packageChannel.id) !==
|
||||
normalizeOptionalLowercaseString(channelId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const configuredState = packageChannel.configuredState;
|
||||
const configuredStateEnv = configuredState?.env;
|
||||
const allOf = configuredStateEnv?.allOf ?? [];
|
||||
const anyOf = configuredStateEnv?.anyOf ?? [];
|
||||
const hasEnvContract = allOf.length > 0 || anyOf.length > 0;
|
||||
if (
|
||||
!hasEnvContract ||
|
||||
const hasBundledModuleContract = Boolean(
|
||||
isBundledManifestOwner(record) &&
|
||||
configuredState?.specifier?.trim() &&
|
||||
configuredState.exportName?.trim(),
|
||||
);
|
||||
if (!hasEnvContract && !hasBundledModuleContract) {
|
||||
continue;
|
||||
}
|
||||
const normalizedChannelId = normalizeOptionalLowercaseString(channelId);
|
||||
if (!normalizedChannelId) {
|
||||
continue;
|
||||
}
|
||||
contractChannelIds.add(normalizedChannelId);
|
||||
if (hasBundledModuleContract && !hasEnvContract) {
|
||||
if (!params.envSignalChannelIds.has(normalizedChannelId)) {
|
||||
continue;
|
||||
}
|
||||
// Probe the already-trusted owner record, not a newly discovered plugin tree;
|
||||
// stale build manifests must not replace the active credential contract.
|
||||
const discovery = {
|
||||
candidates: [
|
||||
{
|
||||
idHint: record.id,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
origin: record.origin,
|
||||
bundledManifestId: record.id,
|
||||
packageManifest: { channel: packageChannel },
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
} satisfies PluginDiscoveryResult;
|
||||
if (
|
||||
!hasBundledChannelConfiguredState({
|
||||
channelId,
|
||||
cfg: params.config,
|
||||
env: params.env,
|
||||
discovery,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} else if (
|
||||
!allOf.every((envVar) => hasNonEmptyEnvValue(params.env, envVar)) ||
|
||||
(anyOf.length > 0 && !anyOf.some((envVar) => hasNonEmptyEnvValue(params.env, envVar)))
|
||||
) {
|
||||
@@ -175,7 +193,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,25 +399,54 @@ export function resolveConfiguredChannelPresencePolicy(params: {
|
||||
|
||||
const disabledChannelIds = new Set(listExplicitlyDisabledChannelIdsForConfig(params.config));
|
||||
const entrySources = new Map<string, Set<ConfiguredChannelPresenceSource>>();
|
||||
const potentialPresenceSignals = listPotentialConfiguredChannelPresenceSignals(
|
||||
params.config,
|
||||
env,
|
||||
{
|
||||
includePersistedAuthState: params.includePersistedAuthState,
|
||||
ambientEnvTriggers: params.ambientEnvTriggers,
|
||||
},
|
||||
);
|
||||
const envSignalChannelIds = new Set(
|
||||
potentialPresenceSignals
|
||||
.filter((signal) => signal.source === "env")
|
||||
.map((signal) => normalizeOptionalLowercaseString(signal.channelId))
|
||||
.filter((channelId): channelId is string => Boolean(channelId)),
|
||||
);
|
||||
const manifestEnv =
|
||||
params.ambientEnvTriggers === "suppress"
|
||||
? undefined
|
||||
: listManifestEnvConfiguredChannelSignals({
|
||||
records,
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
env,
|
||||
envSignalChannelIds,
|
||||
});
|
||||
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,
|
||||
})) {
|
||||
for (const signal of potentialPresenceSignals) {
|
||||
if (signal.source === "config") {
|
||||
continue;
|
||||
}
|
||||
const normalizedChannelId = normalizeOptionalLowercaseString(signal.channelId);
|
||||
// Namespace discovery must not turn one partial credential into a configured channel.
|
||||
if (
|
||||
signal.source === "env" &&
|
||||
normalizedChannelId &&
|
||||
manifestEnv?.contractChannelIds.has(normalizedChannelId) &&
|
||||
!configuredManifestEnvChannelIds.has(normalizedChannelId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
addPolicySignal(entrySources, signal.channelId, signal.source);
|
||||
}
|
||||
if (params.ambientEnvTriggers !== "suppress") {
|
||||
for (const signal of listManifestEnvConfiguredChannelSignals({
|
||||
records,
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
env,
|
||||
})) {
|
||||
if (manifestEnv) {
|
||||
for (const signal of manifestEnv.signals) {
|
||||
addPolicySignal(entrySources, signal.channelId, signal.source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ describe("bundled plugin build entries", () => {
|
||||
expect(entries["extensions/slack/index"]).toBe("extensions/slack/index.ts");
|
||||
expect(entries["extensions/slack/setup-entry"]).toBe("extensions/slack/setup-entry.ts");
|
||||
expect(entries["extensions/msteams/index"]).toBe("extensions/msteams/index.ts");
|
||||
expect(entries["extensions/msteams/configured-state"]).toBe(
|
||||
"extensions/msteams/configured-state.ts",
|
||||
);
|
||||
expect(entries["extensions/clawrouter/index"]).toBe("extensions/clawrouter/index.ts");
|
||||
expect(entryKeys.findIndex((entry) => entry.startsWith("extensions/clickclack/"))).toBeLessThan(
|
||||
entryKeys.findIndex((entry) => entry.startsWith("extensions/slack/")),
|
||||
|
||||
Reference in New Issue
Block a user