diff --git a/architecture/sandbox.md b/architecture/sandbox.md index b1169ca64e..84f28f370a 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -704,7 +704,8 @@ sandbox workload directly. The relay supports: subsystem. The supervisor owns its retained PTY or pipes, a 1 MiB replay buffer, and a single stdin lease across client disconnects. Ctrl-C interrupts the foreground process. For read-only attachments, Ctrl-C only exits the - current viewer. + current viewer. Ctrl-D or Ctrl-P followed by Ctrl-Q detaches without closing + the canonical process stdin. - Supervised CLI attachment. After an established SSH transport fails, the CLI remains alive, requests a fresh SSH session from the gateway, and reattaches to the same canonical main process within a bounded recovery window. It does diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index e5f105919c..47f537faf7 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1717,7 +1717,7 @@ enum SandboxCommands { /// Connect to a sandbox. /// /// When no name is given, reconnects to the last-used sandbox. - /// Press Ctrl-P Ctrl-Q to disconnect without terminating the main process. + /// Press Ctrl-D or Ctrl-P Ctrl-Q to disconnect without terminating the main process. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Connect { /// Sandbox name (defaults to last-used sandbox). diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 204c8f6366..4811a2e3a5 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -640,7 +640,7 @@ async fn sandbox_connect_supervised( recovery_deadline = Some(Instant::now() + CONNECT_RECOVERY_TIMEOUT); retry_delay = CONNECT_RETRY_INITIAL_DELAY; eprintln!( - "Connection to sandbox lost; reconnecting. To disconnect, press Ctrl-P then Ctrl-Q after reattachment; press Ctrl-C while retrying to cancel." + "Connection to sandbox lost; reconnecting. To disconnect, press Ctrl-D or Ctrl-P then Ctrl-Q after reattachment; press Ctrl-C while retrying to cancel." ); } diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 01be34bf75..25c5cfdbbd 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -25,6 +25,7 @@ use tracing::warn; const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); const MAIN_DETACH_PREFIX: u8 = 0x10; const MAIN_DETACH_KEY: u8 = 0x11; +const MAIN_DETACH_EOF: u8 = 0x04; fn filter_main_detach_sequence(prefix_pending: &mut bool, data: &[u8]) -> (Vec, bool) { let mut forward = Vec::with_capacity(data.len() + usize::from(*prefix_pending)); @@ -37,6 +38,9 @@ fn filter_main_detach_sequence(prefix_pending: &mut bool, data: &[u8]) -> (Vec panic!("expected read-only warning, got {event:?}"), } assert!(main_session.acquire_input().is_err()); - channel.data(&b"ignored\x03also ignored"[..]).await.unwrap(); + let mut data = b"ignored".to_vec(); + data.push(detach_key); + data.extend_from_slice(b"also ignored"); + channel.data(&data[..]).await.unwrap(); assert_viewer_closed(&mut channel).await; assert!(!main_session.finished()); assert!( @@ -1603,6 +1615,52 @@ mod tests { main_session.release_input(owner); } + #[tokio::test] + async fn main_attachment_ctrl_d_detaches_without_closing_main_stdin() { + let (main_session, mut input) = MainSession::inert_with_input(); + let client = main_test_client(Some(main_session.clone())).await; + let mut channel = client.channel_open_session().await.unwrap(); + channel + .request_subsystem(true, "openshell-main") + .await + .unwrap(); + assert!(matches!( + next_main_event(&mut channel).await, + russh::ChannelMsg::Success + )); + + channel.data(&b"hello\x04ignored"[..]).await.unwrap(); + assert_eq!(input.recv().await.unwrap(), b"hello"); + assert_viewer_closed(&mut channel).await; + assert!(!main_session.finished()); + assert!(matches!( + input.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + let (owner, _) = main_session + .acquire_input() + .expect("detaching must release the input lease"); + main_session.release_input(owner); + } + + #[test] + fn main_detach_filter_preserves_prefix_before_ctrl_d() { + let mut prefix_pending = false; + assert_eq!( + filter_main_detach_sequence(&mut prefix_pending, b"\x10"), + (Vec::new(), false) + ); + assert_eq!( + filter_main_detach_sequence(&mut prefix_pending, b"\x04ignored"), + (vec![0x10], true) + ); + assert!(!prefix_pending); + assert_eq!( + filter_main_detach_sequence(&mut prefix_pending, b"\x10\x11"), + (Vec::new(), true) + ); + } + #[tokio::test] async fn main_attachment_input_owner_ctrl_c_reaches_main_stdin() { let (main_session, mut input) = MainSession::inert_with_input(); diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index 4fd762ce91..ad65629600 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -252,11 +252,11 @@ Attach to the canonical main process in a running sandbox: openshell sandbox connect my-sandbox ``` -Press `Ctrl-P`, then `Ctrl-Q` in sequence to disconnect. The main process keeps -running and its stdin stays open. A later `connect` attaches to the same process -instance and replays up to 1 MiB of recent output. One attachment owns stdin -at a time. Use `sandbox exec --tty -- /bin/bash -l` when you want a new -independent shell instead. +Press `Ctrl-D` or `Ctrl-P`, then `Ctrl-Q` in sequence to disconnect. The main +process keeps running and its stdin stays open. A later `connect` attaches to +the same process instance and replays up to 1 MiB of recent output. One +attachment owns stdin at a time. Use `sandbox exec --tty -- /bin/bash -l` when +you want a new independent shell instead. If an established connection is interrupted, for example when a laptop sleeps and wakes, the CLI obtains a new SSH session and reattaches to the same main @@ -265,11 +265,12 @@ authentication failures, sandbox lifecycle changes, and clean SSH exits are not retried. `Ctrl-C` retains its normal terminal behavior and interrupts the foreground -process. For read-only attachments, `Ctrl-C` only exits the current viewer. +process. For read-only attachments, `Ctrl-C` or `Ctrl-D` exits only the current +viewer. OpenSSH's `~.` escape reports the same status as a broken transport, so it -starts automatic recovery instead of exiting. After `~.`, use `Ctrl-P`, then -`Ctrl-Q` once OpenShell reattaches, or press `Ctrl-C` while the CLI is between -retry attempts to cancel recovery. +starts automatic recovery instead of exiting. After `~.`, use `Ctrl-D` or +`Ctrl-P`, then `Ctrl-Q` once OpenShell reattaches, or press `Ctrl-C` while the CLI +is between retry attempts to cancel recovery. Launch VS Code or Cursor directly into the sandbox workspace: diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index a2c548c251..d070466f76 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -108,7 +108,7 @@ The agent will be prompted interactively if credentials are missing. ### Step 4: Exit and clean up -Exit the sandbox shell (`exit` or Ctrl-D), then: +Exit the sandbox shell with `exit`, or detach with Ctrl-D, then: ```bash openshell sandbox delete @@ -364,13 +364,13 @@ that process running; reconnecting targets the same process instance and replays recent output. If an established SSH transport is interrupted, such as when a laptop sleeps and wakes, the CLI retries transient failures for up to 60 seconds and reattaches to that same process. Use `sandbox exec --tty -- /bin/bash -l` -for a new shell. Press `Ctrl-P`, then `Ctrl-Q` to disconnect without terminating -main. OpenSSH's `~.` escape looks like transport loss and therefore starts -automatic recovery; after it reattaches, use `Ctrl-P`, then `Ctrl-Q` to exit, or -press `Ctrl-C` between retry attempts to cancel recovery. When you own stdin, -`Ctrl-C` interrupts the foreground process. In a read-only attachment, `Ctrl-C` -exits the viewer and leaves main and other attachments running. Configure VS -Code Remote-SSH with: +for a new shell. Press `Ctrl-D` or `Ctrl-P`, then `Ctrl-Q` to disconnect without +terminating main. OpenSSH's `~.` escape looks like transport loss and therefore +starts automatic recovery; after it reattaches, use `Ctrl-D` or `Ctrl-P`, then +`Ctrl-Q` to exit, or press `Ctrl-C` between retry attempts to cancel recovery. +When you own stdin, `Ctrl-C` interrupts the foreground process. In a read-only +attachment, `Ctrl-C` or `Ctrl-D` exits the viewer and leaves main and other +attachments running. Configure VS Code Remote-SSH with: ```bash openshell sandbox ssh-config my-sandbox >> ~/.ssh/config