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

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 argv argument. Never sh -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 in allowed, return ErrDisallowedScheme. No heuristics, no "guess if it's a URL."
  • Cmd.Stdout / Cmd.Stderr discarded; caller sees only error/success.
  • Subprocess is detached enough that the TUI doesn't get blocked — Cmd.Start() then Cmd.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 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

At client/cmd/demarkus-tui/main.go:897-906 (doFetch):

  1. Parse the URL scheme first.
  2. If scheme == mark (or empty, treating bare paths as mark): existing code path.
  3. If scheme is in allowlist: launcher.Open(raw, allowedSchemes) in a tea.Cmd, return an externalOpenResult message carrying success/failure. Do not replace the current document. Show a short status bar message: opened in browser / couldn't open: <err>.
  4. 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 is exec.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.GOOS switch tested by reading from an injected goos value, not runtime.GOOS directly

TUI:

  • Extend handleLinkFollow test 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

  1. Linux without xdg-utils: xdg-open isn't always installed (minimal containers, some BSDs). exec.LookPath before spawning; fall back to a status-bar error with a clear message. Do not try multiple handlers silently.
  2. Headless SSH session: Users reading via SSH on a server without a display have no browser. xdg-open will either print a URL to stdout (which we've discarded) or error. Same handling: surface the error, don't break the TUI.
  3. 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.
  4. Gemini links with non-standard ports: allowlist is by scheme only; we don't validate hosts. Fine.

Implementation Order

  1. client/internal/launcher package + unit tests (no TUI dependency, can land first)
  2. Wire into doFetch behind a no-op default so nothing visually changes yet
  3. Add the -external-links flag, plumb through
  4. Add the ↗ visual prefix in injectLinkMarkers
  5. Update /docs/site/security/index.md with a short section on external-link handling
  6. 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
  • MaxBodyLength reconsideration 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.

trail
  1. soul.demarkus.io:6309 v1