diff --git a/docs/cli.md b/docs/cli.md index 828a6c5..9b54f44 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -98,7 +98,7 @@ a session nor create a workspace, manifest, run, lock, adapter, or remote connection. Like every pipeline-loading command, they bootstrap the selected or conventional secrets directory before resolving configuration; `config show` never includes bootstrap reports or secret values in its YAML output. -Typed credential references, including `notarius.llm_api_key`, must therefore +Typed credential references, including `notarius.credentials[]`, must therefore resolve even though these inspection commands never construct an adapter. Campaign selection is optional for a pipeline without party-driven artifact @@ -178,7 +178,7 @@ Behavior: - runs `render` before `extract`; an omitted or disabled Notarius configuration records an explicit `notarius_disabled` self-skip; - resolves every selected typed credential during the initial configuration - load, including a Notarius credential when extract is disabled, outside the + load, including Notarius credentials when extract is disabled, outside the selected range, or later skipped as resumable; - skips already-succeeded stages unless `--force` is set or a stage-specific resume check finds its durable result obsolete; diff --git a/docs/config.md b/docs/config.md index d1242ce..8dd8b9f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -230,7 +230,7 @@ inputs: - Do not place raw secrets in YAML. - Credential fields contain environment-variable names, represented internally as ConfigKit typed secret references (for example - `pipeline.notarius.llm_api_key: NOTARIUS_LLM_API_KEY`). A configured reference + `pipeline.notarius.credentials: [NOTARIUS_LLM_API_KEY]`). A configured reference must resolve to a nonempty environment value during load. - Every surviving reference is resolved during ordinary pipeline loading, regardless of whether its integration is disabled, outside the selected @@ -362,7 +362,7 @@ Rules: | `pipeline.trim.seriatim.report` | bool | No | `false` | | `pipeline.notarius.enabled` | bool | No | `false` | | `pipeline.notarius.binary` | string | No | `notarius` | -| `pipeline.notarius.llm_api_key` | secret reference | No | unset; exact environment-variable name forwarded to Notarius | +| `pipeline.notarius.credentials[]` | list[secret reference] | No | empty; exact environment-variable names forwarded to Notarius | | `pipeline.notarius.config_profile` | string | No | unset; exact Notarius ConfigKit profile selector | | `pipeline.notarius.config_path` | string | Conditional | required when enabled; relative paths resolve from the pipeline file directory | | `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled | @@ -406,35 +406,36 @@ does not restrict profile names, resolve profile files, or rewrite the value. This selection is independent of Narratio's own `--overlay` selection. A Narratio overlay may explicitly select a differently named Notarius overlay, but Narratio never forwards its own overlay name automatically. It is also -independent of `pipeline.notarius.llm_api_key`: an overlay may change either or +independent of `pipeline.notarius.credentials`: an overlay may change either or both fields according to the deployed Notarius configuration. See the [Notarius integration contract](./integrations/notarius.md) for invocation and failure behavior. -### Notarius Credential +### Notarius Credentials -`pipeline.notarius.llm_api_key` optionally names the one environment variable +`pipeline.notarius.credentials` optionally lists environment-variable names to forward to the Notarius child process: ```yaml notarius: - llm_api_key: OPENROUTER_API_KEY + credentials: [OPENROUTER_API_KEY, ANTHROPIC_API_KEY] ``` -The field contains the variable name, not a credential value. That name must -match `api_key_env` in the Notarius/PromptKit profile selected by the deployed -Notarius pipeline; Narratio does not infer or remap provider names. Omission or -`null` forwards nothing. A non-null selected reference must resolve at pipeline +The list contains variable names, not credential values. Those names must +match the credential references in the selected Notarius/PromptKit configuration; +Narratio does not infer or remap provider names. Omission, `null`, or `[]` +forwards nothing. Each selected reference must resolve at pipeline load time through the process environment or existing secrets-directory bootstrap, even when Notarius is disabled, extract is outside the requested range, or a completed extraction can be reused. The Notarius child receives only Narratio's minimal execution environment plus -this exact selected name and loaded value. Other credentials are not forwarded. -Changing the value, name, presence, or absence does not invalidate extract's -semantic resume evidence. The public name remains part of safe effective -inspection, so renaming it may appear in `config diff` and change the effective -configuration digest; rotating only its value does neither. +these exact selected names and loaded values. Other credentials are not forwarded. +Changing a value, name, presence, or absence does not invalidate extract's +semantic resume evidence. The public names remain part of safe effective +inspection, so renaming them may appear in `config diff` and change the effective +configuration digest; rotating only their values does neither. An overlay +replaces the whole list, including with `[]` to clear it. See the [Notarius integration contract](./integrations/notarius.md) for the delivery and redaction boundary. diff --git a/docs/integrations/notarius.md b/docs/integrations/notarius.md index a17bd94..7ddc6f3 100644 --- a/docs/integrations/notarius.md +++ b/docs/integrations/notarius.md @@ -105,15 +105,16 @@ causes a cold miss. Chunk refresh bypasses lookup and publishes the accepted replacement for the active source; it does not delete unrelated plans or checkpoint state. -When `pipeline.notarius.llm_api_key` is absent or null, Narratio adds no -credential to that environment. When configured, the field names an environment -variable whose already-loaded nonempty value is forwarded under that exact -name. Operators must use the same name as the `api_key_env` in the selected -Notarius/PromptKit profile. Narratio does not inspect the Notarius configuration -to infer a provider or destination name, and it does not forward unrelated -credentials from its parent environment or secrets directory. +When `pipeline.notarius.credentials` is absent, null, or empty, Narratio adds no +credentials to that environment. When configured, each list entry names an +environment variable whose already-loaded nonempty value is forwarded under +that exact name. Operators must use the names referenced by the selected +Notarius/PromptKit providers. Narratio does not inspect the Notarius configuration +to infer providers or destination names, and it does not forward unrelated +credentials from its parent environment or secrets directory. A Narratio +overlay replaces the whole credential list; `[]` clears it. -The selected name is registered with Narratio's shared streaming redactor. +Every selected name is registered with Narratio's shared streaming redactor. Credential bytes written by the child to captured stdout, captured stderr, or stderr-derived failure diagnostics are replaced with ``, including custom names not known to the built-in sensitive-name list. Narratio does not diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9ee51b4..de0b981 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -241,7 +241,10 @@ a resolver, provider policy, or ambient-secret inheritance. ## Stage 3: Migrate Notarius to credential lists -Status: Not started. +Status: Complete (2026-09-25). + +Verification: `go test ./internal/config ./internal/adapters/notarius ./internal/stage ./internal/app`, +`go test -race ./internal/adapters/notarius`, and `git diff --check` passed. Preparation: `internal/config/config.go`, `credential_projection.go`, loading/ inspection tests; `internal/adapters/notarius/runner.go`, `subprocess.go`; diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3740431..7662213 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -312,7 +312,7 @@ and [Transcribe internals](./internal/stage-transcribe.md). Symptom: - configuration loading reports `secret.unset`, `secret.empty`, or an invalid - name for `pipeline.notarius.llm_api_key`, including during inspection or a + name in `pipeline.notarius.credentials`, including during inspection or a run that would not execute extraction. Likely causes: diff --git a/examples/pipeline.full.annotated.yml b/examples/pipeline.full.annotated.yml index ce88a52..a906753 100644 --- a/examples/pipeline.full.annotated.yml +++ b/examples/pipeline.full.annotated.yml @@ -131,9 +131,9 @@ notarius: # Optional structured extraction after render and before analyze. enabled: true binary: notarius - # Optional environment-variable reference. Match this name to api_key_env in - # the selected Notarius/PromptKit profile; never put the credential here. - llm_api_key: NOTARIUS_LLM_API_KEY + # Optional environment-variable references. Match these names to the + # selected Notarius/PromptKit providers; never put credential values here. + credentials: [NOTARIUS_LLM_API_KEY] # Optionally select a Notarius ConfigKit overlay when extraction runs. This # is independent of Narratio's selected overlay and may use a different name. # See docs/config.md and docs/integrations/notarius.md for the contracts. diff --git a/internal/adapters/notarius/fake.go b/internal/adapters/notarius/fake.go index 76712c1..49ea2e5 100644 --- a/internal/adapters/notarius/fake.go +++ b/internal/adapters/notarius/fake.go @@ -1,6 +1,10 @@ package notarius -import "context" +import ( + "context" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) // FakeRunner is a configurable in-memory runner for stage tests. type FakeRunner struct { @@ -15,6 +19,7 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error) return RunResult{}, err } copyRequest := req + copyRequest.Credentials = append([]configkit.Secret(nil), req.Credentials...) if req.ConfigProfile != nil { profile := *req.ConfigProfile copyRequest.ConfigProfile = &profile diff --git a/internal/adapters/notarius/runner.go b/internal/adapters/notarius/runner.go index 24d968f..52fb727 100644 --- a/internal/adapters/notarius/runner.go +++ b/internal/adapters/notarius/runner.go @@ -25,8 +25,8 @@ type ReferenceBinding struct { // RunRequest contains the resolved inputs and diagnostic destinations for one invocation. type RunRequest struct { Binary string - // LLMAPIKey is an optional already-resolved credential reference. - LLMAPIKey *configkit.Secret + // Credentials are already-resolved environment references selected for Notarius. + Credentials []configkit.Secret ConfigProfile *string ConfigPath string PipelineID string diff --git a/internal/adapters/notarius/subprocess.go b/internal/adapters/notarius/subprocess.go index b271526..231ac6f 100644 --- a/internal/adapters/notarius/subprocess.go +++ b/internal/adapters/notarius/subprocess.go @@ -52,19 +52,9 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult, if err != nil { return RunResult{}, err } - var envOverrides map[string]string - var sensitiveEnvNames []string - if req.LLMAPIKey != nil { - if !req.LLMAPIKey.IsResolved() { - return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty") - } - name := req.LLMAPIKey.EnvName() - value := req.LLMAPIKey.Value() - if value == "" { - return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty") - } - envOverrides = map[string]string{name: value} - sensitiveEnvNames = []string{name} + envOverrides, sensitiveEnvNames, err := subprocess.CredentialEnvironment(req.Credentials) + if err != nil { + return RunResult{}, fmt.Errorf("notarius credentials: %w", err) } args := []string{ diff --git a/internal/adapters/notarius/subprocess_test.go b/internal/adapters/notarius/subprocess_test.go index 038eb6d..8c3cb17 100644 --- a/internal/adapters/notarius/subprocess_test.go +++ b/internal/adapters/notarius/subprocess_test.go @@ -188,12 +188,16 @@ func TestSubprocessRunnerRejectsInvalidChunkCacheBeforeLaunch(t *testing.T) { func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) { const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY" + const otherName = "NARRATIO_NOTARIUS_CUSTOM_OTHER_KEY" const loadedValue = "loaded-notarius-credential" + const otherValue = "loaded-notarius-other-credential" credential := loadTestCredential(t, envName, loadedValue) + otherCredential := loadTestCredential(t, otherName, otherValue) t.Setenv(envName, "changed-parent-credential") + t.Setenv(otherName, "changed-parent-other-credential") req := validRunRequest(t) - req.LLMAPIKey = credential + req.Credentials = []configkit.Secret{*credential, *otherCredential} var captured sharedsubprocess.RunRequest runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { captured = processReq @@ -203,10 +207,10 @@ func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t * if _, err := runner.Run(context.Background(), req); err != nil { t.Fatalf("Run() error = %v", err) } - if len(captured.EnvOverrides) != 1 || captured.EnvOverrides[envName] != loadedValue { - t.Fatal("subprocess did not receive exactly the loaded credential override") + if len(captured.EnvOverrides) != 2 || captured.EnvOverrides[envName] != loadedValue || captured.EnvOverrides[otherName] != otherValue { + t.Fatal("subprocess did not receive exactly the loaded credential overrides") } - if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName}) { + if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName, otherName}) { t.Fatalf("sensitive environment names = %#v, want configured reference name", captured.SensitiveEnvNames) } wantArgs := []string{ @@ -228,7 +232,7 @@ func TestSubprocessRunnerRejectsInvalidProgrammaticCredentialBeforeLaunch(t *tes } { t.Run(test.name, func(t *testing.T) { req := validRunRequest(t) - req.LLMAPIKey = &test.reference + req.Credentials = []configkit.Secret{test.reference} started := false runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { started = true @@ -342,10 +346,12 @@ func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) { func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) { const envName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_KEY" + const otherName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_OTHER_KEY" const canary = "notarius-captured-stream-canary" + const otherCanary = "notarius-captured-stream-other-canary" const unrelated = "NARRATIO_NOTARIUS_UNRELATED_PARENT_KEY" req := validRunRequest(t) - req.LLMAPIKey = loadTestCredential(t, envName, canary) + req.Credentials = []configkit.Secret{*loadTestCredential(t, envName, canary), *loadTestCredential(t, otherName, otherCanary)} writeValidBundleAndReceipt(t, req, false) receiptFixture := req.ReceiptPath + ".fixture" data, err := os.ReadFile(req.ReceiptPath) @@ -356,7 +362,7 @@ func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) if err := json.Unmarshal(data, &receipt); err != nil { t.Fatalf("decode receipt fixture: %v", err) } - receipt["credential_echo"] = canary + receipt["credential_echo"] = canary + " " + otherCanary writeJSONFile(t, receiptFixture, receipt) if err := os.Remove(req.ReceiptPath); err != nil { t.Fatalf("Remove(receipt) error = %v", err) @@ -373,11 +379,16 @@ if [ "$`+envName+`" != '`+canary+`' ]; then printf 'selected credential mismatch\n' >&2 exit 8 fi +if [ "$`+otherName+`" != '`+otherCanary+`' ]; then + printf 'other selected credential mismatch\n' >&2 + exit 10 +fi if [ -n "$`+unrelated+`" ]; then printf 'unrelated credential inherited\n' >&2 exit 9 fi printf '%s\n' "$`+envName+`" >&2 +printf '%s\n' "$`+otherName+`" >&2 cat "$NOTARIUS_RECEIPT_FIXTURE" `) req.Binary = script @@ -392,32 +403,39 @@ cat "$NOTARIUS_RECEIPT_FIXTURE" assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n") assertTextFile(t, filepath.Join(captureDir, "environment"), "") assertRedactedCredentialFile(t, req.LogPath, canary) + assertRedactedCredentialFile(t, req.LogPath, otherCanary) receiptBytes, err := os.ReadFile(req.ReceiptPath) if err != nil { t.Fatalf("ReadFile(receipt) error = %v", err) } assertRedactedCredentialBytes(t, receiptBytes, canary) + assertRedactedCredentialBytes(t, receiptBytes, otherCanary) } func TestSubprocessRunnerRedactsCredentialOnProcessFailure(t *testing.T) { const envName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_KEY" + const otherName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_OTHER_KEY" const canary = "notarius-failure-stream-canary" + const otherCanary = "notarius-failure-stream-other-canary" req := validRunRequest(t) - req.LLMAPIKey = loadTestCredential(t, envName, canary) + req.Credentials = []configkit.Secret{*loadTestCredential(t, envName, canary), *loadTestCredential(t, otherName, otherCanary)} req.Binary = writeShellScript(t, `#!/bin/sh printf '%s\n' "$`+envName+`" printf '%s\n' "$`+envName+`" >&2 +printf '%s\n' "$`+otherName+`" >&2 exit 7 `) _, err := NewSubprocessRunner().Run(context.Background(), req) if err == nil { t.Fatal("Run() error = nil, want process failure") } - if strings.Contains(err.Error(), canary) || !strings.Contains(err.Error(), "") { + if strings.Contains(err.Error(), canary) || strings.Contains(err.Error(), otherCanary) || !strings.Contains(err.Error(), "") { t.Fatal("process failure did not return redacted diagnostics") } assertRedactedCredentialFile(t, req.ReceiptPath, canary) + assertRedactedCredentialFile(t, req.ReceiptPath, otherCanary) assertRedactedCredentialFile(t, req.LogPath, canary) + assertRedactedCredentialFile(t, req.LogPath, otherCanary) } func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) { diff --git a/internal/app/config_commands_test.go b/internal/app/config_commands_test.go index 54e3ddb..d7705ed 100644 --- a/internal/app/config_commands_test.go +++ b/internal/app/config_commands_test.go @@ -389,16 +389,16 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T `, 1) pipelineYAML += `notarius: enabled: false - llm_api_key: NARRATIO_INSPECT_NOTARIUS_ONE + credentials: [NARRATIO_INSPECT_NOTARIUS_ONE] ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatal(err) } writeInspectionOverlays(t, pipelinePath, "audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n", - "audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n llm_api_key: NARRATIO_INSPECT_NOTARIUS_TWO\n", + "audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n credentials: [NARRATIO_INSPECT_NOTARIUS_TWO]\n", ) - mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n") + mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n credentials: []\n") var validate bytes.Buffer if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &validate); err != nil { @@ -439,11 +439,11 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T !strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_TWO") { t.Fatalf("diff omitted safe credential references: %q", diff.String()) } - if !strings.Contains(sources.String(), "notarius.llm_api_key") { + if !strings.Contains(sources.String(), "notarius.credentials") { t.Fatalf("sources omitted Notarius credential ownership: %q", sources.String()) } - if strings.Contains(removed.String(), "llm_api_key") || strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") { - t.Fatalf("overlay null did not remove Notarius credential: %q", removed.String()) + if strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") { + t.Fatalf("empty overlay did not remove Notarius credential: %q", removed.String()) } } @@ -451,7 +451,7 @@ func TestConfigValidateRejectsUnavailableNotariusCredential(t *testing.T) { const envName = "NARRATIO_INSPECT_NOTARIUS_MISSING" restoreEnvAfterTest(t, envName) path := filepath.Join(t.TempDir(), "pipeline.yml") - mustWriteTestFile(t, path, "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n") + mustWriteTestFile(t, path, "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+envName+"]\n") err := ConfigValidate(context.Background(), []string{"--config", path}, io.Discard) if err == nil || !strings.Contains(err.Error(), "secret.unset") { t.Fatalf("ConfigValidate() error = %v, want unavailable credential failure", err) diff --git a/internal/app/extract_lifecycle_test.go b/internal/app/extract_lifecycle_test.go index c10df4a..c9d7ca6 100644 --- a/internal/app/extract_lifecycle_test.go +++ b/internal/app/extract_lifecycle_test.go @@ -665,16 +665,16 @@ func TestExtractLifecycleCredentialChangesRemainResumable(t *testing.T) { t.Run(test.name, func(t *testing.T) { cfg, env, runner := extractionLifecycleFixture(t, true) if test.before != nil { - cfg.Pipeline.Notarius.LLMAPIKey = test.before(t) + cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*test.before(t)} } analyzeRuns := 0 plan := extractionLifecyclePlan(t, &analyzeRuns) if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil { t.Fatalf("initial executeStages() error = %v", err) } - cfg.Pipeline.Notarius.LLMAPIKey = nil + cfg.Pipeline.Notarius.Credentials = nil if test.after != nil { - cfg.Pipeline.Notarius.LLMAPIKey = test.after(t) + cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*test.after(t)} } resumed, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) if err != nil { @@ -876,7 +876,7 @@ func TestPreparedPipelineAndManifestExcludeNotariusCredentialValue(t *testing.T) const name = "NARRATIO_LIFECYCLE_PERSISTED_KEY" const canary = "notarius-persisted-credential-canary" cfg, env, _ := extractionLifecycleFixture(t, true) - cfg.Pipeline.Notarius.LLMAPIKey = loadLifecycleNotariusCredential(t, name, canary) + cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*loadLifecycleNotariusCredential(t, name, canary)} if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil { t.Fatalf("executeStages() error = %v", err) } @@ -1181,12 +1181,12 @@ func loadLifecycleNotariusCredential(t *testing.T, name, value string) *configki t.Helper() t.Setenv(name, value) path := filepath.Join(t.TempDir(), "pipeline.yml") - if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+name+"\n"), 0o644); err != nil { + if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+name+"]\n"), 0o644); err != nil { t.Fatal(err) } pipeline, err := config.LoadPipeline(path) if err != nil { t.Fatal(err) } - return pipeline.Notarius.LLMAPIKey + return &pipeline.Notarius.Credentials[0] } diff --git a/internal/app/secrets_bootstrap_test.go b/internal/app/secrets_bootstrap_test.go index 909a9c9..416597f 100644 --- a/internal/app/secrets_bootstrap_test.go +++ b/internal/app/secrets_bootstrap_test.go @@ -138,7 +138,7 @@ func TestNotariusCredentialLoadsFromCommandSecretBootstrap(t *testing.T) { restoreEnvAfterTest(t, envName) root := t.TempDir() pipelinePath := filepath.Join(root, "pipeline.yml") - if err := os.WriteFile(pipelinePath, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n"), 0o644); err != nil { + if err := os.WriteFile(pipelinePath, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+envName+"]\n"), 0o644); err != nil { t.Fatal(err) } secretsDir := filepath.Join(root, "secrets") @@ -158,8 +158,8 @@ func TestNotariusCredentialLoadsFromCommandSecretBootstrap(t *testing.T) { if err != nil { t.Fatalf("load pipeline: %v", err) } - if pipeline.Notarius == nil || pipeline.Notarius.LLMAPIKey == nil || !pipeline.Notarius.LLMAPIKey.IsResolved() || - pipeline.Notarius.LLMAPIKey.EnvName() != envName || pipeline.Notarius.LLMAPIKey.Value() != test.wantValue { + if pipeline.Notarius == nil || len(pipeline.Notarius.Credentials) != 1 || !pipeline.Notarius.Credentials[0].IsResolved() || + pipeline.Notarius.Credentials[0].EnvName() != envName || pipeline.Notarius.Credentials[0].Value() != test.wantValue { t.Fatal("Notarius credential did not resolve from the selected command bootstrap source") } }) @@ -220,7 +220,7 @@ func TestExecuteConfigValidateReportsSkippedEmptyBeforeTypedLoadFailure(t *testi if err != nil { t.Fatal(err) } - pipelineYAML = append(pipelineYAML, []byte("notarius:\n enabled: false\n llm_api_key: "+name+"\n")...) + pipelineYAML = append(pipelineYAML, []byte("notarius:\n enabled: false\n credentials: ["+name+"]\n")...) if err := os.WriteFile(pipelinePath, pipelineYAML, 0o644); err != nil { t.Fatal(err) } diff --git a/internal/config/config.go b/internal/config/config.go index 3762c4f..3cb7b78 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -309,7 +309,7 @@ type ScriptoriumInputConfig struct { 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"` + Credentials []configkit.Secret `yaml:"credentials,omitempty" json:"credentials,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"` diff --git a/internal/config/credential_projection.go b/internal/config/credential_projection.go index 9d5bff5..3f889dd 100644 --- a/internal/config/credential_projection.go +++ b/internal/config/credential_projection.go @@ -50,12 +50,12 @@ func (cfg AuditaConfig) MarshalYAML() (any, error) { }, nil } -// MarshalYAML exposes only the public Notarius credential reference name. +// MarshalYAML exposes only public Notarius credential reference names. func (cfg NotariusConfig) MarshalYAML() (any, error) { type safeNotarius struct { Enabled bool `yaml:"enabled"` Binary string `yaml:"binary"` - LLMAPIKey *string `yaml:"llm_api_key,omitempty"` + Credentials []string `yaml:"credentials,omitempty"` ConfigProfile *string `yaml:"config_profile,omitempty"` Resume bool `yaml:"resume,omitempty"` ChunkCache string `yaml:"chunk_cache,omitempty"` @@ -67,7 +67,7 @@ func (cfg NotariusConfig) MarshalYAML() (any, error) { Outputs map[string]NotariusOutputConfig `yaml:"outputs"` } return safeNotarius{ - Enabled: cfg.Enabled, Binary: cfg.Binary, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey), + Enabled: cfg.Enabled, Binary: cfg.Binary, Credentials: secretReferenceNames(cfg.Credentials), ConfigProfile: cfg.ConfigProfile, Resume: cfg.Resume, ChunkCache: cfg.ChunkCache, ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout, @@ -75,6 +75,17 @@ func (cfg NotariusConfig) MarshalYAML() (any, error) { }, nil } +func secretReferenceNames(secrets []configkit.Secret) []string { + if secrets == nil { + return nil + } + names := make([]string, len(secrets)) + for index, secret := range secrets { + names[index] = secret.EnvName() + } + return names +} + func secretReferenceName(secret *configkit.Secret) *string { if secret == nil { return nil diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index e03e23c..5aa1465 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -102,7 +102,7 @@ storage: secret_access_key: NARRATIO_TEST_SECRET_KEY notarius: enabled: false - llm_api_key: NARRATIO_TEST_NOTARIUS_KEY + credentials: [NARRATIO_TEST_NOTARIUS_KEY] config_profile: "" ` pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) @@ -146,8 +146,8 @@ notarius: } var roundTrip struct { Notarius *struct { - LLMAPIKey *string `yaml:"llm_api_key"` - ConfigProfile *string `yaml:"config_profile"` + Credentials []string `yaml:"credentials"` + ConfigProfile *string `yaml:"config_profile"` } `yaml:"notarius"` } if err := yaml.Unmarshal(firstYAML, &roundTrip); err != nil { @@ -156,8 +156,8 @@ notarius: 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) + if len(roundTrip.Notarius.Credentials) != 1 || roundTrip.Notarius.Credentials[0] != "NARRATIO_TEST_NOTARIUS_KEY" { + t.Fatalf("round-trip credential reference = %#v, want safe public name", roundTrip.Notarius.Credentials) } t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary) diff --git a/internal/config/notarius_test.go b/internal/config/notarius_test.go index 6ec8f26..d0abd15 100644 --- a/internal/config/notarius_test.go +++ b/internal/config/notarius_test.go @@ -58,8 +58,10 @@ notarius: func TestNotariusCredentialReferenceLoading(t *testing.T) { const envName = "NARRATIO_TEST_NOTARIUS_KEY" + const secondName = "NARRATIO_TEST_NOTARIUS_SECOND_KEY" const canary = "notarius-credential-canary" t.Setenv(envName, canary) + t.Setenv(secondName, "second-canary") tests := []struct { name string @@ -67,8 +69,9 @@ func TestNotariusCredentialReferenceLoading(t *testing.T) { wantNil bool }{ {name: "omitted", wantNil: true}, - {name: "null", credential: " llm_api_key: null\n", wantNil: true}, - {name: "custom reference", credential: " llm_api_key: " + envName + "\n"}, + {name: "null", credential: " credentials: null\n", wantNil: true}, + {name: "empty", credential: " credentials: []\n"}, + {name: "custom references", credential: " credentials: [" + envName + ", " + secondName + "]\n"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -81,15 +84,19 @@ func TestNotariusCredentialReferenceLoading(t *testing.T) { if err != nil { t.Fatal(err) } - if test.wantNil { - if cfg.Notarius.LLMAPIKey != nil { - t.Fatal("credential reference should be nil") - } + if test.wantNil && cfg.Notarius.Credentials != nil { + t.Fatal("credential list should be nil") + } + if test.name == "empty" && len(cfg.Notarius.Credentials) == 0 { return } - if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != envName || - !cfg.Notarius.LLMAPIKey.IsResolved() || cfg.Notarius.LLMAPIKey.Value() != canary { - t.Fatal("credential reference was not loaded and resolved") + if test.wantNil { + return + } + if len(cfg.Notarius.Credentials) != 2 || + cfg.Notarius.Credentials[0].EnvName() != envName || cfg.Notarius.Credentials[0].Value() != canary || + cfg.Notarius.Credentials[1].EnvName() != secondName || cfg.Notarius.Credentials[1].Value() != "second-canary" { + t.Fatal("credential references were not loaded and resolved") } }) } @@ -110,7 +117,7 @@ func TestNotariusCredentialReferenceFailures(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "pipeline.yml") - pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n llm_api_key: " + test.reference + "\n" + pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n credentials: [" + test.reference + "]\n" if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil { t.Fatal(err) } @@ -122,6 +129,17 @@ func TestNotariusCredentialReferenceFailures(t *testing.T) { } } +func TestNotariusRejectsRetiredCredentialField(t *testing.T) { + path := filepath.Join(t.TempDir(), "pipeline.yml") + pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n llm_api_key: NARRATIO_TEST_NOTARIUS_KEY\n" + if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadPipeline(path); err == nil || !strings.Contains(err.Error(), "decode.unknown_field") { + t.Fatalf("LoadPipeline() error = %v, want unknown field", err) + } +} + func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) { dir := t.TempDir() pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml") diff --git a/internal/config/pipeline_configkit_test.go b/internal/config/pipeline_configkit_test.go index ea8b4e7..f57a8f0 100644 --- a/internal/config/pipeline_configkit_test.go +++ b/internal/config/pipeline_configkit_test.go @@ -147,7 +147,7 @@ func TestPipelineOverlayDoesNotRequireUnusedBaseCredentials(t *testing.T) { } } -func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) { +func TestNotariusCredentialOverlayReplacementAndProvenance(t *testing.T) { const baseKey = "NARRATIO_NOTARIUS_UNUSED_BASE_KEY" const profileKey = "NARRATIO_NOTARIUS_PROFILE_KEY" if err := os.Unsetenv(baseKey); err != nil { @@ -155,24 +155,24 @@ func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) { } t.Setenv(profileKey, "synthetic-notarius-profile-key") - for _, credential := range []string{profileKey, "null"} { + for _, credential := range []string{"[" + profileKey + "]", "null", "[]"} { t.Run(credential, func(t *testing.T) { dir := t.TempDir() - root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+baseKey+"\n") - overlay := writePipelineFixture(t, dir, "overlays/testing.yml", "notarius:\n llm_api_key: "+credential+"\n") + root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+baseKey+"]\n") + overlay := writePipelineFixture(t, dir, "overlays/testing.yml", "notarius:\n credentials: "+credential+"\n") profile := "testing" cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile}) if err != nil { t.Fatal(err) } - if credential == "null" { - if cfg.Notarius.LLMAPIKey != nil { - t.Fatal("profile did not remove the base credential") + if credential == "null" || credential == "[]" { + if len(cfg.Notarius.Credentials) != 0 { + t.Fatal("overlay did not clear base credentials") } return } - if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != profileKey || !cfg.Notarius.LLMAPIKey.IsResolved() { - t.Fatal("profile credential was not resolved") + if len(cfg.Notarius.Credentials) != 1 || cfg.Notarius.Credentials[0].EnvName() != profileKey || !cfg.Notarius.Credentials[0].IsResolved() { + t.Fatal("overlay credential was not resolved") } sources, err := EffectivePipelineSources(cfg, ResolvedParty{}) if err != nil { @@ -180,12 +180,12 @@ func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) { } found := false for _, source := range sources { - if source.Path == "notarius.llm_api_key" { + if source.Path == "notarius.credentials" { found = source.Source == overlay } } if !found { - t.Fatal("Notarius credential provenance did not identify the selected profile") + t.Fatal("Notarius credential provenance did not identify the selected overlay") } }) } @@ -234,7 +234,7 @@ func TestNotariusCredentialRootProvenance(t *testing.T) { const envName = "NARRATIO_NOTARIUS_ROOT_KEY" t.Setenv(envName, "synthetic-notarius-root-key") dir := t.TempDir() - root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n") + root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+envName+"]\n") cfg, err := LoadPipeline(root) if err != nil { t.Fatal(err) @@ -244,7 +244,7 @@ func TestNotariusCredentialRootProvenance(t *testing.T) { t.Fatal(err) } for _, source := range sources { - if source.Path == "notarius.llm_api_key" && source.Source == root { + if source.Path == "notarius.credentials" && source.Source == root { return } } diff --git a/internal/stage/extract.go b/internal/stage/extract.go index e0a619b..7aac1e4 100644 --- a/internal/stage/extract.go +++ b/internal/stage/extract.go @@ -146,7 +146,7 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re chunkCache = "refresh" } request := notarius.RunRequest{ - Binary: resolvedBinary, LLMAPIKey: notariusConfig.LLMAPIKey, ConfigProfile: notariusConfig.ConfigProfile, + Binary: resolvedBinary, Credentials: notariusConfig.Credentials, ConfigProfile: notariusConfig.ConfigProfile, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID, InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory, ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, Resume: resume, ChunkCache: chunkCache, References: referenceBindings, diff --git a/internal/stage/extract_test.go b/internal/stage/extract_test.go index ec8cadf..e390c74 100644 --- a/internal/stage/extract_test.go +++ b/internal/stage/extract_test.go @@ -61,7 +61,7 @@ func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) { transcribe_url: https://transcription.example.test/transcribe notarius: enabled: false - llm_api_key: NARRATIO_EXTRACT_NOTARIUS_KEY + credentials: [NARRATIO_EXTRACT_NOTARIUS_KEY] `), 0o644); err != nil { t.Fatal(err) } @@ -71,7 +71,7 @@ notarius: } env, m, fake := setupExtractEnv(t) - env.Config.Pipeline.Notarius.LLMAPIKey = loadedPipeline.Notarius.LLMAPIKey + env.Config.Pipeline.Notarius.Credentials = loadedPipeline.Notarius.Credentials configProfile := "inexpensive" env.Config.Pipeline.Notarius.ConfigProfile = &configProfile result, err := (extractStage{}).Run(context.Background(), env, m) @@ -103,7 +103,7 @@ notarius: if req.Resume || req.ChunkCache != "" { t.Fatalf("default cache controls = resume %v, mode %q", req.Resume, req.ChunkCache) } - if req.LLMAPIKey != loadedPipeline.Notarius.LLMAPIKey || req.LLMAPIKey.EnvName() != credentialName || !req.LLMAPIKey.IsResolved() { + if len(req.Credentials) != 1 || req.Credentials[0].EnvName() != credentialName || !req.Credentials[0].IsResolved() { t.Fatal("request did not retain the loaded Notarius credential reference") } if req.ConfigProfile == nil || *req.ConfigProfile != configProfile { diff --git a/internal/stage/semantic_contracts_delivery_test.go b/internal/stage/semantic_contracts_delivery_test.go index ec9dd4d..2c86282 100644 --- a/internal/stage/semantic_contracts_delivery_test.go +++ b/internal/stage/semantic_contracts_delivery_test.go @@ -194,7 +194,7 @@ func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) { renamed := loadNotariusCredentialForSemanticTest(t, secondName, "second-value") baseline := downstreamSemanticEnv() - baseline.Config.Pipeline.Notarius.LLMAPIKey = first + baseline.Config.Pipeline.Notarius.Credentials = []configkit.Secret{*first} want, err := (extractStage{}).SemanticConfigFingerprint(baseline) if err != nil { t.Fatal(err) @@ -209,7 +209,9 @@ func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { env := downstreamSemanticEnv() - env.Config.Pipeline.Notarius.LLMAPIKey = test.credential + if test.credential != nil { + env.Config.Pipeline.Notarius.Credentials = []configkit.Secret{*test.credential} + } got, err := (extractStage{}).SemanticConfigFingerprint(env) if err != nil { t.Fatal(err) @@ -225,14 +227,14 @@ func loadNotariusCredentialForSemanticTest(t *testing.T, name, value string) *co t.Helper() t.Setenv(name, value) path := filepath.Join(t.TempDir(), "pipeline.yml") - if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+name+"\n"), 0o644); err != nil { + if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+name+"]\n"), 0o644); err != nil { t.Fatal(err) } pipeline, err := config.LoadPipeline(path) if err != nil { t.Fatal(err) } - return pipeline.Notarius.LLMAPIKey + return &pipeline.Notarius.Credentials[0] } func TestPublishSemanticConfigSensitivityAndOperationalExclusions(t *testing.T) {