diff --git a/internal/runtime/registry.go b/internal/runtime/registry.go index 3302ffe61d..7295b73cf4 100644 --- a/internal/runtime/registry.go +++ b/internal/runtime/registry.go @@ -2,6 +2,7 @@ package runtime import ( "fmt" + "slices" "strings" "github.com/fullsend-ai/fullsend/internal/config" @@ -29,19 +30,43 @@ func Resolve(name string) (Backend, error) { } // ResolveFromConfig selects the runtime backend from org config defaults. +// The runtime name is validated against [config.ValidRuntimes] before +// resolution so that stub runtimes registered in [Resolve] for dev/testing +// cannot be activated through config files. func ResolveFromConfig(cfg config.OrgConfigReader) (Backend, error) { rt := "claude" if cfg != nil && cfg.OrgRepoDefaults().Runtime != "" { rt = cfg.OrgRepoDefaults().Runtime } + if err := validateConfigRuntime(rt); err != nil { + return Backend{}, err + } return Resolve(rt) } // ResolveFromPerRepoConfig selects the runtime backend from per-repo config. +// The runtime name is validated against [config.ValidRuntimes] before +// resolution so that stub runtimes registered in [Resolve] for dev/testing +// cannot be activated through config files. func ResolveFromPerRepoConfig(cfg config.PerRepoConfigReader) (Backend, error) { rt := "claude" if cfg != nil && cfg.ConfigRuntime() != "" { rt = cfg.ConfigRuntime() } + if err := validateConfigRuntime(rt); err != nil { + return Backend{}, err + } return Resolve(rt) } + +// validateConfigRuntime checks that rt is in the set of user-facing +// runtimes allowed in config files. Stub runtimes (e.g. "opencode") +// are intentionally excluded from [config.ValidRuntimes] so they +// cannot be activated through org or per-repo config. +func validateConfigRuntime(rt string) error { + valid := config.ValidRuntimes() + if !slices.Contains(valid, rt) { + return fmt.Errorf("invalid runtime %q in config: must be one of %s", rt, strings.Join(valid, ", ")) + } + return nil +} diff --git a/internal/runtime/registry_test.go b/internal/runtime/registry_test.go index e0b29d13b6..a79950078c 100644 --- a/internal/runtime/registry_test.go +++ b/internal/runtime/registry_test.go @@ -69,14 +69,6 @@ func TestResolveFromPerRepoConfig(t *testing.T) { require.NoError(t, err) assert.Equal(t, "dummy", dummyBackend.Runtime.Name()) - // opencode is not in ValidRuntimes() but is resolvable via Resolve(). - // A hand-written config bypassing validation can reach the stub. - ocCfg := config.NewPerRepoConfig(nil, "") - ocCfg.SetRuntime("opencode") - ocBackend, err := ResolveFromPerRepoConfig(ocCfg) - require.NoError(t, err) - assert.Equal(t, "opencode", ocBackend.Runtime.Name()) - // pi is user-selectable (#6464). piCfg := config.NewPerRepoConfig(nil, "") piCfg.SetRuntime("pi") @@ -89,3 +81,44 @@ func TestResolveFromPerRepoConfig(t *testing.T) { _, err = ResolveFromPerRepoConfig(invalidCfg) require.Error(t, err) } + +func TestResolveFromPerRepoConfig_RejectsStubRuntimes(t *testing.T) { + t.Parallel() + + // Stub runtimes like "opencode" are resolvable via Resolve() for + // dev/testing, but must be rejected when coming through config. + for _, name := range []string{"opencode"} { + ocCfg := config.NewPerRepoConfig(nil, "") + ocCfg.SetRuntime(name) + _, err := ResolveFromPerRepoConfig(ocCfg) + require.Error(t, err, "stub runtime %q should fail via config path", name) + assert.Contains(t, err.Error(), "invalid runtime") + } + + // Direct Resolve() still works for dev/testing. + rt, err := Resolve("opencode") + require.NoError(t, err) + assert.Equal(t, "opencode", rt.Runtime.Name()) +} + +func TestResolveFromConfig_RejectsStubRuntimes(t *testing.T) { + t.Parallel() + + // Org config with a stub runtime should fail at resolution time. + cfg, parseErr := config.ParseOrgConfig([]byte(`version: "1" +dispatch: + platform: github-actions +defaults: + roles: [triage] + runtime: opencode +repos: {} +`)) + // ParseOrgConfig calls Validate() which also rejects "opencode", + // so this may fail at parse time. If parsing succeeds (e.g. because + // Validate() is not called), ResolveFromConfig must still reject it. + if parseErr == nil { + _, err := ResolveFromConfig(cfg) + require.Error(t, err, "stub runtime %q should fail via org config path", "opencode") + assert.Contains(t, err.Error(), "invalid runtime") + } +}