@remcp/runtime 0.2.152 → 0.2.154

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.
@@ -0,0 +1,270 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { copyFile, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
6
+ import { readDocxXmlText, readPdfText } from '../documents.mjs';
7
+ import { openDirectoryPath } from '../tools/files.mjs';
8
+ import { resolveSafePath, text } from '../util.mjs';
9
+ import {
10
+ clamp,
11
+ commandExists,
12
+ escapePowerShellSingle,
13
+ jsonResult,
14
+ optionalString,
15
+ removeTemp,
16
+ requireEnum,
17
+ runFile,
18
+ runPowerShell,
19
+ tempDir,
20
+ unavailable,
21
+ } from './common.mjs';
22
+
23
+ // Internal OOXML/PDF helpers; keep all file reads descriptor-bound and size-limited.
24
+
25
+ export function xmlEscape(value) {
26
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
27
+ }
28
+ export function xmlUnescape(value) {
29
+ return String(value).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
30
+ }
31
+
32
+ const MAX_OOXML_ENTRIES = 10_000;
33
+ const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
34
+ const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
35
+ const DOCUMENT_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
36
+
37
+ export function descriptorAnchorsAvailable() {
38
+ return process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
39
+ }
40
+
41
+ export async function openDocumentSource(filePath) {
42
+ if (!descriptorAnchorsAvailable()) {
43
+ const handle = await open(filePath, DOCUMENT_READ_FLAGS);
44
+ try {
45
+ const info = await handle.stat();
46
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
47
+ return { handle, close: () => handle.close() };
48
+ } catch (error) {
49
+ await handle.close().catch(() => {});
50
+ throw error;
51
+ }
52
+ }
53
+ const parent = await openDirectoryPath(path.dirname(filePath));
54
+ let handle;
55
+ try {
56
+ handle = await open(path.join(parent.anchor, path.basename(filePath)), DOCUMENT_READ_FLAGS);
57
+ const info = await handle.stat();
58
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
59
+ return {
60
+ handle,
61
+ close: async () => {
62
+ try { await handle.close(); }
63
+ finally { await parent.close().catch(() => {}); }
64
+ },
65
+ };
66
+ } catch (error) {
67
+ if (handle) await handle.close().catch(() => {});
68
+ await parent.close().catch(() => {});
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ export async function openDocumentDirectoryPath(directory, { create = false } = {}) {
74
+ const opened = await openDirectoryPath(directory, { create });
75
+ return { ...opened, descriptorBound:opened.anchor !== directory || process.platform === 'linux' };
76
+ }
77
+
78
+ export async function createAnchoredTempDirectory(parent, prefix) {
79
+ const directory = await openDocumentDirectoryPath(parent, { create:true });
80
+ try {
81
+ const childAnchor = directory.descriptorBound === false
82
+ ? directory.anchor
83
+ : directory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`);
84
+ const directoryPath = await mkdtemp(path.join(childAnchor, prefix));
85
+ return { path:directoryPath, close:directory.close };
86
+ } catch (error) {
87
+ await directory.close().catch(() => {});
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ export async function readBounded(handle, maximum, label) {
93
+ const chunks = [];
94
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
95
+ let offset = 0;
96
+ for (;;) {
97
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
98
+ if (!bytesRead) break;
99
+ if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maximum) {
100
+ throw new Error(`${label} exceeds the ${maximum}-byte safety limit`);
101
+ }
102
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
103
+ offset += bytesRead;
104
+ }
105
+ return Buffer.concat(chunks, offset);
106
+ }
107
+
108
+ export async function readDocumentFile(filePath) {
109
+ const opened = await openDocumentSource(filePath);
110
+ try {
111
+ const info = await opened.handle.stat();
112
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
113
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
114
+ return await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
115
+ } finally {
116
+ await opened.close();
117
+ }
118
+ }
119
+
120
+ export async function copyRegularNoFollow(source, destination) {
121
+ const opened = await openDocumentSource(source);
122
+ try {
123
+ const info = await opened.handle.stat();
124
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
125
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
126
+ const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
127
+ try {
128
+ const data = await readBounded(opened.handle, MAX_DOCUMENT_BYTES, 'Document');
129
+ await destinationHandle.writeFile(data);
130
+ } finally {
131
+ await destinationHandle.close();
132
+ }
133
+ } finally {
134
+ await opened.close();
135
+ }
136
+ }
137
+
138
+ export async function snapshotDocumentFile(filePath) {
139
+ const dir = await tempDir('remcp-pdf-input-');
140
+ const snapshot = path.join(dir, `input-${randomUUID()}.pdf`);
141
+ try {
142
+ await copyRegularNoFollow(filePath, snapshot);
143
+ return { dir, path: snapshot };
144
+ } catch (error) {
145
+ await removeTemp(dir);
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ export async function assertSafeExtractedTree(root, state = { entries: 0 }) {
151
+ const info = await lstat(root);
152
+ if (info.isSymbolicLink()) throw new Error('OOXML archive contains a symbolic link');
153
+ if (!info.isDirectory() && !info.isFile()) throw new Error('OOXML archive contains a special file');
154
+ state.entries += 1;
155
+ if (state.entries > MAX_OOXML_ENTRIES) throw new Error('OOXML archive has too many extracted entries');
156
+ if (!info.isDirectory()) return;
157
+ for (const entry of await readdir(root, { withFileTypes: true })) {
158
+ await assertSafeExtractedTree(path.join(root, entry.name), state);
159
+ }
160
+ }
161
+
162
+ export function safePdfPattern(value, fallback) {
163
+ const pattern = String(value || fallback);
164
+ if (!pattern || pattern.length > 240 || pattern.includes('\0') || path.isAbsolute(pattern) || /[\\/]/.test(pattern) || pattern.split(/[\\/]/).includes('..')) {
165
+ throw new Error('PDF split pattern must be a filename without path separators');
166
+ }
167
+ return pattern;
168
+ }
169
+
170
+ export function safeArchiveEntry(name) {
171
+ const normalized = String(name || '').replace(/\\/g, '/');
172
+ if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return false;
173
+ return !normalized.split('/').includes('..');
174
+ }
175
+
176
+ export async function validateZipArchive(source) {
177
+ if (process.platform === 'win32') {
178
+ const script = `Add-Type -AssemblyName System.IO.Compression.FileSystem;$z=[IO.Compression.ZipFile]::OpenRead('${escapePowerShellSingle(source)}');try{$count=$z.Entries.Count;$total=0;foreach($e in $z.Entries){$n=$e.FullName.Replace('\\','/');if([IO.Path]::IsPathRooted($n) -or $n.StartsWith('/') -or ($n.Split('/') -contains '..')){throw ('unsafe archive path: '+$n)};$total += $e.Length};if($count -gt ${MAX_OOXML_ENTRIES}){throw 'too many archive entries'};if($total -gt ${MAX_OOXML_UNCOMPRESSED_BYTES}){throw 'archive expands beyond the OOXML limit'};[pscustomobject]@{count=$count;total=$total}|ConvertTo-Json -Compress}finally{$z.Dispose()}`;
179
+ await runPowerShell(script, { label:'validate OOXML archive', timeout:30_000 });
180
+ return;
181
+ }
182
+ if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
183
+ const names = await runFile('unzip', ['-Z','-1',source], { label:'inspect OOXML archive', timeout:30_000, maxBuffer:16 * 1024 * 1024 });
184
+ const entries = names.stdout.split(/\r?\n/).filter(Boolean);
185
+ if (entries.length > MAX_OOXML_ENTRIES) throw new Error(`OOXML archive has too many entries (${entries.length}; max ${MAX_OOXML_ENTRIES})`);
186
+ const unsafe = entries.find(name => !safeArchiveEntry(name));
187
+ if (unsafe) throw new Error(`OOXML archive contains an unsafe path: ${unsafe}`);
188
+ const details = await runFile('unzip', ['-Z','-v',source], { label:'inspect OOXML entry types', timeout:30_000, maxBuffer:16 * 1024 * 1024 });
189
+ if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(details.stdout)) throw new Error('OOXML archive contains a symbolic link');
190
+ const totals = await runFile('unzip', ['-Z','-t',source], { label:'inspect OOXML archive size', timeout:30_000 });
191
+ const uncompressed = Number(totals.stdout.match(/(?:^|,\s)([0-9]+) bytes uncompressed/)?.[1] || 0);
192
+ if (uncompressed > MAX_OOXML_UNCOMPRESSED_BYTES) throw new Error(`OOXML archive expands to ${uncompressed} bytes; max ${MAX_OOXML_UNCOMPRESSED_BYTES}`);
193
+ }
194
+
195
+ export async function extractZip(source, destination) {
196
+ const sourceDirectory = await tempDir('remcp-ooxml-source-');
197
+ const sourceCopy = path.join(sourceDirectory, 'source.zip');
198
+ try {
199
+ await copyRegularNoFollow(source, sourceCopy);
200
+ await validateZipArchive(sourceCopy);
201
+ await mkdir(destination, { recursive:true });
202
+ if (process.platform === 'win32') {
203
+ await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(sourceCopy)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
204
+ } else {
205
+ if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
206
+ await runFile('unzip', ['-qq',sourceCopy,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
207
+ }
208
+ await assertSafeExtractedTree(destination);
209
+ } finally {
210
+ await removeTemp(sourceDirectory);
211
+ }
212
+ }
213
+
214
+ export async function createZip(sourceDir, destination) {
215
+ await rm(destination, { force:true }).catch(() => {});
216
+ if (process.platform === 'win32') {
217
+ await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::CreateFromDirectory('${escapePowerShellSingle(sourceDir)}','${escapePowerShellSingle(destination)}',[IO.Compression.CompressionLevel]::Optimal,$false)`, { label:'create OOXML archive', timeout:30_000 });
218
+ return;
219
+ }
220
+ if (!commandExists('zip')) unavailable('OOXML document editing', 'zip is required');
221
+ await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
222
+ }
223
+
224
+ export async function commitDocumentOutput(staged, destination, { noReplace = false } = {}) {
225
+ const parentDirectory = await openDocumentDirectoryPath(path.dirname(destination));
226
+ const parentAnchor = parentDirectory.anchor;
227
+ const destinationPath = path.join(parentAnchor, path.basename(destination));
228
+ try {
229
+ if (noReplace) {
230
+ await link(staged, destinationPath);
231
+ await unlink(staged);
232
+ return;
233
+ }
234
+ try {
235
+ await rename(staged, destinationPath);
236
+ return;
237
+ } catch (error) {
238
+ if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
239
+ }
240
+ const backup = path.join(parentAnchor, `.${path.basename(destination)}.remcp-old-${randomUUID()}`);
241
+ let moved = false;
242
+ try {
243
+ await rename(destinationPath, backup);
244
+ moved = true;
245
+ await rename(staged, destinationPath);
246
+ await rm(backup, { force: true }).catch(() => {});
247
+ } catch (error) {
248
+ if (moved && !await lstat(destinationPath).catch(() => null)) await rename(backup, destinationPath).catch(() => {});
249
+ throw error;
250
+ }
251
+ } finally {
252
+ await parentDirectory.close().catch(() => {});
253
+ }
254
+ }
255
+
256
+ export async function ensureCreateTargetIsNew(filePath) {
257
+ try {
258
+ await stat(filePath);
259
+ } catch (error) {
260
+ if (error?.code === 'ENOENT') return;
261
+ throw error;
262
+ }
263
+ throw new Error(`Cannot create document because path already exists: ${filePath}`);
264
+ }
265
+
266
+ export async function writeOoxmlPart(root, relative, content) {
267
+ const target = path.join(root, ...relative.split('/'));
268
+ await mkdir(path.dirname(target), { recursive:true });
269
+ await writeFile(target, content, 'utf8');
270
+ }
@@ -0,0 +1,163 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { copyFile, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
6
+ import { readDocxXmlText, readPdfText } from '../documents.mjs';
7
+ import { openDirectoryPath } from '../tools/files.mjs';
8
+ import { resolveSafePath, text } from '../util.mjs';
9
+ import {
10
+ clamp,
11
+ commandExists,
12
+ escapePowerShellSingle,
13
+ jsonResult,
14
+ optionalString,
15
+ removeTemp,
16
+ requireEnum,
17
+ runFile,
18
+ runPowerShell,
19
+ tempDir,
20
+ unavailable,
21
+ } from './common.mjs';
22
+
23
+ import { xmlEscape, xmlUnescape, descriptorAnchorsAvailable, openDocumentSource, openDocumentDirectoryPath, createAnchoredTempDirectory, readBounded, readDocumentFile, copyRegularNoFollow, snapshotDocumentFile, assertSafeExtractedTree, safePdfPattern, safeArchiveEntry, validateZipArchive, extractZip, createZip, commitDocumentOutput, ensureCreateTargetIsNew, writeOoxmlPart } from './documents-filesystem.mjs';
24
+
25
+ export function newWorksheetName(value) {
26
+ const name = optionalString(value) || 'Sheet1';
27
+ if (name.length > 31) throw new Error('Worksheet name must be 31 characters or fewer');
28
+ if (/[\\/*?:\[\]]/.test(name) || name.startsWith("'") || name.endsWith("'")) {
29
+ throw new Error('Worksheet name contains characters Excel does not allow');
30
+ }
31
+ return name;
32
+ }
33
+
34
+ export async function createBlankXlsxTree(root, sheetName) {
35
+ const name = newWorksheetName(sheetName);
36
+ await writeOoxmlPart(root, '[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>');
37
+ await writeOoxmlPart(root, '_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>');
38
+ await writeOoxmlPart(root, 'xl/workbook.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${xmlEscape(name)}" sheetId="1" r:id="rId1"/></sheets></workbook>`);
39
+ await writeOoxmlPart(root, 'xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>');
40
+ await writeOoxmlPart(root, 'xl/worksheets/sheet1.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData></sheetData></worksheet>');
41
+ return name;
42
+ }
43
+
44
+ export async function createBlankDocxTree(root) {
45
+ await writeOoxmlPart(root, '[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
46
+ await writeOoxmlPart(root, '_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
47
+ await writeOoxmlPart(root, 'word/document.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>');
48
+ }
49
+
50
+ export function workbookSheetPath(workbookXml, relsXml, requestedName) {
51
+ const sheets = [...workbookXml.matchAll(/<sheet\b[^>]*name="([^"]+)"[^>]*(?:r:id|id)="([^"]+)"[^>]*\/?>(?:<\/sheet>)?/g)].map(match => ({ name:xmlUnescape(match[1]), rid:match[2] }));
52
+ const sheet = requestedName ? sheets.find(item => item.name.toLowerCase() === requestedName.toLowerCase()) : sheets[0];
53
+ if (!sheet) throw new Error(requestedName ? `Worksheet not found: ${requestedName}` : 'Workbook contains no worksheets');
54
+ const relationships = [...relsXml.matchAll(/<Relationship\b[^>]*Id="([^"]+)"[^>]*Target="([^"]+)"[^>]*\/?>/g)].map(match => ({ id:match[1], target:match[2] }));
55
+ const rel = relationships.find(item => item.id === sheet.rid);
56
+ if (!rel) throw new Error(`Relationship for worksheet ${sheet.name} was not found`);
57
+ const target = rel.target.replace(/^\//, '').replace(/\\/g, '/');
58
+ if (!safeArchiveEntry(target)) throw new Error(`Worksheet relationship contains an unsafe path: ${target}`);
59
+ const relative = path.posix.normalize(target.startsWith('xl/') ? target : path.posix.join('xl', target));
60
+ if (!relative.startsWith('xl/') || relative.split('/').includes('..')) throw new Error(`Worksheet relationship escapes the workbook: ${target}`);
61
+ return { name:sheet.name, relative };
62
+ }
63
+
64
+ export function readSharedStrings(xml) {
65
+ if (!xml) return [];
66
+ return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map(match => [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(textMatch => xmlUnescape(textMatch[1])).join(''));
67
+ }
68
+
69
+ export function readWorksheetCells(xml, sharedStrings, maxCells) {
70
+ const cells = [];
71
+ const pattern = /<c\b([^>]*)\br="([A-Z]+\d+)"([^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*)\br="([A-Z]+\d+)"([^>]*)\/>/g;
72
+ for (const match of xml.matchAll(pattern)) {
73
+ if (cells.length >= maxCells) break;
74
+ const attrs = `${match[1] || match[5] || ''}${match[3] || match[7] || ''}`;
75
+ const ref = match[2] || match[6];
76
+ const body = match[4] || '';
77
+ const type = attrs.match(/\bt="([^"]+)"/)?.[1] || '';
78
+ const formula = body.match(/<f\b[^>]*>([\s\S]*?)<\/f>/)?.[1];
79
+ const raw = body.match(/<v\b[^>]*>([\s\S]*?)<\/v>/)?.[1] ?? '';
80
+ let value;
81
+ if (type === 's') value = sharedStrings[Number(raw)] ?? raw;
82
+ else if (type === 'inlineStr') value = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(item => xmlUnescape(item[1])).join('');
83
+ else if (type === 'b') value = raw === '1';
84
+ else if (raw !== '' && Number.isFinite(Number(raw))) value = Number(raw);
85
+ else value = xmlUnescape(raw);
86
+ cells.push({ ref, value, ...(formula ? { formula:xmlUnescape(formula) } : {}) });
87
+ }
88
+ return cells;
89
+ }
90
+
91
+ export async function readXlsx(filePath, args) {
92
+ const dir = await tempDir('remcp-xlsx-read-');
93
+ try {
94
+ // extractZip already snapshots the user-approved source into a private temp file
95
+ // before invoking unzip. A second snapshot here used to re-open ReMCP's own temp
96
+ // path through allowedRoots, so read_document failed whenever confinement was on.
97
+ await extractZip(filePath, dir);
98
+ const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
99
+ const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
100
+ const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
101
+ const worksheet = await readFile(path.join(dir,...sheet.relative.split('/')),'utf8');
102
+ let shared = [];
103
+ try { shared = readSharedStrings(await readFile(path.join(dir,'xl','sharedStrings.xml'),'utf8')); } catch {}
104
+ const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
105
+ return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
106
+ } finally {
107
+ await removeTemp(dir);
108
+ }
109
+ }
110
+
111
+ export async function readDocxDocument(filePath) {
112
+ const dir = await tempDir('remcp-docx-read-');
113
+ try {
114
+ await extractZip(filePath, dir);
115
+ let xml;
116
+ try {
117
+ xml = await readFile(path.join(dir, 'word', 'document.xml'), 'utf8');
118
+ } catch (error) {
119
+ if (error?.code === 'ENOENT') {
120
+ throw new Error('This file is not a readable .docx (word/document.xml is missing)');
121
+ }
122
+ throw error;
123
+ }
124
+ return readDocxXmlText(xml);
125
+ } finally {
126
+ await removeTemp(dir);
127
+ }
128
+ }
129
+
130
+ export async function readPdfDocument(data) {
131
+ try {
132
+ return readPdfText(data);
133
+ } catch (builtinError) {
134
+ if (commandExists('pdftotext')) {
135
+ const dir = await tempDir('remcp-pdf-read-');
136
+ try {
137
+ const snapshot = path.join(dir, 'input.pdf');
138
+ await writeFile(snapshot, data, { mode: 0o600 });
139
+ const extracted = await runFile('pdftotext', ['-enc','UTF-8',snapshot,'-'], {
140
+ label:'PDF text extraction',
141
+ timeout:60_000,
142
+ allowFailure:true,
143
+ });
144
+ const fallbackText = String(extracted.stdout || '').replace(/\f/g, '\n').trim();
145
+ if (extracted.code === 0 && fallbackText) return fallbackText;
146
+ } finally {
147
+ await removeTemp(dir);
148
+ }
149
+ }
150
+ throw builtinError;
151
+ }
152
+ }
153
+
154
+ export async function readDocument(args) {
155
+ const filePath = await resolveSafePath(args.path,'path');
156
+ const lower = filePath.toLowerCase();
157
+ if (lower.endsWith('.docx')) return text(await readDocxDocument(filePath));
158
+ if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
159
+ const data = await readDocumentFile(filePath);
160
+ if (lower.endsWith('.pdf')) return text(await readPdfDocument(data));
161
+ if (/\.(txt|md|csv|json|xml|yaml|yml)$/i.test(lower)) return text(data.toString('utf8'));
162
+ throw new Error('read_document supports PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, XML, YAML');
163
+ }