soul.demarkus.io:6309/plans/external-links.md/v2 draft reader meta

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 argv argument. Never sh -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, not net/url.Parse, so opaque-form URLs like mailto:alice@example.com are 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 access
  • javascript — some handlers will evaluate
  • data — payload smuggling
  • vscode, 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:

  1. isExternalURL(target) — if true, go to openExternal.
  2. openExternal dispatches launcher.Open(target, m.externalSchemes) in a tea.Cmd, returns an externalOpenResult message. Current page state is preserved.
  3. handleExternalOpenResult surfaces status via the transient bookmarkMsg channel (reused — it's the existing 2-second-auto-clear status message field).
  4. If externalSchemes is 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_ShellMetacharactersPreserved confirms ;, $(), backticks, &&, |, \n reach the handler verbatim in argv[0] with len(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_UnsupportedPlatform ensures the runner is not invoked when runtime.GOOS is unsupported

Tests

client/internal/launcher:

  • TestOpen_SchemeAllowlist — 11 cases covering allowed, rejected, and defaulted schemes
  • TestOpen_PlatformDispatch — darwin, linux, freebsd, openbsd, netbsd, windows argv shape
  • TestOpen_UnsupportedPlatform — ErrNoHandler, runner not called
  • TestOpen_ShellMetacharactersPreserved — 6 malicious URLs verified argv-clean
  • TestOpen_RunnerErrorPropagated
  • TestDefaultAllowlist — 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-open fallback path handled via exec.LookPath; if missing, surfaces ErrNoHandler in the status bar without crashing.
  • Windows: covered by TestOpen_PlatformDispatch only, unverified on a real Windows box. Flagged for manual verification post-merge.

Implementation Artifacts

Files added/modified:

  • client/internal/launcher/launcher.go — new package
  • client/internal/launcher/launcher_test.go — new tests
  • client/cmd/demarkus-tui/main.go — followLink, openExternal, handleExternalOpenResult, isExternalURL, schemeOf, -external-links flag, parseSchemeList, status-bar prefix
  • client/cmd/demarkus-tui/links_test.go — TestIsExternalURL
  • docs/site/security/index.md — new "External Links in the TUI" section
  • docs/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.

trail
  1. soul.demarkus.io:6309 v2