feat(dashboard): open a chat file in the user's editor via shell.openPath - #9007
Conversation
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of All evidence is in hand. The blind reader correctly parsed the new row but flagged the exact ambiguity ("which editor? where?"); the diff shows three states the screenshots never captured, plus two guaranteed-fail configurations the row doesn't gate on. Final review: UX-Verdict: CONCERNS "Open in editor" renders for files it will always refuse — every source-code chip click dead-ends in a raw "unsupported file type" error. Watch
Evidence gaps
[UX-REVIEWED] 87be8d7 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. I have what I need for the verdict: the capability is real but its honest scope is narrower than framed, and there are counted duplication/sibling concerns. Final review: First-Principles-Verdict: CONCERNS The genuinely new capability is Windows-only: on macOS/Linux local desktop the menu now shows two rows that hand the same file to the same OS default handler. What this change shipsIntent: let a user open a chat-referenced file in their own desktop's default handler instead of the built-in viewer — an ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 87be8d7 |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS
Watch
Suggestions
[DESIGN-REVIEWED] 87be8d7 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
7f16627 to
a76ef75
Compare
a76ef75 to
9f079ed
Compare
125b994 to
3c4552b
Compare
3c4552b to
86521cd
Compare
86521cd to
f74f3fb
Compare
…Path Add an 'Open in editor' affordance on the chat file-path chip, backed by a new dashboard:open-file Electron IPC channel that validates a filesystem path and calls shell.openPath, so a referenced file opens in the OS default handler on the user's own machine -- never a URL scheme. - New channel in ipc-registrar.js, gated by the existing three-gate assertLocalDashboard check (sibling of crash-reports:reveal), then realpath -> a plain-text-only extension allowlist -> regular-file -> shell.openPath. The allowlist admits ONLY types whose OS default handler cannot execute embedded content (text/data/config); scripts, browser active documents and rich office/pdf documents are excluded because their handlers can run code. - New fileOpenAPI preload namespace, feature-detected; absent in a plain browser. - Frontend helpers in lib/electron.ts and one menu row on the existing chip, shown only when the bridge is present and the target is a file. - One new i18n key across all locales; en-XA regenerated. - Electron + frontend tests, each mutation-verified; capture harness + light/dark screenshots under temp-screenshots/ per that dir's convention. Refs #8119
f74f3fb to
87be8d7
Compare
What is the problem?
When a chat response references a file, there is no supported way to open it in
the editor the user actually works in. A backticked absolute path opens
KiroCrew's built-in viewer (fine for a look, but not their tooling), and an
editor deep link (
obsidian://,vscode://,zed://) is blocked by design andsilently reopens the dashboard in a new window with no error.
external-scheme.jsdocuments why a configurable scheme allowlist was rejected, and that reasoning is
sound, so the handoff "the agent points at a file, I open it where I work" has no
first-party path today.
Why this matters to the user
For anyone whose notes or code live in an external editor -- Obsidian vaults, VS
Code workspaces -- the handoff breaks at the last step. The documented workaround
is standing up a localhost HTTP server that 302-redirects to the real scheme,
i.e. running a background process purely to launder a URL past a security control.
That it works is not a good sign.
There is already a partial route -- the chip's right-click "Open with default app"
calls
POST /api/reveal, which shells out on the GATEWAY host (/usr/bin/open/xdg-open). But it refuses Windows outright and degrades to a clipboard copy on aremote or tunneled session, and it opens the file on the machine running the
gateway rather than the machine in front of the user. It is not the route the
issue asks for.
How the fix solves it
The issue names the mechanism: hand a filesystem PATH -- never a URL scheme -- to
shell.openPath, on the user's own desktop. This wires exactly that, additively,reusing the primitives already in the tree:
dashboard:open-fileinipc-registrar.js. It is aLAUNCH on the local machine, so it takes the SAME three-gate local-dashboard
trust check (
assertLocalDashboard) as its sibling launch channelcrash-reports:reveal-- a connection window pointed at a remote gateway sharesthis preload and must never reach
shell.openPath. It then validates the pathwith the shape the
mochi-pet:open-imagehandler already uses:realpathSyncBEFORE the extension test (so a
.mdsymlink to a key file is judged by what itresolves to), a fixed text/document extension allowlist, a regular-file
requirement, and a non-empty
openPathreturn surfaced as an OS refusal. Thelaunch itself goes through the shared
openPathHardenedwrapper(
website/electron/open-path.js), the same one every other launch site uses, soa writable leading
PATHentry cannot shadowxdg-openon Linux. Theallowlist admits only types whose OS default handler cannot execute their
contents, which excludes spreadsheets (
.csv/.tsv): their default appevaluates a leading
=cell, so an agent-authored=WEBSERVICE(...)wouldexfiltrate and
=cmd|...would reach DDE.fileOpenAPI.open(path)(preload.js), one moreper-capability bridge in the established pattern, absent in a plain browser and
the PWA.
canOpenFileInEditor()/openFileInEditor()(
lib/electron.ts), and one new "Open in editor" row on the existing chat pathchip (
FilePathMenu), shown only when the bridge exists and the target is afile. In a browser tab the row is hidden and the built-in viewer stays the only
handoff.
Symptom -> root cause chain: the file cannot reach the user's editor (symptom)
because the only host-open route runs on the gateway host and is Windows-refused /
remote-degraded, and the Electron
shell.openPathprimitive -- which runs on theuser's own machine -- was never exposed to the dashboard renderer (root cause).
The fix exposes it, gated and validated.
Security posture: this hands an arbitrary path to the OS, which is the whole risk.
Two boundaries bound it. (1) The affordance is only actionable AFTER the chip's
existing
/api/file-readprobe confirms a non-sensitive regular file -- thecredential-path denylist (
is_sensitive_path) is enforced upstream before the rowcan be clicked. (2) The launch channel itself realpath-canonicalizes, restricts to
a fixed text/document extension set (deliberately not user-extensible, which would
reopen the rejected scheme-allowlist surface), requires a regular file, and is
reachable only from the trusted local dashboard. Project-root confinement (the
issue's "worth discussing" item) is intentionally NOT added here: no sibling launch
channel (
/api/reveal,crash-reports:reveal) project-confines, the Electron mainprocess has no session project dir to confine against, and adding a new bespoke
validator would contradict "reuse the existing validation." Flagging it for a
maintainer decision rather than inventing it.
Security-guidance note: the org security-guidance search tool was unavailable in
this environment (no such tool is loadable here). The query I would have run:
"handing an untrusted filesystem path to a host OS launch primitive -- path
confinement, realpath canonicalization, and sensitive-path denylisting before
launch." I did not treat its absence as clearance; the design applies the in-repo
controls above.
What tests we did
node --test test/ipc-registrar.test.js, 26 tests): new casesassert the channel routes through
assertLocalDashboard(a wrong-origin senderis rejected before any fs/shell work), and that validation rejects an empty /
non-string path, an unsupported extension, an executable-association script
(
.js), a spreadsheet (.csv,.tsv), a directory, and a.mdsymlink whoserealpath is an
.exe; that a valid file is opened at its RESOLVED path; thatthe launch runs with the narrowed launcher
PATHand restores it afterwards;and that an OS refusal and a thrown
openPathare both reported, not swallowed.Six mutations hand-applied, each reddens a distinct assertion (gate removed; ext
checked on raw path not realpath; isFile dropped; openPath return ignored; raw
path passed to openPath instead of realpath;
openPathHardenedswapped back toa bare
shell.openPath).test/shell-contract.test.js): the drift guards that assertevery preload-sent channel has a main handler pass with the new channel; the
SHELL_HANDLESset and channel count were updated accordingly.vitest run MarkdownRenderer.contextmenu.test.tsx, 17 tests): 4 newcases -- the row shows and calls
fileOpenAPI.openwith the chip path when thebridge is present; is hidden in a plain browser; is hidden for a directory; and
surfaces the bridge error on failure. Three mutations, each reddens a distinct
test (drop the bridge check; drop the dir guard; drop the error surfacing).
node scripts/i18n-check.mjs, 19 checks PASS): one new keycomponents.markdownPanel.open_in_editorbesideopen_with_default_app, added toen.manual.jsonand all 11 shipped non-English locales with faithfultranslations matching each locale's register;
en-XAregenerated.key-refs,changed-passthrough,source-strings, andpseudolocaleall pass; catalogparity + duplicate-key tests (104) pass. tsc and eslint clean on changed source.
Screenshots
The change is the right-click menu on a chat file-path chip gaining an "Open in
editor" row (shown only when the desktop shell bridge is present; a plain browser
sees no such row). Captured against the real stylesheet in both themes via
website/scripts/capture-open-in-editor-8119.mjs, which asserts the row ispresent rather than only photographing it:
If the images do not render, see the Files changed tab under
temp-screenshots/open-in-editor-8119/.Any other suggestions
this PR deferred it to Electron shell.openPath inherits PATH with no launcher hardening #9094 on the grounds that the sibling call site
(
mochi-pet:open-image) was equally bare, so the exposure was a repo-wideproperty of the primitive rather than something this affordance introduced.
That is no longer true:
website/electron/open-path.js(openPathHardened)has since landed on main and every other launch site now uses it, so shipping a
bare
shell.openPathwould have made this handler the only unhardened call inthe tree. The launch now goes through
openPathHardened(shell, real), whichnarrows
PATHto system launcher directories across the native launch on Linuxand is a documented pass-through on macOS and Windows. Both review lanes
(GPT 5.6 and First Principles) flagged the omission; both are addressed by the
one-line routing change plus its regression test.
main process refuses every click:
canOpenFileInEditor()tests only for thepreload bridge, and that preload is shared with connection windows, while
assertLocalDashboardrejects the channel for any non-local sender. The refusalis safe and surfaces as the menu's error line, but the row is still an
affordance that cannot succeed there. Narrowing the renderer-side gate needs a
local-vs-remote signal the renderer does not have today, so it is deliberately
not bundled here; declared rather than left implicit.
natural follow-up if maintainers want it; it would need the session's project
dir threaded to the Electron main process, and ideally applied to the sibling
/api/revealopen too for consistency, so it is its own change.rendering and is a separate bug, unaddressed here.
Refs #8119