feat(backend): add tiered environment-variable precedence for launch resolution (#2748)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -52,21 +52,27 @@ func (e *SecretError) Unwrap() error { return e.err }
|
||||
// passed to the callback.
|
||||
type RevealFunc func(context.Context, Definition) (string, error)
|
||||
|
||||
// Validate checks source identity and conflicts without revealing any secret
|
||||
// values. Callers may use it as an early shape check; Resolve must still run at
|
||||
// the final composition boundary after all managed runtime definitions exist.
|
||||
// Validate checks source identity, tier precedence, and secret-veto
|
||||
// conflicts without revealing any secret values. Callers may use it as an
|
||||
// early shape check; Resolve must still run at the final composition
|
||||
// boundary after all managed runtime definitions exist. Validate emits no
|
||||
// override record, no log, and no counter increment — the environment
|
||||
// package holds no logger and stays a pure function of its inputs.
|
||||
func Validate(definitions []Definition) error {
|
||||
_, err := selectDefinitions(definitions)
|
||||
_, _, err := selectDefinitions(definitions)
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve sorts definitions deterministically, deduplicates identical source
|
||||
// identities, reports every origin participating in a conflict, and reveals
|
||||
// secrets only after the complete conflict pass succeeds.
|
||||
func Resolve(ctx context.Context, definitions []Definition, reveal RevealFunc) (map[string]string, error) {
|
||||
selected, err := selectDefinitions(definitions)
|
||||
// Resolve sorts definitions deterministically over five named columns,
|
||||
// merges identical source identities, applies the secret veto and then tier
|
||||
// precedence to any remaining ambiguity, and reveals secrets only after the
|
||||
// complete conflict pass succeeds. On any error it returns a nil map and a
|
||||
// nil []OverrideRecord — all or nothing, even when tier precedence had
|
||||
// already resolved other keys before the failing key was reached.
|
||||
func Resolve(ctx context.Context, definitions []Definition, reveal RevealFunc) (map[string]string, []OverrideRecord, error) {
|
||||
selected, records, err := selectDefinitions(definitions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
resolved := make(map[string]string, len(selected))
|
||||
@@ -80,70 +86,220 @@ func Resolve(ctx context.Context, definitions []Definition, reveal RevealFunc) (
|
||||
value := definition.Literal
|
||||
if definition.SecretID != "" {
|
||||
if reveal == nil {
|
||||
return nil, &SecretError{Key: definition.Key, Origin: definition.Origin, err: fmt.Errorf("secret resolver unavailable")}
|
||||
return nil, nil, &SecretError{Key: definition.Key, Origin: definition.Origin, err: fmt.Errorf("secret resolver unavailable")}
|
||||
}
|
||||
value, err = reveal(ctx, definition)
|
||||
if err != nil {
|
||||
return nil, &SecretError{Key: definition.Key, Origin: definition.Origin, err: err}
|
||||
return nil, nil, &SecretError{Key: definition.Key, Origin: definition.Origin, err: err}
|
||||
}
|
||||
}
|
||||
resolved[key] = value
|
||||
}
|
||||
return resolved, nil
|
||||
return resolved, records, nil
|
||||
}
|
||||
|
||||
func selectDefinitions(definitions []Definition) (map[string]Definition, error) {
|
||||
// selectDefinitions implements the resolution procedure: a deterministic
|
||||
// five-column sort, per-key identity merging, the secret veto, and tier
|
||||
// precedence over any remaining ambiguity.
|
||||
func selectDefinitions(definitions []Definition) (map[string]Definition, []OverrideRecord, error) {
|
||||
ordered := append([]Definition(nil), definitions...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
if ordered[i].Key != ordered[j].Key {
|
||||
return ordered[i].Key < ordered[j].Key
|
||||
}
|
||||
if ordered[i].Origin != ordered[j].Origin {
|
||||
return ordered[i].Origin < ordered[j].Origin
|
||||
}
|
||||
return ordered[i].SecretID < ordered[j].SecretID
|
||||
return definitionLess(ordered[i], ordered[j])
|
||||
})
|
||||
|
||||
selected := make(map[string]Definition, len(ordered))
|
||||
origins := make(map[string]map[string]struct{}, len(ordered))
|
||||
conflicts := make(map[string]map[string]struct{})
|
||||
keyOrder := make([]string, 0, len(ordered))
|
||||
buckets := make(map[string][]Definition, len(ordered))
|
||||
for _, definition := range ordered {
|
||||
if strings.TrimSpace(definition.Key) == "" {
|
||||
continue
|
||||
}
|
||||
if prior, exists := selected[definition.Key]; exists {
|
||||
if definitionIdentity(prior) != definitionIdentity(definition) {
|
||||
if conflicts[definition.Key] == nil {
|
||||
conflicts[definition.Key] = make(map[string]struct{})
|
||||
}
|
||||
for origin := range origins[definition.Key] {
|
||||
conflicts[definition.Key][origin] = struct{}{}
|
||||
}
|
||||
conflicts[definition.Key][definition.Origin] = struct{}{}
|
||||
continue
|
||||
}
|
||||
origins[definition.Key][definition.Origin] = struct{}{}
|
||||
continue
|
||||
if _, exists := buckets[definition.Key]; !exists {
|
||||
keyOrder = append(keyOrder, definition.Key)
|
||||
}
|
||||
selected[definition.Key] = definition
|
||||
origins[definition.Key] = map[string]struct{}{definition.Origin: {}}
|
||||
buckets[definition.Key] = append(buckets[definition.Key], definition)
|
||||
}
|
||||
|
||||
if len(conflicts) > 0 {
|
||||
keys := make([]string, 0, len(conflicts))
|
||||
for key := range conflicts {
|
||||
keys = append(keys, key)
|
||||
selected := make(map[string]Definition, len(keyOrder))
|
||||
var records []OverrideRecord
|
||||
var conflictKeys []string
|
||||
conflicts := make(map[string]*ConflictError, len(keyOrder))
|
||||
|
||||
for _, key := range keyOrder {
|
||||
groups := buildIdentityGroups(buckets[key])
|
||||
definition, record, conflictErr := resolveGroups(key, groups)
|
||||
if conflictErr != nil {
|
||||
conflictKeys = append(conflictKeys, key)
|
||||
conflicts[key] = conflictErr
|
||||
continue
|
||||
}
|
||||
sort.Strings(keys)
|
||||
key := keys[0]
|
||||
conflictOrigins := make([]string, 0, len(conflicts[key]))
|
||||
for origin := range conflicts[key] {
|
||||
conflictOrigins = append(conflictOrigins, origin)
|
||||
selected[key] = definition
|
||||
if record != nil {
|
||||
records = append(records, *record)
|
||||
}
|
||||
sort.Strings(conflictOrigins)
|
||||
return nil, &ConflictError{Key: key, Origins: conflictOrigins}
|
||||
}
|
||||
return selected, nil
|
||||
|
||||
if len(conflictKeys) > 0 {
|
||||
sort.Strings(conflictKeys)
|
||||
return nil, nil, conflicts[conflictKeys[0]]
|
||||
}
|
||||
|
||||
sort.Slice(records, func(i, j int) bool { return records[i].Key < records[j].Key })
|
||||
return selected, records, nil
|
||||
}
|
||||
|
||||
// definitionLess orders definitions by the five named columns — Key, Origin,
|
||||
// SecretID, WorkspaceID, Literal, all ascending — the total order the
|
||||
// procedure requires so merged-identity survivorship and revealed values
|
||||
// never depend on input slice order.
|
||||
func definitionLess(a, b Definition) bool {
|
||||
if a.Key != b.Key {
|
||||
return a.Key < b.Key
|
||||
}
|
||||
if a.Origin != b.Origin {
|
||||
return a.Origin < b.Origin
|
||||
}
|
||||
if a.SecretID != b.SecretID {
|
||||
return a.SecretID < b.SecretID
|
||||
}
|
||||
if a.WorkspaceID != b.WorkspaceID {
|
||||
return a.WorkspaceID < b.WorkspaceID
|
||||
}
|
||||
return a.Literal < b.Literal
|
||||
}
|
||||
|
||||
// identityGroup collects every definition sharing one definitionIdentity for
|
||||
// a key. definition is the representative — the first under the total order
|
||||
// — whose Literal/SecretID/WorkspaceID are used for revealing; origins is
|
||||
// the full set of every origin that contributed to this identity.
|
||||
type identityGroup struct {
|
||||
definition Definition
|
||||
origins map[string]struct{}
|
||||
}
|
||||
|
||||
// buildIdentityGroups groups defs (already ordered by definitionLess) by
|
||||
// definitionIdentity, preserving first-seen order.
|
||||
func buildIdentityGroups(defs []Definition) []*identityGroup {
|
||||
var groups []*identityGroup
|
||||
byIdentity := make(map[string]*identityGroup, len(defs))
|
||||
for _, definition := range defs {
|
||||
id := definitionIdentity(definition)
|
||||
group, ok := byIdentity[id]
|
||||
if !ok {
|
||||
group = &identityGroup{definition: definition, origins: map[string]struct{}{}}
|
||||
byIdentity[id] = group
|
||||
groups = append(groups, group)
|
||||
}
|
||||
group.origins[definition.Origin] = struct{}{}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// resolveGroups applies steps 4 through 6 of the resolution procedure to one
|
||||
// key's identity groups: a single surviving identity wins outright; more
|
||||
// than one triggers the secret veto first, then tier precedence.
|
||||
func resolveGroups(key string, groups []*identityGroup) (Definition, *OverrideRecord, *ConflictError) {
|
||||
if len(groups) == 1 {
|
||||
return groups[0].definition, nil, nil
|
||||
}
|
||||
if hasSecretBackedGroup(groups) {
|
||||
return Definition{}, nil, conflictError(key, groups)
|
||||
}
|
||||
|
||||
winner, winningTier, ambiguous := strongestGroup(groups)
|
||||
if ambiguous {
|
||||
return Definition{}, nil, conflictError(key, groups)
|
||||
}
|
||||
return winner.definition, overrideRecord(key, groups, winner, winningTier), nil
|
||||
}
|
||||
|
||||
func hasSecretBackedGroup(groups []*identityGroup) bool {
|
||||
for _, group := range groups {
|
||||
if group.definition.SecretID != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// strongestGroup finds the group whose strongest contributing origin
|
||||
// classifies into the numerically smallest (strongest) tier. ambiguous is
|
||||
// true when more than one group ties for that top tier.
|
||||
func strongestGroup(groups []*identityGroup) (winner *identityGroup, winningTier Tier, ambiguous bool) {
|
||||
winner = groups[0]
|
||||
winningTier = strongestTier(winner.origins)
|
||||
for _, group := range groups[1:] {
|
||||
tier := strongestTier(group.origins)
|
||||
switch {
|
||||
case tier < winningTier:
|
||||
winner, winningTier, ambiguous = group, tier, false
|
||||
case tier == winningTier:
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
return winner, winningTier, ambiguous
|
||||
}
|
||||
|
||||
// strongestTier is the numerically smallest (strongest) tier among origins.
|
||||
func strongestTier(origins map[string]struct{}) Tier {
|
||||
var best Tier
|
||||
first := true
|
||||
for origin := range origins {
|
||||
tier := TierForOrigin(origin)
|
||||
if first || tier < best {
|
||||
best, first = tier, false
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func conflictError(key string, groups []*identityGroup) *ConflictError {
|
||||
return &ConflictError{Key: key, Origins: allOrigins(groups)}
|
||||
}
|
||||
|
||||
func overrideRecord(key string, groups []*identityGroup, winner *identityGroup, winningTier Tier) *OverrideRecord {
|
||||
losing := make(map[string]Tier)
|
||||
for _, group := range groups {
|
||||
if group == winner {
|
||||
continue
|
||||
}
|
||||
for origin := range group.origins {
|
||||
losing[origin] = TierForOrigin(origin)
|
||||
}
|
||||
}
|
||||
return &OverrideRecord{
|
||||
Key: key,
|
||||
WinningOrigins: sortedOriginSet(winner.origins),
|
||||
WinningTier: winningTier,
|
||||
LosingOrigins: sortedOriginTiers(losing),
|
||||
}
|
||||
}
|
||||
|
||||
func allOrigins(groups []*identityGroup) []string {
|
||||
set := make(map[string]struct{})
|
||||
for _, group := range groups {
|
||||
for origin := range group.origins {
|
||||
set[origin] = struct{}{}
|
||||
}
|
||||
}
|
||||
return sortedOriginSet(set)
|
||||
}
|
||||
|
||||
func sortedOriginSet(set map[string]struct{}) []string {
|
||||
result := make([]string, 0, len(set))
|
||||
for origin := range set {
|
||||
result = append(result, origin)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func sortedOriginTiers(tiers map[string]Tier) []OriginTier {
|
||||
result := make([]OriginTier, 0, len(tiers))
|
||||
for origin, tier := range tiers {
|
||||
result = append(result, OriginTier{Origin: origin, Tier: tier})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Origin < result[j].Origin })
|
||||
return result
|
||||
}
|
||||
|
||||
func definitionIdentity(definition Definition) string {
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func noReveal(context.Context, Definition) (string, error) {
|
||||
return "", errors.New("reveal should not be called")
|
||||
}
|
||||
|
||||
func mustResolve(t *testing.T, defs []Definition) (map[string]string, []OverrideRecord) {
|
||||
t.Helper()
|
||||
env, records, err := Resolve(context.Background(), defs, noReveal)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
}
|
||||
return env, records
|
||||
}
|
||||
|
||||
func conflictOf(t *testing.T, defs []Definition) *ConflictError {
|
||||
t.Helper()
|
||||
env, records, err := Resolve(context.Background(), defs, noReveal)
|
||||
if err == nil {
|
||||
t.Fatalf("Resolve() succeeded, want conflict; env=%#v", env)
|
||||
}
|
||||
if env != nil || records != nil {
|
||||
t.Fatalf("Resolve() on error: env=%#v records=%#v, want both nil", env, records)
|
||||
}
|
||||
var conflictErr *ConflictError
|
||||
if !errors.As(err, &conflictErr) {
|
||||
t.Fatalf("error = %T %v, want *ConflictError", err, err)
|
||||
}
|
||||
return conflictErr
|
||||
}
|
||||
|
||||
// --- Precedence: AC-1 through AC-8e ---
|
||||
|
||||
func TestResolve_ExecutorProfileBeatsAgentProfile(t *testing.T) { // AC-1
|
||||
env, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "executor-value", Origin: OriginExecutorProfile},
|
||||
{Key: "K", Literal: "agent-value", Origin: OriginAgentProfile},
|
||||
})
|
||||
if env["K"] != "executor-value" {
|
||||
t.Fatalf("K = %q, want executor-value", env["K"])
|
||||
}
|
||||
if len(records) != 1 || records[0].WinningTier != TierAuthoritative {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ManagedRuntimeBeatsAgentProfile(t *testing.T) { // AC-2
|
||||
env, _ := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "managed-value", Origin: OriginManagedRuntime},
|
||||
{Key: "K", Literal: "agent-value", Origin: OriginAgentProfile},
|
||||
})
|
||||
if env["K"] != "managed-value" {
|
||||
t.Fatalf("K = %q, want managed-value", env["K"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ManagedCredentialsBeatsAgentProfile(t *testing.T) { // AC-3
|
||||
env, _ := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "cred-value", Origin: OriginManagedCredentials},
|
||||
{Key: "K", Literal: "agent-value", Origin: OriginAgentProfile},
|
||||
})
|
||||
if env["K"] != "cred-value" {
|
||||
t.Fatalf("K = %q, want cred-value", env["K"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_AgentProfileBeatsManagedAgentDefaults(t *testing.T) { // AC-4
|
||||
env, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "profile-value", Origin: OriginAgentProfile},
|
||||
{Key: "K", Literal: "default-value", Origin: OriginManagedAgentDefaults},
|
||||
})
|
||||
if env["K"] != "profile-value" {
|
||||
t.Fatalf("K = %q, want profile-value", env["K"])
|
||||
}
|
||||
if len(records) != 1 || records[0].WinningTier != TierProfileDefault {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
if len(records[0].LosingOrigins) != 1 || records[0].LosingOrigins[0] != (OriginTier{Origin: OriginManagedAgentDefaults, Tier: TierAgentRuntimeDefault}) {
|
||||
t.Fatalf("losing origins = %#v", records[0].LosingOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_TwoDifferingTier1OriginsConflict(t *testing.T) { // AC-5
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "K", Literal: "a", Origin: OriginExecutorProfile},
|
||||
{Key: "K", Literal: "b", Origin: OriginManagedRuntime},
|
||||
})
|
||||
want := []string{OriginExecutorProfile, OriginManagedRuntime}
|
||||
if !reflect.DeepEqual(err.Origins, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_MergedIdentityClassifiesByStrongestOrigin pins AC-5a: a merged
|
||||
// executor-profile/agent-profile identity (same literal) classifies Tier 1
|
||||
// via its strongest contributing origin and conflicts against a differing
|
||||
// managed-runtime literal, rather than being silently ranked Tier 2 by the
|
||||
// surviving Definition.Origin field (which would sort to "agent profile").
|
||||
func TestResolve_MergedIdentityClassifiesByStrongestOrigin(t *testing.T) {
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "AGENT_MODEL", Literal: "shared", Origin: OriginExecutorProfile},
|
||||
{Key: "AGENT_MODEL", Literal: "shared", Origin: OriginAgentProfile},
|
||||
{Key: "AGENT_MODEL", Literal: "different", Origin: OriginManagedRuntime},
|
||||
})
|
||||
want := []string{OriginAgentProfile, OriginExecutorProfile, OriginManagedRuntime}
|
||||
if !reflect.DeepEqual(err.Origins, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_MergedIdentitySpansTiersAndWins pins AC-5b: the same merged
|
||||
// pair, now facing only a differing Tier 3 literal, resolves to the merged
|
||||
// value with WinningOrigins spanning both contributing tiers while
|
||||
// WinningTier records only the strongest.
|
||||
func TestResolve_MergedIdentitySpansTiersAndWins(t *testing.T) {
|
||||
env, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "shared", Origin: OriginExecutorProfile},
|
||||
{Key: "K", Literal: "shared", Origin: OriginAgentProfile},
|
||||
{Key: "K", Literal: "default", Origin: OriginManagedAgentDefaults},
|
||||
})
|
||||
if env["K"] != "shared" {
|
||||
t.Fatalf("K = %q, want shared", env["K"])
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
want := []string{OriginAgentProfile, OriginExecutorProfile}
|
||||
if !reflect.DeepEqual(records[0].WinningOrigins, want) {
|
||||
t.Fatalf("winning origins = %#v, want %#v", records[0].WinningOrigins, want)
|
||||
}
|
||||
if records[0].WinningTier != TierAuthoritative {
|
||||
t.Fatalf("winning tier = %v, want TierAuthoritative", records[0].WinningTier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_SameOriginAmbiguityWithinWinningTierConflicts pins AC-6: two
|
||||
// differing definitions from the SAME Tier-1 origin, with no stronger tier
|
||||
// present, still conflict exactly as today.
|
||||
func TestResolve_SameOriginAmbiguityWithinWinningTierConflicts(t *testing.T) {
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "K", Literal: "a", Origin: OriginExecutorProfile},
|
||||
{Key: "K", Literal: "b", Origin: OriginExecutorProfile},
|
||||
})
|
||||
want := []string{OriginExecutorProfile}
|
||||
if !reflect.DeepEqual(err.Origins, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_AgentProfileOnlyNoExecutorProfile(t *testing.T) { // AC-7
|
||||
env, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "agent-value", Origin: OriginAgentProfile},
|
||||
})
|
||||
if env["K"] != "agent-value" || records != nil {
|
||||
t.Fatalf("env=%#v records=%#v", env, records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_MergedTier1PairBeatsDifferingTier2(t *testing.T) { // AC-8a
|
||||
env, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "shared", Origin: OriginManagedRuntime},
|
||||
{Key: "K", Literal: "shared", Origin: OriginManagedCredentials},
|
||||
{Key: "K", Literal: "profile", Origin: OriginAgentProfile},
|
||||
})
|
||||
if env["K"] != "shared" {
|
||||
t.Fatalf("K = %q, want shared", env["K"])
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
want := []string{OriginManagedCredentials, OriginManagedRuntime}
|
||||
if !reflect.DeepEqual(records[0].WinningOrigins, want) {
|
||||
t.Fatalf("winning origins = %#v, want %#v", records[0].WinningOrigins, want)
|
||||
}
|
||||
if records[0].WinningTier != TierAuthoritative {
|
||||
t.Fatalf("winning tier = %v", records[0].WinningTier)
|
||||
}
|
||||
wantLosing := []OriginTier{{Origin: OriginAgentProfile, Tier: TierProfileDefault}}
|
||||
if !reflect.DeepEqual(records[0].LosingOrigins, wantLosing) {
|
||||
t.Fatalf("losing origins = %#v, want %#v", records[0].LosingOrigins, wantLosing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_TwoDifferingTier1PlusTier2Conflicts(t *testing.T) { // AC-8b
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "K", Literal: "a", Origin: OriginManagedRuntime},
|
||||
{Key: "K", Literal: "b", Origin: OriginManagedCredentials},
|
||||
{Key: "K", Literal: "c", Origin: OriginAgentProfile},
|
||||
})
|
||||
want := []string{OriginAgentProfile, OriginManagedCredentials, OriginManagedRuntime}
|
||||
if !reflect.DeepEqual(err.Origins, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Tier1BeatsBothTier2AndTier3(t *testing.T) { // AC-8c
|
||||
_, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "a", Origin: OriginManagedRuntime},
|
||||
{Key: "K", Literal: "b", Origin: OriginAgentProfile},
|
||||
{Key: "K", Literal: "c", Origin: OriginManagedAgentDefaults},
|
||||
})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
want := []OriginTier{
|
||||
{Origin: OriginAgentProfile, Tier: TierProfileDefault},
|
||||
{Origin: OriginManagedAgentDefaults, Tier: TierAgentRuntimeDefault},
|
||||
}
|
||||
if !reflect.DeepEqual(records[0].LosingOrigins, want) {
|
||||
t.Fatalf("losing origins = %#v, want %#v", records[0].LosingOrigins, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_LosingOriginsDedupedByOrigin pins AC-8d: one Tier-1 winner
|
||||
// plus two agent-profile literals that disagree with each other still
|
||||
// produces exactly ONE LosingOrigins entry and one origin string, never two.
|
||||
func TestResolve_LosingOriginsDedupedByOrigin(t *testing.T) {
|
||||
_, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "a", Origin: OriginManagedRuntime},
|
||||
{Key: "K", Literal: "b", Origin: OriginAgentProfile},
|
||||
{Key: "K", Literal: "c", Origin: OriginAgentProfile},
|
||||
})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
if len(records[0].LosingOrigins) != 1 {
|
||||
t.Fatalf("losing origins = %#v, want exactly 1 entry", records[0].LosingOrigins)
|
||||
}
|
||||
want := OriginTier{Origin: OriginAgentProfile, Tier: TierProfileDefault}
|
||||
if records[0].LosingOrigins[0] != want {
|
||||
t.Fatalf("losing origin = %#v, want %#v", records[0].LosingOrigins[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_WinningOriginsDedupedByOrigin pins AC-8e: one Tier-1 value
|
||||
// contributed by two definitions sharing both Origin and definitionIdentity
|
||||
// lists that origin only once in WinningOrigins.
|
||||
func TestResolve_WinningOriginsDedupedByOrigin(t *testing.T) {
|
||||
_, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "shared", Origin: OriginManagedRuntime, WorkspaceID: ""},
|
||||
{Key: "K", Literal: "shared", Origin: OriginManagedRuntime, WorkspaceID: ""},
|
||||
{Key: "K", Literal: "other", Origin: OriginAgentProfile},
|
||||
})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
want := []string{OriginManagedRuntime}
|
||||
if !reflect.DeepEqual(records[0].WinningOrigins, want) {
|
||||
t.Fatalf("winning origins = %#v, want %#v", records[0].WinningOrigins, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Secret veto: AC-9 through AC-13 ---
|
||||
|
||||
func TestResolve_SecretVetoBlocksAcrossTiers(t *testing.T) { // AC-9, AC-10
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "K", SecretID: "secret-1", Origin: OriginExecutorProfile},
|
||||
{Key: "K", Literal: "literal", Origin: OriginAgentProfile},
|
||||
})
|
||||
want := []string{OriginAgentProfile, OriginExecutorProfile}
|
||||
if !reflect.DeepEqual(err.Origins, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_SecretVetoSymmetric(t *testing.T) { // AC-11
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "K", Literal: "literal", Origin: OriginExecutorProfile},
|
||||
{Key: "K", SecretID: "secret-1", Origin: OriginAgentProfile},
|
||||
})
|
||||
want := []string{OriginAgentProfile, OriginExecutorProfile}
|
||||
if !reflect.DeepEqual(err.Origins, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_SecretVetoBlocksRepositoryAgainstOtherOrigin(t *testing.T) { // AC-12
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "K", SecretID: "secret-1", Origin: "repository app", WorkspaceID: "ws-1"},
|
||||
{Key: "K", Literal: "literal", Origin: OriginManagedRuntime},
|
||||
})
|
||||
want := []string{"repository app", OriginManagedRuntime}
|
||||
if len(err.Origins) != 2 {
|
||||
t.Fatalf("origins = %#v, want 2 entries", err.Origins)
|
||||
}
|
||||
got := append([]string(nil), err.Origins...)
|
||||
sort.Strings(got)
|
||||
sort.Strings(want)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_VetoEnumeratesEveryContributingOriginOfMergedSecretIdentity
|
||||
// pins AC-12a: two secret-backed definitions sharing one SecretID merge into
|
||||
// one identity, and the veto's error must still name BOTH their origins
|
||||
// (not just the surviving Definition.Origin), alongside the differing
|
||||
// literal's origin.
|
||||
func TestResolve_VetoEnumeratesEveryContributingOriginOfMergedSecretIdentity(t *testing.T) {
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "K", SecretID: "secret-1", Origin: "repository app", WorkspaceID: "ws-1"},
|
||||
{Key: "K", SecretID: "secret-1", Origin: OriginExecutorProfile},
|
||||
{Key: "K", Literal: "literal", Origin: OriginAgentProfile},
|
||||
})
|
||||
want := []string{OriginAgentProfile, OriginExecutorProfile, "repository app"}
|
||||
if !reflect.DeepEqual(err.Origins, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ConflictErrorLeaksNoSecretValue(t *testing.T) { // AC-13
|
||||
_, _, err := Resolve(context.Background(), []Definition{
|
||||
{Key: "K", SecretID: "super-secret-id", Origin: "repository app", WorkspaceID: "ws-1"},
|
||||
{Key: "K", Literal: "super-secret-literal", Origin: OriginAgentProfile},
|
||||
}, noReveal)
|
||||
if err == nil {
|
||||
t.Fatal("want conflict error")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leaked := range []string{"super-secret-id", "super-secret-literal"} {
|
||||
if strings.Contains(msg, leaked) {
|
||||
t.Fatalf("error %q leaks %q", msg, leaked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- No regression for currently-succeeding tasks: AC-14 through AC-17 ---
|
||||
|
||||
func TestResolve_SingleDefinitionUnchanged(t *testing.T) { // AC-14
|
||||
env, records := mustResolve(t, []Definition{{Key: "K", Literal: "v", Origin: OriginAgentProfile}})
|
||||
if env["K"] != "v" || records != nil {
|
||||
t.Fatalf("env=%#v records=%#v", env, records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_IdenticalIdentitiesMergeWithoutOverride(t *testing.T) { // AC-15
|
||||
env, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "v", Origin: OriginExecutorProfile},
|
||||
{Key: "K", Literal: "v", Origin: OriginAgentProfile},
|
||||
})
|
||||
if env["K"] != "v" {
|
||||
t.Fatalf("K = %q, want v", env["K"])
|
||||
}
|
||||
if records != nil {
|
||||
t.Fatalf("records = %#v, want nil — nothing was overridden", records)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Observability: AC-20 through AC-26 ---
|
||||
|
||||
func TestResolve_NoOverrideRecordWhenNoConflict(t *testing.T) { // AC-26
|
||||
_, records, err := Resolve(context.Background(), []Definition{
|
||||
{Key: "K", Literal: "v", Origin: OriginAgentProfile},
|
||||
}, noReveal)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if records != nil {
|
||||
t.Fatalf("records = %#v, want nil (not an empty non-nil slice)", records)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_ErrorReturnsNoRecordsEvenAfterEarlierOverride pins AC-20a: a
|
||||
// set where key A resolves by tier precedence and key B conflicts must
|
||||
// return nil records alongside the error, not a partial record for A.
|
||||
func TestResolve_ErrorReturnsNoRecordsEvenAfterEarlierOverride(t *testing.T) {
|
||||
env, records, err := Resolve(context.Background(), []Definition{
|
||||
{Key: "A", Literal: "a1", Origin: OriginExecutorProfile},
|
||||
{Key: "A", Literal: "a2", Origin: OriginAgentProfile},
|
||||
{Key: "B", Literal: "b1", Origin: OriginManagedRuntime},
|
||||
{Key: "B", Literal: "b2", Origin: OriginExecutorProfile},
|
||||
}, noReveal)
|
||||
if err == nil {
|
||||
t.Fatal("want conflict on key B")
|
||||
}
|
||||
if env != nil || records != nil {
|
||||
t.Fatalf("env=%#v records=%#v, want both nil", env, records)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_ErrorReturnsNoRecordsWhenSecretRevealFailsAfterOverride pins
|
||||
// the second AC-20a branch: key A overrides, then a secret for key B fails
|
||||
// to reveal — no records must leak through even though A already resolved.
|
||||
func TestResolve_ErrorReturnsNoRecordsWhenSecretRevealFailsAfterOverride(t *testing.T) {
|
||||
failingReveal := func(context.Context, Definition) (string, error) {
|
||||
return "", errors.New("reveal failed")
|
||||
}
|
||||
env, records, err := Resolve(context.Background(), []Definition{
|
||||
{Key: "A", Literal: "a1", Origin: OriginExecutorProfile},
|
||||
{Key: "A", Literal: "a2", Origin: OriginAgentProfile},
|
||||
{Key: "B", SecretID: "secret-1", Origin: "repository app", WorkspaceID: "ws-1"},
|
||||
}, failingReveal)
|
||||
if err == nil {
|
||||
t.Fatal("want secret error")
|
||||
}
|
||||
var secretErr *SecretError
|
||||
if !errors.As(err, &secretErr) {
|
||||
t.Fatalf("error = %T %v, want *SecretError", err, err)
|
||||
}
|
||||
if env != nil || records != nil {
|
||||
t.Fatalf("env=%#v records=%#v, want both nil", env, records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OverrideRecordCarriesNoSecretOrLiteral(t *testing.T) { // AC-21
|
||||
_, records := mustResolve(t, []Definition{
|
||||
{Key: "K", Literal: "secret-looking-value", Origin: OriginExecutorProfile},
|
||||
{Key: "K", Literal: "other", Origin: OriginAgentProfile},
|
||||
})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
v := reflect.ValueOf(records[0])
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Type().Field(i)
|
||||
if field.Name == "WinningOrigins" || field.Name == "LosingOrigins" || field.Name == "WinningTier" || field.Name == "Key" {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("OverrideRecord has unexpected field %q — every field must be identity/rank only", field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Determinism and ordering: AC-27 through AC-30 ---
|
||||
|
||||
func TestResolve_DeterministicAcrossRepeatedCalls(t *testing.T) { // AC-27
|
||||
defs := []Definition{
|
||||
{Key: "A", Literal: "a1", Origin: OriginExecutorProfile},
|
||||
{Key: "A", Literal: "a2", Origin: OriginAgentProfile},
|
||||
{Key: "B", Literal: "b1", Origin: OriginManagedRuntime},
|
||||
{Key: "B", Literal: "b2", Origin: OriginManagedAgentDefaults},
|
||||
}
|
||||
env1, records1, err1 := Resolve(context.Background(), defs, noReveal)
|
||||
env2, records2, err2 := Resolve(context.Background(), defs, noReveal)
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("errors: %v, %v", err1, err2)
|
||||
}
|
||||
if !reflect.DeepEqual(env1, env2) {
|
||||
t.Fatalf("env1=%#v env2=%#v", env1, env2)
|
||||
}
|
||||
if !reflect.DeepEqual(records1, records2) {
|
||||
t.Fatalf("records1=%#v records2=%#v", records1, records2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RecordsOrderedByKeyAscending(t *testing.T) { // AC-28
|
||||
_, records := mustResolve(t, []Definition{
|
||||
{Key: "ZKEY", Literal: "a", Origin: OriginExecutorProfile},
|
||||
{Key: "ZKEY", Literal: "b", Origin: OriginAgentProfile},
|
||||
{Key: "AKEY", Literal: "a", Origin: OriginExecutorProfile},
|
||||
{Key: "AKEY", Literal: "b", Origin: OriginAgentProfile},
|
||||
})
|
||||
if len(records) != 2 || records[0].Key != "AKEY" || records[1].Key != "ZKEY" {
|
||||
t.Fatalf("records = %#v, want AKEY then ZKEY", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_SameResultRegardlessOfInputOrder(t *testing.T) { // AC-29, AC-30
|
||||
forward := []Definition{
|
||||
{Key: "K", Literal: "shared", Origin: OriginManagedRuntime},
|
||||
{Key: "K", Literal: "shared", Origin: OriginManagedCredentials},
|
||||
{Key: "K", Literal: "profile", Origin: OriginAgentProfile},
|
||||
}
|
||||
reversed := make([]Definition, len(forward))
|
||||
for i, d := range forward {
|
||||
reversed[len(forward)-1-i] = d
|
||||
}
|
||||
envA, recA := mustResolve(t, forward)
|
||||
envB, recB := mustResolve(t, reversed)
|
||||
if !reflect.DeepEqual(envA, envB) {
|
||||
t.Fatalf("envA=%#v envB=%#v", envA, envB)
|
||||
}
|
||||
if !reflect.DeepEqual(recA, recB) {
|
||||
t.Fatalf("recA=%#v recB=%#v", recA, recB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_WorkspaceIDTieBreakIsDeterministic pins AC-29b: two
|
||||
// definitions sharing Key, Origin and SecretID but differing WorkspaceID
|
||||
// merge, and the lexicographically smaller WorkspaceID's definition survives
|
||||
// regardless of input order — observable via which reveal path is invoked.
|
||||
func TestResolve_WorkspaceIDTieBreakIsDeterministic(t *testing.T) {
|
||||
reveal := func(_ context.Context, d Definition) (string, error) {
|
||||
return "workspace=" + d.WorkspaceID, nil
|
||||
}
|
||||
forward := []Definition{
|
||||
{Key: "K", SecretID: "secret-1", Origin: "repository app", WorkspaceID: "ws-b"},
|
||||
{Key: "K", SecretID: "secret-1", Origin: "repository app", WorkspaceID: "ws-a"},
|
||||
}
|
||||
reversed := []Definition{forward[1], forward[0]}
|
||||
|
||||
envA, _, errA := Resolve(context.Background(), forward, reveal)
|
||||
envB, _, errB := Resolve(context.Background(), reversed, reveal)
|
||||
if errA != nil || errB != nil {
|
||||
t.Fatalf("errors: %v, %v", errA, errB)
|
||||
}
|
||||
if envA["K"] != "workspace=ws-a" || envB["K"] != "workspace=ws-a" {
|
||||
t.Fatalf("envA=%#v envB=%#v, want both to pick ws-a", envA, envB)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nil, empty, and error behaviour: AC-31 through AC-37 ---
|
||||
|
||||
func TestResolve_EmptyDefinitionsReturnsEmptyMapNoError(t *testing.T) { // AC-31
|
||||
env, records, err := Resolve(context.Background(), nil, noReveal)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if env == nil || len(env) != 0 {
|
||||
t.Fatalf("env = %#v, want empty non-nil map", env)
|
||||
}
|
||||
if records != nil {
|
||||
t.Fatalf("records = %#v, want nil", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_BlankKeyDefinitionsSkipped(t *testing.T) { // AC-32
|
||||
env, _ := mustResolve(t, []Definition{
|
||||
{Key: "", Literal: "v", Origin: OriginAgentProfile},
|
||||
{Key: " ", Literal: "v", Origin: OriginAgentProfile},
|
||||
{Key: "K", Literal: "v", Origin: OriginAgentProfile},
|
||||
})
|
||||
if len(env) != 1 || env["K"] != "v" {
|
||||
t.Fatalf("env = %#v, want only K", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NilRevealOnSecretReturnsSecretError(t *testing.T) { // AC-33
|
||||
_, _, err := Resolve(context.Background(), []Definition{
|
||||
{Key: "K", SecretID: "secret-1", Origin: OriginExecutorProfile},
|
||||
}, nil)
|
||||
var secretErr *SecretError
|
||||
if !errors.As(err, &secretErr) {
|
||||
t.Fatalf("error = %T %v, want *SecretError", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RevealErrorReturnsSecretError(t *testing.T) { // AC-34
|
||||
_, _, err := Resolve(context.Background(), []Definition{
|
||||
{Key: "K", SecretID: "secret-1", Origin: OriginExecutorProfile},
|
||||
}, func(context.Context, Definition) (string, error) {
|
||||
return "", errors.New("boom")
|
||||
})
|
||||
var secretErr *SecretError
|
||||
if !errors.As(err, &secretErr) {
|
||||
t.Fatalf("error = %T %v, want *SecretError", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_FirstConflictingKeyReported(t *testing.T) { // AC-36
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "ZKEY", Literal: "a", Origin: OriginExecutorProfile},
|
||||
{Key: "ZKEY", Literal: "b", Origin: OriginManagedRuntime},
|
||||
{Key: "AKEY", Literal: "a", Origin: OriginExecutorProfile},
|
||||
{Key: "AKEY", Literal: "b", Origin: OriginManagedRuntime},
|
||||
})
|
||||
if err.Key != "AKEY" {
|
||||
t.Fatalf("key = %q, want AKEY", err.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OnlyWinnerIsRevealed(t *testing.T) { // AC-37
|
||||
var revealed []string
|
||||
reveal := func(_ context.Context, d Definition) (string, error) {
|
||||
revealed = append(revealed, d.Origin)
|
||||
return "value", nil
|
||||
}
|
||||
// Two literal definitions plus one distinguishing secret-backed loser is
|
||||
// impossible under the veto, so this covers the ordinary case: a secret
|
||||
// winner is revealed and no other definition for the key is passed to
|
||||
// reveal.
|
||||
_, _, err := Resolve(context.Background(), []Definition{
|
||||
{Key: "K", SecretID: "secret-1", Origin: OriginExecutorProfile},
|
||||
}, reveal)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(revealed) != 1 || revealed[0] != OriginExecutorProfile {
|
||||
t.Fatalf("revealed = %#v", revealed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Migration: AC-49 (resolver-level qualifiers) ---
|
||||
|
||||
func TestResolve_MigrationExecutorWinsWhenLiteralsDiffer(t *testing.T) {
|
||||
env, records := mustResolve(t, []Definition{
|
||||
{Key: "ANTHROPIC_BASE_URL", Literal: "https://executor.example", Origin: OriginExecutorProfile},
|
||||
{Key: "ANTHROPIC_BASE_URL", Literal: "https://agent.example", Origin: OriginAgentProfile},
|
||||
})
|
||||
if env["ANTHROPIC_BASE_URL"] != "https://executor.example" {
|
||||
t.Fatalf("env = %#v", env)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_MigrationSecretSideStillBlocks(t *testing.T) {
|
||||
err := conflictOf(t, []Definition{
|
||||
{Key: "ANTHROPIC_BASE_URL", SecretID: "secret-1", Origin: OriginExecutorProfile},
|
||||
{Key: "ANTHROPIC_BASE_URL", Literal: "https://agent.example", Origin: OriginAgentProfile},
|
||||
})
|
||||
want := []string{OriginAgentProfile, OriginExecutorProfile}
|
||||
if !reflect.DeepEqual(err.Origins, want) {
|
||||
t.Fatalf("origins = %#v, want %#v", err.Origins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_MigrationIdenticalValuesStillMergeNoOverride(t *testing.T) {
|
||||
_, records := mustResolve(t, []Definition{
|
||||
{Key: "ANTHROPIC_BASE_URL", Literal: "https://same.example", Origin: OriginExecutorProfile},
|
||||
{Key: "ANTHROPIC_BASE_URL", Literal: "https://same.example", Origin: OriginAgentProfile},
|
||||
})
|
||||
if records != nil {
|
||||
t.Fatalf("records = %#v, want nil", records)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidate_NoOverrideSideEffectOnSetThatWouldOverrideUnderResolve pins
|
||||
// AC-19b: Validate keeps the func([]Definition) error signature and emits no
|
||||
// observable override side effect (Resolve's third return value has no
|
||||
// counterpart here at all), even for a definition set that DOES resolve by
|
||||
// tier precedence when passed to Resolve instead.
|
||||
func TestValidate_NoOverrideSideEffectOnSetThatWouldOverrideUnderResolve(t *testing.T) {
|
||||
defs := []Definition{
|
||||
{Key: "SHARED", Literal: "managed-value", Origin: OriginManagedRuntime},
|
||||
{Key: "SHARED", Literal: "profile-value", Origin: OriginAgentProfile},
|
||||
}
|
||||
|
||||
// Resolve on the same set produces an override: proves this is genuinely
|
||||
// the "would override" case Validate must stay silent about.
|
||||
_, records, err := Resolve(context.Background(), defs, noReveal)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("Resolve() records = %#v, want exactly one override", records)
|
||||
}
|
||||
|
||||
if err := Validate(defs); err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil (Tier 1 accepts the set precedence would also accept)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_MatchesResolveErrorVerdict pins the shared-error-path half of
|
||||
// AC-19b/AC-18: Validate's accept/reject verdict is derived from the exact
|
||||
// same selectDefinitions call Resolve uses, so a conflicting set is rejected
|
||||
// identically by both.
|
||||
func TestValidate_MatchesResolveErrorVerdict(t *testing.T) {
|
||||
defs := []Definition{
|
||||
{Key: "SHARED", Literal: "one", Origin: OriginManagedRuntime},
|
||||
{Key: "SHARED", Literal: "two", Origin: OriginExecutorProfile},
|
||||
}
|
||||
|
||||
if err := Validate(defs); err == nil {
|
||||
t.Fatal("Validate() error = nil, want conflict")
|
||||
}
|
||||
|
||||
_, _, err := Resolve(context.Background(), defs, noReveal)
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want conflict")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tier ranks an origin. LOWER IS STRONGER: Tier 1 beats Tier 2 beats Tier 3.
|
||||
type Tier int
|
||||
|
||||
const (
|
||||
TierAuthoritative Tier = 1
|
||||
TierProfileDefault Tier = 2
|
||||
TierAgentRuntimeDefault Tier = 3
|
||||
)
|
||||
|
||||
// Origin constants shared by both assembly sites so tier classification
|
||||
// cannot drift between two independent spellings of the same origin.
|
||||
const (
|
||||
OriginManagedRuntime = "managed runtime"
|
||||
OriginManagedCredentials = "managed credentials"
|
||||
OriginManagedAgentDefaults = "managed agent defaults"
|
||||
OriginAgentProfile = "agent profile"
|
||||
OriginExecutorProfile = "executor profile"
|
||||
|
||||
// OriginRepositoryPrefix is a CONSTRUCTION constant used to build a
|
||||
// repository origin string (OriginRepositoryPrefix + name). It is not a
|
||||
// matching rule — classification always goes through IsRepositoryOrigin.
|
||||
OriginRepositoryPrefix = "repository "
|
||||
)
|
||||
|
||||
// originTiers is the single source of truth for tier membership. It stays
|
||||
// unexported: an exported map could be mutated by any importer, defeating
|
||||
// the single-source-of-truth guarantee this table exists to provide.
|
||||
// TierForOrigin is the only reader.
|
||||
var originTiers = map[string]Tier{
|
||||
OriginManagedRuntime: TierAuthoritative,
|
||||
OriginManagedCredentials: TierAuthoritative,
|
||||
OriginExecutorProfile: TierAuthoritative,
|
||||
OriginAgentProfile: TierProfileDefault,
|
||||
OriginManagedAgentDefaults: TierAgentRuntimeDefault,
|
||||
}
|
||||
|
||||
// TierForOrigin classifies one origin string into its tier. It is the ONLY
|
||||
// place the tier table is read. An origin the table does not name classifies
|
||||
// as TierAuthoritative — fail closed, so an unrecognised origin never
|
||||
// silently loses to a known one.
|
||||
func TierForOrigin(origin string) Tier {
|
||||
if IsRepositoryOrigin(origin) {
|
||||
return TierAuthoritative
|
||||
}
|
||||
if tier, ok := originTiers[origin]; ok {
|
||||
return tier
|
||||
}
|
||||
return TierAuthoritative
|
||||
}
|
||||
|
||||
// IsRepositoryOrigin reports whether origin is a repository origin, per the
|
||||
// single normative rule: strings.Fields(origin)[0] == "repository".
|
||||
// TierForOrigin and NormalizeOriginLabel both call it; neither restates it.
|
||||
func IsRepositoryOrigin(origin string) bool {
|
||||
fields := strings.Fields(origin)
|
||||
return len(fields) > 0 && fields[0] == "repository"
|
||||
}
|
||||
|
||||
// RepositoryOrigin builds a repository origin string from a repository name,
|
||||
// falling back to the bare prefix when name is blank. Both assembly sites
|
||||
// (orchestrator and lifecycle) construct repository origins through this
|
||||
// helper so the two spellings cannot drift.
|
||||
func RepositoryOrigin(name string) string {
|
||||
if trimmed := strings.TrimSpace(name); trimmed != "" {
|
||||
return OriginRepositoryPrefix + trimmed
|
||||
}
|
||||
return strings.TrimSpace(OriginRepositoryPrefix)
|
||||
}
|
||||
|
||||
// NormalizeOriginLabel derives the metric-label form of one origin: a
|
||||
// repository origin collapses to the bare value "repository" so per-name
|
||||
// cardinality cannot leak into a metric label; every other origin is used
|
||||
// verbatim.
|
||||
func NormalizeOriginLabel(origin string) string {
|
||||
if IsRepositoryOrigin(origin) {
|
||||
return "repository"
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
// JoinOriginLabels normalises, de-duplicates, sorts ascending, and joins with
|
||||
// "+" — the label form for a WinningOrigins list that may span several
|
||||
// origins. Returns "" for a nil or empty slice.
|
||||
func JoinOriginLabels(origins []string) string {
|
||||
if len(origins) == 0 {
|
||||
return ""
|
||||
}
|
||||
seen := make(map[string]struct{}, len(origins))
|
||||
normalized := make([]string, 0, len(origins))
|
||||
for _, origin := range origins {
|
||||
label := NormalizeOriginLabel(origin)
|
||||
if _, ok := seen[label]; ok {
|
||||
continue
|
||||
}
|
||||
seen[label] = struct{}{}
|
||||
normalized = append(normalized, label)
|
||||
}
|
||||
sort.Strings(normalized)
|
||||
return strings.Join(normalized, "+")
|
||||
}
|
||||
|
||||
// OriginTier pairs an origin string with the tier it classified into.
|
||||
type OriginTier struct {
|
||||
Origin string
|
||||
Tier Tier
|
||||
}
|
||||
|
||||
// OverrideRecord describes one environment key whose value was chosen by
|
||||
// tier precedence instead of blocking the launch. It carries identity and
|
||||
// rank only — never a literal value, a SecretID, or a decrypted secret.
|
||||
type OverrideRecord struct {
|
||||
// Key is the environment key that was overridden.
|
||||
Key string
|
||||
// WinningOrigins is a de-duplicated SET: each distinct contributing
|
||||
// origin of the winning identity, listed once, sorted ascending. May
|
||||
// span tiers when a merged identity was contributed by origins of
|
||||
// different tiers carrying the same value.
|
||||
WinningOrigins []string
|
||||
// WinningTier is the STRONGEST tier among WinningOrigins — the tier the
|
||||
// winning identity was classified into.
|
||||
WinningTier Tier
|
||||
// LosingOrigins is a de-duplicated SET: each distinct origin appearing
|
||||
// among the discarded identities, paired with its own tier, sorted by
|
||||
// Origin ascending. One entry per ORIGIN, never one per discarded
|
||||
// identity.
|
||||
LosingOrigins []OriginTier
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package environment
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsRepositoryOrigin pins the normative repository-origin rule and its
|
||||
// worked boundary table from the spec: the first whitespace-delimited field
|
||||
// must be exactly "repository".
|
||||
func TestIsRepositoryOrigin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
want bool
|
||||
}{
|
||||
{name: "bare repository (blank name case)", origin: "repository", want: true},
|
||||
{name: "repository with name", origin: "repository app", want: true},
|
||||
{name: "repository with two spaces", origin: "repository app", want: true},
|
||||
{name: "repository with tab", origin: "repository\tapp", want: true},
|
||||
{name: "no separator", origin: "repositoryapp", want: false},
|
||||
{name: "empty string", origin: "", want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsRepositoryOrigin(tc.origin); got != tc.want {
|
||||
t.Fatalf("IsRepositoryOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepositoryOrigin pins the shared repository-origin construction used by
|
||||
// both launch assembly paths, including its blank-name fallback.
|
||||
func TestRepositoryOrigin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "named repository", input: "app", want: "repository app"},
|
||||
{name: "trims repository name", input: " app ", want: "repository app"},
|
||||
{name: "empty repository name", input: "", want: "repository"},
|
||||
{name: "whitespace-only repository name", input: " \t ", want: "repository"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RepositoryOrigin(tc.input); got != tc.want {
|
||||
t.Fatalf("RepositoryOrigin(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTierForOrigin_KnownOrigins pins tier membership for every named origin.
|
||||
func TestTierForOrigin_KnownOrigins(t *testing.T) {
|
||||
cases := []struct {
|
||||
origin string
|
||||
want Tier
|
||||
}{
|
||||
{OriginManagedRuntime, TierAuthoritative},
|
||||
{OriginManagedCredentials, TierAuthoritative},
|
||||
{OriginExecutorProfile, TierAuthoritative},
|
||||
{"repository", TierAuthoritative},
|
||||
{"repository app", TierAuthoritative},
|
||||
{OriginAgentProfile, TierProfileDefault},
|
||||
{OriginManagedAgentDefaults, TierAgentRuntimeDefault},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.origin, func(t *testing.T) {
|
||||
if got := TierForOrigin(tc.origin); got != tc.want {
|
||||
t.Fatalf("TierForOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTierForOrigin_UnrecognisedIsAuthoritative pins AC-35: an origin string
|
||||
// the tier table does not name, and which is not a repository origin,
|
||||
// classifies as TierAuthoritative — fail closed rather than a silent loser.
|
||||
func TestTierForOrigin_UnrecognisedIsAuthoritative(t *testing.T) {
|
||||
for _, origin := range []string{"", "repositoryapp", "some-future-origin"} {
|
||||
if got := TierForOrigin(origin); got != TierAuthoritative {
|
||||
t.Fatalf("TierForOrigin(%q) = %v, want TierAuthoritative", origin, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOriginLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
origin string
|
||||
want string
|
||||
}{
|
||||
{"repository app", "repository"},
|
||||
{"repository web", "repository"},
|
||||
{"repository", "repository"},
|
||||
{OriginManagedRuntime, OriginManagedRuntime},
|
||||
{OriginAgentProfile, OriginAgentProfile},
|
||||
{"some-future-origin", "some-future-origin"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.origin, func(t *testing.T) {
|
||||
if got := NormalizeOriginLabel(tc.origin); got != tc.want {
|
||||
t.Fatalf("NormalizeOriginLabel(%q) = %q, want %q", tc.origin, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJoinOriginLabels_NilAndEmpty pins F34: an unreachable-today but
|
||||
// declared boundary — nil/empty input returns "" rather than panicking or
|
||||
// erroring.
|
||||
func TestJoinOriginLabels_NilAndEmpty(t *testing.T) {
|
||||
if got := JoinOriginLabels(nil); got != "" {
|
||||
t.Fatalf("JoinOriginLabels(nil) = %q, want \"\"", got)
|
||||
}
|
||||
if got := JoinOriginLabels([]string{}); got != "" {
|
||||
t.Fatalf("JoinOriginLabels([]string{}) = %q, want \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinOriginLabels_SortsAndJoins(t *testing.T) {
|
||||
got := JoinOriginLabels([]string{OriginManagedRuntime, OriginExecutorProfile})
|
||||
want := "executor profile+managed runtime"
|
||||
if got != want {
|
||||
t.Fatalf("JoinOriginLabels = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJoinOriginLabels_DedupesAfterNormalization covers AC-25's join
|
||||
// de-duplication: two distinct repository origins both normalise to
|
||||
// "repository" and must collapse to one label rather than "repository+repository".
|
||||
func TestJoinOriginLabels_DedupesAfterNormalization(t *testing.T) {
|
||||
got := JoinOriginLabels([]string{"repository app", "repository web"})
|
||||
if got != "repository" {
|
||||
t.Fatalf("JoinOriginLabels = %q, want %q", got, "repository")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package envmetrics publishes the environment-override-precedence expvar
|
||||
// counter. It is a neutral package, following the convention in
|
||||
// internal/workflow/signalmetrics, so the environment resolver stays a pure
|
||||
// function with no logging or metrics dependency of its own.
|
||||
package envmetrics
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// environmentOverrideApplied counts one environment key resolved by tier
|
||||
// precedence against one losing origin, labelled by winning and losing
|
||||
// origin. It is the AC-24 `environment_override_applied_total` counter.
|
||||
var environmentOverrideApplied = expvar.NewMap("environment_override_applied_total")
|
||||
|
||||
// metricLabel builds a "k1=v1;k2=v2" label string for an expvar map key,
|
||||
// matching the convention in internal/workflow/signalmetrics/metrics_vars.go
|
||||
// so a downstream Prometheus translation splits on the same delimiters.
|
||||
func metricLabel(pairs ...string) string {
|
||||
if len(pairs)%2 != 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(pairs)/2)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
parts = append(parts, pairs[i]+"="+pairs[i+1])
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// RecordOverrideApplied counts one environment key resolved by tier
|
||||
// precedence against one losing origin. winningOrigin and losingOrigin must
|
||||
// already be normalised per environment.NormalizeOriginLabel /
|
||||
// environment.JoinOriginLabels (AC-25) — this package performs no
|
||||
// normalisation of its own and has no dependency on the environment
|
||||
// package, keeping the resolver pure (AC-24b).
|
||||
func RecordOverrideApplied(winningOrigin, losingOrigin string) {
|
||||
environmentOverrideApplied.Add(metricLabel("winning_origin", winningOrigin, "losing_origin", losingOrigin), 1)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package envmetrics
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// readCounter walks the expvar map looking for a key that matches the
|
||||
// supplied prefix. Returns 0 when no key matches. The prefix match keeps the
|
||||
// assertion robust against process-wide test pollution (other tests in this
|
||||
// package may push entries with different labels).
|
||||
func readCounter(t *testing.T, m *expvar.Map, prefix string) int64 {
|
||||
t.Helper()
|
||||
var total int64
|
||||
m.Do(func(kv expvar.KeyValue) {
|
||||
if !strings.HasPrefix(kv.Key, prefix) {
|
||||
return
|
||||
}
|
||||
n, err := strconv.ParseInt(kv.Value.String(), 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("counter %q value not int: %s", kv.Key, kv.Value.String())
|
||||
}
|
||||
total += n
|
||||
})
|
||||
return total
|
||||
}
|
||||
|
||||
func TestMetricLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pairs []string
|
||||
want string
|
||||
}{
|
||||
{"single_pair", []string{"winning_origin", "executor profile"}, "winning_origin=executor profile"},
|
||||
{"two_pairs", []string{"winning_origin", "executor profile", "losing_origin", "agent profile"},
|
||||
"winning_origin=executor profile;losing_origin=agent profile"},
|
||||
{"odd_args_returns_empty", []string{"winning_origin"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := metricLabel(tc.pairs...); got != tc.want {
|
||||
t.Errorf("metricLabel(%v) = %q, want %q", tc.pairs, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordOverrideApplied(t *testing.T) {
|
||||
label := metricLabel("winning_origin", "executor profile", "losing_origin", "agent profile-metrics-test")
|
||||
|
||||
before := readCounter(t, environmentOverrideApplied, label)
|
||||
RecordOverrideApplied("executor profile", "agent profile-metrics-test")
|
||||
after := readCounter(t, environmentOverrideApplied, label)
|
||||
|
||||
if after-before != 1 {
|
||||
t.Errorf("counter delta = %d, want 1", after-before)
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,15 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
runtimeenv "github.com/kandev/kandev/internal/agent/runtime/environment"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/envmetrics"
|
||||
"github.com/kandev/kandev/internal/secrets"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
const (
|
||||
managedRuntimeOrigin = "managed runtime"
|
||||
managedCredentialOrigin = "managed credentials"
|
||||
managedAgentDefaultsOrigin = "managed agent defaults"
|
||||
agentProfileOrigin = "agent profile"
|
||||
executorProfileOrigin = "executor profile"
|
||||
)
|
||||
|
||||
// TaskEnvironmentRepositoryReader supplies durable repository bindings when a
|
||||
// backend restart reconstructs a workspace-only execution.
|
||||
type TaskEnvironmentRepositoryReader interface {
|
||||
@@ -36,7 +31,7 @@ func (m *Manager) resolveStrictEnvironment(
|
||||
profileInfo *AgentProfileInfo,
|
||||
) (map[string]string, error) {
|
||||
definitions := append([]runtimeenv.Definition(nil), req.EnvironmentDefinitions...)
|
||||
appendMapDefinitions(&definitions, req.Env, managedRuntimeOrigin)
|
||||
appendMapDefinitions(&definitions, req.Env, runtimeenv.OriginManagedRuntime)
|
||||
appendAgentProfileDefinitions(&definitions, profileInfo)
|
||||
|
||||
appendStandardDefinitions(&definitions, executionID, req)
|
||||
@@ -44,30 +39,60 @@ func (m *Manager) resolveStrictEnvironment(
|
||||
appendRequiredCredentialDefinitions(ctx, &definitions, m.credsMgr, agentConfig)
|
||||
if req.managedGoCachePath != "" {
|
||||
definitions = append(definitions, runtimeenv.Definition{
|
||||
Key: "GOCACHE", Literal: req.managedGoCachePath, Origin: managedRuntimeOrigin,
|
||||
Key: "GOCACHE", Literal: req.managedGoCachePath, Origin: runtimeenv.OriginManagedRuntime,
|
||||
})
|
||||
}
|
||||
|
||||
resolved, err := runtimeenv.Resolve(ctx, definitions, m.resolveEnvironmentDefinition)
|
||||
resolved, records, err := runtimeenv.Resolve(ctx, definitions, m.resolveEnvironmentDefinition)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve task environment: %w", err)
|
||||
}
|
||||
m.logEnvironmentOverrides(req, records)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// logEnvironmentOverrides emits the AC-23 structured log and the AC-24
|
||||
// envmetrics counter for every OverrideRecord a successful Resolve produced.
|
||||
// It runs only on Resolve's success path: all-or-nothing governs Resolve's
|
||||
// own error paths, not a later launch failure, so a failure downstream of a
|
||||
// successful resolve must not roll these back (F32).
|
||||
func (m *Manager) logEnvironmentOverrides(req *LaunchRequest, records []runtimeenv.OverrideRecord) {
|
||||
for _, record := range records {
|
||||
losingOrigins := make([]string, 0, len(record.LosingOrigins))
|
||||
losingTiers := make([]int, 0, len(record.LosingOrigins))
|
||||
for _, losing := range record.LosingOrigins {
|
||||
losingOrigins = append(losingOrigins, losing.Origin)
|
||||
losingTiers = append(losingTiers, int(losing.Tier))
|
||||
envmetrics.RecordOverrideApplied(
|
||||
runtimeenv.JoinOriginLabels(record.WinningOrigins),
|
||||
runtimeenv.NormalizeOriginLabel(losing.Origin),
|
||||
)
|
||||
}
|
||||
m.logger.Info("environment override applied",
|
||||
zap.String("env_key", record.Key),
|
||||
zap.Strings("winning_origins", record.WinningOrigins),
|
||||
zap.Int("winning_tier", int(record.WinningTier)),
|
||||
zap.Strings("losing_origins", losingOrigins),
|
||||
zap.Ints("losing_tiers", losingTiers),
|
||||
zap.String("task_id", req.TaskID),
|
||||
zap.String("session_id", req.SessionID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func appendAgentProfileDefinitions(definitions *[]runtimeenv.Definition, profileInfo *AgentProfileInfo) {
|
||||
if profileInfo == nil {
|
||||
return
|
||||
}
|
||||
appendProfileDefinitions(definitions, profileInfo.EnvVars, agentProfileOrigin)
|
||||
appendProfileDefinitions(definitions, profileInfo.EnvVars, runtimeenv.OriginAgentProfile)
|
||||
if profileInfo.Model != "" {
|
||||
*definitions = append(*definitions, runtimeenv.Definition{
|
||||
Key: "AGENT_MODEL", Literal: profileInfo.Model, Origin: managedRuntimeOrigin,
|
||||
Key: "AGENT_MODEL", Literal: profileInfo.Model, Origin: runtimeenv.OriginManagedRuntime,
|
||||
})
|
||||
}
|
||||
if profileInfo.AutoApprove {
|
||||
*definitions = append(*definitions, runtimeenv.Definition{
|
||||
Key: "AGENTCTL_AUTO_APPROVE_PERMISSIONS", Literal: "true", Origin: managedRuntimeOrigin,
|
||||
Key: "AGENTCTL_AUTO_APPROVE_PERMISSIONS", Literal: "true", Origin: runtimeenv.OriginManagedRuntime,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -85,7 +110,7 @@ func appendAgentRuntimeDefaults(definitions *[]runtimeenv.Definition, agentConfi
|
||||
continue
|
||||
}
|
||||
*definitions = append(*definitions, runtimeenv.Definition{
|
||||
Key: key, Literal: value, Origin: managedAgentDefaultsOrigin,
|
||||
Key: key, Literal: value, Origin: runtimeenv.OriginManagedAgentDefaults,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -109,7 +134,7 @@ func appendRequiredCredentialDefinitions(
|
||||
continue
|
||||
}
|
||||
*definitions = append(*definitions, runtimeenv.Definition{
|
||||
Key: key, Literal: value, Origin: managedCredentialOrigin,
|
||||
Key: key, Literal: value, Origin: runtimeenv.OriginManagedCredentials,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -150,7 +175,7 @@ func appendStandardDefinitions(definitions *[]runtimeenv.Definition, executionID
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
*definitions = append(*definitions, runtimeenv.Definition{Key: key, Literal: value, Origin: managedRuntimeOrigin})
|
||||
*definitions = append(*definitions, runtimeenv.Definition{Key: key, Literal: value, Origin: runtimeenv.OriginManagedRuntime})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,10 +247,7 @@ func (m *Manager) repositoryEnvironmentDefinitions(ctx context.Context, taskID,
|
||||
if repository.WorkspaceID != workspaceID {
|
||||
return nil, errors.New("repository environment belongs to a different workspace")
|
||||
}
|
||||
origin := "repository"
|
||||
if name := strings.TrimSpace(repository.Name); name != "" {
|
||||
origin = "repository " + name
|
||||
}
|
||||
origin := runtimeenv.RepositoryOrigin(repository.Name)
|
||||
for _, binding := range repository.SecretBindings {
|
||||
if strings.TrimSpace(binding.Key) == "" || strings.TrimSpace(binding.SecretID) == "" {
|
||||
return nil, fmt.Errorf("repository %s has an invalid secret binding", repository.ID)
|
||||
@@ -258,7 +280,7 @@ func (m *Manager) executorProfileEnvironmentDefinitions(ctx context.Context, pro
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, runtimeenv.Definition{
|
||||
Key: value.Key, Literal: value.Value, SecretID: value.SecretID, Origin: executorProfileOrigin,
|
||||
Key: value.Key, Literal: value.Value, SecretID: value.SecretID, Origin: runtimeenv.OriginExecutorProfile,
|
||||
})
|
||||
}
|
||||
return definitions, nil
|
||||
|
||||
+11
-11
@@ -27,7 +27,7 @@ func TestAppendAgentRuntimeDefaultsFillsOnlyMissingKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
definitions := []runtimeenv.Definition{
|
||||
{Key: claimed, Literal: "profile-wins", Origin: agentProfileOrigin},
|
||||
{Key: claimed, Literal: "profile-wins", Origin: runtimeenv.OriginAgentProfile},
|
||||
}
|
||||
appendAgentRuntimeDefaults(&definitions, agentConfig)
|
||||
|
||||
@@ -40,13 +40,13 @@ func TestAppendAgentRuntimeDefaultsFillsOnlyMissingKeys(t *testing.T) {
|
||||
"every Agent.Runtime().Env key must reach the launch environment exactly once")
|
||||
require.Equal(t, "profile-wins", byKey[claimed].Literal,
|
||||
"an existing higher-precedence definition must not be overwritten by the agent default")
|
||||
require.Equal(t, agentProfileOrigin, byKey[claimed].Origin)
|
||||
require.Equal(t, runtimeenv.OriginAgentProfile, byKey[claimed].Origin)
|
||||
for key, value := range runtimeEnv {
|
||||
if key == claimed {
|
||||
continue
|
||||
}
|
||||
require.Equal(t, value, byKey[key].Literal, "runtime env %q lost its value", key)
|
||||
require.Equal(t, managedAgentDefaultsOrigin, byKey[key].Origin)
|
||||
require.Equal(t, runtimeenv.OriginManagedAgentDefaults, byKey[key].Origin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,16 @@ func TestAppendMapDefinitionsIsDeterministicallyOrdered(t *testing.T) {
|
||||
|
||||
appendMapDefinitions(&definitions, map[string]string{
|
||||
"ZED": "3", "ALPHA": "1", "MIKE": "2",
|
||||
}, agentProfileOrigin)
|
||||
}, runtimeenv.OriginAgentProfile)
|
||||
|
||||
require.Equal(t, []runtimeenv.Definition{
|
||||
{Key: "ALPHA", Literal: "1", Origin: agentProfileOrigin},
|
||||
{Key: "MIKE", Literal: "2", Origin: agentProfileOrigin},
|
||||
{Key: "ZED", Literal: "3", Origin: agentProfileOrigin},
|
||||
{Key: "ALPHA", Literal: "1", Origin: runtimeenv.OriginAgentProfile},
|
||||
{Key: "MIKE", Literal: "2", Origin: runtimeenv.OriginAgentProfile},
|
||||
{Key: "ZED", Literal: "3", Origin: runtimeenv.OriginAgentProfile},
|
||||
}, definitions, "map iteration order must not leak into the launch environment")
|
||||
|
||||
var empty []runtimeenv.Definition
|
||||
appendMapDefinitions(&empty, nil, agentProfileOrigin)
|
||||
appendMapDefinitions(&empty, nil, runtimeenv.OriginAgentProfile)
|
||||
require.Empty(t, empty)
|
||||
}
|
||||
|
||||
@@ -99,11 +99,11 @@ func TestAppendProfileDefinitionsDropsUnusableEntries(t *testing.T) {
|
||||
{Key: "EMPTY"},
|
||||
{Key: "LITERAL", Value: "value"},
|
||||
{Key: "SECRET", SecretID: "secret-1"},
|
||||
}, agentProfileOrigin)
|
||||
}, runtimeenv.OriginAgentProfile)
|
||||
|
||||
require.Equal(t, []runtimeenv.Definition{
|
||||
{Key: "LITERAL", Literal: "value", Origin: agentProfileOrigin},
|
||||
{Key: "SECRET", SecretID: "secret-1", Origin: agentProfileOrigin},
|
||||
{Key: "LITERAL", Literal: "value", Origin: runtimeenv.OriginAgentProfile},
|
||||
{Key: "SECRET", SecretID: "secret-1", Origin: runtimeenv.OriginAgentProfile},
|
||||
}, definitions)
|
||||
}
|
||||
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
runtimeenv "github.com/kandev/kandev/internal/agent/runtime/environment"
|
||||
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/secrets"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// readOverrideCounter sums every environment_override_applied_total entry
|
||||
// matching the given winning/losing origin label pair.
|
||||
func readOverrideCounter(t *testing.T, winning, losing string) int64 {
|
||||
t.Helper()
|
||||
v := expvar.Get("environment_override_applied_total")
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
m, ok := v.(*expvar.Map)
|
||||
if !ok {
|
||||
t.Fatalf("environment_override_applied_total is %T, want *expvar.Map", v)
|
||||
}
|
||||
label := "winning_origin=" + winning + ";losing_origin=" + losing
|
||||
var total int64
|
||||
m.Do(func(kv expvar.KeyValue) {
|
||||
if kv.Key != label {
|
||||
return
|
||||
}
|
||||
n, parseErr := strconv.ParseInt(kv.Value.String(), 10, 64)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("counter %q value not int: %s", kv.Key, kv.Value.String())
|
||||
}
|
||||
total += n
|
||||
})
|
||||
return total
|
||||
}
|
||||
|
||||
// TestResolveStrictEnvironment_EmitsOverrideLogAndCounter pins AC-23/AC-24:
|
||||
// a launch that applies tier precedence to a key that would previously have
|
||||
// conflicted emits one "environment override applied" Info log record and
|
||||
// one envmetrics counter increment per losing origin.
|
||||
func TestResolveStrictEnvironment_EmitsOverrideLogAndCounter(t *testing.T) {
|
||||
core, logs := observer.New(zapcore.InfoLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
require.NoError(t, err)
|
||||
|
||||
mgr := newTestManager(t)
|
||||
mgr.logger = log
|
||||
|
||||
before := readOverrideCounter(t, runtimeenv.OriginManagedRuntime, runtimeenv.OriginAgentProfile)
|
||||
|
||||
req := &LaunchRequest{
|
||||
TaskID: "task-1", SessionID: "session-1",
|
||||
EnvironmentResolutionRequired: true,
|
||||
EnvironmentDefinitions: []runtimeenv.Definition{
|
||||
{Key: "SHARED", Literal: "managed-value", Origin: runtimeenv.OriginManagedRuntime},
|
||||
},
|
||||
}
|
||||
profile := &AgentProfileInfo{
|
||||
EnvVars: []settingsmodels.ProfileEnvVar{{Key: "SHARED", Value: "profile-value"}},
|
||||
}
|
||||
|
||||
env, err := mgr.buildEnvForExecution(context.Background(), "exec-1", req, nil, profile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "managed-value", env["SHARED"], "Tier 1 must win over the Tier 2 profile literal")
|
||||
|
||||
entries := logs.FilterMessage("environment override applied").All()
|
||||
require.Len(t, entries, 1, "exactly one OverrideRecord was produced")
|
||||
fields := entries[0].ContextMap()
|
||||
require.Equal(t, "SHARED", fields["env_key"])
|
||||
require.Equal(t, []interface{}{runtimeenv.OriginManagedRuntime}, fields["winning_origins"])
|
||||
require.EqualValues(t, int(runtimeenv.TierAuthoritative), fields["winning_tier"])
|
||||
require.Equal(t, []interface{}{runtimeenv.OriginAgentProfile}, fields["losing_origins"])
|
||||
require.EqualValues(t, []interface{}{int(runtimeenv.TierProfileDefault)}, fields["losing_tiers"])
|
||||
require.Equal(t, "task-1", fields["task_id"])
|
||||
require.Equal(t, "session-1", fields["session_id"])
|
||||
|
||||
after := readOverrideCounter(t, runtimeenv.OriginManagedRuntime, runtimeenv.OriginAgentProfile)
|
||||
require.Equal(t, before+1, after)
|
||||
}
|
||||
|
||||
// TestResolveStrictEnvironment_PreservesCapturedExecutorProfileDefinitions
|
||||
// pins the executor-profile half of AC-42: resolveStrictEnvironment
|
||||
// consumes req.EnvironmentDefinitions exactly as the orchestrator captured
|
||||
// them and never re-reads the executor profile. m.executorProfileReader is
|
||||
// left nil here — if this call chain re-read the executor profile it would
|
||||
// panic on the nil reader instead of resolving from the captured
|
||||
// definitions, so a clean resolve proves the capture is honoured.
|
||||
func TestResolveStrictEnvironment_PreservesCapturedExecutorProfileDefinitions(t *testing.T) {
|
||||
mgr := newTestManager(t)
|
||||
require.Nil(t, mgr.executorProfileReader)
|
||||
|
||||
req := &LaunchRequest{
|
||||
TaskID: "task-1", SessionID: "session-1",
|
||||
EnvironmentResolutionRequired: true,
|
||||
EnvironmentDefinitions: []runtimeenv.Definition{
|
||||
{Key: "TOKEN", Literal: "captured-value", Origin: runtimeenv.OriginExecutorProfile},
|
||||
},
|
||||
}
|
||||
|
||||
env, err := mgr.buildEnvForExecution(context.Background(), "exec-1", req, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "captured-value", env["TOKEN"])
|
||||
}
|
||||
|
||||
// TestBuildEnvForExecution_PreflightAcceptsButLifecycleResolveConflicts pins
|
||||
// AC-43 using the F29-corrected construction. The spec's own literal
|
||||
// wording — two differing Tier 1/Tier 2 literals — cannot produce this
|
||||
// case: Tier 1 always wins that pair outright, which is this feature's
|
||||
// headline behaviour, not a bug. Construction (a) instead uses a
|
||||
// SECRET-BACKED agent-profile definition against a literal preflight key:
|
||||
// the orchestrator preflight only ever sees the literal (validates
|
||||
// cleanly), and the lifecycle site's later-appended secret-backed
|
||||
// agent-profile definition trips the AC-9 secret veto the preflight could
|
||||
// not see, so the launch fails on the RESOLVER's error, not the
|
||||
// preflight's success.
|
||||
func TestBuildEnvForExecution_PreflightAcceptsButLifecycleResolveConflicts(t *testing.T) {
|
||||
store := newInMemorySecretStore()
|
||||
require.NoError(t, store.Create(context.Background(), &secrets.SecretWithValue{
|
||||
Secret: secrets.Secret{ID: "secret-shared"}, Value: "secret-value",
|
||||
}))
|
||||
|
||||
preflight := []runtimeenv.Definition{
|
||||
{Key: "SHARED", Literal: "preflight-value", Origin: runtimeenv.OriginExecutorProfile},
|
||||
}
|
||||
require.NoError(t, runtimeenv.Validate(preflight), "the preflight must accept a single Tier 1 literal")
|
||||
|
||||
mgr := newTestManager(t)
|
||||
mgr.secretStore = store
|
||||
req := &LaunchRequest{
|
||||
TaskID: "task-1", SessionID: "session-1",
|
||||
EnvironmentResolutionRequired: true,
|
||||
EnvironmentDefinitions: preflight,
|
||||
}
|
||||
profile := &AgentProfileInfo{
|
||||
EnvVars: []settingsmodels.ProfileEnvVar{{Key: "SHARED", SecretID: "secret-shared"}},
|
||||
}
|
||||
|
||||
_, err := mgr.buildEnvForExecution(context.Background(), "exec-1", req, nil, profile)
|
||||
require.Error(t, err)
|
||||
var conflictErr *runtimeenv.ConflictError
|
||||
require.ErrorAs(t, err, &conflictErr)
|
||||
}
|
||||
|
||||
// TestExecutorProfileEnvironmentDefinitions_SkipsBlankEntry is the
|
||||
// lifecycle-side half of AC-38b's dual-path equivalence requirement: given
|
||||
// the identical ProfileEnvVar input used by
|
||||
// TestTaskEnvironmentSources_SkipsBlankProfileEnvVar in the orchestrator
|
||||
// package, executorProfileEnvironmentDefinitions produces the same
|
||||
// definition set — one definition, for the populated key only. This path
|
||||
// already dropped blanks before this feature
|
||||
// (environment_resolution.go:257-259 pre-change); the orchestrator path is
|
||||
// what this feature changed, and both must now agree.
|
||||
func TestExecutorProfileEnvironmentDefinitions_SkipsBlankEntry(t *testing.T) {
|
||||
reader := &fakeExecutorProfileReader{
|
||||
profiles: map[string]*models.ExecutorProfile{
|
||||
"prof-1": {
|
||||
ID: "prof-1",
|
||||
EnvVars: []models.ProfileEnvVar{
|
||||
{Key: "POPULATED", Value: "value"},
|
||||
{Key: "BLANK"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
mgr := newExecutorProfileEnvManager(t, reader)
|
||||
|
||||
defs, err := mgr.executorProfileEnvironmentDefinitions(context.Background(), "prof-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, defs, 1)
|
||||
require.Equal(t, "POPULATED", defs[0].Key)
|
||||
}
|
||||
@@ -20,31 +20,11 @@ type environmentSource struct {
|
||||
workspaceID string
|
||||
}
|
||||
|
||||
type environmentSourceResolver func(context.Context, environmentSource) (string, error)
|
||||
|
||||
// Keep the historical executor error names as aliases while the lifecycle and
|
||||
// orchestrator share one source-aware resolver.
|
||||
type EnvironmentConflictError = runtimeenv.ConflictError
|
||||
type EnvironmentSecretError = runtimeenv.SecretError
|
||||
|
||||
func resolveEnvironmentSources(
|
||||
ctx context.Context, sources []environmentSource, resolve environmentSourceResolver,
|
||||
) (map[string]string, error) {
|
||||
definitions := make([]runtimeenv.Definition, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
definitions = append(definitions, runtimeenv.Definition{
|
||||
Key: source.key, Literal: source.literal, SecretID: source.secretID,
|
||||
Origin: source.origin, WorkspaceID: source.workspaceID,
|
||||
})
|
||||
}
|
||||
return runtimeenv.Resolve(ctx, definitions, func(ctx context.Context, definition runtimeenv.Definition) (string, error) {
|
||||
return resolve(ctx, environmentSource{
|
||||
key: definition.Key, literal: definition.Literal, secretID: definition.SecretID,
|
||||
origin: definition.Origin, workspaceID: definition.WorkspaceID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Executor) taskEnvironmentSources(
|
||||
workspaceID string,
|
||||
managed map[string]string,
|
||||
@@ -60,11 +40,14 @@ func (e *Executor) taskEnvironmentSources(
|
||||
}
|
||||
sort.Strings(managedKeys)
|
||||
for _, key := range managedKeys {
|
||||
sources = append(sources, environmentSource{key: key, literal: managed[key], origin: "managed runtime"})
|
||||
sources = append(sources, environmentSource{key: key, literal: managed[key], origin: runtimeenv.OriginManagedRuntime})
|
||||
}
|
||||
for _, envVar := range profileEnvVars {
|
||||
if envVar.SecretID == "" && envVar.Value == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, environmentSource{
|
||||
key: envVar.Key, literal: envVar.Value, secretID: envVar.SecretID, origin: "executor profile",
|
||||
key: envVar.Key, literal: envVar.Value, secretID: envVar.SecretID, origin: runtimeenv.OriginExecutorProfile,
|
||||
})
|
||||
}
|
||||
for _, info := range repositories {
|
||||
@@ -74,10 +57,7 @@ func (e *Executor) taskEnvironmentSources(
|
||||
if info.Repository.WorkspaceID != workspaceID {
|
||||
return nil, fmt.Errorf("repository environment belongs to a different workspace")
|
||||
}
|
||||
origin := "repository"
|
||||
if name := strings.TrimSpace(info.Repository.Name); name != "" {
|
||||
origin = "repository " + name
|
||||
}
|
||||
origin := runtimeenv.RepositoryOrigin(info.Repository.Name)
|
||||
for _, binding := range info.Repository.SecretBindings {
|
||||
sources = append(sources, environmentSource{
|
||||
key: binding.Key, secretID: binding.SecretID, origin: origin, workspaceID: workspaceID,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
runtimeenv "github.com/kandev/kandev/internal/agent/runtime/environment"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// TestTaskEnvironmentSources_SkipsBlankProfileEnvVar pins AC-38b at the
|
||||
// orchestrator assembly site: a ProfileEnvVar blank on both Value and
|
||||
// SecretID must not produce an environmentSource, and its key must not
|
||||
// reach req.EnvironmentDefinitions. This reverses the earlier (false) spec
|
||||
// assumption that validateEnvVarValue already prevents such an entry —
|
||||
// that check is agent-profile-only, so an executor profile can hold one.
|
||||
func TestTaskEnvironmentSources_SkipsBlankProfileEnvVar(t *testing.T) {
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository())
|
||||
|
||||
sources, err := exec.taskEnvironmentSources("workspace-1", nil, []models.ProfileEnvVar{
|
||||
{Key: "POPULATED", Value: "value"},
|
||||
{Key: "BLANK"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("taskEnvironmentSources() error = %v", err)
|
||||
}
|
||||
|
||||
if len(sources) != 1 || sources[0].key != "POPULATED" {
|
||||
t.Fatalf("sources = %#v, want only the populated entry", sources)
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.key == "BLANK" {
|
||||
t.Fatalf("sources = %#v, blank entry must not be assembled", sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskEnvironmentSources_KeepsSecretBackedBlankValueEntry pins the exact
|
||||
// boundary AC-38b draws: an entry with a blank Value but a non-empty
|
||||
// SecretID is secret-backed, not blank, and SHALL still be emitted.
|
||||
func TestTaskEnvironmentSources_KeepsSecretBackedBlankValueEntry(t *testing.T) {
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository())
|
||||
|
||||
sources, err := exec.taskEnvironmentSources("workspace-1", nil, []models.ProfileEnvVar{
|
||||
{Key: "TOKEN", SecretID: "secret-1"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("taskEnvironmentSources() error = %v", err)
|
||||
}
|
||||
if len(sources) != 1 || sources[0].key != "TOKEN" || sources[0].secretID != "secret-1" {
|
||||
t.Fatalf("sources = %#v, want the secret-backed entry kept", sources)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLaunchEnvironment_DuplicateExecutorProfileKeyConflicts pins
|
||||
// AC-6a through Executor.taskEnvironmentSources, per spec: the duplicate-key
|
||||
// save-time guarantee is agent-profile-only (profile_crud.go:908), so one
|
||||
// executor profile may legitimately hold two entries for the same key with
|
||||
// different values, and the assembled path must still reject that pair —
|
||||
// unchanged from today, since executor profile is Tier 1 and nothing here
|
||||
// outranks it.
|
||||
func TestResolveLaunchEnvironment_DuplicateExecutorProfileKeyConflicts(t *testing.T) {
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository())
|
||||
req := &LaunchAgentRequest{
|
||||
WorkspaceID: "workspace-1",
|
||||
ExecutorType: string(models.ExecutorTypeLocal),
|
||||
Env: map[string]string{},
|
||||
}
|
||||
|
||||
err := exec.resolveLaunchEnvironment(context.Background(), req, []models.ProfileEnvVar{
|
||||
{Key: "TOKEN", Value: "value-a"},
|
||||
{Key: "TOKEN", Value: "value-b"},
|
||||
}, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("resolveLaunchEnvironment() error = nil, want conflict")
|
||||
}
|
||||
var conflictErr *EnvironmentConflictError
|
||||
if !errors.As(err, &conflictErr) {
|
||||
t.Fatalf("error = %T %v, want EnvironmentConflictError", err, err)
|
||||
}
|
||||
if len(conflictErr.Origins) != 1 || conflictErr.Origins[0] != runtimeenv.OriginExecutorProfile {
|
||||
t.Fatalf("conflict origins = %#v, want only %q", conflictErr.Origins, runtimeenv.OriginExecutorProfile)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLaunchEnvironment_ExecutorProfileVsManagedRuntimeStillConflicts
|
||||
// pins AC-40's second clause: precedence creates no new path to resolve a
|
||||
// key two Tier 1 origins both claim. An executor-profile definition for a
|
||||
// key managed runtime also sets must still fail the preflight, exactly as
|
||||
// it does today, because both origins tie at the top tier.
|
||||
func TestResolveLaunchEnvironment_ExecutorProfileVsManagedRuntimeStillConflicts(t *testing.T) {
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository())
|
||||
req := &LaunchAgentRequest{
|
||||
WorkspaceID: "workspace-1",
|
||||
ExecutorType: string(models.ExecutorTypeLocal),
|
||||
Env: map[string]string{"KANDEV_FOO": "managed-value"},
|
||||
}
|
||||
|
||||
err := exec.resolveLaunchEnvironment(context.Background(), req, []models.ProfileEnvVar{
|
||||
{Key: "KANDEV_FOO", Value: "profile-value"},
|
||||
}, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("resolveLaunchEnvironment() error = nil, want conflict")
|
||||
}
|
||||
var conflictErr *EnvironmentConflictError
|
||||
if !errors.As(err, &conflictErr) {
|
||||
t.Fatalf("error = %T %v, want EnvironmentConflictError", err, err)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,35 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
runtimeenv "github.com/kandev/kandev/internal/agent/runtime/environment"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
type environmentSourceResolver func(context.Context, environmentSource) (string, error)
|
||||
|
||||
// resolveEnvironmentSources is test-only: it exists to exercise
|
||||
// runtimeenv.Resolve against the environmentSource shape without a
|
||||
// task/session context to log or count overrides against. It discards the
|
||||
// OverrideRecords Resolve returns and SHALL NOT log or count on its own.
|
||||
func resolveEnvironmentSources(
|
||||
ctx context.Context, sources []environmentSource, resolve environmentSourceResolver,
|
||||
) (map[string]string, error) {
|
||||
definitions := make([]runtimeenv.Definition, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
definitions = append(definitions, runtimeenv.Definition{
|
||||
Key: source.key, Literal: source.literal, SecretID: source.secretID,
|
||||
Origin: source.origin, WorkspaceID: source.workspaceID,
|
||||
})
|
||||
}
|
||||
resolved, _, err := runtimeenv.Resolve(ctx, definitions, func(ctx context.Context, definition runtimeenv.Definition) (string, error) {
|
||||
return resolve(ctx, environmentSource{
|
||||
key: definition.Key, literal: definition.Literal, secretID: definition.SecretID,
|
||||
origin: definition.Origin, workspaceID: definition.WorkspaceID,
|
||||
})
|
||||
})
|
||||
return resolved, err
|
||||
}
|
||||
|
||||
func TestResolveEnvironmentSources_DeduplicatesSameSecretAndIgnoresOrder(t *testing.T) {
|
||||
resolver := func(_ context.Context, source environmentSource) (string, error) {
|
||||
return map[string]string{"secret-a": "alpha", "secret-b": "bravo"}[source.secretID], nil
|
||||
|
||||
@@ -294,6 +294,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
|
||||
| [e2e-duration-aware-sharding](e2e-duration-aware-sharding/spec.md) | implemented |
|
||||
| [board-step-visibility-filter](board-step-visibility-filter/spec.md) | draft |
|
||||
| [shutdown-turn-failure-suppression](shutdown-turn-failure-suppression/spec.md) | draft |
|
||||
| [executor-profile-env-precedence](executor-profile-env-precedence/spec.md) | building |
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user