#!/usr/bin/env node // Pi Lab mock-mode API smoke // 需要以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock 启动 mnote-web;Pi Lab 默认开启。 // 验证 start/send/abort/events、allowed roots、越界拒绝、当前页 read、文件 patch、receipt。 "use strict"; const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const BASE = process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3000"; const AUTH = process.env.MNOTE_PI_LAB_AUTH || "Bearer pi-lab-smoke"; const ROOT = process.env.MNOTE_PI_LAB_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-lab-")); const DIRECT_TOOL_CALL_APPROVAL = process.env.MNOTE_PI_LAB_DIRECT_TOOL_APPROVAL === "1"; function assert(condition, message) { if (!condition) throw new Error(message); } async function fetchJson(url, options = {}) { const headers = { "Content-Type": "application/json", Authorization: AUTH, ...(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, headers: res.headers }; } async function main() { fs.mkdirSync(ROOT, { recursive: true }); const pagePath = "page.md"; const pageFile = path.join(ROOT, pagePath); fs.writeFileSync(pageFile, "# Pi Lab smoke\n\nOriginal body\n", "utf8"); const rootUri = `file://${ROOT}`; console.log(`\n🧪 Pi Lab mock API smoke (base: ${BASE}, root: ${ROOT})\n`); const status = await fetchJson(`${BASE}/api/page-ai/pi/status`); assert(status.status === 200, `status endpoint returned ${status.status}`); assert(status.body.schema === "mnote.page_ai_pi.status.v1", "status schema mismatch"); assert(status.body.enabled === true, "Pi Lab must be enabled for mock smoke"); assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode"); assert(status.body.runtimeMode === "mock", `runtimeMode must be mock, got ${status.body.runtimeMode}`); console.log(" ✅ status enabled/mock"); const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, { method: "POST", body: JSON.stringify({ rootUri, workspaceId: "pi-lab-smoke", pagePath, pageTitle: "Pi Lab smoke", }), }); assert(start.status === 200, `start returned ${start.status}: ${JSON.stringify(start.body)}`); assert(start.body.schema === "mnote.page_ai_pi.start.v1", "start schema mismatch"); const sessionId = start.body.session && start.body.session.sessionId; assert(sessionId, "start did not return sessionId"); assert(start.body.session.allowedRootsSnapshot, "start did not capture allowed roots snapshot"); console.log(` ✅ start session ${sessionId}`); const events = await fetch(`${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`, { headers: { Authorization: AUTH, Accept: "text/event-stream" }, }); assert(events.status === 200, `events returned ${events.status}`); assert((events.headers.get("content-type") || "").includes("text/event-stream"), "events is not SSE"); if (events.body && events.body.cancel) await events.body.cancel(); console.log(" ✅ events SSE"); const allowed = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, { method: "POST", body: JSON.stringify({ sessionId, toolName: "mnote.allowed_roots.describe", params: {}, }), }); assert(allowed.status === 200, `allowed roots returned ${allowed.status}`); assert(allowed.body.ok === true, "allowed roots tool should be allowed"); assert(Array.isArray(allowed.body.result.allowedRoots), "allowed roots missing"); console.log(" ✅ allowed roots describe"); const currentPage = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, { method: "POST", body: JSON.stringify({ sessionId, toolName: "mnote.current_page.read", params: { rootUri, pagePath }, }), }); assert(currentPage.status === 200 && currentPage.body.ok === true, "current page read failed"); assert(String(currentPage.body.result.content).includes("Original body"), "current page content mismatch"); console.log(" ✅ current page read"); const denied = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, { method: "POST", body: JSON.stringify({ sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" }, }), }); assert(denied.status === 200, `deny read returned ${denied.status}`); assert(denied.body.ok === false, "out-of-root read should be denied"); assert(denied.body.receipt, "denied read should still write receipt"); assert(denied.body.approvalRequired === true, "direct local file read should require approval before path checks"); console.log(" ✅ out-of-root read blocked by ask policy with receipt"); const patch = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, { method: "POST", body: JSON.stringify({ sessionId, toolName: "mnote.local_file.patch", params: { rootUri, path: pagePath, operations: [{ op: "replace", old: "Original body", new: "Patched body" }], }, }), }); if (DIRECT_TOOL_CALL_APPROVAL) { assert(patch.status === 200 && patch.body.ok === true, "local file patch failed"); assert(patch.body.result.polling === false, "patch must not request polling"); assert(String(patch.body.result.refresh || "").includes("watcher"), "patch must declare watcher refresh"); assert(fs.readFileSync(pageFile, "utf8").includes("Patched body"), "patched file content mismatch"); console.log(" ✅ local file patch + watcher refresh metadata"); } else { assert(patch.status === 200, `patch returned ${patch.status}`); assert(patch.body.ok === false, "direct local file patch should require UI/bridge approval"); assert(patch.body.approvalRequired === true, "patch should expose approvalRequired"); assert(String(patch.body.result?.code || "") === "page_ai_pi_lab_tool_approval_required", "patch should be blocked by ask policy"); assert(fs.readFileSync(pageFile, "utf8").includes("Original body"), "unapproved patch must not modify file"); console.log(" ✅ direct local file patch blocked by ask policy"); } const rag = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, { method: "POST", body: JSON.stringify({ sessionId, toolName: "mnote.knowledge_rag.query", params: { rootUri, query: "Pi Lab smoke", topK: 1 }, }), }); assert(rag.status === 200, `knowledge RAG facade returned HTTP ${rag.status}`); assert(typeof rag.body.ok === "boolean", "knowledge RAG facade did not return tool envelope"); console.log(` ✅ LightRAG facade exercised (${rag.body.ok ? "ok" : "provider unavailable/denied"})`); const reference = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, { method: "POST", body: JSON.stringify({ sessionId, toolName: "mnote.reference.open", params: { rootUri, filePath: pagePath }, }), }); assert(reference.status === 200, `reference.open facade returned HTTP ${reference.status}`); assert(typeof reference.body.ok === "boolean", "reference.open facade did not return tool envelope"); console.log(` ✅ citation/open-reference facade exercised (${reference.body.ok ? "ok" : "provider unavailable/denied"})`); const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, { method: "POST", body: JSON.stringify({ sessionId, message: "hello from smoke" }), }); assert(send.status === 200 && send.body.accepted === true, "send did not accept prompt"); console.log(" ✅ send prompt"); const abort = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { method: "POST", body: JSON.stringify({ sessionId }), }); assert(abort.status === 200 && abort.body.aborted === true, "abort failed"); console.log(" ✅ abort"); console.log("\n✅ Pi Lab mock API smoke passed.\n"); } main().catch((error) => { console.error(`\n❌ Pi Lab mock API smoke failed: ${error.message}`); process.exit(1); });