feat: bundle provider auth plugins

Co-authored-by: ItzR3NO <ItzR3NO@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-17 09:33:56 +00:00
parent b6ea5895b6
commit a6deb0d9d5
31 changed files with 1485 additions and 542 deletions

View File

@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export function resolveBundledPluginsDir(): string | undefined {
const override = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR?.trim();
if (override) return override;
// bun --compile: ship a sibling `extensions/` next to the executable.
try {
const execDir = path.dirname(process.execPath);
const sibling = path.join(execDir, "extensions");
if (fs.existsSync(sibling)) return sibling;
} catch {
// ignore
}
// npm/dev: walk up from this module to find `extensions/` at the package root.
try {
let cursor = path.dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 6; i += 1) {
const candidate = path.join(cursor, "extensions");
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(cursor);
if (parent === cursor) break;
cursor = parent;
}
} catch {
// ignore
}
return undefined;
}

View File

@@ -15,7 +15,9 @@ function makeTempDir() {
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
const prev = process.env.CLAWDBOT_STATE_DIR;
const prevBundled = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
process.env.CLAWDBOT_STATE_DIR = stateDir;
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
vi.resetModules();
try {
return await fn();
@@ -25,6 +27,11 @@ async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
} else {
process.env.CLAWDBOT_STATE_DIR = prev;
}
if (prevBundled === undefined) {
delete process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
} else {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = prevBundled;
}
vi.resetModules();
}
}

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
@@ -271,29 +272,7 @@ export function discoverClawdbotPlugins(params: {
const candidates: PluginCandidate[] = [];
const diagnostics: PluginDiagnostic[] = [];
const seen = new Set<string>();
const globalDir = path.join(CONFIG_DIR, "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",
candidates,
diagnostics,
seen,
});
const workspaceDir = params.workspaceDir?.trim();
if (workspaceDir) {
const workspaceRoot = resolveUserPath(workspaceDir);
const workspaceExt = path.join(workspaceRoot, ".clawdbot", "extensions");
discoverInDirectory({
dir: workspaceExt,
origin: "workspace",
workspaceDir: workspaceRoot,
candidates,
diagnostics,
seen,
});
}
const extra = params.extraPaths ?? [];
for (const extraPath of extra) {
@@ -309,6 +288,38 @@ export function discoverClawdbotPlugins(params: {
seen,
});
}
if (workspaceDir) {
const workspaceRoot = resolveUserPath(workspaceDir);
const workspaceExt = path.join(workspaceRoot, ".clawdbot", "extensions");
discoverInDirectory({
dir: workspaceExt,
origin: "workspace",
workspaceDir: workspaceRoot,
candidates,
diagnostics,
seen,
});
}
const globalDir = path.join(CONFIG_DIR, "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",
candidates,
diagnostics,
seen,
});
const bundledDir = resolveBundledPluginsDir();
if (bundledDir) {
discoverInDirectory({
dir: bundledDir,
origin: "bundled",
candidates,
diagnostics,
seen,
});
}
return { candidates, diagnostics };
}

View File

