diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5a7b62f..9ee51b4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -216,7 +216,10 @@ downstream model-profile concepts. ## Stage 2: Add shared resolved-credential forwarding -Status: Not started. +Status: Complete (2026-09-25). + +Verification: `go test ./internal/adapters/subprocess`, +`go test -race ./internal/adapters/subprocess`, and `git diff --check` passed. Preparation: `internal/adapters/subprocess/run.go`, its environment/redaction helpers and tests; `internal/adapters/notarius/subprocess.go`; diff --git a/internal/adapters/subprocess/credentials.go b/internal/adapters/subprocess/credentials.go new file mode 100644 index 0000000..73288b2 --- /dev/null +++ b/internal/adapters/subprocess/credentials.go @@ -0,0 +1,29 @@ +package subprocess + +import ( + "fmt" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +// CredentialEnvironment returns captured secret values and names for a child +// invocation. References must already have been resolved by ConfigKit. +func CredentialEnvironment(credentials []configkit.Secret) (map[string]string, []string, error) { + if len(credentials) == 0 { + return nil, nil, nil + } + overrides := make(map[string]string, len(credentials)) + names := make([]string, 0, len(credentials)) + for _, credential := range credentials { + name := credential.EnvName() + value := credential.Value() + if !credential.IsResolved() || value == "" { + return nil, nil, fmt.Errorf("credential %q must be resolved and nonempty", name) + } + if _, exists := overrides[name]; !exists { + names = append(names, name) + overrides[name] = value + } + } + return overrides, names, nil +} diff --git a/internal/adapters/subprocess/credentials_test.go b/internal/adapters/subprocess/credentials_test.go new file mode 100644 index 0000000..9fbf52a --- /dev/null +++ b/internal/adapters/subprocess/credentials_test.go @@ -0,0 +1,51 @@ +package subprocess + +import ( + "reflect" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestCredentialEnvironmentUsesCapturedReferences(t *testing.T) { + t.Setenv("CUSTOM_ALPHA_KEY", "alpha-value") + t.Setenv("SECOND_PROVIDER_TOKEN", "second-value") + t.Setenv("UNSELECTED_SECRET", "unselected-value") + type configuration struct { + Credentials []configkit.Secret `yaml:"credentials"` + } + loaded, err := configkit.Load[configuration](configkit.Bytes("credentials.yml", []byte("credentials: [CUSTOM_ALPHA_KEY, SECOND_PROVIDER_TOKEN, CUSTOM_ALPHA_KEY]\n"))) + if err != nil { + t.Fatal(err) + } + t.Setenv("CUSTOM_ALPHA_KEY", "later-value") + overrides, names, err := CredentialEnvironment(loaded.Config.Credentials) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(overrides, map[string]string{"CUSTOM_ALPHA_KEY": "alpha-value", "SECOND_PROVIDER_TOKEN": "second-value"}) || + !reflect.DeepEqual(names, []string{"CUSTOM_ALPHA_KEY", "SECOND_PROVIDER_TOKEN"}) { + t.Fatalf("credential selection = %#v, %#v", overrides, names) + } + overrides["CUSTOM_ALPHA_KEY"] = "modified" + names[0] = "modified" + again, againNames, err := CredentialEnvironment(loaded.Config.Credentials) + if err != nil || again["CUSTOM_ALPHA_KEY"] != "alpha-value" || againNames[0] != "CUSTOM_ALPHA_KEY" { + t.Fatalf("credential conversion retained mutable result: %#v, %#v, %v", again, againNames, err) + } +} + +func TestCredentialEnvironmentHandlesEmptyAndUnresolvedReferences(t *testing.T) { + for _, credentials := range [][]configkit.Secret{nil, {}} { + overrides, names, err := CredentialEnvironment(credentials) + if err != nil || len(overrides) != 0 || len(names) != 0 { + t.Fatalf("empty credentials = %#v, %#v, %v", overrides, names, err) + } + } + for _, credentials := range [][]configkit.Secret{{configkit.SecretEnv("UNRESOLVED_KEY")}, {{}}} { + overrides, names, err := CredentialEnvironment(credentials) + if err == nil || overrides != nil || names != nil { + t.Fatalf("unresolved credentials = %#v, %#v, %v", overrides, names, err) + } + } +}