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
18 changes: 9 additions & 9 deletions .claude/rules/openshell-api.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
---
description: OpenShell gRPC API reference for dashboard development — proto is source of truth
globs: "backend/internal/gateway/**,backend/proto/**,frontend/src/api/**,frontend/src/types/**"
description: OpenShell gRPC API reference for dashboard development — the vendored Go SDK is source of truth
globs: "backend/internal/sdkclient/**,backend/internal/api/**,frontend/src/api/**,frontend/src/types/**"
alwaysApply: false
---

# OpenShell API Reference

**THE PROTO FILES IN `backend/proto/` ARE THE SOURCE OF TRUTH.** Before writing any gateway wrapper method, REST handler, or TypeScript type, read the actual RPC and message definitions in the proto. Never invent RPCs, fields, or lifecycle states. If a UI idea has no backing RPC, flag it — do not fabricate an endpoint.
**THE VENDORED GO SDK IS THE SOURCE OF TRUTH.** Since the SDK migration (PR #43), the BFF calls `github.com/NVIDIA/OpenShell/sdk/go` directly — there is no local `backend/proto/` copy or `backend/gen/` anymore (both were removed; `backend/proto/*.proto` predates the migration and is stale/unused — do not edit or regenerate from it). Before writing any handler or TypeScript type, read the actual RPC/message definitions in `$(go env GOMODCACHE)/github.com/!n!v!i!d!i!a/!open!shell/sdk/go@<version>/openshell/v1/` (hand-written client wrappers with doc comments; `types/*.go` for message shapes) or via `go doc`. Never invent RPCs, fields, or lifecycle states. If a UI idea has no backing RPC, flag it — do not fabricate an endpoint. Track upstream via `go get github.com/NVIDIA/OpenShell/sdk/go@latest`.

## Services

| Service | Proto | What we use |
|---------|-------|-------------|
| `openshell.v1.OpenShell` (64 RPCs) | openshell.proto | Sandbox, workspace, member, provider, profile, policy, draft policy, logs, SSH, services |
| `openshell.inference.v1.Inference` (4 RPCs) | inference.proto | Inference route CRUD |
| Service | SDK package | What we use |
|---------|-------------|-------------|
| `openshell.v1.OpenShell` | `openshell/v1` (`Client.Sandboxes()`, `.Workspaces()`, `.Providers()`, `.Policy()`, ...) | Sandbox (incl. stop/start), workspace, member, provider, profile, policy, draft policy, logs, SSH, services |
| `openshell.inference.v1.Inference` | `openshell/v1` (`Client.Inference()`) | Inference route CRUD |

Skip `GatewayInterceptor`, `SupervisorMiddleware`, `ComputeDriver` — internal/operator only.

## Hard facts — do not violate these

1. **No sandbox stop/start/suspend/restart.** `SandboxPhase` = UNSPECIFIED, PROVISIONING, READY, ERROR, DELETING, UNKNOWN. Lifecycle: Create → Ready/Error → Delete. Never generate stop/start endpoints, buttons, or "Suspended" states.
1. **Sandbox stop/start exists (as of v0.0.113); no suspend/restart.** `StopSandbox` retains persistent state; `StartSandbox` resumes. `SandboxPhase` = UNSPECIFIED, PROVISIONING, READY, ERROR, DELETING, STOPPING, STOPPED, STARTING, UNKNOWN. Lifecycle: Create → Ready/Error → (Stop ⇄ Start) → Delete. There is still no suspend/restart RPC and no "Suspended" state — do not invent those.
2. **No workspace-scoped named-policy resource.** Policy exists as: (a) `SandboxSpec.policy` — **required** on `CreateSandbox`, then versioned revisions per sandbox; (b) gateway-global via `UpdateConfig(global=true)`. No CreatePolicy/DeletePolicy/ListPolicies-by-workspace RPCs exist.
3. **Sandbox-scoped `UpdateConfig` may only change `network_policies` and inference fields.** filesystem/landlock/process are immutable after create — render read-only.
4. **No OCSF events API.** Observability = `GetSandboxLogs` (structured `fields` map on log lines) and `WatchSandbox` platform events. Never build an events query endpoint.
Expand All @@ -29,7 +29,7 @@ Skip `GatewayInterceptor`, `SupervisorMiddleware`, `ComputeDriver` — internal/
8. **Provider model** (`datamodel.v1.Provider`): metadata, type (profile slug like "claude"/"gitlab"), credentials (map, `secret` option — strip before returning to browser), config (map), credential_expires_at_ms, profile_workspace. No endpoint-URL/status/model fields. Valid types come from `ListProviderProfiles`.
9. **No list-images API.** Sandbox images are free-text OCI refs; community images by convention `ghcr.io/nvidia/openshell-community/sandboxes/<name>`.
10. **`GetGatewayInfo` returns only** status, gateway_version, compute_drivers[]. No uptime/db/TLS/auth-mode fields.
11. **Optimistic concurrency:** `AttachSandboxProvider`, `DetachSandboxProvider`, `UpdateConfig`, `UpdateProviderProfiles` accept `expected_resource_version` — pass the ObjectMeta.resource_version from the last read.
11. **Optimistic concurrency:** `AttachSandboxProvider`, `DetachSandboxProvider`, `UpdateConfig`, `UpdateProviderProfiles` accept `expected_resource_version` — pass the ObjectMeta.resource_version from the last read. `ApproveDraftChunk` similarly takes a `review_token` (from the chunk's last `GetDraft` read) pinning the exact evaluated candidate; the BFF falls back to resolving it server-side if the client doesn't send one.
12. **Workspace scoping:** most requests carry a `workspace` field (empty = "default"); list RPCs offer `all_workspaces`. Workspaces themselves are top-level.
13. **Secret fields** are annotated `[(openshell.options.v1.secret) = true]` in proto — grep for `secret` when adding a wrapper and never serialize those fields to the frontend.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Each rule has a corresponding Architecture Decision Record in [`docs/adrs/`](doc

## OpenShell API reference

The gateway exposes 68 gRPC RPCs across 2 services (64 in `OpenShell`, 4 in `Inference`). Proto files are in `backend/proto/`.
The gateway exposes RPCs across 2 services (`OpenShell`, `Inference`), consumed via the vendored `github.com/NVIDIA/OpenShell/sdk/go` (see `.claude/rules/openshell-api.md`) — not a local proto copy.

## Personas

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ This is a legal requirement for Apache 2.0 licensed projects. CI will reject uns
**The proto files are the source of truth** (see `.claude/rules/openshell-api.md`). Do not invent RPCs, fields, or lifecycle states. If a UI concept has no backing RPC, open an issue — do not fabricate an endpoint.

Common mistakes to avoid:
- No sandbox stop/start/suspend (lifecycle is create → ready/error → delete)
- Sandbox stop/start exists (v0.0.113+); still no suspend/restart (lifecycle is create → ready/error → stop ⇄ start → delete)
- No workspace-level policy CRUD (policy is per-sandbox or gateway-global)
- No events API (use GetSandboxLogs + polling)
- Provider credentials are write-only — never return secrets to the frontend
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Browser ── REST ──► Go BFF ── gRPC (bearer) ──► OpenShell ga
- **Proto is source of truth.** `backend/proto/` is copied from `NVIDIA/OpenShell/proto/`; `make proto` regenerates `backend/gen/`. Wrappers in `backend/internal/gateway/` cover the Phase 1 user-facing RPCs only.
- **Polling for status**: sandbox state uses polling (5s via React Query `refetchInterval`). WebSockets are used only for the interactive terminal.
- **Secrets never reach the browser**: provider credentials are write-only; the BFF serializes only credential key names.
- **No sandbox stop/start**: the OpenShell lifecycle is create → ready/error → delete. The UI reflects the API as-is.
- **Sandbox stop/start** (OpenShell v0.0.113+): the lifecycle is create → ready/error → (stop ⇄ start) → delete. Stopping retains persistent state; there is still no suspend/restart. The UI reflects the API as-is.
- Sandbox **policy is required at create**: the form ships client-side starter templates (the gateway has no server-side policy library).

See `CLAUDE.md` and `.claude/rules/` for contributor conventions.
4 changes: 2 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ module github.com/Gkrumbach07/openshell-dashboard/backend
go 1.25.1

require (
github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260814001211-c4b500a7de64
github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260827175501-197b41371d28
github.com/go-chi/chi/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
)

require (
Expand All @@ -16,5 +17,4 @@ require (
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
4 changes: 2 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260814001211-c4b500a7de64 h1:1bxTs8ykTjWia0soV7vw6m/0ZhWDjcOD38kGOcH/7r4=
github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260814001211-c4b500a7de64/go.mod h1:PPbbhH5tmSfoWzVcb5CXurnMkuJOYpesZmUB1itrlFY=
github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260827175501-197b41371d28 h1:Grmzq50KhPbrLFm3xt64gwn3jDMHNMMiqZX2PLbcXXs=
github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260827175501-197b41371d28/go.mod h1:fWdNw+NcUcKcvMLTa0/q66wVUJHGwWWV+CZOh/BdMZw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ func (app *App) Routes() http.Handler {
r.Post("/sandboxes", app.CreateSandbox)
r.Get("/sandboxes/{name}", app.GetSandbox)
r.Delete("/sandboxes/{name}", app.DeleteSandbox)
r.Post("/sandboxes/{name}/stop", app.StopSandbox)
r.Post("/sandboxes/{name}/start", app.StartSandbox)
r.Get("/sandboxes/{name}/logs", app.GetSandboxLogs)
r.Get("/sandboxes/{name}/terminal", app.Terminal)
r.Get("/sandboxes/{name}/providers", app.ListSandboxProviders)
Expand Down
49 changes: 48 additions & 1 deletion backend/internal/api/drafts_handler.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"context"
"encoding/json"
"log/slog"
"net/http"
Expand Down Expand Up @@ -44,9 +45,39 @@ func (app *App) GetDraftPolicy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, models.FromSDKDraftPolicy(draft))
}

// ApproveDraftChunkRequest optionally carries the chunk's review token, which
// the frontend already has from its last GetDraftPolicy fetch. Absent for
// older clients or the approve-from-notification path.
type ApproveDraftChunkRequest struct {
ReviewToken string `json:"reviewToken,omitempty"`
}

// ApproveDraftChunk merges one proposed rule into the active policy.
func (app *App) ApproveDraftChunk(w http.ResponseWriter, r *http.Request) {
result, err := app.sdk.Policy().ApproveDraftChunk(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name"), chi.URLParam(r, "chunk"))
workspace := chi.URLParam(r, "workspace")
name := chi.URLParam(r, "name")
chunkID := chi.URLParam(r, "chunk")

var body ApproveDraftChunkRequest
if r.ContentLength > 0 && !decodeBody(w, r, &body) {
return
}

// The gateway binds each approval to a review_token that pins the exact
// evaluated candidate (optimistic concurrency). Use the token the client
// already has; only fall back to a GetDraft round-trip when it didn't send
// one. Older gateways return an empty token, which the RPC accepts.
reviewToken := body.ReviewToken
if reviewToken == "" {
var err error
reviewToken, err = app.resolveDraftReviewToken(r.Context(), workspace, name, chunkID)
if err != nil {
writeSDKError(w, err)
return
}
}

result, err := app.sdk.Policy().ApproveDraftChunk(r.Context(), workspace, name, chunkID, reviewToken)
if err != nil {
writeSDKError(w, err)
return
Expand All @@ -57,6 +88,22 @@ func (app *App) ApproveDraftChunk(w http.ResponseWriter, r *http.Request) {
})
}

// resolveDraftReviewToken returns the review token bound to the given draft
// chunk, or an empty string if the chunk carries none. The token pins an
// approval to the exact candidate the gateway last evaluated.
func (app *App) resolveDraftReviewToken(ctx context.Context, workspace, name, chunkID string) (string, error) {
draft, err := app.sdk.Policy().GetDraft(ctx, workspace, name)
if err != nil {
return "", err
}
for i := range draft.Chunks {
if draft.Chunks[i].ID == chunkID {
return draft.Chunks[i].ReviewToken, nil
}
}
return "", nil
}

// RejectDraftChunkRequest carries the optional reviewer reason, surfaced back
// to the in-sandbox agent.
type RejectDraftChunkRequest struct {
Expand Down
22 changes: 15 additions & 7 deletions backend/internal/api/mock_sdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ type mockSDKSandboxes struct {
detachFn func(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*openshell.DetachProviderResult, error)
listProvidersFn func(ctx context.Context, workspace, sandboxName string) ([]*openshell.Provider, error)
getLogsFn func(ctx context.Context, workspace, name string, opts ...openshell.LogOption) (*openshell.LogResult, error)
stopFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error)
startFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error)
}

func (m *mockSDKSandboxes) List(ctx context.Context, workspace string, opts ...openshell.ListOptions) ([]*openshell.Sandbox, error) {
Expand Down Expand Up @@ -112,12 +114,18 @@ func (m *mockSDKSandboxes) GetLogs(ctx context.Context, workspace, name string,
return &openshell.LogResult{}, nil
}

func (m *mockSDKSandboxes) Start(_ context.Context, _, _ string) (*openshell.Sandbox, error) {
panic("not implemented")
func (m *mockSDKSandboxes) Start(ctx context.Context, workspace, name string) (*openshell.Sandbox, error) {
if m.startFn != nil {
return m.startFn(ctx, workspace, name)
}
return &openshell.Sandbox{Name: name}, nil
}

func (m *mockSDKSandboxes) Stop(_ context.Context, _, _ string) (*openshell.Sandbox, error) {
panic("not implemented")
func (m *mockSDKSandboxes) Stop(ctx context.Context, workspace, name string) (*openshell.Sandbox, error) {
if m.stopFn != nil {
return m.stopFn(ctx, workspace, name)
}
return &openshell.Sandbox{Name: name}, nil
}

func (m *mockSDKSandboxes) WaitStopped(_ context.Context, _, _ string, _ ...openshell.WaitOptions) (*openshell.Sandbox, error) {
Expand Down Expand Up @@ -331,7 +339,7 @@ func (m *mockSDKWorkspaces) ListMembers(ctx context.Context, workspace string, o

type mockSDKPolicy struct {
getDraftFn func(ctx context.Context, workspace, sandboxName string, opts ...openshell.GetDraftOption) (*openshell.DraftPolicy, error)
approveFn func(ctx context.Context, workspace, sandboxName, chunkID string) (*openshell.ApproveResult, error)
approveFn func(ctx context.Context, workspace, sandboxName, chunkID, reviewToken string) (*openshell.ApproveResult, error)
rejectFn func(ctx context.Context, workspace, sandboxName, chunkID, reason string) error
approveAllFn func(ctx context.Context, workspace, sandboxName string, opts ...openshell.ApproveAllOption) (*openshell.ApproveAllResult, error)
clearFn func(ctx context.Context, workspace, sandboxName string) (*openshell.ClearResult, error)
Expand All @@ -349,9 +357,9 @@ func (m *mockSDKPolicy) GetDraft(ctx context.Context, workspace, sandboxName str
return &openshell.DraftPolicy{}, nil
}

func (m *mockSDKPolicy) ApproveDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*openshell.ApproveResult, error) {
func (m *mockSDKPolicy) ApproveDraftChunk(ctx context.Context, workspace, sandboxName, chunkID, reviewToken string) (*openshell.ApproveResult, error) {
if m.approveFn != nil {
return m.approveFn(ctx, workspace, sandboxName, chunkID)
return m.approveFn(ctx, workspace, sandboxName, chunkID, reviewToken)
}
return &openshell.ApproveResult{}, nil
}
Expand Down
66 changes: 57 additions & 9 deletions backend/internal/api/policies_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,63 @@ func TestGetDraftPolicy(t *testing.T) {
}

func TestApproveDraftChunk(t *testing.T) {
app := newTestAppWithSDK(&mockSDK{})
r := chi.NewRouter()
r.Post("/workspaces/{workspace}/sandboxes/{name}/drafts/{chunk}/approve", app.ApproveDraftChunk)
req := httptest.NewRequest(http.MethodPost, "/workspaces/default/sandboxes/my-sandbox/drafts/c1/approve", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
t.Run("uses client-supplied review token without resolving the draft", func(t *testing.T) {
sdk := &mockSDK{}
sdk.policy.getDraftFn = func(_ context.Context, _, _ string, _ ...openshell.GetDraftOption) (*openshell.DraftPolicy, error) {
t.Fatal("GetDraft should not be called when the client supplies a reviewToken")
return nil, nil
}
var gotToken string
sdk.policy.approveFn = func(_ context.Context, _, _, _, reviewToken string) (*openshell.ApproveResult, error) {
gotToken = reviewToken
return &openshell.ApproveResult{PolicyVersion: 2}, nil
}
app := newTestAppWithSDK(sdk)
r := chi.NewRouter()
r.Post("/workspaces/{workspace}/sandboxes/{name}/drafts/{chunk}/approve", app.ApproveDraftChunk)
req := httptest.NewRequest(
http.MethodPost,
"/workspaces/default/sandboxes/my-sandbox/drafts/c1/approve",
strings.NewReader(`{"reviewToken":"client-token"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body: %s", w.Code, w.Body.String())
}
if gotToken != "client-token" {
t.Errorf("reviewToken passed to ApproveDraftChunk = %q, want %q", gotToken, "client-token")
}
})

t.Run("resolves the review token from the draft when the client sends none", func(t *testing.T) {
sdk := &mockSDK{}
sdk.policy.getDraftFn = func(_ context.Context, _, _ string, _ ...openshell.GetDraftOption) (*openshell.DraftPolicy, error) {
return &openshell.DraftPolicy{
Chunks: []openshell.PolicyChunk{
{ID: "c1", ReviewToken: "resolved-token"},
},
}, nil
}
var gotToken string
sdk.policy.approveFn = func(_ context.Context, _, _, _, reviewToken string) (*openshell.ApproveResult, error) {
gotToken = reviewToken
return &openshell.ApproveResult{PolicyVersion: 2}, nil
}
app := newTestAppWithSDK(sdk)
r := chi.NewRouter()
r.Post("/workspaces/{workspace}/sandboxes/{name}/drafts/{chunk}/approve", app.ApproveDraftChunk)
req := httptest.NewRequest(http.MethodPost, "/workspaces/default/sandboxes/my-sandbox/drafts/c1/approve", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body: %s", w.Code, w.Body.String())
}
if gotToken != "resolved-token" {
t.Errorf("reviewToken passed to ApproveDraftChunk = %q, want %q", gotToken, "resolved-token")
}
})
}

func TestRejectDraftChunk(t *testing.T) {
Expand Down
23 changes: 23 additions & 0 deletions backend/internal/api/sandboxes_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,29 @@ func (app *App) GetSandbox(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, models.FromSDKSandbox(sandbox))
}

// StopSandbox stops a running sandbox while retaining its persistent state.
// The sandbox transitions through STOPPING to STOPPED and can be resumed with
// StartSandbox.
func (app *App) StopSandbox(w http.ResponseWriter, r *http.Request) {
sandbox, err := app.sdk.Sandboxes().Stop(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name"))
if err != nil {
writeSDKError(w, err)
return
}
writeJSON(w, http.StatusOK, models.FromSDKSandbox(sandbox))
}

// StartSandbox resumes a previously stopped sandbox. The sandbox transitions
// through STARTING back to READY.
func (app *App) StartSandbox(w http.ResponseWriter, r *http.Request) {
sandbox, err := app.sdk.Sandboxes().Start(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name"))
if err != nil {
writeSDKError(w, err)
return
}
writeJSON(w, http.StatusOK, models.FromSDKSandbox(sandbox))
}

func (app *App) DeleteSandbox(w http.ResponseWriter, r *http.Request) {
if err := app.sdk.Sandboxes().Delete(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name")); err != nil {
writeSDKError(w, err)
Expand Down
Loading
Loading