From ea3bae5068fc9525065e4c47d6c1cbb920e36ffa Mon Sep 17 00:00:00 2001 From: claude-ceo-assistant Date: Sun, 31 May 2026 20:05:35 -0700 Subject: [PATCH] fix(provision): fail loud on runtime-seed mismatch instead of silent claude-code fallback (#2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a workspace NAMES a runtime but the config.yaml about to be seeded declares a different top-level runtime, refuse to launch and surface WORKSPACE_PROVISION_FAILED — the symmetric counterpart to selectImage's ErrUnresolvableRuntime guard, on the config/template side. Pre-fix: if a runtime's workspace template wasn't in the tenant cache at provision time (or sanitizeRuntime coerced an unknown runtime), config seeding silently fell back to claude-code-default. The image+env said e.g. google-adk but the seeded config said claude-code, so the agent booted mislabeled and personaless yet looked 'online' and returned canned non-answers (hit the molecule-adk-demo hackathon org: 4 google-adk agents). The guard is in prepareProvisionContext (shared by Docker + SaaS paths). Empty requested runtime (org-template default path) and an indeterminate seeded runtime (CP mode, no local config bytes) are both allowed — it only fails on a concrete, contradictory signal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace_provision_runtime_seed_test.go | 110 ++++++++++++++++++ .../handlers/workspace_provision_shared.go | 89 ++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 workspace-server/internal/handlers/workspace_provision_runtime_seed_test.go diff --git a/workspace-server/internal/handlers/workspace_provision_runtime_seed_test.go b/workspace-server/internal/handlers/workspace_provision_runtime_seed_test.go new file mode 100644 index 000000000..2d317a86e --- /dev/null +++ b/workspace-server/internal/handlers/workspace_provision_runtime_seed_test.go @@ -0,0 +1,110 @@ +package handlers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseTopLevelRuntime(t *testing.T) { + cases := []struct { + name string + yaml string + want string + }{ + {"top-level claude-code", "name: x\nruntime: claude-code\ntier: 2\n", "claude-code"}, + {"top-level google-adk", "runtime: google-adk\n", "google-adk"}, + {"quoted value", `runtime: "google-adk"` + "\n", "google-adk"}, + {"single-quoted value", "runtime: 'codex'\n", "codex"}, + {"ignores runtime_config nested model", "runtime: google-adk\nruntime_config:\n model: vertex:gemini-2.5-pro\n", "google-adk"}, + {"runtime_config only, no top-level runtime", "name: y\nruntime_config:\n model: x\n", ""}, + {"indented runtime is not top-level", "wrapper:\n runtime: claude-code\n", ""}, + {"empty", "", ""}, + {"no runtime key", "name: z\ntier: 4\n", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := parseTopLevelRuntime([]byte(tc.yaml)); got != tc.want { + t.Fatalf("parseTopLevelRuntime(%q) = %q, want %q", tc.yaml, got, tc.want) + } + }) + } +} + +func TestSeededConfigRuntime(t *testing.T) { + // in-memory configFiles wins over template dir. + t.Run("from configFiles", func(t *testing.T) { + cf := map[string][]byte{"config.yaml": []byte("runtime: google-adk\n")} + if got := seededConfigRuntime("/nonexistent", cf); got != "google-adk" { + t.Fatalf("got %q, want google-adk", got) + } + }) + + // falls back to template dir's config.yaml. + t.Run("from template dir", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("name: a\nruntime: claude-code\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := seededConfigRuntime(dir, nil); got != "claude-code" { + t.Fatalf("got %q, want claude-code", got) + } + }) + + // nothing available → "". + t.Run("indeterminate", func(t *testing.T) { + if got := seededConfigRuntime("", nil); got != "" { + t.Fatalf("got %q, want empty", got) + } + if got := seededConfigRuntime("/does/not/exist", map[string][]byte{}); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) +} + +func TestRuntimeSeedMismatchAbort(t *testing.T) { + adkCfg := map[string][]byte{"config.yaml": []byte("runtime: google-adk\n")} + ccCfg := map[string][]byte{"config.yaml": []byte("name: Claude Code Agent\nruntime: claude-code\n")} + + t.Run("mismatch fails loud (the #2027 demo bug)", func(t *testing.T) { + // requested google-adk, but seeding the claude-code-default config. + abort := runtimeSeedMismatchAbort("google-adk", "", ccCfg) + if abort == nil { + t.Fatal("expected abort for google-adk requested but claude-code seeded, got nil") + } + if abort.Extra["requested_runtime"] != "google-adk" || abort.Extra["seeded_runtime"] != "claude-code" { + t.Fatalf("abort.Extra mismatch: %+v", abort.Extra) + } + if abort.Extra["issue"] != "2027" { + t.Fatalf("expected issue 2027 tag, got %v", abort.Extra["issue"]) + } + }) + + t.Run("match is allowed", func(t *testing.T) { + if abort := runtimeSeedMismatchAbort("google-adk", "", adkCfg); abort != nil { + t.Fatalf("expected no abort when seeded runtime matches, got %q", abort.Msg) + } + }) + + t.Run("empty requested runtime is allowed (org-template default path)", func(t *testing.T) { + if abort := runtimeSeedMismatchAbort("", "", ccCfg); abort != nil { + t.Fatalf("expected no abort for unspecified runtime, got %q", abort.Msg) + } + }) + + t.Run("indeterminate seed is allowed (CP mode, no local config bytes)", func(t *testing.T) { + if abort := runtimeSeedMismatchAbort("google-adk", "", nil); abort != nil { + t.Fatalf("expected no abort when seeded runtime is indeterminate, got %q", abort.Msg) + } + }) + + t.Run("mismatch via template dir also fails loud", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("runtime: claude-code\n"), 0o600); err != nil { + t.Fatal(err) + } + if abort := runtimeSeedMismatchAbort("hermes", dir, nil); abort == nil { + t.Fatal("expected abort for hermes requested but claude-code template seeded") + } + }) +} diff --git a/workspace-server/internal/handlers/workspace_provision_shared.go b/workspace-server/internal/handlers/workspace_provision_shared.go index a3ae0cbf0..38c471e4a 100644 --- a/workspace-server/internal/handlers/workspace_provision_shared.go +++ b/workspace-server/internal/handlers/workspace_provision_shared.go @@ -37,8 +37,11 @@ package handlers import ( "context" "errors" + "fmt" "log" + "os" "path/filepath" + "strings" "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db" "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events" @@ -263,6 +266,22 @@ func (h *WorkspaceHandler) prepareProvisionContext( } } + // Preflight: runtime-seed match (issue #2027). Fail LOUD when a workspace + // NAMED a runtime but the config.yaml we're about to seed declares a + // different top-level runtime — the symmetric counterpart to selectImage's + // ErrUnresolvableRuntime guard, on the config/template side. Pre-fix, when a + // runtime's workspace template wasn't in the tenant cache at provision time + // (or sanitizeRuntime coerced an unknown runtime), seeding silently fell + // back to the claude-code-default template: the image+env said e.g. + // google-adk but the seeded config said claude-code, so the agent booted + // mislabeled and personaless yet looked 'online' and returned canned + // non-answers. Refusing loudly turns that silent wrong-agent into a visible + // WORKSPACE_PROVISION_FAILED the operator can act on. + if abort := runtimeSeedMismatchAbort(payload.Runtime, templatePath, configFiles); abort != nil { + log.Printf("Provisioner: ABORT workspace=%s — %s", workspaceID, abort.Msg) + return nil, abort + } + cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath) cfg.ResetClaudeSession = resetClaudeSession @@ -273,6 +292,76 @@ func (h *WorkspaceHandler) prepareProvisionContext( }, nil } +// runtimeSeedMismatchAbort returns a non-nil abort when a workspace NAMED a +// runtime but the config.yaml about to be seeded declares a *different* +// top-level runtime — the fail-loud counterpart to selectImage's +// ErrUnresolvableRuntime (issue #2027). It catches the silent +// claude-code-default substitution that occurs when a runtime's workspace +// template isn't cached at provision time (or sanitizeRuntime coerced an +// unknown runtime to claude-code): both surface as a seeded config whose +// runtime contradicts the requested one. +// +// Pure (modulo reading the template dir's config.yaml). An empty +// requestedRuntime (unspecified / org-template default path) or an +// indeterminate seeded runtime (e.g. CP mode with no local config bytes) is +// allowed — we only fail on a concrete, contradictory signal, never on +// absence of one. +func runtimeSeedMismatchAbort(requestedRuntime, templatePath string, configFiles map[string][]byte) *provisionAbort { + if requestedRuntime == "" { + return nil + } + seeded := seededConfigRuntime(templatePath, configFiles) + if seeded == "" || seeded == requestedRuntime { + return nil + } + msg := fmt.Sprintf( + "runtime seed mismatch: workspace requested runtime %q but the seeded config.yaml declares %q — the %q workspace template was not available at provision time (silent %q fallback). Refusing to launch a mislabeled agent; refresh the template cache (POST /admin/templates/refresh) and re-provision.", + requestedRuntime, seeded, requestedRuntime, seeded, + ) + return &provisionAbort{ + Msg: msg, + Extra: map[string]interface{}{ + "error": msg, + "requested_runtime": requestedRuntime, + "seeded_runtime": seeded, + "issue": "2027", + }, + } +} + +// seededConfigRuntime extracts the top-level `runtime:` from the config.yaml +// that will be seeded into the workspace — preferring the in-memory +// configFiles, falling back to the template directory on disk. Returns "" +// when no config.yaml is available or it declares no top-level runtime. +func seededConfigRuntime(templatePath string, configFiles map[string][]byte) string { + if data, ok := configFiles["config.yaml"]; ok { + return parseTopLevelRuntime(data) + } + if templatePath != "" { + if data, err := os.ReadFile(filepath.Join(templatePath, "config.yaml")); err == nil { + return parseTopLevelRuntime(data) + } + } + return "" +} + +// parseTopLevelRuntime returns the value of the top-level `runtime:` key in a +// config.yaml, ignoring the nested `runtime_config:` block. A small dedicated +// line scanner (mirrors the one the Create handler uses to read a template's +// runtime) so the provision-time guard needs no YAML dependency. +func parseTopLevelRuntime(data []byte) string { + for _, raw := range strings.Split(string(data), "\n") { + trimmed := strings.TrimLeft(raw, " \t") + if len(raw) > len(trimmed) { + continue // indented — inside a nested block (e.g. runtime_config:) + } + if strings.HasPrefix(trimmed, "runtime:") && !strings.HasPrefix(trimmed, "runtime_config") { + return strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "runtime:")), `"'`) + } + } + return "" +} + // mintWorkspaceSecrets issues + persists the workspace auth token // AND the platform→workspace inbound secret (#2312). Both modes MUST // call this — Docker mints + writes to local config volume; SaaS -- 2.52.0