omitly-mcp 5.4.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/THIRD-PARTY-NOTICES.md +175 -10
- package/dist/annotations.js +8 -0
- package/dist/annotations.js.map +1 -0
- package/dist/edit-tools.js +993 -0
- package/dist/edit-tools.js.map +1 -0
- package/dist/index.js +23 -3
- package/dist/index.js.map +1 -1
- package/dist/paths.js +40 -1
- package/dist/paths.js.map +1 -1
- package/dist/pdf-edit/compose.js +175 -0
- package/dist/pdf-edit/compose.js.map +1 -0
- package/dist/pdf-edit/docFacts.js +587 -0
- package/dist/pdf-edit/docFacts.js.map +1 -0
- package/dist/pdf-edit/info.js +50 -0
- package/dist/pdf-edit/info.js.map +1 -0
- package/dist/pdf-edit/merge.js +78 -0
- package/dist/pdf-edit/merge.js.map +1 -0
- package/dist/pdf-edit/pageOps.js +1245 -0
- package/dist/pdf-edit/pageOps.js.map +1 -0
- package/dist/pdf-edit/pageSpec.js +85 -0
- package/dist/pdf-edit/pageSpec.js.map +1 -0
- package/dist/pdf-edit/refusals.js +108 -0
- package/dist/pdf-edit/refusals.js.map +1 -0
- package/dist/pdf-edit/worker.js +364 -0
- package/dist/pdf-edit/worker.js.map +1 -0
- package/dist/usage.js +3 -1
- package/dist/usage.js.map +1 -1
- package/package.json +4 -2
- package/skill/SKILL.md +2 -2
- package/wasm/leakcheck_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { closeSync, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { PDFDocument } from "pdf-lib";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { allowedRoot, confineDir, confineInput, confineOutput } from "./paths.js";
|
|
10
|
+
import { FONT_RENAME_WARNING, LAYERS_WARNING, RENAME_WARNING, SIGNATURE_WARNING } from "./pdf-edit/docFacts.js";
|
|
11
|
+
import { expandPageSpec, isContiguousAscending, splitFileName, SPLIT_PREFIX, MAX_EXPANDED_PAGES, } from "./pdf-edit/pageSpec.js";
|
|
12
|
+
import { CHILD_CODES, EditRefusal, messageFor } from "./pdf-edit/refusals.js";
|
|
13
|
+
import { annotations } from "./annotations.js";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Limits (design 4.1, 4.2). The 2048 MB heap and the 120 s budget are set from
|
|
16
|
+
// the timed probes before B merges; tests override them through the seam.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const MiB = 1024 * 1024;
|
|
19
|
+
export const DEFAULT_CAPS = {
|
|
20
|
+
/** Largest input PDF (and, for single-input ops, the largest output). */
|
|
21
|
+
perFileBytes: 200 * MiB,
|
|
22
|
+
/** merge: all inputs together, and the merged output. */
|
|
23
|
+
totalBytes: 500 * MiB,
|
|
24
|
+
/** split: everything written by one call. */
|
|
25
|
+
splitTotalBytes: 1024 * MiB,
|
|
26
|
+
};
|
|
27
|
+
export const DEFAULT_MAX_OLD_SPACE_MB = 2048;
|
|
28
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
29
|
+
const PING_TIMEOUT_MS = 5_000;
|
|
30
|
+
const MAX_CONCURRENT = 2;
|
|
31
|
+
const MAX_WAITING = 8;
|
|
32
|
+
const MAX_STDOUT = 1024 * 1024;
|
|
33
|
+
const MAX_STDERR = 64 * 1024;
|
|
34
|
+
const MAX_SPLIT_RANGES = 100;
|
|
35
|
+
const COPY_CHUNK = 1024 * 1024;
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Pure rules (unit-tested)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* The default-root rule (design 4.4). When OMITLY_ALLOWED_DIR is unset (or empty
|
|
41
|
+
* after trimming) the root is the working directory; if that is the filesystem
|
|
42
|
+
* root or the user's home, the five tools refuse rather than be handed everything.
|
|
43
|
+
* `home` must already be a real path. `platform` is an argument so the comparison is unit-tested for
|
|
44
|
+
* win32, darwin and linux.
|
|
45
|
+
*/
|
|
46
|
+
export function rootNeedsExplicitDir(root, explicit, home, platform = process.platform, sameDir = statSameDir) {
|
|
47
|
+
if (explicit)
|
|
48
|
+
return false;
|
|
49
|
+
// `home` is undefined when it cannot be determined (no HOME and no passwd entry): then only the
|
|
50
|
+
// filesystem-root check applies.
|
|
51
|
+
const pathLib = platform === "win32" ? path.win32 : path.posix;
|
|
52
|
+
return sameDir(root, pathLib.parse(root).root) || (home !== undefined && sameDir(root, home));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Are `a` and `b` the same FOLDER? By file identity (device and inode, as bigints, which also works on
|
|
56
|
+
* Windows), not by name: a name comparison is wrong in both directions (`A` and `a` are two folders on a
|
|
57
|
+
* case-sensitive APFS volume, one folder on the default). Only if either path cannot be stat'ed does it fall
|
|
58
|
+
* back to comparing names, folded where the platform's default filesystem ignores case.
|
|
59
|
+
*/
|
|
60
|
+
export function statSameDir(a, b, platform = process.platform) {
|
|
61
|
+
try {
|
|
62
|
+
const x = statSync(a, { bigint: true });
|
|
63
|
+
const y = statSync(b, { bigint: true });
|
|
64
|
+
if (x.ino !== 0n || y.ino !== 0n)
|
|
65
|
+
return x.dev === y.dev && x.ino === y.ino;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* fall through to names */
|
|
69
|
+
}
|
|
70
|
+
return foldedSameDir(platform)(a, b);
|
|
71
|
+
}
|
|
72
|
+
/** Name comparison: resolved, no trailing separator, case-folded on Windows and macOS (the default filesystems), exact on Linux. */
|
|
73
|
+
export function foldedSameDir(platform) {
|
|
74
|
+
const fold = (p) => {
|
|
75
|
+
const resolved = platform === "win32" ? path.win32.resolve(p) : path.posix.resolve(p);
|
|
76
|
+
return platform === "win32" || platform === "darwin" ? resolved.toLowerCase() : resolved;
|
|
77
|
+
};
|
|
78
|
+
return (a, b) => fold(a) === fold(b);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The child's environment. We pass an EMPTY one on every platform (filtering by name misses Windows'
|
|
82
|
+
* `Path` casing): NODE_OPTIONS and OMITLY_* never reach the child, which takes all its settings from
|
|
83
|
+
* the request. (On Windows the child still ends up with a few system variables, HOMEDRIVE, HOMEPATH,
|
|
84
|
+
* LOGONSERVER, PATH, SYSTEMDRIVE, SYSTEMROOT, TEMP, USERDOMAIN, USERNAME, USERPROFILE and WINDIR:
|
|
85
|
+
* libuv's make_program_env copies them from the parent into any child environment that lacks them.
|
|
86
|
+
* They are not ours to prevent, and none of them reaches the agent.) Two exceptions, each for a reason:
|
|
87
|
+
* - inside an Electron-hosted client `process.execPath` is the app, so it must run as Node;
|
|
88
|
+
* - on Windows a process started with no `SystemRoot`/`WINDIR` can fail to start at all (and the
|
|
89
|
+
* first-use ping would then disable the tools for the session), so exactly those two are
|
|
90
|
+
* copied, found case-insensitively. Nothing else is.
|
|
91
|
+
*/
|
|
92
|
+
export function childEnvironment(opts) {
|
|
93
|
+
const out = {};
|
|
94
|
+
if (opts.platform === "win32") {
|
|
95
|
+
for (const want of ["SystemRoot", "WINDIR"]) {
|
|
96
|
+
const key = Object.keys(opts.env).find((k) => k.toLowerCase() === want.toLowerCase());
|
|
97
|
+
const v = key === undefined ? undefined : opts.env[key];
|
|
98
|
+
if (v !== undefined)
|
|
99
|
+
out[want] = v;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (opts.electron)
|
|
103
|
+
out.ELECTRON_RUN_AS_NODE = "1";
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
const DEVICE_NAME = /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9¹²³]|LPT[0-9¹²³])$/i;
|
|
107
|
+
/**
|
|
108
|
+
* Output name rules, on every platform: `.pdf` (case-insensitive); no `:` (a
|
|
109
|
+
* Windows alternate data stream, `x.pdf:hidden`); no Windows device name (`CON`,
|
|
110
|
+
* `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM0`-`COM9`, `LPT0`-`LPT9`, with or without an
|
|
111
|
+
* extension); no trailing dot or space. Returns the refusal code, or null.
|
|
112
|
+
*/
|
|
113
|
+
export function outputNameRefusal(name) {
|
|
114
|
+
if (!name.toLowerCase().endsWith(".pdf"))
|
|
115
|
+
return "not_pdf_name";
|
|
116
|
+
if (name.includes(":"))
|
|
117
|
+
return "not_pdf_name";
|
|
118
|
+
if (/[. ]$/.test(name))
|
|
119
|
+
return "not_pdf_name";
|
|
120
|
+
// Windows treats `CON.anything` as the device: the part before the first dot.
|
|
121
|
+
const stem = name.split(".")[0].replace(/ +$/, "");
|
|
122
|
+
if (DEVICE_NAME.test(stem))
|
|
123
|
+
return "not_pdf_name";
|
|
124
|
+
// eslint-disable-next-line no-control-regex
|
|
125
|
+
if (/[\u0000-\u001f<>"|?*\\/]/.test(name))
|
|
126
|
+
return "not_pdf_name";
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/** Hidden-path rule, measured on the path RELATIVE to the root so a root that
|
|
130
|
+
* itself sits under a dot-folder still works. Any segment starting with a dot. */
|
|
131
|
+
export function isHiddenRelative(root, realOutput) {
|
|
132
|
+
const rel = path.relative(root, realOutput);
|
|
133
|
+
return rel.split(path.sep).some((seg) => seg.startsWith("."));
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Errors
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
/** A message from confineInput / confineOutput / confineDir, passed through verbatim. */
|
|
139
|
+
class PassThrough extends Error {
|
|
140
|
+
}
|
|
141
|
+
function pass(fn) {
|
|
142
|
+
try {
|
|
143
|
+
return fn();
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
throw new PassThrough(e.message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Worker response (validated: the child is untrusted too)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
const LOST_CODES = ["outlines", "info", "page_labels", "xfa", "layers"];
|
|
154
|
+
const KNOWN_WARNINGS = new Set([SIGNATURE_WARNING, LAYERS_WARNING, RENAME_WARNING, FONT_RENAME_WARNING]);
|
|
155
|
+
const workerOk = z.object({
|
|
156
|
+
v: z.literal(1),
|
|
157
|
+
ok: z.literal(true),
|
|
158
|
+
meta: z.record(z.string(), z.unknown()),
|
|
159
|
+
parts: z.array(z.object({ temp: z.string(), byteSize: z.number().int().nonnegative() }).strict()).max(MAX_SPLIT_RANGES),
|
|
160
|
+
warnings: z.array(z.string()).max(20),
|
|
161
|
+
lost: z.array(z.enum(LOST_CODES)).max(10),
|
|
162
|
+
});
|
|
163
|
+
const workerErr = z.object({
|
|
164
|
+
v: z.literal(1),
|
|
165
|
+
ok: z.literal(false),
|
|
166
|
+
code: z.string().max(64),
|
|
167
|
+
item: z.number().int().optional(),
|
|
168
|
+
pageCount: z.number().int().nonnegative().optional(),
|
|
169
|
+
inputIndex: z.number().int().positive().optional(),
|
|
170
|
+
});
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// The runner: slots, ping, spawn, kill, place, clean up
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/** Exported for tests only (the slot queue is exercised directly). */
|
|
175
|
+
export class EditRunner {
|
|
176
|
+
o;
|
|
177
|
+
running = 0;
|
|
178
|
+
waiting = [];
|
|
179
|
+
ping;
|
|
180
|
+
constructor(o) {
|
|
181
|
+
this.o = o;
|
|
182
|
+
}
|
|
183
|
+
async run(job, signal) {
|
|
184
|
+
const release = await this.acquire(signal);
|
|
185
|
+
try {
|
|
186
|
+
if (!(await this.ensurePing()))
|
|
187
|
+
throw new EditRefusal("failed");
|
|
188
|
+
return await this.execute(job, signal);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
release();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// --- concurrency: two workers at once, eight calls waiting, a ninth refused ---
|
|
195
|
+
acquire(signal) {
|
|
196
|
+
// A call already cancelled takes neither a slot nor a place in the queue.
|
|
197
|
+
if (signal?.aborted)
|
|
198
|
+
return Promise.reject(new EditRefusal("failed"));
|
|
199
|
+
const make = () => {
|
|
200
|
+
let released = false;
|
|
201
|
+
return () => {
|
|
202
|
+
if (released)
|
|
203
|
+
return;
|
|
204
|
+
released = true;
|
|
205
|
+
const next = this.waiting.shift();
|
|
206
|
+
if (next)
|
|
207
|
+
next.grant();
|
|
208
|
+
else
|
|
209
|
+
this.running--;
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
if (this.running < MAX_CONCURRENT) {
|
|
213
|
+
this.running++;
|
|
214
|
+
return Promise.resolve(make());
|
|
215
|
+
}
|
|
216
|
+
if (this.waiting.length >= MAX_WAITING)
|
|
217
|
+
return Promise.reject(new EditRefusal("busy"));
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
const entry = { grant: () => resolve(make()), reject };
|
|
220
|
+
this.waiting.push(entry);
|
|
221
|
+
signal?.addEventListener("abort", () => {
|
|
222
|
+
const i = this.waiting.indexOf(entry);
|
|
223
|
+
if (i !== -1) {
|
|
224
|
+
this.waiting.splice(i, 1);
|
|
225
|
+
reject(new EditRefusal("failed"));
|
|
226
|
+
}
|
|
227
|
+
}, { once: true });
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// --- first use: a 5-second ping, so a child that cannot start fails fast ---
|
|
231
|
+
ensurePing() {
|
|
232
|
+
this.ping ??= this.doPing();
|
|
233
|
+
return this.ping;
|
|
234
|
+
}
|
|
235
|
+
async doPing() {
|
|
236
|
+
let dir;
|
|
237
|
+
try {
|
|
238
|
+
const doc = await PDFDocument.create({ updateMetadata: false });
|
|
239
|
+
doc.addPage([200, 200]);
|
|
240
|
+
dir = mkdtempSync(path.join(realpathSync(os.tmpdir()), "omitly-edit-ping-"));
|
|
241
|
+
const file = path.join(dir, "ping.pdf");
|
|
242
|
+
writeFileSync(file, await doc.save());
|
|
243
|
+
const req = {
|
|
244
|
+
v: 1,
|
|
245
|
+
op: "info",
|
|
246
|
+
inputs: [file],
|
|
247
|
+
outDir: dir,
|
|
248
|
+
temps: [],
|
|
249
|
+
caps: { perFileBytes: MiB, totalBytes: MiB },
|
|
250
|
+
timeoutMs: PING_TIMEOUT_MS,
|
|
251
|
+
params: {},
|
|
252
|
+
};
|
|
253
|
+
const out = await this.spawnChild(req, PING_TIMEOUT_MS, undefined);
|
|
254
|
+
const parsed = this.parseOutcome(out);
|
|
255
|
+
const info = parsed.ok ? parsed.value.meta.info : undefined;
|
|
256
|
+
if (parsed.ok && info?.pageCount === 1)
|
|
257
|
+
return true;
|
|
258
|
+
// Logged locally for the person running the server; never returned to the agent.
|
|
259
|
+
console.error(`[omitly-mcp] the PDF edit worker did not answer its start-up check (exit ${out.code}, signal ${out.signal}); ` +
|
|
260
|
+
`the page tools are disabled for this session. ${out.stderr.slice(0, 2000)}`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
console.error(`[omitly-mcp] the PDF edit worker start-up check failed: ${e.message}`);
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
if (dir)
|
|
269
|
+
rmSync(dir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// --- the child ---
|
|
273
|
+
spawnChild(req, timeoutMs, signal) {
|
|
274
|
+
return new Promise((resolve) => {
|
|
275
|
+
const env = childEnvironment({ platform: process.platform, env: process.env, electron: Boolean(process.versions.electron) });
|
|
276
|
+
const child = spawn(process.execPath, [...this.o.workerArgs, `--max-old-space-size=${this.o.maxOldSpaceMb}`, this.o.workerPath], { env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
277
|
+
const out = {
|
|
278
|
+
code: null,
|
|
279
|
+
signal: null,
|
|
280
|
+
stdout: "",
|
|
281
|
+
stderr: "",
|
|
282
|
+
oversize: false,
|
|
283
|
+
timedOut: false,
|
|
284
|
+
aborted: false,
|
|
285
|
+
spawnError: false,
|
|
286
|
+
};
|
|
287
|
+
const kill = () => {
|
|
288
|
+
try {
|
|
289
|
+
child.kill("SIGKILL");
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
/* already gone */
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const timer = setTimeout(() => {
|
|
296
|
+
out.timedOut = true;
|
|
297
|
+
kill();
|
|
298
|
+
}, timeoutMs);
|
|
299
|
+
const onAbort = () => {
|
|
300
|
+
out.aborted = true;
|
|
301
|
+
kill();
|
|
302
|
+
};
|
|
303
|
+
if (signal?.aborted)
|
|
304
|
+
onAbort();
|
|
305
|
+
else
|
|
306
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
307
|
+
child.stdout.setEncoding("utf8");
|
|
308
|
+
child.stdout.on("data", (d) => {
|
|
309
|
+
if (out.oversize)
|
|
310
|
+
return;
|
|
311
|
+
out.stdout += d;
|
|
312
|
+
if (out.stdout.length > MAX_STDOUT) {
|
|
313
|
+
out.oversize = true;
|
|
314
|
+
out.stdout = "";
|
|
315
|
+
kill();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
child.stderr.setEncoding("utf8");
|
|
319
|
+
child.stderr.on("data", (d) => {
|
|
320
|
+
if (out.stderr.length < MAX_STDERR)
|
|
321
|
+
out.stderr += d.slice(0, MAX_STDERR - out.stderr.length);
|
|
322
|
+
});
|
|
323
|
+
child.stdin.on("error", () => {
|
|
324
|
+
/* the child may already be dead; its exit is handled below */
|
|
325
|
+
});
|
|
326
|
+
child.on("error", () => {
|
|
327
|
+
out.spawnError = true;
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
signal?.removeEventListener("abort", onAbort);
|
|
330
|
+
resolve(out);
|
|
331
|
+
});
|
|
332
|
+
// `close`, not `exit`: stdout is drained, and a killed child has stopped writing.
|
|
333
|
+
child.on("close", (code, sig) => {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
signal?.removeEventListener("abort", onAbort);
|
|
336
|
+
out.code = code;
|
|
337
|
+
out.signal = sig;
|
|
338
|
+
// The pipe is left open until now: ending stdin is how the child learns the
|
|
339
|
+
// server is gone, so the server must not end it while it is still working.
|
|
340
|
+
resolve(out);
|
|
341
|
+
});
|
|
342
|
+
// One JSON line. stdin stays open until the child exits.
|
|
343
|
+
child.stdin.write(JSON.stringify(req) + "\n");
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
parseOutcome(out) {
|
|
347
|
+
if (out.spawnError || out.oversize || out.code !== 0 || out.signal)
|
|
348
|
+
return { ok: false, error: null };
|
|
349
|
+
const text = out.stdout.trim();
|
|
350
|
+
if (text === "" || text.includes("\n"))
|
|
351
|
+
return { ok: false, error: null };
|
|
352
|
+
let json;
|
|
353
|
+
try {
|
|
354
|
+
json = JSON.parse(text);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return { ok: false, error: null };
|
|
358
|
+
}
|
|
359
|
+
const good = workerOk.safeParse(json);
|
|
360
|
+
if (good.success)
|
|
361
|
+
return { ok: true, value: good.data };
|
|
362
|
+
const bad = workerErr.safeParse(json);
|
|
363
|
+
return { ok: false, error: bad.success ? bad.data : null };
|
|
364
|
+
}
|
|
365
|
+
// --- one call ---
|
|
366
|
+
async execute(job, signal) {
|
|
367
|
+
const callId = randomBytes(8).toString("hex");
|
|
368
|
+
const temps = job.finals.map((_, i) => `.omitly-tmp-${callId}-${i}.pdf`);
|
|
369
|
+
const issued = temps.map((t) => path.join(job.outDir, t));
|
|
370
|
+
/** Files this call created, by the inode it created: only these are ever removed on failure. */
|
|
371
|
+
const ours = new Map();
|
|
372
|
+
let ok = false;
|
|
373
|
+
try {
|
|
374
|
+
const req = {
|
|
375
|
+
v: 1,
|
|
376
|
+
op: job.op,
|
|
377
|
+
inputs: job.inputs,
|
|
378
|
+
outDir: job.outDir,
|
|
379
|
+
temps,
|
|
380
|
+
caps: job.caps,
|
|
381
|
+
timeoutMs: this.o.timeoutMs,
|
|
382
|
+
params: job.params,
|
|
383
|
+
};
|
|
384
|
+
const out = await this.spawnChild(req, this.o.timeoutMs, signal);
|
|
385
|
+
if (out.timedOut)
|
|
386
|
+
throw new EditRefusal("timeout");
|
|
387
|
+
if (out.aborted)
|
|
388
|
+
throw new EditRefusal("failed");
|
|
389
|
+
const parsed = this.parseOutcome(out);
|
|
390
|
+
if (!parsed.ok) {
|
|
391
|
+
const e = parsed.error;
|
|
392
|
+
if (e && CHILD_CODES.has(e.code)) {
|
|
393
|
+
throw new EditRefusal(e.code, { item: e.item, pageCount: e.pageCount, inputIndex: e.inputIndex });
|
|
394
|
+
}
|
|
395
|
+
throw new EditRefusal("failed");
|
|
396
|
+
}
|
|
397
|
+
const res = parsed.value;
|
|
398
|
+
// Parts: exactly the names this call issued, in order.
|
|
399
|
+
if (res.parts.length !== temps.length)
|
|
400
|
+
throw new EditRefusal("failed");
|
|
401
|
+
res.parts.forEach((p, i) => {
|
|
402
|
+
if (p.temp !== temps[i])
|
|
403
|
+
throw new EditRefusal("failed");
|
|
404
|
+
});
|
|
405
|
+
const byteSizes = [];
|
|
406
|
+
for (let i = 0; i < res.parts.length; i++) {
|
|
407
|
+
const temp = issued[i];
|
|
408
|
+
const st = lstatSync(temp, { throwIfNoEntry: false });
|
|
409
|
+
if (!st || !st.isFile() || st.size !== res.parts[i].byteSize)
|
|
410
|
+
throw new EditRefusal("failed");
|
|
411
|
+
const head = Buffer.alloc(5);
|
|
412
|
+
const fd = openSync(temp, "r");
|
|
413
|
+
try {
|
|
414
|
+
readSync(fd, head, 0, 5, 0);
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
closeSync(fd);
|
|
418
|
+
}
|
|
419
|
+
if (head.toString("latin1") !== "%PDF-")
|
|
420
|
+
throw new EditRefusal("failed");
|
|
421
|
+
byteSizes.push(st.size);
|
|
422
|
+
}
|
|
423
|
+
// Place: create-only. A link fails with EEXIST if the name is taken (a dangling
|
|
424
|
+
// symlink included) and never overwrites; where hard links are impossible
|
|
425
|
+
// (FAT32, exFAT, many network shares) the fallback opens the final name with `wx`
|
|
426
|
+
// (O_EXCL) and copies the bytes into THAT descriptor, so it is create-only too and
|
|
427
|
+
// the server knows it created the file it may later have to remove.
|
|
428
|
+
//
|
|
429
|
+
// RESIDUAL WINDOW (stated, not closed): the output folder was confined to a real path
|
|
430
|
+
// at the start of the call. A hostile LOCAL user who swaps that folder (or a parent)
|
|
431
|
+
// for a symlink between our check and the link/open system call can still redirect one
|
|
432
|
+
// write. Hostile local users are outside this server's threat model (paths.ts). What is
|
|
433
|
+
// done about it: the child checks the folder's real path before it writes each temp, and
|
|
434
|
+
// the server checks it immediately before AND after each placement; a mismatch after
|
|
435
|
+
// placement removes what this call placed (by inode) and refuses `write_failed`.
|
|
436
|
+
// Also accepted: if another process RENAMES the folder mid-call, the call returns `write_failed`
|
|
437
|
+
// but files already placed, and a temp, can remain under the renamed folder, because cleanup goes
|
|
438
|
+
// by the original path (Node has no directory handles; same root cause as the window above).
|
|
439
|
+
const link = this.o.link ?? linkSync;
|
|
440
|
+
const dirIsConfined = (final) => {
|
|
441
|
+
try {
|
|
442
|
+
return realpathSync(path.dirname(final)) === job.outDir;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
for (let i = 0; i < job.finals.length; i++) {
|
|
449
|
+
const final = job.finals[i];
|
|
450
|
+
if (!dirIsConfined(final))
|
|
451
|
+
throw new EditRefusal("write_failed");
|
|
452
|
+
this.o.beforePlace?.(final);
|
|
453
|
+
const before = lstatSync(issued[i], { throwIfNoEntry: false });
|
|
454
|
+
let created;
|
|
455
|
+
try {
|
|
456
|
+
link(issued[i], final);
|
|
457
|
+
const st = lstatSync(final, { throwIfNoEntry: false });
|
|
458
|
+
// The inode we linked in; if the name now holds something else, it is not ours.
|
|
459
|
+
if (st && before && st.ino === before.ino && st.dev === before.dev)
|
|
460
|
+
created = { dev: st.dev, ino: st.ino };
|
|
461
|
+
if (created)
|
|
462
|
+
ours.set(final, created);
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
if (e.code === "EEXIST")
|
|
466
|
+
throw new EditRefusal("output_exists");
|
|
467
|
+
let fd;
|
|
468
|
+
try {
|
|
469
|
+
fd = openSync(final, "wx", 0o600);
|
|
470
|
+
}
|
|
471
|
+
catch (e2) {
|
|
472
|
+
if (e2.code === "EEXIST")
|
|
473
|
+
throw new EditRefusal("output_exists");
|
|
474
|
+
throw new EditRefusal("write_failed");
|
|
475
|
+
}
|
|
476
|
+
// From here on the file at `final` is ours: any failure removes it.
|
|
477
|
+
const mine = fstatSync(fd);
|
|
478
|
+
ours.set(final, { dev: mine.dev, ino: mine.ino });
|
|
479
|
+
try {
|
|
480
|
+
// In fixed chunks: the child exists to keep big buffers out of THIS process, and a
|
|
481
|
+
// split can write up to 1 GB.
|
|
482
|
+
const from = openSync(issued[i], "r");
|
|
483
|
+
try {
|
|
484
|
+
const chunk = Buffer.allocUnsafe(COPY_CHUNK);
|
|
485
|
+
for (;;) {
|
|
486
|
+
const n = readSync(from, chunk, 0, COPY_CHUNK, null);
|
|
487
|
+
if (n === 0)
|
|
488
|
+
break;
|
|
489
|
+
let off = 0;
|
|
490
|
+
while (off < n)
|
|
491
|
+
off += writeSync(fd, chunk, off, n - off);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
closeSync(from);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
throw new EditRefusal("write_failed");
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
closeSync(fd);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (!dirIsConfined(final))
|
|
506
|
+
throw new EditRefusal("write_failed");
|
|
507
|
+
const st = lstatSync(final, { throwIfNoEntry: false });
|
|
508
|
+
if (!st || !st.isFile() || st.size !== byteSizes[i])
|
|
509
|
+
throw new EditRefusal("write_failed");
|
|
510
|
+
await removeQuiet(issued[i]);
|
|
511
|
+
}
|
|
512
|
+
ok = true;
|
|
513
|
+
return {
|
|
514
|
+
meta: res.meta,
|
|
515
|
+
finals: [...job.finals],
|
|
516
|
+
byteSizes,
|
|
517
|
+
warnings: res.warnings.filter((w) => KNOWN_WARNINGS.has(w)),
|
|
518
|
+
lost: res.lost,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
if (e instanceof EditRefusal)
|
|
523
|
+
throw e;
|
|
524
|
+
throw new EditRefusal("failed");
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
// The child has already closed (spawnChild resolves on `close`), so nothing
|
|
528
|
+
// is still writing. Only names THIS call issued and finals THIS call created (by inode);
|
|
529
|
+
// never a directory sweep, so another call's files are never touched.
|
|
530
|
+
for (const t of issued)
|
|
531
|
+
await removeQuiet(t);
|
|
532
|
+
if (!ok)
|
|
533
|
+
for (const [f, id] of ours)
|
|
534
|
+
await removeQuiet(f, id);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** lstat then unlink; retries EBUSY/EPERM briefly (Windows holds a just-killed child's files). */
|
|
539
|
+
async function removeQuiet(file, only) {
|
|
540
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
541
|
+
try {
|
|
542
|
+
const st = lstatSync(file, { throwIfNoEntry: false });
|
|
543
|
+
if (st === undefined)
|
|
544
|
+
return;
|
|
545
|
+
// `only`: remove it only if it is still the very file this call created.
|
|
546
|
+
if (only && (st.ino !== only.ino || st.dev !== only.dev))
|
|
547
|
+
return;
|
|
548
|
+
unlinkSync(file);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
const code = e.code;
|
|
553
|
+
if (code === "ENOENT")
|
|
554
|
+
return;
|
|
555
|
+
if (code !== "EBUSY" && code !== "EPERM" && code !== "EACCES")
|
|
556
|
+
return;
|
|
557
|
+
await sleep(50);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// Output schemas (strict; `warnings` and `lost` on every writer)
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
const lostSchema = z.array(z.enum(LOST_CODES));
|
|
565
|
+
const warningsSchema = z.array(z.string());
|
|
566
|
+
const pageInfoSchema = z
|
|
567
|
+
.object({
|
|
568
|
+
pageNumber: z.number().int(),
|
|
569
|
+
widthPt: z.number().nullable(),
|
|
570
|
+
heightPt: z.number().nullable(),
|
|
571
|
+
rotation: z.number().nullable(),
|
|
572
|
+
})
|
|
573
|
+
.strict();
|
|
574
|
+
export const pdfInfoOutputSchema = z
|
|
575
|
+
.object({
|
|
576
|
+
pageCount: z.number().int(),
|
|
577
|
+
pages: z.array(pageInfoSchema),
|
|
578
|
+
pagesTruncated: z.boolean(),
|
|
579
|
+
encrypted: z.boolean().nullable(),
|
|
580
|
+
hasForm: z.boolean().nullable(),
|
|
581
|
+
formFieldCount: z.number().int().nullable(),
|
|
582
|
+
hasSignatures: z.boolean().nullable(),
|
|
583
|
+
hasComments: z.boolean().nullable(),
|
|
584
|
+
hasXfa: z.enum(["none", "static", "dynamic"]).nullable(),
|
|
585
|
+
hasLayers: z.boolean().nullable(),
|
|
586
|
+
byteSize: z.number().int(),
|
|
587
|
+
})
|
|
588
|
+
.strict();
|
|
589
|
+
export const mergeOutputSchema = z
|
|
590
|
+
.object({
|
|
591
|
+
outputPath: z.string(),
|
|
592
|
+
pageCount: z.number().int(),
|
|
593
|
+
byteSize: z.number().int(),
|
|
594
|
+
sources: z.array(z.object({ input: z.string(), pages: z.number().int() }).strict()),
|
|
595
|
+
lost: lostSchema,
|
|
596
|
+
warnings: warningsSchema,
|
|
597
|
+
})
|
|
598
|
+
.strict();
|
|
599
|
+
export const extractOutputSchema = z
|
|
600
|
+
.object({
|
|
601
|
+
outputPath: z.string(),
|
|
602
|
+
pageCount: z.number().int(),
|
|
603
|
+
byteSize: z.number().int(),
|
|
604
|
+
sourcePages: z.array(z.number().int()),
|
|
605
|
+
lost: lostSchema,
|
|
606
|
+
warnings: warningsSchema,
|
|
607
|
+
})
|
|
608
|
+
.strict();
|
|
609
|
+
export const splitOutputSchema = z
|
|
610
|
+
.object({
|
|
611
|
+
files: z.array(z
|
|
612
|
+
.object({
|
|
613
|
+
outputPath: z.string(),
|
|
614
|
+
sourcePages: z.array(z.number().int()),
|
|
615
|
+
pageCount: z.number().int(),
|
|
616
|
+
byteSize: z.number().int(),
|
|
617
|
+
})
|
|
618
|
+
.strict()),
|
|
619
|
+
lost: lostSchema,
|
|
620
|
+
warnings: warningsSchema,
|
|
621
|
+
})
|
|
622
|
+
.strict();
|
|
623
|
+
export const editPagesOutputSchema = z
|
|
624
|
+
.object({
|
|
625
|
+
outputPath: z.string(),
|
|
626
|
+
pageCount: z.number().int(),
|
|
627
|
+
byteSize: z.number().int(),
|
|
628
|
+
layout: z.array(z
|
|
629
|
+
.object({
|
|
630
|
+
pageNumber: z.number().int(),
|
|
631
|
+
from: z.union([z.number().int(), z.literal("blank")]),
|
|
632
|
+
rotation: z.number(),
|
|
633
|
+
})
|
|
634
|
+
.strict()),
|
|
635
|
+
lost: lostSchema,
|
|
636
|
+
warnings: warningsSchema,
|
|
637
|
+
})
|
|
638
|
+
.strict();
|
|
639
|
+
/** Same job as `checked()` in index.ts (kept local: index.ts imports this file). */
|
|
640
|
+
function checkedOut(schema, value) {
|
|
641
|
+
const parsed = schema.safeParse(value);
|
|
642
|
+
if (!parsed.success)
|
|
643
|
+
throw new EditRefusal("failed");
|
|
644
|
+
return parsed.data;
|
|
645
|
+
}
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// Registration
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
const PAGE_RULE = "Page numbers start at 1 (the check tools return 0-based `page` values: add 1). ";
|
|
650
|
+
const PAGES_ITEM_DESC = 'A page number like 3 or a range like "2-5". A bare-number string ("5"), a leading zero, a reversed range and a repeated page are refused.';
|
|
651
|
+
const NEW_FILE_RULE = "Writes a NEW .pdf inside the allowed folder and never changes or overwrites an existing file. ";
|
|
652
|
+
const NOT_REDACTION = "This is not redaction and does not sanitise: pages you leave out are not copied, but document metadata, " +
|
|
653
|
+
"bookmark titles and anything a kept page shares with an omitted page can remain. Use redact_pdf to remove sensitive content. " +
|
|
654
|
+
"Nothing is uploaded.";
|
|
655
|
+
const NOT_REDACTION_MERGE = "This is not redaction and does not sanitise. Use redact_pdf to remove sensitive content. Nothing is uploaded.";
|
|
656
|
+
const pageItem = z.union([z.number(), z.string()]).describe(PAGES_ITEM_DESC);
|
|
657
|
+
function text(t, isError = false) {
|
|
658
|
+
return { content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) };
|
|
659
|
+
}
|
|
660
|
+
function refusalResult(e) {
|
|
661
|
+
if (e instanceof EditRefusal || e instanceof PassThrough)
|
|
662
|
+
return text(e.message, true);
|
|
663
|
+
// Never an exception message: it could carry a path or document content.
|
|
664
|
+
return text(messageFor("failed"), true);
|
|
665
|
+
}
|
|
666
|
+
export function registerEditTools(server, options = {}) {
|
|
667
|
+
const root = options.root ?? allowedRoot();
|
|
668
|
+
const rootExplicit = options.rootExplicit ?? Boolean(process.env.OMITLY_ALLOWED_DIR?.trim());
|
|
669
|
+
const caps = { ...DEFAULT_CAPS, ...options.caps };
|
|
670
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
671
|
+
const runner = new EditRunner({
|
|
672
|
+
workerPath: options.workerPath ?? fileURLToPath(new URL("./pdf-edit/worker.js", import.meta.url)),
|
|
673
|
+
workerArgs: options.workerArgs ?? [],
|
|
674
|
+
maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
|
|
675
|
+
timeoutMs,
|
|
676
|
+
link: options.link,
|
|
677
|
+
beforePlace: options.beforePlace,
|
|
678
|
+
});
|
|
679
|
+
function guardRoot() {
|
|
680
|
+
// An explicit root never needs the home folder, so do not even ask for it.
|
|
681
|
+
let home;
|
|
682
|
+
if (!rootExplicit) {
|
|
683
|
+
try {
|
|
684
|
+
home = os.homedir();
|
|
685
|
+
home = realpathSync(home);
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
/* homedir() throws with no HOME and no passwd entry; an unresolvable path is kept as is */
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (rootNeedsExplicitDir(root, rootExplicit, home))
|
|
692
|
+
throw new EditRefusal("allowed_dir_needed");
|
|
693
|
+
}
|
|
694
|
+
/** confineInput, then stat on the CONFINED path only. */
|
|
695
|
+
function confineInputFile(p, inputIndex) {
|
|
696
|
+
const real = pass(() => confineInput(p, root));
|
|
697
|
+
let st;
|
|
698
|
+
try {
|
|
699
|
+
st = statSync(real);
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
throw new EditRefusal("not_a_file", { inputIndex });
|
|
703
|
+
}
|
|
704
|
+
if (!st.isFile())
|
|
705
|
+
throw new EditRefusal("not_a_file", { inputIndex });
|
|
706
|
+
if (st.size > caps.perFileBytes)
|
|
707
|
+
throw new EditRefusal("too_large", { inputIndex });
|
|
708
|
+
return { real, size: st.size };
|
|
709
|
+
}
|
|
710
|
+
/** Output name rules, then confineOutput, then the hidden-path rule. */
|
|
711
|
+
function confineNewPdf(p) {
|
|
712
|
+
const nameRefusal = outputNameRefusal(path.basename(path.resolve(root, p)));
|
|
713
|
+
if (nameRefusal)
|
|
714
|
+
throw new EditRefusal(nameRefusal);
|
|
715
|
+
const real = pass(() => confineOutput(p, root));
|
|
716
|
+
if (isHiddenRelative(root, real))
|
|
717
|
+
throw new EditRefusal("hidden_path");
|
|
718
|
+
return real;
|
|
719
|
+
}
|
|
720
|
+
// --- pdf_info ---
|
|
721
|
+
server.registerTool("pdf_info", {
|
|
722
|
+
title: "PDF info",
|
|
723
|
+
description: "Read basic facts about a PDF without opening it in a viewer: page count, each page's size and rotation " +
|
|
724
|
+
"(first 500 pages), and flags for encryption, forms, signatures, comments, XFA and layers. Returns counts and " +
|
|
725
|
+
"flags only, never text or field values. Works on encrypted files (reports encrypted: true). " +
|
|
726
|
+
PAGE_RULE +
|
|
727
|
+
'Example: {"pdfPath":"invoice.pdf"}. Nothing is uploaded.',
|
|
728
|
+
inputSchema: { pdfPath: z.string().describe("path to the PDF, inside the allowed folder") },
|
|
729
|
+
outputSchema: pdfInfoOutputSchema,
|
|
730
|
+
annotations: annotations("PDF info", true, true),
|
|
731
|
+
}, async ({ pdfPath }, extra) => {
|
|
732
|
+
try {
|
|
733
|
+
guardRoot();
|
|
734
|
+
const input = confineInputFile(pdfPath);
|
|
735
|
+
const r = await runner.run({ op: "info", inputs: [input.real], outDir: path.dirname(input.real), finals: [], params: {}, caps: { perFileBytes: caps.perFileBytes, totalBytes: caps.perFileBytes } }, extra?.signal);
|
|
736
|
+
const info = checkedOut(pdfInfoOutputSchema, r.meta.info);
|
|
737
|
+
return {
|
|
738
|
+
content: [{ type: "text", text: `${info.pageCount} page(s), ${info.byteSize} bytes.` }],
|
|
739
|
+
structuredContent: info,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
catch (e) {
|
|
743
|
+
return refusalResult(e);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
// --- merge_pdfs ---
|
|
747
|
+
server.registerTool("merge_pdfs", {
|
|
748
|
+
title: "Merge PDFs",
|
|
749
|
+
description: "Join 2 to 50 PDFs, in the order given, into one new PDF. All pages of every input are kept. " +
|
|
750
|
+
NEW_FILE_RULE +
|
|
751
|
+
"Form fields are kept (a clashing field name is renamed, with a warning). The result starts from an empty " +
|
|
752
|
+
"document, so bookmarks, document metadata, page labels and layers are not carried over; they are listed in `lost`. " +
|
|
753
|
+
"Digital signatures are removed (the output is a new, unsigned document). " +
|
|
754
|
+
'Example: {"inputs":["a.pdf","b.pdf"],"outputPath":"merged.pdf"}. ' +
|
|
755
|
+
NOT_REDACTION_MERGE,
|
|
756
|
+
inputSchema: {
|
|
757
|
+
inputs: z.array(z.string()).min(2).max(50).describe("PDFs to join, in order"),
|
|
758
|
+
outputPath: z.string().describe("new .pdf to create; must not exist"),
|
|
759
|
+
},
|
|
760
|
+
outputSchema: mergeOutputSchema,
|
|
761
|
+
annotations: annotations("Merge PDFs", false),
|
|
762
|
+
}, async ({ inputs, outputPath }, extra) => {
|
|
763
|
+
try {
|
|
764
|
+
guardRoot();
|
|
765
|
+
let total = 0;
|
|
766
|
+
const reals = inputs.map((p, i) => {
|
|
767
|
+
const f = confineInputFile(p, i + 1);
|
|
768
|
+
total += f.size;
|
|
769
|
+
if (total > caps.totalBytes)
|
|
770
|
+
throw new EditRefusal("too_large", { inputIndex: i + 1 });
|
|
771
|
+
return f.real;
|
|
772
|
+
});
|
|
773
|
+
const final = confineNewPdf(outputPath);
|
|
774
|
+
const r = await runner.run({ op: "merge", inputs: reals, outDir: path.dirname(final), finals: [final], params: {}, caps: { perFileBytes: caps.perFileBytes, totalBytes: caps.totalBytes } }, extra?.signal);
|
|
775
|
+
const meta = checkedOut(z.object({ pageCount: z.number().int(), pageCounts: z.array(z.number().int()).length(inputs.length) }), r.meta);
|
|
776
|
+
const structuredContent = checkedOut(mergeOutputSchema, {
|
|
777
|
+
outputPath: r.finals[0],
|
|
778
|
+
pageCount: meta.pageCount,
|
|
779
|
+
byteSize: r.byteSizes[0],
|
|
780
|
+
sources: inputs.map((input, i) => ({ input, pages: meta.pageCounts[i] })),
|
|
781
|
+
lost: r.lost,
|
|
782
|
+
warnings: r.warnings,
|
|
783
|
+
});
|
|
784
|
+
return {
|
|
785
|
+
content: [{ type: "text", text: `Merged ${inputs.length} PDFs into ${r.finals[0]} (${meta.pageCount} pages).` }],
|
|
786
|
+
structuredContent,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
catch (e) {
|
|
790
|
+
return refusalResult(e);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
// --- extract_pages ---
|
|
794
|
+
server.registerTool("extract_pages", {
|
|
795
|
+
title: "Extract pages",
|
|
796
|
+
description: "Copy chosen pages of a PDF into one new PDF, in the order listed. " +
|
|
797
|
+
PAGE_RULE +
|
|
798
|
+
NEW_FILE_RULE +
|
|
799
|
+
"Pages are an array of numbers and ranges; a page may not be listed twice. Bookmarks to kept pages, metadata " +
|
|
800
|
+
"and form fields on kept pages are kept; layers and static XFA are dropped (listed in `lost`). Digital signatures " +
|
|
801
|
+
"are removed. A dynamic XFA form or an encrypted PDF is refused. " +
|
|
802
|
+
'Example: {"pdfPath":"report.pdf","pages":[1,"3-5"],"outputPath":"report-part.pdf"}. ' +
|
|
803
|
+
NOT_REDACTION,
|
|
804
|
+
inputSchema: {
|
|
805
|
+
pdfPath: z.string().describe("path to the PDF, inside the allowed folder"),
|
|
806
|
+
pages: z.array(pageItem).min(1).max(1000).describe("pages to keep, 1-based, in output order"),
|
|
807
|
+
outputPath: z.string().describe("new .pdf to create; must not exist"),
|
|
808
|
+
},
|
|
809
|
+
outputSchema: extractOutputSchema,
|
|
810
|
+
annotations: annotations("Extract pages", false),
|
|
811
|
+
}, async ({ pdfPath, pages, outputPath }, extra) => {
|
|
812
|
+
try {
|
|
813
|
+
guardRoot();
|
|
814
|
+
const spec = expandPageSpec(pages);
|
|
815
|
+
const input = confineInputFile(pdfPath);
|
|
816
|
+
const final = confineNewPdf(outputPath);
|
|
817
|
+
const r = await runner
|
|
818
|
+
.run({ op: "extract", inputs: [input.real], outDir: path.dirname(final), finals: [final], params: { pages: spec.pages.map((p) => p - 1) }, caps: { perFileBytes: caps.perFileBytes, totalBytes: caps.perFileBytes } }, extra?.signal)
|
|
819
|
+
.catch((e) => {
|
|
820
|
+
throw withItem(e, spec.pages, spec.itemOf.map((i) => pages[i]));
|
|
821
|
+
});
|
|
822
|
+
const meta = checkedOut(z.object({ pageCount: z.number().int() }), r.meta);
|
|
823
|
+
const structuredContent = checkedOut(extractOutputSchema, {
|
|
824
|
+
outputPath: r.finals[0],
|
|
825
|
+
pageCount: meta.pageCount,
|
|
826
|
+
byteSize: r.byteSizes[0],
|
|
827
|
+
sourcePages: spec.pages,
|
|
828
|
+
lost: r.lost,
|
|
829
|
+
warnings: r.warnings,
|
|
830
|
+
});
|
|
831
|
+
return {
|
|
832
|
+
content: [{ type: "text", text: `Extracted ${meta.pageCount} page(s) to ${r.finals[0]}.` }],
|
|
833
|
+
structuredContent,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
catch (e) {
|
|
837
|
+
return refusalResult(e);
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
// --- split_pdf ---
|
|
841
|
+
server.registerTool("split_pdf", {
|
|
842
|
+
title: "Split PDF",
|
|
843
|
+
description: "Split a PDF into several new PDFs, one per range. " +
|
|
844
|
+
PAGE_RULE +
|
|
845
|
+
"Each range is a page spec (numbers and ranges) of consecutive pages in order, such as [\"1-3\"] or [4,5]; each " +
|
|
846
|
+
"file is named `<prefix>-p<first>-<last>.pdf` (or `<prefix>-p<first>.pdf` for one page) inside `outputDir`. " +
|
|
847
|
+
"All files are created or none are. " +
|
|
848
|
+
NEW_FILE_RULE +
|
|
849
|
+
"Layers and static XFA are dropped (listed in `lost`); digital signatures are removed. " +
|
|
850
|
+
'Example: {"pdfPath":"book.pdf","ranges":[["1-10"],["11-20"]],"outputDir":"parts","prefix":"book"}. ' +
|
|
851
|
+
NOT_REDACTION,
|
|
852
|
+
inputSchema: {
|
|
853
|
+
pdfPath: z.string().describe("path to the PDF, inside the allowed folder"),
|
|
854
|
+
ranges: z.array(z.array(pageItem).min(1).max(1000)).min(1).max(MAX_SPLIT_RANGES).describe("one page spec per output file"),
|
|
855
|
+
outputDir: z.string().describe("existing folder inside the allowed folder (the allowed folder itself is fine)"),
|
|
856
|
+
prefix: z.string().regex(SPLIT_PREFIX).describe("file name prefix: letters, digits, . _ -, up to 64"),
|
|
857
|
+
},
|
|
858
|
+
outputSchema: splitOutputSchema,
|
|
859
|
+
annotations: annotations("Split PDF", false),
|
|
860
|
+
}, async ({ pdfPath, ranges, outputDir, prefix }, extra) => {
|
|
861
|
+
try {
|
|
862
|
+
guardRoot();
|
|
863
|
+
// Syntax, duplicates, the 10,000-page total, contiguity and names: before any I/O on the file.
|
|
864
|
+
let budget = MAX_EXPANDED_PAGES;
|
|
865
|
+
const expanded = ranges.map((spec) => {
|
|
866
|
+
const e = expandPageSpec(spec, { budget });
|
|
867
|
+
budget -= e.pages.length;
|
|
868
|
+
if (!isContiguousAscending(e.pages))
|
|
869
|
+
throw new EditRefusal("split_not_contiguous");
|
|
870
|
+
return e;
|
|
871
|
+
});
|
|
872
|
+
const names = expanded.map((e) => splitFileName(prefix, e.pages));
|
|
873
|
+
const seen = new Set();
|
|
874
|
+
for (const n of names) {
|
|
875
|
+
const key = n.toLowerCase(); // a case-insensitive filesystem would collide these
|
|
876
|
+
if (seen.has(key))
|
|
877
|
+
throw new EditRefusal("split_name_clash", { item: n });
|
|
878
|
+
seen.add(key);
|
|
879
|
+
}
|
|
880
|
+
const input = confineInputFile(pdfPath);
|
|
881
|
+
const dir = pass(() => confineDir(outputDir, root));
|
|
882
|
+
const finals = names.map((n) => confineNewPdf(path.join(dir, n)));
|
|
883
|
+
const r = await runner
|
|
884
|
+
.run({ op: "split", inputs: [input.real], outDir: dir, finals, params: { ranges: expanded.map((e) => e.pages.map((p) => p - 1)) }, caps: { perFileBytes: caps.perFileBytes, totalBytes: caps.splitTotalBytes } }, extra?.signal)
|
|
885
|
+
.catch((e) => {
|
|
886
|
+
throw withItem(e, expanded.flatMap((x) => x.pages), expanded.flatMap((x, ri) => x.itemOf.map((ii) => ranges[ri][ii])));
|
|
887
|
+
});
|
|
888
|
+
const meta = checkedOut(z.object({ pageCounts: z.array(z.number().int()).length(finals.length) }), r.meta);
|
|
889
|
+
const structuredContent = checkedOut(splitOutputSchema, {
|
|
890
|
+
files: finals.map((f, i) => ({
|
|
891
|
+
outputPath: r.finals[i],
|
|
892
|
+
sourcePages: expanded[i].pages,
|
|
893
|
+
pageCount: meta.pageCounts[i],
|
|
894
|
+
byteSize: r.byteSizes[i],
|
|
895
|
+
})),
|
|
896
|
+
lost: r.lost,
|
|
897
|
+
warnings: r.warnings,
|
|
898
|
+
});
|
|
899
|
+
return {
|
|
900
|
+
content: [{ type: "text", text: `Wrote ${finals.length} file(s) into ${dir}.` }],
|
|
901
|
+
structuredContent,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
catch (e) {
|
|
905
|
+
return refusalResult(e);
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
// --- edit_pages ---
|
|
909
|
+
const sourceEntry = z
|
|
910
|
+
.object({
|
|
911
|
+
source: z.number().int().min(1).describe("page of the ORIGINAL document, 1-based"),
|
|
912
|
+
rotate: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]).optional().describe("clockwise turn added to the page's rotation"),
|
|
913
|
+
})
|
|
914
|
+
.strict();
|
|
915
|
+
const blankEntry = z
|
|
916
|
+
.object({
|
|
917
|
+
blank: z.literal(true),
|
|
918
|
+
size: z.enum(["previous", "A4", "Letter"]).optional().describe("default previous: the size of the page before it (A4 if first)"),
|
|
919
|
+
})
|
|
920
|
+
.strict();
|
|
921
|
+
server.registerTool("edit_pages", {
|
|
922
|
+
title: "Edit pages",
|
|
923
|
+
description: "Reorder, delete, rotate and insert blank pages in one step by giving the FINAL page list. Each entry is " +
|
|
924
|
+
'`{"source": n, "rotate": 0|90|180|270}` (n is a page of the ORIGINAL document, 1-based) or ' +
|
|
925
|
+
'`{"blank": true, "size": "previous"|"A4"|"Letter"}`. A page you leave out is deleted; the list order is the new ' +
|
|
926
|
+
"order; a source page may appear only once; at least one entry must be a source page. " +
|
|
927
|
+
PAGE_RULE +
|
|
928
|
+
NEW_FILE_RULE +
|
|
929
|
+
"Page labels are removed when pages are reordered or a blank is inserted; layers and static XFA are dropped " +
|
|
930
|
+
"(listed in `lost`); digital signatures are removed. `layout` in the result says where each output page came from. " +
|
|
931
|
+
'Example: {"pdfPath":"deck.pdf","pages":[{"source":2},{"source":1,"rotate":90},{"blank":true}],"outputPath":"deck-edited.pdf"}. ' +
|
|
932
|
+
NOT_REDACTION,
|
|
933
|
+
inputSchema: {
|
|
934
|
+
pdfPath: z.string().describe("path to the PDF, inside the allowed folder"),
|
|
935
|
+
pages: z.array(z.union([sourceEntry, blankEntry])).min(1).max(MAX_EXPANDED_PAGES).describe("the final page list"),
|
|
936
|
+
outputPath: z.string().describe("new .pdf to create; must not exist"),
|
|
937
|
+
},
|
|
938
|
+
outputSchema: editPagesOutputSchema,
|
|
939
|
+
annotations: annotations("Edit pages", false),
|
|
940
|
+
}, async ({ pdfPath, pages, outputPath }, extra) => {
|
|
941
|
+
try {
|
|
942
|
+
guardRoot();
|
|
943
|
+
const seen = new Set();
|
|
944
|
+
let sources = 0;
|
|
945
|
+
for (const e of pages) {
|
|
946
|
+
if ("blank" in e)
|
|
947
|
+
continue;
|
|
948
|
+
if (seen.has(e.source))
|
|
949
|
+
throw new EditRefusal("duplicate_page", { item: e.source });
|
|
950
|
+
seen.add(e.source);
|
|
951
|
+
sources++;
|
|
952
|
+
}
|
|
953
|
+
if (sources === 0)
|
|
954
|
+
throw new EditRefusal("no_source_pages");
|
|
955
|
+
const input = confineInputFile(pdfPath);
|
|
956
|
+
const final = confineNewPdf(outputPath);
|
|
957
|
+
const layout = pages.map((e) => "blank" in e ? { blank: true, size: e.size ?? "previous" } : { source: e.source, rotate: e.rotate ?? 0 });
|
|
958
|
+
const r = await runner.run({ op: "compose", inputs: [input.real], outDir: path.dirname(final), finals: [final], params: { layout }, caps: { perFileBytes: caps.perFileBytes, totalBytes: caps.perFileBytes } }, extra?.signal);
|
|
959
|
+
const meta = checkedOut(z.object({ pageCount: z.number().int(), layout: editPagesOutputSchema.shape.layout }), r.meta);
|
|
960
|
+
const structuredContent = checkedOut(editPagesOutputSchema, {
|
|
961
|
+
outputPath: r.finals[0],
|
|
962
|
+
pageCount: meta.pageCount,
|
|
963
|
+
byteSize: r.byteSizes[0],
|
|
964
|
+
layout: meta.layout,
|
|
965
|
+
lost: r.lost,
|
|
966
|
+
warnings: r.warnings,
|
|
967
|
+
});
|
|
968
|
+
return {
|
|
969
|
+
content: [{ type: "text", text: `Wrote ${meta.pageCount} page(s) to ${r.finals[0]}.` }],
|
|
970
|
+
structuredContent,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
catch (e) {
|
|
974
|
+
return refusalResult(e);
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Turn a child's `page_out_of_range` (which names the offending 1-based page number) into
|
|
980
|
+
* the agent-facing message that quotes the ORIGINAL spec item, so no child-supplied
|
|
981
|
+
* string ever reaches the message. `itemPerPage[k]` is the spec item `pages[k]` came from.
|
|
982
|
+
*/
|
|
983
|
+
function withItem(e, pages, itemPerPage) {
|
|
984
|
+
if (e instanceof EditRefusal && e.code === "page_out_of_range" && typeof e.detail.item === "number") {
|
|
985
|
+
const k = pages.indexOf(e.detail.item);
|
|
986
|
+
return new EditRefusal("page_out_of_range", {
|
|
987
|
+
item: k === -1 ? e.detail.item : itemPerPage[k],
|
|
988
|
+
pageCount: e.detail.pageCount,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
return e;
|
|
992
|
+
}
|
|
993
|
+
//# sourceMappingURL=edit-tools.js.map
|