Skip to content

feat(cli): add sandbox exec subcommand with TTY support#752

Open
drew wants to merge 2 commits intomainfrom
feat/sandbox-exec-cli
Open

feat(cli): add sandbox exec subcommand with TTY support#752
drew wants to merge 2 commits intomainfrom
feat/sandbox-exec-cli

Conversation

@drew
Copy link
Copy Markdown
Collaborator

@drew drew commented Apr 4, 2026

Summary

Add openshell sandbox exec subcommand that runs a command inside an already-running sandbox via the gRPC ExecSandbox streaming RPC, with full --tty/--no-tty support and hardened stdin handling.

Related Issue

Closes #750

Changes

  • proto/openshell.proto: Add bool tty = 7 field to ExecSandboxRequest for PTY allocation
  • crates/openshell-cli/src/main.rs: Add Exec variant to SandboxCommands with --name, --workdir, --env, --timeout, --tty/--no-tty flags; add dispatch in main()
  • crates/openshell-cli/src/run.rs: Implement sandbox_exec_grpc() — resolves sandbox, validates phase is Ready, parses env pairs, reads stdin via spawn_blocking with 4 MiB size limit, streams gRPC output to stdout/stderr, returns remote exit code
  • crates/openshell-server/src/grpc.rs: Plumb tty field through stream_exec_over_sshrun_exec_with_russh; call channel.request_pty() before channel.exec() when TTY is requested

Design decisions

  • --name as flag not positional: The issue spec shows <NAME> as positional, but combining a positional name with trailing_var_arg for command creates parsing ambiguity when relying on the last-used sandbox fallback. The --name/-n flag resolves this cleanly.
  • Client-side phase check: The server already checks SandboxPhase::Ready, but the client now provides a user-friendly error message instead of a raw gRPC FAILED_PRECONDITION.
  • save_last_sandbox after exec: Other subcommands (e.g., Connect) save after the operation succeeds. Moved to match that pattern.
  • 4 MiB stdin limit: Prevents the CLI from reading unbounded piped input into memory before the server rejects it.

Testing

  • mise run pre-commit passes (license check failure is pre-existing local tmp/ file, unrelated)
  • All 743 unit tests pass
  • E2E tests added/updated (not applicable — no changes under e2e/)

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable)

drew added 2 commits March 16, 2026 07:27
Add a new 'openshell sandbox exec' command that executes commands inside
running sandboxes using the gRPC ExecSandbox streaming endpoint (the same
endpoint the Python SDK uses). This provides a lightweight alternative to
SSH-based execution for non-interactive command runs.

Key features:
- Real-time stdout/stderr streaming to the terminal
- Exit code propagation (CLI exits with the remote command's exit code)
- Piped stdin support (automatically detected when stdin is not a TTY)
- Environment variable overrides via --env/-e flags
- Working directory override via --workdir
- Configurable timeout (exit code 124 on timeout, matching Unix convention)
- Last-used sandbox fallback when --name is omitted
Add missing --tty/--no-tty flag required by the issue spec, plumbed
through proto, CLI, and server-side PTY allocation over SSH.

Harden the implementation with a client-side sandbox phase check,
a 4 MiB stdin size limit to prevent OOM, and spawn_blocking for
stdin reads to avoid blocking the async runtime. Move
save_last_sandbox after exec succeeds for consistency.

Closes #750
@drew drew requested a review from a team as a code owner April 4, 2026 02:01
@drew drew added the area:cli CLI-related work label Apr 4, 2026
@drew drew self-assigned this Apr 4, 2026
tokio::task::spawn_blocking(|| {
let mut buf = Vec::new();
std::io::stdin()
.read_to_end(&mut buf)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unbound read into memory before checking the size, could we read MAX_STDIN_PAYLOAD + 1 instead or something?

}

// Parse KEY=VALUE environment pairs into a HashMap.
let environment: HashMap<String, String> = env_pairs
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the behavior of environment variables here? Do they get set as env variables only within the scope of the running process? Would it be possible to replace a global sandbox env (such as an API key?) and start a long running process that exposes sensitive env vars for a long time?

Copy link
Copy Markdown
Collaborator

@johntmyers johntmyers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One nit on unbounded read and generally curious if we run any risks on this replacing how long running processes should run esp wrt env var injection

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli CLI-related work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(cli): add openshell sandbox exec subcommand

2 participants