362 lines
14 KiB
JavaScript
362 lines
14 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { ToolRegistry } = require("../backend/tool_router");
|
|
const { registerManagedTool } = require("../backend/tool_registry");
|
|
const { ToolRepoClient, CACHE_TTL_MS } = require("../backend/tool_repo_client");
|
|
const { ToolInstaller, validateToolDirectory } = require("../backend/tool_installer");
|
|
const { ToolLoader } = require("../backend/tool_loader");
|
|
const { ToolManager } = require("../backend/tool_manager");
|
|
const { ToolSettings } = require("../backend/tool_settings");
|
|
const { isDestructivePath, issueConfirmation, consumeConfirmation } = require("../../../src/services/destructive-confirm");
|
|
|
|
async function run() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-ai-tools-"));
|
|
const pluginsDir = path.join(root, "plugins");
|
|
const stagingRoot = path.join(root, "manager-data", "staging");
|
|
const stateFile = path.join(root, "manager-data", "enabled.json");
|
|
const remoteDir = path.join(root, "remote");
|
|
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
fs.mkdirSync(remoteDir, { recursive: true });
|
|
|
|
const remoteTools = new Map();
|
|
createTool(path.join(remoteDir, "lumi_ai_weather"), metadata("lumi_ai_weather", "1.0.0"), backendSource());
|
|
remoteTools.set("lumi_ai_weather", metadata("lumi_ai_weather", "1.0.0"));
|
|
const repoClient = {
|
|
calls: 0,
|
|
fail: false,
|
|
async discover() {
|
|
return {
|
|
repository: "https://git.example/owner/lumi",
|
|
branch: "main",
|
|
checked_at: new Date().toISOString(),
|
|
cached: false,
|
|
stale: false,
|
|
tools: [...remoteTools.values()]
|
|
};
|
|
},
|
|
async downloadTool(toolId, destination) {
|
|
this.calls += 1;
|
|
if (this.fail) {
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
fs.writeFileSync(path.join(destination, "tool_info.json"), "{}");
|
|
return;
|
|
}
|
|
copyDirectory(path.join(remoteDir, toolId), destination);
|
|
},
|
|
async readReadme() {
|
|
return "# Weather\nRemote documentation.";
|
|
}
|
|
};
|
|
|
|
const registry = new ToolRegistry(() => {});
|
|
const installer = new ToolInstaller({ repoClient, pluginsDir, stagingRoot });
|
|
const settings = { getSetting: (key, fallback) => key === "platform_discord_enabled" ? true : fallback };
|
|
const loader = new ToolLoader({
|
|
registry,
|
|
installer,
|
|
settings,
|
|
stateFile,
|
|
lumiAiVersion: "0.7.1",
|
|
lumiVersion: "0.1.0"
|
|
});
|
|
const toolSettings = new ToolSettings({ installer });
|
|
const manager = new ToolManager({ repoClient, installer, loader, settings: toolSettings });
|
|
|
|
let listing = await manager.list();
|
|
assert.equal(listing.tools.length, 1);
|
|
assert.equal(listing.tools[0].installed, false);
|
|
assert.equal(listing.tools[0].update_enabled, false);
|
|
|
|
const enabled = await manager.enable("lumi_ai_weather");
|
|
assert.equal(enabled.loaded, true);
|
|
assert.equal(registry.tools.has("lumi_ai_weather.lookup"), true);
|
|
listing = await manager.list();
|
|
assert.equal(listing.tools[0].installed, true);
|
|
assert.equal(listing.tools[0].enabled, true);
|
|
|
|
await manager.disable("lumi_ai_weather");
|
|
assert.equal(registry.tools.has("lumi_ai_weather.lookup"), false);
|
|
assert.equal(fs.existsSync(path.join(pluginsDir, "lumi_ai_weather")), true);
|
|
listing = await manager.list();
|
|
assert.equal(listing.tools[0].enabled, false);
|
|
|
|
fs.mkdirSync(path.join(pluginsDir, "lumi_ai_weather", "data"), { recursive: true });
|
|
fs.writeFileSync(path.join(pluginsDir, "lumi_ai_weather", "data", "local.json"), "preserve");
|
|
createTool(path.join(remoteDir, "lumi_ai_weather"), metadata("lumi_ai_weather", "1.1.0"), backendSource("updated"));
|
|
remoteTools.set("lumi_ai_weather", metadata("lumi_ai_weather", "1.1.0"));
|
|
listing = await manager.list();
|
|
assert.equal(listing.tools[0].update_available, true);
|
|
await manager.update("lumi_ai_weather");
|
|
assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0");
|
|
assert.equal(fs.readFileSync(path.join(pluginsDir, "lumi_ai_weather", "data", "local.json"), "utf8"), "preserve");
|
|
|
|
repoClient.fail = true;
|
|
await assert.rejects(() => manager.update("lumi_ai_weather"), /required|missing/i);
|
|
assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0");
|
|
repoClient.fail = false;
|
|
|
|
createTool(path.join(pluginsDir, "lumi_ai_local"), metadata("lumi_ai_local", "2.0.0"), backendSource());
|
|
listing = await manager.list();
|
|
const localOnly = listing.tools.find((tool) => tool.tool_id === "lumi_ai_local");
|
|
assert.equal(localOnly.remote_missing, true);
|
|
assert.equal(localOnly.update_enabled, false);
|
|
|
|
createTool(
|
|
path.join(pluginsDir, "lumi_ai_optional"),
|
|
{ ...metadata("lumi_ai_optional", "1.0.0"), dependencies: ["module-that-does-not-exist"] },
|
|
backendSource("ok", "lumi_ai_optional")
|
|
);
|
|
const optionalResult = await loader.enable("lumi_ai_optional");
|
|
assert.equal(optionalResult.loaded, true);
|
|
assert(optionalResult.dependencies.optional.some((entry) => entry.includes("module-that-does-not-exist")));
|
|
assert.match(loader.status("lumi_ai_optional").message, /limitations/);
|
|
|
|
createTool(
|
|
path.join(pluginsDir, "lumi_ai_configurable"),
|
|
{
|
|
...metadata("lumi_ai_configurable", "1.0.0"),
|
|
settings_schema: {
|
|
enabled: { type: "boolean", default: false, label: "Enabled" },
|
|
limit: { type: "integer", default: 5, minimum: 1, maximum: 10, label: "Limit" },
|
|
api_key: { type: "string", default: "", secret: true, label: "API key" }
|
|
}
|
|
},
|
|
backendSource("ok", "lumi_ai_configurable")
|
|
);
|
|
listing = await manager.list();
|
|
assert.equal(listing.tools.find((tool) => tool.tool_id === "lumi_ai_configurable").has_settings, true);
|
|
assert.deepEqual(manager.settingsFor("lumi_ai_configurable").values, { enabled: false, limit: 5, api_key: "" });
|
|
let savedSettings = await manager.saveSettings("lumi_ai_configurable", {
|
|
enabled: true,
|
|
limit: 99,
|
|
api_key: "secret-value"
|
|
});
|
|
assert.deepEqual(savedSettings.values, { enabled: true, limit: 10, api_key: "" });
|
|
assert.equal(savedSettings.configured_secrets.api_key, true);
|
|
savedSettings = await manager.saveSettings("lumi_ai_configurable", {
|
|
enabled: false,
|
|
limit: 4,
|
|
api_key: ""
|
|
});
|
|
assert.equal(toolSettings.readRaw("lumi_ai_configurable").api_key, "secret-value");
|
|
|
|
createTool(
|
|
path.join(pluginsDir, "lumi_ai_cross_dependency"),
|
|
{ ...metadata("lumi_ai_cross_dependency", "1.0.0"), required_plugins: ["lumi_ai_weather"] },
|
|
backendSource("ok", "lumi_ai_cross_dependency")
|
|
);
|
|
const blockedDependency = await loader.enable("lumi_ai_cross_dependency");
|
|
assert.equal(blockedDependency.unavailable, true);
|
|
assert.match(blockedDependency.message, /cannot depend on AI tool plugin/);
|
|
|
|
createTool(
|
|
path.join(pluginsDir, "lumi_ai_partial"),
|
|
metadata("lumi_ai_partial", "1.0.0"),
|
|
`module.exports.register = ({ registerTool }) => {
|
|
registerTool(${serializeDefinition("lumi_ai_partial.first")});
|
|
registerTool(${serializeDefinition("outside_namespace.second")});
|
|
};\n`
|
|
);
|
|
const partialResult = await loader.enable("lumi_ai_partial");
|
|
assert.equal(partialResult.unavailable, true);
|
|
assert.equal(registry.tools.has("lumi_ai_partial.first"), false);
|
|
|
|
const strictRegistry = new ToolRegistry(() => {});
|
|
registerManagedTool(strictRegistry, {
|
|
...metadata("lumi_ai_strict", "1.0.0"),
|
|
permissions: { required_role: "admin" }
|
|
}, definition("lumi_ai_strict.lookup", "user"));
|
|
assert.throws(() => strictRegistry.prepare({
|
|
tool: "lumi_ai_strict.lookup",
|
|
args: {},
|
|
user: { id: "ordinary-user" },
|
|
role: "user",
|
|
sessionId: "session"
|
|
}), /Permission denied/);
|
|
assert.equal(strictRegistry.unregisterOwner("lumi_ai_strict"), 1);
|
|
|
|
const contextRegistry = new ToolRegistry(() => {});
|
|
registerManagedTool(contextRegistry, metadata("lumi_ai_context", "1.0.0"), {
|
|
...definition("lumi_ai_context.lookup"),
|
|
schema: {
|
|
query: { type: "string", required: true },
|
|
freshness: { type: "string", required: false }
|
|
},
|
|
workflow_handler: async ({ arguments: args, ctx }) => ({ query: args.query, origin: ctx.origin })
|
|
});
|
|
const preparedContext = contextRegistry.prepare({
|
|
tool: "lumi_ai_context.lookup",
|
|
args: { query: "current information" },
|
|
user: { id: "ordinary-user" },
|
|
role: "user",
|
|
sessionId: "session",
|
|
context: { origin: "discord" }
|
|
});
|
|
const contextResult = await contextRegistry.execute({
|
|
checked: preparedContext.checked,
|
|
user: { id: "ordinary-user" },
|
|
requestId: "request",
|
|
context: preparedContext.context
|
|
});
|
|
assert.deepEqual(contextResult, { query: "current information", origin: "discord" });
|
|
|
|
const unrelated = path.join(pluginsDir, "ordinary-plugin");
|
|
fs.mkdirSync(unrelated);
|
|
assert.equal(isDestructivePath("/plugins/lumi_ai/tools/lumi_ai_weather/delete"), true);
|
|
const request = fakeRequest();
|
|
const confirmation = issueConfirmation(request, "/plugins/lumi_ai/tools/lumi_ai_weather/delete");
|
|
assert.equal(consumeConfirmation(request, "/plugins/lumi_ai/tools/lumi_ai_weather/delete", confirmation.token).reason, "too_early");
|
|
await manager.delete("lumi_ai_weather");
|
|
assert.equal(fs.existsSync(path.join(pluginsDir, "lumi_ai_weather")), false);
|
|
assert.equal(fs.existsSync(unrelated), true);
|
|
|
|
repoClient.fail = true;
|
|
remoteTools.set("lumi_ai_broken", metadata("lumi_ai_broken", "1.0.0"));
|
|
await assert.rejects(() => manager.enable("lumi_ai_broken"), /required|missing/i);
|
|
assert.equal(fs.existsSync(path.join(pluginsDir, "lumi_ai_broken")), false);
|
|
|
|
await verifyRemoteCache(root);
|
|
|
|
const settingsTemplate = fs.readFileSync(path.join(__dirname, "..", "views", "settings.ejs"), "utf8");
|
|
const modalTemplate = fs.readFileSync(path.join(__dirname, "..", "views", "tool-modal.ejs"), "utf8");
|
|
const clientScript = fs.readFileSync(path.join(__dirname, "..", "public", "tool-manager.js"), "utf8");
|
|
const pluginLoader = fs.readFileSync(path.join(__dirname, "..", "..", "..", "src", "services", "plugins.js"), "utf8");
|
|
assert(settingsTemplate.indexOf("data-ai-tools-open") < settingsTemplate.indexOf("Improvement Center"));
|
|
assert(modalTemplate.includes("data-ai-tools-list"));
|
|
assert(modalTemplate.includes("data-ai-tool-readme-modal"));
|
|
assert(modalTemplate.includes("data-ai-tool-settings-modal"));
|
|
assert(clientScript.includes('button("Update"'));
|
|
assert(clientScript.includes('button("Settings"'));
|
|
assert(clientScript.includes("update.disabled = !tool.update_enabled"));
|
|
assert(clientScript.includes('form.dataset.confirmMode = "modal"'));
|
|
assert(pluginLoader.includes('entry.name, "tool_info.json"'));
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
console.log("Lumi AI tool manager verification passed.");
|
|
}
|
|
|
|
async function verifyRemoteCache(root) {
|
|
let now = Date.now();
|
|
let requests = 0;
|
|
const info = metadata("lumi_ai_remote", "1.2.3");
|
|
const fetch = async (url) => {
|
|
requests += 1;
|
|
const payload = url.includes("/contents/plugins/lumi_ai_remote/tool_info.json")
|
|
? { type: "file", encoding: "base64", content: Buffer.from(JSON.stringify(info)).toString("base64") }
|
|
: [{ type: "dir", name: "lumi_ai_remote", path: "plugins/lumi_ai_remote" }];
|
|
return { ok: true, status: 200, async json() { return payload; } };
|
|
};
|
|
const client = new ToolRepoClient({
|
|
settings: { getSetting: (key) => key === "git_remote" ? "https://git.example/owner/lumi" : "main" },
|
|
fetch,
|
|
cacheFile: path.join(root, "remote-cache.json"),
|
|
now: () => now
|
|
});
|
|
await client.discover();
|
|
assert.equal(requests, 2);
|
|
await client.discover();
|
|
assert.equal(requests, 2);
|
|
now += CACHE_TTL_MS + 1;
|
|
await client.discover();
|
|
assert.equal(requests, 4);
|
|
}
|
|
|
|
function metadata(toolId, version) {
|
|
return {
|
|
tool_id: toolId,
|
|
display_name: toolId.replaceAll("_", " "),
|
|
version,
|
|
description: "Test tool",
|
|
scope: "assistant",
|
|
permissions: { required_role: "user" },
|
|
capabilities: ["lookup"],
|
|
limitations: ["test only"],
|
|
tool_type: "lookup",
|
|
entrypoints: { backend: "index.js" },
|
|
confirmation_required: false
|
|
};
|
|
}
|
|
|
|
function definition(toolId, requiredRole = "user") {
|
|
return {
|
|
tool_id: toolId,
|
|
display_name: "Lookup",
|
|
description: "Runs a safe lookup.",
|
|
required_role: requiredRole,
|
|
required_permission: `${toolId}.use`,
|
|
audit_category: "lookup",
|
|
confirmation_required: false,
|
|
risk_level: "low",
|
|
schema: {},
|
|
permission_check: () => true,
|
|
workflow_handler: async () => ({ ok: true })
|
|
};
|
|
}
|
|
|
|
function backendSource(label = "ok", owner = "lumi_ai_weather") {
|
|
return `module.exports.register = ({ registerTool }) => {
|
|
registerTool({
|
|
tool_id: "${owner}.lookup",
|
|
display_name: "Weather lookup",
|
|
description: "Returns test weather.",
|
|
required_role: "user",
|
|
required_permission: "weather.lookup",
|
|
audit_category: "lookup",
|
|
confirmation_required: false,
|
|
risk_level: "low",
|
|
schema: {},
|
|
permission_check: () => true,
|
|
workflow_handler: async () => ({ value: ${JSON.stringify(label)} })
|
|
});
|
|
};\n`;
|
|
}
|
|
|
|
function serializeDefinition(toolId) {
|
|
return `{
|
|
tool_id: ${JSON.stringify(toolId)},
|
|
display_name: "Lookup",
|
|
description: "Safe lookup",
|
|
required_role: "user",
|
|
required_permission: "lookup.use",
|
|
audit_category: "lookup",
|
|
confirmation_required: false,
|
|
risk_level: "low",
|
|
schema: {},
|
|
permission_check: () => true,
|
|
workflow_handler: async () => ({ ok: true })
|
|
}`;
|
|
}
|
|
|
|
function createTool(directory, info, source) {
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
fs.writeFileSync(path.join(directory, "tool_info.json"), `${JSON.stringify(info, null, 2)}\n`);
|
|
fs.writeFileSync(path.join(directory, "readme.md"), `# ${info.display_name}\n`);
|
|
fs.writeFileSync(path.join(directory, "index.js"), source);
|
|
}
|
|
|
|
function copyDirectory(source, destination) {
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
const from = path.join(source, entry.name);
|
|
const to = path.join(destination, entry.name);
|
|
if (entry.isDirectory()) copyDirectory(from, to);
|
|
else fs.copyFileSync(from, to);
|
|
}
|
|
}
|
|
|
|
function fakeRequest() {
|
|
return {
|
|
session: { user: { id: "admin", isAdmin: true } },
|
|
body: {},
|
|
get() { return null; }
|
|
};
|
|
}
|
|
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|