feat(config): auto-enable configured plugins

This commit is contained in:
Peter Steinberger
2026-01-18 16:22:50 +00:00
parent be6a3d4caf
commit 32ae4566c6
9 changed files with 534 additions and 9 deletions

View File

@@ -19,15 +19,18 @@ import {
applySessionDefaults,
applyTalkApiKey,
} from "./defaults.js";
import { VERSION } from "../version.js";
import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import { validateConfigObject } from "./validation.js";
import { ClawdbotSchema } from "./zod-schema.js";
import { compareClawdbotVersions } from "./version.js";
// Re-export for backwards compatibility
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
@@ -146,6 +149,30 @@ function formatLegacyMigrationLog(changes: string[]): string {
return `Auto-migrated config:\n${changes.map((entry) => `- ${entry}`).join("\n")}`;
}
function stampConfigVersion(cfg: ClawdbotConfig): ClawdbotConfig {
const now = new Date().toISOString();
return {
...cfg,
meta: {
...cfg.meta,
lastTouchedVersion: VERSION,
lastTouchedAt: now,
},
};
}
function warnIfConfigFromFuture(cfg: ClawdbotConfig, logger: Pick<typeof console, "warn">): void {
const touched = cfg.meta?.lastTouchedVersion;
if (!touched) return;
const cmp = compareClawdbotVersions(VERSION, touched);
if (cmp === null) return;
if (cmp < 0) {
logger.warn(
`Config was last written by a newer Clawdbot (${touched}); current version is ${VERSION}.`,
);
}
}
function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void {
const envConfig = cfg.env;
if (!envConfig) return;
@@ -205,7 +232,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const writeConfigFileSync = (cfg: ClawdbotConfig) => {
const dir = path.dirname(configPath);
deps.fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
.trimEnd()
.concat("\n");
const tmp = path.join(
dir,
@@ -277,7 +306,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const substituted = resolveConfigEnvVars(resolved, deps.env);
const migrated = applyLegacyMigrations(substituted);
const resolvedConfig = migrated.next ?? substituted;
let resolvedConfig = migrated.next ?? substituted;
const autoEnable = applyPluginAutoEnable({
config: coerceConfig(resolvedConfig),
env: deps.env,
});
resolvedConfig = autoEnable.config;
const migrationChanges = [...migrated.changes, ...autoEnable.changes];
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {};
const validated = ClawdbotSchema.safeParse(resolvedConfig);
@@ -288,8 +323,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
return {};
}
if (migrated.next && migrated.changes.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrated.changes));
warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger);
if (migrationChanges.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrationChanges));
try {
writeConfigFileSync(resolvedConfig as ClawdbotConfig);
} catch (err) {
@@ -426,7 +462,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
const migrated = applyLegacyMigrations(substituted);
const resolvedConfigRaw = migrated.next ?? substituted;
let resolvedConfigRaw = migrated.next ?? substituted;
const autoEnable = applyPluginAutoEnable({
config: coerceConfig(resolvedConfigRaw),
env: deps.env,
});
resolvedConfigRaw = autoEnable.config;
const migrationChanges = [...migrated.changes, ...autoEnable.changes];
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
const validated = validateConfigObject(resolvedConfigRaw);
@@ -444,8 +486,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
};
}
if (migrated.next && migrated.changes.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrated.changes));
warnIfConfigFromFuture(validated.config, deps.logger);
if (migrationChanges.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrationChanges));
await writeConfigFile(validated.config).catch((err) => {
deps.logger.warn(`Failed to write migrated config at ${configPath}: ${String(err)}`);
});
@@ -486,7 +529,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
async function writeConfigFile(cfg: ClawdbotConfig) {
const dir = path.dirname(configPath);
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
.trimEnd()
.concat("\n");
const tmp = path.join(
dir,

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
describe("applyPluginAutoEnable", () => {
it("enables configured channel plugins and updates allowlist", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { allow: ["telegram"] },
},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["telegram", "slack"]);
expect(result.changes.join("\n")).toContain('Enabled plugin "slack"');
});
it("respects explicit disable", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { entries: { slack: { enabled: false } } },
},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
expect(result.changes).toEqual([]);
});
it("enables provider auth plugins when profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
auth: {
profiles: {
"google-antigravity:default": {
provider: "google-antigravity",
mode: "oauth",
},
},
},
},
});
expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true);
});
it("skips when plugins are globally disabled", () => {
const result = applyPluginAutoEnable({
config: {
channels: { slack: { botToken: "x" } },
plugins: { enabled: false },
},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
expect(result.changes).toEqual([]);
});
});

