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
+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);