Files
mnote/scripts/task-pi-lab-rpc-api-smoke.js
T

486 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Pi Lab RPC-mode API smoke
// 验证 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc + MNOTE_PAGE_AI_PI_BIN wrapper 下:
// start/send/abort/tool-call/越界拒绝/.md patch
// 需要:mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh 启动
// 可选: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 ROOT = process.env.MNOTE_PI_LAB_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-"));
const PI_BIN_ENV = process.env.MNOTE_PAGE_AI_PI_BIN || "/tmp/mnote-pi-cli-wrapper.sh";
const HAS_API_KEY = !!(process.env.MNOTE_PI_LAB_OPENAI_API_KEY || process.env.OPENAI_API_KEY);
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 };
}
/**
* 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,
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.md";
const pageFile = path.join(ROOT, pagePath);
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(` API key available: ${HAS_API_KEY}\n`);
let allPassed = true;
const pass = (msg) => { console.log(` ✅ ${msg}`); };
const fail = (msg) => { console.error(` ❌ ${msg}`); allPassed = false; };
// ── 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(Array.isArray(status.body.disabledPiBuiltinTools), "missing disabledPiBuiltinTools");
assert(status.body.disabledPiBuiltinTools.includes("bash"), "bash should be in disabled list");
assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode");
pass("status enabled/rpc with disabled builtins 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: "pi-lab-rpc-smoke",
pagePath,
pageTitle: "Pi Lab RPC 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");
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.disabledPiBuiltinTools), "missing disabledPiBuiltinTools in start");
assert(start.body.mnoteToolOnly === true, "start must declare mnoteToolOnly");
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, 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.disabledBuiltinTools), "missing disabledBuiltinTools in event");
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 === "provider_neutral_jsonl_adapter_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 === "provider_neutral_jsonl_adapter_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"
);
const hasMnoteBridgeTool = events.some((event) =>
event.kind === "tool_call"
&& event.payload?.toolName === "mnote.current_page.read"
);
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");
} 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: "pi-lab-rpc-smoke-2",
pagePath,
pageTitle: "Pi Lab RPC smoke 2",
}),
});
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);
});