fix: v0.5.0 security and robustness fixes from the 2026-10-07 review
- 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:
+43
-20
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user