486 lines
22 KiB
JavaScript
486 lines
22 KiB
JavaScript
#!/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);
|
||
});
|