From 931c9591cd76239aa461a309070455e66775c4d6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 21 Sep 2026 15:03:29 +0000 Subject: [PATCH] Add Notarius configuration profile support --- docs/roadmap/implementation.md | 9 ++- internal/adapters/notarius/fake.go | 4 ++ internal/adapters/notarius/runner.go | 1 + internal/adapters/notarius/subprocess.go | 3 + internal/adapters/notarius/subprocess_test.go | 58 ++++++++++++++++++- internal/config/config.go | 1 + internal/config/credential_projection.go | 4 +- internal/config/load_validate_test.go | 19 ++++++ internal/config/pipeline_configkit_test.go | 40 +++++++++++++ 9 files changed, 135 insertions(+), 4 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f93cf30..d97cefb 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implementation Plan: Notarius Configuration Profile Selection -Status: Ready for implementation; all stages pending. +Status: Implementation in progress; Stage 1 complete. ## Scope and Working Instructions @@ -56,7 +56,12 @@ the feature request: ## Stage 1: Add Configuration and Adapter Support -Status: Pending. +Status: Complete. + +Completion marker (2026-09-21): Added composed optional configuration and safe +inspection support, adapter request/argument forwarding, copy auditing, and +focused configuration and subprocess coverage. Verified with +`go test ./internal/config ./internal/adapters/notarius`. Preparation: diff --git a/internal/adapters/notarius/fake.go b/internal/adapters/notarius/fake.go index 756d5b0..76712c1 100644 --- a/internal/adapters/notarius/fake.go +++ b/internal/adapters/notarius/fake.go @@ -15,6 +15,10 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error) return RunResult{}, err } copyRequest := req + if req.ConfigProfile != nil { + profile := *req.ConfigProfile + copyRequest.ConfigProfile = &profile + } copyRequest.References = append([]ReferenceBinding(nil), req.References...) f.Requests = append(f.Requests, copyRequest) if f.Err != nil { diff --git a/internal/adapters/notarius/runner.go b/internal/adapters/notarius/runner.go index 0d1785d..24d968f 100644 --- a/internal/adapters/notarius/runner.go +++ b/internal/adapters/notarius/runner.go @@ -27,6 +27,7 @@ type RunRequest struct { Binary string // LLMAPIKey is an optional already-resolved credential reference. LLMAPIKey *configkit.Secret + ConfigProfile *string ConfigPath string PipelineID string InputPath string diff --git a/internal/adapters/notarius/subprocess.go b/internal/adapters/notarius/subprocess.go index f7c0892..b271526 100644 --- a/internal/adapters/notarius/subprocess.go +++ b/internal/adapters/notarius/subprocess.go @@ -82,6 +82,9 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult, for _, reference := range references { args = append(args, "--reference", reference.Selector+"="+reference.Path) } + if req.ConfigProfile != nil { + args = append(args, "--config-profile", *req.ConfigProfile) + } args = append(args, "--json") processResult, err := r.run(ctx, subprocess.RunRequest{ Executable: req.Binary, diff --git a/internal/adapters/notarius/subprocess_test.go b/internal/adapters/notarius/subprocess_test.go index d23349f..038eb6d 100644 --- a/internal/adapters/notarius/subprocess_test.go +++ b/internal/adapters/notarius/subprocess_test.go @@ -118,6 +118,53 @@ func TestSubprocessRunnerMapsCacheControlsToArguments(t *testing.T) { } } +func TestSubprocessRunnerMapsConfigProfileToArgumentPair(t *testing.T) { + tests := []struct { + name string + supplied bool + value string + }{ + {name: "omitted"}, + {name: "empty", supplied: true}, + {name: "default", supplied: true, value: "default"}, + {name: "named", supplied: true, value: "inexpensive"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := validRunRequest(t) + if test.supplied { + req.ConfigProfile = &test.value + } + var captured sharedsubprocess.RunRequest + runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { + captured = processReq + writeValidBundleAndReceipt(t, req, false) + return sharedsubprocess.RunResult{ExitCode: 0}, nil + }} + + if _, err := runner.Run(context.Background(), req); err != nil { + t.Fatalf("Run() error = %v", err) + } + flagIndex := -1 + for index, arg := range captured.Args { + if arg == "--config-profile" { + flagIndex = index + break + } + } + if !test.supplied { + if flagIndex >= 0 { + t.Fatalf("subprocess args unexpectedly contain config profile: %#v", captured.Args) + } + return + } + if flagIndex < 0 || flagIndex+2 >= len(captured.Args) || captured.Args[flagIndex+1] != test.value || captured.Args[flagIndex+2] != "--json" { + t.Fatalf("config profile argument boundary = %#v, want flag, exact value, then --json", captured.Args) + } + }) + } +} + func TestSubprocessRunnerRejectsInvalidChunkCacheBeforeLaunch(t *testing.T) { for _, mode := range []string{"AUTO", "invalid", " auto "} { t.Run(mode, func(t *testing.T) { @@ -388,6 +435,10 @@ func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T for _, test := range tests { t.Run(test.name, func(t *testing.T) { req := validRunRequest(t) + if test.name == "nonzero" { + profile := "missing-profile" + req.ConfigProfile = &profile + } req.Binary = writeShellScript(t, "#!/bin/sh\n"+test.scriptBody) req.Timeout = test.timeout ctx := context.Background() @@ -684,7 +735,8 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin } func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) { - req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}} + profile := "testing" + req := RunRequest{PipelineID: "pipeline", ConfigProfile: &profile, References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}} want := RunResult{BundleRoot: "/bundle"} fake := &FakeRunner{Result: want} got, err := fake.Run(context.Background(), req) @@ -695,6 +747,10 @@ func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) { if fake.Requests[0].References[0].Path != "/references/party.json" { t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References) } + profile = "changed" + if *fake.Requests[0].ConfigProfile != "testing" { + t.Fatalf("fake retained aliased config profile: %q", *fake.Requests[0].ConfigProfile) + } wantErr := errors.New("configured failure") fake.Err = wantErr diff --git a/internal/config/config.go b/internal/config/config.go index cb0f8e1..3762c4f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -310,6 +310,7 @@ type NotariusConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` Binary string `yaml:"binary" json:"binary"` LLMAPIKey *configkit.Secret `yaml:"llm_api_key,omitempty" json:"llm_api_key,omitempty"` + ConfigProfile *string `yaml:"config_profile,omitempty" json:"config_profile,omitempty"` Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` ChunkCache string `yaml:"chunk_cache,omitempty" json:"chunk_cache,omitempty"` ConfigPath string `yaml:"config_path" json:"config_path"` diff --git a/internal/config/credential_projection.go b/internal/config/credential_projection.go index 3c27b04..9d5bff5 100644 --- a/internal/config/credential_projection.go +++ b/internal/config/credential_projection.go @@ -56,6 +56,7 @@ func (cfg NotariusConfig) MarshalYAML() (any, error) { Enabled bool `yaml:"enabled"` Binary string `yaml:"binary"` LLMAPIKey *string `yaml:"llm_api_key,omitempty"` + ConfigProfile *string `yaml:"config_profile,omitempty"` Resume bool `yaml:"resume,omitempty"` ChunkCache string `yaml:"chunk_cache,omitempty"` ConfigPath string `yaml:"config_path"` @@ -67,7 +68,8 @@ func (cfg NotariusConfig) MarshalYAML() (any, error) { } return safeNotarius{ Enabled: cfg.Enabled, Binary: cfg.Binary, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey), - Resume: cfg.Resume, ChunkCache: cfg.ChunkCache, + ConfigProfile: cfg.ConfigProfile, + Resume: cfg.Resume, ChunkCache: cfg.ChunkCache, ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout, WorkingDirectory: cfg.WorkingDirectory, References: cfg.References, Outputs: cfg.Outputs, }, nil diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 41b24d1..d82549a 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -103,6 +103,7 @@ storage: notarius: enabled: false llm_api_key: NARRATIO_TEST_NOTARIUS_KEY + config_profile: "" ` pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) @@ -140,6 +141,24 @@ notarius: } } } + if !strings.Contains(string(firstYAML), "config_profile: \"\"\n") { + t.Fatalf("effective YAML omitted explicit empty Notarius config profile: %q", firstYAML) + } + var roundTrip struct { + Notarius *struct { + LLMAPIKey *string `yaml:"llm_api_key"` + ConfigProfile *string `yaml:"config_profile"` + } `yaml:"notarius"` + } + if err := yaml.Unmarshal(firstYAML, &roundTrip); err != nil { + t.Fatalf("round-trip effective YAML: %v", err) + } + if roundTrip.Notarius == nil || roundTrip.Notarius.ConfigProfile == nil || *roundTrip.Notarius.ConfigProfile != "" { + t.Fatalf("round-trip config profile = %#v, want explicit empty", roundTrip.Notarius) + } + if roundTrip.Notarius.LLMAPIKey == nil || *roundTrip.Notarius.LLMAPIKey != "NARRATIO_TEST_NOTARIUS_KEY" { + t.Fatalf("round-trip credential reference = %#v, want safe public name", roundTrip.Notarius.LLMAPIKey) + } t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary) t.Setenv("NARRATIO_TEST_SECRET_KEY", secondCanary) diff --git a/internal/config/pipeline_configkit_test.go b/internal/config/pipeline_configkit_test.go index cd492d1..d84b24a 100644 --- a/internal/config/pipeline_configkit_test.go +++ b/internal/config/pipeline_configkit_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "reflect" "strings" "testing" ) @@ -161,6 +162,45 @@ func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) { } } +func TestNotariusConfigProfileComposition(t *testing.T) { + tests := []struct { + name string + baseValue string + overlayName string + overlay string + want *string + }{ + {name: "omitted"}, + {name: "explicit empty", baseValue: `""`, want: stringPointer("")}, + {name: "explicit default", baseValue: "default", want: stringPointer("default")}, + {name: "base named selection", baseValue: "production", want: stringPointer("production")}, + {name: "profile replaces named selection", baseValue: "production", overlayName: "testing", overlay: "notarius:\n config_profile: inexpensive\n", want: stringPointer("inexpensive")}, + {name: "profile clears named selection", baseValue: "production", overlayName: "testing", overlay: "notarius:\n config_profile: null\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + rootYAML := "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n" + if test.baseValue != "" { + rootYAML += " config_profile: " + test.baseValue + "\n" + } + root := writePipelineFixture(t, dir, "pipeline.yml", rootYAML) + options := PipelineLoadOptions{} + if test.overlayName != "" { + writePipelineFixture(t, dir, "profiles/"+test.overlayName+".yml", test.overlay) + options.Profile = &test.overlayName + } + cfg, err := LoadPipelineWithOptions(root, options) + if err != nil { + t.Fatal(err) + } + if cfg.Notarius == nil || !reflect.DeepEqual(cfg.Notarius.ConfigProfile, test.want) { + t.Fatalf("config profile = %#v, want %#v", cfg.Notarius.ConfigProfile, test.want) + } + }) + } +} + func TestNotariusCredentialRootProvenance(t *testing.T) { const envName = "NARRATIO_NOTARIUS_ROOT_KEY" t.Setenv(envName, "synthetic-notarius-root-key")