Files
mnote/scripts/task-pi-lab-full-access-builtin-delete-smoke.js
T

382 lines
18 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
"use strict";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
setupWorkspaceAccess,
seedAiPolicy,
} = require("./lib/control-plane-dev-seed");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_OUT || path.join(os.tmpdir(), `mnote-pi-full-access-builtin-tools-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "360000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_WORKSPACE_ID || `local-ws:${ACTOR_ID}:pi-full-access-builtins-${STAMP}`;
const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_PATH || path.join(OUT, "workspace");
const ROOT_URI = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_URI || `file://${ROOT_PATH}`;
const MODEL_PROVIDER = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_ID || "gpt-5.4-mini";
const MARKER = `PI_FULL_ACCESS_CONTROLLED_TOOLS_OK_${STAMP}`;
const PAGE_PATH = `pi-full-access-builtin-tools-${STAMP}.md`;
const SCRATCH_PATH = `pi-full-access-builtin-tools-${STAMP}.txt`;
const BUILTIN_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls", "hashline_edit"];
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
async function quickLogin(page) {
// 7-76 P0: 标准表单登录(无测试快速登录按钮)
const base =
(typeof BASE_URL !== "undefined" && BASE_URL) ||
(typeof baseUrl !== "undefined" && baseUrl) ||
process.env.MNOTE_UI_BASE_URL ||
"http://127.0.0.1:3000";
const timeout =
(typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) ||
(typeof TIMEOUT !== "undefined" && TIMEOUT) ||
30_000;
if (!String(page.url() || "").includes("/auth")) {
await page.goto(String(base).replace(/\/+$/, "") + "/auth", {
waitUntil: "commit",
timeout,
});
}
await loginViaAuthForm(page, {
baseUrl: base,
timeoutMs: timeout,
gotoAuth: false,
});
await page
.waitForURL((url) => !String(url).includes("/auth"), { timeout })
.catch(() => {});
}
async function requestJson(page, url, options = {}) {
const response = await page.request.fetch(`${BASE}${url}`, {
...options,
headers: {
accept: "application/json",
"content-type": "application/json",
...(options.headers || {}),
},
timeout: options.timeout || TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
return body;
}
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
return { name, description, source, toolNames, riskLevel, requiredScopes, enabled: true };
}
function policyForFullAccessBuiltinTools() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.tool_receipt.write": "allow",
},
skills: {},
mcpServers: {},
piExtensions: {
"pi-rust-official-question": piExtensionConfig("Pi Rust Official Question", "Pi Rust 官方索引 question 扩展的本地镜像。", "pi-rust-official:question", ["question"], "low", ["ui:ask"]),
"pi-rust-official-questionnaire": piExtensionConfig("Pi Rust Official Questionnaire", "Pi Rust 官方索引 questionnaire 扩展的本地镜像。", "pi-rust-official:questionnaire", ["questionnaire"], "low", ["ui:ask"]),
"pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方索引 permission-gate 扩展的本地镜像。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]),
},
};
}
async function seedWorkspace(page) {
mkdirp(ROOT_PATH);
fs.writeFileSync(
path.join(ROOT_PATH, PAGE_PATH),
"# Pi full access builtin tools smoke\n\nBUILTIN_READ_MARKER\n",
"utf8",
);
fs.rmSync(path.join(ROOT_PATH, SCRATCH_PATH), { force: true });
await setupWorkspaceAccess(page.request, BASE, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
username: ACTOR_ID,
displayName: ACTOR_ID,
role: "admin",
workspaceId: WORKSPACE_ID,
workspaceName: "Pi full access builtin tools smoke",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
permission: "write",
capabilities: ["ai", "read", "write"],
timeoutMs: TIMEOUT,
});
await seedAiPolicy(page.request, BASE, {
id: `pi-full-access-builtins-${ACTOR_ID}-${WORKSPACE_ID}`,
userId: ACTOR_ID,
workspaceId: WORKSPACE_ID,
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
modelPolicyJson: policyForFullAccessBuiltinTools(),
quotaJson: { daily: 200 },
timeoutMs: TIMEOUT,
});
await requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`);
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status && status.session && status.session.sessionId;
if (!sessionId) return null;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
return sessionId;
}
async function startRealPi(page) {
const sessionId = `pi-full-access-builtin-tools-${STAMP}`;
const start = await requestJson(page, "/api/page-ai/pi/start", {
method: "POST",
data: {
sessionId,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
pagePath: PAGE_PATH,
pageTitle: "Pi full access builtin tools smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel: "medium",
permissionMode: "full_access",
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.permissionMode, "full_access", "start response should expose full_access mode");
assert.equal(start.session.runtimePolicySnapshot.permissionMode, "full_access", "runtime policy should persist full_access");
assert.equal(start.session.runtimeMode, "rpc", `Pi 必须以 rpc 模式启动,实际=${start.session.runtimeMode}`);
assert(start.session.runtimePid, "真实 Pi RPC 启动后应有 runtimePid");
start.session.managedPiBuiltinTools = start.managedPiBuiltinTools || [];
return start.session;
}
async function openPiUi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return /ready|running|streaming/.test(text);
}, null, { timeout: TIMEOUT }).catch(() => null);
}
function readSessionJsonl(sessionDir) {
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(full);
}
}
};
walk(sessionDir);
files.sort();
const sessionFile = files[files.length - 1];
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
}
function readBuiltinToolResults(raw) {
return raw
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return undefined;
}
})
.filter((entry) => entry?.type === "message"
&& entry?.message?.role === "toolResult"
&& BUILTIN_TOOLS.includes(entry.message.toolName))
.map((entry) => entry.message);
}
async function waitForCompleteSessionJsonl(sessionDir) {
const deadline = Date.now() + Math.min(TIMEOUT, 15000);
let snapshot = readSessionJsonl(sessionDir);
while (Date.now() < deadline) {
const calledTools = BUILTIN_TOOLS.filter((tool) => snapshot.raw.includes(`"name":"${tool}"`));
if (calledTools.includes("ls")
&& calledTools.includes("read")
&& calledTools.includes("bash")
&& !snapshot.raw.includes('"name":"mnote_local_file_read"')
&& snapshot.raw.includes(MARKER)) {
return snapshot;
}
await new Promise((resolve) => setTimeout(resolve, 100));
snapshot = readSessionJsonl(sessionDir);
}
return snapshot;
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
const page = await context.newPage();
const consoleMessages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const result = {
base: BASE,
outputDir: OUT,
marker: MARKER,
rootUri: ROOT_URI,
screenshots: {},
checks: {},
consoleMessages,
};
try {
await quickLogin(page);
await seedWorkspace(page);
await abortExistingSession(page);
const session = await startRealPi(page);
result.session = {
sessionId: session.sessionId,
runtimePid: session.runtimePid,
piSessionDir: session.piSessionDir,
runtimePolicySnapshot: session.runtimePolicySnapshot,
};
const enabledSources = session.runtimePolicySnapshot.enabledPiExtensionSources || [];
const permissionConfigPath = path.join(session.piSessionDir, "config", "extensions", "pi-permission-system", "config.json");
result.permissionConfigPath = permissionConfigPath;
result.checks.permissionSystemConfigAbsent = !fs.existsSync(permissionConfigPath);
result.checks.noLegacyNpmAskUser = !enabledSources.includes("npm:pi-ask-user");
result.checks.noExternalPermissionSystem = !enabledSources.includes("npm:@gotgenes/pi-permission-system");
result.checks.officialPermissionGateConfigured = enabledSources.includes("pi-rust-official:permission-gate");
result.checks.managedBuiltinToolsAtStart = session.managedPiBuiltinTools || [];
assert.equal(result.checks.permissionSystemConfigAbsent, true, "full_access smoke should not generate legacy pi-permission-system config");
assert.equal(result.checks.noLegacyNpmAskUser, true, "full_access smoke should not use unavailable npm:pi-ask-user");
assert.equal(result.checks.noExternalPermissionSystem, true, "full_access smoke should not load incompatible pi-permission-system");
assert.equal(result.checks.officialPermissionGateConfigured, true, "policy should include Pi Rust official permission-gate");
assert.deepEqual(
[...result.checks.managedBuiltinToolsAtStart].sort(),
[...BUILTIN_TOOLS].sort(),
"full_access should expose Pi Rust official built-in tools instead of replacing them with MNote file tools",
);
await openPiUi(page);
result.checks.attachmentButtonsEnabled = await page.locator('[data-page-ai-pi-lab-menu-action="attach-file"]:not([disabled])').count() === 1
&& await page.locator('[data-page-ai-pi-lab-menu-action="camera"]:not([disabled])').count() === 1;
const rpcState = await requestJson(page, "/api/page-ai/pi/rpc-command", {
method: "POST",
data: {
sessionId: session.sessionId,
type: "get_state",
params: {},
timeoutMs: 10000,
},
});
result.checks.rpcCommandGetStateOk = rpcState.ok === true && rpcState.command === "get_state";
assert.equal(result.checks.attachmentButtonsEnabled, true, "Pi RPC 图片附件入口应该可用");
assert.equal(result.checks.rpcCommandGetStateOk, true, "受控 Pi RPC command wrapper 应能调用官方 get_state");
await page.screenshot({ path: path.join(OUT, "01-full-access-started.png"), fullPage: false });
result.screenshots.started = path.join(OUT, "01-full-access-started.png");
const prompt = [
"当前是 full_access 验收。必须真实调用 Pi Rust 内置工具,不能只描述。",
"先调用 ls 列出当前工作目录,再调用 read 读取当前页文件。",
"然后调用 bash 执行 pwd。确认当前页内容包含 BUILTIN_READ_MARKER。",
"不要调用 mnote_local_file_read 或 mnote_local_file_patchMNote 工具只用于上下文和知识库,不应替代 Pi 原生文件工具。",
`最终单独输出一行:${MARKER}`,
].join("\n");
await page.locator("[data-page-ai-pi-lab-input]").fill(prompt);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const markerLocator = page
.locator('[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, [data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown')
.filter({ hasText: MARKER })
.last();
const startedAt = Date.now();
while (Date.now() - startedAt < TIMEOUT) {
const permissionVisible = await page.locator("text=Permission Required").count();
assert.equal(permissionVisible, 0, "official permission-gate smoke should not ask legacy Permission Required dialog");
if (await markerLocator.isVisible({ timeout: 500 }).catch(() => false)) break;
await page.waitForTimeout(500);
}
await markerLocator.waitFor({ state: "visible", timeout: 1000 });
await page.screenshot({ path: path.join(OUT, "02-full-access-builtin-tools-answer.png"), fullPage: false });
result.screenshots.answer = path.join(OUT, "02-full-access-builtin-tools-answer.png");
const sessionJsonl = await waitForCompleteSessionJsonl(session.piSessionDir);
result.session.sessionFile = sessionJsonl.sessionFile;
result.answerText = ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
result.checks.calledBuiltinTools = BUILTIN_TOOLS.filter((tool) => sessionJsonl.raw.includes(`"name":"${tool}"`));
result.checks.calledMnoteLocalFileRead = sessionJsonl.raw.includes('"name":"mnote_local_file_read"');
result.checks.calledMnoteLocalFilePatch = sessionJsonl.raw.includes('"name":"mnote_local_file_patch"');
const builtinToolResults = readBuiltinToolResults(sessionJsonl.raw);
result.checks.builtinToolResultCount = builtinToolResults.length;
result.checks.failedBuiltinTools = builtinToolResults
.filter((message) => message.isError === true)
.map((message) => message.toolName);
result.checks.readContainsMarker = sessionJsonl.raw.includes("BUILTIN_READ_MARKER");
result.checks.lsSawPage = sessionJsonl.raw.includes(PAGE_PATH);
result.checks.noBridgeSessionFailure = !/page_ai_pi_lab_session_not_found|page_ai_pi_lab_bridge_token_invalid|mnote_pi_rust_service_bridge_unavailable|mnote_pi_bridge_session_id_missing/i.test(sessionJsonl.raw);
result.checks.scratchContent = fs.existsSync(path.join(ROOT_PATH, SCRATCH_PATH))
? fs.readFileSync(path.join(ROOT_PATH, SCRATCH_PATH), "utf8")
: "";
result.checks.noPermissionRequiredPrompt = await page.locator("text=Permission Required").count() === 0;
assert(result.checks.calledBuiltinTools.includes("ls"), "full_access should allow Pi Rust builtin ls");
assert(result.checks.calledBuiltinTools.includes("read"), "full_access should allow Pi Rust builtin read");
assert(result.checks.calledBuiltinTools.includes("bash"), "full_access should allow Pi Rust builtin bash");
assert.equal(result.checks.calledMnoteLocalFileRead, false, "Pi Rust builtin read must not be replaced by mnote_local_file_read");
assert.equal(result.checks.calledMnoteLocalFilePatch, false, "This smoke must not use mnote_local_file_patch");
assert.equal(result.checks.readContainsMarker, true, "Pi Rust builtin read result should contain the seeded page marker");
assert.equal(result.checks.lsSawPage, true, "Pi Rust builtin ls should list the seeded page file");
assert.equal(result.checks.noBridgeSessionFailure, true, "MNote bridge context must not fail while Pi builtins are available");
assert.equal(result.checks.scratchContent, "", "negative full_access smoke should not create scratch files via raw builtins");
assert(result.checks.noPermissionRequiredPrompt, "official permission-gate smoke should not show legacy Permission Required prompt");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: session.sessionId } }).catch(() => ({}));
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
}
}
main();