@@ -9,6 +9,7 @@ import { loadClawdbotPlugins } from "./loader.js";
type TempPlugin = { dir: string; file: string; id: string };
const tempDirs: string[] = [];
const prevBundledDir = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`);
@@ -32,10 +33,49 @@ afterEach(() => {
// ignore cleanup failures
}
}
if (prevBundledDir === undefined) {
delete process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
} else {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = prevBundledDir;
}
});
describe("loadClawdbotPlugins", () => {
it("disables bundled plugins by default", () => {
const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "bundled.ts");
fs.writeFileSync(bundledPath, "export default function () {}", "utf-8");
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({
cache: false,
config: {
plugins: {
allow: ["bundled"],
},
},
});
const bundled = registry.plugins.find((entry) => entry.id === "bundled");
expect(bundled?.status).toBe("disabled");
const enabledRegistry = loadClawdbotPlugins({
cache: false,
config: {
plugins: {
allow: ["bundled"],
entries: {
bundled: { enabled: true },
},
},
},
});
const enabled = enabledRegistry.plugins.find((entry) => entry.id === "bundled");
expect(enabled?.status).toBe("loaded");
});
it("loads plugins from config paths", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "allowed",
body: `export default function (api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); }`,
@@ -52,12 +92,13 @@ describe("loadClawdbotPlugins", () => {
},
});
expect(registry.plugins.length).toBe(1);
expect(registry.plugins[0]?.status).toBe("loaded");
const loaded = registry.plugins.find((entry) => entry.id === "allowed");
expect(loaded?.status).toBe("loaded");
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping");
});
it("denylist disables plugins even if allowed", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "blocked",
body: `export default function () {}`,
@@ -75,10 +116,12 @@ describe("loadClawdbotPlugins", () => {
},
});
expect(registry.plugins[0]?.status).toBe("disabled");
const blocked = registry.plugins.find((entry) => entry.id === "blocked");
expect(blocked?.status).toBe("disabled");
});
it("fails fast on invalid plugin config", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "configurable",
body: `export default {\n id: "configurable",\n configSchema: {\n parse(value) {\n if (!value || typeof value !== "object" || Array.isArray(value)) {\n throw new Error("bad config");\n }\n return value;\n }\n },\n register() {}\n};`,
@@ -99,11 +142,13 @@ describe("loadClawdbotPlugins", () => {
},
});
expect(registry.plugins[0]?.status).toBe("error");
const configurable = registry.plugins.find((entry) => entry.id === "configurable");
expect(configurable?.status).toBe("error");
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
});
it("registers channel plugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "channel-demo",
body: `export default function (api) {
@@ -139,11 +184,12 @@ describe("loadClawdbotPlugins", () => {
},
});
expect(registry.channels.length).toBe(1);
expect(registry.channels[0]?.plugin.id).toBe("demo");
const channel = registry.channels.find((entry) => entry.plugin.id === "demo");
expect(channel).toBeDefined();
});
it("registers http handlers", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "http-demo",
body: `export default function (api) {
@@ -162,8 +208,9 @@ describe("loadClawdbotPlugins", () => {
},
});
expect(registry.httpHandlers.length).toBe(1);
expect(registry.httpHandlers[0]?.pluginId).toBe("http-demo");
expect(registry.plugins[0]?.httpHandlers).toBe(1);
const handler = registry.httpHandlers.find((entry) => entry.pluginId === "http-demo");
expect(handler).toBeDefined();
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo");
expect(httpPlugin?.httpHandlers).toBe(1);
});
});

View File

@@ -86,6 +86,7 @@ function buildCacheKey(params: {
function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
@@ -98,9 +99,15 @@ function resolveEnableState(
return { enabled: false, reason: "not in allowlist" };
}
const entry = config.entries[id];
if (entry?.enabled === true) {
return { enabled: true };
}
if (entry?.enabled === false) {
return { enabled: false, reason: "disabled in config" };
}
if (origin === "bundled") {
return { enabled: false, reason: "bundled (disabled by default)" };
}
return { enabled: true };
}
@@ -237,8 +244,29 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
interopDefault: true,
});
const seenIds = new Map<string, PluginRecord["origin"]>();
for (const candidate of discovery.candidates) {
const enableState = resolveEnableState(candidate.idHint, normalized);
const existingOrigin = seenIds.get(candidate.idHint);
if (existingOrigin) {
const record = createPluginRecord({
id: candidate.idHint,
name: candidate.packageName ?? candidate.idHint,
description: candidate.packageDescription,
version: candidate.packageVersion,
source: candidate.source,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: false,
configSchema: false,
});
record.status = "disabled";
record.error = `overridden by ${existingOrigin} plugin`;
registry.plugins.push(record);
continue;
}
const enableState = resolveEnableState(candidate.idHint, candidate.origin, normalized);
const entry = normalized.entries[candidate.idHint];
const record = createPluginRecord({
id: candidate.idHint,
@@ -256,6 +284,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "disabled";
record.error = enableState.reason;
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
continue;
}
@@ -266,6 +295,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
@@ -324,6 +354,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error";
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
@@ -337,6 +368,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error";
record.error = "plugin export missing register/activate";
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
@@ -362,10 +394,12 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
});
}
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
} catch (err) {
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,

View File

@@ -175,7 +175,7 @@ export type ClawdbotPluginApi = {
resolvePath: (input: string) => string;
};
export type PluginOrigin = "global" | "workspace" | "config";
export type PluginOrigin = "bundled" | "global" | "workspace" | "config";
export type PluginDiagnostic = {
level: "warn" | "error";