Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 55 additions & 9 deletions pkg/agent/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A
// config.yaml (seeded from harness embeds) always has the user field.
// Also check template directories since harness-configs may be bundled
// inside templates (§3.4 of agnostic-template-design).
// resolvedHarnessConfigAuth captures the auth metadata from the resolved
// on-disk harness config for use by the auth pipeline later.
var resolvedHarnessConfigAuth *config.HarnessAuthMetadata
if harnessConfigName != "" {
var templatePaths []string
// Prefer opts.Template when it is an absolute path (e.g. hydrated
Expand Down Expand Up @@ -248,6 +251,9 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A
if hcDir.Config.User != "" {
unixUsername = hcDir.Config.User
}
if hcDir.Config.Auth != nil {
resolvedHarnessConfigAuth = hcDir.Config.Auth
}
} else {
util.Debugf("image resolution: on-disk harness-config %q not found: %v", harnessConfigName, err)
}
Expand Down Expand Up @@ -389,6 +395,18 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A
}
}

// Resolve auth metadata for the config-driven env var pipeline.
// Prefer the on-disk harness config (already resolved above for image/
// user); fall back to the settings entry.
var authMeta *config.HarnessAuthMetadata
if resolvedHarnessConfigAuth != nil {
authMeta = resolvedHarnessConfigAuth
} else if harnessConfigName != "" && settings != nil {
if hcEntry, err := settings.ResolveHarnessConfig(profileName, harnessConfigName); err == nil && hcEntry.Auth != nil {
authMeta = hcEntry.Auth
}
}

// 3. Resolve credentials via new auth pipeline