View File

@@ -0,0 +1,328 @@
import type { ClawdbotConfig } from "./config.js";
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
import { normalizeProviderId } from "../agents/model-selection.js";
type PluginEnableChange = {
pluginId: string;
reason: string;
};
export type PluginAutoEnableResult = {
config: ClawdbotConfig;
changes: string[];
};
const CHANNEL_PLUGIN_IDS = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"msteams",
"matrix",
"zalo",
"zalouser",
"bluebubbles",
] as const;
const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
{ pluginId: "google-antigravity-auth", providerId: "google-antigravity" },
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
{ pluginId: "copilot-proxy", providerId: "copilot-proxy" },
];
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function recordHasKeys(value: unknown): boolean {
return isRecord(value) && Object.keys(value).length > 0;
}
function accountsHaveKeys(
value: unknown,
keys: string[],
): boolean {
if (!isRecord(value)) return false;
for (const account of Object.values(value)) {
if (!isRecord(account)) continue;
for (const key of keys) {
if (hasNonEmptyString(account[key])) return true;
}
}
return false;
}
function resolveChannelConfig(cfg: ClawdbotConfig, channelId: string): Record<string, unknown> | null {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
return isRecord(entry) ? entry : null;
}
function isTelegramConfigured(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
if (hasNonEmptyString(env.TELEGRAM_BOT_TOKEN)) return true;
const entry = resolveChannelConfig(cfg, "telegram");
if (!entry) return false;
if (hasNonEmptyString(entry.botToken) || hasNonEmptyString(entry.tokenFile)) return true;
if (accountsHaveKeys(entry.accounts, ["botToken", "tokenFile"])) return true;
return recordHasKeys(entry);
}
function isDiscordConfigured(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
if (hasNonEmptyString(env.DISCORD_BOT_TOKEN)) return true;
const entry = resolveChannelConfig(cfg, "discord");
if (!entry) return false;
if (hasNonEmptyString(entry.token)) return true;
if (accountsHaveKeys(entry.accounts, ["token"])) return true;
return recordHasKeys(entry);
}
function isSlackConfigured(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
if (
hasNonEmptyString(env.SLACK_BOT_TOKEN) ||
hasNonEmptyString(env.SLACK_APP_TOKEN) ||
hasNonEmptyString(env.SLACK_USER_TOKEN)
) {
return true;
}
const entry = resolveChannelConfig(cfg, "slack");
if (!entry) return false;
if (
hasNonEmptyString(entry.botToken) ||
hasNonEmptyString(entry.appToken) ||
hasNonEmptyString(entry.userToken)
) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["botToken", "appToken", "userToken"])) return true;
return recordHasKeys(entry);
}
function isSignalConfigured(cfg: ClawdbotConfig): boolean {
const entry = resolveChannelConfig(cfg, "signal");
if (!entry) return false;
if (
hasNonEmptyString(entry.account) ||
hasNonEmptyString(entry.httpUrl) ||
hasNonEmptyString(entry.httpHost) ||
typeof entry.httpPort === "number" ||
hasNonEmptyString(entry.cliPath)
) {
return true;
}
if (accountsHaveKeys(entry.accounts, ["account", "httpUrl", "httpHost", "cliPath"])) return true;
return recordHasKeys(entry);
}
function isIMessageConfigured(cfg: ClawdbotConfig): boolean {
const entry = resolveChannelConfig(cfg, "imessage");
if (!entry) return false;
if (hasNonEmptyString(entry.cliPath)) return true;
return recordHasKeys(entry);
}
function isWhatsAppConfigured(cfg: ClawdbotConfig): boolean {
if (hasAnyWhatsAppAuth(cfg)) return true;
const entry = resolveChannelConfig(cfg, "whatsapp");
if (!entry) return false;
return recordHasKeys(entry);
}
function isGenericChannelConfigured(cfg: ClawdbotConfig, channelId: string): boolean {
const entry = resolveChannelConfig(cfg, channelId);
return recordHasKeys(entry);
}
export function isChannelConfigured(
cfg: ClawdbotConfig,
channelId: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
switch (channelId) {
case "whatsapp":
return isWhatsAppConfigured(cfg);
case "telegram":
return isTelegramConfigured(cfg, env);
case "discord":
return isDiscordConfigured(cfg, env);
case "slack":
return isSlackConfigured(cfg, env);
case "signal":
return isSignalConfigured(cfg);
case "imessage":
return isIMessageConfigured(cfg);
default:
return isGenericChannelConfigured(cfg, channelId);
}
}
function collectModelRefs(cfg: ClawdbotConfig): string[] {
const refs: string[] = [];
const pushModelRef = (value: unknown) => {
if (typeof value === "string" && value.trim()) refs.push(value.trim());
};
const collectFromAgent = (agent: Record<string, unknown> | null | undefined) => {
if (!agent) return;
const model = agent.model;
if (typeof model === "string") {
pushModelRef(model);
} else if (isRecord(model)) {
pushModelRef(model.primary);
const fallbacks = model.fallbacks;
if (Array.isArray(fallbacks)) {
for (const entry of fallbacks) pushModelRef(entry);
}
}
const models = agent.models;
if (isRecord(models)) {
for (const key of Object.keys(models)) {
pushModelRef(key);
}
}
};
const defaults = cfg.agents?.defaults as Record<string, unknown> | undefined;
collectFromAgent(defaults);
const list = cfg.agents?.list;
if (Array.isArray(list)) {
for (const entry of list) {
if (isRecord(entry)) collectFromAgent(entry);
}
}
return refs;
}
function extractProviderFromModelRef(value: string): string | null {
const trimmed = value.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0) return null;
return normalizeProviderId(trimmed.slice(0, slash));
}
function isProviderConfigured(cfg: ClawdbotConfig, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
const profiles = cfg.auth?.profiles;
if (profiles && typeof profiles === "object") {
for (const profile of Object.values(profiles)) {
if (!isRecord(profile)) continue;
const provider = normalizeProviderId(String(profile.provider ?? ""));
if (provider === normalized) return true;
}
}
const providerConfig = cfg.models?.providers;
if (providerConfig && typeof providerConfig === "object") {
for (const key of Object.keys(providerConfig)) {
if (normalizeProviderId(key) === normalized) return true;
}
}
const modelRefs = collectModelRefs(cfg);
for (const ref of modelRefs) {
const provider = extractProviderFromModelRef(ref);
if (provider && provider === normalized) return true;
}
return false;
}
function resolveConfiguredPlugins(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): PluginEnableChange[] {
const changes: PluginEnableChange[] = [];
for (const channelId of CHANNEL_PLUGIN_IDS) {
if (isChannelConfigured(cfg, channelId, env)) {
changes.push({
pluginId: channelId,
reason: `${channelId} configured`,
});
}
}
for (const mapping of PROVIDER_PLUGIN_IDS) {
if (isProviderConfigured(cfg, mapping.providerId)) {
changes.push({
pluginId: mapping.pluginId,
reason: `${mapping.providerId} auth configured`,
});
}
}
return changes;
}
function isPluginExplicitlyDisabled(cfg: ClawdbotConfig, pluginId: string): boolean {
const entry = cfg.plugins?.entries?.[pluginId];
return entry?.enabled === false;
}
function isPluginDenied(cfg: ClawdbotConfig, pluginId: string): boolean {
const deny = cfg.plugins?.deny;
return Array.isArray(deny) && deny.includes(pluginId);
}
function ensureAllowlisted(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const allow = cfg.plugins?.allow;
if (!Array.isArray(allow) || allow.includes(pluginId)) return cfg;
return {
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId],
},
};
}
function enablePluginEntry(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const entries = {
...cfg.plugins?.entries,
[pluginId]: {
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
enabled: true,
},
};
return {
...cfg,
plugins: {
...cfg.plugins,
entries,
...(cfg.plugins?.enabled === false ? { enabled: true } : {}),
},
};
}
export function applyPluginAutoEnable(params: {
config: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
}): PluginAutoEnableResult {
const env = params.env ?? process.env;
const configured = resolveConfiguredPlugins(params.config, env);
if (configured.length === 0) {
return { config: params.config, changes: [] };
}
let next = params.config;
const changes: string[] = [];
if (next.plugins?.enabled === false) {
return { config: next, changes };
}
for (const entry of configured) {
if (isPluginDenied(next, entry.pluginId)) continue;
if (isPluginExplicitlyDisabled(next, entry.pluginId)) continue;
const allow = next.plugins?.allow;
const allowMissing = Array.isArray(allow) && !allow.includes(entry.pluginId);
const alreadyEnabled = next.plugins?.entries?.[entry.pluginId]?.enabled === true;
if (alreadyEnabled && !allowMissing) continue;
next = enablePluginEntry(next, entry.pluginId);
next = ensureAllowlisted(next, entry.pluginId);
changes.push(`Enabled plugin "${entry.pluginId}" (${entry.reason}).`);
}
return { config: next, changes };
}

