Plan: External Links in demarkus-tui — COMPLETE (Phase 1 only)
Closes issue #75 in full (as rescoped). Phase 2 (non-markdown files served over mark://) is dropped — the demarkus server's scope is markdown-only and will not serve binary files. Inline image rendering may revisit someday as a separate feature, not a continuation of this plan.
Scope
Shipped: 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.
Dropped: Images or other file types served over mark:// on the same server. The server doesn't serve non-markdown, so there's nothing for the TUI to route externally. If inline image rendering ever happens, it will be via terminal graphics protocols (Kitty/Sixel/iTerm2) against images embedded in or linked from markdown, handled as a separate initiative.
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,netbsd→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 a small RFC 3986 parser (
schemeOf) in the TUI, notnet/url.Parse, so opaque-form URLs likemailto:alice@example.comare correctly classified. Cmd.Start()+Cmd.Process.Release()— we don't block the TUI on the browser.- Stdout/stderr discarded; caller sees only error/success.
Allowlist
Default set: http, https, gemini, mailto.
Explicitly excluded by default:
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
Introduced followLink(target) on the model. Both keyboard (handleLinkFollow) and mouse (handleMouseClick) paths delegate to it:
isExternalURL(target)— if true, go toopenExternal.openExternaldispatcheslauncher.Open(target, m.externalSchemes)in atea.Cmd, returns anexternalOpenResultmessage. Current page state is preserved.handleExternalOpenResultsurfaces status via the transientbookmarkMsgchannel (reused — it's the existing 2-second-auto-clear status message field).- If
externalSchemesis empty, external links are rejected up front with an "External links disabled" message.
Visual affordance
Selected external links are prefixed with ↗ in the status bar hint at main.go:867-875. Inline prefixing in the rendered body was considered and dropped for now — would require modifying injectLinkMarkers and risks nudging word-wrap on already-rendered text. Can revisit if users ask for it.
Config surface
-external-links string comma-separated URL schemes to open in the system handler
(default "http,https,gemini,mailto"; empty string disables)
Parsed once at startup by parseSchemeList, passed into the model. No env var.
No changes to CLI or MCP. Launching external handlers is TUI-only — CLI and MCP are non-interactive and must not spawn GUI processes.
Security Properties (all verified)
- URL is passed as
argv, never assembled into a shell string - Unknown schemes rejected before reaching
exec.Command -
TestOpen_ShellMetacharactersPreservedconfirms;,$(), backticks,&&,|,\nreach the handler verbatim inargv[0]withlen(args)==1 - Subprocess stdout/stderr not wired to TUI stdout
- Default allowlist excludes
file,javascript,data,vscode - Security note added to
/docs/site/security/index.md -
TestOpen_UnsupportedPlatformensures the runner is not invoked whenruntime.GOOSis unsupported
Tests
client/internal/launcher:
TestOpen_SchemeAllowlist— 11 cases covering allowed, rejected, and defaulted schemesTestOpen_PlatformDispatch— darwin, linux, freebsd, openbsd, netbsd, windows argv shapeTestOpen_UnsupportedPlatform— ErrNoHandler, runner not calledTestOpen_ShellMetacharactersPreserved— 6 malicious URLs verified argv-cleanTestOpen_RunnerErrorPropagatedTestDefaultAllowlist— forbidden-scheme regression check
TUI:
TestIsExternalURL— 14 cases: mark, bare paths, opaque mailto, authority-form, dangerous schemes, digit-start rejection
Non-Goals (stayed out)
- No confirmation prompt on every external link. Allowlist is the gate.
- No tracking of clicks.
- No HTML passthrough.
- No inline
↗prefix on rendered body — status-bar only.
Risks — Live-Test Coverage
- Darwin: verified manually.
- Linux:
xdg-openfallback path handled viaexec.LookPath; if missing, surfacesErrNoHandlerin the status bar without crashing. - Windows: covered by
TestOpen_PlatformDispatchonly, unverified on a real Windows box. Flagged for manual verification post-merge.
Implementation Artifacts
Files added/modified:
client/internal/launcher/launcher.go— new packageclient/internal/launcher/launcher_test.go— new testsclient/cmd/demarkus-tui/main.go—followLink,openExternal,handleExternalOpenResult,isExternalURL,schemeOf,-external-linksflag,parseSchemeList, status-bar prefixclient/cmd/demarkus-tui/links_test.go—TestIsExternalURLdocs/site/security/index.md— new "External Links in the TUI" sectiondocs/site/client/index.md— new "External links" section under TUI with flag examples
Conventional commit: feat(client): TUI opens http/https/gemini/mailto links via system handler.