#!/usr/bin/env node // Pi Lab RPC-mode API smoke // 验证 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc + Pi Rust runtime 下: // start/send/abort/tool-call/越界拒绝/.md patch // 需要:mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,默认使用 Pi Rust。 // 可选:MNOTE_PI_LAB_OPENAI_API_KEY(真实 send 需要 API key) "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 ACTOR_ID = process.env.MNOTE_PI_LAB_ACTOR_ID || process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e"; const ACTOR_TYPE = process.env.MNOTE_PI_LAB_ACTOR_TYPE || "user"; const DEFAULT_E2E_ROOT = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; const ROOT_FROM_ENV = process.env.MNOTE_PI_LAB_SMOKE_ROOT; const USING_DEFAULT_E2E_ROOT = !ROOT_FROM_ENV && ACTOR_ID === "mnote-e2e" && fs.existsSync(DEFAULT_E2E_ROOT); const ROOT = ROOT_FROM_ENV || (USING_DEFAULT_E2E_ROOT ? DEFAULT_E2E_ROOT : fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-"))); const PAGE_PATH = process.env.MNOTE_PI_LAB_PAGE_PATH || (USING_DEFAULT_E2E_ROOT ? `.mnote/smoke/pi-rpc-${Date.now()}.md` : "page.md"); const PI_BIN_ENV = process.env.MNOTE_PAGE_AI_PI_RUST_BIN || process.env.MNOTE_PAGE_AI_PI_BIN || "/home/lix/.local/share/mnote/pi-rust/bin/pi"; const HAS_API_KEY = !!(process.env.MNOTE_PI_LAB_OPENAI_API_KEY || process.env.OPENAI_API_KEY); const STRICT_TOOL_CALL = process.env.MNOTE_PI_LAB_STRICT_TOOL_CALL === "1"; const WORKSPACE_ID = "pi-lab-rpc-smoke"; 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": ACTOR_TYPE, ...(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 seedWorkspaceAccess(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: ACTOR_TYPE, workspace_id: WORKSPACE_ID, workspace_name: "Pi Lab RPC smoke", root_uri: rootUri, root_path: rootPath, source_kind: "local_folder", permission: "write", capabilities: ["ai"], grant_source: "pi_lab_rpc_smoke", grant_created_by: ACTOR_ID, }], }), }); if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { return { ok: false, skipped: true, reason: "dev_seed_disabled" }; } assert(seed.status === 200 && seed.body.ok === true, `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`); return { ok: true, skipped: false }; } /** * Collect SSE events from /api/page-ai/pi/events for a short window. */ function collectSseEvents(sessionId, timeoutMs = 5000) { return new Promise((resolve) => { const events = []; const url = `${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`; const controller = new AbortController(); const timer = setTimeout(() => { controller.abort(); }, timeoutMs); fetch(url, { headers: { Authorization: AUTH, "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": ACTOR_TYPE, Accept: "text/event-stream", }, signal: controller.signal, }) .then((res) => { if (!res.ok) { events.push({ error: `SSE returned ${res.status}` }); clearTimeout(timer); resolve(events); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; function read() { reader .read() .then(({ done, value }) => { if (done) { clearTimeout(timer); resolve(events); return; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.startsWith("data: ")) { try { const parsed = JSON.parse(line.slice(6)); events.push(parsed); } catch { // skip parse errors } } } read(); }) .catch((err) => { if (err.name !== "AbortError") { events.push({ error: err.message }); } clearTimeout(timer); resolve(events); }); } read(); }) .catch((err) => { if (err.name !== "AbortError") { events.push({ error: err.message }); } clearTimeout(timer); resolve(events); }); }); } /** * Wait for a specific SSE event kind. */ async function waitForSseEvent(sessionId, targetKind, timeoutMs = 15000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const events = await collectSseEvents(sessionId, 3000); const matched = events.find((e) => e.kind === targetKind); if (matched) return matched; await new Promise((r) => setTimeout(r, 500)); } return null; } async function main() { fs.mkdirSync(ROOT, { recursive: true }); const pagePath = PAGE_PATH; const pageFile = path.join(ROOT, pagePath); fs.mkdirSync(path.dirname(pageFile), { recursive: true }); fs.writeFileSync(pageFile, "# Pi Lab RPC smoke\n\nOriginal body\n", "utf8"); const rootUri = `file://${ROOT}`; console.log(`\n🧪 Pi Lab RPC API smoke (base: ${BASE}, root: ${ROOT})`); console.log(` Pi binary: ${PI_BIN_ENV}`); console.log(` Actor: ${ACTOR_ID}`); console.log(` Page path: ${pagePath}`); console.log(` API key available: ${HAS_API_KEY}\n`); let allPassed = true; const pass = (msg) => { console.log(` ✅ ${msg}`); }; const fail = (msg) => { console.error(` ❌ ${msg}`); allPassed = false; }; try { const seeded = await seedWorkspaceAccess(rootUri, ROOT); if (seeded.skipped) { pass("dev seed disabled; using existing control-plane directory grants"); } else { pass("seeded control-plane user/workspace/directory grant for RPC smoke"); } } catch (err) { fail(`dev seed: ${err.message}`); } // ── 1. Status: enabled=true, runtimeMode=rpc ────────────────────── try { const status = await fetchJson(`${BASE}/api/page-ai/pi/status`); assert(status.status === 200, `status returned ${status.status}`); assert(status.body.schema === "mnote.page_ai_pi.status.v1", "status schema mismatch"); assert(status.body.enabled === true, "MNOTE_PAGE_AI_PI_LAB must be enabled for RPC smoke"); assert(status.body.runtimeMode === "rpc", `runtimeMode must be rpc, got ${status.body.runtimeMode}`); assert(status.body.runtimeImplementation === "pi-rust", `runtimeImplementation must default to pi-rust, got ${status.body.runtimeImplementation}`); assert(status.body.runtimeBinary, "status should expose runtimeBinary for Pi Rust diagnostics"); assert(Array.isArray(status.body.managedPiBuiltinTools), "missing managedPiBuiltinTools"); assert(status.body.managedPiBuiltinTools.length === 0, "Pi Rust should keep raw builtins disabled by default"); assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode"); pass("status enabled/rpc with Rust bridge policy and independent Pi Lab UI mode"); } catch (err) { fail(`status check: ${err.message}`); } // ── 2. Start session (triggers real Pi RPC subprocess) ──────────── let sessionId = null; try { const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, { method: "POST", body: JSON.stringify({ rootUri, workspaceId: WORKSPACE_ID, pagePath, pageTitle: "Pi Lab RPC smoke", permissionMode: "auto_edit", }), }); 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"); sessionId = start.body.session && start.body.session.sessionId; assert(sessionId, "start did not return sessionId"); assert(start.body.session.runtimeMode === "rpc", `runtimeMode must be rpc, got ${start.body.session.runtimeMode}`); assert(start.body.session.runtimePid, "RPC mode must have runtimePid (real Pi subprocess PID)"); assert(typeof start.body.session.runtimePid === "number", "runtimePid must be a number"); assert(start.body.session.runtimePid > 0, "runtimePid must be positive"); assert(start.body.session.allowedRootsSnapshot, "start did not capture allowed roots snapshot"); assert(Array.isArray(start.body.managedPiBuiltinTools), "missing managedPiBuiltinTools in start"); assert(start.body.managedPiBuiltinTools.length === 0, "start should not expose raw Pi builtins by default"); assert(start.body.mnoteToolOnly === false, "start should expose MNote bridge tools through Pi Rust extension"); pass(`start session ${sessionId} with real Pi PID ${start.body.session.runtimePid}`); } catch (err) { fail(`start: ${err.message}`); } // ── 3. SSE events endpoint ──────────────────────────────────────── if (sessionId) { try { const evtUrl = `${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`; const evtRes = await fetch(evtUrl, { headers: { Authorization: AUTH, "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": ACTOR_TYPE, Accept: "text/event-stream", }, signal: AbortSignal.timeout(3000), }).catch(() => null); if (evtRes && evtRes.ok) { const ct = evtRes.headers.get("content-type") || ""; assert(ct.includes("text/event-stream"), `unexpected Content-Type: ${ct}`); pass("events SSE endpoint returns text/event-stream"); } else { // May fail if session has no events yet - accept 200 only assert(evtRes && evtRes.status === 200, `events returned ${evtRes ? evtRes.status : "timeout"}`); } } catch (err) { fail(`events SSE: ${err.message}`); } // ── 4. Verify runtime start evidence ─────────────────────────── // runtime_started may be published before the smoke attaches to SSE, so // treat the start response runtimePid as the hard assertion and use SSE as // opportunistic event evidence. try { const runtimeEvent = await waitForSseEvent(sessionId, "runtime_started", 5000); if (runtimeEvent) { const payload = runtimeEvent.payload || {}; assert(payload.mode === "rpc", `mode must be rpc, got ${payload.mode}`); assert(typeof payload.pid === "number", "PID must be a number in event"); assert(Array.isArray(payload.managedBuiltinTools), "missing managedBuiltinTools in event"); assert(payload.managedBuiltinTools.length === 0, "runtime event should keep raw Pi builtins disabled by default"); pass(`runtime_started event observed (mode=rpc, pid=${payload.pid})`); } else { const statusAfterStart = await fetchJson(`${BASE}/api/page-ai/pi/status`); assert(statusAfterStart.status === 200, `status after start returned ${statusAfterStart.status}`); assert(statusAfterStart.body.running === true, "status after start must show running=true"); assert(statusAfterStart.body.pid, "status after start must expose runtime PID"); pass(`runtime start confirmed by status (pid=${statusAfterStart.body.pid}); runtime_started SSE was already consumed`); } } catch (err) { fail(`runtime start evidence: ${err.message}`); } } // ── 5. Tool call: mnote.allowed_roots.describe ──────────────────── if (sessionId) { try { 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"); assert(allowed.body.receipt, "missing receipt"); assert(allowed.body.receipt.storage === "control_plane_turso_libsql_v1", "receipt storage mismatch"); pass("mnote.allowed_roots.describe returns allowed roots and receipt"); } catch (err) { fail(`allowed_roots.describe: ${err.message}`); } // ── 6. Tool call: current page read ───────────────────────────── try { 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"); assert(currentPage.body.result.format === "markdown", "format must be markdown"); assert(currentPage.body.result.fileVersion, "missing fileVersion"); pass("mnote.current_page.read returns page content with fileVersion"); } catch (err) { fail(`current_page.read: ${err.message}`); } // ── 7. Tool call: out-of-bounds read denied ───────────────────── try { 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.result.ok === false, "denied result must contain ok=false"); assert(denied.body.receipt, "denied read should still write receipt"); assert(denied.body.receipt.storage === "control_plane_turso_libsql_v1", "receipt storage mismatch"); pass("out-of-root read denied with receipt"); } catch (err) { fail(`out-of-bound read: ${err.message}`); } // ── 8. Tool call: patch current .md file ──────────────────────── try { 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 by RPC smoke" }], }, }), }); 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(patch.body.result.beforeFileVersion, "missing beforeFileVersion"); assert(patch.body.result.afterFileVersion !== patch.body.result.beforeFileVersion, "file version must change after patch"); assert(patch.body.result.diffSummary, "missing diffSummary"); const patchedContent = fs.readFileSync(pageFile, "utf8"); assert(patchedContent.includes("Patched by RPC smoke"), "patched file content mismatch"); assert(!patchedContent.includes("Original body"), "old content should be replaced in file"); assert(patch.body.receipt, "missing receipt"); pass("mnote.local_file.patch writes file + watcher refresh + version change + receipt"); } catch (err) { fail(`local_file.patch: ${err.message}`); } // ── 9. Tool call: LightRAG knowledge_rag.query facade ─────────── try { 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 RPC 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"); assert(rag.body.receipt, "missing receipt"); // LightRAG may return empty results if no knowledge base indexed, that's OK pass(`knowledge_rag.query facade exercised (ok=${rag.body.ok}, receipt present)`); } catch (err) { fail(`knowledge_rag.query: ${err.message}`); } // ── 10. Tool call: reference.open facade ──────────────────────── try { 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"); assert(reference.body.receipt, "missing receipt"); pass(`reference.open facade exercised (ok=${reference.body.ok})`); } catch (err) { fail(`reference.open: ${err.message}`); } // ── 11. Send prompt (only if API key available) ───────────────── if (HAS_API_KEY) { try { const marker = `REAL_PI_TOOL_BRIDGE_OK_${Date.now()}`; const pendingEvents = collectSseEvents(sessionId, 45000); const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, { method: "POST", body: JSON.stringify({ sessionId, message: `You must call the available tool named mnote_current_page_read before answering. After reading the page, reply exactly ${marker} if the tool result contains "Patched by RPC smoke". Do not explain.`, }), }); assert(send.status === 200 && send.body.accepted === true, "send did not accept prompt"); assert(send.body.schema === "mnote.page_ai_pi.send.v1", "send schema mismatch"); assert(send.body.eventStream, "send must return eventStream path"); pass("send accepts prompt and routes to Pi RPC stdin"); const events = await pendingEvents; const eventText = JSON.stringify(events); const hasPiToolStart = events.some((event) => event.kind === "pi_rpc_event" && event.payload?.type === "tool_execution_start" && (event.payload?.toolName === "mnote_current_page_read" || event.payload?.toolName === "mnote.current_page.read") ); const hasMnoteBridgeTool = events.some((event) => event.kind === "tool_call" && event.payload?.toolName === "mnote.current_page.read" ); const hasBridgeEvidence = hasMnoteBridgeTool || eventText.includes(marker); if (STRICT_TOOL_CALL) { assert(hasPiToolStart, "Pi did not emit mnote_current_page_read tool_execution_start"); assert(hasMnoteBridgeTool, "MNote bridge did not execute mnote.current_page.read"); assert(eventText.includes(marker), "Pi final response marker not observed after tool call"); pass("real Pi custom tool call flows through MNote bridge and returns marker"); } else if (hasPiToolStart && hasBridgeEvidence) { pass("real Pi custom tool call evidence observed through MNote bridge"); } else { pass("send accepted by Pi Rust runtime; tool-call evidence not strict in default smoke"); } } catch (err) { fail(`send: ${err.message}`); } } else { console.log(" ⏭ Send/abort skipped (set MNOTE_PI_LAB_OPENAI_API_KEY for real Pi RPC prompt test)"); } // ── 13. Abort ─────────────────────────────────────────────────── // Abort works even without API key - it kills the Pi subprocess try { const abort = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { method: "POST", body: JSON.stringify({ sessionId }), }); assert(abort.status === 200, `abort returned ${abort.status}`); assert(abort.body.aborted === true, "abort must return aborted=true"); assert(abort.body.schema === "mnote.page_ai_pi.abort.v1", "abort schema mismatch"); pass("abort kills Pi RPC subprocess and returns aborted=true"); } catch (err) { fail(`abort: ${err.message}`); } // ── 14. Verify session status changed to Aborted ──────────────── try { const status2 = await fetchJson(`${BASE}/api/page-ai/pi/status`); assert(status2.body.session, "status should return current session"); assert( status2.body.session.status === "aborted", `expected session status aborted, got ${status2.body.session.status}` ); pass("session status transitions to aborted"); } catch (err) { fail(`session status aborted: ${err.message}`); } // ── 15. Start a second session to verify multi-session lifecycle ─ try { const start2 = await fetchJson(`${BASE}/api/page-ai/pi/start`, { method: "POST", body: JSON.stringify({ rootUri, workspaceId: WORKSPACE_ID, pagePath, pageTitle: "Pi Lab RPC smoke 2", permissionMode: "auto_edit", }), }); assert(start2.status === 200, `second start returned ${start2.status}`); const session2Id = start2.body.session && start2.body.session.sessionId; assert(session2Id, "second start did not return sessionId"); assert(session2Id !== sessionId, "second session must have different ID"); assert(start2.body.session.runtimePid, "second session must also have runtimePid"); pass(`second session ${session2Id} started with PID ${start2.body.session.runtimePid}`); // Clean up second session const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { method: "POST", body: JSON.stringify({ sessionId: session2Id }), }); assert(abort2.status === 200, `second abort returned ${abort2.status}`); assert(abort2.body.aborted === true, "second abort must return aborted=true"); pass("second session abort cleans up Pi subprocess"); } catch (err) { fail(`multi-session lifecycle: ${err.message}`); } } // ── 16. Runtime browser asset integrity ─────────────────────────── try { const res = await fetch(`${BASE}/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js`); assert(res.status === 200, `runtime asset status ${res.status}`); const text = await res.text(); assert(text.includes("createSidebarPageAiPiLabRuntime"), "missing expected export"); assert(!/setInterval\s*\(/.test(text), "runtime should not use setInterval polling"); assert(text.includes("NO setInterval polling"), "missing NO setInterval polling comment"); assert(text.includes("/api/page-ai/pi/start"), "missing start endpoint"); assert(text.includes("/api/page-ai/pi/send"), "missing send endpoint"); assert(text.includes("/api/page-ai/pi/abort"), "missing abort endpoint"); assert(text.includes("/api/page-ai/pi/events"), "missing events endpoint"); pass("client runtime JS served with correct endpoints and no polling"); } catch (err) { fail(`runtime asset: ${err.message}`); } // ── Summary ─────────────────────────────────────────────────────── if (allPassed) { console.log("\n✅ Pi Lab RPC API smoke passed.\n"); } else { console.error("\n❌ Pi Lab RPC API smoke: some checks failed.\n"); process.exit(1); } } main().catch((error) => { console.error(`\n❌ Pi Lab RPC API smoke failed: ${error.message}`); process.exit(1); });