fix: v0.5.0 security and robustness fixes from the 2026-10-07 review
build-deb / Lint, test, package (push) Successful in 1m49s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Skipped

- Ask Claude: strip control characters, send as bracketed paste (closes #2)
- end: forget before kill, so ending from inside a session leaves no ghost (closes #3)
- reconnects use `attach --existing` and never resurrect ended/renamed sessions (closes #4)
- repo: git --no-optional-locks, no safe.directory override, refresh on connect/bell/60s (closes #5)
- uploads: unique names via mktemp, cleanup trap on hangup (closes #6)
- clipboard: write-only OSC 52 provider, tmux allow-passthrough off (closes #7)
- uploads: size check up front, streamed slices, idle timeout (closes #9)
- --list: path last in TSV rows, tolerant numbers (closes #10)
- tokens via curl config on stdin; userinfo stripped from remote URLs (closes #11)
- ttyd max clients 32 and at most 2 concurrent side-channel calls (#8; BroadcastChannel polling still open)
- tmux: fixed terminal-features index (no duplicates on re-source), no status-right clock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:15:47 -04:00
co-authored by Claude Opus 5.5
parent 364bd9b66f
commit 7aa624bc13
9 changed files with 192 additions and 50 deletions
+43 -20
View File
@@ -11,7 +11,7 @@
# devdesk pick interactive picker
#
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES |
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME`
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
# through ttyd; those print JSON.
set -euo pipefail
@@ -25,7 +25,8 @@ usage() {
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
running_tsv() {
dd_server_up || return 0
dd_tmux list-sessions -F '#{session_name} #{session_path} #{session_attached} #{session_activity} #{session_windows}' 2>/dev/null || true
# Path last: it may contain anything (tabs included) and is re-joined below.
dd_tmux list-sessions -F '#{session_name} #{session_attached} #{session_activity} #{session_windows} #{session_path}' 2>/dev/null || true
}
# Tab-separated top-level project directories: slug, dir.
@@ -68,9 +69,9 @@ cmd_list() {
--arg root "$DEVDESK_PROJECTS_ROOT" '
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
($reg.sessions // {}) as $known
| [rows($run)[] | {name: .[0], dir: .[1], running: true,
attached: (.[2] | tonumber), activity: (.[3] | tonumber),
windows: (.[4] | tonumber)}] as $live
| [rows($run)[] | {name: .[0], dir: (.[4:] | join("\t")), running: true,
attached: (.[1] | tonumber? // 0), activity: (.[2] | tonumber? // 0),
windows: (.[3] | tonumber? // 0)}] as $live
| ($live | map(.name)) as $liveNames
| {default: $dflt, root: $root,
sessions: (
@@ -82,7 +83,7 @@ cmd_list() {
claude_session_id: (.value.claude_session_id // null)}))
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}),
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: .[1]}
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
}
@@ -143,19 +144,23 @@ cmd_rename() {
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
# Forget first: killing the session SIGHUPs us when run from inside it.
dd_registry_remove "$1"
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
fi
dd_registry_remove "$1"
}
# GET $DEVDESK_GITEA_URL/api/v1<path>; token optional (private repos only).
gitea_get() {
local auth=()
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
auth=(-H "Authorization: token $(tr -d '[:space:]' <"$DEVDESK_GITEA_TOKEN_FILE")")
fi
curl -fsS --max-time 4 "${auth[@]}" "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null
# The token goes in via a curl config on stdin, never on the command line (ps).
{
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: token %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GITEA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 4 -K - "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null
}
# owner/repo when REMOTE points at our Gitea (https or ssh, any configured host), else nothing.
@@ -177,7 +182,9 @@ cmd_repo() {
dd_valid_name "$1" || dd_die "invalid session name: $1"
local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null
dir=$(dd_resolve_dir "$1")
g=(git -c safe.directory='*' -C "$dir")
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
# No safe.directory override: repos owned by other users stay untrusted.
g=(git --no-optional-locks -C "$dir")
if ! "${g[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
jq -cMn --arg s "$1" --arg dir "$dir" '{session: $s, dir: $dir, git: false}'
return
@@ -189,7 +196,7 @@ cmd_repo() {
upstream=true
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
fi
remote=$("${g[@]}" remote get-url origin 2>/dev/null || true)
remote=$("${g[@]}" remote get-url origin 2>/dev/null | sed -E 's#^([a-z+]+://)[^/@]*@#\1#' || true)
[ -n "$remote" ] && slug=$(gitea_slug "$remote")
if [ -n "$slug" ] && [ -n "$sha" ]; then
ci=$(gitea_get "/repos/$slug/commits/$sha/status" |
@@ -216,11 +223,13 @@ cmd_alerts() {
jq -cMn '{configured: false, alerts: []}'
return
fi
local auth=() raw
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
auth=(-H "Authorization: Bearer $(tr -d '[:space:]' <"$DEVDESK_GRAFANA_TOKEN_FILE")")
fi
if ! raw=$(curl -fsS --max-time 6 "${auth[@]}" "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then
local raw
if ! raw=$({
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 6 -K - "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then
jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}'
return
fi
@@ -255,7 +264,10 @@ cmd_upload() {
dir="$DEVDESK_STATE_DIR/uploads/$session"
mkdir -p "$dir"
find "$DEVDESK_STATE_DIR/uploads" -type f -mtime +7 -delete 2>/dev/null || true
dest="$dir/$(date +%Y%m%d-%H%M%S)-$name"
# Unique even for same-second uploads of the same name.
dest=$(mktemp --tmpdir="$dir" --suffix="-$name" "$(date +%Y%m%d-%H%M%S)-XXXX")
# A dropped connection SIGHUPs us mid-transfer: never leave a partial file.
trap 'rm -f "$dest"' HUP INT TERM
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
b64len=$(((size + 2) / 3 * 4))
stty raw -echo 2>/dev/null || true
@@ -267,6 +279,8 @@ cmd_upload() {
rm -f "$dest"
dd_die "upload incomplete ($actual of $size bytes)"
fi
trap - HUP INT TERM
chmod 0644 "$dest"
jq -cMn --arg p "$dest" '{ok: true, path: $p}'
}
@@ -305,6 +319,15 @@ cmd_attach() {
ok_json
return
;;
--existing)
# Reconnects: never re-create a session that was ended or renamed meanwhile.
shift
[ $# -eq 1 ] || dd_die "attach --existing takes exactly one session name"
dd_valid_name "$1" || dd_die "invalid session name: $1"
if ! { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } && [ -z "$(dd_registry_get "$1" dir)" ]; then
dd_die "session '$1' no longer exists (it was ended or renamed)"
fi
;;
-*) dd_die "unknown option: $1" ;;
esac
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
+1 -1
View File
@@ -13,7 +13,7 @@ args=(
--writable # ttyd >= 1.7.4 is read-only by default
--url-arg # ?arg=<session> becomes `devdesk attach <session>`
--check-origin # refuse cross-site WebSocket connections
--max-clients "${DEVDESK_MAX_CLIENTS:-8}"
--max-clients "${DEVDESK_MAX_CLIENTS:-32}"
--credential "$DEVDESK_CREDENTIAL" # injected by NPM as an Authorization header
--index "${DEVDESK_INDEX:-/usr/share/devdesk/index.html}"
--debug "${DEVDESK_TTYD_LOG_LEVEL:-3}" # errors+warnings only: notice level would log the credential
+3 -3
View File
@@ -6,10 +6,10 @@ set -s escape-time 10 # Esc reaches Claude (interrupt) immediat
set -s focus-events on
set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser clipboard
set -s default-terminal "tmux-256color"
set -as terminal-features ",xterm-256color:RGB"
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
set -g history-limit 50000
set -g mouse on # wheel scrolls history; Shift+drag selects natively
set -g allow-passthrough on
set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
set -g renumber-windows on
# --- bells reach the browser (Claude's terminal_bell notifications) ----------
@@ -29,7 +29,7 @@ set -g status-position bottom
set -g status-style "bg=#060c14,fg=#7fa3bf"
set -g status-left "#[fg=#FF6B00,bold] #S #[fg=#3e607a,nobold]│ "
set -g status-left-length 40
set -g status-right "#[fg=#3e607a]devdesk #[fg=#00D4FF]%H:%M "
set -g status-right ""
set -g window-status-format " #I:#W "
set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W "
set -g window-status-bell-style "fg=#FFB300,bold"
+52
View File
@@ -295,6 +295,58 @@ claude_args() {
[ "$status" -ne 0 ]
}
@test "end run from inside the session leaves no ghost registry entry (#3)" {
start_server
devdesk new alpha
tmux -S "$DEVDESK_SOCKET" send-keys -t =alpha:shell "$REPO/bin/devdesk end alpha" Enter
for _ in $(seq 1 50); do
tmux -S "$DEVDESK_SOCKET" has-session -t =alpha 2>/dev/null || break
sleep 0.1
done
run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha
[ "$status" -ne 0 ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
}
@test "attach --existing refuses sessions that were ended or renamed (#4)" {
start_server
run devdesk attach --existing ghost
[ "$status" -ne 0 ]
[[ $output == *"no longer exists"* ]]
run devdesk attach --existing ../x
[ "$status" -ne 0 ]
}
@test "same-second uploads of the same name get distinct files (#6)" {
printf 'one' | base64 -w0 >"$TMP/a.b64"
printf 'two' | base64 -w0 >"$TMP/b.b64"
p1=$(devdesk attach --upload alpha image.png 3 <"$TMP/a.b64" | tail -n1 | jq -r .path)
p2=$(devdesk attach --upload alpha image.png 3 <"$TMP/b.b64" | tail -n1 | jq -r .path)
[ "$p1" != "$p2" ]
[ "$(cat "$p1")" = one ]
[ "$(cat "$p2")" = two ]
[[ $p1 == *-image.png ]]
}
@test "a tab in a project path does not break --list (#10)" {
mkdir -p "$DEVDESK_PROJECTS_ROOT/odd name"
start_server
run devdesk attach --list
[ "$status" -eq 0 ]
jq -e '.projects | map(.dir) | any(endswith("odd\tname"))' <<<"$output" >/dev/null
}
@test "repo hides credentials embedded in the remote URL (#11)" {
export DEVDESK_GITEA_URL=http://127.0.0.1:9
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
git -C "$repo" remote add origin https://jared:hunter2@example.com/x/y.git
run devdesk repo alpha
[ "$status" -eq 0 ]
[ "$(jq -r .remote <<<"$output")" = https://example.com/x/y.git ]
[[ $output != *hunter2* ]]
}
@test "web actions run through attach and answer with JSON" {
start_server
run devdesk attach --new web1 alpha
+7 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
parseListOutput, parseTitle, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
parseListOutput, parseTitle, pasteSafe, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
} from '../web/src/protocol.js';
const text = (bytes) => new TextDecoder().decode(bytes);
@@ -80,3 +80,9 @@ test('flow control pauses after highWater pending writes and resumes below lowWa
assert.equal(flow.release(), null); // 1
assert.equal(flow.release(), Client.RESUME); // 0
});
test('untrusted text loses control characters before it is typed (#2)', () => {
assert.equal(pasteSafe('ok\nrm -rf /\r\x1b[31mred\x07'), 'ok rm -rf / [31mred ');
assert.equal(pasteSafe('plain text'), 'plain text');
assert.equal(pasteSafe(null), '');
});
+47 -12
View File
@@ -94,9 +94,28 @@ export class TtydConnection {
}
}
// Side-channel commands each occupy a ttyd client slot and a server process:
// run at most two at a time (devdesk#8).
let active = 0;
const waiting = [];
async function limited(task) {
if (active >= 2) await new Promise((resolve) => waiting.push(resolve));
active += 1;
try {
return await task();
} finally {
active -= 1;
waiting.shift()?.();
}
}
// Run a short-lived `devdesk attach <args…>` and return its output as text.
// ttyd is the only server-side endpoint, so session actions travel this way.
export async function runCommand(args, timeoutMs = 8000) {
export function runCommand(args, timeoutMs = 8000) {
return limited(() => runCommandNow(args, timeoutMs));
}
async function runCommandNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
@@ -139,31 +158,47 @@ function toBase64(bytes) {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const UPLOAD_MAX = 25 * 1024 * 1024;
// Upload a file into the session's upload folder through ttyd: wait for the
// server's ready line, stream the file as base64 input, read back {ok, path}.
export async function uploadFile(session, file, onProgress) {
// server's ready line, stream the file as base64 input in slices (never the
// whole file in memory), read back {ok, path}. Fails on 20 s of inactivity
// rather than a fixed total, so slow links still work (devdesk#9).
export function uploadFile(session, file, onProgress) {
if (!file.size) return Promise.reject(new Error('file is empty'));
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
return limited(() => uploadNow(session, file, onProgress));
}
async function uploadNow(session, file, onProgress) {
const token = await fetchToken();
const bytes = new Uint8Array(await file.arrayBuffer());
const b64 = toBase64(bytes);
const name = (file.name || 'pasted-image.png').slice(-100);
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(bytes.length)]), ['tty']);
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']);
socket.binaryType = 'arraybuffer';
const decoder = new TextDecoder();
let output = '';
let sending = false;
const timer = setTimeout(() => socket.close(), 30000 + bytes.length / 50);
let idle = 0;
const touch = () => {
clearTimeout(idle);
idle = setTimeout(() => socket.close(), 20000);
};
const send = async () => {
const chunk = 48 * 1024;
for (let i = 0; i < b64.length; i += chunk) {
for (let offset = 0; offset < file.size; offset += slice) {
while (socket.bufferedAmount > 1 << 20) await sleep(20);
if (socket.readyState !== WebSocket.OPEN) return;
socket.send(encodeInput(b64.slice(i, i + chunk)));
onProgress?.(Math.min(1, (i + chunk) / b64.length));
const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer());
socket.send(encodeInput(toBase64(bytes)));
touch();
onProgress?.(Math.min(1, (offset + slice) / file.size));
}
};
touch();
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
touch();
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
output += decoder.decode(payload, { stream: true });
@@ -174,7 +209,7 @@ export async function uploadFile(session, file, onProgress) {
};
socket.onerror = () => {};
socket.onclose = () => {
clearTimeout(timer);
clearTimeout(idle);
const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''));
try {
const result = parseListOutput(text);
+17 -11
View File
@@ -3,7 +3,7 @@ import { createTerminal, loadFonts } from './term.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, sessionFromSearch } from './protocol.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderContext } from './context.js';
@@ -246,7 +246,9 @@ function updateUrl(session) {
// ---------------------------------------------------------------- connection
function connect(session) {
// reconnect: automatic re-attach after a drop. It must not re-create a session
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
function connect(session, { reconnect = false } = {}) {
clearTimeout(state.retryTimer);
state.conn?.dispose();
state.session = session;
@@ -260,7 +262,7 @@ function connect(session) {
term.reset();
const conn = new TtydConnection({
arg: session,
arg: session && reconnect ? ['--existing', session] : session,
size: () => ({ cols: term.cols, rows: term.rows }),
onOpen: () => {
state.openedAt = Date.now();
@@ -353,13 +355,13 @@ function scheduleReconnect() {
showOverlay({
title: 'Reconnecting',
text: `Connection lost; Claude keeps running on the server. Retrying in ${seconds}s (attempt ${state.attempts}).`,
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session) }, pickerAction],
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session, { reconnect: true }) }, pickerAction],
});
state.retryTimer = setTimeout(() => connect(state.session), delay);
state.retryTimer = setTimeout(() => connect(state.session, { reconnect: true }), delay);
}
function retrySoon() {
if (state.retryTimer && !state.conn?.isOpen) connect(state.session);
if (state.retryTimer && !state.conn?.isOpen) connect(state.session, { reconnect: true });
}
// ---------------------------------------------------------------- titles & bell
@@ -474,6 +476,10 @@ function wireChrome() {
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts();
}, 30 * 1000);
// Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8).
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
}, 60 * 1000);
ui.attention.addEventListener('click', () => {
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
});
@@ -500,6 +506,7 @@ async function boot() {
term.onBell(() => {
notify.ring(state.session, ui.paneTitle.textContent);
refreshSoon(); // Claude finished a turn: context numbers just changed
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
});
let fitFrame = 0;
@@ -537,14 +544,13 @@ async function boot() {
ui.sessionChip.textContent = to;
sessionsPanel.setCurrent(to);
},
onData: () => {
showContext();
refreshRepo();
},
onData: () => showContext(),
},
});
bindAskClaude($('sidebar'), (text) => {
state.conn?.sendInput(text);
// Alert text is untrusted: no control characters (a newline would run it in a
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
term.paste(pasteSafe(text));
term.focus();
});
wireChrome();
+8
View File
@@ -67,6 +67,14 @@ export function slugify(text) {
.replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64);
}
// Untrusted text (e.g. Grafana alert annotations) typed into a terminal: drop
// every control character so it can't run a command (newline in a shell),
// submit early (CR) or inject escape sequences (devdesk#2).
export function pasteSafe(text) {
// eslint-disable-next-line no-control-regex
return String(text ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ');
}
export function stripAnsi(text) {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '');
+14 -2
View File
@@ -3,7 +3,17 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { Base64, ClipboardAddon } from '@xterm/addon-clipboard';
// Terminal output (tmux copy-mode, Claude) may *write* the browser clipboard
// via OSC 52, but must never read it: anything printed could exfiltrate it (devdesk#7).
const writeOnlyClipboard = {
readText: () => '',
writeText: (selection, text) => {
if (selection === 'c') return navigator.clipboard?.writeText(text).catch(() => {});
return undefined;
},
};
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
export const THEME = Object.freeze({
@@ -62,7 +72,7 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
term.loadAddon(new ClipboardAddon()); // OSC 52 from tmux copy-mode / Claude
term.loadAddon(new ClipboardAddon(new Base64(), writeOnlyClipboard));
term.loadAddon(new WebLinksAddon((event, uri) => {
event.preventDefault();
window.open(uri, '_blank', 'noopener');
@@ -90,6 +100,8 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
term.open(container);
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());