Plan: External Links in demarkus-tui (Phase 1)
Closes a portion of issue #75 — "handle links to things other than mark:// URLs in demarkus-tui".
Scope
In scope (Phase 1): Open non-mark:// links (http, https, gemini, mailto) in the user's system handler via the OS launcher. No protocol changes, no binary file handling, no inline rendering.
Out of scope (defer to Phase 2): Images or other file types served over mark:// on the same server. Requires content-type frontmatter (optional protocol change) and temp-file staging; bigger surface area, separate plan.
Problem
Today, goldmark extracts every link from the rendered document, but fetch.ParseMarkURL at client/internal/fetch/fetch.go:21-39 rejects anything that isn't mark://. Non-mark links render, get selectable highlights, and then die with "unsupported scheme" on Enter. The TUI looks broken for any document that mixes mark links with ordinary web links.
Design
New package: client/internal/launcher
Single-responsibility: given a URL, open it in the user's default handler for that scheme. No TUI state, no config loading.
package launcher
// Open opens url in the user's default handler. Only schemes in the caller-provided
// allowlist are launched; all others return ErrDisallowedScheme. The URL is passed
// as an argv argument — never through a shell — and the subprocess inherits no
// file descriptors from the caller.
func Open(url string, allowed []string) error
- Platform dispatch via
runtime.GOOS:darwin→exec.Command("open", url)linux,freebsd,openbsd→exec.Command("xdg-open", url)windows→exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
- URL is always passed as an
argvargument. Neversh -c, never string concatenation. This is the single most important security property of the package. - Scheme parsed with
net/url.Parse. If the scheme isn't inallowed, returnErrDisallowedScheme. No heuristics, no "guess if it's a URL." Cmd.Stdout/Cmd.Stderrdiscarded; caller sees only error/success.- Subprocess is detached enough that the TUI doesn't get blocked —
Cmd.Start()thenCmd.Process.Release(). We don't care about the exit code of the browser.
Allowlist
Default set: http, https, gemini, mailto.
Explicitly excluded by default (all rejected — user can add with a flag, but we push back hard in docs):
file— arbitrary filesystem accessjavascript— some handlers will evaluatedata— payload smugglingvscode,ms-*,steam,slack, etc. — custom handler RCE history
This mirrors the established posture: capability-based, narrow by default.
TUI integration
At client/cmd/demarkus-tui/main.go:897-906 (doFetch):
- Parse the URL scheme first.
- If scheme ==
mark(or empty, treating bare paths as mark): existing code path. - If scheme is in allowlist:
launcher.Open(raw, allowedSchemes)in atea.Cmd, return anexternalOpenResultmessage carrying success/failure. Do not replace the current document. Show a short status bar message:opened in browser/couldn't open: <err>. - Otherwise: status bar error
unsupported scheme: <scheme>.
Visual affordance
In the rendered output, prefix external links with a glyph so users know Enter leaves the TUI. Candidate: ↗ (U+2197). Implementation point: injectLinkMarkers at client/cmd/demarkus-tui/main.go:929 already has LinkInfo.Dest — add scheme detection there and splice the prefix into the marker region. Keep it a single rune so column math stays simple.
Leave mark:// links visually unchanged — they're the default.
Config surface
New flag on demarkus-tui:
-external-links string comma-separated allowlist of URL schemes to open externally
(default "http,https,gemini,mailto"; set to "" to disable)
Parsed once at startup, passed into the model. No env var yet — keep the surface small. Empty string disables external launching entirely (the url scheme rejection path handles everything).
No changes to CLI or MCP. Document this explicitly in code:
// Launching external handlers is TUI-only. CLI and MCP are non-interactive
// and must never spawn GUI processes on the user's behalf.
Security Checklist (must be true before merge)
- URL is passed as
argv, never assembled into a shell string - Unknown schemes rejected before reaching
exec.Command - No shell expansion anywhere in the launcher path — audit test uses a URL containing
;,$(), backticks, newlines, and confirms they reach the handler verbatim - Subprocess stdout/stderr not wired to TUI stdout (would corrupt the Bubble Tea screen)
- Default allowlist does not include
file,javascript,data - Security note added to
/docs/site/security/index.md
Tests
New package client/internal/launcher:
- Table-driven test for scheme parsing and allowlist rejection (no subprocess)
- Platform dispatch test with an injected command runner (
type runner func(name string, args ...string) error) — default isexec.Command(...).Start(), tests substitute a spy that asserts argv shape - Shell-metachar test: URLs containing
;,&&,$(...),`...`,\n— confirm argv has them verbatim and no shell is invoked runtime.GOOSswitch tested by reading from an injectedgoosvalue, notruntime.GOOSdirectly
TUI:
- Extend
handleLinkFollowtest coverage to cover: allowlisted scheme → launcher called (with spy), disallowed scheme → status error, mark:// → existing fetch path
Non-Goals (explicit)
- No confirmation prompt on every external link. Too noisy for readers. Scheme allowlist is the gate.
- No tracking which links users click. (None anyway — that's the project's posture.)
- No support for
<a href>in raw HTML. goldmark is already configured to extract markdown links; HTML passthrough is out of scope. - No mime-type sniffing for mark:// URLs that turn out to be binary. That's Phase 2.
Risks / Open Questions
- Linux without xdg-utils:
xdg-openisn't always installed (minimal containers, some BSDs).exec.LookPathbefore spawning; fall back to a status-bar error with a clear message. Do not try multiple handlers silently. - Headless SSH session: Users reading via SSH on a server without a display have no browser.
xdg-openwill either print a URL to stdout (which we've discarded) or error. Same handling: surface the error, don't break the TUI. - Windows testing: I don't have a Windows box. Cover the dispatch in unit tests with a spy; live-test on darwin and linux; accept that Windows is unverified at merge time and flag it in the PR.
- Gemini links with non-standard ports: allowlist is by scheme only; we don't validate hosts. Fine.
Implementation Order
client/internal/launcherpackage + unit tests (no TUI dependency, can land first)- Wire into
doFetchbehind a no-op default so nothing visually changes yet - Add the
-external-linksflag, plumb through - Add the
↗visual prefix ininjectLinkMarkers - Update
/docs/site/security/index.mdwith a short section on external-link handling - Run
bash pre-commit.sh
Conventional commit targets: feat(client): launcher package for external URLs, then feat(client): TUI opens http/https/gemini links via system handler.
Out of Scope — Captured for Phase 2
- Content-type frontmatter on FETCH responses (optional, backward-compatible)
- Binary file fetch → tempfile → launcher flow
MaxBodyLengthreconsideration for binary content- Inline image rendering (Kitty / Sixel / iTerm2 graphics protocols)
These should land as a separate plan once Phase 1 is shipped and we have a feel for how users use external links.