mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-20 01:27:50 +00:00
feat(canonical): validate profile name, apply env-var substitution before render
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user