Persist PageTree expand state via control-plane view-state and align chevron/DOM with restored expansion; keep Sidex-style shallow page-tree scan and drop the unused recursive scanner that only added cargo noise. Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi into a module package, and retire Hermes/ACP/OpenHub recycle + root harness evidence from the index while gitignoring recycle and local diag dumps. Archive superseded design/bugs docs under old/, point architecture at ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor regressions so the working tree can stay clean.
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);
|
|
});
|