test: dedupe plugin runtime utility suites

This commit is contained in:
Peter Steinberger
2026-03-28 02:02:32 +00:00
parent 2926c25e10
commit 7d79134cee
21 changed files with 1087 additions and 927 deletions

View File

@@ -14,6 +14,15 @@ import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
describe("Claude bundle plugin inspect integration", () => {
let rootDir: string;
function expectLoadedClaudeManifest() {
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("expected Claude bundle manifest to load");
}
return result.manifest;
}
beforeAll(() => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-"));
@@ -113,13 +122,7 @@ describe("Claude bundle plugin inspect integration", () => {
});
it("loads the full Claude bundle manifest with all capabilities", () => {
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
const m = result.manifest;
const m = expectLoadedClaudeManifest();
expect(m.name).toBe("Test Claude Plugin");
expect(m.description).toBe("Integration test fixture for Claude bundle inspection");
expect(m.version).toBe("1.0.0");
@@ -127,57 +130,39 @@ describe("Claude bundle plugin inspect integration", () => {
});
it("resolves skills from skills, commands, and agents paths", () => {
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest.skills).toContain("skill-packs");
expect(result.manifest.skills).toContain("extra-commands");
const manifest = expectLoadedClaudeManifest();
expect(manifest.skills).toContain("skill-packs");
expect(manifest.skills).toContain("extra-commands");
// Agent and output style dirs are merged into skills so their .md files are discoverable
expect(result.manifest.skills).toContain("agents");
expect(result.manifest.skills).toContain("output-styles");
expect(manifest.skills).toContain("agents");
expect(manifest.skills).toContain("output-styles");
});
it("resolves hooks from default and declared paths", () => {
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
const manifest = expectLoadedClaudeManifest();
// Default hooks/hooks.json path + declared custom-hooks
expect(result.manifest.hooks).toContain("hooks/hooks.json");
expect(result.manifest.hooks).toContain("custom-hooks");
expect(manifest.hooks).toContain("hooks/hooks.json");
expect(manifest.hooks).toContain("custom-hooks");
});
it("detects settings files", () => {
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest.settingsFiles).toEqual(["settings.json"]);
expect(expectLoadedClaudeManifest().settingsFiles).toEqual(["settings.json"]);
});
it("detects all bundle capabilities", () => {
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
const caps = result.manifest.capabilities;
expect(caps).toContain("skills");
expect(caps).toContain("commands");
expect(caps).toContain("agents");
expect(caps).toContain("hooks");
expect(caps).toContain("mcpServers");
expect(caps).toContain("lspServers");
expect(caps).toContain("outputStyles");
expect(caps).toContain("settings");
const caps = expectLoadedClaudeManifest().capabilities;
expect(caps).toEqual(
expect.arrayContaining([
"skills",
"commands",
"agents",
"hooks",
"mcpServers",
"lspServers",
"outputStyles",
"settings",
]),
);
});
it("inspects MCP runtime support with supported and unsupported servers", () => {

View File

@@ -11,17 +11,27 @@ afterEach(async () => {
await tempHarness.cleanup();
});
async function withBundleHomeEnv<T>(
prefix: string,
run: (params: { homeDir: string; workspaceDir: string }) => Promise<T>,
): Promise<T> {
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
try {
const homeDir = await tempHarness.createTempDir(`${prefix}-home-`);
const workspaceDir = await tempHarness.createTempDir(`${prefix}-workspace-`);
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
return await run({ homeDir, workspaceDir });
} finally {
env.restore();
}
}
describe("loadEnabledClaudeBundleCommands", () => {
it("loads enabled Claude bundle markdown commands and skips disabled-model-invocation entries", async () => {
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
try {
const homeDir = await tempHarness.createTempDir("openclaw-bundle-commands-home-");
const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-commands-workspace-");
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
await withBundleHomeEnv("openclaw-bundle-commands", async ({ homeDir, workspaceDir }) => {
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "compound-bundle");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginRoot, "commands", "workflows"), { recursive: true });
@@ -87,8 +97,6 @@ describe("loadEnabledClaudeBundleCommands", () => {
]),
);
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
} finally {
env.restore();
}
});
});
});

View File

