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

486 lines
22 KiB
JavaScript
Raw Normal View History

2026-07-04 21:47:42 +08:00
#!/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);
});