From 12c737d4f11c28e2bb65de47e2325d2106113c04 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 08:56:34 +0000 Subject: [PATCH 1/2] feat(cli): add workspace set-runtime and set-model commands Adds two tenant-management verbs tied to molecule-core#2056: - -> PATCH /workspaces/:id - -> PUT /workspaces/:id/model Guards: - set-model relies on workspace-server registry validation (fail-closed). - set-runtime fetches current model and target runtime's offered-model menu (GET /admin/llm/offered-models) and rejects a switch that would orphan the current model. Unknown/federated runtimes fail-open to match server contract. Also adds to the client Workspace struct and integration/unit tests. Fixes #20. Co-Authored-By: Claude --- cmd/molecule/molecule_test.go | 168 ++++++++++++++++- internal/client/management.go | 47 +++++ internal/client/platform.go | 1 + internal/cmd/workspace_runtime_model.go | 182 +++++++++++++++++++ internal/cmd/workspace_runtime_model_test.go | 92 ++++++++++ 5 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/workspace_runtime_model.go create mode 100644 internal/cmd/workspace_runtime_model_test.go diff --git a/cmd/molecule/molecule_test.go b/cmd/molecule/molecule_test.go index e17b8fe..e46b695 100644 --- a/cmd/molecule/molecule_test.go +++ b/cmd/molecule/molecule_test.go @@ -26,6 +26,7 @@ func mockServer(t *testing.T, basePath string) *httptest.Server { "status": "online", "role": "researcher", "runtime": "claude-code", + "model": "claude-sonnet-4-6", "created_at": "2026-04-01T12:00:00Z", "tier": 2, }, @@ -36,6 +37,15 @@ func mockServer(t *testing.T, basePath string) *httptest.Server { "role": "pm", "tier": 3, }, + { + "id": "ws-codex", + "name": "codex-workspace", + "status": "online", + "role": "code reviewer", + "runtime": "codex", + "model": "gpt-5.5", + "tier": 4, + }, } mux.HandleFunc(basePath+"/workspaces", func(w http.ResponseWriter, r *http.Request) { @@ -68,6 +78,15 @@ func mockServer(t *testing.T, basePath string) *httptest.Server { case http.MethodDelete: // CLI may send ?confirm=true query param w.WriteHeader(http.StatusNoContent) + case http.MethodPatch: + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + resp := map[string]interface{}{"status": "updated"} + if _, ok := body["runtime"]; ok { + resp["needs_restart"] = true + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } @@ -189,6 +208,66 @@ func mockServer(t *testing.T, basePath string) *httptest.Server { json.NewEncoder(w).Encode(resp) }) + // --- Workspace runtime / model management --- + mux.HandleFunc(basePath+"/workspaces/ws-codex", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(workspaces[2]) + case http.MethodPatch: + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + resp := map[string]interface{}{"status": "updated"} + if _, ok := body["runtime"]; ok { + resp["needs_restart"] = true + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + mux.HandleFunc(basePath+"/workspaces/ws-001/model", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + model := body["model"] + if model == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "cleared"}) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "saved", "model": model}) + }) + + mux.HandleFunc(basePath+"/admin/llm/offered-models", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + runtime := r.URL.Query().Get("runtime") + menus := map[string][]string{ + "claude-code": {"claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"}, + "codex": {"gpt-5.5", "gpt-5.4", "gpt-5.4-mini"}, + } + models, ok := menus[runtime] + if !ok { + http.Error(w, `{"error":"unknown runtime"}`, http.StatusNotFound) + return + } + out := []map[string]string{} + for _, m := range models { + out = append(out, map[string]string{"model": m, "provider": "platform"}) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"runtime": runtime, "models": out}) + }) + return server } @@ -319,8 +398,8 @@ func TestCLI_WorkspaceList_JSON(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { t.Fatalf("non-JSON output: %s\nstderr: %s", stdout.String(), stderr.String()) } - if len(out) != 2 { - t.Errorf("expected 2 workspaces, got %d", len(out)) + if len(out) != 3 { + t.Errorf("expected 3 workspaces, got %d", len(out)) } } @@ -945,3 +1024,88 @@ func TestCLI_Completion_InvalidShell(t *testing.T) { t.Errorf("expected non-zero exit code for unsupported shell, got 0") } } + +func TestCLI_WorkspaceSetModel(t *testing.T) { + server := mockServer(t, "") + defer server.Close() + + exe := mol(t) + root := repoRoot() + cmd := exec.Command(exe, "--api-url", server.URL, "workspace", "set-model", "ws-001", "claude-opus-4-7") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Dir = root + err := cmd.Run() + if err != nil { + t.Fatalf("molecule workspace set-model: %v\nstderr: %s", err, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "claude-opus-4-7") { + t.Errorf("expected updated model in output, got:\n%s", out) + } +} + +func TestCLI_WorkspaceSetModel_Clear(t *testing.T) { + server := mockServer(t, "") + defer server.Close() + + exe := mol(t) + root := repoRoot() + cmd := exec.Command(exe, "--api-url", server.URL, "workspace", "set-model", "ws-001", "") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Dir = root + err := cmd.Run() + if err != nil { + t.Fatalf("molecule workspace set-model clear: %v\nstderr: %s", err, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "cleared") { + t.Errorf("expected 'cleared' in output, got:\n%s", out) + } +} + +func TestCLI_WorkspaceSetRuntime_Compatible(t *testing.T) { + server := mockServer(t, "") + defer server.Close() + + exe := mol(t) + root := repoRoot() + cmd := exec.Command(exe, "--api-url", server.URL, "workspace", "set-runtime", "ws-001", "claude-code") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Dir = root + err := cmd.Run() + if err != nil { + t.Fatalf("molecule workspace set-runtime compatible: %v\nstderr: %s", err, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "updated") { + t.Errorf("expected 'updated' in output, got:\n%s", out) + } + if !strings.Contains(out, "Restart required") { + t.Errorf("expected restart hint in output, got:\n%s", out) + } +} + +func TestCLI_WorkspaceSetRuntime_Incompatible(t *testing.T) { + server := mockServer(t, "") + defer server.Close() + + exe := mol(t) + root := repoRoot() + cmd := exec.Command(exe, "--api-url", server.URL, "workspace", "set-runtime", "ws-001", "codex") + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.Dir = root + err := cmd.Run() + if err == nil { + t.Fatal("expected error for incompatible runtime switch, got none") + } + if !strings.Contains(stderr.String(), "not compatible") { + t.Errorf("expected compatibility error in stderr, got:\n%s", stderr.String()) + } +} diff --git a/internal/client/management.go b/internal/client/management.go index f1c47e5..f9548ac 100644 --- a/internal/client/management.go +++ b/internal/client/management.go @@ -167,6 +167,53 @@ func (p *Platform) ResumeWorkspace(id string) error { return err } +// SetRuntime updates a workspace's runtime (PATCH /workspaces/:id {runtime}). +// The response is the handler's raw JSON (e.g. {"status":"updated","needs_restart":true}). +func (p *Platform) SetRuntime(id, runtime string) (json.RawMessage, error) { + body := map[string]string{"runtime": runtime} + return p.patchRaw("/workspaces/"+url.PathEscape(id), body) +} + +// SetModel updates a workspace's model override (PUT /workspaces/:id/model). +// The workspace-server validates the (runtime, model) pair and auto-restarts. +func (p *Platform) SetModel(id, model string) (json.RawMessage, error) { + body := map[string]string{"model": model} + return p.putRaw("/workspaces/"+url.PathEscape(id)+"/model", body) +} + +// OfferedModel is one selectable (runtime, model) entry from the registry. +type OfferedModel struct { + Model string `json:"model"` + Provider string `json:"provider"` + PlatformBilled bool `json:"platform_billed"` + AuthEnv []string `json:"auth_env,omitempty"` +} + +// OfferedModelsResponse is the envelope returned by GET /admin/llm/offered-models. +type OfferedModelsResponse struct { + Runtime string `json:"runtime"` + Models []OfferedModel `json:"models"` +} + +// ListOfferedModels returns the registry's native model menu for a runtime +// (GET /admin/llm/offered-models?runtime=...). A non-200 response is surfaced +// as an error so callers can decide whether to fail-open for unknown runtimes. +func (p *Platform) ListOfferedModels(runtime string) (*OfferedModelsResponse, error) { + u, err := url.Parse("/admin/llm/offered-models") + if err != nil { + return nil, fmt.Errorf("parse offered-models path: %w", err) + } + q := u.Query() + q.Set("runtime", runtime) + u.RawQuery = q.Encode() + + var out OfferedModelsResponse + if err := p.getInto(u.String(), &out); err != nil { + return nil, err + } + return &out, nil +} + // GetBudget returns a workspace's budget (GET /workspaces/:id/budget). func (p *Platform) GetBudget(id string) (json.RawMessage, error) { return p.getRaw("/workspaces/" + url.PathEscape(id) + "/budget") diff --git a/internal/client/platform.go b/internal/client/platform.go index 9faceb2..64048db 100644 --- a/internal/client/platform.go +++ b/internal/client/platform.go @@ -64,6 +64,7 @@ type Workspace struct { Role string `json:"role,omitempty"` ParentID string `json:"parent_id,omitempty"` Runtime string `json:"runtime,omitempty"` + Model string `json:"model,omitempty"` WorkspaceDir string `json:"workspace_dir,omitempty"` CreatedAt string `json:"created_at,omitempty"` Tier int `json:"tier,omitempty"` diff --git a/internal/cmd/workspace_runtime_model.go b/internal/cmd/workspace_runtime_model.go new file mode 100644 index 0000000..e508220 --- /dev/null +++ b/internal/cmd/workspace_runtime_model.go @@ -0,0 +1,182 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" +) + +// --------------------------------------------------------------------------- +// Workspace runtime / model management +// +// molecule workspace set-runtime +// molecule workspace set-model +// +// Tied to molecule-core#2056 (OpenAPI management surface). The server-side +// SetModel handler validates (runtime, model) against the provider registry and +// rejects invalid combos (fail-closed). set-runtime guards against orphaning +// the workspace's current model by consulting the offered-models menu for the +// target runtime before applying the switch. +// --------------------------------------------------------------------------- + +func init() { + workspaceCmd.AddCommand(workspaceSetRuntimeCmd, workspaceSetModelCmd) +} + +// =========================================================================== +// molecule workspace set-runtime +// =========================================================================== +var workspaceSetRuntimeCmd = &cobra.Command{ + Use: "set-runtime ", + Short: "Change a workspace's runtime", + Long: `Changes the runtime image a workspace uses (PATCH /workspaces/:id). + +Before applying the switch, the CLI checks that the workspace's CURRENT model is +compatible with the target runtime. If it is not, the command fails and tells +you to run "molecule workspace set-model" first. This prevents the runtime +switch from orphaning a model the target runtime cannot route. + +After a successful switch, run "molecule workspace restart " to boot into +the new runtime.`, + Args: cobra.ExactArgs(2), + RunE: runWorkspaceSetRuntime, +} + +func runWorkspaceSetRuntime(_ *cobra.Command, args []string) error { + id, runtime := args[0], args[1] + cl := newClient() + + ws, err := cl.GetWorkspace(id) + if err != nil { + return fmt.Errorf("workspace set-runtime: %w", err) + } + + if ws.Model != "" { + if err := requireModelCompatibleWithRuntime(cl, ws.Model, runtime); err != nil { + return fmt.Errorf("workspace set-runtime: %w", err) + } + } + + raw, err := cl.SetRuntime(id, runtime) + if err != nil { + return fmt.Errorf("workspace set-runtime: %w", err) + } + + var resp struct { + Status string `json:"status"` + NeedsRestart bool `json:"needs_restart"` + } + _ = json.Unmarshal(raw, &resp) // best-effort pretty print + + if outputFormat == "json" || outputFormat == "yaml" { + return printRaw(raw) + } + + fmt.Printf("Runtime for workspace %q updated to %q.\n", id, runtime) + if resp.NeedsRestart { + fmt.Printf("Restart required: run `molecule workspace restart %s`.\n", id) + } + return nil +} + +// =========================================================================== +// molecule workspace set-model +// =========================================================================== +var workspaceSetModelCmd = &cobra.Command{ + Use: "set-model ", + Short: "Change a workspace's LLM model override", + Long: `Sets the model override for a workspace (PUT /workspaces/:id/model). + +The workspace-server validates the (runtime, model) pair against the provider +registry and rejects incompatible combinations fail-closed (e.g. claude-code + +gpt-5.5). The server auto-restarts the workspace so the new model takes effect.`, + Args: cobra.ExactArgs(2), + RunE: runWorkspaceSetModel, +} + +func runWorkspaceSetModel(_ *cobra.Command, args []string) error { + id, model := args[0], args[1] + cl := newClient() + + raw, err := cl.SetModel(id, model) + if err != nil { + return fmt.Errorf("workspace set-model: %w", err) + } + + if outputFormat == "json" || outputFormat == "yaml" { + return printRaw(raw) + } + + var resp struct { + Status string `json:"status"` + Model string `json:"model"` + } + if err := json.Unmarshal(raw, &resp); err == nil { + switch resp.Status { + case "saved": + fmt.Printf("Model for workspace %q updated to %q.\n", id, resp.Model) + case "cleared": + fmt.Printf("Model override for workspace %q cleared.\n", id) + default: + return printRaw(raw) + } + return nil + } + return printRaw(raw) +} + +// =========================================================================== +// Compatibility guard +// =========================================================================== + +// requireModelCompatibleWithRuntime queries the target runtime's offered-model +// menu and rejects the current model if it is not on that menu. Unknown +// runtimes (federated / non-first-party) are allowed through: the registry +// cannot speak for them, so we mirror the server's fail-open federation +// contract while still failing closed on known-invalid combos. +func requireModelCompatibleWithRuntime(cl *client.Platform, currentModel, targetRuntime string) error { + currentModel = strings.TrimSpace(currentModel) + if currentModel == "" { + return nil + } + + offered, err := cl.ListOfferedModels(targetRuntime) + if err != nil { + // If the runtime is unknown to the registry, we cannot validate here; + // the server owns the final decision on unregistered runtimes. + return nil + } + + for _, m := range offered.Models { + if m.Model == currentModel { + return nil + } + } + + valid := make([]string, 0, len(offered.Models)) + for _, m := range offered.Models { + valid = append(valid, m.Model) + } + + return &exitError{ + code: 1, + msg: fmt.Sprintf( + "model %q is not compatible with runtime %q; set a compatible model first with `molecule workspace set-model <%s-model>` (valid examples: %s)", + currentModel, targetRuntime, targetRuntime, joinFirstN(valid, 5), + ), + } +} + +func joinFirstN(items []string, n int) string { + if len(items) == 0 { + return "(none)" + } + if len(items) > n { + return strings.Join(items[:n], ", ") + ", ..." + } + return strings.Join(items, ", ") +} diff --git a/internal/cmd/workspace_runtime_model_test.go b/internal/cmd/workspace_runtime_model_test.go new file mode 100644 index 0000000..30a5208 --- /dev/null +++ b/internal/cmd/workspace_runtime_model_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "go.moleculesai.app/cli/internal/client" +) + +// fakeOfferedModelsServer returns a server that responds to GET +// /admin/llm/offered-models?runtime=... with a fixed menu. +func fakeOfferedModelsServer(t *testing.T, runtime string, models []string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/admin/llm/offered-models" { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + if r.URL.Query().Get("runtime") != runtime { + http.Error(w, `{"error":"unknown runtime"}`, http.StatusNotFound) + return + } + out := map[string]interface{}{ + "runtime": runtime, + "models": []map[string]string{}, + } + for _, m := range models { + out["models"] = append(out["models"].([]map[string]string), map[string]string{ + "model": m, + "provider": "platform", + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) + })) +} + +func TestRequireModelCompatibleWithRuntime_AllowsKnownGood(t *testing.T) { + server := fakeOfferedModelsServer(t, "claude-code", []string{"claude-sonnet-4-6", "claude-opus-4-7"}) + defer server.Close() + + cl := client.NewWithAuth(server.URL, "token", "org") + if err := requireModelCompatibleWithRuntime(cl, "claude-sonnet-4-6", "claude-code"); err != nil { + t.Fatalf("expected compatible model to pass, got: %v", err) + } +} + +func TestRequireModelCompatibleWithRuntime_RejectsKnownBad(t *testing.T) { + server := fakeOfferedModelsServer(t, "claude-code", []string{"claude-sonnet-4-6", "claude-opus-4-7"}) + defer server.Close() + + cl := client.NewWithAuth(server.URL, "token", "org") + err := requireModelCompatibleWithRuntime(cl, "gpt-5.5", "claude-code") + if err == nil { + t.Fatal("expected incompatible model to fail") + } + var ee *exitError + if !errors.As(err, &ee) { + t.Fatalf("expected exitError, got %T", err) + } + if ee.code != 1 { + t.Errorf("expected exit code 1, got %d", ee.code) + } +} + +func TestRequireModelCompatibleWithRuntime_EmptyModelAllowed(t *testing.T) { + server := fakeOfferedModelsServer(t, "claude-code", []string{"claude-sonnet-4-6"}) + defer server.Close() + + cl := client.NewWithAuth(server.URL, "token", "org") + if err := requireModelCompatibleWithRuntime(cl, "", "claude-code"); err != nil { + t.Fatalf("empty model should be allowed: %v", err) + } +} + +func TestRequireModelCompatibleWithRuntime_UnknownRuntimeAllowed(t *testing.T) { + // Unknown runtimes (e.g. federated / third-party) return 404 from the + // offered-models endpoint. We fail-open there to match the server's + // federation contract. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"unknown runtime"}`, http.StatusNotFound) + })) + defer server.Close() + + cl := client.NewWithAuth(server.URL, "token", "org") + if err := requireModelCompatibleWithRuntime(cl, "anything", "external"); err != nil { + t.Fatalf("unknown runtime should fail-open: %v", err) + } +} -- 2.52.0 From f484772b78fd3cd7a908a332d4a96acad20af5d1 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 09:19:06 +0000 Subject: [PATCH 2/2] fix(cli): fail-closed on offered-models transient errors (#21)\n\nCR2 RC 11957: requireModelCompatibleWithRuntime previously returned nil on\nANY ListOfferedModels error, failing open on 5xx/network/transient failures.\n\n- ListOfferedModels now distinguishes HTTP 404 (runtime unknown to registry)\n via ErrRuntimeNotInRegistry from other errors.\n- The compat guard only fails-open on ErrRuntimeNotInRegistry (federated\n runtimes); every other fetch error returns an exitError and blocks the\n runtime switch.\n- Added test for 500 response fail-closed behaviour.\n\nCo-Authored-By: Claude --- internal/client/management.go | 45 +++++++++++++++++--- internal/cmd/workspace_runtime_model.go | 19 +++++++-- internal/cmd/workspace_runtime_model_test.go | 23 ++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/internal/client/management.go b/internal/client/management.go index f9548ac..9ecbaa8 100644 --- a/internal/client/management.go +++ b/internal/client/management.go @@ -11,6 +11,7 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -195,21 +196,53 @@ type OfferedModelsResponse struct { Models []OfferedModel `json:"models"` } +// ErrRuntimeNotInRegistry is returned by ListOfferedModels when the server +// reports that the runtime is unknown to the provider registry. Callers that +// enforce model/runtime compatibility should treat this as a federation case +// and fail-open: the registry cannot validate runtimes it does not know. +var ErrRuntimeNotInRegistry = errors.New("runtime not in provider registry") + // ListOfferedModels returns the registry's native model menu for a runtime -// (GET /admin/llm/offered-models?runtime=...). A non-200 response is surfaced -// as an error so callers can decide whether to fail-open for unknown runtimes. +// (GET /admin/llm/offered-models?runtime=...). +// +// - If the server returns 200 OK, the menu is returned. +// - If the server returns 404 because the runtime is unknown to the registry, +// it returns ErrRuntimeNotInRegistry so callers can fail-open for federated +// / third-party runtimes. +// - Any other HTTP error or network failure is returned as a normal error and +// MUST be treated as fail-closed by callers. func (p *Platform) ListOfferedModels(runtime string) (*OfferedModelsResponse, error) { - u, err := url.Parse("/admin/llm/offered-models") + u, err := url.Parse(p.BaseURL + "/admin/llm/offered-models") if err != nil { - return nil, fmt.Errorf("parse offered-models path: %w", err) + return nil, fmt.Errorf("parse offered-models URL: %w", err) } q := u.Query() q.Set("runtime", runtime) u.RawQuery = q.Encode() + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, fmt.Errorf("new GET request: %w", err) + } + p.setAuth(req) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", u.String(), err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusNotFound { + return nil, ErrRuntimeNotInRegistry + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("GET %s: HTTP %d — %s", u.String(), resp.StatusCode, string(body)) + } + var out OfferedModelsResponse - if err := p.getInto(u.String(), &out); err != nil { - return nil, err + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("decode GET %s: %w", u.String(), err) } return &out, nil } diff --git a/internal/cmd/workspace_runtime_model.go b/internal/cmd/workspace_runtime_model.go index e508220..d72ef8f 100644 --- a/internal/cmd/workspace_runtime_model.go +++ b/internal/cmd/workspace_runtime_model.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "strings" @@ -146,9 +147,21 @@ func requireModelCompatibleWithRuntime(cl *client.Platform, currentModel, target offered, err := cl.ListOfferedModels(targetRuntime) if err != nil { - // If the runtime is unknown to the registry, we cannot validate here; - // the server owns the final decision on unregistered runtimes. - return nil + // Only federated / non-first-party runtimes (unknown to the registry) + // are allowed through. Every other fetch failure is treated as + // ambiguous and therefore fail-closed — we cannot prove the switch + // is safe, so we reject it. + if errors.Is(err, client.ErrRuntimeNotInRegistry) { + return nil + } + return &exitError{ + code: 1, + msg: fmt.Sprintf( + "could not verify model compatibility for runtime %q: %v; "+ + "set a compatible model first with `molecule workspace set-model ` and retry", + targetRuntime, err, + ), + } } for _, m := range offered.Models { diff --git a/internal/cmd/workspace_runtime_model_test.go b/internal/cmd/workspace_runtime_model_test.go index 30a5208..b4634a2 100644 --- a/internal/cmd/workspace_runtime_model_test.go +++ b/internal/cmd/workspace_runtime_model_test.go @@ -90,3 +90,26 @@ func TestRequireModelCompatibleWithRuntime_UnknownRuntimeAllowed(t *testing.T) { t.Fatalf("unknown runtime should fail-open: %v", err) } } + +func TestRequireModelCompatibleWithRuntime_TransientErrorFailsClosed(t *testing.T) { + // Any non-404 error from the offered-models endpoint must be treated as + // ambiguous and fail-closed; otherwise a transient 5xx/network blip lets + // an unsafe runtime switch through. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) + })) + defer server.Close() + + cl := client.NewWithAuth(server.URL, "token", "org") + err := requireModelCompatibleWithRuntime(cl, "claude-sonnet-4-6", "claude-code") + if err == nil { + t.Fatal("expected transient error to fail closed") + } + var ee *exitError + if !errors.As(err, &ee) { + t.Fatalf("expected exitError, got %T", err) + } + if ee.code != 1 { + t.Errorf("expected exit code 1, got %d", ee.code) + } +} -- 2.52.0