# Plan: External Links in demarkus-tui (Phase 1) Closes a portion of [issue #75](https://github.com/latebit-io/demarkus/issues/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. ```go 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: `. 4. Otherwise: status bar error `unsupported 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: ```go // 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 `` 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.