Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
);
}

Expand Down
70 changes: 64 additions & 6 deletions crates/openshell-supervisor-process/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>, bool) {
let mut forward = Vec::with_capacity(data.len() + usize::from(*prefix_pending));
Expand All @@ -37,6 +38,9 @@ fn filter_main_detach_sequence(prefix_pending: &mut bool, data: &[u8]) -> (Vec<u
forward.push(MAIN_DETACH_PREFIX);
*prefix_pending = false;
}
if byte == MAIN_DETACH_EOF {
return (forward, true);
}
if byte == MAIN_DETACH_PREFIX {
*prefix_pending = true;
} else {
Expand Down Expand Up @@ -711,7 +715,7 @@ impl russh::server::Handler for SshHandler {
.extended_data(
channel,
1,
format!("openshell: {error}; attached read-only; press Ctrl-C to exit{line_ending}").into_bytes(),
format!("openshell: {error}; attached read-only; press Ctrl-C or Ctrl-D to exit{line_ending}").into_bytes(),
)
.await;
}
Expand Down Expand Up @@ -1501,15 +1505,20 @@ mod tests {

#[tokio::test]
async fn main_attachment_occupied_stdin_still_attaches_read_only() {
assert_read_only_attachment(false, "\n").await;
assert_read_only_attachment(false, "\n", 0x03).await;
}

#[tokio::test]
async fn main_attachment_read_only_pty_warning_returns_cursor_to_start_of_line() {
assert_read_only_attachment(true, "\r\n").await;
assert_read_only_attachment(true, "\r\n", 0x03).await;
}

async fn assert_read_only_attachment(terminal: bool, line_ending: &str) {
#[tokio::test]
async fn main_attachment_read_only_ctrl_d_detaches_without_releasing_owner_lease() {
assert_read_only_attachment(false, "\n", MAIN_DETACH_EOF).await;
}

async fn assert_read_only_attachment(terminal: bool, line_ending: &str, detach_key: u8) {
let main_session = MainSession::inert();
let (owner, _input) = main_session.acquire_input().unwrap();
let client = main_test_client(Some(main_session.clone())).await;
Expand Down Expand Up @@ -1537,14 +1546,17 @@ mod tests {
assert_eq!(
String::from_utf8_lossy(&data),
format!(
"openshell: canonical main process already has an input owner; attached read-only; press Ctrl-C to exit{line_ending}"
"openshell: canonical main process already has an input owner; attached read-only; press Ctrl-C or Ctrl-D to exit{line_ending}"
)
);
}
event => 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!(
Expand Down Expand Up @@ -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();
Expand Down
19 changes: 10 additions & 9 deletions docs/how-it-works/sandboxes/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down
16 changes: 8 additions & 8 deletions skills/openshell-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>
Expand Down Expand Up @@ -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
Expand Down
Loading