diff --git a/README.md b/README.md index 12776b3..7218675 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Ask the agent to call `read_rich_document` with a document path inside the curre The tool returns structure-preserving Markdown for headings, lists, tables, links, sections, slide context, notes, and supported document content. It also returns a structured media index in tool metadata. -It appends an ordered embedded-media index containing each attachment's source name, MIME type, document section, and isolated temporary path. +For calls without `export`, it appends an ordered embedded-media index containing each attachment's source name, MIME type, document section, and isolated temporary path. Use a media label from the index when vision inspection is relevant. The selected image is returned as a native OpenCode file attachment, while its isolated temporary path remains available for follow-up tools. @@ -38,13 +38,33 @@ To attach selected image media directly to the next model turn, call the tool ag Selectable image media supports `image/jpeg`, `image/png`, `image/gif`, `image/bmp`, `image/tiff`, `image/svg+xml`, and `image/webp` attachments. +### Durable export (opt-in) + +Calls without `export` keep the existing ephemeral behavior. To explicitly save a durable copy beside the source document, pass an empty export request: + +```json +{"path":"docs/architecture.docx","export":{}} +``` + +For `docs/architecture.docx`, the default sibling export directory is `docs/architecture.export/`. It contains `architecture.md`, a `media/` directory containing every extracted attachment, and `manifest.json`. The exported Markdown preserves the document structure and uses relative paths such as `media/media-1.png` in its media index. + +Callers may choose another durable directory with a project-relative destination: + +```json +{"path":"docs/architecture.docx","export":{"destination":"artifacts/architecture"}} +``` + +The destination is an output directory relative to the current project. Missing parent directories are created, but an existing destination is rejected and never overwritten; choose a new destination or remove the old export explicitly before retrying. Absolute destinations and paths that escape the project, including escaping symlinks, are rejected. + +The result metadata includes `metadata.export.directoryPath`, `markdownPath`, `mediaDirectoryPath`, `manifestPath`, and durable paths for each exported media item. The manifest is JSON and repeats those paths and the source-relative path so later turns can use the saved artifacts without depending on the temporary extraction directory. Export is never automatic, and the source document is not changed. + ## Safety and boundaries The source document is read without modification. Paths must resolve inside the current project, and symlinks that escape it are rejected. -Extracted files are written beneath a unique system temporary directory rather than beside the source document. +Without an explicit `export` request, extracted files are written beneath a unique system temporary directory rather than beside the source document, and the existing temporary media index is returned. Successful reads keep that directory available for the current agent workflow; failed reads clean up partial extraction. An explicit export additionally writes the durable directory described above and does not change the non-export contract. The parser applies bounded archive, entry-count, and table-cell limits. @@ -62,7 +82,7 @@ npm run check npm test ``` -The test suite creates minimal DOCX, ODT, and PPTX fixtures, verifies structure and media extraction, and exercises path and malformed-document errors. +The test suite creates minimal DOCX, ODT, and PPTX fixtures, verifies structure and media extraction, exercises durable export contents and path/collision behavior, and covers malformed-document errors. ## License diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 0000000..e6f44ae --- /dev/null +++ b/src/export.ts @@ -0,0 +1,130 @@ +import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, extname, join } from "node:path"; +import type { + DurableExportMedia, + DurableExportMetadata, + MediaRecord, + ResolvedExportDestination, +} from "./types.ts"; + +export class DurableExportError extends Error { + readonly code: "EXPORT_EXISTS" | "EXPORT_FAILED"; + + constructor(code: DurableExportError["code"], message: string, options?: ErrorOptions) { + super(message, options); + this.name = "DurableExportError"; + this.code = code; + } +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +function fileStem(sourcePath: string): string { + const stem = basename(sourcePath, extname(sourcePath)); + return stem || basename(sourcePath); +} + +export interface DurableExportOptions { + destination: ResolvedExportDestination; + sourcePath: string; + format: string; + markdown: string; + records: readonly MediaRecord[]; + abortSignal: AbortSignal; +} + +export async function writeDurableExport({ + destination, + sourcePath, + format, + markdown, + records, + abortSignal, +}: DurableExportOptions): Promise { + const directoryPath = destination.absolutePath; + const mediaDirectoryPath = join(directoryPath, "media"); + const markdownPath = join(directoryPath, `${fileStem(sourcePath)}.md`); + const manifestPath = join(directoryPath, "manifest.json"); + let createdDirectory = false; + + try { + abortSignal.throwIfAborted(); + await mkdir(dirname(directoryPath), { recursive: true, mode: 0o700 }); + await mkdir(directoryPath, { recursive: false, mode: 0o700 }); + createdDirectory = true; + await mkdir(mediaDirectoryPath, { mode: 0o700 }); + + const media: DurableExportMedia[] = []; + for (const { entry } of records) { + abortSignal.throwIfAborted(); + const fileName = basename(entry.temporaryPath); + const path = join(mediaDirectoryPath, fileName); + await copyFile(entry.temporaryPath, path); + media.push({ + label: entry.label, + type: entry.type, + originalName: entry.originalName, + mimeType: entry.mimeType, + location: entry.location, + path, + relativePath: `media/${fileName}`, + }); + } + + abortSignal.throwIfAborted(); + await writeFile(markdownPath, markdown, { encoding: "utf8", mode: 0o600 }); + const metadata: DurableExportMetadata = { + directoryPath, + markdownPath, + mediaDirectoryPath, + manifestPath, + media, + }; + await writeFile( + manifestPath, + `${JSON.stringify( + { + version: 1, + sourcePath, + format, + exportDirectory: directoryPath, + exportDirectoryRelativePath: destination.relativePath, + markdownPath, + markdownRelativePath: basename(markdownPath), + mediaDirectoryPath, + mediaDirectoryRelativePath: "media", + manifestPath, + manifestRelativePath: "manifest.json", + media, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + abortSignal.throwIfAborted(); + return metadata; + } catch (error) { + if (createdDirectory) { + await rm(directoryPath, { recursive: true, force: true }).catch(() => undefined); + } + if (abortSignal.aborted) abortSignal.throwIfAborted(); + if (error instanceof DurableExportError) throw error; + const code = error && typeof error === "object" && "code" in error ? error.code : undefined; + if (code === "EEXIST") { + throw new DurableExportError( + "EXPORT_EXISTS", + `Export destination already exists and was not overwritten: ${directoryPath}`, + { cause: error }, + ); + } + throw new DurableExportError( + "EXPORT_FAILED", + `Could not write durable export to ${directoryPath}: ${errorMessage(error)}`, + { cause: error }, + ); + } +} diff --git a/src/index.ts b/src/index.ts index 799afb1..36f098a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,13 +5,24 @@ export const RichDocumentReaderPlugin: Plugin = async () => ({ tool: { read_rich_document: tool({ description: - "Read a DOCX, ODT, or PPTX file as structure-preserving Markdown and list its extracted media. Select image labels only when vision inspection is needed.", + "Read a DOCX, ODT, or PPTX file as structure-preserving Markdown and list its extracted media. Select image labels only when vision inspection is needed. Pass export: {} to save a durable sibling export, or export.destination for another project-relative directory.", args: { path: tool.schema.string().describe("Path to a DOCX, ODT, or PPTX inside the current project."), media: tool.schema .array(tool.schema.string()) .optional() .describe("Optional media labels from the embedded media index to attach as images."), + export: tool.schema + .object({ + destination: tool.schema + .string() + .optional() + .describe("Optional project-relative destination directory; omit for a sibling .export directory."), + }) + .optional() + .describe( + "Explicitly persist the Markdown, all extracted media, and a manifest. Omit to keep the current temporary behavior.", + ), }, async execute(args, context) { return readRichDocument(args, context); diff --git a/src/media.ts b/src/media.ts index e96199f..17769b0 100644 --- a/src/media.ts +++ b/src/media.ts @@ -3,6 +3,11 @@ import { basename, extname, join } from "node:path"; import type { OfficeAttachment, OfficeContentNode, OfficeParserAST } from "officeparser"; import type { MediaIndexEntry, MediaRecord } from "./types.ts"; +export interface MediaTableOptions { + pathFor?: (entry: MediaIndexEntry) => string; + pathHeading?: string; +} + interface LocationContext { heading?: string; role?: string; @@ -169,16 +174,18 @@ export async function writeMedia( return records; } -export function mediaTable(records: MediaRecord[]): string { +export function mediaTable(records: MediaRecord[], options: MediaTableOptions = {}): string { const cell = (value: string) => value.replace(/\|/g, "\\|").replace(/[\r\n]+/g, " "); + const pathHeading = options.pathHeading ?? "Temporary path"; + const pathFor = options.pathFor ?? ((entry: MediaIndexEntry) => entry.temporaryPath); const rows = records.map(({ entry }) => - `| \`${cell(entry.label)}\` | ${cell(entry.type)} | ${cell(entry.originalName)} | ${cell(entry.mimeType)} | ${cell(entry.temporaryPath)} | ${cell(entry.location)} |`, + `| \`${cell(entry.label)}\` | ${cell(entry.type)} | ${cell(entry.originalName)} | ${cell(entry.mimeType)} | ${cell(pathFor(entry))} | ${cell(entry.location)} |`, ); return [ "## Embedded media", "", - "| Label | Type | Original attachment | MIME type | Temporary path | Location |", + `| Label | Type | Original attachment | MIME type | ${pathHeading} | Location |`, "| --- | --- | --- | --- | --- | --- |", ...(rows.length ? rows : ["| _none_ | | | | | |"]), ].join("\n"); diff --git a/src/path-safety.ts b/src/path-safety.ts index 58b7355..1c54649 100644 --- a/src/path-safety.ts +++ b/src/path-safety.ts @@ -1,7 +1,7 @@ import { access, lstat, realpath, stat } from "node:fs/promises"; import { constants } from "node:fs"; -import { extname, isAbsolute, relative, resolve, sep } from "node:path"; -import type { ProjectPaths, ResolvedDocument } from "./types.ts"; +import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path"; +import type { ProjectPaths, ResolvedDocument, ResolvedExportDestination } from "./types.ts"; export class DocumentPathError extends Error { readonly code: @@ -20,6 +20,20 @@ export class DocumentPathError extends Error { } } +export class ExportPathError extends Error { + readonly code: + | "INVALID_EXPORT_DESTINATION" + | "EXPORT_PATH_ESCAPE" + | "EXPORT_SYMLINK_ESCAPE" + | "EXPORT_DESTINATION_UNREADABLE"; + + constructor(code: ExportPathError["code"], message: string) { + super(message); + this.name = "ExportPathError"; + this.code = code; + } +} + function isWithin(root: string, candidate: string): boolean { const distance = relative(root, candidate); return distance === "" || (distance !== ".." && !distance.startsWith(`..${sep}`) && !isAbsolute(distance)); @@ -33,6 +47,81 @@ async function canonicalProjectRoot(root: string): Promise { } } +function exportStem(sourcePath: string): string { + const extension = extname(sourcePath); + const stem = basename(sourcePath, extension); + return stem || basename(sourcePath); +} + +function pathErrorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error ? String(error.code) : undefined; +} + +async function assertExportPathInsideProject(projectRoot: string, candidate: string): Promise { + let current = candidate; + while (true) { + try { + const canonical = await realpath(current); + if (!isWithin(projectRoot, canonical)) { + throw new ExportPathError( + "EXPORT_SYMLINK_ESCAPE", + `Export destination symlink escapes the current project: ${candidate}`, + ); + } + return; + } catch (error) { + if (error instanceof ExportPathError) throw error; + if (pathErrorCode(error) !== "ENOENT" && pathErrorCode(error) !== "ENOTDIR") { + throw new ExportPathError( + "EXPORT_DESTINATION_UNREADABLE", + `Export destination cannot be inspected: ${candidate}`, + ); + } + const parent = dirname(current); + if (parent === current) { + throw new ExportPathError( + "EXPORT_DESTINATION_UNREADABLE", + `Export destination cannot be inspected: ${candidate}`, + ); + } + current = parent; + } + } +} + +export async function resolveExportDestination( + destination: string | undefined, + rootInput: string, + sourcePath: string, +): Promise { + if (destination !== undefined && (!destination.trim() || destination.includes("\0"))) { + throw new ExportPathError("INVALID_EXPORT_DESTINATION", "Export destination must be a non-empty path."); + } + + const projectRoot = await canonicalProjectRoot(rootInput); + const candidate = + destination === undefined + ? join(dirname(sourcePath), `${exportStem(sourcePath)}.export`) + : resolve(projectRoot, destination); + + if (destination !== undefined && (isAbsolute(destination) || win32.isAbsolute(destination))) { + throw new ExportPathError( + "EXPORT_PATH_ESCAPE", + `Export destination must be project-relative, not absolute: ${destination}`, + ); + } + if (!isWithin(projectRoot, candidate)) { + throw new ExportPathError( + "EXPORT_PATH_ESCAPE", + `Export destination escapes the current project: ${destination ?? candidate}`, + ); + } + + await assertExportPathInsideProject(projectRoot, candidate); + const relativePath = relative(projectRoot, candidate).split(sep).join("/") || "."; + return { projectRoot, absolutePath: candidate, relativePath }; +} + export async function resolveDocumentPath(documentInput: string, rootInput: string): Promise { if (!documentInput.trim() || documentInput.includes("\0")) { throw new DocumentPathError("INVALID_PATH", "A non-empty document path is required."); diff --git a/src/reader.ts b/src/reader.ts index cde33bd..f8a0417 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -1,21 +1,24 @@ import { constants } from "node:fs"; import { mkdtemp, open, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { basename, join, relative } from "node:path"; import type { ToolAttachment } from "@opencode-ai/plugin"; import type { OfficeContentNode, OfficeParserAST } from "officeparser"; import { formatForExtension, formatRegistry } from "./registry.ts"; import { READER_LIMITS, parserLimits } from "./limits.ts"; import { MediaSelectionError, + type MediaTableOptions, mediaTable, selectedAttachments, selectMedia, sectionBoundariesFor, writeMedia, } from "./media.ts"; -import { DocumentPathError, projectPaths, resolveDocumentPath } from "./path-safety.ts"; +import { DurableExportError, writeDurableExport } from "./export.ts"; +import { DocumentPathError, projectPaths, resolveDocumentPath, resolveExportDestination } from "./path-safety.ts"; import type { + DurableExportMetadata, MediaIndexEntry, MediaRecord, ReadRichDocumentArgs, @@ -70,9 +73,14 @@ function stripInlineMediaData(markdown: string): string { ); } -function completeMarkdown(markdown: string, records: MediaRecord[], issues: DocumentIssue[]): string { +function completeMarkdown( + markdown: string, + records: MediaRecord[], + issues: DocumentIssue[], + mediaOptions?: MediaTableOptions, +): string { const body = stripInlineMediaData(markdown.trim()) || "_No readable text content was found._"; - return [body, mediaTable(records), warningSection(issues)].join("\n\n"); + return [body, mediaTable(records, mediaOptions), warningSection(issues)].join("\n\n"); } function errorMessage(error: unknown): string { @@ -382,6 +390,10 @@ export async function readRichDocument( ); } + const exportDestination = args.export + ? await resolveExportDestination(args.export.destination, paths.projectRoot, resolved.absolutePath) + : undefined; + const input = await readValidatedDocument(resolved.absolutePath, args.path, context.abort); const issues: ParseIssue[] = []; @@ -411,15 +423,42 @@ export async function readRichDocument( for (const issue of conversion.messages) issues.push({ issue, source: "conversion" }); const media = attachmentMetadata(extraction.records); + const issuesForMarkdown = uniqueIssues(issues); + const ephemeralMarkdown = completeMarkdown(String(conversion.value), extraction.records, issuesForMarkdown); + let output = ephemeralMarkdown; + let exportMetadata: DurableExportMetadata | undefined; + if (exportDestination) { + const exportedMarkdown = completeMarkdown(String(conversion.value), extraction.records, issuesForMarkdown, { + pathHeading: "Exported path", + pathFor: (entry) => `media/${basename(entry.temporaryPath)}`, + }); + try { + exportMetadata = await writeDurableExport({ + destination: exportDestination, + sourcePath: sourceLabel(args.path, projectRoot, resolved.absolutePath), + format: format.parserType, + markdown: exportedMarkdown, + records: extraction.records, + abortSignal: context.abort, + }); + } catch (error) { + if (error instanceof DurableExportError) { + throw new RichDocumentError(error.code, error.message, { cause: error }); + } + throw error; + } + output = exportedMarkdown; + } const metadata: RichDocumentResultMetadata = { format: format.parserType, sourcePath: sourceLabel(args.path, projectRoot, resolved.absolutePath), media, + ...(exportMetadata ? { export: exportMetadata } : {}), }; const attachments = toolAttachments(selected); const result: RichDocumentToolResult = { title: `Read ${sourceLabel(args.path, projectRoot, resolved.absolutePath)}`, - output: completeMarkdown(String(conversion.value), extraction.records, uniqueIssues(issues)), + output, metadata, }; if (attachments.length) result.attachments = attachments; diff --git a/src/types.ts b/src/types.ts index 30b261a..2147684 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,9 @@ export interface DocumentIssue { export interface ReadRichDocumentArgs { path: string; media?: string[]; + export?: { + destination?: string; + }; } export type ReaderToolContext = Pick; @@ -39,6 +42,7 @@ export interface RichDocumentResultMetadata { format: SupportedFileType; sourcePath: string; media: MediaIndexEntry[]; + export?: DurableExportMetadata; } export type RichDocumentToolResult = Omit, "metadata"> & { @@ -66,6 +70,30 @@ export interface ProjectPaths { documentPath: string; } +export interface ResolvedExportDestination { + projectRoot: string; + absolutePath: string; + relativePath: string; +} + +export interface DurableExportMedia { + label: string; + type: OfficeAttachment["type"]; + originalName: string; + mimeType: string; + location: string; + path: string; + relativePath: string; +} + +export interface DurableExportMetadata { + directoryPath: string; + markdownPath: string; + mediaDirectoryPath: string; + manifestPath: string; + media: DurableExportMedia[]; +} + export interface ReadRichDocumentDependencies { formats?: ReadonlyMap; tempDirectory?: string; diff --git a/test/read-rich-document.test.ts b/test/read-rich-document.test.ts index 4eff756..37f1dec 100644 --- a/test/read-rich-document.test.ts +++ b/test/read-rich-document.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; -import { readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; import type { OfficeParserAST } from "officeparser"; import { RichDocumentReaderPlugin } from "../src/index.ts"; @@ -61,6 +61,129 @@ describe("read_rich_document", () => { assert.doesNotMatch(result.output, /iVBORw0KGgo/); }); + it("keeps omitted export reads ephemeral", async () => { + const result = await readRichDocument({ path: "slides.pptx" }, context()); + rememberExtraction(result); + + assert.equal(result.metadata?.export, undefined); + assert.match(result.output, /Temporary path/); + assert.ok(result.metadata?.media[0].temporaryPath); + await assert.rejects(() => stat(join(fixtures.root, "slides.export")), { code: "ENOENT" }); + }); + + it("writes a default sibling export with Markdown, all media, and a manifest", async () => { + const beforeBytes = await readFile(fixtures.docx); + const beforeStat = await stat(fixtures.docx); + const result = await readRichDocument({ path: "structure.docx", export: {} }, context()); + rememberExtraction(result); + + const exported = result.metadata?.export; + assert.ok(exported); + assert.ok(result.metadata); + const canonicalRoot = await realpath(fixtures.root); + assert.equal(exported.directoryPath, join(canonicalRoot, "structure.export")); + assert.equal(exported.markdownPath, join(exported.directoryPath, "structure.md")); + assert.equal(exported.mediaDirectoryPath, join(exported.directoryPath, "media")); + assert.equal(exported.manifestPath, join(exported.directoryPath, "manifest.json")); + assert.equal(exported.media.length, 1); + assert.equal((await stat(exported.mediaDirectoryPath)).isDirectory(), true); + assert.match(result.output, /# Project Overview/); + assert.match(result.output, /\| Exported path \|/); + assert.match(result.output, /media\/media-1\.png/); + + const exportedMarkdown = await readFile(exported.markdownPath, "utf8"); + assert.equal(exportedMarkdown, result.output); + assert.deepEqual( + await readFile(exported.media[0].path), + await readFile(result.metadata.media[0].temporaryPath), + ); + const manifest = JSON.parse(await readFile(exported.manifestPath, "utf8")); + assert.equal(manifest.version, 1); + assert.equal(manifest.sourcePath, "structure.docx"); + assert.equal(manifest.markdownPath, exported.markdownPath); + assert.equal(manifest.mediaDirectoryPath, exported.mediaDirectoryPath); + assert.deepEqual(manifest.media, exported.media); + assert.doesNotMatch(JSON.stringify(manifest), /opencode-rich-document-.*media-1/); + + await rm(dirname(result.metadata.media[0].temporaryPath), { recursive: true, force: true }); + assert.equal((await stat(exported.media[0].path)).isFile(), true); + + assert.deepEqual(await readFile(fixtures.docx), beforeBytes); + assert.equal((await stat(fixtures.docx)).mtimeMs, beforeStat.mtimeMs); + }); + + it("writes a custom project-relative export and creates missing parent directories", async () => { + const result = await readRichDocument( + { path: "slides.pptx", export: { destination: "artifacts/slides" } }, + context(), + ); + rememberExtraction(result); + + const exported = result.metadata?.export; + assert.ok(exported); + assert.ok(result.metadata); + const canonicalRoot = await realpath(fixtures.root); + assert.equal(exported.directoryPath, join(canonicalRoot, "artifacts", "slides")); + assert.equal(exported.media.length, result.metadata.media.length); + const manifest = JSON.parse(await readFile(exported.manifestPath, "utf8")); + assert.equal(manifest.exportDirectoryRelativePath, "artifacts/slides"); + for (const media of exported.media) { + assert.equal(await stat(media.path).then(() => true), true); + assert.match(media.relativePath, /^media\/media-\d+\.[a-z0-9]+$/); + } + }); + + it("rejects absolute and project-escaping export destinations", async () => { + await assert.rejects( + () => readRichDocument({ path: "structure.docx", export: { destination: "../outside-export" } }, context()), + /export destination escapes the current project/i, + ); + await assert.rejects( + () => + readRichDocument( + { path: "structure.docx", export: { destination: join(fixtures.root, "absolute-export") } }, + context(), + ), + /must be project-relative, not absolute/i, + ); + }); + + it("rejects export destinations whose symlinked parent escapes the project", async () => { + const outside = join(dirname(fixtures.root), `${basename(fixtures.root)}-export-outside`); + const link = join(fixtures.root, "export-link"); + await mkdir(outside); + await symlink(outside, link); + + await assert.rejects( + () => + readRichDocument( + { path: "structure.docx", export: { destination: "export-link/nested" } }, + context(), + ), + /export destination symlink escapes the current project/i, + ); + + await rm(link, { force: true }); + await rm(outside, { recursive: true, force: true }); + }); + + it("rejects an existing export destination without overwriting it", async () => { + const destination = join(fixtures.root, "artifacts", "collision"); + const marker = join(destination, "keep.txt"); + await mkdir(destination, { recursive: true }); + await writeFile(marker, "keep this file"); + + await assert.rejects( + () => + readRichDocument( + { path: "structure.docx", export: { destination: "artifacts/collision" } }, + context(), + ), + /already exists and was not overwritten/i, + ); + assert.equal(await readFile(marker, "utf8"), "keep this file"); + }); + it("associates media after a DOCX section boundary with the physical section", async () => { const ast = { config: {},