Repository: NVIDIA/OpenShell (2026-03-31T06:20:24Z)

Issue:
bug(cli): sandbox create with duplicate name returns raw database error instead of user-friendly message
## agent diagnostic

- loaded `openshell-cli` skill — checked `sandbox create --help` for `--force`/`--replace`/`--overwrite` flags; none exist
- loaded `debug-openshell-cluster` skill — cluster is healthy (`openshell status` and `openshell doctor check` both pass)
- confirmed workaround: `sandbox delete <name>` then `sandbox create --name <name>` works, but the error message gives no hint to do this
- agent cannot resolve: the error originates in the grpc server layer — sqlite unique constraint error (code 2067) is surfaced as a raw `internal` grpc status instead of being caught and mapped to `alreadyexists` with a human-readable message. this requires a server-side code change in the persist layer or grpc handler.

## description

**actual behavior:** creating a sandbox with a name that already exists returns a raw internal error exposing sqlite internals and grpc metadata:

```
error: × status: Internal, message: "persist sandbox failed: execution error:
  database error: error returned from database: (code: 2067) UNIQUE
  constraint failed: objects.object_type, objects.name", details: [],
  metadata: MetadataMap { headers: {"content-type": "application/grpc",
  "date": "Mon, 30 Mar 2026 23:51:20 GMT"} }
```

**expected behavior:** a clear, actionable error like:

```
error: sandbox 'dupe-test' already exists

hint: delete it first with: openshell sandbox delete dupe-test
      or use a different name
```

## reproduction steps

1. `openshell sandbox create --name dupe-test --no-tty -- sleep 120 &`
2. wait for sandbox to reach `ready` phase (`openshell sandbox list`)
3. `openshell sandbox create --name dupe-test --no-tty -- echo "second"`
4. observe raw database error instead of "already exists" message

## environment

- os: macos 26.3.1 (darwin, apple silicon)
- docker: docker desktop 29.2.1
- openshell: v0.0.6

## logs

```
$ openshell sandbox create --name dupe-test --no-tty -- echo "second"
Error:   × status: Internal, message: "persist sandbox failed: execution error:
  │ database error: error returned from database: (code: 2067) UNIQUE
  │ constraint failed: objects.object_type, objects.name", details: [],
  │ metadata: MetadataMap { headers: {"content-type": "application/grpc",
  │ "date": "Mon, 30 Mar 2026 23:51:20 GMT"} }
```


Discussion:
## suggested fix

the server already handles the k8s duplicate case correctly at `crates/openshell-server/src/grpc.rs:273`:

```rust
return Err(Status::already_exists("sandbox already exists"));
```

the store persist at line 258 just needs the same pattern. the codebase already checks for `UNIQUE` constraint errors elsewhere (line 2620-2621):

```rust
if msg.contains("UNIQUE") || msg.contains("unique") || msg.contains("duplicate") {
```

suggested change at line 256-258:

```rust
// before (current)
.map_err(|e| Status::internal(format!("persist sandbox failed: {e}")))?;

// after (fix)
.map_err(|e| {
    let msg = e.to_string();
    if msg.contains("UNIQUE") || msg.contains("unique") {
        Status::already_exists(format!("sandbox '{}' already exists", name))
    } else {
        Status::internal(format!("persist sandbox failed: {e}"))
    }
})?;
```

same fix applies to the second `put_message` call at line 612-614.

the cli side could also validate client-side before the grpc call using `sandbox list`, but the server-side fix is the right layer since other clients (tui, api) would hit the same error.



Patch:
diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs
index bab819137..98e110d88 100644
--- a/crates/openshell-cli/src/run.rs
+++ b/crates/openshell-cli/src/run.rs
@@ -2045,7 +2045,16 @@ pub async fn sandbox_create(
         name: name.unwrap_or_default().to_string(),
     };
 
-    let response = client.create_sandbox(request).await.into_diagnostic()?;
+    let response = match client.create_sandbox(request).await {
+        Ok(resp) => resp,
+        Err(status) if status.code() == Code::AlreadyExists => {
+            return Err(miette::miette!(
+                "{}\n\nhint: delete it first with: openshell sandbox delete <name>\n      or use a different name",
+                status.message()
+            ));
+        }
+        Err(status) => return Err(status).into_diagnostic(),
+    };
     let sandbox = response
         .into_inner()
         .sandbox
diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs
index fd4bf5859..911d2f093 100644
--- a/crates/openshell-server/src/grpc.rs
+++ b/crates/openshell-server/src/grpc.rs
@@ -244,6 +244,20 @@ impl OpenShell for OpenShellService {
             ..Default::default()
         };
 
+        // Reject duplicate names early, before touching the index or store.
+        // This mirrors the provider-creation pattern (see `create_provider`).
+        let existing = self
+            .state
+            .store
+            .get_message_by_name::<Sandbox>(&name)
+            .await
+            .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?;
+        if existing.is_some() {
+            return Err(Status::already_exists(format!(
+                "sandbox '{name}' already exists"
+            )));
+        }
+
         // Persist to the store FIRST so the sandbox watcher always finds
         // the record with `spec` populated.  If we created the k8s
         // resource first, the watcher could race us and write a fallback


Test patch:
diff --git a/e2e/python/test_inference_routing.py b/e2e/python/test_inference_routing.py
index bda0d8cbc..c35e02535 100644
--- a/e2e/python/test_inference_routing.py
+++ b/e2e/python/test_inference_routing.py
@@ -95,11 +95,6 @@ def _upsert_managed_inference(
             except grpc.RpcError as create_exc:
                 if create_exc.code() == grpc.StatusCode.ALREADY_EXISTS:
                     continue
-                if (
-                    create_exc.code() == grpc.StatusCode.INTERNAL
-                    and "UNIQUE constraint failed" in (create_exc.details() or "")
-                ):
-                    continue
                 raise
     else:
         raise RuntimeError("failed to upsert managed e2e provider after retries")