# Clickable URLs & file paths in the terminal **Status of premise (read first).** Two independent capabilities are bundled here: 1. **URLs** — already *90% shipped.* `public/terminal-session.ts:148` already does `this.term.loadAddon(new WebLinksAddon())`, and `@xterm/addon-web-links@^0.12.0` is already in `package.json`. Remaining work is a security hardening pass on link activation (scheme allowlist + `noopener`). Pure frontend. 2. **File paths → editor** — needs a **custom link provider** *and* a **small, additive server change.** The task brief says "POST to the existing `/open-in-editor` … no server change," but the existing route **cannot** open a file at a line: `openInEditor` (`src/http/editor.ts:31`) rejects anything that is not an **absolute existing _directory_** (lines 35, 45–47) and spawns `code ` with **no `--goto`/line** (line 51). Feeding it `src/app.ts:42` fails three validators at once. See the **Decision** callout below — I recommend a tiny additive `openFileInEditor` alongside the untouched `openInEditor`. A strict "frontend-only" fallback exists but degrades to "open the containing folder, no line jump." `src/types.ts` is **not** touched — this is an HTTP JSON body, not a WS protocol message; no shared-contract change. The byte-shuttle terminal stream is untouched. --- ## Decision: how file-path clicks reach the editor | Option | What clicking `src/app.ts:42` does | Server change | Recommendation | |---|---|---|---| | **A — additive `openFileInEditor` (recommended)** | Opens the file at line 42 (`code --goto /abs/src/app.ts:42`) | +~35 lines in `src/http/editor.ts`, +1 branch in the `server.ts:384` route. Backward-compatible; `openInEditor` + its tests untouched | **Yes.** Only option that delivers the actual feature (jump to file:line). Additive and low-risk. | | **B — strict frontend-only** | Resolves to the file's parent dir and calls existing `openInEditor` → opens the *repo/folder*, no file, no line | None | Fallback only. Poor UX; loses the whole point (line jump). Document but don't ship as primary. | The rest of this plan assumes **Option A**. The frontend work is identical either way; only the POST body and server branch differ. --- ## Contract ### Route (extended, backward-compatible) `POST /open-in-editor` (`src/server.ts:384`) — unchanged guards: `express.json({ limit: '4kb' })`, `requireAllowedOrigin` (CSRF, `src/server.ts:352`). New branch on body shape: - **Existing (directory)** — `{ "path": "" }` → `openInEditor(cfg, body.path)` (unchanged; Projects panel keeps working). - **New (file+line)** — `{ "file": "", "line": , "column": }` → `openFileInEditor(cfg, body.file, body.line)`. - Response envelope unchanged: `204` on success; `{ error }` + `4xx/5xx` on failure (mirrors `src/server.ts:388–392`). Body is a discriminated request: **`file` present ⇒ file mode; else path mode.** If neither present → `400 { error: 'path or file is required' }`. ### New server function (`src/http/editor.ts`) ``` export async function openFileInEditor( cfg: Config, rawFile: unknown, rawLine?: unknown ): Promise ``` Reuses the existing `OpenEditorResult` type (`editor.ts:20`). Validates: string + non-empty (`400`), `path.isAbsolute` (`400`), `fs.stat` exists (`404`), `stat.isFile()` (`400 'path is not a file'`), and `line` (when present) is an integer in `1..1_000_000` else `400`. Spawns via `execFile(cfg.editorCmd, args)` (no shell, same as line 51) where: - `args = isGotoEditor(cfg.editorCmd) && line !== undefined ? ['--goto', `${file}:${line}`] : [file]` - `isGotoEditor` = basename ∈ `{code, code-insiders, codium, cursor, windsurf}` (the editors that accept `--goto`). Unknown editors open the bare file (never pass a bogus `--goto` argv). ### New frontend module (`public/link-paths.ts`) — pure, node-testable ``` export interface PathMatch { text: string // exact matched substring (e.g. "src/app.ts:42") path: string // "src/app.ts" line?: number column?: number startX: number // 1-based column of first char (xterm range.start.x) endX: number // 1-based column of last char (xterm range.end.x, inclusive) } export function findPathMatches(lineText: string): PathMatch[] ``` Matching rules (concrete): a token is a path candidate iff it has a filename with a dot-extension, optionally preceded by `./`, `../`, or `dir/…/` segments, optionally suffixed `:line` and `:line:col`. A candidate becomes a match iff **(has a `/` separator) OR (has a `:line` suffix) OR (extension ∈ `CODE_EXT` allowlist)** — this links `src/app.ts:42`, `README.md`, `main.rs:10` while rejecting `example.com`, `v1.2`, `foo.bar`. Reject candidates immediately preceded by `/` or `:` (avoids grabbing the tail of a `https://host/path.html` URL, which `WebLinksAddon` owns). `CODE_EXT` = a named const set (`ts tsx js jsx mjs cjs py go rs rb java kt c h cpp hpp cc cs php swift css scss html json yaml yml toml md txt sh sql vue svelte` …). ### Env vars **None new.** `EDITOR_CMD` (default `'code'`) already exists (`src/config.ts:49`, `src/types.ts:42`) and is reused. ### WS protocol / `src/types.ts` **No change.** No new client→server or server→client message types. --- ## Files to change | Path | Change | |---|---| | `public/link-paths.ts` | **New.** Pure `findPathMatches` + `CODE_EXT` const + `PathMatch` type. No DOM. (Keeps matcher unit-testable in node and file <150 lines.) | | `public/terminal-session.ts` | Replace bare `new WebLinksAddon()` at **line 148** with a hardened handler (scheme allowlist + `window.open(uri,'_blank','noopener,noreferrer')`). After `term.open` (line 149) register a path link provider via `this.term.registerLinkProvider(...)`; store the returned `IDisposable`. Add `private openPath(m: PathMatch)` (resolve rel→abs via `this.cwdValue`, in-flight guard, `fetch('/open-in-editor', …)`, error→`statusLine` toast). Dispose the provider in `dispose()` (line 452). Small helpers `openWebLink`, `makePathLinkProvider`. | | `src/http/editor.ts` | **Add** `openFileInEditor` + `isGotoEditor` helper. **`openInEditor` unchanged** (Projects panel + its tests keep passing). | | `src/server.ts` | In the `/open-in-editor` handler (**line 384–393**) branch: `body.file` present → `openFileInEditor(cfg, body.file, body.line)`; else existing `openInEditor(cfg, body.path)`. | | `src/types.ts` | **Not touched** — noted here only to confirm no shared-contract edit is required. | --- ## TDD steps (ordered) > Run with `npm test` (vitest). New frontend-logic tests are **node** (pure matcher); DOM-wiring tests reuse the existing **jsdom** harness in `test/terminal-session.test.ts`. 1. **`test/link-paths.test.ts` (new, node).** RED→GREEN for `findPathMatches`: - `'see src/app.ts:42 for'` → one match, `path:'src/app.ts', line:42`, `startX/endX` correct (1-based, `startX = index+1`, `endX = index+len`). - `'./a/b.tsx:10:5'` → `line:10, column:5`. - `'README.md'` (allowlisted ext, no slash) → matched; `'example.com'` and `'v1.2.3'` → **no** match. - URL guard: `'https://host/path.html'` → **no** path match (preceded-by-`/` rule). - Two paths on one line → two matches with disjoint ranges. Then implement `public/link-paths.ts` to green. 2. **`test/editor.test.ts` (extend, node — mirror existing spawn-a-harmless-process style, `editorCmd:'true'`).** Add a `describe('openFileInEditor')`: - relative → `400`; missing → `404`; **directory** → `400 'is not a file'`; non-int/`0`/`1e9+` line → `400`. - success on a real temp file (editorCmd `'true'`) → `status 204`. - **argv assertion:** write a tiny recorder script into the temp dir (`#!/bin/sh; printf '%s\n' "$@" > "$ARGS_OUT"`), set `editorCmd` to it, call with `line:42`, assert the recorded argv is `--goto`, `:42`; call an unknown editor name → argv is just `` (no `--goto`). Then implement `openFileInEditor` + `isGotoEditor` to green. 3. **`test/integration/server.test.ts` (extend; harness at line 229).** Boot the app, `POST /open-in-editor`: - `{file:, line:3}` with a valid `Origin` → `204`. - foreign/missing `Origin` → `403` (proves the CSRF guard still covers the new branch). - `{path:}` still `204` (regression: directory mode intact). Wire the `server.ts` branch to green. 4. **`test/terminal-session.test.ts` (extend, jsdom).** Extend `FakeTerminal` (line 14) with `registerLinkProvider = vi.fn(p => { this.captured = p; return {dispose:vi.fn()} })` and a `buffer = { active: { getLine: (y)=>({ translateToString:()=> this.lineText }) } }`. Keep the `WebLinksAddon` mock (line 48) but assert the constructor **received a handler fn**. Tests: - after construct, a link provider is registered; feeding a line with `src/app.ts:42` → `provideLinks` callback yields one `ILink` whose `text/range` match `findPathMatches`. - calling `link.activate(mouseEvent, text)` when `cwd` is set (drive an OSC-7 via the captured handler, or set via a resolved path) → `fetch` (stub via `vi.stubGlobal('fetch', …)` as in `test/preview-grid.test.ts:140`) called once with `'/open-in-editor'`, method `POST`, body `{file:, line:42}`. - relative path **with null cwd** → **no** fetch; a `statusLine` is written to the terminal (assert `term.write`). - in-flight guard: two rapid `activate` calls → **one** fetch. - `openWebLink('javascript:alert(1)')` → `window.open` **not** called; `openWebLink('https://x')` → `window.open('https://x','_blank','noopener,noreferrer')` called. Wire `terminal-session.ts` to green. **Coverage:** the matcher (branch-heavy) is fully covered by the node test; the DOM wiring by jsdom; the server branch + validators by editor/integration tests. This keeps the 80% gate comfortably. --- ## Edge cases & failure modes - **No cwd yet** (OSC-7 never fired, `this.cwdValue === null`, `terminal-session.ts:97`) → relative paths are unresolvable → **skip activation**, write a one-line `statusLine('cannot open : working dir unknown')`. Absolute paths still work. - **Path doesn't exist / is a dir** → server returns `404`/`400`; frontend shows a non-blocking `statusLine` toast, no throw (mirror the existing `openProjectInEditor` catch that only `console.error`s). - **Wide (CJK) glyphs before a path** shift xterm columns vs JS string index. Paths are ASCII, but a preceding CJK run offsets `startX`. Acceptable v1 caveat; note it. (Fixable later by walking cells; YAGNI now.) - **URL/path overlap** (`https://host/a.ts:5`) — `WebLinksAddon` links the URL; the `/`-preceded guard stops the path provider from double-linking the tail. Verify the two providers don't both underline. - **`provideLinks` line indexing** — pass `terminal.buffer.active.getLine(bufferLineNumber - 1)` and set `range.{start,end}.y = bufferLineNumber` (xterm gives a 1-based buffer row). **Verify once in a real browser** — the single indexing footgun. - **Rapid clicks** spawn N detached GUI processes on the host → in-flight boolean guard (+ optional 500 ms cooldown) on the frontend. - **Non-`code` editor** without `--goto` support → `isGotoEditor` returns false → open bare file (never inject a stray `--goto` argv the editor would treat as a filename). - **Line-only false positives** like `12:34` (a timestamp) — filtered because the token needs a filename-with-extension before the `:line`. --- ## Security - **Origin/CSRF:** unchanged. `/open-in-editor` stays behind `requireAllowedOrigin` (`src/server.ts:385`, `:352`). The frontend POST is same-origin, so the browser sends `Origin` and passes; a foreign page's no-preflight POST is rejected `403`. Integration test #3 asserts this on the new branch. - **No shell / no injection:** `openFileInEditor` uses `execFile` with an **argv array** (as `editor.ts:51`); the file path and `:` are argv elements, never a command line. `line` is validated to an integer before interpolation, so `${file}:${line}` can't smuggle shell metacharacters via the line field. - **Path containment:** the resolved path is validated **absolute + existing + `isFile()`** server-side. Terminal output is attacker-influenced (a malicious repo could print `../../etc/hosts:1`), but opening a file the user could already `cat` in the shell this app *already grants* adds no privilege (threat model: LAN, no auth, full shell). Optional hardening (note, not required v1): reject resolved paths that escape the session `cwd` root. - **URL activation hardening (the real new surface):** custom `WebLinksAddon` handler (replacing the default at line 148) **allowlists schemes** to `http:/https:/mailto:` and opens with `window.open(uri, '_blank', 'noopener,noreferrer')` — blocks `javascript:`/`data:`/`file:` URIs and prevents reverse-tabnabbing. Activation is gated on the click (a genuine user gesture); no hover/auto-open. - **Rate-limit:** frontend in-flight guard caps editor-spawn fan-out; the route's `express.json({limit:'4kb'})` bounds body size. No new secrets, no logging of paths beyond the existing `console.error` on failure. --- ## Effort & dependencies - **Effort:** ~**1.5–2 days.** Matcher + tests (0.5d), frontend wiring + jsdom tests + hardened URL handler (0.5d), server `openFileInEditor` + editor/integration tests (0.5d), manual browser verify of link indexing/overlap (0.25d). - **Depends on:** nothing — OSC-7 `cwd` capture (`terminal-session.ts:155–159`), the `/open-in-editor` route, and the `WebLinksAddon` dependency all already exist. Self-contained, W1. - **Unlocks / synergy:** the **Approval preview (W1, task #7)** and **diff viewer (W4, task #13)** can reuse `findPathMatches` + `openFileInEditor` to make paths in a diff/command preview clickable. `link-paths.ts` is deliberately a standalone pure module for that reuse. - **Scope flag for the orchestrator:** Option A adds a ~35-line server function (contradicting the brief's "no server change"). It is additive and backward-compatible, but it *is* a deviation — record it in `PROGRESS_LOG.md`. If the "no server change" constraint is hard, ship Option B (folder-open fallback) and defer file:line jump.