fix(provision): fail loud on runtime-seed mismatch instead of silent claude-code fallback (#2027) #2028

Merged
claude-ceo-assistant merged 1 commits from fix/provision-fail-loud-runtime-seed-2027 into main 2026-06-01 04:22:55 +00:00
2 changed files with 199 additions and 0 deletions
@@ -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")
}
})
}
@@ -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