Skip to content

feat(reader): add opt-in durable document export - #2

Merged
ddbaron merged 2 commits into
mainfrom
fm/opencode-reader-export-v4
Aug 15, 2026
Merged

feat(reader): add opt-in durable document export#2
ddbaron merged 2 commits into
mainfrom
fm/opencode-reader-export-v4

Conversation

@ddbaron

@ddbaron ddbaron commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Intent

Add an explicit, durable export/save capability to the public OpenCode rich-document reader. Extend the existing read_rich_document tool with an explicit opt-in export request. When export is omitted, preserve the current ephemeral behavior exactly, including the existing temporary media/index behavior. When export is requested without a destination, write the structure-preserving Markdown and media directory beside the source document by default; the accepted design is an optional export destination with sibling output as the default. Support an optional project-relative destination for callers who want another durable location, while rejecting absolute destinations and any path that escapes the current project, including escaping symlinks. The durable export must contain the structure-preserving Markdown, all extracted media, and a machine-readable manifest or equivalent returned paths so later turns can refer to saved artifacts without relying on the temporary directory. Preserve the source document unchanged. Choose a clear, backward-compatible argument shape for the explicit export request and document it with examples. Define and test collision/overwrite behavior; this implementation rejects an existing export destination and never overwrites it. Add behavioral tests for default sibling export, custom project-relative export, path-boundary rejection, Markdown/media/manifest contents, collision behavior, and the unchanged ephemeral default. Update the README safety and usage sections with the exact durable-export behavior and lifecycle. Do not broaden this feature to arbitrary absolute paths, automatic persistence, or changes to the existing non-export contract.

What Changed

  • Added an explicit export request to read_rich_document, supporting sibling exports by default or custom project-relative destinations with structure-preserving Markdown, extracted media, and a JSON manifest returned through export metadata.
  • Added project-boundary and symlink escape checks, plus collision protection that rejects existing destinations without overwriting them.
  • Preserved the existing ephemeral behavior when export is omitted, including temporary media paths and unchanged source documents.

Risk Assessment

🚨 High: The required project-boundary guarantee is vulnerable to a symlink change between validation and export creation, allowing durable output outside the project; export mode also needlessly duplicates large Markdown allocation.

Testing

The focused export tests passed after restoring dependencies, and the executable end-to-end tool transcript verified the requested durable files, all media, manifest paths, ephemeral default behavior, path-boundary rejection, collision safety, and unchanged source; no full-suite, lint, or static-analysis commands were run.

