207 lines
7.2 KiB
JavaScript
207 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
// S2: skills/MCP delete → removedDefault* tombstones; effective has no ghosts.
|
||
|
|
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1.
|
||
|
|
// Prefer MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock for a fast local loop.
|
||
|
|
|
||
|
|
"use strict";
|
||
|
|
|
||
|
|
const fs = require("node:fs");
|
||
|
|
const os = require("node:os");
|
||
|
|
const path = require("node:path");
|
||
|
|
|
||
|
|
const BASE = process.env.MNOTE_S2_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||
|
|
const ACTOR_ID = process.env.MNOTE_S2_ACTOR_ID || "s2-skills-mcp-admin";
|
||
|
|
const AUTH = process.env.MNOTE_S2_AUTH || "Bearer s2-skills-mcp";
|
||
|
|
const ROOT =
|
||
|
|
process.env.MNOTE_S2_ROOT ||
|
||
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s2-skills-mcp-"));
|
||
|
|
|
||
|
|
function assert(condition, message) {
|
||
|
|
if (!condition) throw new Error(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchJson(url, options = {}) {
|
||
|
|
const headers = {
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
Authorization: AUTH,
|
||
|
|
"x-mnote-actor-id": ACTOR_ID,
|
||
|
|
"x-mnote-actor-type": "admin",
|
||
|
|
...(options.headers || {}),
|
||
|
|
};
|
||
|
|
const res = await fetch(url, { ...options, headers });
|
||
|
|
const text = await res.text();
|
||
|
|
let body = {};
|
||
|
|
try {
|
||
|
|
body = text ? JSON.parse(text) : {};
|
||
|
|
} catch {
|
||
|
|
body = { raw: text };
|
||
|
|
}
|
||
|
|
return { status: res.status, body };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function seedWorkspace(rootUri, rootPath) {
|
||
|
|
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
seeds: [
|
||
|
|
{
|
||
|
|
kind: "setupWorkspace",
|
||
|
|
user_id: ACTOR_ID,
|
||
|
|
email: `${ACTOR_ID}@example.com`,
|
||
|
|
username: ACTOR_ID,
|
||
|
|
display_name: ACTOR_ID,
|
||
|
|
role: "admin",
|
||
|
|
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||
|
|
workspace_id: `local-ws:${ACTOR_ID}:s2`,
|
||
|
|
workspace_name: "S2 skills MCP delete",
|
||
|
|
root_uri: rootUri,
|
||
|
|
root_path: rootPath,
|
||
|
|
source_kind: "local_folder",
|
||
|
|
permission: "write",
|
||
|
|
capabilities: ["ai"],
|
||
|
|
grant_source: "s2_skills_mcp_smoke",
|
||
|
|
grant_created_by: ACTOR_ID,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||
|
|
throw new Error(
|
||
|
|
"S2 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
assert(
|
||
|
|
seed.status === 200 && seed.body.ok === true,
|
||
|
|
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function skillIds(listOrMap) {
|
||
|
|
if (Array.isArray(listOrMap)) {
|
||
|
|
return listOrMap.map((s) => String(s.name || s.id || "")).filter(Boolean);
|
||
|
|
}
|
||
|
|
if (listOrMap && typeof listOrMap === "object") {
|
||
|
|
return Object.keys(listOrMap);
|
||
|
|
}
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
function mcpIds(listOrMap) {
|
||
|
|
if (Array.isArray(listOrMap)) {
|
||
|
|
return listOrMap.map((s) => String(s.name || s.id || "")).filter(Boolean);
|
||
|
|
}
|
||
|
|
if (listOrMap && typeof listOrMap === "object") {
|
||
|
|
return Object.keys(listOrMap);
|
||
|
|
}
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
fs.mkdirSync(ROOT, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(ROOT, "s2.md"), "# S2 skills MCP delete\n", "utf8");
|
||
|
|
const rootUri = `file://${ROOT}`;
|
||
|
|
|
||
|
|
console.log(`\n🧪 S2 skills/MCP delete no ghosts (base=${BASE}, actor=${ACTOR_ID})\n`);
|
||
|
|
|
||
|
|
await seedWorkspace(rootUri, ROOT);
|
||
|
|
|
||
|
|
const adminBefore = await fetchJson(`${BASE}/api/ai-admin/settings`);
|
||
|
|
assert(
|
||
|
|
adminBefore.status === 200 && adminBefore.body.ok === true,
|
||
|
|
`admin get: ${adminBefore.status} ${JSON.stringify(adminBefore.body)}`,
|
||
|
|
);
|
||
|
|
|
||
|
|
const beforeSkills = skillIds(adminBefore.body.skills);
|
||
|
|
const beforeMcps = mcpIds(adminBefore.body.mcpServers);
|
||
|
|
assert(beforeSkills.includes("vpn") || beforeSkills.length > 0, "expected default skills in admin");
|
||
|
|
console.log(` baseline skills=${beforeSkills.length} mcp=${beforeMcps.length}`);
|
||
|
|
|
||
|
|
// Keep only a custom skill + custom mcp; omit all defaults → tombstones.
|
||
|
|
const keepSkill = {
|
||
|
|
"s2-keep-skill": {
|
||
|
|
name: "S2 Keep Skill",
|
||
|
|
enabled: true,
|
||
|
|
description: "s2 keep",
|
||
|
|
source: path.join(ROOT, "SKILL.md"),
|
||
|
|
riskLevel: "low",
|
||
|
|
requiredScopes: [],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const keepMcp = {
|
||
|
|
"s2-keep-mcp": {
|
||
|
|
name: "S2 Keep MCP",
|
||
|
|
enabled: true,
|
||
|
|
url: "",
|
||
|
|
transport: "stdio",
|
||
|
|
command: "s2-keep-mcp",
|
||
|
|
networkPolicy: "deny-all",
|
||
|
|
secretRefs: [],
|
||
|
|
facadeOnly: true,
|
||
|
|
sandbox: true,
|
||
|
|
description: "s2 keep mcp",
|
||
|
|
riskLevel: "medium",
|
||
|
|
requiredScopes: [],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
fs.writeFileSync(path.join(ROOT, "SKILL.md"), "# S2 Keep\n", "utf8");
|
||
|
|
|
||
|
|
const put = await fetchJson(`${BASE}/api/ai-admin/settings`, {
|
||
|
|
method: "PUT",
|
||
|
|
body: JSON.stringify({
|
||
|
|
skills: keepSkill,
|
||
|
|
mcpServers: keepMcp,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
assert(put.status === 200 && put.body.ok === true, `admin put: ${put.status} ${JSON.stringify(put.body)}`);
|
||
|
|
|
||
|
|
const adminAfter = await fetchJson(`${BASE}/api/ai-admin/settings`);
|
||
|
|
assert(adminAfter.status === 200 && adminAfter.body.ok === true, `admin after: ${adminAfter.status}`);
|
||
|
|
const afterSkills = skillIds(adminAfter.body.skills);
|
||
|
|
const afterMcps = mcpIds(adminAfter.body.mcpServers);
|
||
|
|
assert(
|
||
|
|
afterSkills.includes("s2-keep-skill") || afterSkills.includes("S2 Keep Skill"),
|
||
|
|
`admin skills missing keep: ${JSON.stringify(afterSkills)}`,
|
||
|
|
);
|
||
|
|
assert(!afterSkills.includes("vpn"), `ghost skill vpn still in admin: ${JSON.stringify(afterSkills)}`);
|
||
|
|
assert(
|
||
|
|
afterMcps.includes("s2-keep-mcp") || afterMcps.includes("S2 Keep MCP"),
|
||
|
|
`admin mcp missing keep: ${JSON.stringify(afterMcps)}`,
|
||
|
|
);
|
||
|
|
assert(!afterMcps.includes("context7"), `ghost mcp context7 still in admin: ${JSON.stringify(afterMcps)}`);
|
||
|
|
console.log(` [admin] skills=${afterSkills.join(",")} mcp=${afterMcps.join(",")}`);
|
||
|
|
|
||
|
|
const effective = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
||
|
|
assert(effective.status === 200, `effective: ${effective.status}`);
|
||
|
|
const effSkills = skillIds(effective.body.skills);
|
||
|
|
const effMcps = mcpIds(effective.body.mcpServers);
|
||
|
|
assert(!effSkills.includes("vpn"), `effective still has vpn ghost: ${JSON.stringify(effSkills)}`);
|
||
|
|
assert(
|
||
|
|
!effSkills.some((id) => id === "vpn" || id.toLowerCase() === "vpn"),
|
||
|
|
`effective skills contain vpn: ${JSON.stringify(effSkills)}`,
|
||
|
|
);
|
||
|
|
assert(!effMcps.includes("context7"), `effective still has context7 ghost: ${JSON.stringify(effMcps)}`);
|
||
|
|
assert(
|
||
|
|
effSkills.some((id) => id.includes("S2 Keep") || id === "s2-keep-skill"),
|
||
|
|
`effective missing keep skill: ${JSON.stringify(effSkills)}`,
|
||
|
|
);
|
||
|
|
console.log(` [effective] skills=${effSkills.join(",")} mcp=${effMcps.join(",")}`);
|
||
|
|
|
||
|
|
// Second put with same payload must remain ghost-free (tombstones sticky).
|
||
|
|
const put2 = await fetchJson(`${BASE}/api/ai-admin/settings`, {
|
||
|
|
method: "PUT",
|
||
|
|
body: JSON.stringify({ skills: keepSkill, mcpServers: keepMcp }),
|
||
|
|
});
|
||
|
|
assert(put2.status === 200 && put2.body.ok === true, `admin put2: ${put2.status}`);
|
||
|
|
const effective2 = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
||
|
|
const effSkills2 = skillIds(effective2.body.skills);
|
||
|
|
assert(!effSkills2.includes("vpn"), `ghost rehydrated after second put: ${JSON.stringify(effSkills2)}`);
|
||
|
|
console.log(" [sticky] second put still tombstoned defaults");
|
||
|
|
|
||
|
|
console.log("\n✅ S2 skills/MCP delete no ghosts smoke passed\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((err) => {
|
||
|
|
console.error("\n❌ S2 smoke failed:", err.message || err);
|
||
|
|
process.exit(1);
|
||
|
|
});
|