Skip to content

Commit 8c9f987

Browse files
committed
fix(sandbox): track PTY state per SSH channel to fix terminal resize
window_change_request previously applied resize to a single shared pty_master field, ignoring the channel ID. When multiple channels were open, resize requests would affect the wrong terminal or be lost entirely. Replace the flat pty_master/input_sender/pty_request fields in SshHandler with a HashMap<ChannelId, ChannelState> so each channel tracks its own PTY master, input sender, and pending PTY request. This ensures window_change_request, data, and channel_eof all operate on the correct channel. Closes #543
1 parent f37b69b commit 8c9f987

1 file changed

Lines changed: 178 additions & 27 deletions

File tree

  • crates/openshell-sandbox/src

crates/openshell-sandbox/src/ssh.rs

Lines changed: 178 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,26 @@ fn hmac_sha256(key: &[u8], data: &[u8]) -> String {
263263
hex::encode(result)
264264
}
265265

266+
/// Per-channel state for tracking PTY resources and I/O senders.
267+
///
268+
/// Each SSH channel gets its own PTY master (if a PTY was requested) and input
269+
/// sender. This allows `window_change_request` to resize the correct PTY when
270+
/// multiple channels are open simultaneously (e.g. parallel shells, shell +
271+
/// sftp, etc.).
272+
struct ChannelState {
273+
input_sender: Option<mpsc::Sender<Vec<u8>>>,
274+
pty_master: Option<std::fs::File>,
275+
pty_request: Option<PtyRequest>,
276+
}
277+
266278
struct SshHandler {
267279
policy: SandboxPolicy,
268280
workdir: Option<String>,
269281
netns_fd: Option<RawFd>,
270282
proxy_url: Option<String>,
271283
ca_file_paths: Option<Arc<(PathBuf, PathBuf)>>,
272284
provider_env: HashMap<String, String>,
273-
input_sender: Option<mpsc::Sender<Vec<u8>>>,
274-
pty_master: Option<std::fs::File>,
275-
pty_request: Option<PtyRequest>,
285+
channels: HashMap<ChannelId, ChannelState>,
276286
}
277287

