feat(canonical): validate profile name, apply env-var substitution before render

This commit is contained in:
2026-05-23 11:55:26 +01:00
parent 5e35dc47fe
commit 50059d7517
14 changed files with 423 additions and 23 deletions
+74
View File
@@ -0,0 +1,74 @@
package canonical
import (
"fmt"
"github.com/lukaszraczylo/harness-sync/internal/secrets"
)
// SubstituteSecrets walks the bundle and replaces ${VAR} placeholders in all
// config fields using lookup. Skill/agent/instruction bodies are intentionally
// NOT substituted — they are content, not config.
//
// Returns an error (wrapping the offending field path) if any referenced
// variable is missing from lookup.
func SubstituteSecrets(b *Bundle, lookup secrets.Lookup) error {
var err error
// Gateway
if b.Profile.Gateway.Token, err = sub(lookup, "profile.gateway.token", b.Profile.Gateway.Token); err != nil {
return err
}
if b.Profile.Gateway.URL, err = sub(lookup, "profile.gateway.url", b.Profile.Gateway.URL); err != nil {
return err
}
if b.Profile.Gateway.DefaultModel, err = sub(lookup, "profile.gateway.default_model", b.Profile.Gateway.DefaultModel); err != nil {
return err
}
// Upstreams
for i := range b.Profile.Upstreams {
u := &b.Profile.Upstreams[i]
field := fmt.Sprintf("profile.upstreams[%d](%s)", i, u.Name)
if u.APIKey, err = sub(lookup, field+".api_key", u.APIKey); err != nil {
return err
}
if u.BaseURL, err = sub(lookup, field+".base_url", u.BaseURL); err != nil {
return err
}
}
// MCP servers
for i := range b.MCP.Servers {
sv := &b.MCP.Servers[i]
field := fmt.Sprintf("mcp.servers[%d](%s)", i, sv.Name)
if sv.Command, err = sub(lookup, field+".command", sv.Command); err != nil {
return err
}
if sv.URL, err = sub(lookup, field+".url", sv.URL); err != nil {
return err
}
for j, arg := range sv.Args {
if sv.Args[j], err = sub(lookup, fmt.Sprintf("%s.args[%d]", field, j), arg); err != nil {
return err
}
}
for k, v := range sv.Env {
if sv.Env[k], err = sub(lookup, fmt.Sprintf("%s.env[%s]", field, k), v); err != nil {
return err
}
}
}
return nil
}
// sub applies secrets.Substitute and wraps errors with field context.
func sub(lookup secrets.Lookup, field, value string) (string, error) {
result, err := secrets.Substitute(value, lookup)
if err != nil {
return "", fmt.Errorf("%s: %w", field, err)
}
return result, nil
}
+101
View File
@@ -0,0 +1,101 @@
package canonical
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// lookup returns env values from the provided map; missing keys return false.
func mapLookup(m map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
v, ok := m[name]
return v, ok
}
}
func TestSubstituteSecretsGatewayToken(t *testing.T) {
b := &Bundle{
Profile: Profile{
Name: "test",
Gateway: Gateway{
Token: "${MY_TOKEN}",
URL: "https://gw.lan",
},
},
}
err := SubstituteSecrets(b, mapLookup(map[string]string{"MY_TOKEN": "secret123"}))
require.NoError(t, err)
assert.Equal(t, "secret123", b.Profile.Gateway.Token)
assert.Equal(t, "https://gw.lan", b.Profile.Gateway.URL)
}
func TestSubstituteSecretsUpstreamAPIKey(t *testing.T) {
b := &Bundle{
Profile: Profile{
Name: "test",
Upstreams: []Upstream{
{Name: "openai", APIKey: "${OPENAI_KEY}", BaseURL: "https://api.openai.com"},
},
},
}
err := SubstituteSecrets(b, mapLookup(map[string]string{"OPENAI_KEY": "sk-abc"}))
require.NoError(t, err)
assert.Equal(t, "sk-abc", b.Profile.Upstreams[0].APIKey)
assert.Equal(t, "https://api.openai.com", b.Profile.Upstreams[0].BaseURL)
}
func TestSubstituteSecretsMCPEnv(t *testing.T) {
b := &Bundle{
MCP: MCPRegistry{
Servers: []MCPServer{
{Name: "myserver", Env: map[string]string{"API_KEY": "${SRV_KEY}"}},
},
},
}
err := SubstituteSecrets(b, mapLookup(map[string]string{"SRV_KEY": "srv-secret"}))
require.NoError(t, err)
assert.Equal(t, "srv-secret", b.MCP.Servers[0].Env["API_KEY"])
}
func TestSubstituteSecretsMCPArgs(t *testing.T) {
b := &Bundle{
MCP: MCPRegistry{
Servers: []MCPServer{
{Name: "myserver", Args: []string{"--token", "${MCP_TOKEN}", "--plain"}},
},
},
}
err := SubstituteSecrets(b, mapLookup(map[string]string{"MCP_TOKEN": "tok"}))
require.NoError(t, err)
assert.Equal(t, []string{"--token", "tok", "--plain"}, b.MCP.Servers[0].Args)
}
func TestSubstituteSecretsMissingVarErrors(t *testing.T) {
b := &Bundle{
Profile: Profile{
Name: "test",
Gateway: Gateway{
Token: "${MISSING_TOKEN}",
},
},
}
err := SubstituteSecrets(b, mapLookup(map[string]string{}))
require.Error(t, err)
assert.Contains(t, err.Error(), "MISSING_TOKEN")
assert.Contains(t, err.Error(), "profile.gateway.token")
}
func TestSubstituteSecretsSkillBodyUntouched(t *testing.T) {
b := &Bundle{
Profile: Profile{Name: "test"},
Skills: []Skill{
{Name: "mys", Body: "Use ${FOO} in your prompt."},
},
}
err := SubstituteSecrets(b, mapLookup(map[string]string{}))
require.NoError(t, err)
// skill body must be unchanged — ${FOO} is content, not config
assert.Equal(t, "Use ${FOO} in your prompt.", b.Skills[0].Body)
}
@@ -0,0 +1 @@
active_profile: [bad yaml: {
@@ -0,0 +1,2 @@
enabled_harnesses: [claude-code]
active_profile: nonexistent
@@ -0,0 +1,2 @@
enabled_harnesses: [claude-code]
active_profile: noname
@@ -0,0 +1,5 @@
gateway:
url: https://gw.lan
token: dummy
models:
- id: claude-sonnet-4-6