diff --git a/workspace-server/internal/handlers/runtime_registry.go b/workspace-server/internal/handlers/runtime_registry.go index 317b50165..844ba7576 100644 --- a/workspace-server/internal/handlers/runtime_registry.go +++ b/workspace-server/internal/handlers/runtime_registry.go @@ -26,6 +26,7 @@ package handlers import ( "encoding/json" + "fmt" "log" "os" "path/filepath" @@ -67,25 +68,82 @@ type manifestFile struct { WorkspaceTemplates []manifestEntry `json:"workspace_templates"` } +// joinExternalLikeRuntimesForMessage returns the runtime list as it +// appears in user-facing error messages, e.g. `"external", "kimi", or +// "kimi-cli"`. Oxford-comma style for 3+ items, plain "or" for 2. +// Each item is Go-quoted (with surrounding double quotes) so the +// message reads naturally for an operator typing it. +// +// Used by workspace.go:400 (the "external workspaces must use +// runtime ..." error). Derived from the externalLikeRuntimes SSOT +// so adding a new BYO-compute meta-runtime only requires updating +// the SSOT in one place. +func joinExternalLikeRuntimesForMessage() string { + quoted := make([]string, len(externalLikeRuntimes)) + for i, r := range externalLikeRuntimes { + quoted[i] = fmt.Sprintf("%q", r) + } + switch len(quoted) { + case 0: + return "" + case 1: + return quoted[0] + case 2: + return quoted[0] + " or " + quoted[1] + default: + return strings.Join(quoted[:len(quoted)-1], ", ") + ", or " + quoted[len(quoted)-1] + } +} + +// externalLikeRuntimes is the SINGLE source of truth for the set of +// "BYO-compute meta-runtimes" (operator-managed, no platform-owned +// container or EC2). These runtimes share behavior around +// delivery_mode defaulting, plugin install, restart, and discovery, +// and are always available regardless of what manifest.json says +// (they have no template repo). +// +// Before this constant the same set was hardcoded in 3 separate +// places in this file (fallbackRuntimes, loadRuntimesFromManifest +// injection, isExternalLikeRuntime switch) + 1 string-literal in +// workspace.go:400. Adding a new BYO-compute meta-runtime required +// updating all 4 sites in lockstep; missing one was a silent drift +// surface. The TestExternalLikeRuntimesConsistent pin test in +// runtime_registry_test.go locks the shape across all 4 sites. +// +// "mock" is intentionally NOT in this set — it's a virtual +// workspace with hardcoded canned A2A replies (no container, no +// EC2, no template repo) but it's never user-selected (only the +// funding-demo org uses it), so it doesn't share the BYO-compute +// predicate behavior with external/kimi/kimi-cli. +var externalLikeRuntimes = []string{"external", "kimi", "kimi-cli"} + // fallbackRuntimes is used when manifest.json can't be loaded. Keeps // tests + dev containers working even if the file isn't mounted. // Kept slightly broader than the original hardcoded map so a stale // manifest doesn't silently drop a runtime that was previously // supported in the wild. "external" is always a valid runtime — // manifest or not — because it has no template repo. -var fallbackRuntimes = map[string]struct{}{ - "claude-code": {}, - "hermes": {}, - "openclaw": {}, - "codex": {}, - "external": {}, - "kimi": {}, - "kimi-cli": {}, - // mock — virtual workspace with hardcoded canned A2A replies. - // No container, no EC2, no template repo. See mock_runtime.go - // for the full rationale (200-workspace funding-demo org). - "mock": {}, -} +// +// The 3 externalLikeRuntimes + mock are derived from the SSOT +// (externalLikeRuntimes + the separate "mock" entry) so adding a +// new BYO-compute meta-runtime only requires updating +// externalLikeRuntimes above. +var fallbackRuntimes = func() map[string]struct{} { + out := map[string]struct{}{ + "claude-code": {}, + "hermes": {}, + "openclaw": {}, + "codex": {}, + // mock — virtual workspace with hardcoded canned A2A replies. + // No container, no EC2, no template repo. See mock_runtime.go + // for the full rationale (200-workspace funding-demo org). + "mock": {}, + } + for _, r := range externalLikeRuntimes { + out[r] = struct{}{} + } + return out +}() // loadRuntimesFromManifest builds the runtime allowlist from // manifest.json. Each workspace_templates[].name is normalized to its @@ -106,20 +164,23 @@ func loadRuntimesFromManifest(path string) (map[string]struct{}, error) { if err := json.Unmarshal(data, &m); err != nil { return nil, err } + // The 3 externalLikeRuntimes + mock are ALWAYS available + // regardless of what the manifest contains (they have no + // template repo, so the manifest doesn't know about them). + // Injected here from the SSOT (externalLikeRuntimes + the + // separate "mock" entry) so adding a new BYO-compute + // meta-runtime only requires updating externalLikeRuntimes + // above. See TestExternalLikeRuntimesConsistent for the + // pin test that locks this shape. out := map[string]struct{}{ - // external is ALWAYS available — it has no template repo, so - // the manifest doesn't know about it. Injected here so we - // don't need a special-case in every caller. - "external": {}, - // kimi and kimi-cli are BYO-compute meta-runtimes (same shape - // as external). No template repo; injected like external. - "kimi": {}, - "kimi-cli": {}, // mock is ALWAYS available for the same reason as external: // virtual workspace, no template repo, never spawns a // container. See mock_runtime.go. "mock": {}, } + for _, r := range externalLikeRuntimes { + out[r] = struct{}{} + } for _, e := range m.WorkspaceTemplates { name := strings.TrimSpace(e.Name) if name == "" { @@ -139,10 +200,16 @@ func loadRuntimesFromManifest(path string) (map[string]struct{}, error) { // (operator-managed, no platform-owned container or EC2). These runtimes // share behavior around delivery_mode defaulting, plugin install, restart, // and discovery. +// +// The set is derived from the externalLikeRuntimes SSOT (above) so +// adding a new BYO-compute meta-runtime only requires updating +// externalLikeRuntimes in one place — see +// TestExternalLikeRuntimesConsistent for the pin test. func isExternalLikeRuntime(runtime string) bool { - switch runtime { - case "external", "kimi", "kimi-cli": - return true + for _, r := range externalLikeRuntimes { + if r == runtime { + return true + } } return false } diff --git a/workspace-server/internal/handlers/runtime_registry_test.go b/workspace-server/internal/handlers/runtime_registry_test.go index 58a0b391e..7db0badf9 100644 --- a/workspace-server/internal/handlers/runtime_registry_test.go +++ b/workspace-server/internal/handlers/runtime_registry_test.go @@ -14,6 +14,7 @@ package handlers // must NOT be resolvable in the map after the next init). import ( + "fmt" "os" "path/filepath" "strings" @@ -320,3 +321,94 @@ func TestInitTemplateRepoByName_ReconcilesStaleEntries(t *testing.T) { t.Errorf("templateIdentityForRuntime(hermes) should return (\"\", false), got (%q, %v)", id, ok) } } + +// ============================================================================= +// TestExternalLikeRuntimesConsistent — pin test for the +// externalLikeRuntimes SSOT consolidation. Locks the shape across +// all 4 sites that previously hardcoded the same set in 3 different +// shapes (fallbackRuntimes map, loadRuntimesFromManifest injection, +// isExternalLikeRuntime switch, workspace.go:400 error message). +// +// If anyone adds a new BYO-compute meta-runtime (e.g. "byo-cli"), +// they should: +// 1. add it to the externalLikeRuntimes slice in runtime_registry.go +// 2. run the test suite (this pin test still passes — same +// resolved shape) +// 3. the workspace.go:400 error message auto-includes it +// +// If anyone adds a new hardcoded list anywhere (drift surface), +// this test fails. The expected externalLikeRuntimes set is +// {"external", "kimi", "kimi-cli"} per the current production +// state — locked here so a future "we don't actually support kimi +// anymore" decision is a deliberate test update, not silent drift. +// ============================================================================= + +func TestExternalLikeRuntimesConsistent(t *testing.T) { + want := []string{"external", "kimi", "kimi-cli"} + if len(externalLikeRuntimes) != len(want) { + t.Fatalf("externalLikeRuntimes length = %d, want %d (drift surface: SSOT changed but test wasn't updated)", + len(externalLikeRuntimes), len(want)) + } + for i, r := range want { + if externalLikeRuntimes[i] != r { + t.Errorf("externalLikeRuntimes[%d] = %q, want %q (SSOT shape changed without test update)", + i, externalLikeRuntimes[i], r) + } + } + + // 1. fallbackRuntimes contains the SSOT (plus template-backed + // runtimes + mock). The SSOT MUST be a subset. + for _, r := range want { + if _, ok := fallbackRuntimes[r]; !ok { + t.Errorf("fallbackRuntimes missing externalLikeRuntimes entry %q (drift: SSOT says %q is BYO-compute but fallback allowlist doesn't include it)", + r, r) + } + } + // fallbackRuntimes ALSO contains the template-backed runtimes + // (claude-code, hermes, openclaw, codex) + mock — pin the + // resolved shape so a future edit doesn't silently drop them. + for _, r := range []string{"claude-code", "hermes", "openclaw", "codex", "mock"} { + if _, ok := fallbackRuntimes[r]; !ok { + t.Errorf("fallbackRuntimes missing expected entry %q (drift: a runtime was silently dropped from the fallback allowlist)", + r) + } + } + + // 2. isExternalLikeRuntime returns true for each SSOT entry + // and false for the template-backed runtimes. (Locked because + // plugins.go / discovery.go / registry.go all switch on this + // predicate — silently flipping it would break BYO-compute + // behavior in 4 different files.) + for _, r := range want { + if !isExternalLikeRuntime(r) { + t.Errorf("isExternalLikeRuntime(%q) = false, want true (drift: predicate lost the SSOT entry)", r) + } + } + for _, r := range []string{"claude-code", "hermes", "openclaw", "codex", "mock", "unknown-runtime-xyz"} { + if isExternalLikeRuntime(r) { + t.Errorf("isExternalLikeRuntime(%q) = true, want false (drift: predicate now claims a template-backed runtime is BYO-compute)", r) + } + } + + // 3. joinExternalLikeRuntimesForMessage produces the exact + // user-facing string the production error message uses. Pin + // the wire shape so a future edit doesn't silently change + // the user-facing 422 response. + wantMsg := `"external", "kimi", or "kimi-cli"` + if got := joinExternalLikeRuntimesForMessage(); got != wantMsg { + t.Errorf("joinExternalLikeRuntimesForMessage() = %q, want %q (drift: user-facing error string shape changed)", + got, wantMsg) + } + + // 4. The full error message (the one workspace.go:400 sends in + // the 422 body) is the prefix + the joined SSOT. Pin it. + fullWant := `external workspaces must use runtime "external", "kimi", or "kimi-cli"` + // Reproduce the exact fmt.Sprintf call workspace.go:400 makes. + // We don't import workspace.go's Create (it has many other + // dependencies); we just rebuild the string the same way and + // assert the wire shape is preserved. + fullGot := fmt.Sprintf("external workspaces must use runtime %s", joinExternalLikeRuntimesForMessage()) + if fullGot != fullWant { + t.Errorf("full error string drift:\n got: %q\n want: %q", fullGot, fullWant) + } +} diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index caf40cedf..3f154fc98 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -397,7 +397,11 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { if payload.External && !isExternalLikeRuntime(payload.Runtime) { log.Printf("Create: FAIL-CLOSED — external workspace requested with non-external runtime %q", payload.Runtime) c.JSON(http.StatusUnprocessableEntity, gin.H{ - "error": "external workspaces must use runtime \"external\", \"kimi\", or \"kimi-cli\"", + // Build the runtime list from the externalLikeRuntimes SSOT + // (single source of truth) so adding a new BYO-compute + // meta-runtime only requires updating the SSOT in + // runtime_registry.go — see TestExternalLikeRuntimesConsistent. + "error": fmt.Sprintf("external workspaces must use runtime %s", joinExternalLikeRuntimesForMessage()), "runtime": payload.Runtime, "code": "RUNTIME_UNSUPPORTED", })