ext-apps/examples/pdf-server/server.test.ts
Olivier Chafik 3cf5b588a1
chore(pdf-server): switch pdf-lib to maintained @cantoo/pdf-lib fork (#651)
The original pdf-lib package has been unmaintained since Nov 2021.
@cantoo/pdf-lib is an actively maintained, API-compatible fork with
ongoing releases and accumulated bugfixes.

Also updates test assertions from the deprecated PDFNumber.value() to
PDFNumber.asNumber() per the fork's API guidance.
2026-05-08 13:01:54 +01:00

1818 lines
63 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { PDFDocument } from "@cantoo/pdf-lib";
import { makeRandomJpeg } from "../../tests/helpers/range-counting-server";
import {
createPdfCache,
createServer,
extractFormSchema,
PdfCacheRangeTransport,
MAX_CHUNK_BYTES,
validateUrl,
isAncestorDir,
allowedLocalFiles,
allowedLocalDirs,
pathToFileUrl,
startFileWatch,
stopFileWatch,
cliLocalFiles,
isWritablePath,
writeFlags,
viewSourcePaths,
CACHE_INACTIVITY_TIMEOUT_MS,
CACHE_MAX_LIFETIME_MS,
CACHE_MAX_PDF_SIZE_BYTES,
type PdfCache,
} from "./server";
describe("PDF Cache with Timeouts", () => {
let pdfCache: PdfCache;
beforeEach(() => {
// Each test gets its own session-local cache
pdfCache = createPdfCache();
});
afterEach(() => {
pdfCache.clearCache();
});
describe("cache configuration", () => {
it("should have 10 second inactivity timeout", () => {
expect(CACHE_INACTIVITY_TIMEOUT_MS).toBe(10_000);
});
it("should have 60 second max lifetime timeout", () => {
expect(CACHE_MAX_LIFETIME_MS).toBe(60_000);
});
it("should have 50MB max PDF size limit", () => {
expect(CACHE_MAX_PDF_SIZE_BYTES).toBe(50 * 1024 * 1024);
});
});
describe("cache management", () => {
it("should start with empty cache", () => {
expect(pdfCache.getCacheSize()).toBe(0);
});
it("should clear all entries", () => {
pdfCache.clearCache();
expect(pdfCache.getCacheSize()).toBe(0);
});
it("should isolate caches between sessions", () => {
// Create two independent cache instances
const cache1 = createPdfCache();
const cache2 = createPdfCache();
// They should be independent (both start empty)
expect(cache1.getCacheSize()).toBe(0);
expect(cache2.getCacheSize()).toBe(0);
});
});
describe("byte-cap LRU eviction", () => {
const tenBytes = new Uint8Array(10);
async function fill(cache: PdfCache, url: string): Promise<void> {
const m = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(tenBytes, { status: 200 }),
);
try {
await cache.readPdfRange(url, 0, 1024);
} finally {
m.mockRestore();
}
}
it("evicts least-recently-used entry when total exceeds cap", async () => {
const cache = createPdfCache(25);
try {
await fill(cache, "https://arxiv.org/pdf/a");
await fill(cache, "https://arxiv.org/pdf/b");
expect(cache.getCacheSize()).toBe(2);
// Touch A so B becomes least-recently-used
await cache.readPdfRange("https://arxiv.org/pdf/a", 0, 1);
// Inserting C (10B) pushes total to 30 > 25 → evict LRU (B)
await fill(cache, "https://arxiv.org/pdf/c");
expect(cache.getCacheSize()).toBe(2);
// A and C still served from cache; B re-fetches
const m = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(tenBytes, { status: 200 }),
);
try {
await cache.readPdfRange("https://arxiv.org/pdf/a", 0, 1);
await cache.readPdfRange("https://arxiv.org/pdf/c", 0, 1);
expect(m).toHaveBeenCalledTimes(0);
await cache.readPdfRange("https://arxiv.org/pdf/b", 0, 1);
expect(m).toHaveBeenCalledTimes(1);
} finally {
m.mockRestore();
}
} finally {
cache.clearCache();
}
});
it("evicts multiple entries if a single insert exceeds the cap", async () => {
const cache = createPdfCache(25);
try {
await fill(cache, "https://arxiv.org/pdf/a");
await fill(cache, "https://arxiv.org/pdf/b");
const big = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(new Uint8Array(20), { status: 200 }),
);
try {
await cache.readPdfRange("https://arxiv.org/pdf/big", 0, 1024);
} finally {
big.mockRestore();
}
expect(cache.getCacheSize()).toBe(1);
} finally {
cache.clearCache();
}
});
});
describe("readPdfRange caching behavior", () => {
const testUrl = "https://arxiv.org/pdf/test-pdf";
const testData = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF header
it("should cache full body when server returns HTTP 200", async () => {
// Mock fetch to return HTTP 200 (full body, no range support)
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(testData, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
try {
// First request - should fetch and cache
const result1 = await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(result1.data).toEqual(testData);
expect(result1.totalBytes).toBe(testData.length);
expect(pdfCache.getCacheSize()).toBe(1);
// Second request - should serve from cache (no new fetch)
const result2 = await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(result2.data).toEqual(testData);
expect(mockFetch).toHaveBeenCalledTimes(1); // Only one fetch call
} finally {
mockFetch.mockRestore();
}
});
it("should not cache when server returns HTTP 206 (range supported)", async () => {
const chunkData = new Uint8Array([0x25, 0x50]); // First 2 bytes
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(chunkData, {
status: 206,
headers: {
"Content-Type": "application/pdf",
"Content-Range": "bytes 0-1/100",
},
}),
);
try {
await pdfCache.readPdfRange(testUrl, 0, 2);
expect(pdfCache.getCacheSize()).toBe(0); // Not cached when 206
} finally {
mockFetch.mockRestore();
}
});
it("should slice cached data for subsequent range requests", async () => {
const fullData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(fullData, { status: 200 }),
);
try {
// First request caches full body
await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(pdfCache.getCacheSize()).toBe(1);
// Subsequent request gets slice from cache
const result = await pdfCache.readPdfRange(testUrl, 2, 3);
expect(result.data).toEqual(new Uint8Array([3, 4, 5]));
expect(result.totalBytes).toBe(10);
expect(mockFetch).toHaveBeenCalledTimes(1);
} finally {
mockFetch.mockRestore();
}
});
it("should fall back to GET when server returns 501 for Range request", async () => {
const fullData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
const mockFetch = spyOn(globalThis, "fetch")
// First call: Range request returns 501
.mockResolvedValueOnce(
new Response("Unsupported client Range", { status: 501 }),
)
// Second call: plain GET returns full body
.mockResolvedValueOnce(
new Response(fullData, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
try {
const result = await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(result.data).toEqual(fullData);
expect(result.totalBytes).toBe(fullData.length);
expect(pdfCache.getCacheSize()).toBe(1);
expect(mockFetch).toHaveBeenCalledTimes(2);
} finally {
mockFetch.mockRestore();
}
});
it("should reject PDFs larger than max size limit", async () => {
const hugeUrl = "https://arxiv.org/pdf/huge-pdf";
// Create data larger than the limit
const hugeData = new Uint8Array(CACHE_MAX_PDF_SIZE_BYTES + 1);
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(hugeData, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
try {
await expect(pdfCache.readPdfRange(hugeUrl, 0, 1024)).rejects.toThrow(
/PDF too large to cache/,
);
expect(pdfCache.getCacheSize()).toBe(0); // Should not be cached
} finally {
mockFetch.mockRestore();
}
});
it("should reject when Content-Length header exceeds limit", async () => {
const headerUrl = "https://arxiv.org/pdf/huge-pdf-header";
const smallData = new Uint8Array([1, 2, 3, 4]);
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(smallData, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(CACHE_MAX_PDF_SIZE_BYTES + 1),
},
}),
);
try {
await expect(pdfCache.readPdfRange(headerUrl, 0, 1024)).rejects.toThrow(
/PDF too large to cache/,
);
expect(pdfCache.getCacheSize()).toBe(0);
} finally {
mockFetch.mockRestore();
}
});
});
// Note: Timer-based tests (inactivity/max lifetime) would require
// using fake timers which can be complex with async code.
// The timeout behavior is straightforward and can be verified
// through manual testing or E2E tests.
});
describe("PdfCacheRangeTransport", () => {
it("accumulates ranges larger than MAX_CHUNK_BYTES into one onDataRange call", async () => {
const big = MAX_CHUNK_BYTES * 2 + 100;
const reads: Array<[number, number]> = [];
const t = new PdfCacheRangeTransport("u", big, async (_u, off, n) => {
reads.push([off, n]);
return {
data: new Uint8Array(Math.min(n, MAX_CHUNK_BYTES)),
totalBytes: big,
};
});
const delivered: Array<[number, number]> = [];
t.addRangeListener((begin: number, chunk: Uint8Array) =>
delivered.push([begin, chunk.length]),
);
t.requestDataRange(0, big);
await new Promise((r) => setTimeout(r, 10));
// pdf.js's reader is keyed by the original begin and removed after one
// delivery, so deliver() must call onDataRange exactly once with the
// accumulated buffer — multiple calls would throw inside pdfjs.
expect(delivered).toEqual([[0, big]]);
expect(reads).toEqual([
[0, MAX_CHUNK_BYTES],
[MAX_CHUNK_BYTES, MAX_CHUNK_BYTES],
[MAX_CHUNK_BYTES * 2, 100],
]);
});
it("rejects .failed when a range fetch errors instead of hanging", async () => {
const t = new PdfCacheRangeTransport("u", 1000, async () => {
throw new Error("network down");
});
t.requestDataRange(0, 100);
await expect(
Promise.race([
t.failed,
new Promise((r) => setTimeout(() => r("timeout"), 200)),
]),
).rejects.toThrow("network down");
});
it("rejects .failed on zero-length response (would otherwise spin)", async () => {
const t = new PdfCacheRangeTransport("u", 1000, async () => ({
data: new Uint8Array(0),
totalBytes: 1000,
}));
t.requestDataRange(0, 100);
await expect(t.failed).rejects.toThrow(/empty range/);
});
it("getDocument resolves on a >1MB PDF when readPdfRange clamps to MAX_CHUNK_BYTES", async () => {
// pdfjs coalesces adjacent missing chunks into one requestDataRange that
// can exceed MAX_CHUNK_BYTES. deliver() must accumulate clamped reads and
// hand pdfjs a single onDataRange(begin, fullBuffer). This test fails if
// deliver() either truncates or calls onDataRange more than once per
// requestDataRange (pdf.mjs _onReceiveData matches by exact begin).
const d = await PDFDocument.create();
const img = await d.embedJpg(makeRandomJpeg(1_100_000));
const page = d.addPage([612, 792]);
page.drawImage(img, { x: 36, y: 36, width: 540, height: 720 });
const bytes = await d.save();
expect(bytes.length).toBeGreaterThan(2 * MAX_CHUNK_BYTES);
const readClamped: PdfCache["readPdfRange"] = async (_u, off, n) => {
const len = Math.min(n, MAX_CHUNK_BYTES, bytes.length - off);
return { data: bytes.slice(off, off + len), totalBytes: bytes.length };
};
// Record the spans pdfjs actually requests so the test fails fast if it
// never asks for >MAX_CHUNK_BYTES (i.e. can't go vacuously green).
const spans: number[] = [];
class RecordingTransport extends PdfCacheRangeTransport {
override requestDataRange(begin: number, end: number): void {
spans.push(end - begin);
super.requestDataRange(begin, end);
}
}
const transport = new RecordingTransport(
"mem://big",
bytes.length,
readClamped,
);
const orHang = <T>(p: Promise<T>, what: string): Promise<T> =>
Promise.race([
p,
transport.failed,
new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error(`${what} hung`)), 5000),
),
]);
const doc = await orHang(
getDocument({
range: transport,
length: bytes.length,
disableAutoFetch: true,
disableStream: true,
rangeChunkSize: 64 * 1024,
}).promise,
"getDocument",
);
const p1 = await orHang(doc.getPage(1), "getPage");
// getPage() alone doesn't decode the image XObject; getOperatorList() does,
// which is what triggers the >512KB coalesced range request.
await orHang(p1.getOperatorList(), "getOperatorList");
expect(Math.max(...spans)).toBeGreaterThan(MAX_CHUNK_BYTES);
doc.destroy();
});
});
describe("display_pdf transport-error handling", () => {
it("returns (does not hang) when range fetches fail mid-load", async () => {
// First fetch = the 1-byte size probe → 206 with Content-Range so
// display_pdf gets totalBytes. Every subsequent fetch (made by
// PdfCacheRangeTransport via readPdfRange) rejects, which must surface
// through transport.failed → orFail() → outer catch, not hang.
let calls = 0;
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
async () => {
if (calls++ === 0) {
return new Response(new Uint8Array(1), {
status: 206,
headers: { "Content-Range": "bytes 0-0/50000" },
});
}
throw new Error("network down");
},
);
const server = createServer();
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
try {
const result = await Promise.race([
client.callTool({
name: "display_pdf",
arguments: { url: "https://arxiv.org/pdf/err-test" },
}),
new Promise<never>((_, rej) =>
setTimeout(
() => rej(new Error("display_pdf hung on transport error")),
3000,
),
),
]);
expect(result.isError).toBeFalsy();
const sc = result.structuredContent as { formFields?: unknown };
expect(sc.formFields).toBeUndefined();
expect(calls).toBeGreaterThan(1);
} finally {
mockFetch.mockRestore();
await client.close();
await server.close();
}
});
});
describe("extractFormSchema field-tree handling", () => {
async function schemaFor(bytes: Uint8Array) {
const doc = await getDocument({ data: bytes }).promise;
try {
const fo = (await doc.getFieldObjects()) as Parameters<
typeof extractFormSchema
>[1];
return await extractFormSchema(doc, fo);
} finally {
doc.destroy();
}
}
it("handles pdf-lib separated field/widget structure", async () => {
const d = await PDFDocument.create();
const form = d.getForm();
d.addPage([612, 792]);
form
.createTextField("alpha")
.addToPage(d.getPage(0), { x: 50, y: 700, width: 200, height: 20 });
form
.createCheckBox("agree")
.addToPage(d.getPage(0), { x: 50, y: 660, width: 20, height: 20 });
form
.createDropdown("choice")
.addToPage(d.getPage(0), { x: 50, y: 620, width: 100, height: 20 });
const schema = await schemaFor(await d.save());
expect(schema).not.toBeNull();
expect(schema!.properties.alpha).toEqual({
type: "string",
title: "alpha",
});
expect(schema!.properties.agree).toEqual({
type: "boolean",
title: "agree",
});
expect(schema!.properties.choice.type).toBe("string");
});
it("handles fields with multiple widgets across pages", async () => {
const d = await PDFDocument.create();
const form = d.getForm();
d.addPage([612, 792]);
d.addPage([612, 792]);
const tf = form.createTextField("shared");
tf.addToPage(d.getPage(0), { x: 50, y: 700, width: 200, height: 20 });
tf.addToPage(d.getPage(1), { x: 50, y: 700, width: 200, height: 20 });
const schema = await schemaFor(await d.save());
expect(schema?.properties.shared).toEqual({
type: "string",
title: "shared",
});
});
it("skips container nodes and finds leaf fields (W-9 style)", async () => {
const bytes = fs.readFileSync(
path.join(__dirname, "../../tests/helpers/assets/fw9.pdf"),
);
const doc = await getDocument({ data: new Uint8Array(bytes) }).promise;
try {
const fo = (await doc.getFieldObjects()) as Parameters<
typeof extractFormSchema
>[1];
// Container nodes (no leaf type) should not crash extraction
expect(fo!["topmostSubform[0]"]).toBeDefined();
// Schema is null for W-9 (mechanical names), but extraction must not throw
const schema = await extractFormSchema(doc, fo);
expect(schema).toBeNull();
} finally {
doc.destroy();
}
});
it("returns null when no AcroForm present", async () => {
const d = await PDFDocument.create();
d.addPage([612, 792]);
const schema = await schemaFor(await d.save());
expect(schema).toBeNull();
});
});
describe("validateUrl loopback HTTP allow (PDF_SERVER_ALLOW_LOOPBACK_HTTP)", () => {
it("rejects http://127.0.0.1 by default", () => {
expect(validateUrl("http://127.0.0.1:9999/x.pdf").valid).toBe(false);
});
it("accepts http://127.0.0.1 only when the env gate is set, and never non-loopback http", () => {
const prev = process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP;
process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP = "1";
try {
expect(validateUrl("http://127.0.0.1:9999/x.pdf").valid).toBe(true);
expect(validateUrl("http://169.254.169.254/").valid).toBe(false);
} finally {
if (prev === undefined) delete process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP;
else process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP = prev;
}
});
});
describe("validateUrl with MCP roots (allowedLocalDirs)", () => {
const savedFiles = new Set(allowedLocalFiles);
const savedDirs = new Set(allowedLocalDirs);
beforeEach(() => {
allowedLocalFiles.clear();
allowedLocalDirs.clear();
});
afterEach(() => {
allowedLocalFiles.clear();
allowedLocalDirs.clear();
for (const f of savedFiles) allowedLocalFiles.add(f);
for (const d of savedDirs) allowedLocalDirs.add(d);
});
it("should allow a file under an allowed directory", () => {
// Use a real existing directory+file for the existsSync check
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const filePath = path.join(dir, "server.ts");
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should reject a file outside allowed directories", () => {
allowedLocalDirs.add("/some/allowed/dir");
const result = validateUrl("file:///other/dir/test.pdf");
expect(result.valid).toBe(false);
expect(result.error).toContain("not in allowed list");
});
it("should prevent prefix-based directory traversal", () => {
// /tmp/safe should NOT allow /tmp/safevil/file.pdf
allowedLocalDirs.add("/tmp/safe");
const result = validateUrl("file:///tmp/safevil/file.pdf");
expect(result.valid).toBe(false);
});
it("should still allow exact file matches from allowedLocalFiles", () => {
const filePath = path.resolve(import.meta.dirname, "server.ts");
allowedLocalFiles.add(filePath);
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should reject non-existent file even if under allowed dir", () => {
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const result = validateUrl(
pathToFileUrl(path.join(dir, "nonexistent-file.pdf")),
);
expect(result.valid).toBe(false);
expect(result.error).toContain("File not found");
});
it("should allow a file under an allowed dir with trailing slash", () => {
const dir = path.resolve(import.meta.dirname);
// Simulate a dir stored with a trailing slash (e.g. from CLI path)
allowedLocalDirs.add(dir + "/");
const filePath = path.join(dir, "server.ts");
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should allow a file under a grandparent allowed dir", () => {
// Allow a directory two levels up from the file
const grandparent = path.resolve(path.join(import.meta.dirname, ".."));
allowedLocalDirs.add(grandparent);
const filePath = path.join(import.meta.dirname, "server.ts");
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should accept computer:// URLs as local files", () => {
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const filePath = path.join(dir, "server.ts");
const encoded = encodeURIComponent(filePath).replace(/%2F/g, "/");
const result = validateUrl(`computer://${encoded}`);
expect(result.valid).toBe(true);
});
it("should accept bare absolute paths as local files", () => {
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const filePath = path.join(dir, "server.ts");
const result = validateUrl(filePath);
expect(result.valid).toBe(true);
});
it("should decode percent-encoded bare paths (e.g. %20 for spaces)", () => {
const fs = require("node:fs");
const os = require("node:os");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf test "));
const testFile = path.join(tmpDir, "file.txt");
try {
fs.writeFileSync(testFile, "hello");
allowedLocalDirs.add(tmpDir);
// Encode spaces as %20 in the path (as some clients do)
const encoded = testFile.replace(/ /g, "%20");
const result = validateUrl(encoded);
expect(result.valid).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
it("should allow file accessed via symlink when real dir is allowed", () => {
const fs = require("node:fs");
const os = require("node:os");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-test-"));
const realDir = path.join(tmpDir, "real");
const linkDir = path.join(tmpDir, "link");
const testFile = path.join(realDir, "test.txt");
try {
fs.mkdirSync(realDir);
fs.writeFileSync(testFile, "hello");
fs.symlinkSync(realDir, linkDir);
// Allow the REAL directory
allowedLocalDirs.add(realDir);
// Access via the SYMLINK path — should still be allowed
const symlinkPath = path.join(linkDir, "test.txt");
const result = validateUrl(symlinkPath);
expect(result.valid).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
it("should allow file when allowed dir is a symlink to real parent", () => {
const fs = require("node:fs");
const os = require("node:os");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-test-"));
const realDir = path.join(tmpDir, "real");
const linkDir = path.join(tmpDir, "link");
const testFile = path.join(realDir, "test.txt");
try {
fs.mkdirSync(realDir);
fs.writeFileSync(testFile, "hello");
fs.symlinkSync(realDir, linkDir);
// Allow the SYMLINK directory
allowedLocalDirs.add(linkDir);
// Access via the REAL path — should still be allowed
const result = validateUrl(testFile);
expect(result.valid).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
});
describe("isAncestorDir", () => {
it("should return true for a direct child", () => {
expect(isAncestorDir("/Users/test/dir", "/Users/test/dir/file.pdf")).toBe(
true,
);
});
it("should return true for a nested child", () => {
expect(isAncestorDir("/Users/test", "/Users/test/sub/dir/file.pdf")).toBe(
true,
);
});
it("should return false for a file outside the dir", () => {
expect(isAncestorDir("/Users/test/dir", "/Users/test/other/file.pdf")).toBe(
false,
);
});
it("should return false for the dir itself", () => {
expect(isAncestorDir("/Users/test/dir", "/Users/test/dir")).toBe(false);
});
it("should prevent .. traversal", () => {
expect(
isAncestorDir("/Users/test/dir", "/Users/test/dir/../other/file.pdf"),
).toBe(false);
});
it("should prevent prefix-based traversal", () => {
// /tmp/safe should NOT match /tmp/safevil/file.pdf
expect(isAncestorDir("/tmp/safe", "/tmp/safevil/file.pdf")).toBe(false);
});
it("should handle dirs with trailing slash", () => {
expect(isAncestorDir("/Users/test/dir/", "/Users/test/dir/file.pdf")).toBe(
true,
);
});
});
describe("createServer useClientRoots option", () => {
it("should not set up roots handlers by default", () => {
const server = createServer();
// When useClientRoots is false (default), oninitialized should NOT
// be overridden by our roots logic.
expect(server.server.oninitialized).toBeUndefined();
server.close();
});
it("should not set up roots handlers when useClientRoots is false", () => {
const server = createServer({ useClientRoots: false });
expect(server.server.oninitialized).toBeUndefined();
server.close();
});
it("should set up roots handlers when useClientRoots is true", () => {
const server = createServer({ useClientRoots: true });
// When useClientRoots is true, oninitialized should be set to
// the roots refresh handler.
expect(server.server.oninitialized).toBeFunction();
server.close();
});
});
describe("isWritablePath", () => {
let savedFiles: Set<string>;
let savedDirs: Set<string>;
let savedCli: Set<string>;
let savedAllowUploadsRoot: boolean;
beforeEach(() => {
savedFiles = new Set(allowedLocalFiles);
savedDirs = new Set(allowedLocalDirs);
savedCli = new Set(cliLocalFiles);
allowedLocalFiles.clear();
allowedLocalDirs.clear();
cliLocalFiles.clear();
savedAllowUploadsRoot = writeFlags.allowUploadsRoot;
writeFlags.allowUploadsRoot = false;
});
afterEach(() => {
allowedLocalFiles.clear();
allowedLocalDirs.clear();
cliLocalFiles.clear();
for (const x of savedFiles) allowedLocalFiles.add(x);
for (const x of savedDirs) allowedLocalDirs.add(x);
for (const x of savedCli) cliLocalFiles.add(x);
writeFlags.allowUploadsRoot = savedAllowUploadsRoot;
});
it("nothing is writable when no roots and no CLI files", () => {
expect(isWritablePath("/any/path/file.pdf")).toBe(false);
});
it("CLI file is writable", () => {
allowedLocalFiles.add("/tmp/explicit.pdf");
cliLocalFiles.add("/tmp/explicit.pdf");
expect(isWritablePath("/tmp/explicit.pdf")).toBe(true);
});
it("MCP file root is NOT writable", () => {
allowedLocalFiles.add("/tmp/uploaded.pdf"); // from refreshRoots, no CLI
expect(isWritablePath("/tmp/uploaded.pdf")).toBe(false);
});
it("file under a directory root at any depth is writable", () => {
allowedLocalDirs.add("/home/user/docs");
expect(isWritablePath("/home/user/docs/file.pdf")).toBe(true);
expect(isWritablePath("/home/user/docs/sub/deep/file.pdf")).toBe(true);
});
it("the directory root itself is NOT writable", () => {
allowedLocalDirs.add("/home/user/docs");
expect(isWritablePath("/home/user/docs")).toBe(false);
});
it("MCP file root stays read-only even when under a directory root", () => {
// Client sent BOTH the directory and a file inside it as roots.
// The explicit file-root is the stronger signal: treat as upload.
allowedLocalDirs.add("/home/user/docs");
allowedLocalFiles.add("/home/user/docs/uploaded.pdf");
expect(isWritablePath("/home/user/docs/uploaded.pdf")).toBe(false);
// Siblings not sent as file roots remain writable
expect(isWritablePath("/home/user/docs/other.pdf")).toBe(true);
});
it("CLI file wins even if also in allowedLocalFiles", () => {
// CLI file added to both sets (main.ts does this)
allowedLocalFiles.add("/tmp/cli.pdf");
cliLocalFiles.add("/tmp/cli.pdf");
expect(isWritablePath("/tmp/cli.pdf")).toBe(true);
});
it("file outside any directory root is not writable", () => {
allowedLocalDirs.add("/home/user/docs");
expect(isWritablePath("/home/user/other/file.pdf")).toBe(false);
expect(isWritablePath("/home/user/docsevil/file.pdf")).toBe(false);
});
it("dir root named 'uploads' is read-only by default", () => {
// Claude Desktop mounts the conversation's attachment drop folder as a
// directory root literally named 'uploads'. The attached PDF lives
// directly under it.
allowedLocalDirs.add("/var/folders/xy/T/claude/uploads");
expect(isWritablePath("/var/folders/xy/T/claude/uploads/Form.pdf")).toBe(
false,
);
// Deep nesting under the uploads root — still the same root, still no.
expect(
isWritablePath("/var/folders/xy/T/claude/uploads/sub/deep.pdf"),
).toBe(false);
});
it("uploads-root guard matches basename, not substring", () => {
allowedLocalDirs.add("/home/user/my-uploads"); // contains 'uploads' but ≠
allowedLocalDirs.add("/home/user/uploads-archive");
expect(isWritablePath("/home/user/my-uploads/f.pdf")).toBe(true);
expect(isWritablePath("/home/user/uploads-archive/f.pdf")).toBe(true);
});
it("--writeable-uploads-root opts back in", () => {
allowedLocalDirs.add("/var/folders/xy/T/claude/uploads");
writeFlags.allowUploadsRoot = true;
expect(isWritablePath("/var/folders/xy/T/claude/uploads/Form.pdf")).toBe(
true,
);
});
it("CLI file under an uploads root is still writable", () => {
// Explicit CLI intent beats the uploads-basename heuristic.
allowedLocalDirs.add("/tmp/uploads");
allowedLocalFiles.add("/tmp/uploads/explicit.pdf");
cliLocalFiles.add("/tmp/uploads/explicit.pdf");
expect(isWritablePath("/tmp/uploads/explicit.pdf")).toBe(true);
});
});
describe("file watching", () => {
let tmpDir: string;
let tmpFile: string;
const uuid = "test-watch-uuid";
// Long-poll timeout is 30s — tests that poll must complete sooner.
const pollWithTimeout = async (
client: Client,
timeoutMs = 5000,
): Promise<{ type: string; mtimeMs?: number }[]> => {
const result = await Promise.race([
client.callTool({
name: "poll_pdf_commands",
arguments: { viewUUID: uuid },
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("poll timeout")), timeoutMs),
),
]);
return (
((result as { structuredContent?: { commands?: unknown[] } })
.structuredContent?.commands as { type: string; mtimeMs?: number }[]) ??
[]
);
};
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-watch-"));
tmpFile = path.join(tmpDir, "test.pdf");
fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%test\n"));
allowedLocalFiles.add(tmpFile);
cliLocalFiles.add(tmpFile); // save_pdf test needs write scope
});
afterEach(() => {
stopFileWatch(uuid);
allowedLocalFiles.delete(tmpFile);
cliLocalFiles.delete(tmpFile);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("enqueues file_changed after external write", async () => {
const server = createServer({ enableInteract: true });
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
startFileWatch(uuid, tmpFile);
await new Promise((r) => setTimeout(r, 50)); // let watcher settle
fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%changed\n"));
const cmds = await pollWithTimeout(client);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("file_changed");
expect(cmds[0].mtimeMs).toBeGreaterThan(0);
await client.close();
await server.close();
});
it("debounces rapid writes into one command", async () => {
const server = createServer({ enableInteract: true });
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
startFileWatch(uuid, tmpFile);
await new Promise((r) => setTimeout(r, 50));
fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%a\n"));
fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%b\n"));
fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%c\n"));
const cmds = await pollWithTimeout(client);
expect(cmds).toHaveLength(1);
await client.close();
await server.close();
});
it("stopFileWatch prevents further commands", async () => {
const server = createServer({ enableInteract: true });
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
startFileWatch(uuid, tmpFile);
await new Promise((r) => setTimeout(r, 50));
stopFileWatch(uuid);
fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%x\n"));
// Debounce window + margin — no event should fire
await new Promise((r) => setTimeout(r, 300));
// Poll should block (long-poll) → timeout here means no command was queued
await expect(pollWithTimeout(client, 500)).rejects.toThrow("poll timeout");
await client.close();
await server.close();
});
it("save_pdf returns mtimeMs in structuredContent", async () => {
const server = createServer({ enableInteract: true });
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
const before = fs.statSync(tmpFile).mtimeMs;
// Ensure mtime will differ on coarse-granularity filesystems
await new Promise((r) => setTimeout(r, 10));
const r = await client.callTool({
name: "save_pdf",
arguments: {
url: tmpFile,
data: Buffer.from("%PDF-1.4\nnew").toString("base64"),
},
});
expect(r.isError).toBeFalsy();
const sc = r.structuredContent as { filePath: string; mtimeMs: number };
expect(sc.filePath).toBe(tmpFile);
expect(sc.mtimeMs).toBeGreaterThanOrEqual(before);
await client.close();
await server.close();
});
it("save_pdf refuses file roots from MCP client (not CLI)", async () => {
// Simulate: file is readable (in allowedLocalFiles via refreshRoots)
// but NOT in cliLocalFiles — it came from the client, not a CLI arg.
cliLocalFiles.delete(tmpFile);
const server = createServer({ enableInteract: true });
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
const original = fs.readFileSync(tmpFile);
const r = await client.callTool({
name: "save_pdf",
arguments: {
url: tmpFile,
data: Buffer.from("%PDF-1.4\nshould-not-write").toString("base64"),
},
});
expect(r.isError).toBe(true);
const text = (r.content as { text: string }[])[0].text;
expect(text).toContain("read-only");
// Verify the file was NOT modified
expect(fs.readFileSync(tmpFile)).toEqual(original);
await client.close();
await server.close();
});
it("save_pdf allows files under a directory root", async () => {
// File is under a mounted directory root — but NOT itself a file root
// (a file root, even under a mounted dir, is read-only per isWritablePath).
cliLocalFiles.delete(tmpFile);
allowedLocalFiles.delete(tmpFile);
allowedLocalDirs.add(tmpDir);
const server = createServer({ enableInteract: true });
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
const r = await client.callTool({
name: "save_pdf",
arguments: {
url: tmpFile,
data: Buffer.from("%PDF-1.4\nvia-dir-root").toString("base64"),
},
});
expect(r.isError).toBeFalsy();
expect(fs.readFileSync(tmpFile, "utf8")).toBe("%PDF-1.4\nvia-dir-root");
allowedLocalDirs.delete(tmpDir);
await client.close();
await server.close();
});
// fs.watch on a file that gets replaced via rename: on macOS (kqueue)
// the watcher reliably fires a "rename" event which our re-attach logic
// handles. On Linux (inotify), a watcher on the old inode often gets no
// event at all — inotify watches inodes, and the rename just atomically
// swaps the directory entry to a NEW inode. Directory-level watching
// would fix this but isn't what we do. Skip on non-darwin.
it.skipIf(process.platform !== "darwin")(
"detects atomic rename (macOS kqueue only)",
async () => {
const server = createServer({ enableInteract: true });
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
startFileWatch(uuid, tmpFile);
await new Promise((r) => setTimeout(r, 50));
// Simulate vim/vscode: write to temp, rename over original
const tmpWrite = tmpFile + ".swp";
fs.writeFileSync(tmpWrite, Buffer.from("%PDF-1.4\n%atomic\n"));
fs.renameSync(tmpWrite, tmpFile);
const cmds = await pollWithTimeout(client);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("file_changed");
await client.close();
await server.close();
},
);
});
describe("interact tool", () => {
// Helper: connected server+client pair with interact enabled.
// Command queues are MODULE-LEVEL (shared across server instances), so each
// test uses a distinct viewUUID to avoid cross-test interference.
async function connect() {
const server = createServer({ enableInteract: true });
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
return { server, client };
}
// Helper: poll with an outer deadline so a failing test doesn't hang for the
// full 30s long-poll. Safe ONLY when a command is already enqueued — poll
// then returns after the 200ms batch window.
async function poll(client: Client, uuid: string, timeoutMs = 2000) {
const result = await Promise.race([
client.callTool({
name: "poll_pdf_commands",
arguments: { viewUUID: uuid },
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("poll timeout")), timeoutMs),
),
]);
return ((result as { structuredContent?: { commands?: unknown[] } })
.structuredContent?.commands ?? []) as Array<Record<string, unknown>>;
}
function firstText(r: Awaited<ReturnType<Client["callTool"]>>): string {
return (r.content as Array<{ type: string; text: string }>)[0].text;
}
it("enqueue → poll roundtrip delivers the command", async () => {
const { server, client } = await connect();
const uuid = "test-interact-roundtrip";
const r = await client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "navigate", page: 5 },
});
expect(r.isError).toBeFalsy();
expect(firstText(r)).toContain("Queued");
expect(firstText(r)).toContain("page 5");
// Core mechanism: the viewer polls for what the model enqueued.
const cmds = await poll(client, uuid);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("navigate");
expect(cmds[0].page).toBe(5);
await client.close();
await server.close();
});
it("navigate without `page` returns isError with a helpful message", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "interact",
arguments: { viewUUID: "test-err-nav", action: "navigate" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("navigate");
expect(firstText(r)).toContain("page");
await client.close();
await server.close();
});
it("fill_form without `fields` returns isError with a helpful message", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "interact",
arguments: { viewUUID: "test-err-fill", action: "fill_form" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("fill_form");
expect(firstText(r)).toContain("fields");
await client.close();
await server.close();
});
it("add_annotations without `annotations` returns isError with a helpful message", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "interact",
arguments: { viewUUID: "test-err-ann", action: "add_annotations" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("add_annotations");
expect(firstText(r)).toContain("annotations");
await client.close();
await server.close();
});
it("isolates command queues across distinct viewUUIDs", async () => {
const { server, client } = await connect();
const uuidA = "test-isolate-A";
const uuidB = "test-isolate-B";
await client.callTool({
name: "interact",
arguments: { viewUUID: uuidA, action: "navigate", page: 3 },
});
await client.callTool({
name: "interact",
arguments: { viewUUID: uuidB, action: "search", query: "quantum" },
});
const cmdsA = await poll(client, uuidA);
expect(cmdsA).toHaveLength(1);
expect(cmdsA[0].type).toBe("navigate");
expect(cmdsA[0].page).toBe(3);
const cmdsB = await poll(client, uuidB);
expect(cmdsB).toHaveLength(1);
expect(cmdsB[0].type).toBe("search");
expect(cmdsB[0].query).toBe("quantum");
await client.close();
await server.close();
});
// SKIPPED: the unknown-UUID path enters the long-poll branch and blocks for
// the full LONG_POLL_TIMEOUT_MS (30s, module-local const, not configurable).
// The handler does dequeue [] at the end, so the return value IS
// {commands: []} — but there's no fast path to reach it without waiting.
// See the `stopFileWatch prevents further commands` test above for indirect
// coverage of the same blocking behaviour.
it.skip("poll with unknown viewUUID returns {commands: []} after long-poll", () => {});
it("fill_form passes all fields through when viewFieldNames is not registered", async () => {
const { server, client } = await connect();
// Fresh UUID never seen by display_pdf → viewFieldNames.get(uuid) is
// undefined → the known-fields guard (`knownFields && !knownFields.has()`)
// is falsy for every field → everything is enqueued.
const uuid = "test-fillform-passthrough";
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: uuid,
action: "fill_form",
fields: [
{ name: "anything", value: "goes" },
{ name: "unchecked", value: true },
],
},
});
expect(r.isError).toBeFalsy();
expect(firstText(r)).toContain("Filled 2 field(s)");
// No rejection complaint — the registry has no entry for this UUID
expect(firstText(r)).not.toContain("Unknown");
const cmds = await poll(client, uuid);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("fill_form");
const fields = cmds[0].fields as Array<{ name: string; value: unknown }>;
expect(fields).toHaveLength(2);
expect(fields.map((f) => f.name).sort()).toEqual(["anything", "unchecked"]);
// Note: the "registered → reject unknown" branch needs viewFieldNames
// populated, which only happens inside display_pdf (requires a real PDF).
// That map isn't exported, so the rejection path is covered by e2e only.
await client.close();
await server.close();
});
// SECURITY: resolveImageAnnotation must not read arbitrary local files.
// The model controls imageUrl; without validation it's an exfil primitive
// (readFile → base64 → iframe → get_screenshot reads it back).
describe("add_annotations image: imageUrl validation", () => {
let savedDirs: Set<string>;
beforeEach(() => {
savedDirs = new Set(allowedLocalDirs);
allowedLocalDirs.clear();
});
afterEach(() => {
allowedLocalDirs.clear();
for (const d of savedDirs) allowedLocalDirs.add(d);
});
it("rejects local path outside allowed roots", async () => {
const { server, client } = await connect();
// Whitelist a harmless temp dir; target a path clearly outside it.
allowedLocalDirs.add(os.tmpdir());
const target = path.join(os.homedir(), ".ssh", "id_rsa");
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "sec-local",
action: "add_annotations",
annotations: [{ type: "image", id: "i1", page: 1, imageUrl: target }],
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("imageUrl rejected");
await client.close();
await server.close();
});
it("rejects http:// URL (SSRF)", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "sec-http",
action: "add_annotations",
annotations: [
{
type: "image",
id: "i1",
page: 1,
imageUrl: "http://169.254.169.254/latest/meta-data/",
},
],
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("imageUrl rejected");
await client.close();
await server.close();
});
it("accepts path under an allowed dir (reaches readFile)", async () => {
const { server, client } = await connect();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-imgurl-"));
allowedLocalDirs.add(dir);
// Minimal valid PNG (1x1 transparent): 8-byte sig + IHDR + IDAT + IEND.
const png = Buffer.from(
"89504E470D0A1A0A0000000D49484452000000010000000108060000001F15C4890000000A49444154789C6360000000000200015E9AFE400000000049454E44AE426082",
"hex",
);
const imgPath = path.join(dir, "sig.png");
fs.writeFileSync(imgPath, png);
try {
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "sec-ok",
action: "add_annotations",
annotations: [
{ type: "image", id: "i1", page: 1, imageUrl: imgPath },
],
},
});
// No security rejection; readFile succeeds; command enqueued.
expect(r.isError).toBeFalsy();
const cmds = await poll(client, "sec-ok");
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("add_annotations");
const anns = cmds[0].annotations as Array<Record<string, unknown>>;
// validateUrl passed → readFile ran → imageData populated.
expect(typeof anns[0].imageData).toBe("string");
expect((anns[0].imageData as string).length).toBeGreaterThan(0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
await client.close();
await server.close();
});
});
describe("save_as", () => {
// Roundtrip tests need: writable scope, kick off interact WITHOUT awaiting
// (it blocks until the view replies), poll → submit → await. The poll()
// call also registers the uuid in viewsPolled, satisfying
// ensureViewerIsPolling — without it interact would hang ~8s and fail.
let tmpDir: string;
let savedDirs: Set<string>;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-saveas-"));
savedDirs = new Set(allowedLocalDirs);
allowedLocalDirs.add(tmpDir); // make tmpDir a directory root → writable
});
afterEach(() => {
allowedLocalDirs.clear();
for (const x of savedDirs) allowedLocalDirs.add(x);
viewSourcePaths.clear();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("no path, no source tracked → tells model to provide a path", async () => {
// Fresh UUID never seen by display_pdf → viewSourcePaths has no entry.
// Same condition as a remote (https://) PDF or a stale viewUUID.
const { server, client } = await connect();
const r = await client.callTool({
name: "interact",
arguments: { viewUUID: "saveas-nosource", action: "save_as" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("no local source file");
expect(firstText(r)).toContain("Provide an explicit `path`");
await client.close();
await server.close();
});
it("no path, source tracked, overwrite omitted → asks for confirmation", async () => {
const { server, client } = await connect();
const source = path.join(tmpDir, "original.pdf");
fs.writeFileSync(source, "%PDF-1.4\noriginal");
viewSourcePaths.set("saveas-noconfirm", source);
const r = await client.callTool({
name: "interact",
arguments: { viewUUID: "saveas-noconfirm", action: "save_as" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("overwrites the original");
expect(firstText(r)).toContain(source);
expect(firstText(r)).toContain("overwrite: true");
// Nothing enqueued, file untouched
expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\noriginal");
await client.close();
await server.close();
});
it("no path, source not writable → same gate as save button", async () => {
const { server, client } = await connect();
// Source outside any directory root → isWritablePath false → save button
// would be hidden in the viewer. save_as should refuse for the same reason.
const outside = path.join(os.tmpdir(), "saveas-outside.pdf");
fs.writeFileSync(outside, "x");
viewSourcePaths.set("saveas-buttongate", outside);
try {
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "saveas-buttongate",
action: "save_as",
overwrite: true,
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("not writable");
expect(firstText(r)).toContain("save button is hidden");
} finally {
fs.rmSync(outside, { force: true });
}
await client.close();
await server.close();
});
it("no path, overwrite: true → roundtrip overwrites the original", async () => {
const { server, client } = await connect();
const uuid = "saveas-original";
const source = path.join(tmpDir, "report.pdf");
fs.writeFileSync(source, "%PDF-1.4\noriginal contents");
viewSourcePaths.set(uuid, source);
const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "save_as", overwrite: true },
});
const cmds = await poll(client, uuid);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("save_as");
await client.callTool({
name: "submit_save_data",
arguments: {
requestId: cmds[0].requestId as string,
data: Buffer.from("%PDF-1.4\nannotated").toString("base64"),
},
});
const r = await interactPromise;
expect(r.isError).toBeFalsy();
expect(firstText(r)).toContain(source);
expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\nannotated");
await client.close();
await server.close();
});
it("rejects non-absolute path", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "saveas-rel",
action: "save_as",
path: "relative.pdf",
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("absolute");
await client.close();
await server.close();
});
it("rejects non-writable path", async () => {
const { server, client } = await connect();
// Path outside any directory root → not writable. Validation is sync,
// so nothing is enqueued and the queue stays empty.
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "saveas-nowrite",
action: "save_as",
path: "/somewhere/else/out.pdf",
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("not under a mounted directory root");
await client.close();
await server.close();
});
it("rejects existing file when overwrite is false (default)", async () => {
const { server, client } = await connect();
const target = path.join(tmpDir, "exists.pdf");
fs.writeFileSync(target, "old contents");
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "saveas-exists",
action: "save_as",
path: target,
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("already exists");
expect(firstText(r)).toContain("overwrite: true");
// Existence check is sync — nothing enqueued, file untouched.
expect(fs.readFileSync(target, "utf8")).toBe("old contents");
await client.close();
await server.close();
});
it("full roundtrip: enqueue → poll → submit → file written", async () => {
const { server, client } = await connect();
const uuid = "saveas-roundtrip";
const target = path.join(tmpDir, "out.pdf");
const pdfBytes = "%PDF-1.4\nfake-annotated-contents\n%%EOF";
// interact blocks in waitForSaveData until submit_save_data resolves it
const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "save_as", path: target },
});
// Viewer polls → receives the save_as command with a requestId
const cmds = await poll(client, uuid);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("save_as");
const requestId = cmds[0].requestId as string;
expect(typeof requestId).toBe("string");
// Viewer submits bytes
const submit = await client.callTool({
name: "submit_save_data",
arguments: {
requestId,
data: Buffer.from(pdfBytes).toString("base64"),
},
});
expect(submit.isError).toBeFalsy();
// interact now unblocks with success
const r = await interactPromise;
expect(r.isError).toBeFalsy();
expect(firstText(r)).toContain("Saved");
expect(firstText(r)).toContain(target);
expect(fs.readFileSync(target, "utf8")).toBe(pdfBytes);
await client.close();
await server.close();
});
it("overwrite: true replaces an existing file", async () => {
const { server, client } = await connect();
const uuid = "saveas-overwrite";
const target = path.join(tmpDir, "replace.pdf");
fs.writeFileSync(target, "old contents");
const interactPromise = client.callTool({
name: "interact",
arguments: {
viewUUID: uuid,
action: "save_as",
path: target,
overwrite: true,
},
});
const cmds = await poll(client, uuid);
const requestId = cmds[0].requestId as string;
await client.callTool({
name: "submit_save_data",
arguments: {
requestId,
data: Buffer.from("%PDF-1.4\nnew").toString("base64"),
},
});
const r = await interactPromise;
expect(r.isError).toBeFalsy();
expect(fs.readFileSync(target, "utf8")).toBe("%PDF-1.4\nnew");
await client.close();
await server.close();
});
it("propagates viewer-reported errors to the model", async () => {
const { server, client } = await connect();
const uuid = "saveas-viewerr";
const target = path.join(tmpDir, "wontwrite.pdf");
const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "save_as", path: target },
});
const cmds = await poll(client, uuid);
// Viewer hit an error building bytes → reports it instead of timing out
await client.callTool({
name: "submit_save_data",
arguments: {
requestId: cmds[0].requestId as string,
error: "pdf-lib choked on a comb field",
},
});
const r = await interactPromise;
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("pdf-lib choked on a comb field");
expect(fs.existsSync(target)).toBe(false);
await client.close();
await server.close();
});
it("submit_save_data with unknown requestId returns isError", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "submit_save_data",
arguments: { requestId: "never-created", data: "AAAA" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("No pending request");
await client.close();
await server.close();
});
});
describe("viewer liveness", () => {
// get_screenshot/get_text fail fast when the iframe never polled, instead
// of waiting 45s for a viewer that isn't there. Reproduces the case where
// the host goes idle before the iframe reaches startPolling().
it("get_screenshot fails fast when viewer never polled", async () => {
const { server, client } = await connect();
const uuid = "never-polled-screenshot";
const started = Date.now();
const r = await client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
});
const elapsed = Date.now() - started;
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("never connected");
expect(firstText(r)).toContain(uuid);
expect(firstText(r)).toContain("display_pdf again"); // recovery hint
// Fast-fail bound (~8s grace), well under the 45s page-data timeout.
// 15s upper bound leaves slack for CI scheduling.
expect(elapsed).toBeLessThan(15_000);
await client.close();
await server.close();
}, 20_000);
it("get_screenshot waits full timeout when viewer polled then went silent", async () => {
// Viewer polled once (proving it mounted) then hung on a heavy render.
// The grace check passes, so we fall through to the 45s page-data wait —
// verified here by racing against a 12s deadline that should NOT win.
const { server, client } = await connect();
const uuid = "polled-then-silent";
// Viewer's first poll: drain whatever's there so it returns fast.
// Enqueue a trivial command first so poll returns via the batch-wait
// path (~200ms) instead of blocking on the 30s long-poll.
await client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "navigate", page: 1 },
});
await poll(client, uuid);
// Now get_screenshot — viewer has polled, so no fast-fail. But viewer
// never calls submit_page_data → should wait beyond the grace period.
const outcome = await Promise.race([
client
.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
})
.then(() => "completed" as const),
new Promise<"still-waiting">((r) =>
setTimeout(() => r("still-waiting"), 12_000),
),
]);
expect(outcome).toBe("still-waiting");
await client.close();
await server.close();
}, 20_000);
it("get_screenshot succeeds when viewer polls during grace window", async () => {
// Model calls interact before the viewer has polled — but the viewer
// shows up within the grace period and completes the roundtrip.
const { server, client } = await connect();
const uuid = "late-arriving-viewer";
const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "get_screenshot", page: 1 },
});
// Viewer connects 500ms late — well inside the grace window.
await new Promise((r) => setTimeout(r, 500));
const cmds = await poll(client, uuid);
const getPages = cmds.find((c) => c.type === "get_pages");
expect(getPages).toBeDefined();
// Viewer responds with the page data.
await client.callTool({
name: "submit_page_data",
arguments: {
requestId: getPages!.requestId as string,
pages: [
{ page: 1, image: Buffer.from("fake-jpeg").toString("base64") },
],
},
});
const r = await interactPromise;
expect(r.isError).toBeFalsy();
expect((r.content as Array<{ type: string }>)[0].type).toBe("image");
await client.close();
await server.close();
}, 15_000);
it("batch step failure: 1:1 content, no isError, ERROR-prefixed slot", async () => {
// LocalAgentMode SDK 2.1.87 collapses isError:true results to a bare
// string of content[0].text — drops images from earlier successful
// steps. So we don't set isError on a step failure: each command gets
// one content slot, the failed one starts with "ERROR", and the batch
// stops there. content.length tells the model how far it got.
const { server, client } = await connect();
const uuid = "batch-error-ordering";
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: uuid,
commands: [
{ action: "navigate", page: 3 }, // succeeds → "Queued: ..."
{ action: "get_screenshot", page: 1 }, // never-polled → fast-fail
{ action: "navigate", page: 5 }, // never reached
],
},
});
// Not isError — that would trigger the SDK flatten
expect(r.isError).toBeFalsy();
const content = r.content as Array<{ type: string; text?: string }>;
// Stopped at step 2; step 3 never ran
expect(content).toHaveLength(2);
// Slot 0: step 1's success, untouched
expect(content[0].text).toContain("Queued");
expect(content[0].text).not.toMatch(/^ERROR/);
// Slot 1: step 2's failure, ERROR-prefixed with the actual message
expect(content[1].text).toMatch(/^ERROR/);
expect(content[1].text).toContain("2/3");
expect(content[1].text).toContain("get_screenshot");
expect(content[1].text).toContain("never connected");
await client.close();
await server.close();
}, 15_000);
});
});