Evidence: End-to-end rich-document export transcript
{
  "interface": "RichDocumentReaderPlugin -> read_rich_document.execute",
  "ephemeral": {
    "request": {
      "path": "slides.pptx"
    },
    "title": "Read slides.pptx",
    "exportMetadataPresent": false,
    "output": "---\n---\n\n## Slide 1 {#slide-1}\n\n\n---\n\n# PPTX Slide One {#pptx-slide-one}\n\n- Slide bullet\n\n\n> **Note:** Speaker notes\n\n## Embedded media\n\n| Label | Type | Original attachment | MIME type | Temporary path | Location |\n| --- | --- | --- | --- | --- | --- |\n| `media-1` | image | image1.png | image/png | /var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-rich-document-fuLOoy/media-1.png | Slide 1 - Section: PPTX Slide One |\n| `media-2` | chart | chart1.xml | application/vnd.openxmlformats-officedocument.drawingml.chart+xml | /var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-rich-document-fuLOoy/media-2.xml | Slide 1 - Section: PPTX Slide One |\n\n## Conversion warnings\n\n- None reported.",
    "temporaryMediaPath": "/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-rich-document-fuLOoy/media-1.png",
    "temporaryMediaStillPresent": true,
    "siblingExportPresent": false
  },
  "defaultExport": {
    "request": {
      "path": "structure.docx",
      "export": {}
    },
    "title": "Read structure.docx",
    "output": "---\n---\n\n## Section 1 {#section-1}\n\n# Project Overview {#project-overview}\n\nDOCX introduction.\n\n- First bullet\n\n[Open reference](https://example.com/reference)\n\n\n| Header | Value |\n|  ---  |  ---  |\n| Row | 42 |\n\n\n\nSection boundary.\n\n## Section 2 {#section-2}\n\n# Second Section {#second-section}\n\n## Embedded media\n\n| Label | Type | Original attachment | MIME type | Exported path | Location |\n| --- | --- | --- | --- | --- | --- |\n| `media-1` | image | image1.png | image/png | media/media-1.png | Section 1 - Section: Project Overview |\n\n## Conversion warnings\n\n- None reported.",
    "directoryEntries": [
      "manifest.json",
      "media",
      "structure.md"
    ],
    "metadata": {
      "directoryPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export",
      "markdownPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/structure.md",
      "mediaDirectoryPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/media",
      "manifestPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/manifest.json",
      "media": [
        {
          "label": "media-1",
          "type": "image",
          "originalName": "image1.png",
          "mimeType": "image/png",
          "location": "Section 1 - Section: Project Overview",
          "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/media/media-1.png",
          "relativePath": "media/media-1.png"
        }
      ]
    },
    "manifest": {
      "version": 1,
      "sourcePath": "structure.docx",
      "format": "docx",
      "exportDirectory": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export",
      "exportDirectoryRelativePath": "structure.export",
      "markdownPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/structure.md",
      "markdownRelativePath": "structure.md",
      "mediaDirectoryPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/media",
      "mediaDirectoryRelativePath": "media",
      "manifestPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/manifest.json",
      "manifestRelativePath": "manifest.json",
      "media": [
        {
          "label": "media-1",
          "type": "image",
          "originalName": "image1.png",
          "mimeType": "image/png",
          "location": "Section 1 - Section: Project Overview",
          "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/media/media-1.png",
          "relativePath": "media/media-1.png"
        }
      ]
    },
    "mediaFiles": [
      {
        "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.export/media/media-1.png",
        "bytes": 68,
        "sha256": "431ced6916a2a21a156e38701afe55bbd7f88969fbbfc56d7fe099d47f265460"
      }
    ]
  },
  "customExport": {
    "request": {
      "path": "slides.pptx",
      "export": {
        "destination": "artifacts/slides"
      }
    },
    "metadata": {
      "directoryPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides",
      "markdownPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/slides.md",
      "mediaDirectoryPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/media",
      "manifestPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/manifest.json",
      "media": [
        {
          "label": "media-1",
          "type": "image",
          "originalName": "image1.png",
          "mimeType": "image/png",
          "location": "Slide 1 - Section: PPTX Slide One",
          "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/media/media-1.png",
          "relativePath": "media/media-1.png"
        },
        {
          "label": "media-2",
          "type": "chart",
          "originalName": "chart1.xml",
          "mimeType": "application/vnd.openxmlformats-officedocument.drawingml.chart+xml",
          "location": "Slide 1 - Section: PPTX Slide One",
          "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/media/media-2.xml",
          "relativePath": "media/media-2.xml"
        }
      ]
    },
    "manifest": {
      "version": 1,
      "sourcePath": "slides.pptx",
      "format": "pptx",
      "exportDirectory": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides",
      "exportDirectoryRelativePath": "artifacts/slides",
      "markdownPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/slides.md",
      "markdownRelativePath": "slides.md",
      "mediaDirectoryPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/media",
      "mediaDirectoryRelativePath": "media",
      "manifestPath": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/manifest.json",
      "manifestRelativePath": "manifest.json",
      "media": [
        {
          "label": "media-1",
          "type": "image",
          "originalName": "image1.png",
          "mimeType": "image/png",
          "location": "Slide 1 - Section: PPTX Slide One",
          "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/media/media-1.png",
          "relativePath": "media/media-1.png"
        },
        {
          "label": "media-2",
          "type": "chart",
          "originalName": "chart1.xml",
          "mimeType": "application/vnd.openxmlformats-officedocument.drawingml.chart+xml",
          "location": "Slide 1 - Section: PPTX Slide One",
          "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/media/media-2.xml",
          "relativePath": "media/media-2.xml"
        }
      ]
    },
    "mediaFiles": [
      {
        "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/media/media-1.png",
        "bytes": 68,
        "sha256": "431ced6916a2a21a156e38701afe55bbd7f88969fbbfc56d7fe099d47f265460"
      },
      {
        "path": "/private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/artifacts/slides/media/media-2.xml",
        "bytes": 287,
        "sha256": "b65aadf02b5da1461ffc12b4579c04b898eb760b9f73c9cb761b8e9077230c15"
      }
    ]
  },
  "rejectionChecks": {
    "collision": {
      "ok": true,
      "name": "RichDocumentError",
      "code": "EXPORT_EXISTS",
      "message": "Export destination already exists and was not overwritten: /private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/collision"
    },
    "collisionMarkerAfter": "keep this file",
    "lexicalEscape": {
      "ok": true,
      "name": "ExportPathError",
      "code": "EXPORT_PATH_ESCAPE",
      "message": "Export destination escapes the current project: ../outside-export"
    },
    "absoluteEscape": {
      "ok": true,
      "name": "ExportPathError",
      "code": "EXPORT_PATH_ESCAPE",
      "message": "Export destination must be project-relative, not absolute: /var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/absolute-export"
    },
    "symlinkEscape": {
      "ok": true,
      "name": "ExportPathError",
      "code": "EXPORT_SYMLINK_ESCAPE",
      "message": "Export destination symlink escapes the current project: /private/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/escape-link/nested"
    }
  },
  "source": {
    "path": "/var/folders/p9/clm2b7rd78d_3y7gydm7l7gc0000gn/T/opencode-reader-fixtures-ZaceSd/structure.docx",
    "unchangedByteForByte": true,
    "unchangedMtime": true
  }
}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 issues (1 error, 1 warning)
  • 🚨 src/path-safety.ts:120 - The required intent is to reject "any path that escapes the current project, including escaping symlinks." The destination is realpath-checked here, but writeDurableExport then calls recursive mkdir at src/export.ts:55 and writes through the unchecked path. If a checked parent is replaced with an outside symlink between validation and creation, the export follows it outside the project. Enforce the boundary at directory creation with no-follow/atomic component handling rather than relying on this preflight check.
  • ⚠️ src/reader.ts:427 - Every export request first builds the complete temporary-path Markdown here, then rebuilds the same converted body with exported paths at lines 431-434 and discards the first string when output is replaced at line 450. This adds unnecessary work and peak memory, with avoidable OOM pressure for large documents; construct only the selected output variant.
✅ **Test** - passed

✅ No issues found.

  • npm ci to restore the lockfile dependencies after the initial targeted test reported missing @opencode-ai/plugin
  • node --experimental-strip-types --test --test-name-pattern='(keeps omitted export reads ephemeral|writes a default sibling export|writes a custom project-relative export|rejects absolute and project-escaping export destinations|rejects export destinations whose symlinked parent escapes the project|rejects an existing export destination without overwriting it|leaves the source document byte-for-byte unchanged)' test/read-rich-document.test.ts
  • Manual public-tool verification through RichDocumentReaderPlugin -> read_rich_document.execute covering ephemeral reads, default sibling export, custom project-relative export, media and manifest persistence, collision preservation, lexical/absolute/symlink escapes, and source immutability
  • Removed the transient node_modules directory created for local testing; only the pre-existing ignored .roam/ directory remains in the worktree
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@ddbaron
ddbaron merged commit 0ca0299 into main Aug 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant