fix(provision): fail loud on runtime-seed mismatch instead of silent claude-code fallback (#2027) #2028
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user