Fall back to xterm-256color when the client TERM isn't on the server
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

dosh propagates the client's TERM, but a server lacking that terminal's
terminfo (e.g. xterm-ghostty, xterm-kitty) made tmux/vim fail with
"missing or unsuitable terminal". build_shell_command now keeps the
requested TERM only when its terminfo exists on this host, otherwise falls
back to xterm-256color so remote apps always work. Affects both the
in-process and holder-spawned shells.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DuProcess
2026-06-14 17:03:06 -04:00
parent 41256b66b7
commit eec8ef0a02
+65 -2
View File
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd}; use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -148,16 +148,65 @@ pub fn spawn_pty_session(
}) })
} }
/// Whether this host has a terminfo entry for `term`, searching the standard
/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the
/// hashed (`78/xterm`) subdirectory layouts are checked.
fn terminfo_available(term: &str) -> bool {
let Some(first) = term.chars().next() else {
return false;
};
let letter = first.to_string();
let hashed = format!("{:x}", first as u32);
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(t) = std::env::var("TERMINFO")
&& !t.is_empty()
{
dirs.push(PathBuf::from(t));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".terminfo"));
}
if let Ok(td) = std::env::var("TERMINFO_DIRS") {
for d in td.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d));
}
}
for d in [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
"/usr/share/lib/terminfo",
] {
dirs.push(PathBuf::from(d));
}
dirs.iter()
.any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists())
}
/// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process /// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process
/// `spawn_pty_session` and the out-of-process holder, so a persistent session's /// `spawn_pty_session` and the out-of-process holder, so a persistent session's
/// environment matches a non-persistent one. /// environment matches a non-persistent one.
pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder { pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder {
let mut cmd = CommandBuilder::new(shell); let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor"); cmd.env("COLORTERM", "truecolor");
for (name, value) in env { for (name, value) in env {
cmd.env(name, value); cmd.env(name, value);
} }
// The client's TERM is propagated, but a server that lacks that terminal's
// terminfo entry (e.g. xterm-ghostty, xterm-kitty) breaks ncurses apps like
// tmux/vim with "missing or unsuitable terminal". Keep the requested TERM
// only when this host actually has its terminfo; otherwise fall back to a
// universally available entry so remote apps always work.
let term = match env
.iter()
.find(|(n, _)| n == "TERM")
.map(|(_, v)| v.as_str())
{
Some(requested) if terminfo_available(requested) => requested.to_string(),
_ => "xterm-256color".to_string(),
};
cmd.env("TERM", term);
cmd.env("SHELL", shell); cmd.env("SHELL", shell);
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {
cmd.env("HOME", home.as_os_str()); cmd.env("HOME", home.as_os_str());
@@ -235,3 +284,17 @@ fn spawn_reader_thread(
.context("spawn pty reader")?; .context("spawn pty reader")?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminfo_available_detects_known_and_unknown() {
// A near-universal entry should be present on any host with ncurses.
assert!(terminfo_available("xterm") || terminfo_available("xterm-256color"));
// Bogus / empty names must report missing so we fall back.
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
assert!(!terminfo_available(""));
}
}