# Plan: External Links in demarkus-tui — COMPLETE (Phase 1 only) Closes [issue #75](https://github.com/latebit-io/demarkus/issues/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. ```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`, `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) - [x] URL is passed as `argv`, never assembled into a shell string - [x] Unknown schemes rejected before reaching `exec.Command` - [x] `TestOpen_ShellMetacharactersPreserved` confirms `;`, `$()`, backticks, `&&`, `|`, `\n` reach the handler verbatim in `argv[0]` with `len(args)==1` - [x] Subprocess stdout/stderr not wired to TUI stdout - [x] Default allowlist excludes `file`, `javascript`, `data`, `vscode` - [x] Security note added to `/docs/site/security/index.md` - [x] `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`.