# Replace CLI exec_command with gRPC exec_in_sandbox

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace broken `exec_command` (CLI-based) with tested `exec_in_sandbox` (gRPC-based) throughout platform_store and sync.

**Architecture:** Bot already has `mtls_dir` and `sandbox_id` at startup. Thread them into sync and platform_store via a new `SandboxExec` struct that wraps gRPC client + sandbox_id. All callers use this instead of raw sandbox name strings.

**Tech Stack:** Rust, tonic gRPC, OpenShell proto

---

## File Structure

| Action | File | Responsibility |
|--------|------|----------------|
| Create | `crates/rightclaw/src/sandbox_exec.rs` | SandboxExec struct wrapping gRPC client + sandbox_id, exec method |
| Modify | `crates/rightclaw/src/lib.rs` | Add `pub mod sandbox_exec;` |
| Modify | `crates/rightclaw/src/platform_store.rs` | Replace `exec_command` calls with `SandboxExec::exec` |
| Modify | `crates/bot/src/sync.rs` | Accept `SandboxExec`, use it for exec calls |
| Modify | `crates/bot/src/lib.rs:340-350` | Create `SandboxExec`, pass to sync |
| Modify | `crates/rightclaw/src/openshell.rs` | Delete `exec_command` function |

---

### Task 1: Create SandboxExec wrapper

**Files:**
- Create: `crates/rightclaw/src/sandbox_exec.rs`
- Modify: `crates/rightclaw/src/lib.rs`

- [ ] **Step 1: Write test**

Add to `crates/rightclaw/src/sandbox_exec.rs`:

```rust
//! Sandbox command execution via gRPC.
//!
//! Wraps OpenShell gRPC client + sandbox_id into a clonable handle
//! that platform_store and sync use for executing commands inside sandboxes.

use crate::openshell_proto::openshell::v1::open_shell_client::OpenShellClient;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tonic::transport::Channel;

/// Handle for executing commands inside a sandbox via gRPC.
///
/// Clonable — wraps client in Arc<Mutex> so it can be shared
/// across sync and platform_store without ownership issues.
#[derive(Clone)]
pub struct SandboxExec {
    mtls_dir: PathBuf,
    sandbox_name: String,
    sandbox_id: String,
}

impl SandboxExec {
    /// Create a new SandboxExec handle.
    ///
    /// `mtls_dir`: path to mTLS certificates for gRPC connection.
    /// `sandbox_name`: e.g. "rightclaw-right".
    /// `sandbox_id`: UUID resolved via gRPC at bot startup.
    pub fn new(mtls_dir: PathBuf, sandbox_name: String, sandbox_id: String) -> Self {
        Self { mtls_dir, sandbox_name, sandbox_id }
    }

    /// Execute a command inside the sandbox. Returns (stdout, exit_code).
    ///
    /// Creates a fresh gRPC connection per call. This avoids holding a
    /// long-lived connection across async boundaries and is fine for
    /// the low call frequency of sync (every 5 min).
    pub async fn exec(&self, cmd: &[&str]) -> miette::Result<(String, i32)> {
        let mut client = crate::openshell::connect_grpc(&self.mtls_dir).await?;
        crate::openshell::exec_in_sandbox(&mut client, &self.sandbox_id, cmd).await
    }

    /// Sandbox name (for upload_file which uses CLI).
    pub fn sandbox_name(&self) -> &str {
        &self.sandbox_name
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sandbox_exec_is_clone() {
        // Compile-time check that SandboxExec is Clone
        fn assert_clone<T: Clone>() {}
        assert_clone::<SandboxExec>();
    }
}
```

- [ ] **Step 2: Add module to lib.rs**

```rust
pub mod sandbox_exec;
```

- [ ] **Step 3: Verify compiles**

Run: `devenv shell -- cargo check -p rightclaw`

- [ ] **Step 4: Commit**

```bash
git add crates/rightclaw/src/sandbox_exec.rs crates/rightclaw/src/lib.rs
git commit -m "feat: add SandboxExec wrapper for gRPC exec"
```

---

### Task 2: Update platform_store to use SandboxExec

**Files:**
- Modify: `crates/rightclaw/src/platform_store.rs`

- [ ] **Step 1: Replace exec_command with SandboxExec**

Change all public functions that currently take `sandbox: &str` to take `sbox: &crate::sandbox_exec::SandboxExec`:

- `deploy_file(sbox, name, content, hash, link_path, platform_prefix)`
- `deploy_directory(sbox, name, host_dir, hash, link_path, platform_prefix)`
- `gc_platform(sbox, active_targets)`
- `deploy_manifest(sbox, manifest)`
- `atomic_symlink(sbox, target, link_path)` (private)

