diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 149914706..06e15b449 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -377,11 +377,18 @@ export function billingModeForSelectedProvider( // config.yaml` on the container is a separate runtime-internal file, // not this one. const RUNTIMES_WITH_OWN_CONFIG = new Set(["external", "kimi", "kimi-cli", "openclaw"]); -const SUPPORTED_RUNTIME_VALUES = new Set(["claude-code", "codex", "openclaw", "hermes"]); +// The runtime picker is SSOT-driven: options come from GET /templates, +// which workspace-server already gates to the manifest.json maintained set +// (loadRuntimesFromManifest). A hand-maintained frontend allowlist silently +// dropped runtimes the backend added (google-adk shipped in manifest but was +// filtered out, so its workspaces rendered the wrong default option). A +// template may still opt OUT of the picker via `displayable: false` on its +// /templates row. See project_canvas_runtime_dropdown_ssot_fix. const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [ { value: "claude-code", label: "Claude Code", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] }, { value: "codex", label: "Codex", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] }, + { value: "google-adk", label: "Google ADK", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] }, { value: "openclaw", label: "OpenClaw", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] }, { value: "hermes", label: "Hermes", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] }, ]; @@ -585,13 +592,16 @@ export function ConfigTab({ workspaceId }: Props) { registry_backed?: boolean; registry_providers?: RegistryProvider[]; registry_models?: RegistryModel[]; + displayable?: boolean; }>>("/templates") .then((rows) => { if (cancelled || !Array.isArray(rows)) return; const byRuntime = new Map(); for (const r of rows) { const v = (r.runtime || "").trim(); - if (!SUPPORTED_RUNTIME_VALUES.has(v)) continue; + if (!v) continue; + // Honor an explicit opt-out; absent/true means show it. + if (r.displayable === false) continue; // Last template wins if two templates share a runtime — rare, and the // one with the richer models list is probably newer. const existing = byRuntime.get(v); diff --git a/canvas/src/components/tabs/__tests__/ConfigTab.googleAdk.test.tsx b/canvas/src/components/tabs/__tests__/ConfigTab.googleAdk.test.tsx new file mode 100644 index 000000000..9cf4d6bdc --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ConfigTab.googleAdk.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +// +// Regression: project_canvas_runtime_dropdown_ssot_fix — a google-adk +// workspace's Config tab showed the wrong runtime ("LangGraph (default)" +// / first option) because a hardcoded frontend allowlist +// (SUPPORTED_RUNTIME_VALUES) dropped google-adk from the /templates-derived +// options even though the backend served it. A Save from that state would +// PATCH runtime to the wrong value and break the ADK agent. +// +// The fix: the dropdown is SSOT-driven — it trusts GET /templates (which the +// backend already gates to the manifest maintained set) and hides a runtime +// only when its row carries `displayable: false`. This pins: a google-adk +// workspace shows "google-adk" selected, and a displayable:false template is +// not offered. +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import React from "react"; + +afterEach(cleanup); + +const apiGet = vi.fn(); +const apiPatch = vi.fn(); +const apiPut = vi.fn(); +vi.mock("@/lib/api", () => ({ + api: { + get: (path: string) => apiGet(path), + patch: (path: string, body: unknown) => apiPatch(path, body), + put: (path: string, body: unknown) => apiPut(path, body), + post: vi.fn(), + del: vi.fn(), + }, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ restartWorkspace: vi.fn(), updateNodeData: vi.fn() }), + { getState: () => ({ restartWorkspace: vi.fn(), updateNodeData: vi.fn() }) }, + ), +})); + +vi.mock("../AgentCardSection", () => ({ + AgentCardSection: () =>
, +})); + +import { ConfigTab } from "../ConfigTab"; + +function wireApi(templates: Array<{ id: string; name?: string; runtime?: string; models?: unknown[]; displayable?: boolean }>) { + apiGet.mockImplementation((path: string) => { + if (path === "/workspaces/ws-adk") return Promise.resolve({ runtime: "google-adk" }); + if (path === "/workspaces/ws-adk/model") return Promise.resolve({ model: "vertex:gemini-2.5-pro" }); + if (path === "/workspaces/ws-adk/files/config.yaml") return Promise.resolve({ content: "name: adk\nruntime: google-adk\n" }); + if (path === "/templates") return Promise.resolve(templates); + return Promise.reject(new Error(`unmocked api.get: ${path}`)); + }); +} + +beforeEach(() => { + apiGet.mockReset(); + apiPatch.mockReset(); + apiPut.mockReset(); +}); + +describe("ConfigTab — google-adk runtime (SSOT dropdown)", () => { + it("shows google-adk selected in the runtime dropdown (#ssot-fix)", async () => { + wireApi([ + { id: "claude-code", name: "Claude Code", runtime: "claude-code", models: [] }, + { id: "google-adk", name: "Google ADK", runtime: "google-adk", models: [] }, + ]); + render(); + const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i })); + expect((select as HTMLSelectElement).value).toBe("google-adk"); + const opts = Array.from((select as HTMLSelectElement).options).map((o) => o.value); + expect(opts).toContain("google-adk"); + }); + + it("hides a template flagged displayable:false", async () => { + wireApi([ + { id: "google-adk", name: "Google ADK", runtime: "google-adk", models: [] }, + { id: "legacy", name: "Legacy", runtime: "legacy", models: [], displayable: false }, + ]); + render(); + const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i })); + const opts = Array.from((select as HTMLSelectElement).options).map((o) => o.value); + expect(opts).toContain("google-adk"); + expect(opts).not.toContain("legacy"); + }); +}); diff --git a/workspace-server/internal/handlers/templates.go b/workspace-server/internal/handlers/templates.go index c5a0a49f1..839454714 100644 --- a/workspace-server/internal/handlers/templates.go +++ b/workspace-server/internal/handlers/templates.go @@ -224,6 +224,15 @@ type templateSummary struct { // 0 = template hasn't declared one, falls through to canvas's // runtime-profile default. ProvisionTimeoutSeconds int `json:"provision_timeout_seconds,omitempty"` + // Displayable lets a template opt OUT of the canvas runtime picker + // declaratively (config.yaml `displayable: false`) while still being a + // provisionable runtime. nil/absent or true → shown; only an explicit + // false hides it. The canvas runtime dropdown is SSOT-driven off this + // list (no hardcoded frontend allowlist), so this is the single place a + // runtime is hidden from the picker. Pointer so "unset" is distinct from + // "false" and omitempty keeps the payload unchanged for existing + // templates that never declare it. + Displayable *bool `json:"displayable,omitempty"` } // resolveTemplateDir finds the template directory for a workspace on the host. @@ -270,6 +279,7 @@ func (h *TemplatesHandler) List(c *gin.Context) { Runtime string `yaml:"runtime"` Model string `yaml:"model"` Skills []string `yaml:"skills"` + Displayable *bool `yaml:"displayable"` // Top-level `providers:` block — structured registry. Distinct // from runtime_config.providers (slug list) below. Both shapes // coexist in production: claude-code ships the structured @@ -334,6 +344,7 @@ func (h *TemplatesHandler) List(c *gin.Context) { Skills: raw.Skills, SkillCount: len(raw.Skills), ProvisionTimeoutSeconds: raw.RuntimeConfig.ProvisionTimeoutSeconds, + Displayable: raw.Displayable, } // internal#718 P3: serve the SELECTABLE provider/model list from diff --git a/workspace-server/internal/handlers/templates_test.go b/workspace-server/internal/handlers/templates_test.go index eebbf600c..2e5bb9387 100644 --- a/workspace-server/internal/handlers/templates_test.go +++ b/workspace-server/internal/handlers/templates_test.go @@ -1554,3 +1554,86 @@ skills: [] t.Errorf("template Providers unchanged: got %v", got.Providers) } } + +// TestTemplatesList_DisplayableFlag verifies the SSOT-driven runtime-picker +// opt-out: a template's config.yaml `displayable: false` surfaces as a +// non-nil false on the /templates row (canvas hides it), while an absent +// flag stays nil (canvas shows it) and an explicit true surfaces as true. +// This is the backend half of removing the hardcoded frontend allowlist — +// the picker trusts this list, so hiding a runtime must be declarative here. +func TestTemplatesList_DisplayableFlag(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + + tmpDir := t.TempDir() + + mk := func(dir, yaml string) { + d := filepath.Join(tmpDir, dir) + if err := os.MkdirAll(d, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "config.yaml"), []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + } + // absent → nil + mk("adk-shown", "name: ADK Shown\nruntime: claude-code\n") + // explicit false → hidden marker + mk("adk-hidden", "name: ADK Hidden\nruntime: claude-code\ndisplayable: false\n") + // explicit true → shown marker + mk("adk-explicit", "name: ADK Explicit\nruntime: claude-code\ndisplayable: true\n") + + handler := NewTemplatesHandler(tmpDir, nil, nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/templates", nil) + handler.List(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp []templateSummary + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v", err) + } + byID := map[string]templateSummary{} + for _, s := range resp { + byID[s.ID] = s + } + + if s, ok := byID["adk-shown"]; !ok { + t.Fatal("adk-shown missing") + } else if s.Displayable != nil { + t.Errorf("adk-shown: expected nil Displayable (absent), got %v", *s.Displayable) + } + + if s, ok := byID["adk-hidden"]; !ok { + t.Fatal("adk-hidden missing") + } else if s.Displayable == nil || *s.Displayable != false { + t.Errorf("adk-hidden: expected non-nil false Displayable, got %v", s.Displayable) + } + + if s, ok := byID["adk-explicit"]; !ok { + t.Fatal("adk-explicit missing") + } else if s.Displayable == nil || *s.Displayable != true { + t.Errorf("adk-explicit: expected non-nil true Displayable, got %v", s.Displayable) + } + + // JSON contract: omitempty drops the field entirely when nil so existing + // templates' payloads are byte-unchanged; present when set. + var rawRows []map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &rawRows); err != nil { + t.Fatalf("raw parse: %v", err) + } + for _, row := range rawRows { + id := "" + _ = json.Unmarshal(row["id"], &id) + _, present := row["displayable"] + if id == "adk-shown" && present { + t.Error("adk-shown: displayable key should be omitted when nil") + } + if (id == "adk-hidden" || id == "adk-explicit") && !present { + t.Errorf("%s: displayable key should be present when set", id) + } + } +}