@@ -34,17 +34,27 @@ afterEach(async () => {
await tempHarness.cleanup();
});
async function withBundleHomeEnv<T>(
prefix: string,
run: (params: { homeDir: string; workspaceDir: string }) => Promise<T>,
): Promise<T> {
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
try {
const homeDir = await tempHarness.createTempDir(`${prefix}-home-`);
const workspaceDir = await tempHarness.createTempDir(`${prefix}-workspace-`);
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
return await run({ homeDir, workspaceDir });
} finally {
env.restore();
}
}
describe("loadEnabledBundleMcpConfig", () => {
it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => {
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
try {
const homeDir = await tempHarness.createTempDir("openclaw-bundle-mcp-home-");
const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-mcp-workspace-");
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
await withBundleHomeEnv("openclaw-bundle-mcp", async ({ homeDir, workspaceDir }) => {
const { pluginRoot, serverPath } = await createBundleProbePlugin(homeDir);
const config: OpenClawConfig = {
@@ -76,21 +86,11 @@ describe("loadEnabledBundleMcpConfig", () => {
normalizePathForAssertion(resolvedServerPath),
);
await expectResolvedPathEqual(loadedServer.cwd, resolvedPluginRoot);
} finally {
env.restore();
}
});
});
it("merges inline bundle MCP servers and skips disabled bundles", async () => {
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
try {
const homeDir = await tempHarness.createTempDir("openclaw-bundle-inline-home-");
const workspaceDir = await tempHarness.createTempDir("openclaw-bundle-inline-workspace-");
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
await withBundleHomeEnv("openclaw-bundle-inline", async ({ homeDir, workspaceDir }) => {
const enabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-enabled");
const disabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-disabled");
await fs.mkdir(path.join(enabledRoot, ".claude-plugin"), { recursive: true });
@@ -146,88 +146,77 @@ describe("loadEnabledBundleMcpConfig", () => {
expect(loaded.config.mcpServers.enabledProbe).toBeDefined();
expect(loaded.config.mcpServers.disabledProbe).toBeUndefined();
} finally {
env.restore();
}
});
});
it("resolves inline Claude MCP paths from the plugin root and expands CLAUDE_PLUGIN_ROOT", async () => {
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
try {
const homeDir = await tempHarness.createTempDir("openclaw-bundle-inline-placeholder-home-");
const workspaceDir = await tempHarness.createTempDir(
"openclaw-bundle-inline-placeholder-workspace-",
);
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "inline-claude");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(
{
name: "inline-claude",
mcpServers: {
inlineProbe: {
command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh",
args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"],
cwd: "${CLAUDE_PLUGIN_ROOT}",
env: {
PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}",
await withBundleHomeEnv(
"openclaw-bundle-inline-placeholder",
async ({ homeDir, workspaceDir }) => {
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "inline-claude");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(
{
name: "inline-claude",
mcpServers: {
inlineProbe: {
command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh",
args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"],
cwd: "${CLAUDE_PLUGIN_ROOT}",
env: {
PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}",
},
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
null,
2,
)}\n`,
"utf-8",
);
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: {
plugins: {
entries: {
"inline-claude": { enabled: true },
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: {
plugins: {
entries: {
"inline-claude": { enabled: true },
},
},
},
},
});
const loadedServer = loaded.config.mcpServers.inlineProbe;
const loadedArgs = getServerArgs(loadedServer);
const loadedCommand = isRecord(loadedServer) ? loadedServer.command : undefined;
const loadedCwd = isRecord(loadedServer) ? loadedServer.cwd : undefined;
const loadedEnv =
isRecord(loadedServer) && isRecord(loadedServer.env) ? loadedServer.env : {};
});
const loadedServer = loaded.config.mcpServers.inlineProbe;
const loadedArgs = getServerArgs(loadedServer);
const loadedCommand = isRecord(loadedServer) ? loadedServer.command : undefined;
const loadedCwd = isRecord(loadedServer) ? loadedServer.cwd : undefined;
const loadedEnv =
isRecord(loadedServer) && isRecord(loadedServer.env) ? loadedServer.env : {};
expect(loaded.diagnostics).toEqual([]);
await expectResolvedPathEqual(loadedCwd, pluginRoot);
expect(typeof loadedCommand).toBe("string");
expect(loadedArgs).toHaveLength(2);
expect(typeof loadedEnv.PLUGIN_ROOT).toBe("string");
if (typeof loadedCommand !== "string" || typeof loadedCwd !== "string") {
throw new Error("expected inline bundled MCP server to expose command and cwd");
}
expect(normalizePathForAssertion(path.relative(loadedCwd, loadedCommand))).toBe(
normalizePathForAssertion(path.join("bin", "server.sh")),
);
expect(
loadedArgs?.map((entry) =>
typeof entry === "string"
? normalizePathForAssertion(path.relative(loadedCwd, entry))
: entry,
),
).toEqual([
normalizePathForAssertion(path.join("servers", "probe.mjs")),
normalizePathForAssertion("local-probe.mjs"),
]);
await expectResolvedPathEqual(loadedEnv.PLUGIN_ROOT, pluginRoot);
} finally {
env.restore();
}
expect(loaded.diagnostics).toEqual([]);
await expectResolvedPathEqual(loadedCwd, pluginRoot);
expect(typeof loadedCommand).toBe("string");
expect(loadedArgs).toHaveLength(2);
expect(typeof loadedEnv.PLUGIN_ROOT).toBe("string");
if (typeof loadedCommand !== "string" || typeof loadedCwd !== "string") {
throw new Error("expected inline bundled MCP server to expose command and cwd");
}
expect(normalizePathForAssertion(path.relative(loadedCwd, loadedCommand))).toBe(
normalizePathForAssertion(path.join("bin", "server.sh")),
);
expect(
loadedArgs?.map((entry) =>
typeof entry === "string"
? normalizePathForAssertion(path.relative(loadedCwd, entry))
: entry,
),
).toEqual([
normalizePathForAssertion(path.join("servers", "probe.mjs")),
normalizePathForAssertion("local-probe.mjs"),
]);
await expectResolvedPathEqual(loadedEnv.PLUGIN_ROOT, pluginRoot);
},
);
});
});

View File

@@ -15,6 +15,63 @@ function makeRepoRoot(prefix: string): string {
return repoRoot;
}
function createOpenClawRoot(params: {
prefix: string;
hasExtensions?: boolean;
hasSrc?: boolean;
hasDistRuntimeExtensions?: boolean;
hasDistExtensions?: boolean;
hasGitCheckout?: boolean;
}) {
const repoRoot = makeRepoRoot(params.prefix);
if (params.hasExtensions) {
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
}
if (params.hasSrc) {
fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true });
}
if (params.hasDistRuntimeExtensions) {
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
}
if (params.hasDistExtensions) {
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
}
if (params.hasGitCheckout) {
fs.writeFileSync(path.join(repoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8");
}
fs.writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
return repoRoot;
}
function expectResolvedBundledDir(params: {
cwd: string;
expectedDir: string;
argv1?: string;
bundledDirOverride?: string;
vitest?: string;
}) {
vi.spyOn(process, "cwd").mockReturnValue(params.cwd);
process.argv[1] = params.argv1 ?? "/usr/bin/env";
if (params.vitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = params.vitest;
}
if (params.bundledDirOverride === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = params.bundledDirOverride;
}
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(params.expectedDir),
);
}
afterEach(() => {
vi.restoreAllMocks();
if (originalBundledDir === undefined) {
@@ -34,124 +91,94 @@ afterEach(() => {
});
describe("resolveBundledPluginsDir", () => {
it("prefers the staged runtime bundled plugin tree from the package root", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-dir-runtime-");
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
process.argv[1] = "/usr/bin/env";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "dist-runtime", "extensions")),
);
});
it("falls back to built dist/extensions in installed package roots", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-dir-dist-");
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
process.argv[1] = "/usr/bin/env";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "dist", "extensions")),
);
});
it("prefers source extensions under vitest to avoid stale staged plugins", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-dir-vitest-");
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
process.env.VITEST = "true";
process.argv[1] = "/usr/bin/env";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "extensions")),
);
});
it("prefers source extensions in a git checkout even without vitest env", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-dir-git-");
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(path.join(repoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8");
fs.writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
delete process.env.VITEST;
process.argv[1] = "/usr/bin/env";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "extensions")),
);
it.each([
[
"prefers the staged runtime bundled plugin tree from the package root",
{
prefix: "openclaw-bundled-dir-runtime-",
hasDistRuntimeExtensions: true,
hasDistExtensions: true,
},
{
expectedRelativeDir: path.join("dist-runtime", "extensions"),
},
],
[
"falls back to built dist/extensions in installed package roots",
{
prefix: "openclaw-bundled-dir-dist-",
hasDistExtensions: true,
},
{
expectedRelativeDir: path.join("dist", "extensions"),
},
],
[
"prefers source extensions under vitest to avoid stale staged plugins",
{
prefix: "openclaw-bundled-dir-vitest-",
hasExtensions: true,
hasDistRuntimeExtensions: true,
hasDistExtensions: true,
},
{
expectedRelativeDir: "extensions",
vitest: "true",
},
],
[
"prefers source extensions in a git checkout even without vitest env",
{
prefix: "openclaw-bundled-dir-git-",
hasExtensions: true,
hasSrc: true,
hasDistRuntimeExtensions: true,
hasDistExtensions: true,
hasGitCheckout: true,
},
{
expectedRelativeDir: "extensions",
},
],
] as const)("%s", (_name, layout, expectation) => {
const repoRoot = createOpenClawRoot(layout);
expectResolvedBundledDir({
cwd: repoRoot,
expectedDir: path.join(repoRoot, expectation.expectedRelativeDir),
vitest: "vitest" in expectation ? expectation.vitest : undefined,
});
});
it("prefers the running CLI package root over an unrelated cwd checkout", () => {
const installedRoot = makeRepoRoot("openclaw-bundled-dir-installed-");
fs.mkdirSync(path.join(installedRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(installedRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
const installedRoot = createOpenClawRoot({
prefix: "openclaw-bundled-dir-installed-",
hasDistExtensions: true,
});
const cwdRepoRoot = createOpenClawRoot({
prefix: "openclaw-bundled-dir-cwd-",
hasExtensions: true,
hasSrc: true,
hasGitCheckout: true,
});
const cwdRepoRoot = makeRepoRoot("openclaw-bundled-dir-cwd-");
fs.mkdirSync(path.join(cwdRepoRoot, "extensions"), { recursive: true });
fs.mkdirSync(path.join(cwdRepoRoot, "src"), { recursive: true });
fs.writeFileSync(path.join(cwdRepoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8");
fs.writeFileSync(
path.join(cwdRepoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
vi.spyOn(process, "cwd").mockReturnValue(cwdRepoRoot);
process.argv[1] = path.join(installedRoot, "openclaw.mjs");
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(installedRoot, "dist", "extensions")),
);
expectResolvedBundledDir({
cwd: cwdRepoRoot,
argv1: path.join(installedRoot, "openclaw.mjs"),
expectedDir: path.join(installedRoot, "dist", "extensions"),
});
});
it("falls back to the running installed package when the override path is stale", () => {
const installedRoot = makeRepoRoot("openclaw-bundled-dir-override-");
fs.mkdirSync(path.join(installedRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(installedRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
const installedRoot = createOpenClawRoot({
prefix: "openclaw-bundled-dir-override-",
hasDistExtensions: true,
});
process.argv[1] = path.join(installedRoot, "openclaw.mjs");
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(installedRoot, "missing-extensions");
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(installedRoot, "dist", "extensions")),
);
expectResolvedBundledDir({
cwd: process.cwd(),
argv1: path.join(installedRoot, "openclaw.mjs"),
bundledDirOverride: path.join(installedRoot, "missing-extensions"),
expectedDir: path.join(installedRoot, "dist", "extensions"),
});
});
});

View File

@@ -85,66 +85,75 @@ function resolveAllowedPackageNamesForId(pluginId: string): string[] {
return ALLOWED_PACKAGE_SUFFIXES.map((suffix) => `@openclaw/${pluginId}${suffix}`);
}
function expectNoBundledPluginNamingMismatches(params: {
message: string;
collectMismatches: (records: BundledPluginRecord[]) => string[];
}) {
const mismatches = params.collectMismatches(readBundledPluginRecords());
expect(mismatches, `${params.message}\nFound: ${mismatches.join(", ") || "<none>"}`).toEqual([]);
}
describe("bundled plugin naming guardrails", () => {
it("keeps bundled workspace package names anchored to the plugin id", () => {
const mismatches = readBundledPluginRecords()
.filter(
({ packageName, manifestId }) =>
!resolveAllowedPackageNamesForId(manifestId).includes(packageName),
)
.map(
({ dirName, packageName, manifestId }) => `${dirName}: ${packageName} (id=${manifestId})`,
);
expect(
mismatches,
`Bundled extension package names must stay anchored to the manifest id via @openclaw/<id> or an approved suffix (${ALLOWED_PACKAGE_SUFFIXES.join(", ")}). Update the plugin naming docs and this invariant before adding a new naming form.\nFound: ${mismatches.join(", ") || "<none>"}`,
).toEqual([]);
});
it("keeps bundled workspace directories aligned with the plugin id unless explicitly allowlisted", () => {
const mismatches = readBundledPluginRecords()
.filter(
({ dirName, manifestId }) => (DIR_ID_EXCEPTIONS.get(dirName) ?? dirName) !== manifestId,
)
.map(({ dirName, manifestId }) => `${dirName} -> ${manifestId}`);
expect(
mismatches,
`Bundled extension directory names should match openclaw.plugin.json:id. If a legacy exception is unavoidable, add it to DIR_ID_EXCEPTIONS with a comment.\nFound: ${mismatches.join(", ") || "<none>"}`,
).toEqual([]);
});
it("keeps bundled openclaw.install.npmSpec aligned with the package name", () => {
const mismatches = readBundledPluginRecords()
.filter(
({ installNpmSpec, packageName }) =>
typeof installNpmSpec === "string" && installNpmSpec !== packageName,
)
.map(
({ dirName, packageName, installNpmSpec }) =>
`${dirName}: package=${packageName}, npmSpec=${installNpmSpec}`,
);
expect(
mismatches,
`Bundled openclaw.install.npmSpec values must match the package name so install/update paths stay deterministic.\nFound: ${mismatches.join(", ") || "<none>"}`,
).toEqual([]);
});
it("keeps bundled channel ids aligned with the canonical plugin id", () => {
const mismatches = readBundledPluginRecords()
.filter(
({ channelId, manifestId }) => typeof channelId === "string" && channelId !== manifestId,
)
.map(
({ dirName, manifestId, channelId }) =>
`${dirName}: channel=${channelId}, id=${manifestId}`,
);
expect(
mismatches,
`Bundled openclaw.channel.id values must match openclaw.plugin.json:id for the owning plugin.\nFound: ${mismatches.join(", ") || "<none>"}`,
).toEqual([]);
it.each([
{
name: "keeps bundled workspace package names anchored to the plugin id",
message: `Bundled extension package names must stay anchored to the manifest id via @openclaw/<id> or an approved suffix (${ALLOWED_PACKAGE_SUFFIXES.join(", ")}). Update the plugin naming docs and this invariant before adding a new naming form.`,
collectMismatches: (records: BundledPluginRecord[]) =>
records
.filter(
({ packageName, manifestId }) =>
!resolveAllowedPackageNamesForId(manifestId).includes(packageName),
)
.map(
({ dirName, packageName, manifestId }) =>
`${dirName}: ${packageName} (id=${manifestId})`,
),
},
{
name: "keeps bundled workspace directories aligned with the plugin id unless explicitly allowlisted",
message:
"Bundled extension directory names should match openclaw.plugin.json:id. If a legacy exception is unavoidable, add it to DIR_ID_EXCEPTIONS with a comment.",
collectMismatches: (records: BundledPluginRecord[]) =>
records
.filter(
({ dirName, manifestId }) => (DIR_ID_EXCEPTIONS.get(dirName) ?? dirName) !== manifestId,
)
.map(({ dirName, manifestId }) => `${dirName} -> ${manifestId}`),
},
{
name: "keeps bundled openclaw.install.npmSpec aligned with the package name",
message:
"Bundled openclaw.install.npmSpec values must match the package name so install/update paths stay deterministic.",
collectMismatches: (records: BundledPluginRecord[]) =>
records
.filter(
({ installNpmSpec, packageName }) =>
typeof installNpmSpec === "string" && installNpmSpec !== packageName,
)
.map(
({ dirName, packageName, installNpmSpec }) =>
`${dirName}: package=${packageName}, npmSpec=${installNpmSpec}`,
),
},
{
name: "keeps bundled channel ids aligned with the canonical plugin id",
message:
"Bundled openclaw.channel.id values must match openclaw.plugin.json:id for the owning plugin.",
collectMismatches: (records: BundledPluginRecord[]) =>
records
.filter(
({ channelId, manifestId }) =>
typeof channelId === "string" && channelId !== manifestId,
)
.map(
({ dirName, manifestId, channelId }) =>
`${dirName}: channel=${channelId}, id=${manifestId}`,
),
},
] as const)("$name", ({ message, collectMismatches }) => {
expectNoBundledPluginNamingMismatches({
message,
collectMismatches,
});
});
});

View File

@@ -16,6 +16,31 @@ vi.mock("./manifest.js", () => ({
loadPluginManifest: (...args: unknown[]) => loadPluginManifestMock(...args),
}));
function setBundledDiscoveryCandidates(candidates: unknown[]) {
discoverOpenClawPluginsMock.mockReturnValue({
candidates,
diagnostics: [],
});
}
function expectBundledSourceLookup(
lookup: Parameters<typeof findBundledPluginSource>[0]["lookup"],
expected:
| {
pluginId: string;
localPath: string;
}
| undefined,
) {
const resolved = findBundledPluginSource({ lookup });
if (!expected) {
expect(resolved).toBeUndefined();
return;
}
expect(resolved?.pluginId).toBe(expected.pluginId);
expect(resolved?.localPath).toBe(expected.localPath);
}
describe("bundled plugin sources", () => {
beforeEach(() => {
discoverOpenClawPluginsMock.mockReset();
@@ -77,30 +102,50 @@ describe("bundled plugin sources", () => {
});
});
it("finds bundled source by npm spec", () => {
discoverOpenClawPluginsMock.mockReturnValue({
candidates: [
{
origin: "bundled",
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
],
diagnostics: [],
});
it.each([
[
"finds bundled source by npm spec",
{ kind: "npmSpec", value: "@openclaw/feishu" } as const,
{ pluginId: "feishu", localPath: "/app/extensions/feishu" },
],
[
"returns undefined for missing npm spec",
{ kind: "npmSpec", value: "@openclaw/not-found" } as const,
undefined,
],
[
"finds bundled source by plugin id",
{ kind: "pluginId", value: "diffs" } as const,
{ pluginId: "diffs", localPath: "/app/extensions/diffs" },
],
[
"returns undefined for missing plugin id",
{ kind: "pluginId", value: "not-found" } as const,
undefined,
],
] as const)("%s", (_name, lookup, expected) => {
setBundledDiscoveryCandidates([
{
origin: "bundled",
rootDir: "/app/extensions/feishu",
packageName: "@openclaw/feishu",
packageManifest: { install: { npmSpec: "@openclaw/feishu" } },
},
{
origin: "bundled",
rootDir: "/app/extensions/diffs",
packageName: "@openclaw/diffs",
packageManifest: { install: { npmSpec: "@openclaw/diffs" } },
},
]);
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } });
const resolved = findBundledPluginSource({
lookup: { kind: "npmSpec", value: "@openclaw/feishu" },
});
const missing = findBundledPluginSource({
lookup: { kind: "npmSpec", value: "@openclaw/not-found" },
});
expect(resolved?.pluginId).toBe("feishu");
expect(resolved?.localPath).toBe("/app/extensions/feishu");
expect(missing).toBeUndefined();
loadPluginManifestMock.mockImplementation((rootDir: string) => ({
ok: true,
manifest: {
id: rootDir === "/app/extensions/diffs" ? "diffs" : "feishu",
},
}));
expectBundledSourceLookup(lookup, expected);
});
it("forwards an explicit env to bundled discovery helpers", () => {
@@ -131,32 +176,6 @@ describe("bundled plugin sources", () => {
});
});
it("finds bundled source by plugin id", () => {
discoverOpenClawPluginsMock.mockReturnValue({
candidates: [
{
origin: "bundled",
rootDir: "/app/extensions/diffs",
packageName: "@openclaw/diffs",
packageManifest: { install: { npmSpec: "@openclaw/diffs" } },
},
],
diagnostics: [],
});
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "diffs" } });
const resolved = findBundledPluginSource({
lookup: { kind: "pluginId", value: "diffs" },
});
const missing = findBundledPluginSource({
lookup: { kind: "pluginId", value: "not-found" },
});
expect(resolved?.pluginId).toBe("diffs");
expect(resolved?.localPath).toBe("/app/extensions/diffs");
expect(missing).toBeUndefined();
});
it("reuses a pre-resolved bundled map for repeated lookups", () => {
const bundled = new Map([
[

View File

@@ -6,25 +6,36 @@ import {
} from "./bundled-web-search.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
function resolveManifestBundledWebSearchPluginIds() {
return loadPluginManifestRegistry({})
.plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
function resolveRegistryBundledWebSearchPluginIds() {
return listBundledWebSearchProviders()
.map(({ pluginId }) => pluginId)
.filter((value, index, values) => values.indexOf(value) === index)
.toSorted((left, right) => left.localeCompare(right));
}
describe("bundled web search metadata", () => {
it("keeps bundled web search compat ids aligned with bundled manifests", () => {
const bundledWebSearchPluginIds = loadPluginManifestRegistry({})
.plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
expect(resolveBundledWebSearchPluginIds({})).toEqual(bundledWebSearchPluginIds);
});
it("keeps bundled web search fast-path ids aligned with the registry", () => {
expect([...BUNDLED_WEB_SEARCH_PLUGIN_IDS]).toEqual(
listBundledWebSearchProviders()
.map(({ pluginId }) => pluginId)
.filter((value, index, values) => values.indexOf(value) === index)
.toSorted((left, right) => left.localeCompare(right)),
);
it.each([
[
"keeps bundled web search compat ids aligned with bundled manifests",
resolveBundledWebSearchPluginIds({}),
resolveManifestBundledWebSearchPluginIds(),
],
[
"keeps bundled web search fast-path ids aligned with the registry",
[...BUNDLED_WEB_SEARCH_PLUGIN_IDS],
resolveRegistryBundledWebSearchPluginIds(),
],
] as const)("%s", (_name, actual, expected) => {
expect(actual).toEqual(expected);
});
});

View File

@@ -38,6 +38,38 @@ vi.mock("./bundled-compat.js", () => ({
let resolvePluginCapabilityProviders: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders;
function expectBundledCompatLoadPath(params: {
cfg: OpenClawConfig;
allowlistCompat: { plugins: { allow: string[] } };
enablementCompat: {
plugins: {
allow: string[];
entries: { openai: { enabled: boolean } };
};
};
}) {
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
config: params.cfg,
env: process.env,
});
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({
config: params.cfg,
pluginIds: ["openai"],
});
expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({
config: params.allowlistCompat,
pluginIds: ["openai"],
});
expect(mocks.withBundledPluginVitestCompat).toHaveBeenCalledWith({
config: params.enablementCompat,
pluginIds: ["openai"],
env: process.env,
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith({
config: params.enablementCompat,
});
}
describe("resolvePluginCapabilityProviders", () => {
beforeEach(async () => {
vi.resetModules();
@@ -116,25 +148,10 @@ describe("resolvePluginCapabilityProviders", () => {
resolvePluginCapabilityProviders({ key, cfg });
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
config: cfg,
env: process.env,
});
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({
config: cfg,
pluginIds: ["openai"],
});
expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({
config: allowlistCompat,
pluginIds: ["openai"],
});
expect(mocks.withBundledPluginVitestCompat).toHaveBeenCalledWith({
config: enablementCompat,
pluginIds: ["openai"],
env: process.env,
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith({
config: enablementCompat,
expectBundledCompatLoadPath({
cfg,
allowlistCompat,
enablementCompat,
});
});
});

View File

@@ -14,6 +14,16 @@ vi.mock("./manifest-registry.js", () => ({
import { resolveGatewayStartupPluginIds } from "./channel-plugin-ids.js";
function expectStartupPluginIds(config: OpenClawConfig, expected: readonly string[]) {
expect(
resolveGatewayStartupPluginIds({
config,
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(expected);
}
describe("resolveGatewayStartupPluginIds", () => {
beforeEach(() => {
listPotentialConfiguredChannelIds.mockReset().mockReturnValue(["demo-channel"]);
@@ -64,67 +74,46 @@ describe("resolveGatewayStartupPluginIds", () => {
});
});
it("includes configured channels, explicit bundled sidecars, and enabled non-bundled sidecars", () => {
const config = {
plugins: {
entries: {
"demo-bundled-sidecar": { enabled: true },
},
},
agents: {
defaults: {
model: { primary: "demo-cli/demo-model" },
models: {
"demo-cli/demo-model": {},
it.each([
[
"includes configured channels, explicit bundled sidecars, and enabled non-bundled sidecars",
{
plugins: {
entries: {
"demo-bundled-sidecar": { enabled: true },
},
},
},
} as OpenClawConfig;
expect(
resolveGatewayStartupPluginIds({
config,
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual([
"demo-channel",
"demo-provider-plugin",
"demo-bundled-sidecar",
"demo-global-sidecar",
]);
});
it("does not pull default-on bundled non-channel plugins into startup", () => {
const config = {} as OpenClawConfig;
expect(
resolveGatewayStartupPluginIds({
config,
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["demo-channel", "demo-global-sidecar"]);
});
it("auto-loads bundled plugins referenced by configured provider ids", () => {
const config = {
models: {
providers: {
"demo-provider": {
baseUrl: "https://example.com",
models: [],
agents: {
defaults: {
model: { primary: "demo-cli/demo-model" },
models: {
"demo-cli/demo-model": {},
},
},
},
},
} as OpenClawConfig;
expect(
resolveGatewayStartupPluginIds({
config,
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["demo-channel", "demo-provider-plugin", "demo-global-sidecar"]);
} as OpenClawConfig,
["demo-channel", "demo-provider-plugin", "demo-bundled-sidecar", "demo-global-sidecar"],
],
[
"does not pull default-on bundled non-channel plugins into startup",
{} as OpenClawConfig,
["demo-channel", "demo-global-sidecar"],
],
[
"auto-loads bundled plugins referenced by configured provider ids",
{
models: {
providers: {
"demo-provider": {
baseUrl: "https://example.com",
models: [],
},
},
},
} as OpenClawConfig,
["demo-channel", "demo-provider-plugin", "demo-global-sidecar"],
],
] as const)("%s", (_name, config, expected) => {
expectStartupPluginIds(config, expected);
});
});

View File

@@ -14,6 +14,14 @@ vi.mock("./loader.js", () => ({
import { registerPluginCliCommands } from "./cli.js";
function createProgram(existingCommandName?: string) {
const program = new Command();
if (existingCommandName) {
program.command(existingCommandName);
}
return program;
}
describe("registerPluginCliCommands", () => {
beforeEach(() => {
mocks.memoryRegister.mockClear();
@@ -38,8 +46,7 @@ describe("registerPluginCliCommands", () => {
});
it("skips plugin CLI registrars when commands already exist", () => {
const program = new Command();
program.command("memory");
const program = createProgram("memory");
// oxlint-disable-next-line typescript/no-explicit-any
registerPluginCliCommands(program, {} as any);
@@ -49,7 +56,7 @@ describe("registerPluginCliCommands", () => {
});
it("forwards an explicit env to plugin loading", () => {
const program = new Command();
const program = createProgram();
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
registerPluginCliCommands(program, {} as OpenClawConfig, env);

View File

@@ -3,73 +3,92 @@ import type { OpenClawConfig } from "../config/config.js";
import { enablePluginInConfig } from "./enable.js";
describe("enablePluginInConfig", () => {
it("enables a plugin entry", () => {
const cfg: OpenClawConfig = {};
const result = enablePluginInConfig(cfg, "google");
expect(result.enabled).toBe(true);
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
});
it("adds plugin to allowlist when allowlist is configured", () => {
const cfg: OpenClawConfig = {
plugins: {
allow: ["memory-core"],
it.each([
{
name: "enables a plugin entry",
cfg: {} as OpenClawConfig,
pluginId: "google",
expectedEnabled: true,
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
},
};
const result = enablePluginInConfig(cfg, "google");
expect(result.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["memory-core", "google"]);
});
it("refuses enable when plugin is denylisted", () => {
const cfg: OpenClawConfig = {
plugins: {
deny: ["google"],
},
};
const result = enablePluginInConfig(cfg, "google");
expect(result.enabled).toBe(false);
expect(result.reason).toBe("blocked by denylist");
});
it("writes built-in channels to channels.<id>.enabled and plugins.entries", () => {
const cfg: OpenClawConfig = {};
const result = enablePluginInConfig(cfg, "telegram");
expect(result.enabled).toBe(true);
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
});
it("adds built-in channel id to allowlist when allowlist is configured", () => {
const cfg: OpenClawConfig = {
plugins: {
allow: ["memory-core"],
},
};
const result = enablePluginInConfig(cfg, "telegram");
expect(result.enabled).toBe(true);
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["memory-core", "telegram"]);
});
it("re-enables built-in channels after explicit plugin-level disable", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
enabled: true,
},
{
name: "adds plugin to allowlist when allowlist is configured",
cfg: {
plugins: {
allow: ["memory-core"],
},
} as OpenClawConfig,
pluginId: "google",
expectedEnabled: true,
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
expect(result.config.plugins?.allow).toEqual(["memory-core", "google"]);
},
plugins: {
entries: {
},
{
name: "refuses enable when plugin is denylisted",
cfg: {
plugins: {
deny: ["google"],
},
} as OpenClawConfig,
pluginId: "google",
expectedEnabled: false,
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
expect(result.reason).toBe("blocked by denylist");
},
},
{
name: "writes built-in channels to channels.<id>.enabled and plugins.entries",
cfg: {} as OpenClawConfig,
pluginId: "telegram",
expectedEnabled: true,
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
},
},
{
name: "adds built-in channel id to allowlist when allowlist is configured",
cfg: {
plugins: {
allow: ["memory-core"],
},
} as OpenClawConfig,
pluginId: "telegram",
expectedEnabled: true,
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.allow).toEqual(["memory-core", "telegram"]);
},
},
{
name: "re-enables built-in channels after explicit plugin-level disable",
cfg: {
channels: {
telegram: {
enabled: false,
enabled: true,
},
},
plugins: {
entries: {
telegram: {
enabled: false,
},
},
},
} as OpenClawConfig,
pluginId: "telegram",
expectedEnabled: true,
assert: (result: ReturnType<typeof enablePluginInConfig>) => {
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
},
};
const result = enablePluginInConfig(cfg, "telegram");
expect(result.enabled).toBe(true);
expect(result.config.channels?.telegram?.enabled).toBe(true);
expect(result.config.plugins?.entries?.telegram?.enabled).toBe(true);
},
])("$name", ({ cfg, pluginId, expectedEnabled, assert }) => {
const result = enablePluginInConfig(cfg, pluginId);
expect(result.enabled).toBe(expectedEnabled);
assert(result);
});
});

View File

@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { startLazyPluginServiceModule } from "./lazy-service-module.js";
function createAsyncHookMock() {
return vi.fn(async () => {});
}
describe("startLazyPluginServiceModule", () => {
afterEach(() => {
delete process.env.OPENCLAW_LAZY_SERVICE_SKIP;
@@ -8,8 +12,8 @@ describe("startLazyPluginServiceModule", () => {
});
it("starts the default module and returns its stop hook", async () => {
const start = vi.fn(async () => {});
const stop = vi.fn(async () => {});
const start = createAsyncHookMock();
const stop = createAsyncHookMock();
const handle = await startLazyPluginServiceModule({
loadDefaultModule: async () => ({
@@ -28,7 +32,7 @@ describe("startLazyPluginServiceModule", () => {
it("honors skip env before loading the module", async () => {
process.env.OPENCLAW_LAZY_SERVICE_SKIP = "1";
const loadDefaultModule = vi.fn(async () => ({ startDefault: vi.fn(async () => {}) }));
const loadDefaultModule = vi.fn(async () => ({ startDefault: createAsyncHookMock() }));
const handle = await startLazyPluginServiceModule({
skipEnvVar: "OPENCLAW_LAZY_SERVICE_SKIP",
@@ -42,12 +46,12 @@ describe("startLazyPluginServiceModule", () => {
it("uses the override module when configured", async () => {
process.env.OPENCLAW_LAZY_SERVICE_OVERRIDE = "virtual:service";
const start = vi.fn(async () => {});
const start = createAsyncHookMock();
const loadOverrideModule = vi.fn(async () => ({ startOverride: start }));
await startLazyPluginServiceModule({
overrideEnvVar: "OPENCLAW_LAZY_SERVICE_OVERRIDE",
loadDefaultModule: async () => ({ startDefault: vi.fn(async () => {}) }),
loadDefaultModule: async () => ({ startDefault: createAsyncHookMock() }),
loadOverrideModule,
startExportNames: ["startOverride", "startDefault"],
});

View File

@@ -61,39 +61,26 @@ describe("buildSingleProviderApiKeyCatalog", () => {
expect(result).toEqual({ provider: "z.ai", id: "glm-4.7" });
});
it("returns null when api key is missing", async () => {
const result = await buildSingleProviderApiKeyCatalog({
ctx: createCatalogContext({}),
providerId: "test-provider",
buildProvider: () => createProviderConfig(),
});
expect(result).toBeNull();
});
it("adds api key to the built provider", async () => {
const result = await buildSingleProviderApiKeyCatalog({
ctx: createCatalogContext({
it.each([
["returns null when api key is missing", createCatalogContext({}), undefined, null],
[
"adds api key to the built provider",
createCatalogContext({
apiKeys: { "test-provider": "secret-key" },
}),
providerId: "test-provider",
buildProvider: async () => createProviderConfig(),
});
expect(result).toEqual({
provider: {
api: "openai-completions",
baseUrl: "https://default.example/v1",
models: [],
apiKey: "secret-key",
undefined,
{
provider: {
api: "openai-completions",
baseUrl: "https://default.example/v1",
models: [],
apiKey: "secret-key",
},
},
});
});
it("prefers explicit base url when allowed", async () => {
const result = await buildSingleProviderApiKeyCatalog({
ctx: createCatalogContext({
],
[
"prefers explicit base url when allowed",
createCatalogContext({
apiKeys: { "test-provider": "secret-key" },
config: {
models: {
@@ -106,19 +93,25 @@ describe("buildSingleProviderApiKeyCatalog", () => {
},
},
}),
true,
{
provider: {
api: "openai-completions",
baseUrl: "https://override.example/v1/",
models: [],
apiKey: "secret-key",
},
},
],
] as const)("%s", async (_name, ctx, allowExplicitBaseUrl, expected) => {
const result = await buildSingleProviderApiKeyCatalog({
ctx,
providerId: "test-provider",
buildProvider: () => createProviderConfig(),
allowExplicitBaseUrl: true,
allowExplicitBaseUrl,
});
expect(result).toEqual({
provider: {
api: "openai-completions",
baseUrl: "https://override.example/v1/",
models: [],
apiKey: "secret-key",
},
});
expect(result).toEqual(expected);
});
it("matches explicit base url config across canonical provider aliases", async () => {

View File

@@ -34,54 +34,66 @@ function makeModelProviderConfig(overrides?: Partial<ModelProviderConfig>): Mode
}
describe("groupPluginDiscoveryProvidersByOrder", () => {
it("groups providers by declared order and sorts labels within each group", () => {
const grouped = groupPluginDiscoveryProvidersByOrder([
makeProvider({ id: "late-b", label: "Zulu" }),
makeProvider({ id: "late-a", label: "Alpha" }),
makeProvider({ id: "paired", label: "Paired", order: "paired" }),
makeProvider({ id: "profile", label: "Profile", order: "profile" }),
makeProvider({ id: "simple", label: "Simple", order: "simple" }),
]);
it.each([
{
name: "groups providers by declared order and sorts labels within each group",
providers: [
makeProvider({ id: "late-b", label: "Zulu" }),
makeProvider({ id: "late-a", label: "Alpha" }),
makeProvider({ id: "paired", label: "Paired", order: "paired" }),
makeProvider({ id: "profile", label: "Profile", order: "profile" }),
makeProvider({ id: "simple", label: "Simple", order: "simple" }),
],
expected: {
simple: ["simple"],
profile: ["profile"],
paired: ["paired"],
late: ["late-a", "late-b"],
},
},
{
name: "uses the legacy discovery hook when catalog is absent",
providers: [
makeProvider({ id: "legacy", label: "Legacy", order: "profile", mode: "discovery" }),
],
expected: {
simple: [],
profile: ["legacy"],
paired: [],
late: [],
},
},
] as const)("$name", ({ providers, expected }) => {
const grouped = groupPluginDiscoveryProvidersByOrder([...providers]);
expect(grouped.simple.map((provider) => provider.id)).toEqual(["simple"]);
expect(grouped.profile.map((provider) => provider.id)).toEqual(["profile"]);
expect(grouped.paired.map((provider) => provider.id)).toEqual(["paired"]);
expect(grouped.late.map((provider) => provider.id)).toEqual(["late-a", "late-b"]);
});
it("uses the legacy discovery hook when catalog is absent", () => {
const grouped = groupPluginDiscoveryProvidersByOrder([
makeProvider({ id: "legacy", label: "Legacy", order: "profile", mode: "discovery" }),
]);
expect(grouped.profile.map((provider) => provider.id)).toEqual(["legacy"]);
expect(grouped.simple.map((provider) => provider.id)).toEqual(expected.simple);
expect(grouped.profile.map((provider) => provider.id)).toEqual(expected.profile);
expect(grouped.paired.map((provider) => provider.id)).toEqual(expected.paired);
expect(grouped.late.map((provider) => provider.id)).toEqual(expected.late);
});
});
describe("normalizePluginDiscoveryResult", () => {
it("maps a single provider result to the plugin id", () => {
const provider = makeProvider({ id: "Ollama" });
const normalized = normalizePluginDiscoveryResult({
provider,
it.each([
{
name: "maps a single provider result to the plugin id",
provider: makeProvider({ id: "Ollama" }),
result: {
provider: makeModelProviderConfig({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
}),
},
});
expect(normalized).toEqual({
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
expected: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
});
});
it("normalizes keys for multi-provider discovery results", () => {
const normalized = normalizePluginDiscoveryResult({
},
{
name: "normalizes keys for multi-provider discovery results",
provider: makeProvider({ id: "ignored" }),
result: {
providers: {
@@ -89,14 +101,16 @@ describe("normalizePluginDiscoveryResult", () => {
"": makeModelProviderConfig({ baseUrl: "http://ignored" }),
},
},
});
expect(normalized).toEqual({
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",
models: [],
expected: {
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",
models: [],
},
},
});
},
] as const)("$name", ({ provider, result, expected }) => {
const normalized = normalizePluginDiscoveryResult({ provider, result });
expect(normalized).toEqual(expected);
});
});

View File

@@ -22,12 +22,9 @@ function makeProvider(overrides: Partial<ProviderPlugin>): ProviderPlugin {
}
describe("normalizeRegisteredProvider", () => {
it("drops invalid and duplicate auth methods, and clears bad wizard method bindings", () => {
const { diagnostics, pushDiagnostic } = collectDiagnostics();
const provider = normalizeRegisteredProvider({
pluginId: "demo-plugin",
source: "/tmp/demo/index.ts",
it.each([
{
name: "drops invalid and duplicate auth methods, and clears bad wizard method bindings",
provider: makeProvider({
id: " demo ",
label: " Demo Provider ",
@@ -68,66 +65,58 @@ describe("normalizeRegisteredProvider", () => {
},
},
}),
pushDiagnostic,
});
expect(provider).toMatchObject({
id: "demo",
label: "Demo Provider",
aliases: ["alias-one"],
deprecatedProfileIds: ["demo:legacy"],
envVars: ["DEMO_API_KEY"],
auth: [
{
id: "primary",
label: "Primary",
wizard: {
choiceId: "demo-primary",
modelAllowlist: {
allowedKeys: ["demo/model"],
initialSelections: ["demo/model"],
message: "Demo models",
expectedProvider: {
id: "demo",
label: "Demo Provider",
aliases: ["alias-one"],
deprecatedProfileIds: ["demo:legacy"],
envVars: ["DEMO_API_KEY"],
auth: [
{
id: "primary",
label: "Primary",
wizard: {
choiceId: "demo-primary",
modelAllowlist: {
allowedKeys: ["demo/model"],
initialSelections: ["demo/model"],
message: "Demo models",
},
},
},
],
wizard: {
setup: {
choiceId: "demo-choice",
},
modelPicker: {
label: "Demo models",
},
},
},
expectedDiagnostics: [
{
level: "error",
message: 'provider "demo" auth method duplicated id "primary"',
},
{
level: "error",
message: 'provider "demo" auth method missing id',
},
{
level: "warn",
message:
'provider "demo" setup method "missing" not found; falling back to available methods',
},
{
level: "warn",
message:
'provider "demo" model-picker method "missing" not found; falling back to available methods',
},
],
wizard: {
setup: {
choiceId: "demo-choice",
},
modelPicker: {
label: "Demo models",
},
},
});
expect(diagnostics.map((diag) => ({ level: diag.level, message: diag.message }))).toEqual([
{
level: "error",
message: 'provider "demo" auth method duplicated id "primary"',
},
{
level: "error",
message: 'provider "demo" auth method missing id',
},
{
level: "warn",
message:
'provider "demo" setup method "missing" not found; falling back to available methods',
},
{
level: "warn",
message:
'provider "demo" model-picker method "missing" not found; falling back to available methods',
},
]);
});
it("drops wizard metadata when a provider has no auth methods", () => {
const { diagnostics, pushDiagnostic } = collectDiagnostics();
const provider = normalizeRegisteredProvider({
pluginId: "demo-plugin",
source: "/tmp/demo/index.ts",
},
{
name: "drops wizard metadata when a provider has no auth methods",
provider: makeProvider({
wizard: {
setup: {
@@ -138,15 +127,39 @@ describe("normalizeRegisteredProvider", () => {
},
},
}),
pushDiagnostic,
});
assert: (
provider: ReturnType<typeof normalizeRegisteredProvider>,
diagnostics: PluginDiagnostic[],
) => {
expect(provider?.wizard).toBeUndefined();
expect(diagnostics.map((diag) => diag.message)).toEqual([
'provider "demo" setup metadata ignored because it has no auth methods',
'provider "demo" model-picker metadata ignored because it has no auth methods',
]);
},
},
] as const)(
"$name",
({ provider: inputProvider, expectedProvider, expectedDiagnostics, assert }) => {
const { diagnostics, pushDiagnostic } = collectDiagnostics();
const provider = normalizeRegisteredProvider({
pluginId: "demo-plugin",
source: "/tmp/demo/index.ts",
provider: inputProvider,
pushDiagnostic,
});
expect(provider?.wizard).toBeUndefined();
expect(diagnostics.map((diag) => diag.message)).toEqual([
'provider "demo" setup metadata ignored because it has no auth methods',
'provider "demo" model-picker metadata ignored because it has no auth methods',
]);
});
if (assert) {
assert(provider, diagnostics);
return;
}
expect(provider).toMatchObject(expectedProvider);
expect(diagnostics.map((diag) => ({ level: diag.level, message: diag.message }))).toEqual(
expectedDiagnostics,
);
},
);
it("prefers catalog when a provider registers both catalog and discovery", () => {
const { diagnostics, pushDiagnostic } = collectDiagnostics();

View File

@@ -14,6 +14,16 @@ vi.mock("./manifest-registry.js", () => ({
let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOwningPluginIdsForProvider;
let resolvePluginProviders: typeof import("./providers.runtime.js").resolvePluginProviders;
function getLastLoadPluginsCall(): Record<string, unknown> {
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
expect(call).toBeDefined();
return (call ?? {}) as Record<string, unknown>;
}
function cloneOptions<T>(value: T): T {
return structuredClone(value);
}
describe("resolvePluginProviders", () => {
beforeEach(async () => {
vi.resetModules();
@@ -56,33 +66,102 @@ describe("resolvePluginProviders", () => {
);
});
it("can augment restrictive allowlists for bundled provider compatibility", () => {
resolvePluginProviders({
config: {
plugins: {
allow: ["openrouter"],
it.each([
{
name: "can augment restrictive allowlists for bundled provider compatibility",
options: {
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledProviderAllowlistCompat: true,
},
bundledProviderAllowlistCompat: true,
});
expectedAllow: ["openrouter", "google", "kilocode", "moonshot"],
expectedEntries: {
google: { enabled: true },
kilocode: { enabled: true },
moonshot: { enabled: true },
},
},
{
name: "does not reintroduce the retired google auth plugin id into compat allowlists",
options: {
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledProviderAllowlistCompat: true,
},
expectedAllow: ["google"],
unexpectedAllow: ["google-gemini-cli-auth"],
},
{
name: "does not inject non-bundled provider plugin ids into compat allowlists",
options: {
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledProviderAllowlistCompat: true,
},
unexpectedAllow: ["workspace-provider"],
},
{
name: "scopes bundled provider compat expansion to the requested plugin ids",
options: {
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledProviderAllowlistCompat: true,
onlyPluginIds: ["moonshot"],
},
expectedAllow: ["openrouter", "moonshot"],
unexpectedAllow: ["google", "kilocode"],
expectedOnlyPluginIds: ["moonshot"],
},
] as const)(
"$name",
({ options, expectedAllow, expectedEntries, expectedOnlyPluginIds, unexpectedAllow }) => {
resolvePluginProviders(
cloneOptions(options) as unknown as Parameters<typeof resolvePluginProviders>[0],
);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]),
entries: expect.objectContaining({
google: { enabled: true },
kilocode: { enabled: true },
moonshot: { enabled: true },
}),
}),
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
cache: false,
activate: false,
...(expectedOnlyPluginIds ? { onlyPluginIds: expectedOnlyPluginIds } : {}),
}),
cache: false,
activate: false,
}),
);
});
);
const call = getLastLoadPluginsCall();
const config = call.config as
| {
plugins?: {
allow?: string[];
entries?: Record<string, { enabled?: boolean }>;
};
}
| undefined;
const allow = config?.plugins?.allow ?? [];
if (expectedAllow) {
expect(allow).toEqual(expect.arrayContaining([...expectedAllow]));
}
if (expectedEntries) {
expect(config?.plugins?.entries).toEqual(expect.objectContaining(expectedEntries));
}
for (const disallowedPluginId of unexpectedAllow ?? []) {
expect(allow).not.toContain(disallowedPluginId);
}
},
);
it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => {
resolvePluginProviders({
env: { VITEST: "1" } as NodeJS.ProcessEnv,
@@ -131,67 +210,6 @@ describe("resolvePluginProviders", () => {
}
});
it("does not reintroduce the retired google auth plugin id into compat allowlists", () => {
resolvePluginProviders({
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledProviderAllowlistCompat: true,
});
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
const allow = call?.config?.plugins?.allow;
expect(allow).toContain("google");
expect(allow).not.toContain("google-gemini-cli-auth");
});
it("does not inject non-bundled provider plugin ids into compat allowlists", () => {
resolvePluginProviders({
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledProviderAllowlistCompat: true,
});
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
const allow = call?.config?.plugins?.allow;
expect(allow).not.toContain("workspace-provider");
});
it("scopes bundled provider compat expansion to the requested plugin ids", () => {
resolvePluginProviders({
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledProviderAllowlistCompat: true,
onlyPluginIds: ["moonshot"],
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["moonshot"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining(["openrouter", "moonshot"]),
}),
}),
}),
);
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
const allow = call?.config?.plugins?.allow;
expect(allow).not.toContain("google");
expect(allow).not.toContain("kilocode");
});
it("loads only provider plugins on the provider runtime path", () => {
resolvePluginProviders({
bundledProviderAllowlistCompat: true,
@@ -203,7 +221,6 @@ describe("resolvePluginProviders", () => {
}),
);
});
it("maps provider ids to owning plugin ids via manifests", () => {
loadPluginManifestRegistryMock.mockReturnValue({
plugins: [

View File

@@ -23,11 +23,25 @@ describe("runtime live state guardrails", () => {
for (const [relativePath, guard] of Object.entries(LIVE_RUNTIME_STATE_GUARDS)) {
const source = readFileSync(resolve(repoRoot, relativePath), "utf8");
for (const required of guard.required) {
expect(source, `${relativePath} missing ${required}`).toContain(required);
}
for (const forbidden of guard.forbidden) {
expect(source, `${relativePath} must not contain ${forbidden}`).not.toContain(forbidden);
const assertions = [
...guard.required.map((needle) => ({
type: "required" as const,
needle,
message: `${relativePath} missing ${needle}`,
})),
...guard.forbidden.map((needle) => ({
type: "forbidden" as const,
needle,
message: `${relativePath} must not contain ${needle}`,
})),
];
for (const assertion of assertions) {
if (assertion.type === "required") {
expect(source, assertion.message).toContain(assertion.needle);
} else {
expect(source, assertion.message).not.toContain(assertion.needle);
}
}
}
});

View File

@@ -12,6 +12,13 @@ import {
setActivePluginRegistry,
} from "./runtime.js";
function createRegistryWithChannel(pluginId = "demo-channel") {
const registry = createEmptyPluginRegistry();
const plugin = { id: pluginId, meta: {} } as never;
registry.channels = [{ plugin }] as never;
return { registry, plugin };
}
describe("channel registry pinning", () => {
afterEach(() => {
resetPluginRuntimeStateForTest();
@@ -24,8 +31,7 @@ describe("channel registry pinning", () => {
});
it("preserves pinned channel registry across setActivePluginRegistry calls", () => {
const startup = createEmptyPluginRegistry();
startup.channels = [{ plugin: { id: "demo-channel" } }] as never;
const { registry: startup } = createRegistryWithChannel();
setActivePluginRegistry(startup);
pinActivePluginChannelRegistry(startup);
@@ -38,17 +44,13 @@ describe("channel registry pinning", () => {
});
it("re-pin invalidates cached channel lookups", () => {
const setup = createEmptyPluginRegistry();
const setupPlugin = { id: "demo-channel", meta: {} } as never;
setup.channels = [{ plugin: setupPlugin }] as never;
const { registry: setup, plugin: setupPlugin } = createRegistryWithChannel();
setActivePluginRegistry(setup);
pinActivePluginChannelRegistry(setup);
expect(getChannelPlugin("demo-channel")).toBe(setupPlugin);
const full = createEmptyPluginRegistry();
const fullPlugin = { id: "demo-channel", meta: {} } as never;
full.channels = [{ plugin: fullPlugin }] as never;
const { registry: full, plugin: fullPlugin } = createRegistryWithChannel();
setActivePluginRegistry(full);
expect(getChannelPlugin("demo-channel")).toBe(setupPlugin);
@@ -62,42 +64,47 @@ describe("channel registry pinning", () => {
expect(getChannelPlugin("demo-channel")).toBe(fullPlugin);
});
it("updates channel registry on swap when not pinned", () => {
const first = createEmptyPluginRegistry();
setActivePluginRegistry(first);
expect(getActivePluginChannelRegistry()).toBe(first);
const second = createEmptyPluginRegistry();
setActivePluginRegistry(second);
expect(getActivePluginChannelRegistry()).toBe(second);
});
it("release restores live-tracking behavior", () => {
it.each([
{
name: "updates channel registry on swap when not pinned",
pin: false,
releasePinnedRegistry: false,
expectDuringPin: false,
expectAfterSwap: "second",
},
{
name: "release restores live-tracking behavior",
pin: true,
releasePinnedRegistry: true,
expectDuringPin: true,
expectAfterSwap: "second",
},
{
name: "release is a no-op when the pinned registry does not match",
pin: true,
releasePinnedRegistry: false,
expectDuringPin: true,
expectAfterSwap: "first",
},
] as const)("$name", ({ pin, releasePinnedRegistry, expectDuringPin, expectAfterSwap }) => {
const startup = createEmptyPluginRegistry();
setActivePluginRegistry(startup);
pinActivePluginChannelRegistry(startup);
const replacement = createEmptyPluginRegistry();
setActivePluginRegistry(replacement);
expect(getActivePluginChannelRegistry()).toBe(startup);
releasePinnedPluginChannelRegistry(startup);
// After release, the channel registry should follow the active registry.
expect(getActivePluginChannelRegistry()).toBe(replacement);
});
it("release is a no-op when the pinned registry does not match", () => {
const startup = createEmptyPluginRegistry();
setActivePluginRegistry(startup);
pinActivePluginChannelRegistry(startup);
const unrelated = createEmptyPluginRegistry();
releasePinnedPluginChannelRegistry(unrelated);
// Pin is still held — unrelated release was ignored.
const replacement = createEmptyPluginRegistry();
if (pin) {
pinActivePluginChannelRegistry(startup);
}
setActivePluginRegistry(replacement);
expect(getActivePluginChannelRegistry()).toBe(startup);
expect(getActivePluginChannelRegistry()).toBe(expectDuringPin ? startup : replacement);
if (pin) {
releasePinnedPluginChannelRegistry(releasePinnedRegistry ? startup : unrelated);
}
expect(getActivePluginChannelRegistry()).toBe(
expectAfterSwap === "second" ? replacement : startup,
);
});
it("requireActivePluginChannelRegistry creates a registry when none exists", () => {

View File

@@ -11,6 +11,19 @@ import {
setActivePluginRegistry,
} from "./runtime.js";
function createRegistryWithRoute(path: string) {
const registry = createEmptyPluginRegistry();
registry.httpRoutes.push({
path,
auth: "plugin",
match: path === "/plugins/diffs" ? "prefix" : "exact",
handler: () => true,
pluginId: path === "/plugins/diffs" ? "diffs" : "demo",
source: "test",
});
return registry;
}
describe("plugin runtime route registry", () => {
afterEach(() => {
releasePinnedPluginHttpRouteRegistry();
@@ -51,47 +64,25 @@ describe("plugin runtime route registry", () => {
expect(getActivePluginHttpRouteRegistryVersion()).toBe(routeVersionBeforeRepin + 1);
});
it("falls back to the provided registry when the pinned route registry has no routes", () => {
const startupRegistry = createEmptyPluginRegistry();
const explicitRegistry = createEmptyPluginRegistry();
explicitRegistry.httpRoutes.push({
path: "/demo",
auth: "plugin",
match: "exact",
handler: () => true,
pluginId: "demo",
source: "test",
});
it.each([
{
name: "falls back to the provided registry when the pinned route registry has no routes",
pinnedRegistry: createEmptyPluginRegistry(),
explicitRegistry: createRegistryWithRoute("/demo"),
expected: "explicit",
},
{
name: "prefers the pinned route registry when it already owns routes",
pinnedRegistry: createRegistryWithRoute("/bluebubbles-webhook"),
explicitRegistry: createRegistryWithRoute("/plugins/diffs"),
expected: "pinned",
},
] as const)("$name", ({ pinnedRegistry, explicitRegistry, expected }) => {
setActivePluginRegistry(pinnedRegistry);
pinActivePluginHttpRouteRegistry(pinnedRegistry);
setActivePluginRegistry(startupRegistry);
pinActivePluginHttpRouteRegistry(startupRegistry);
expect(resolveActivePluginHttpRouteRegistry(explicitRegistry)).toBe(explicitRegistry);
});
it("prefers the pinned route registry when it already owns routes", () => {
const startupRegistry = createEmptyPluginRegistry();
const explicitRegistry = createEmptyPluginRegistry();
startupRegistry.httpRoutes.push({
path: "/bluebubbles-webhook",
auth: "plugin",
match: "exact",
handler: () => true,
pluginId: "bluebubbles",
source: "test",
});
explicitRegistry.httpRoutes.push({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
handler: () => true,
pluginId: "diffs",
source: "test",
});
setActivePluginRegistry(startupRegistry);
pinActivePluginHttpRouteRegistry(startupRegistry);
expect(resolveActivePluginHttpRouteRegistry(explicitRegistry)).toBe(startupRegistry);
expect(resolveActivePluginHttpRouteRegistry(explicitRegistry)).toBe(
expected === "pinned" ? pinnedRegistry : explicitRegistry,
);
});
});

View File

@@ -11,18 +11,22 @@ afterEach(() => {
});
describe("gateway request scope", () => {
async function importGatewayRequestScopeModule() {
return await import("./gateway-request-scope.js");
}
it("reuses AsyncLocalStorage across reloaded module instances", async () => {
const first = await import("./gateway-request-scope.js");
const first = await importGatewayRequestScopeModule();
await first.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => {
vi.resetModules();
const second = await import("./gateway-request-scope.js");
const second = await importGatewayRequestScopeModule();
expect(second.getPluginRuntimeGatewayRequestScope()).toEqual(TEST_SCOPE);
});
});
it("attaches plugin id to the active scope", async () => {
const runtimeScope = await import("./gateway-request-scope.js");
const runtimeScope = await importGatewayRequestScopeModule();
await runtimeScope.withPluginRuntimeGatewayRequestScope(TEST_SCOPE, async () => {
await runtimeScope.withPluginRuntimePluginIdScope("voice-call", async () => {

View File

@@ -11,42 +11,52 @@ import {
setGatewaySubagentRuntime,
} from "./index.js";
function createCommandResult() {
return {
pid: 12345,
stdout: "hello\n",
stderr: "",
code: 0,
signal: null,
killed: false,
noOutputTimedOut: false,
termination: "exit" as const,
};
}
describe("plugin runtime command execution", () => {
beforeEach(() => {
vi.restoreAllMocks();
clearGatewaySubagentRuntime();
});
it("exposes runtime.system.runCommandWithTimeout by default", async () => {
const commandResult = {
pid: 12345,
stdout: "hello\n",
stderr: "",
code: 0,
signal: null,
killed: false,
noOutputTimedOut: false,
termination: "exit" as const,
};
const runCommandWithTimeoutMock = vi
.spyOn(execModule, "runCommandWithTimeout")
.mockResolvedValue(commandResult);
it.each([
{
name: "exposes runtime.system.runCommandWithTimeout by default",
mockKind: "resolve" as const,
expected: "resolve" as const,
},
{
name: "forwards runtime.system.runCommandWithTimeout errors",
mockKind: "reject" as const,
expected: "reject" as const,
},
] as const)("$name", async ({ mockKind, expected }) => {
const commandResult = createCommandResult();
const runCommandWithTimeoutMock = vi.spyOn(execModule, "runCommandWithTimeout");
if (mockKind === "resolve") {
runCommandWithTimeoutMock.mockResolvedValue(commandResult);
} else {
runCommandWithTimeoutMock.mockRejectedValue(new Error("boom"));
}
const runtime = createPluginRuntime();
await expect(
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
).resolves.toEqual(commandResult);
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 });
});
it("forwards runtime.system.runCommandWithTimeout errors", async () => {
const runCommandWithTimeoutMock = vi
.spyOn(execModule, "runCommandWithTimeout")
.mockRejectedValue(new Error("boom"));
const runtime = createPluginRuntime();
await expect(
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
).rejects.toThrow("boom");
const command = runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 });
if (expected === "resolve") {
await expect(command).resolves.toEqual(commandResult);
} else {
await expect(command).rejects.toThrow("boom");
}
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 });
});
@@ -56,25 +66,56 @@ describe("plugin runtime command execution", () => {
expect(runtime.events.onSessionTranscriptUpdate).toBe(onSessionTranscriptUpdate);
});
it("exposes runtime.mediaUnderstanding helpers and keeps stt as an alias", () => {
it.each([
{
name: "exposes runtime.mediaUnderstanding helpers and keeps stt as an alias",
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
expect(typeof runtime.mediaUnderstanding.runFile).toBe("function");
expect(typeof runtime.mediaUnderstanding.describeImageFile).toBe("function");
expect(typeof runtime.mediaUnderstanding.describeImageFileWithModel).toBe("function");
expect(typeof runtime.mediaUnderstanding.describeVideoFile).toBe("function");
expect(runtime.mediaUnderstanding.transcribeAudioFile).toBe(
runtime.stt.transcribeAudioFile,
);
},
},
{
name: "exposes runtime.imageGeneration helpers",
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
expect(typeof runtime.imageGeneration.generate).toBe("function");
expect(typeof runtime.imageGeneration.listProviders).toBe("function");
},
},
{
name: "exposes runtime.webSearch helpers",
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
expect(typeof runtime.webSearch.listProviders).toBe("function");
expect(typeof runtime.webSearch.search).toBe("function");
},
},
{
name: "exposes runtime.agent host helpers",
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
expect(runtime.agent.defaults).toEqual({
model: DEFAULT_MODEL,
provider: DEFAULT_PROVIDER,
});
expect(typeof runtime.agent.runEmbeddedPiAgent).toBe("function");
expect(typeof runtime.agent.resolveAgentDir).toBe("function");
expect(typeof runtime.agent.session.resolveSessionFilePath).toBe("function");
},
},
{
name: "exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider",
assert: (runtime: ReturnType<typeof createPluginRuntime>) => {
expect(runtime.modelAuth).toBeDefined();
expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function");
expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function");
},
},
] as const)("$name", ({ assert }) => {
const runtime = createPluginRuntime();
expect(typeof runtime.mediaUnderstanding.runFile).toBe("function");
expect(typeof runtime.mediaUnderstanding.describeImageFile).toBe("function");
expect(typeof runtime.mediaUnderstanding.describeImageFileWithModel).toBe("function");
expect(typeof runtime.mediaUnderstanding.describeVideoFile).toBe("function");
expect(runtime.mediaUnderstanding.transcribeAudioFile).toBe(runtime.stt.transcribeAudioFile);
});
it("exposes runtime.imageGeneration helpers", () => {
const runtime = createPluginRuntime();
expect(typeof runtime.imageGeneration.generate).toBe("function");
expect(typeof runtime.imageGeneration.listProviders).toBe("function");
});
it("exposes runtime.webSearch helpers", () => {
const runtime = createPluginRuntime();
expect(typeof runtime.webSearch.listProviders).toBe("function");
expect(typeof runtime.webSearch.search).toBe("function");
assert(runtime);
});
it("exposes runtime.system.requestHeartbeatNow", () => {
@@ -82,24 +123,6 @@ describe("plugin runtime command execution", () => {
expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow);
});
it("exposes runtime.agent host helpers", () => {
const runtime = createPluginRuntime();
expect(runtime.agent.defaults).toEqual({
model: DEFAULT_MODEL,
provider: DEFAULT_PROVIDER,
});
expect(typeof runtime.agent.runEmbeddedPiAgent).toBe("function");
expect(typeof runtime.agent.resolveAgentDir).toBe("function");
expect(typeof runtime.agent.session.resolveSessionFilePath).toBe("function");
});
it("exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider", () => {
const runtime = createPluginRuntime();
expect(runtime.modelAuth).toBeDefined();
expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function");
expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function");
});
it("modelAuth wrappers strip agentDir and store to prevent credential steering", async () => {
// The wrappers should not forward agentDir or store from plugin callers.
// We verify this by checking the wrapper functions exist and are not the