278288
impl SshHandler {
@@ -291,9 +301,7 @@ impl SshHandler {
291301
proxy_url,
292302
ca_file_paths,
293303
provider_env,
294-
input_sender: None,
295-
pty_master: None,
296-
pty_request: None,
304+
channels: HashMap::new(),
297305
}
298306
}
299307
}
@@ -388,7 +396,15 @@ impl russh::server::Handler for SshHandler {
388396
_modes: &[(russh::Pty, u32)],
389397
session: &mut Session,
390398
) -> Result<(), Self::Error> {
391-
self.pty_request = Some(PtyRequest {
399+
let state = self
400+
.channels
401+
.entry(channel)
402+
.or_insert_with(|| ChannelState {
403+
input_sender: None,
404+
pty_master: None,
405+
pty_request: None,
406+
});
407+
state.pty_request = Some(PtyRequest {
392408
term: term.to_string(),
393409
col_width,
394410
row_height,
@@ -401,21 +417,25 @@ impl russh::server::Handler for SshHandler {
401417

402418
async fn window_change_request(
403419
&mut self,
404-
_channel: ChannelId,
420+
channel: ChannelId,
405421
col_width: u32,
406422
row_height: u32,
407423
pixel_width: u32,
408424
pixel_height: u32,
409425
_session: &mut Session,
410426
) -> Result<(), Self::Error> {
411-
if let Some(master) = self.pty_master.as_ref() {
412-
let winsize = Winsize {
413-
ws_row: to_u16(row_height.max(1)),
414-
ws_col: to_u16(col_width.max(1)),
415-
ws_xpixel: to_u16(pixel_width),
416-
ws_ypixel: to_u16(pixel_height),
417-
};
418-
let _ = unsafe_pty::set_winsize(master.as_raw_fd(), winsize);
427+
if let Some(state) = self.channels.get(&channel) {
428+
if let Some(master) = state.pty_master.as_ref() {
429+
let winsize = Winsize {
430+
ws_row: to_u16(row_height.max(1)),
431+
ws_col: to_u16(col_width.max(1)),
432+
ws_xpixel: to_u16(pixel_width),
433+
ws_ypixel: to_u16(pixel_height),
434+
};
435+
if let Err(e) = unsafe_pty::set_winsize(master.as_raw_fd(), winsize) {
436+
warn!("failed to resize PTY for channel {channel:?}: {e}");
437+
}
438+
}
419439
}
420440
Ok(())
421441
}
@@ -474,7 +494,15 @@ impl russh::server::Handler for SshHandler {
474494
self.ca_file_paths.clone(),
475495
&self.provider_env,
476496
)?;
477-
self.input_sender = Some(input_sender);
497+
let state = self
498+
.channels
499+
.entry(channel)
500+
.or_insert_with(|| ChannelState {
501+
input_sender: None,
502+
pty_master: None,
503+
pty_request: None,
504+
});
505+
state.input_sender = Some(input_sender);
478506
} else {
479507
warn!(subsystem = name, "unsupported subsystem requested");
480508
session.channel_failure(channel)?;
@@ -499,26 +527,30 @@ impl russh::server::Handler for SshHandler {
499527

500528
async fn data(
501529
&mut self,
502-
_channel: ChannelId,
530+
channel: ChannelId,
503531
data: &[u8],
504532
_session: &mut Session,
505533
) -> Result<(), Self::Error> {
506-
if let Some(sender) = self.input_sender.as_ref() {
507-
let _ = sender.send(data.to_vec());
534+
if let Some(state) = self.channels.get(&channel) {
535+
if let Some(sender) = state.input_sender.as_ref() {
536+
let _ = sender.send(data.to_vec());
537+
}
508538
}
509539
Ok(())
510540
}
511541

512542
async fn channel_eof(
513543
&mut self,
514-
_channel: ChannelId,
544+
channel: ChannelId,
515545
_session: &mut Session,
516546
) -> Result<(), Self::Error> {
517547
// Drop the input sender so the stdin writer thread sees a
518548
// disconnected channel and closes the child's stdin pipe. This
519549
// is essential for commands like `cat | tar xf -` which need
520550
// stdin EOF to know the input stream is complete.
521-
self.input_sender.take();
551+
if let Some(state) = self.channels.get_mut(&channel) {
552+
state.input_sender.take();
553+
}
522554
Ok(())
523555
}
524556
}
@@ -530,7 +562,15 @@ impl SshHandler {
530562
handle: Handle,
531563
command: Option<String>,
532564
) -> anyhow::Result<()> {
533-
if let Some(pty) = self.pty_request.take() {
565+
let state = self
566+
.channels
567+
.entry(channel)
568+
.or_insert_with(|| ChannelState {
569+
input_sender: None,
570+
pty_master: None,
571+
pty_request: None,
572+
});
573+
if let Some(pty) = state.pty_request.take() {
534574
// PTY was requested — allocate a real PTY (interactive shell or
535575
// exec that explicitly asked for a terminal).
536576
let (pty_master, input_sender) = spawn_pty_shell(
@@ -545,8 +585,8 @@ impl SshHandler {
545585
self.ca_file_paths.clone(),
546586
&self.provider_env,
547587
)?;
548-
self.pty_master = Some(pty_master);
549-
self.input_sender = Some(input_sender);
588+
state.pty_master = Some(pty_master);
589+
state.input_sender = Some(input_sender);
550590
} else {
551591
// No PTY requested — use plain pipes so stdout/stderr are
552592
// separate and output has clean LF line endings. This is the
@@ -562,7 +602,7 @@ impl SshHandler {
562602
self.ca_file_paths.clone(),
563603
&self.provider_env,
564604
)?;
565-
self.input_sender = Some(input_sender);
605+
state.input_sender = Some(input_sender);
566606
}
567607
Ok(())
568608
}
@@ -999,7 +1039,7 @@ mod unsafe_pty {
9991039

10001040
#[allow(unsafe_code)]
10011041
pub fn set_winsize(fd: RawFd, winsize: Winsize) -> std::io::Result<()> {
1002-
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, winsize) };
1042+
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) };
10031043
if rc != 0 {
10041044
return Err(std::io::Error::last_os_error());
10051045
}
@@ -1404,4 +1444,115 @@ mod tests {
14041444
assert!(!is_loopback_host("not-an-ip"));
14051445
assert!(!is_loopback_host("[]"));
14061446
}
1447+
1448+
// -----------------------------------------------------------------------
1449+
// Per-channel PTY state tests (#543)
1450+
// -----------------------------------------------------------------------
1451+
1452+
#[test]
1453+
fn set_winsize_applies_to_correct_pty() {
1454+
// Verify that set_winsize applies to a specific PTY master FD,
1455+
// which is the mechanism that per-channel tracking relies on.
1456+
// With the old single-pty_master design, a window_change_request
1457+
// for channel N would resize whatever PTY was stored last —
1458+
// potentially belonging to a different channel.
1459+
let pty_a = openpty(None, None).expect("openpty a");
1460+
let pty_b = openpty(None, None).expect("openpty b");
1461+
let master_a = std::fs::File::from(pty_a.master);
1462+
let master_b = std::fs::File::from(pty_b.master);
1463+
let fd_a = master_a.as_raw_fd();
1464+
let fd_b = master_b.as_raw_fd();
1465+
assert_ne!(fd_a, fd_b, "two PTYs must have distinct FDs");
1466+
1467+
// Close the slave ends to avoid leaking FDs in the test.
1468+
drop(std::fs::File::from(pty_a.slave));
1469+
drop(std::fs::File::from(pty_b.slave));
1470+
1471+
// Resize only PTY B.
1472+
let winsize_b = Winsize {
1473+
ws_row: 50,
1474+
ws_col: 120,
1475+
ws_xpixel: 0,
1476+
ws_ypixel: 0,
1477+
};
1478+
unsafe_pty::set_winsize(fd_b, winsize_b).expect("set_winsize on PTY B");
1479+
1480+
// Resize PTY A to a different size.
1481+
let winsize_a = Winsize {
1482+
ws_row: 24,
1483+
ws_col: 80,
1484+
ws_xpixel: 0,
1485+
ws_ypixel: 0,
1486+
};
1487+
unsafe_pty::set_winsize(fd_a, winsize_a).expect("set_winsize on PTY A");
1488+
1489+
// Read back sizes via ioctl to verify independence.
1490+
let mut actual_a: libc::winsize = unsafe { std::mem::zeroed() };
1491+
let mut actual_b: libc::winsize = unsafe { std::mem::zeroed() };
1492+
#[allow(unsafe_code)]
1493+
unsafe {
1494+
libc::ioctl(fd_a, libc::TIOCGWINSZ, &mut actual_a);
1495+
libc::ioctl(fd_b, libc::TIOCGWINSZ, &mut actual_b);
1496+
}
1497+
1498+
assert_eq!(actual_a.ws_row, 24, "PTY A should be 24 rows");
1499+
assert_eq!(actual_a.ws_col, 80, "PTY A should be 80 cols");
1500+
assert_eq!(actual_b.ws_row, 50, "PTY B should be 50 rows");
1501+
assert_eq!(actual_b.ws_col, 120, "PTY B should be 120 cols");
1502+
}
1503+
1504+
#[test]
1505+
fn channel_state_independent_input_senders() {
1506+
// Verify that each channel gets its own input sender so that
1507+
// data() and channel_eof() affect only the targeted channel.
1508+
// Uses the ChannelState struct directly without needing ChannelId
1509+
// constructors.
1510+
let (tx_a, rx_a) = mpsc::channel::<Vec<u8>>();
1511+
let (tx_b, rx_b) = mpsc::channel::<Vec<u8>>();
1512+
1513+
let mut state_a = ChannelState {
1514+
input_sender: Some(tx_a),
1515+
pty_master: None,
1516+
pty_request: None,
1517+
};
1518+
let state_b = ChannelState {
1519+
input_sender: Some(tx_b),
1520+
pty_master: None,
1521+
pty_request: None,
1522+
};
1523+
1524+
// Send data to channel A only.
1525+
state_a
1526+
.input_sender
1527+
.as_ref()
1528+
.unwrap()
1529+
.send(b"hello-a".to_vec())
1530+
.unwrap();
1531+
// Send data to channel B only.
1532+
state_b
1533+
.input_sender
1534+
.as_ref()
1535+
.unwrap()
1536+
.send(b"hello-b".to_vec())
1537+
.unwrap();
1538+
1539+
assert_eq!(rx_a.recv().unwrap(), b"hello-a");
1540+
assert_eq!(rx_b.recv().unwrap(), b"hello-b");
1541+
1542+
// EOF on channel A (drop sender) should not affect channel B.
1543+
state_a.input_sender.take();
1544+
assert!(
1545+
rx_a.recv().is_err(),
1546+
"channel A sender dropped, recv should fail"
1547+
);
1548+
1549+
// Channel B should still be functional.
1550+
state_b
1551+
.input_sender
1552+
.as_ref()
1553+
.unwrap()
1554+
.send(b"still-alive".to_vec())
1555+
.unwrap();
1556+
assert_eq!(rx_b.recv().unwrap(), b"still-alive");
1557+
}
14071558
}

0 commit comments

Comments
 (0)