forked from bai/curriculum-project-hub
feat(hub): add markdown_to_pdf tool and default web tools
Teachers need ad-hoc Markdown → PDF. Ship an MCP tool powered by md-to-pdf (Marked + headless Chrome) so remote images/CSS work, with workspace-scoped basedir, front-matter stripped so untrusted markdown cannot override dest/basedir/launch options, and MathJax for $/$ math. Also include WebFetch and WebSearch in the unrestricted role tool surface by default. Deploy skips Puppeteer's browser download and expects a host Chrome/Chromium (PUPPETEER_EXECUTABLE_PATH / CHROME_PATH).
This commit is contained in:
@@ -10,12 +10,15 @@ export const TOOL_OPTIONS: ToolOption[] = [
|
||||
{ id: 'list_files', label: '列目录', group: '文件' },
|
||||
{ id: 'search_files', label: '搜索', group: '文件' },
|
||||
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
||||
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
|
||||
{ id: 'web_search', label: 'WebSearch', group: '网络' },
|
||||
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
||||
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
||||
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
|
||||
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
|
||||
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
|
||||
{ id: 'request_approval', label: '请求审批', group: '飞书' },
|
||||
{ id: 'markdown_to_pdf', label: 'Markdown → PDF', group: '文档' },
|
||||
];
|
||||
|
||||
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel) */
|
||||
|
||||
@@ -95,7 +95,7 @@ flock /var/lock/cph-hub-release-publish bash -c '
|
||||
exit 0
|
||||
fi
|
||||
cd "$HUB_DIR"
|
||||
npm ci
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
|
||||
npm ci --prefix admin-web
|
||||
npm run audit:production
|
||||
npm run build
|
||||
|
||||
@@ -63,7 +63,7 @@ if [ "$release_ready" = false ]; then
|
||||
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
|
||||
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
||||
"cd '$HUB_DIR' && npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
fi
|
||||
|
||||
# 3. Ensure the service is installed (idempotent), then restart.
|
||||
|
||||
Generated
+1491
-5
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"ai": "^7.0.16",
|
||||
"dotenv": "^17.4.2",
|
||||
"fastify": "^5.8.5",
|
||||
"md-to-pdf": "^5.2.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Markdown → PDF via md-to-pdf (Marked + headless Chrome).
|
||||
*
|
||||
* Input/output paths are confined to the project workspace. The package's local
|
||||
* file server uses `basedir = workspaceDir` so relative media resolve inside
|
||||
* the project, and remote images/styles load over the network. Front-matter is
|
||||
* stripped so untrusted markdown cannot override basedir/dest/launch options.
|
||||
*/
|
||||
import { access } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { mdToPdf } from "md-to-pdf";
|
||||
|
||||
const DEFAULT_MAX_MARKDOWN_BYTES = 2 * 1024 * 1024;
|
||||
const RENDER_TIMEOUT_MS = 120_000;
|
||||
|
||||
const DEFAULT_CSS = `
|
||||
body {
|
||||
font-family: "Noto Sans CJK SC", "Source Han Sans SC", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||
font-size: 12pt;
|
||||
line-height: 1.65;
|
||||
color: #111;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
img, svg, video { max-width: 100%; height: auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.4em 0.6em; vertical-align: top; }
|
||||
th { background: #f5f5f5; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
|
||||
pre { overflow: auto; padding: 0.75em 1em; background: #f6f8fa; border-radius: 6px; }
|
||||
blockquote { margin-left: 0; padding-left: 1em; border-left: 4px solid #ddd; color: #444; }
|
||||
.page-break { page-break-after: always; break-after: page; }
|
||||
`;
|
||||
|
||||
const MATHJAX_CONFIG = `
|
||||
window.MathJax = {
|
||||
tex: {
|
||||
inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],
|
||||
displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],
|
||||
processEscapes: true,
|
||||
},
|
||||
options: {
|
||||
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'],
|
||||
},
|
||||
};
|
||||
`;
|
||||
|
||||
const SYSTEM_CHROME_CANDIDATES = [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/chromium",
|
||||
"/snap/bin/chromium",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
] as const;
|
||||
|
||||
export class MarkdownToPdfError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly reason: "boundary" | "not_found" | "limit" | "config" | "compile" | "io" = "io",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MarkdownToPdfError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarkdownToPdfInput {
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly markdownPath: string;
|
||||
readonly outputPath?: string | undefined;
|
||||
readonly chromePath?: string | undefined;
|
||||
readonly maxMarkdownBytes?: number | undefined;
|
||||
}
|
||||
|
||||
export interface MarkdownToPdfResult {
|
||||
readonly outputPath: string;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
export function defaultMarkdownToPdfOutputPath(markdownPath: string): string {
|
||||
const base = basename(markdownPath.trim());
|
||||
const stem = base === "" || base === "." || base === ".."
|
||||
? "document"
|
||||
: basename(base, extname(base)) || "document";
|
||||
return join("build", `${stem}.pdf`);
|
||||
}
|
||||
|
||||
export async function renderMarkdownToPdf(input: MarkdownToPdfInput): Promise<MarkdownToPdfResult> {
|
||||
const workspaceRoot = input.workspaceRoot.trim();
|
||||
if (workspaceRoot === "") {
|
||||
throw new MarkdownToPdfError("markdown_to_pdf requires a configured workspace root", "config");
|
||||
}
|
||||
const workspaceDir = input.workspaceDir.trim();
|
||||
if (workspaceDir === "") {
|
||||
throw new MarkdownToPdfError("markdown_to_pdf requires a configured workspace directory", "config");
|
||||
}
|
||||
|
||||
const maxMarkdownBytes = input.maxMarkdownBytes ?? DEFAULT_MAX_MARKDOWN_BYTES;
|
||||
const markdownAbsolute = await resolveReadableWorkspaceFile(
|
||||
workspaceRoot,
|
||||
workspaceDir,
|
||||
input.markdownPath,
|
||||
maxMarkdownBytes,
|
||||
);
|
||||
// Strip front-matter: md-to-pdf merges YAML into config and would otherwise
|
||||
// let untrusted markdown override basedir/dest/launch_options.
|
||||
const markdownBody = stripYamlFrontMatter(await readFile(markdownAbsolute, "utf8"));
|
||||
|
||||
const outputRelative = input.outputPath?.trim() || defaultMarkdownToPdfOutputPath(input.markdownPath);
|
||||
const outputAbsolute = confineWorkspacePath(outputRelative, workspaceDir);
|
||||
if (!outputAbsolute.toLowerCase().endsWith(".pdf")) {
|
||||
throw new MarkdownToPdfError(`output path must end with .pdf: ${outputRelative}`, "config");
|
||||
}
|
||||
|
||||
const workspaceReal = await realpath(workspaceDir);
|
||||
const markdownDir = dirname(markdownAbsolute);
|
||||
// Content-mode relativePath is "."; serve the markdown's directory so
|
||||
// sibling relative media work, without exposing parent of the project.
|
||||
const markdownDirReal = await realpath(markdownDir);
|
||||
const dirRel = relative(workspaceReal, markdownDirReal);
|
||||
if (dirRel === ".." || dirRel.startsWith(`..${sep}`) || isAbsolute(dirRel)) {
|
||||
throw new MarkdownToPdfError("markdown directory escapes workspace", "boundary");
|
||||
}
|
||||
await mkdir(dirname(outputAbsolute), { recursive: true });
|
||||
|
||||
const chromePath = input.chromePath?.trim()
|
||||
|| process.env["PUPPETEER_EXECUTABLE_PATH"]?.trim()
|
||||
|| process.env["CHROME_PATH"]?.trim()
|
||||
|| process.env["GOOGLE_CHROME_BIN"]?.trim()
|
||||
|| await findSystemChrome();
|
||||
|
||||
const launchOptions: Record<string, unknown> = {
|
||||
headless: true,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--font-render-hinting=medium",
|
||||
"--disable-gpu",
|
||||
],
|
||||
};
|
||||
if (chromePath !== undefined && chromePath !== "") {
|
||||
launchOptions.executablePath = chromePath;
|
||||
}
|
||||
|
||||
let pdf;
|
||||
try {
|
||||
pdf = await withTimeout(
|
||||
mdToPdf(
|
||||
{ content: markdownBody },
|
||||
{
|
||||
// Local images resolve relative to the markdown file's directory.
|
||||
basedir: markdownDirReal,
|
||||
dest: outputAbsolute,
|
||||
body_class: ["markdown-body"],
|
||||
css: DEFAULT_CSS,
|
||||
script: [
|
||||
{ content: MATHJAX_CONFIG },
|
||||
{ url: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" },
|
||||
],
|
||||
pdf_options: {
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: { top: "18mm", right: "16mm", bottom: "18mm", left: "16mm" },
|
||||
},
|
||||
launch_options: launchOptions,
|
||||
// gray-matter JS engines stay off (default). Front-matter already stripped.
|
||||
},
|
||||
),
|
||||
RENDER_TIMEOUT_MS,
|
||||
"md-to-pdf timed out",
|
||||
);
|
||||
} catch (error) {
|
||||
throw new MarkdownToPdfError(`markdown pdf render failed: ${formatError(error)}`, "compile");
|
||||
}
|
||||
|
||||
if (pdf === undefined || pdf.content === undefined) {
|
||||
throw new MarkdownToPdfError("markdown pdf render produced no output", "compile");
|
||||
}
|
||||
|
||||
const bytes = Buffer.isBuffer(pdf.content)
|
||||
? pdf.content
|
||||
: Buffer.from(pdf.content as Uint8Array);
|
||||
if (!bytes.subarray(0, 4).equals(Buffer.from("%PDF"))) {
|
||||
// md-to-pdf may have written dest already; re-read as source of truth.
|
||||
const written = await readFile(outputAbsolute);
|
||||
if (!written.subarray(0, 4).equals(Buffer.from("%PDF"))) {
|
||||
throw new MarkdownToPdfError("markdown pdf render produced a non-PDF artifact", "compile");
|
||||
}
|
||||
return {
|
||||
outputPath: relative(workspaceDir, outputAbsolute).split(sep).join("/"),
|
||||
bytes: written.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure dest holds the bytes even if the package's write path differed.
|
||||
await writeFile(outputAbsolute, bytes);
|
||||
return {
|
||||
outputPath: relative(workspaceDir, outputAbsolute).split(sep).join("/"),
|
||||
bytes: bytes.length,
|
||||
};
|
||||
}
|
||||
|
||||
function stripYamlFrontMatter(source: string): string {
|
||||
if (!source.startsWith("---\n") && !source.startsWith("---\r\n")) return source;
|
||||
const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(source);
|
||||
if (match === null) return source;
|
||||
return source.slice(match[0].length);
|
||||
}
|
||||
|
||||
function confineWorkspacePath(requestedPath: string, workspaceDir: string): string {
|
||||
const workspace = resolve(workspaceDir);
|
||||
const resolved = isAbsolute(requestedPath)
|
||||
? resolve(requestedPath)
|
||||
: resolve(workspace, requestedPath);
|
||||
const rel = relative(workspace, resolved);
|
||||
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new MarkdownToPdfError(`path escapes workspace: ${requestedPath}`, "boundary");
|
||||
}
|
||||
if (rel.split(sep)[0] === ".cph") {
|
||||
throw new MarkdownToPdfError(
|
||||
`platform runtime paths are not valid markdown_to_pdf targets: ${requestedPath}`,
|
||||
"boundary",
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function resolveReadableWorkspaceFile(
|
||||
workspaceRoot: string,
|
||||
workspaceDir: string,
|
||||
requestedPath: string,
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
const root = resolve(workspaceRoot);
|
||||
const workspace = resolve(workspaceDir);
|
||||
const workspaceRel = relative(root, workspace);
|
||||
if (
|
||||
workspaceRel === "" ||
|
||||
workspaceRel === ".." ||
|
||||
workspaceRel.startsWith(`..${sep}`) ||
|
||||
isAbsolute(workspaceRel)
|
||||
) {
|
||||
throw new MarkdownToPdfError("project workspace escapes configured workspace root", "boundary");
|
||||
}
|
||||
|
||||
const candidate = confineWorkspacePath(requestedPath, workspace);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await lstat(candidate);
|
||||
} catch (error) {
|
||||
if (isErrno(error, "ENOENT")) {
|
||||
throw new MarkdownToPdfError(`markdown file not found: ${requestedPath}`, "not_found");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new MarkdownToPdfError(`markdown path must not be a symlink: ${requestedPath}`, "boundary");
|
||||
}
|
||||
if (!metadata.isFile()) {
|
||||
throw new MarkdownToPdfError(`markdown path is not a regular file: ${requestedPath}`, "boundary");
|
||||
}
|
||||
if (metadata.size > maxBytes) {
|
||||
throw new MarkdownToPdfError(
|
||||
`markdown file exceeds ${maxBytes} bytes: ${requestedPath}`,
|
||||
"limit",
|
||||
);
|
||||
}
|
||||
|
||||
const realFile = await realpath(candidate);
|
||||
const realWorkspace = await realpath(workspace);
|
||||
const realRel = relative(realWorkspace, realFile);
|
||||
if (realRel === "" || realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
|
||||
throw new MarkdownToPdfError(
|
||||
`markdown path escapes workspace after resolution: ${requestedPath}`,
|
||||
"boundary",
|
||||
);
|
||||
}
|
||||
return realFile;
|
||||
}
|
||||
|
||||
async function findSystemChrome(): Promise<string | undefined> {
|
||||
for (const candidate of SYSTEM_CHROME_CANDIDATES) {
|
||||
try {
|
||||
await access(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return new Promise<T>((resolvePromise, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${message} after ${ms}ms`)), ms);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim() !== "") return error.message.trim();
|
||||
return String(error);
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const;
|
||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
] as const;
|
||||
|
||||
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
||||
export const CPH_HUB_MCP_TOOL_IDS = [
|
||||
@@ -6,6 +14,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
|
||||
"feishu_read_context",
|
||||
"feishu_download_resource",
|
||||
"request_approval",
|
||||
"markdown_to_pdf",
|
||||
] as const;
|
||||
|
||||
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
||||
@@ -26,11 +35,15 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
|
||||
// would need a separate command-policy layer.
|
||||
["cph_check", ["Bash"]],
|
||||
["cph_build", ["Bash"]],
|
||||
["web_fetch", ["WebFetch"]],
|
||||
["web_search", ["WebSearch"]],
|
||||
["Read", ["Read"]],
|
||||
["Write", ["Write"]],
|
||||
["Bash", ["Bash"]],
|
||||
["Glob", ["Glob"]],
|
||||
["Grep", ["Grep"]],
|
||||
["WebFetch", ["WebFetch"]],
|
||||
["WebSearch", ["WebSearch"]],
|
||||
]);
|
||||
|
||||
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||
@@ -38,10 +51,12 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||
["feishu_read_context", "feishu_read_context"],
|
||||
["feishu_download_resource", "feishu_download_resource"],
|
||||
["request_approval", "request_approval"],
|
||||
["markdown_to_pdf", "markdown_to_pdf"],
|
||||
["mcp__cph_hub__send_file", "send_file"],
|
||||
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
|
||||
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
|
||||
["mcp__cph_hub__request_approval", "request_approval"],
|
||||
["mcp__cph_hub__markdown_to_pdf", "markdown_to_pdf"],
|
||||
]);
|
||||
|
||||
const SUPPORTED_ROLE_TOOLS = new Set([
|
||||
|
||||
@@ -42,6 +42,9 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
request_approval: "thumb-up-filled",
|
||||
feishu_read_context: "search-filled",
|
||||
feishu_download_resource: "download-filled",
|
||||
markdown_to_pdf: "file-link-excel-filled",
|
||||
webfetch: "link-copy-filled",
|
||||
websearch: "search-filled",
|
||||
};
|
||||
|
||||
function toolIcon(toolName: string): string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { downloadFeishuMessageResource } from "./download.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
import type { ApprovalManager } from "./approval.js";
|
||||
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
|
||||
import { MarkdownToPdfError, renderMarkdownToPdf } from "../agent/markdownToPdf.js";
|
||||
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
|
||||
|
||||
export interface FileDeliveryToolOptions {
|
||||
@@ -182,18 +183,18 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
tools.push(
|
||||
tool(
|
||||
"request_approval",
|
||||
"Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.",
|
||||
"Send an interactive Feishu approval/confirmation card in the current chat and wait for the user's button click.",
|
||||
{
|
||||
title: z.string().describe("Card title."),
|
||||
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
|
||||
options: z
|
||||
.array(z.object({
|
||||
label: z.string().describe("Button label shown to the user."),
|
||||
value: z.string().describe("Action value returned to the agent when this button is clicked."),
|
||||
style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."),
|
||||
value: z.string().describe("Stable option value returned after the user clicks."),
|
||||
style: z.enum(["default", "primary", "danger"]).optional(),
|
||||
}))
|
||||
.min(1)
|
||||
.describe("Button choices for the approval card."),
|
||||
.describe("One or more response options."),
|
||||
},
|
||||
async (args) => {
|
||||
const messageId = await sendApprovalCard(
|
||||
@@ -230,6 +231,76 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("markdown_to_pdf")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"markdown_to_pdf",
|
||||
"Render a Markdown file in the current project workspace to PDF (CommonMark/GFM via md-to-pdf + headless Chrome). Supports external image/CSS URLs, local images relative to the project root, and LaTeX math ($...$ / $$...$$). path is workspace-relative; output defaults to build/<name>.pdf.",
|
||||
{
|
||||
path: z.string().describe("Workspace-relative Markdown path (or absolute path inside the workspace)."),
|
||||
output: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Workspace-relative PDF output path. Defaults to build/<markdown-basename>.pdf."),
|
||||
},
|
||||
async (args) => {
|
||||
const workspaceRoot = options.workspaceRoot?.trim();
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") {
|
||||
options.rt.logger.error(
|
||||
{ runId: options.runId, projectId: options.projectId },
|
||||
"markdown_to_pdf missing configured workspace root",
|
||||
);
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "markdown_to_pdf is unavailable because workspace isolation is not configured." }],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await renderMarkdownToPdf({
|
||||
workspaceRoot,
|
||||
workspaceDir: options.workspaceDir,
|
||||
markdownPath: args.path,
|
||||
...(args.output === undefined ? {} : { outputPath: args.output }),
|
||||
...(options.maxFileBytes === undefined ? {} : { maxMarkdownBytes: options.maxFileBytes }),
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
path: result.outputPath,
|
||||
bytes: result.bytes,
|
||||
}),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownToPdfError) {
|
||||
const log = error.reason === "compile" || error.reason === "config"
|
||||
? options.rt.logger.warn.bind(options.rt.logger)
|
||||
: options.rt.logger.error.bind(options.rt.logger);
|
||||
log(
|
||||
{
|
||||
runId: options.runId,
|
||||
projectId: options.projectId,
|
||||
requestedPath: args.path,
|
||||
reason: error.reason,
|
||||
err: error,
|
||||
},
|
||||
"markdown_to_pdf failed",
|
||||
);
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: error.message }],
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const instructions = mcpInstructions(enabledTools);
|
||||
return createSdkMcpServer({
|
||||
name: "cph_hub",
|
||||
@@ -260,5 +331,11 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
if (enabledTools.has("request_approval")) {
|
||||
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
|
||||
}
|
||||
if (enabledTools.has("markdown_to_pdf")) {
|
||||
instructions.push(
|
||||
"Use markdown_to_pdf to convert a workspace Markdown file into a PDF (GFM + external resources + LaTeX math).",
|
||||
"After rendering, call send_file with the returned PDF path if the user should receive the file in chat.",
|
||||
);
|
||||
}
|
||||
return instructions.join(" ");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { access, constants } from "node:fs";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultMarkdownToPdfOutputPath,
|
||||
MarkdownToPdfError,
|
||||
renderMarkdownToPdf,
|
||||
} from "../../src/agent/markdownToPdf.js";
|
||||
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
|
||||
|
||||
const accessAsync = promisify(access);
|
||||
const roots: string[] = [];
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env["PUPPETEER_EXECUTABLE_PATH"],
|
||||
process.env["CHROME_PATH"],
|
||||
process.env["GOOGLE_CHROME_BIN"],
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].filter((v): v is string => typeof v === "string" && v.trim() !== "");
|
||||
|
||||
afterEach(async () => {
|
||||
while (roots.length > 0) {
|
||||
const root = roots.pop();
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("role tool defaults", () => {
|
||||
it("includes WebFetch, WebSearch, and markdown_to_pdf in the unrestricted surface", () => {
|
||||
const config = claudeSdkToolConfigForRole(undefined);
|
||||
expect(config.tools).toEqual([
|
||||
"Read",
|
||||
"Write",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
]);
|
||||
expect(config.allowedTools).toContain("WebFetch");
|
||||
expect(config.allowedTools).toContain("WebSearch");
|
||||
expect(config.allowedTools).toContain("mcp__cph_hub__markdown_to_pdf");
|
||||
expect(cphHubMcpToolsForRole(undefined)).toContain("markdown_to_pdf");
|
||||
});
|
||||
|
||||
it("maps explicit markdown_to_pdf and web tools", () => {
|
||||
expect(claudeSdkToolConfigForRole(["web_fetch", "web_search", "markdown_to_pdf"])).toEqual({
|
||||
tools: ["WebFetch", "WebSearch"],
|
||||
allowedTools: ["WebFetch", "WebSearch", "mcp__cph_hub__markdown_to_pdf"],
|
||||
});
|
||||
expect(cphHubMcpToolsForRole(["markdown_to_pdf"])).toEqual(["markdown_to_pdf"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMarkdownToPdf", () => {
|
||||
it("defaults the output next to build/<stem>.pdf", () => {
|
||||
expect(defaultMarkdownToPdfOutputPath("notes/handout.md")).toBe(join("build", "handout.pdf"));
|
||||
expect(defaultMarkdownToPdfOutputPath("README")).toBe(join("build", "README.pdf"));
|
||||
});
|
||||
|
||||
it("renders markdown through md-to-pdf with a system Chrome", async () => {
|
||||
let chromePath: string | undefined;
|
||||
for (const candidate of CHROME_CANDIDATES) {
|
||||
try {
|
||||
await accessAsync(candidate, constants.X_OK);
|
||||
chromePath = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
if (chromePath === undefined) {
|
||||
// No browser on this host — mapping tests above still cover the contract.
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-test-"));
|
||||
roots.push(root);
|
||||
const workspace = join(root, "project");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
await writeFile(
|
||||
join(workspace, "lesson.md"),
|
||||
[
|
||||
"# 测试讲义",
|
||||
"",
|
||||
"段落里有行内数学 $E=mc^2$。",
|
||||
"",
|
||||
"$$",
|
||||
"\\int_0^1 x\\,dx = \\frac{1}{2}",
|
||||
"$$",
|
||||
"",
|
||||
"- item a",
|
||||
"- item b",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await renderMarkdownToPdf({
|
||||
workspaceRoot: root,
|
||||
workspaceDir: workspace,
|
||||
markdownPath: "lesson.md",
|
||||
outputPath: "build/lesson.pdf",
|
||||
chromePath,
|
||||
});
|
||||
|
||||
expect(result.outputPath).toBe("build/lesson.pdf");
|
||||
expect(result.bytes).toBeGreaterThan(500);
|
||||
const pdf = await readFile(join(workspace, "build", "lesson.pdf"));
|
||||
expect(pdf.subarray(0, 4).toString("utf8")).toBe("%PDF");
|
||||
}, 120_000);
|
||||
|
||||
it("refuses output outside the workspace", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-boundary-"));
|
||||
roots.push(root);
|
||||
const workspace = join(root, "project");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
await writeFile(join(workspace, "a.md"), "# a\n", "utf8");
|
||||
|
||||
await expect(
|
||||
renderMarkdownToPdf({
|
||||
workspaceRoot: root,
|
||||
workspaceDir: workspace,
|
||||
markdownPath: "a.md",
|
||||
outputPath: "../escape.pdf",
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "boundary" } satisfies Partial<MarkdownToPdfError>);
|
||||
});
|
||||
|
||||
it("ignores front-matter that would override basedir/dest", async () => {
|
||||
let chromePath: string | undefined;
|
||||
for (const candidate of CHROME_CANDIDATES) {
|
||||
try {
|
||||
await accessAsync(candidate, constants.X_OK);
|
||||
chromePath = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
if (chromePath === undefined) return;
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-fm-"));
|
||||
roots.push(root);
|
||||
const workspace = join(root, "project");
|
||||
await mkdir(workspace, { recursive: true });
|
||||
await writeFile(
|
||||
join(workspace, "evil.md"),
|
||||
[
|
||||
"---",
|
||||
"basedir: /",
|
||||
"dest: /tmp/should-not-write.pdf",
|
||||
"---",
|
||||
"",
|
||||
"# safe body",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await renderMarkdownToPdf({
|
||||
workspaceRoot: root,
|
||||
workspaceDir: workspace,
|
||||
markdownPath: "evil.md",
|
||||
outputPath: "build/safe.pdf",
|
||||
chromePath,
|
||||
});
|
||||
expect(result.outputPath).toBe("build/safe.pdf");
|
||||
const pdf = await readFile(join(workspace, "build", "safe.pdf"));
|
||||
expect(pdf.subarray(0, 4).toString("utf8")).toBe("%PDF");
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in New Issue
Block a user