#!/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); });