Replace all `exec_command(sandbox, &[...])` with `sbox.exec(&[...])`.

Replace all `upload_file(sandbox, ...)` with `crate::openshell::upload_file(sbox.sandbox_name(), ...)`.

Remove `use crate::openshell::exec_command;` imports.

- [ ] **Step 2: Run tests**

Run: `devenv shell -- cargo test -p rightclaw platform_store`
Expected: unit tests pass (they don't use exec, only test hashing/manifest)

- [ ] **Step 3: Commit**

```bash
git add crates/rightclaw/src/platform_store.rs
git commit -m "refactor: platform_store uses SandboxExec instead of CLI exec_command"
```

---

### Task 3: Update sync to use SandboxExec

**Files:**
- Modify: `crates/bot/src/sync.rs`

- [ ] **Step 1: Change sync signatures**

Change `initial_sync` and `sync_cycle` to accept `SandboxExec`:

```rust
pub async fn initial_sync(
    agent_dir: &Path,
    sbox: &rightclaw::sandbox_exec::SandboxExec,
) -> miette::Result<()>
```

```rust
async fn sync_cycle(
    agent_dir: &Path,
    sbox: &rightclaw::sandbox_exec::SandboxExec,
) -> miette::Result<()>
```

```rust
pub async fn run_sync_task(
    agent_dir: PathBuf,
    sbox: rightclaw::sandbox_exec::SandboxExec,
    shutdown: CancellationToken,
)
```

Replace `exec_command(sandbox_name, ...)` calls with `sbox.exec(...)`.
Replace `upload_file(sandbox_name, ...)` with `upload_file(sbox.sandbox_name(), ...)`.

Update `ensure_local_bin_in_path` to take `sbox: &SandboxExec`.

- [ ] **Step 2: Run tests**

Run: `devenv shell -- cargo test -p rightclaw-bot --lib`
Expected: pass (sync tests use live sandbox = ignored)

- [ ] **Step 3: Commit**

```bash
git add crates/bot/src/sync.rs
git commit -m "refactor: sync uses SandboxExec instead of CLI exec_command"
```

---

### Task 4: Wire SandboxExec into bot startup

**Files:**
- Modify: `crates/bot/src/lib.rs:340-350`

- [ ] **Step 1: Create SandboxExec and pass to sync**

In `crates/bot/src/lib.rs`, after `sandbox_id` is resolved (around line 301), create `SandboxExec`:

```rust
let sbox = rightclaw::sandbox_exec::SandboxExec::new(
    mtls_dir.clone(),
    sandbox.clone(),
    sandbox_id.clone(),
);
```

Change `initial_sync` call (around line 346):
```rust
sync::initial_sync(&agent_dir, &sbox).await?;
```

Change `run_sync_task` spawn (around line 350):
```rust
Some(tokio::spawn(sync::run_sync_task(sync_agent_dir, sbox.clone(), sync_shutdown)))
```

- [ ] **Step 2: Run tests**

Run: `devenv shell -- cargo test --workspace --lib`

- [ ] **Step 3: Commit**

```bash
git add crates/bot/src/lib.rs
git commit -m "feat: wire SandboxExec into bot startup, pass to sync"
```

---

### Task 5: Delete exec_command and clean up

**Files:**
- Modify: `crates/rightclaw/src/openshell.rs`

- [ ] **Step 1: Delete exec_command function**

Remove the `pub async fn exec_command(sandbox: &str, cmd: &[&str])` function from `openshell.rs`.

- [ ] **Step 2: Verify no remaining references**

Run: `devenv shell -- cargo build --workspace`
Expected: clean build, no references to deleted function

- [ ] **Step 3: Commit**

```bash
git add crates/rightclaw/src/openshell.rs
git commit -m "chore: remove broken CLI-based exec_command"
```

---

### Task 6: Full build + test

- [ ] **Step 1: Run all tests**

Run: `devenv shell -- cargo test --workspace --lib`
Expected: all pass

- [ ] **Step 2: Build**

Run: `devenv shell -- cargo build --workspace`
Expected: clean

- [ ] **Step 3: Deploy and verify**

1. Recreate sandbox: `rightclaw init --force -y ...`
2. `rightclaw up`
3. Verify bot starts, sync completes, `/platform/` has content-addressed files
4. Verify symlinks work: `ssh ... "readlink /sandbox/.claude/settings.json"`
5. Verify agent responds to messages
