* fix(pdf-server): fail fast on never-polled viewer + surface batch errors
interact's get_screenshot/get_text waited the full 45s for a viewer that
never mounted (host went idle before iframe reached startPolling), then
timed out with a generic message. Now tracks viewsPolled and fails in ~8s
with a recovery hint. Batch errors were also ordered success-first —
hosts that flatten isError to content[0] showed "Queued: Filled N fields"
and dropped the actual failure entirely. Viewer side: poll even when PDF
load throws, and catch connect() rejection instead of silently blanking.
* fix(pdf-server): radio button + dropdown handling in fill_form and save
Three related bugs around non-text form fields:
Viewer fill_form (mcp-app.ts): writing a string to annotationStorage on
every radio widget trips pdf.js's inverted string coercion
(`value !== buttonValue` → true) — the first-rendered widget checks
itself and clears the rest, so whatever you ask for, you get the first
option. Fix: extract a setFieldInStorage() helper that writes
{value: true} only on the widget whose buttonValue matches, {value: false}
on siblings, matching pdf.js's own change handler. Also covers the
syncFormValuesToStorage() path (localStorage restore).
Save (pdf-annotations.ts): buildAnnotatedPdfBytes only knew about
getTextField/getCheckBox — dropdowns and radios threw in the try block
and fell through the catch, so they were silently dropped on save.
Fix: instanceof dispatch on getFieldMaybe(). For radios, accept either
the option label or a numeric buttonValue (indexed into getOptions()),
since the viewer stores the latter for PDFs with /Opt.
display_pdf output (server.ts): radio groups showed as N identical
`name [Btn]` lines with no way to tell them apart; dropdowns didn't
list their options. Fix: surface buttonValue as exportValue and the
options array from the annotation data already in hand.
1195 lines
41 KiB
TypeScript
1195 lines
41 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 {
|
|
createPdfCache,
|
|
createServer,
|
|
validateUrl,
|
|
isAncestorDir,
|
|
allowedLocalFiles,
|
|
allowedLocalDirs,
|
|
pathToFileUrl,
|
|
startFileWatch,
|
|
stopFileWatch,
|
|
cliLocalFiles,
|
|
isWritablePath,
|
|
writeFlags,
|
|
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("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("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("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 failure puts error message first", async () => {
|
|
// When [fill_form, get_screenshot] runs and get_screenshot times out,
|
|
// content[0] must describe the failure — not the earlier success. Some
|
|
// hosts flatten isError results to content[0].text only, which previously
|
|
// showed "Queued: Filled N fields" with isError:true and dropped the
|
|
// actual timeout entirely.
|
|
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
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(r.isError).toBe(true);
|
|
const texts = (r.content as Array<{ type: string; text: string }>).map(
|
|
(c) => c.text,
|
|
);
|
|
// content[0]: batch-failed summary naming the culprit
|
|
expect(texts[0]).toContain("failed");
|
|
expect(texts[0]).toContain("2/2");
|
|
expect(texts[0]).toContain("get_screenshot");
|
|
// content[1]: the actual error
|
|
expect(texts[1]).toContain("never connected");
|
|
// content[2]: the earlier success, pushed to the back
|
|
expect(texts[2]).toContain("Queued");
|
|
|
|
await client.close();
|
|
await server.close();
|
|
}, 15_000);
|
|
});
|
|
});
|