View File

@@ -97,6 +97,8 @@ const GROUP_ORDER: Record<string, number> = {
};
const FIELD_LABELS: Record<string, string> = {
"meta.lastTouchedVersion": "Config Last Touched Version",
"meta.lastTouchedAt": "Config Last Touched At",
"update.channel": "Update Channel",
"update.checkOnStart": "Update Check on Start",
"gateway.remote.url": "Remote Gateway URL",
@@ -296,6 +298,8 @@ const FIELD_LABELS: Record<string, string> = {
};
const FIELD_HELP: Record<string, string> = {
"meta.lastTouchedVersion": "Auto-set when Clawdbot writes the config.",
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
"update.channel": 'Update channel for npm installs ("stable" or "beta").',
"update.checkOnStart": "Check for npm updates when the gateway starts (default: true).",
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",

View File

@@ -24,6 +24,12 @@ import type { SkillsConfig } from "./types.skills.js";
import type { ToolsConfig } from "./types.tools.js";
export type ClawdbotConfig = {
meta?: {
/** Last clawdbot version that wrote this config. */
lastTouchedVersion?: string;
/** ISO timestamp when this config was last written. */
lastTouchedAt?: string;
};
auth?: AuthConfig;
env?: {
/** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */

32
src/config/version.ts Normal file
View File

@@ -0,0 +1,32 @@
export type ClawdbotVersion = {
major: number;
minor: number;
patch: number;
revision: number;
};
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+))?/;
export function parseClawdbotVersion(raw: string | null | undefined): ClawdbotVersion | null {
if (!raw) return null;
const match = raw.trim().match(VERSION_RE);
if (!match) return null;
const [, major, minor, patch, revision] = match;
return {
major: Number.parseInt(major, 10),
minor: Number.parseInt(minor, 10),
patch: Number.parseInt(patch, 10),
revision: revision ? Number.parseInt(revision, 10) : 0,
};
}
export function compareClawdbotVersions(a: string | null | undefined, b: string | null | undefined): number | null {
const parsedA = parseClawdbotVersion(a);
const parsedB = parseClawdbotVersion(b);
if (!parsedA || !parsedB) return null;
if (parsedA.major !== parsedB.major) return parsedA.major < parsedB.major ? -1 : 1;
if (parsedA.minor !== parsedB.minor) return parsedA.minor < parsedB.minor ? -1 : 1;
if (parsedA.patch !== parsedB.patch) return parsedA.patch < parsedB.patch ? -1 : 1;
if (parsedA.revision !== parsedB.revision) return parsedA.revision < parsedB.revision ? -1 : 1;
return 0;
}

View File

@@ -8,6 +8,12 @@ import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.sess
export const ClawdbotSchema = z
.object({
meta: z
.object({
lastTouchedVersion: z.string().optional(),
lastTouchedAt: z.string().optional(),
})
.optional(),
env: z
.object({
shellEnv: z

46
src/plugins/enable.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { ClawdbotConfig } from "../config/config.js";
export type PluginEnableResult = {
config: ClawdbotConfig;
enabled: boolean;
reason?: string;
};
function ensureAllowlisted(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig {
const allow = cfg.plugins?.allow;
if (!Array.isArray(allow) || allow.includes(pluginId)) return cfg;
return {
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId],
},
};
}
export function enablePluginInConfig(cfg: ClawdbotConfig, pluginId: string): PluginEnableResult {
if (cfg.plugins?.enabled === false) {
return { config: cfg, enabled: false, reason: "plugins disabled" };
}
if (cfg.plugins?.deny?.includes(pluginId)) {
return { config: cfg, enabled: false, reason: "blocked by denylist" };
}
const entries = {
...cfg.plugins?.entries,
[pluginId]: {
...(cfg.plugins?.entries?.[pluginId] as Record<string, unknown> | undefined),
enabled: true,
},
};
let next: ClawdbotConfig = {
...cfg,
plugins: {
...cfg.plugins,
...(cfg.plugins?.enabled === false ? { enabled: true } : {}),
entries,
},
};
next = ensureAllowlisted(next, pluginId);
return { config: next, enabled: true };
}

View File

@@ -46,7 +46,7 @@ const registryCache = new Map<string, PluginRegistry>();
const defaultLogger = () => createSubsystemLogger("plugins");
const BUNDLED_ENABLED_BY_DEFAULT = new Set(["telegram", "whatsapp", "discord", "slack", "signal"]);
const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];