diff --git a/pkg/agent/run.go b/pkg/agent/run.go index f72aaf527..22615d436 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -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 @@ -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) } @@ -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 @@ -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) } @@ -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 @@ -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 { @@ -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 } @@ -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 } @@ -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) != "" { @@ -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", @@ -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"): diff --git a/pkg/agent/run_test.go b/pkg/agent/run_test.go index a049180f6..51b2cc5f0 100644 --- a/pkg/agent/run_test.go +++ b/pkg/agent/run_test.go @@ -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)) } @@ -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. diff --git a/pkg/api/types.go b/pkg/api/types.go index 659250937..f936b998c 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -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 diff --git a/pkg/harness/auth.go b/pkg/harness/auth.go index ae8383ccb..1e4c3ba11 100644 --- a/pkg/harness/auth.go +++ b/pkg/harness/auth.go @@ -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 @@ -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 @@ -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 diff --git a/pkg/harness/auth_test.go b/pkg/harness/auth_test.go index b908a437b..e67696c64 100644 --- a/pkg/harness/auth_test.go +++ b/pkg/harness/auth_test.go @@ -356,7 +356,7 @@ func TestGatherAuthWithEnv_OverlayTakesPrecedence(t *testing.T) { "GEMINI_API_KEY": "overlay-gemini", } - auth := GatherAuthWithEnv(overlay, true) + auth := GatherAuthWithEnv(overlay, true, nil) if auth.GeminiAPIKey != "overlay-gemini" { t.Errorf("GeminiAPIKey = %q, want %q (overlay should take precedence)", auth.GeminiAPIKey, "overlay-gemini") @@ -372,7 +372,7 @@ func TestGatherAuthWithEnv_NilOverlay(t *testing.T) { t.Setenv("OPENAI_API_KEY", "process-openai") // nil overlay should behave identically to GatherAuth - auth := GatherAuthWithEnv(nil, true) + auth := GatherAuthWithEnv(nil, true, nil) if auth.GeminiAPIKey != "process-gemini" { t.Errorf("GeminiAPIKey = %q, want %q", auth.GeminiAPIKey, "process-gemini") @@ -673,7 +673,7 @@ func TestGatherAuthWithEnv_EmptyOverlayValueFallsThrough(t *testing.T) { "GEMINI_API_KEY": "", } - auth := GatherAuthWithEnv(overlay, true) + auth := GatherAuthWithEnv(overlay, true, nil) if auth.GeminiAPIKey != "process-gemini" { t.Errorf("GeminiAPIKey = %q, want %q (empty overlay should fall through)", auth.GeminiAPIKey, "process-gemini") @@ -691,7 +691,7 @@ func TestGatherAuthWithEnv_OverlayProjectFallbacks(t *testing.T) { "GCP_PROJECT": "overlay-project", } - auth := GatherAuthWithEnv(overlay, true) + auth := GatherAuthWithEnv(overlay, true, nil) if auth.GoogleCloudProject != "overlay-project" { t.Errorf("GoogleCloudProject = %q, want %q (overlay fallback)", auth.GoogleCloudProject, "overlay-project") @@ -724,7 +724,7 @@ func TestGatherAuthWithEnv_OverlayAllKeys(t *testing.T) { "GOOGLE_APPLICATION_CREDENTIALS": "/ov/creds.json", } - auth := GatherAuthWithEnv(overlay, true) + auth := GatherAuthWithEnv(overlay, true, nil) if auth.GeminiAPIKey != "ov-gemini" { t.Errorf("GeminiAPIKey = %q, want %q", auth.GeminiAPIKey, "ov-gemini") @@ -759,14 +759,14 @@ func TestGatherAuthWithEnv_GCPMetadataMode(t *testing.T) { overlay := map[string]string{ "SCION_METADATA_MODE": "assign", } - auth := GatherAuthWithEnv(overlay, true) + auth := GatherAuthWithEnv(overlay, true, nil) if auth.GCPMetadataMode != "assign" { t.Errorf("GCPMetadataMode = %q, want %q", auth.GCPMetadataMode, "assign") } // From process env t.Setenv("SCION_METADATA_MODE", "block") - auth2 := GatherAuthWithEnv(nil, true) + auth2 := GatherAuthWithEnv(nil, true, nil) if auth2.GCPMetadataMode != "block" { t.Errorf("GCPMetadataMode = %q, want %q", auth2.GCPMetadataMode, "block") } @@ -852,7 +852,7 @@ func TestGatherAuthWithEnv_BrokerMode(t *testing.T) { overlay := map[string]string{ "ANTHROPIC_API_KEY": "hub-anthropic", } - auth := GatherAuthWithEnv(overlay, false) + auth := GatherAuthWithEnv(overlay, false, nil) // Overlay key should be present if auth.AnthropicAPIKey != "hub-anthropic" { @@ -1178,3 +1178,131 @@ func TestStageCaptureAuthAssets(t *testing.T) { } }) } + +func TestGatherAuthWithEnv_ConfigDrivenEnvVars(t *testing.T) { + t.Setenv("COPILOT_GITHUB_TOKEN", "ghp_test123") + t.Setenv("GH_TOKEN", "gh_test456") + + authMeta := &config.HarnessAuthMetadata{ + DefaultType: "api-key", + Types: map[string]config.HarnessAuthTypeMetadata{ + "api-key": { + RequiredEnv: []config.HarnessAuthEnvRequirement{ + {AnyOf: []string{"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "SCION_TEST_UNSET_TOKEN"}}, + }, + }, + }, + } + + auth := GatherAuthWithEnv(nil, true, authMeta) + + if auth.EnvVars == nil { + t.Fatal("EnvVars should not be nil when config metadata declares env vars") + } + if auth.EnvVars["COPILOT_GITHUB_TOKEN"] != "ghp_test123" { + t.Errorf("COPILOT_GITHUB_TOKEN = %q, want %q", auth.EnvVars["COPILOT_GITHUB_TOKEN"], "ghp_test123") + } + if auth.EnvVars["GH_TOKEN"] != "gh_test456" { + t.Errorf("GH_TOKEN = %q, want %q", auth.EnvVars["GH_TOKEN"], "gh_test456") + } + if _, ok := auth.EnvVars["SCION_TEST_UNSET_TOKEN"]; ok { + t.Error("SCION_TEST_UNSET_TOKEN should not be in EnvVars when not set in environment") + } +} + +func TestGatherAuthWithEnv_ConfigDrivenEnvVarsFromOverlay(t *testing.T) { + overlay := map[string]string{ + "COPILOT_GITHUB_TOKEN": "overlay-token", + } + + authMeta := &config.HarnessAuthMetadata{ + Types: map[string]config.HarnessAuthTypeMetadata{ + "api-key": { + RequiredEnv: []config.HarnessAuthEnvRequirement{ + {AnyOf: []string{"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"}}, + }, + }, + }, + } + + auth := GatherAuthWithEnv(overlay, true, authMeta) + + if auth.EnvVars == nil { + t.Fatal("EnvVars should not be nil") + } + if auth.EnvVars["COPILOT_GITHUB_TOKEN"] != "overlay-token" { + t.Errorf("COPILOT_GITHUB_TOKEN = %q, want %q", auth.EnvVars["COPILOT_GITHUB_TOKEN"], "overlay-token") + } +} + +func TestGatherAuthWithEnv_NilAuthMetaNoEnvVars(t *testing.T) { + auth := GatherAuthWithEnv(nil, true, nil) + if auth.EnvVars != nil { + t.Errorf("EnvVars should be nil when authMeta is nil, got %v", auth.EnvVars) + } +} + +func TestGatherAuthWithEnv_EmptyAuthMetaNoEnvVars(t *testing.T) { + authMeta := &config.HarnessAuthMetadata{} + auth := GatherAuthWithEnv(nil, true, authMeta) + if auth.EnvVars != nil { + t.Errorf("EnvVars should be nil when authMeta has no types, got %v", auth.EnvVars) + } +} + +func TestGatherAuthWithEnv_ConfigDrivenMultipleAuthTypes(t *testing.T) { + t.Setenv("COPILOT_GITHUB_TOKEN", "ghp_test") + t.Setenv("GOOGLE_CLOUD_PROJECT", "my-project") + + authMeta := &config.HarnessAuthMetadata{ + Types: map[string]config.HarnessAuthTypeMetadata{ + "api-key": { + RequiredEnv: []config.HarnessAuthEnvRequirement{ + {AnyOf: []string{"COPILOT_GITHUB_TOKEN", "GH_TOKEN"}}, + }, + }, + "vertex-ai": { + RequiredEnv: []config.HarnessAuthEnvRequirement{ + {AnyOf: []string{"GOOGLE_CLOUD_PROJECT"}}, + {AnyOf: []string{"GOOGLE_CLOUD_REGION"}}, + }, + }, + }, + } + + auth := GatherAuthWithEnv(nil, true, authMeta) + + if auth.EnvVars["COPILOT_GITHUB_TOKEN"] != "ghp_test" { + t.Errorf("COPILOT_GITHUB_TOKEN = %q, want %q", auth.EnvVars["COPILOT_GITHUB_TOKEN"], "ghp_test") + } + if auth.EnvVars["GOOGLE_CLOUD_PROJECT"] != "my-project" { + t.Errorf("GOOGLE_CLOUD_PROJECT = %q, want %q", auth.EnvVars["GOOGLE_CLOUD_PROJECT"], "my-project") + } + if _, ok := auth.EnvVars["GOOGLE_CLOUD_REGION"]; ok { + t.Error("GOOGLE_CLOUD_REGION should not be in EnvVars when not set") + } +} + +func TestGatherAuthWithEnv_BrokerModeConfigDriven(t *testing.T) { + overlay := map[string]string{ + "COPILOT_GITHUB_TOKEN": "broker-token", + } + + authMeta := &config.HarnessAuthMetadata{ + Types: map[string]config.HarnessAuthTypeMetadata{ + "api-key": { + RequiredEnv: []config.HarnessAuthEnvRequirement{ + {AnyOf: []string{"COPILOT_GITHUB_TOKEN"}}, + }, + }, + }, + } + + // In broker mode (localSources=false), env vars come only from overlay + t.Setenv("COPILOT_GITHUB_TOKEN", "should-not-see-this") + auth := GatherAuthWithEnv(overlay, false, authMeta) + + if auth.EnvVars["COPILOT_GITHUB_TOKEN"] != "broker-token" { + t.Errorf("COPILOT_GITHUB_TOKEN = %q, want overlay value %q", auth.EnvVars["COPILOT_GITHUB_TOKEN"], "broker-token") + } +} diff --git a/pkg/harness/container_script_harness.go b/pkg/harness/container_script_harness.go index b2e594f5b..f3edefbdf 100644 --- a/pkg/harness/container_script_harness.go +++ b/pkg/harness/container_script_harness.go @@ -230,6 +230,16 @@ func (c *ContainerScriptHarness) ResolveAuth(auth api.AuthConfig) (*api.Resolved addIfPresent("GOOGLE_CLOUD_REGION", auth.GoogleCloudRegion) addIfPresent("CODEX_API_KEY", auth.CodexAPIKey) + // Forward config-driven auth env vars. These come from harness config + // metadata (auth.types[*].required_env) and are gathered by + // GatherAuthWithEnv. They are additive — hardcoded fields above take + // precedence if the same key appears in both. + for k, v := range auth.EnvVars { + if _, exists := resolved.EnvVars[k]; !exists { + resolved.EnvVars[k] = v + } + } + if auth.GoogleAppCredentials != "" { resolved.Files = append(resolved.Files, api.FileMapping{ SourcePath: auth.GoogleAppCredentials, diff --git a/pkg/harness/generic.go b/pkg/harness/generic.go index 52800200f..e9c6a8277 100644 --- a/pkg/harness/generic.go +++ b/pkg/harness/generic.go @@ -135,6 +135,12 @@ func (g *Generic) ResolveAuth(auth api.AuthConfig) (*api.ResolvedAuth, error) { result.EnvVars["GOOGLE_CLOUD_REGION"] = auth.GoogleCloudRegion } + for k, v := range auth.EnvVars { + if _, exists := result.EnvVars[k]; !exists { + result.EnvVars[k] = v + } + } + if auth.GoogleAppCredentials != "" { adcContainerPath := "~/.config/gcloud/application_default_credentials.json" result.Files = append(result.Files, api.FileMapping{ diff --git a/pkg/runtimebroker/handlers.go b/pkg/runtimebroker/handlers.go index ec3624a34..8f68b9c55 100644 --- a/pkg/runtimebroker/handlers.go +++ b/pkg/runtimebroker/handlers.go @@ -427,7 +427,29 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) { } } - required, secretInfo := s.extractRequiredEnvKeys(req) + // Hydrate hub-managed harness-config before extracting required keys + // so that config-driven auth metadata is available during env-gather. + // Graceful degradation: if hydration fails, fall back to on-disk only. + var hydratedHCPath string + if req.Config != nil && (req.Config.HarnessConfigID != "" || req.Config.HarnessConfigHash != "") { + hubConn := s.resolveHubConnection(r) + if hubConn != nil { + hydrateCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + hcPath, err := s.hydrateHarnessConfig(hydrateCtx, req.Config, hubConn) + cancel() + if err != nil { + if s.config.Debug { + s.envSecretLog.Debug("Env-gather: harness-config hydration failed, falling back to on-disk", + "error", err.Error(), + ) + } + } else { + hydratedHCPath = hcPath + } + } + } + + required, secretInfo := s.extractRequiredEnvKeys(req, hydratedHCPath) if s.config.Debug { s.envSecretLog.Debug("Env-gather: evaluating env completeness", "gatherEnv", req.GatherEnv, @@ -1865,7 +1887,12 @@ func (s *Server) checkAgentPrompt(w http.ResponseWriter, r *http.Request, id, pr // env requirements. // // Phase 3 (secrets): Collects explicitly-declared secrets from settings and templates. -func (s *Server) extractRequiredEnvKeys(req CreateAgentRequest) ([]string, map[string]api.SecretKeyInfo) { +// +// hydratedHarnessConfigPath, when non-empty, points to a hub-hydrated harness- +// config directory that supplements the on-disk search. This allows env-gather +// to see auth metadata from hub-managed harness-configs that haven't been +// downloaded to the standard on-disk locations yet. +func (s *Server) extractRequiredEnvKeys(req CreateAgentRequest, hydratedHarnessConfigPath ...string) ([]string, map[string]api.SecretKeyInfo) { required := make(map[string]struct{}) var settings *config.VersionedSettings @@ -1946,6 +1973,18 @@ func (s *Server) extractRequiredEnvKeys(req CreateAgentRequest) ([]string, map[s } } + // Fall back to hydrated hub-managed harness-config when on-disk + // search didn't find the config (or didn't populate auth metadata). + if harnessType == "" && len(hydratedHarnessConfigPath) > 0 && hydratedHarnessConfigPath[0] != "" { + if hcDir, err := config.LoadHarnessConfigDir(hydratedHarnessConfigPath[0]); err == nil && hcDir != nil { + harnessType = hcDir.Config.Harness + authType = hcDir.Config.AuthSelectedType + if hcDir.Config.Auth != nil { + authMeta = hcDir.Config.Auth + } + } + } + // Settings harness_configs entry can provide/override if settings != nil { if hcfg, ok := settings.HarnessConfigs[harnessConfigName]; ok {