Skip to content

fix(capabilities): give open_path a scope so it can actually run - #403

Merged
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/opener-path-scope
Aug 2, 2026
Merged

fix(capabilities): give open_path a scope so it can actually run#403
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/opener-path-scope

Conversation

@PathGao

@PathGao PathGao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Closes #399.

The defect

capabilities/default.json lists opener:allow-open-path, which grants the command. Scope is resolved separately, and there it fails:

Permission commands scope
allow-open-path ["open_path"] null
allow-default-urls [] [mailto:*, tel:*, http://*, https://*]

A permission with no commands lands in the plugin's global scope, applying to every opener command — including open_path. And in tauri-plugin-opener 2.5.3:

fn matches_path_program(&self, a: Option<&str>) -> bool {
    match self {
        Self::Url { .. } => false,        // every entry we have is a Url
        Self::Path { app, .. } => app.matches(a),
    }
}

is_path_allowed = fs_scope.is_allowed(path) && allowed.any(matches_path_program) — the fs allow-list is empty, so the first conjunct is false; every scope entry is a Url, so the second is too. open_path returns ForbiddenPath on every platform in every window.

What it actually breaks today

One call site: the "Open exported HTML file now?" prompt after an HTML export. PDF export never offers it, and there is no auto-open anywhere — this is an opt-in yes/no dialog whose Yes button has never worked.

It is not silent: the call site catches the rejection and shows toast.openExportedFileFailed. So it has read as an ordinary failure rather than a missing capability, which is probably why it went unreported.

Why **

It matches the width the app already has, rather than adding to it:

  • assetProtocol.scope is already ["**"] — the webview can read any file through asset://.
  • read_file_content, save_file_content, copy_file, delete_file all take arbitrary paths from the frontend with no scope check.

So unrestricted read and write are already the status quo. The genuine increment is different in kind: open_path hands a path to the OS default handler, and for an executable that means launching it.

That increment belongs to the call site, not to the ACL. The two call sites differ:

Call site Path comes from Untrusted?
askToOpenExportedFile a path the app just wrote, chosen by the user in a save dialog no
relative link resolution (not yet shipped) a link inside the open document yes

Narrowing the ACL would block the first — which has no untrusted input at all — while the second is better answered where the path is produced. A follow-up PR adds that second call site; the question of what a document should be allowed to ask the OS to open belongs in its review, and I'll raise it there.

If you would rather have a narrower scope anyway, say so and I'll write it. Note it cannot be a static list: the export destination is wherever the user's save dialog pointed, so it needs the plugin's runtime scope API rather than a capabilities entry.

Verification

cargo build regenerates gen/schemas/capabilities.json, which now carries:

{ "identifier": "opener:allow-open-path", "allow": [{ "path": "**" }] }

Build clean. No behaviour change beyond the one command becoming callable — no test asserts the old rejection, and nothing else in the tree calls open_path.

🤖 Generated with Claude Code

`opener:allow-open-path` grants the command but carries no scope, and
`allow-default-urls` - which has no commands, so its scope lands in the
plugin's global scope and applies to every opener command - contains
only URL entries. In tauri-plugin-opener,
`Entry::Url::matches_path_program` returns false unconditionally, and
the fs allow-list is empty, so `is_path_allowed` is false on both
conjuncts. Every `open_path` call returns ForbiddenPath.

The one place that calls it today is the "Open exported HTML file now?"
prompt, whose Yes button has therefore never worked; it reports the
failure as a toast rather than doing nothing, which is likely why it
read as an ordinary error.

`**` matches the width the app already has: `assetProtocol.scope` is
`["**"]`, and the file commands take arbitrary paths from the frontend
with no scope check, so read and write access to the filesystem is
already unrestricted. The genuine increment is that `open_path` hands a
path to the OS default handler, which for an executable means launching
it - and that matters at the call site that takes untrusted input, not
in the ACL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit deb0619 into sftwrdotdev:master Aug 2, 2026
4 checks passed
@PathGao
PathGao deleted the fix/opener-path-scope branch August 2, 2026 23:10
PathGao pushed a commit that referenced this pull request Aug 2, 2026
`[data](./data.csv)` was handed to `openUrl(anchor.href)`, and
`anchor.href` is what the DOM resolved against the webview origin, not a
path on disk. The two platforms then failed differently: on macOS and
Linux the origin is `tauri://localhost`, which the opener scope refuses,
so the click did nothing and left an uncaught promise rejection; on
Windows it is `http://tauri.localhost`, which matches `http://*`, so the
browser really opened onto a dead link.

`resolveLocalFileLinkPath` reuses `resolveExportImagePath` from #363 for
the whole scheme, drive-letter, UNC and query-suffix decision table, and
adds two rules a link needs that an image does not: `//host/path` is a web
address, matching what `getMarkdownLinkTarget` already assumes, and a
relative link in an unsaved buffer resolves to nothing rather than to
something relative to the process working directory.

Both OS calls are now inside try/catch with a toast, which removes the
uncaught rejection and makes the macOS failure visible.

The file still will not open until the opener path scope is decided
(#399, #403); everything above is an improvement regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao pushed a commit that referenced this pull request Aug 2, 2026
`[data](./data.csv)` was handed to `openUrl(anchor.href)`, and
`anchor.href` is what the DOM resolved against the webview origin, not a
path on disk. The two platforms then failed differently: on macOS and
Linux the origin is `tauri://localhost`, which the opener scope refuses,
so the click did nothing and left an uncaught promise rejection; on
Windows it is `http://tauri.localhost`, which matches `http://*`, so the
browser really opened onto a dead link.

`resolveLocalFileLinkPath` reuses `resolveExportImagePath` from #363 for
the whole scheme, drive-letter, UNC and query-suffix decision table, and
adds two rules a link needs that an image does not: `//host/path` is a web
address, matching what `getMarkdownLinkTarget` already assumes, and a
relative link in an unsaved buffer resolves to nothing rather than to
something relative to the process working directory.

Both OS calls are now inside try/catch with a toast, which removes the
uncaught rejection and makes the macOS failure visible.

The file still will not open until the opener path scope is decided
(#399, #403); everything above is an improvement regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao pushed a commit that referenced this pull request Aug 2, 2026
`[data](./data.csv)` was handed to `openUrl(anchor.href)`, and
`anchor.href` is what the DOM resolved against the webview origin, not a
path on disk. The two platforms then failed differently: on macOS and
Linux the origin is `tauri://localhost`, which the opener scope refuses,
so the click did nothing and left an uncaught promise rejection; on
Windows it is `http://tauri.localhost`, which matches `http://*`, so the
browser really opened onto a dead link.

`resolveLocalFileLinkPath` reuses `resolveExportImagePath` from #363 for
the whole scheme, drive-letter, UNC and query-suffix decision table, and
adds two rules a link needs that an image does not: `//host/path` is a web
address, matching what `getMarkdownLinkTarget` already assumes, and a
relative link in an unsaved buffer resolves to nothing rather than to
something relative to the process working directory.

Both OS calls are now inside try/catch with a toast, which removes the
uncaught rejection and makes the macOS failure visible.

The file still will not open until the opener path scope is decided
(#399, #403); everything above is an improvement regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao added a commit that referenced this pull request Aug 3, 2026
…ent (#409)

`[data](./data.csv)` was handed to `openUrl(anchor.href)`, and
`anchor.href` is what the DOM resolved against the webview origin, not a
path on disk. The two platforms then failed differently: on macOS and
Linux the origin is `tauri://localhost`, which the opener scope refuses,
so the click did nothing and left an uncaught promise rejection; on
Windows it is `http://tauri.localhost`, which matches `http://*`, so the
browser really opened onto a dead link.

`resolveLocalFileLinkPath` reuses `resolveExportImagePath` from #363 for
the whole scheme, drive-letter, UNC and query-suffix decision table, and
adds two rules a link needs that an image does not: `//host/path` is a web
address, matching what `getMarkdownLinkTarget` already assumes, and a
relative link in an unsaved buffer resolves to nothing rather than to
something relative to the process working directory.

Both OS calls are now inside try/catch with a toast, which removes the
uncaught rejection and makes the macOS failure visible.

The file still will not open until the opener path scope is decided
(#399, #403); everything above is an improvement regardless.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PathGao added a commit that referenced this pull request Aug 3, 2026
… asset URL (#415)

`resolveLocalFileLinkPath` delegates to `resolveExportImagePath`, which
accepts `asset://localhost/…` and `http://asset.localhost/…` - the shape a
local image's `src` takes inside the webview, which the exporter has to
turn back into a disk path to inline the bytes.

A link is the opposite situation: the href is the author's own text. So
`[report](http://asset.localhost/Users/me/.ssh/id_rsa)` reads as a remote
address in the link text and the status bar, resolves to
`/Users/me/.ssh/id_rsa`, and is handed to the OS default handler - now
reachable, since the opener path scope was opened in #403.

Reusing the image resolver was right; inheriting that one clause of it
was not. The link resolver rejects asset URLs before delegating.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

openPath is rejected by the opener scope, so "open the exported file" never works

1 participant