// Inject profile/harness-config env vars into opts.Env BEFORE building the
Expand Down Expand Up @@ -426,7 +444,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A
var auth api.AuthConfig
var resolvedAuth *api.ResolvedAuth
if !opts.NoAuth {
auth = harness.GatherAuthWithEnv(authEnvOverlay, !opts.BrokerMode)
auth = harness.GatherAuthWithEnv(authEnvOverlay, !opts.BrokerMode, authMeta)
if opts.BrokerMode {
harness.OverlayFileSecrets(&auth, opts.ResolvedSecrets)
}
Expand Down Expand Up @@ -473,7 +491,8 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A
util.Debugf("auth: applied harness-specific settings for %q", harnessName)
}
resolvedAuth = resolved
opts.ResolvedSecrets = filterResolvedSecretsForResolvedAuth(opts.ResolvedSecrets, &resolvedForSecretFilter)
configKeys := configAuthEnvKeySet(authMeta)
opts.ResolvedSecrets = filterResolvedSecretsForResolvedAuth(opts.ResolvedSecrets, &resolvedForSecretFilter, configKeys)
// The hub pre-merges environment-type secrets into ResolvedEnv before
// dispatching to the broker (see pkg/hub/httpdispatcher.go), so auth
// env keys copied into opts.Env via start_context's ResolvedEnv merge
Expand All @@ -486,7 +505,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A
requiredAuthEnv[k] = struct{}{}
}
for k := range opts.Env {
if !isAuthEnvKey(k) {
if !isAuthEnvKey(k, configKeys) {
continue
}
if _, required := requiredAuthEnv[k]; !required {
Expand Down Expand Up @@ -1115,8 +1134,9 @@ func buildAuthEnvOverlay(baseEnv map[string]string, secrets []api.ResolvedSecret

// filterResolvedSecretsForResolvedAuth drops auth-candidate secrets that are
// not required by the selected resolved auth method while preserving all
// non-auth secrets.
func filterResolvedSecretsForResolvedAuth(secrets []api.ResolvedSecret, resolved *api.ResolvedAuth) []api.ResolvedSecret {
// non-auth secrets. configAuthKeys extends the hardcoded auth key set with
// keys from the harness config's auth metadata.
func filterResolvedSecretsForResolvedAuth(secrets []api.ResolvedSecret, resolved *api.ResolvedAuth, configAuthKeys map[string]struct{}) []api.ResolvedSecret {
if len(secrets) == 0 || resolved == nil {
return secrets
}
Expand All @@ -1136,7 +1156,7 @@ func filterResolvedSecretsForResolvedAuth(secrets []api.ResolvedSecret, resolved

filtered := make([]api.ResolvedSecret, 0, len(secrets))
for _, s := range secrets {
if !isAuthCandidateSecret(s) {
if !isAuthCandidateSecret(s, configAuthKeys) {
filtered = append(filtered, s)
continue
}
Expand All @@ -1163,8 +1183,8 @@ func filterResolvedSecretsForResolvedAuth(secrets []api.ResolvedSecret, resolved
return filtered
}

func isAuthCandidateSecret(s api.ResolvedSecret) bool {
if (s.Type == "environment" || s.Type == "") && isAuthEnvKey(secretEnvTarget(s)) {
func isAuthCandidateSecret(s api.ResolvedSecret, configAuthKeys map[string]struct{}) bool {
if (s.Type == "environment" || s.Type == "") && isAuthEnvKey(secretEnvTarget(s), configAuthKeys) {
return true
}
if s.Type == "file" && authFileKind(s.Name, s.Target) != "" {
Expand All @@ -1180,7 +1200,7 @@ func secretEnvTarget(s api.ResolvedSecret) string {
return s.Name
}

func isAuthEnvKey(key string) bool {
func isAuthEnvKey(key string, extraAuthKeys ...map[string]struct{}) bool {
switch key {
case "GEMINI_API_KEY",
"GOOGLE_API_KEY",
Expand All @@ -1196,10 +1216,36 @@ func isAuthEnvKey(key string) bool {
"GOOGLE_CLOUD_LOCATION":
return true
default:
for _, extra := range extraAuthKeys {
if _, ok := extra[key]; ok {
return true
}
}
return false
}
}

// configAuthEnvKeySet builds a set of env var keys declared across all auth
// types in a harness config's auth metadata. Returns nil when no keys are
// declared. Used to extend isAuthEnvKey with config-driven keys.
func configAuthEnvKeySet(authMeta *config.HarnessAuthMetadata) map[string]struct{} {
if authMeta == nil || len(authMeta.Types) == 0 {
return nil
}
keys := make(map[string]struct{})
for _, authType := range authMeta.Types {
for _, req := range authType.RequiredEnv {
for _, k := range req.AnyOf {
keys[k] = struct{}{}
}
}
}
if len(keys) == 0 {
return nil
}
return keys
}

func authFileKind(name, target string) string {
switch {
case name == "gcloud-adc" || strings.HasSuffix(target, "/application_default_credentials.json"):
Expand Down
109 changes: 108 additions & 1 deletion pkg/agent/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1772,7 +1772,7 @@ func TestFilterResolvedSecretsForResolvedAuth(t *testing.T) {
},
}

filtered := filterResolvedSecretsForResolvedAuth(secrets, resolved)
filtered := filterResolvedSecretsForResolvedAuth(secrets, resolved, nil)
if len(filtered) != 2 {
t.Fatalf("expected 2 secrets after filtering, got %d", len(filtered))
}
Expand All @@ -1792,6 +1792,113 @@ func TestFilterResolvedSecretsForResolvedAuth(t *testing.T) {
}
}

func TestIsAuthEnvKey_BuiltinKeys(t *testing.T) {
builtins := []string{
"GEMINI_API_KEY", "GOOGLE_API_KEY", "ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY",
"GOOGLE_CLOUD_PROJECT", "GCP_PROJECT", "ANTHROPIC_VERTEX_PROJECT_ID",
"GOOGLE_CLOUD_REGION", "CLOUD_ML_REGION", "GOOGLE_CLOUD_LOCATION",
}
for _, key := range builtins {
if !isAuthEnvKey(key) {
t.Errorf("isAuthEnvKey(%q) = false, want true", key)
}
}
if isAuthEnvKey("RANDOM_ENV_VAR") {
t.Error("isAuthEnvKey(RANDOM_ENV_VAR) = true, want false")
}
}

func TestIsAuthEnvKey_ConfigDrivenKeys(t *testing.T) {
configKeys := map[string]struct{}{
"COPILOT_GITHUB_TOKEN": {},
"GH_TOKEN": {},
"GITHUB_TOKEN": {},
}

if !isAuthEnvKey("COPILOT_GITHUB_TOKEN", configKeys) {
t.Error("isAuthEnvKey(COPILOT_GITHUB_TOKEN, configKeys) = false, want true")
}
if !isAuthEnvKey("GH_TOKEN", configKeys) {
t.Error("isAuthEnvKey(GH_TOKEN, configKeys) = false, want true")
}
// Built-in keys still work with config keys present
if !isAuthEnvKey("GEMINI_API_KEY", configKeys) {
t.Error("isAuthEnvKey(GEMINI_API_KEY, configKeys) = false, want true")
}
// Unknown key is still not auth
if isAuthEnvKey("RANDOM_VAR", configKeys) {
t.Error("isAuthEnvKey(RANDOM_VAR, configKeys) = true, want false")
}
}

func TestConfigAuthEnvKeySet(t *testing.T) {
authMeta := &config.HarnessAuthMetadata{
Types: map[string]config.HarnessAuthTypeMetadata{
"api-key": {
RequiredEnv: []config.HarnessAuthEnvRequirement{
{AnyOf: []string{"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"}},
},
},
"vertex-ai": {
RequiredEnv: []config.HarnessAuthEnvRequirement{
{AnyOf: []string{"GOOGLE_CLOUD_PROJECT"}},
},
},
},
}

keys := configAuthEnvKeySet(authMeta)
expected := []string{"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "GOOGLE_CLOUD_PROJECT"}
for _, k := range expected {
if _, ok := keys[k]; !ok {
t.Errorf("expected key %q in configAuthEnvKeySet result", k)
}
}

nilKeys := configAuthEnvKeySet(nil)
if nilKeys != nil {
t.Errorf("expected nil for nil authMeta, got %v", nilKeys)
}
}

func TestFilterResolvedSecretsForResolvedAuth_ConfigDrivenKeys(t *testing.T) {
configKeys := map[string]struct{}{
"COPILOT_GITHUB_TOKEN": {},
"GH_TOKEN": {},
}

secrets := []api.ResolvedSecret{
{Name: "COPILOT_GITHUB_TOKEN", Type: "environment", Target: "COPILOT_GITHUB_TOKEN", Value: "ghp_test"},
{Name: "GH_TOKEN", Type: "environment", Target: "GH_TOKEN", Value: "gh_test"},
{Name: "SOME_OTHER_SECRET", Type: "environment", Target: "SOME_OTHER_SECRET", Value: "other"},
}

resolved := &api.ResolvedAuth{
Method: "api-key",
EnvVars: map[string]string{
"COPILOT_GITHUB_TOKEN": "ghp_test",
},
}

filtered := filterResolvedSecretsForResolvedAuth(secrets, resolved, configKeys)

got := make(map[string]struct{}, len(filtered))
for _, s := range filtered {
got[s.Name] = struct{}{}
}

if _, ok := got["COPILOT_GITHUB_TOKEN"]; !ok {
t.Error("expected COPILOT_GITHUB_TOKEN to be kept (required by resolved auth)")
}
if _, ok := got["GH_TOKEN"]; ok {
t.Error("expected GH_TOKEN to be dropped (config-driven auth key not required by resolved auth)")
}
if _, ok := got["SOME_OTHER_SECRET"]; !ok {
t.Error("expected SOME_OTHER_SECRET to be kept (not an auth key)")
}
}

func TestStartInjectsHubEnvFromProjectSettings(t *testing.T) {
// When project settings have hub enabled with an endpoint, Start() should
// inject SCION_HUB_ENDPOINT and SCION_HUB_URL into the container env.
Expand Down
6 changes: 6 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,12 @@ type AuthConfig struct {

// Auth mode selection
SelectedType string

// EnvVars holds config-driven auth env vars gathered from harness
// config metadata (auth.types[*].required_env). These flow through
// the auth pipeline alongside the hardcoded fields above, enabling
// new harnesses to declare auth requirements without Go code changes.
EnvVars map[string]string
}

// ResolvedAuth represents the single best auth method selected by a harness's
Expand Down
44 changes: 42 additions & 2 deletions pkg/harness/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import (
// It is source-agnostic: it checks env vars and well-known file paths
// without knowing which harness will consume the result.
func GatherAuth() api.AuthConfig {
return GatherAuthWithEnv(nil, true)
return GatherAuthWithEnv(nil, true, nil)
}

// GatherAuthWithEnv is like GatherAuth but checks the provided env overlay
Expand All @@ -43,7 +43,11 @@ func GatherAuth() api.AuthConfig {
// the env map and never falls back to os.Getenv(), and filesystem scanning
// for well-known credential files is skipped entirely. This prevents broker
// operator credentials from leaking into hub-dispatched agents.
func GatherAuthWithEnv(env map[string]string, localSources bool) api.AuthConfig {
//
// When authMeta is non-nil, env vars declared in the harness config's
// auth.types[*].required_env groups are gathered into AuthConfig.EnvVars,
// enabling config-driven auth passthrough without hardcoded Go fields.
func GatherAuthWithEnv(env map[string]string, localSources bool, authMeta *config.HarnessAuthMetadata) api.AuthConfig {
lookup := func(key string) string {
if v, ok := env[key]; ok && v != "" {
return v
Expand Down Expand Up @@ -115,9 +119,45 @@ func GatherAuthWithEnv(env map[string]string, localSources bool) api.AuthConfig
}
}

// Populate EnvVars from config-driven auth metadata. Every env key
// declared in any auth type's required_env groups is looked up; keys
// with non-empty values are included. This lets harness configs like
// copilot declare their own env requirements and have them flow through
// the auth pipeline without per-harness Go code.
if authMeta != nil {
auth.EnvVars = gatherConfigEnvVars(lookup, authMeta)
}

return auth
}

// gatherConfigEnvVars collects env var values for all keys declared in any
// auth type's required_env groups. Returns nil when no values are found.
func gatherConfigEnvVars(lookup func(string) string, authMeta *config.HarnessAuthMetadata) map[string]string {
if authMeta == nil || len(authMeta.Types) == 0 {
return nil
}
var result map[string]string
seen := make(map[string]struct{})
for _, authType := range authMeta.Types {
for _, req := range authType.RequiredEnv {
for _, key := range req.AnyOf {
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if v := lookup(key); v != "" {
if result == nil {
result = make(map[string]string)
}
result[key] = v
}
}
}
}
return result
}

// OverlayFileSecrets bridges file-type ResolvedSecrets from the hub into
// AuthConfig fields so that ResolveAuth can determine the correct auth method.
// It maps well-known secret names/targets to the corresponding AuthConfig fields
Expand Down
Loading
Loading