Skip to content

feat(secrets): add KeychainProvider#403

Merged
intel352 merged 8 commits into
mainfrom
feat/secrets-keychain-provider
Apr 16, 2026
Merged

feat(secrets): add KeychainProvider#403
intel352 merged 8 commits into
mainfrom
feat/secrets-keychain-provider

Conversation

@intel352

Copy link
Copy Markdown
Contributor

Summary

  • Adds KeychainProvider, a new secrets.Provider implementation backed by the OS credential store (macOS Keychain, Linux Secret Service, Windows Credential Manager) via github.com/zalando/go-keyring.
  • Registers secrets.keychain as a first-class module type in the plugin system, factory, and type registry.
  • New SecretsKeychainModule follows the same modular pattern as SecretsAWSModule and SecretsVaultModule.

Motivation

Single-user local services (e.g., MCP servers) benefit from storing OAuth tokens and app credentials in the OS credential store rather than plain files or environment variables. This is the minimal upstream addition required by zoom-mcp; other workflow-based single-user services benefit the same way.

Surface

modules:
  - name: my-secrets
    type: secrets.keychain
    config:
      service: my-app

Use with step.secret_fetch:

steps:
  - name: fetch-token
    type: step.secret_fetch
    provider: my-secrets
    keys: [zoom_access_token]

Notes

  • Linux caveat: requires a running Secret Service implementation (libsecret/gnome-keyring/KWallet). Documented in KeychainProvider godoc. Headless servers should use FileProvider or VaultProvider.
  • List() limitation: returns only keys set during the current process because go-keyring has no native list-by-service operation. Noted in code comment. Keys persist across restarts (they live in the OS keychain); only in-process trackedKeys is used for List().
  • Delete() is idempotent: keyring.ErrNotFound is silently swallowed.
  • ErrNotFound reused from existing secrets/secrets.go — no new error types introduced.

Test plan

  • Four unit tests using keyring.MockInit(): SetAndGet, GetMissing, Delete, List
  • Factory dispatch tests: happy path + missing-service error
  • Plugin test: TestKeychainModuleFactory, TestModuleFactories (count: 2→3), TestPluginLoads (count: 2→3)
  • DOCUMENTATION.md coverage test passes (TestDocumentationCoverage)
  • go test ./... passes (only pre-existing mcp.registry failure in cmd/wfctl unrelated to this PR)
  • Manual: set a value, restart process, verify persistence (reviewer to confirm on macOS/Linux)

🤖 Generated with Claude Code

intel352 and others added 3 commits April 15, 2026 17:56
Four test functions covering Set+Get, GetMissing, Delete, and List,
all using keyring.MockInit() for hermetic in-process testing.
Tests fail with "undefined: secrets.NewKeychainProvider" until
the implementation is added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements secrets.Provider using the OS credential store via
github.com/zalando/go-keyring (macOS Keychain, Linux Secret Service,
Windows Credential Manager).

Design notes:
- ErrNotFound already existed in secrets/secrets.go; reused as-is.
- trackedKeys is an in-process map for List() support because go-keyring
  has no native list-by-service operation. Keys set in prior processes
  will not appear in List() — documented as a known limitation.
- Delete() is idempotent: keyring.ErrNotFound is silently swallowed.
- Verified against four unit tests using keyring.MockInit() for hermeticity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- plugins/secrets/plugin.go: add "secrets.keychain" to ModuleTypes and
  ModuleFactories, dispatching to the new SecretsKeychainModule
- module/secrets_keychain.go: new modular module wrapping KeychainProvider;
  follows the same pattern as SecretsAWSModule (Init/Start/Stop/Provider/Get)
- cmd/wfctl/infra_secrets.go: add case "keychain" to resolveSecretsProvider;
  'service' config key is required, returns an error if absent
- cmd/wfctl/type_registry.go: register "secrets.keychain" in KnownModuleTypes
  so TestKnownModuleTypesCoverAllPlugins stays in sync
- cmd/wfctl/infra_secrets_test.go: factory dispatch tests (happy path +
  missing-service error) and compile-time interface assertion
- README.md: add secrets.keychain to the module-type count/list in the table
  (Other: 6 -> 7)

Linux caveat: secrets.keychain requires a running Secret Service
(libsecret/gnome-keyring/KWallet); documented in KeychainProvider godoc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 16, 2026 00:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an OS keychain–backed secrets provider/module to the workflow engine, enabling local single-user deployments to store and fetch secrets via the native credential store (macOS Keychain, Linux Secret Service, Windows Credential Manager).

Changes:

  • Introduces secrets.KeychainProvider backed by github.com/zalando/go-keyring, with unit tests using keyring.MockInit().
  • Adds secrets.keychain as a first-class module type in the secrets plugin and wfctl module type registry.
  • Updates CLI secrets-provider resolution and docs to include the new keychain option.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
secrets/keychain_provider.go New OS keychain provider implementation.
secrets/keychain_provider_test.go Unit tests for keychain provider behavior via go-keyring mock.
module/secrets_keychain.go New modular service wrapper for the keychain provider (secrets.keychain).
plugins/secrets/plugin.go Registers secrets.keychain module type in the secrets plugin factories/manifest.
plugins/secrets/plugin_test.go Updates plugin tests to expect/register the new module type.
cmd/wfctl/type_registry.go Adds secrets.keychain to wfctl’s known module types list.
cmd/wfctl/infra_secrets.go Adds keychain option to wfctl infra bootstrap secrets provider resolution.
cmd/wfctl/infra_secrets_test.go Tests wfctl’s keychain provider resolution + interface satisfaction check.
go.mod Adds go-keyring and platform dependencies.
go.sum Adds checksums for new dependencies.
README.md Updates module type counts/listing to include secrets.keychain.
DOCUMENTATION.md Documents secrets.keychain module type and Linux requirements.

}
if len(keys) != 2 {
t.Errorf("got %d keys, want 2", len(keys))
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List() returns keys in non-deterministic map iteration order, so checking only len(keys) can miss cases where the wrong keys are returned. Consider asserting the returned slice contains both "a" and "b" (order-independent) to make the test meaningful.

Suggested change
}
}
gotKeys := make(map[string]struct{}, len(keys))
for _, key := range keys {
gotKeys[key] = struct{}{}
}
if _, ok := gotKeys["a"]; !ok {
t.Errorf("List() missing key %q; got %v", "a", keys)
}
if _, ok := gotKeys["b"]; !ok {
t.Errorf("List() missing key %q; got %v", "b", keys)
}

Copilot uses AI. Check for mistakes.
Comment thread go.mod Outdated
Comment on lines +20 to +26
type KeychainProvider struct {
service string
// trackedKeys is maintained in-process for List() support, because the
// go-keyring API doesn't provide a native list-by-service operation.
// On cold start, List() returns only keys set during this process.
trackedKeys map[string]struct{}
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeychainProvider stores trackedKeys in a plain map without synchronization. Since workflows can execute steps concurrently, concurrent Set/Delete/List calls can race or panic with "concurrent map writes". Add a sync.RWMutex (or sync.Map) and lock around all trackedKeys reads/writes.

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +43
func (p *KeychainProvider) Get(ctx context.Context, key string) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
v, err := keyring.Get(p.service, key)
if err != nil {

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the other secrets.Provider implementations, KeychainProvider doesn't validate that key is non-empty. For consistency and to avoid creating unnamed keychain entries, return secrets.ErrInvalidKey when key == "" in Get/Set/Delete (and consider validating service, too).

Copilot uses AI. Check for mistakes.
Comment thread secrets/keychain_provider.go Outdated
Comment on lines +29 to +35
func TestKeychainProvider_GetMissing(t *testing.T) {
keyring.MockInit()
p := secrets.NewKeychainProvider("test-service")
_, err := p.Get(context.Background(), "absent")
if err == nil {
t.Fatal("expected error for missing key, got nil")
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only checks err != nil for a missing key. Since KeychainProvider is expected to map keyring.ErrNotFound to secrets.ErrNotFound, please assert errors.Is(err, secrets.ErrNotFound) to lock in the contract and prevent regressions.

Copilot uses AI. Check for mistakes.
@github-actions

github-actions Bot commented Apr 16, 2026

Copy link
Copy Markdown

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:245: parsing iteration count: invalid syntax
baseline-bench.txt:362853: parsing iteration count: invalid syntax
baseline-bench.txt:690904: parsing iteration count: invalid syntax
baseline-bench.txt:1038122: parsing iteration count: invalid syntax
baseline-bench.txt:1375119: parsing iteration count: invalid syntax
baseline-bench.txt:1717681: parsing iteration count: invalid syntax
benchmark-results.txt:245: parsing iteration count: invalid syntax
benchmark-results.txt:336933: parsing iteration count: invalid syntax
benchmark-results.txt:665704: parsing iteration count: invalid syntax
benchmark-results.txt:1012010: parsing iteration count: invalid syntax
benchmark-results.txt:1322460: parsing iteration count: invalid syntax
benchmark-results.txt:1658482: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 9V74 80-Core Processor                
                            │ baseline-bench.txt │       benchmark-results.txt        │
                            │       sec/op       │    sec/op     vs base              │
InterpreterCreation-4               2.998m ± 98%   3.141m ± 29%       ~ (p=0.394 n=6)
ComponentLoad-4                     3.513m ±  5%   3.496m ±  3%       ~ (p=0.937 n=6)
ComponentExecute-4                  1.826µ ±  2%   1.818µ ±  1%       ~ (p=0.104 n=6)
PoolContention/workers-1-4          1.026µ ±  1%   1.020µ ±  1%       ~ (p=0.260 n=6)
PoolContention/workers-2-4          1.021µ ±  4%   1.020µ ±  2%       ~ (p=0.513 n=6)
PoolContention/workers-4-4          1.022µ ±  1%   1.024µ ±  1%       ~ (p=0.790 n=6)
PoolContention/workers-8-4          1.019µ ±  1%   1.022µ ±  1%       ~ (p=0.130 n=6)
PoolContention/workers-16-4         1.026µ ±  4%   1.019µ ±  3%       ~ (p=0.240 n=6)
ComponentLifecycle-4                3.499m ±  2%   3.522m ±  2%       ~ (p=0.310 n=6)
SourceValidation-4                  2.099µ ±  2%   2.065µ ±  1%  -1.62% (p=0.004 n=6)
RegistryConcurrent-4                752.6n ±  3%   739.6n ±  4%       ~ (p=0.394 n=6)
LoaderLoadFromString-4              3.537m ±  1%   3.567m ±  1%       ~ (p=0.093 n=6)
geomean                             16.54µ         16.56µ        +0.08%

                            │ baseline-bench.txt │        benchmark-results.txt         │
                            │        B/op        │     B/op      vs base                │
InterpreterCreation-4               2.027Mi ± 0%   2.027Mi ± 0%       ~ (p=0.515 n=6)
ComponentLoad-4                     2.180Mi ± 0%   2.180Mi ± 0%       ~ (p=0.615 n=6)
ComponentExecute-4                  1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4         1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                2.183Mi ± 0%   2.183Mi ± 0%       ~ (p=0.513 n=6)
SourceValidation-4                  1.984Ki ± 0%   1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                1.133Ki ± 0%   1.133Ki ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4              2.182Mi ± 0%   2.182Mi ± 0%       ~ (p=0.411 n=6)
geomean                             15.25Ki        15.25Ki       +0.00%
¹ all samples are equal

                            │ baseline-bench.txt │        benchmark-results.txt        │
                            │     allocs/op      │  allocs/op   vs base                │
InterpreterCreation-4                15.68k ± 0%   15.68k ± 0%       ~ (p=1.000 n=6) ¹
ComponentLoad-4                      18.02k ± 0%   18.02k ± 0%       ~ (p=1.000 n=6)
ComponentExecute-4                    25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4           25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                 18.07k ± 0%   18.07k ± 0%       ~ (p=1.000 n=6) ¹
SourceValidation-4                    32.00 ± 0%    32.00 ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                  2.000 ± 0%    2.000 ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4               18.06k ± 0%   18.06k ± 0%       ~ (p=1.000 n=6) ¹
geomean                               183.3         183.3       +0.00%
¹ all samples are equal

pkg: github.com/GoCodeAlone/workflow/middleware
                                  │ baseline-bench.txt │       benchmark-results.txt       │
                                  │       sec/op       │   sec/op     vs base              │
CircuitBreakerDetection-4                  299.5n ± 5%   296.1n ± 1%  -1.10% (p=0.026 n=6)
CircuitBreakerExecution_Success-4          22.69n ± 1%   22.68n ± 4%       ~ (p=0.970 n=6)
CircuitBreakerExecution_Failure-4          70.94n ± 0%   71.17n ± 1%  +0.33% (p=0.002 n=6)
geomean                                    78.40n        78.19n       -0.27%

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │        B/op        │    B/op     vs base                │
CircuitBreakerDetection-4                 144.0 ± 0%     144.0 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │     allocs/op      │ allocs/op   vs base                │
CircuitBreakerDetection-4                 1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/module
                                 │ baseline-bench.txt │       benchmark-results.txt        │
                                 │       sec/op       │    sec/op     vs base              │
JQTransform_Simple-4                     836.8n ± 27%   889.2n ± 19%       ~ (p=0.859 n=6)
JQTransform_ObjectConstruction-4         1.425µ ±  1%   1.395µ ±  1%  -2.11% (p=0.002 n=6)
JQTransform_ArraySelect-4                3.412µ ±  2%   3.350µ ±  1%  -1.80% (p=0.002 n=6)
JQTransform_Complex-4                    42.10µ ±  1%   40.30µ ±  2%  -4.28% (p=0.002 n=6)
JQTransform_Throughput-4                 1.747µ ±  1%   1.721µ ±  1%  -1.49% (p=0.002 n=6)
SSEPublishDelivery-4                     63.90n ±  2%   63.62n ±  1%       ~ (p=0.818 n=6)
geomean                                  1.635µ         1.624µ        -0.69%

                                 │ baseline-bench.txt │        benchmark-results.txt         │
                                 │        B/op        │     B/op      vs base                │
JQTransform_Simple-4                   1.273Ki ± 0%     1.273Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4       1.773Ki ± 0%     1.773Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4              2.625Ki ± 0%     2.625Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                  16.22Ki ± 0%     16.22Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4               1.984Ki ± 0%     1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²                 +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                 │ baseline-bench.txt │       benchmark-results.txt        │
                                 │     allocs/op      │ allocs/op   vs base                │
JQTransform_Simple-4                     10.00 ± 0%     10.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4         15.00 ± 0%     15.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4                30.00 ± 0%     30.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                    324.0 ± 0%     324.0 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4                 17.00 ± 0%     17.00 ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/schema
                                    │ baseline-bench.txt │       benchmark-results.txt       │
                                    │       sec/op       │   sec/op     vs base              │
SchemaValidation_Simple-4                    1.101µ ± 4%   1.075µ ± 1%       ~ (p=0.093 n=6)
SchemaValidation_AllFields-4                 1.657µ ± 5%   1.686µ ± 9%       ~ (p=0.394 n=6)
SchemaValidation_FormatValidation-4          1.576µ ± 1%   1.563µ ± 2%       ~ (p=0.093 n=6)
SchemaValidation_ManySchemas-4               1.562µ ± 1%   1.589µ ± 2%  +1.70% (p=0.017 n=6)
geomean                                      1.456µ        1.456µ       +0.03%

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │        B/op        │    B/op     vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │     allocs/op      │ allocs/op   vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/store
                                   │ baseline-bench.txt │       benchmark-results.txt        │
                                   │       sec/op       │    sec/op     vs base              │
EventStoreAppend_InMemory-4                1.033µ ± 20%   1.100µ ± 18%       ~ (p=0.937 n=6)
EventStoreAppend_SQLite-4                  1.124m ±  3%   1.035m ±  4%  -7.98% (p=0.002 n=6)
GetTimeline_InMemory/events-10-4           12.93µ ±  3%   13.20µ ±  3%       ~ (p=0.065 n=6)
GetTimeline_InMemory/events-50-4           73.00µ ±  5%   73.26µ ±  9%       ~ (p=1.000 n=6)
GetTimeline_InMemory/events-100-4          116.4µ ± 29%   115.4µ ±  0%       ~ (p=1.000 n=6)
GetTimeline_InMemory/events-500-4          579.2µ ±  1%   590.3µ ±  1%  +1.92% (p=0.002 n=6)
GetTimeline_InMemory/events-1000-4         1.180m ±  1%   1.203m ±  1%  +1.91% (p=0.002 n=6)
GetTimeline_SQLite/events-10-4             86.27µ ±  1%   89.15µ ±  1%  +3.34% (p=0.002 n=6)
GetTimeline_SQLite/events-50-4             225.8µ ±  2%   233.6µ ±  0%  +3.46% (p=0.002 n=6)
GetTimeline_SQLite/events-100-4            395.6µ ±  2%   406.4µ ±  1%  +2.74% (p=0.004 n=6)
GetTimeline_SQLite/events-500-4            1.728m ±  1%   1.755m ±  6%  +1.55% (p=0.002 n=6)
GetTimeline_SQLite/events-1000-4           3.372m ±  0%   3.423m ±  1%  +1.53% (p=0.002 n=6)
geomean                                    199.3µ         201.9µ        +1.32%

                                   │ baseline-bench.txt │        benchmark-results.txt         │
                                   │        B/op        │     B/op      vs base                │
EventStoreAppend_InMemory-4                 749.5 ± 14%     800.5 ± 7%       ~ (p=0.177 n=6)
EventStoreAppend_SQLite-4                 1.987Ki ±  1%   1.984Ki ± 1%       ~ (p=0.411 n=6)
GetTimeline_InMemory/events-10-4          7.953Ki ±  0%   7.953Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4          46.62Ki ±  0%   46.62Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4         94.48Ki ±  0%   94.48Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4         472.8Ki ±  0%   472.8Ki ± 0%       ~ (p=1.000 n=6)
GetTimeline_InMemory/events-1000-4        944.3Ki ±  0%   944.3Ki ± 0%       ~ (p=0.545 n=6)
GetTimeline_SQLite/events-10-4            16.74Ki ±  0%   16.74Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4            87.14Ki ±  0%   87.14Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4           175.4Ki ±  0%   175.4Ki ± 0%       ~ (p=1.000 n=6)
GetTimeline_SQLite/events-500-4           846.1Ki ±  0%   846.1Ki ± 0%       ~ (p=0.545 n=6)
GetTimeline_SQLite/events-1000-4          1.639Mi ±  0%   1.639Mi ± 0%       ~ (p=0.706 n=6)
geomean                                   67.06Ki         67.42Ki       +0.54%
¹ all samples are equal

                                   │ baseline-bench.txt │        benchmark-results.txt        │
                                   │     allocs/op      │  allocs/op   vs base                │
EventStoreAppend_InMemory-4                  7.000 ± 0%    7.000 ± 0%       ~ (p=1.000 n=6) ¹
EventStoreAppend_SQLite-4                    53.00 ± 0%    53.00 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-10-4             125.0 ± 0%    125.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4             653.0 ± 0%    653.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4           1.306k ± 0%   1.306k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4           6.514k ± 0%   6.514k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-1000-4          13.02k ± 0%   13.02k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-10-4               382.0 ± 0%    382.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4              1.852k ± 0%   1.852k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4             3.681k ± 0%   3.681k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4             18.54k ± 0%   18.54k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-1000-4            37.29k ± 0%   37.29k ± 0%       ~ (p=1.000 n=6) ¹
geomean                                     1.162k        1.162k       +0.00%
¹ all samples are equal

Benchmarks run with go test -bench=. -benchmem -count=6.
Regressions ≥ 20% are flagged. Results compared via benchstat.

intel352 and others added 2 commits April 15, 2026 21:15
… assertion placement

- Add sync.RWMutex to KeychainProvider; guard trackedKeys in Set/Delete (Lock) and List (RLock) — provider is now safe for concurrent use
- Include key name in Get() ErrNotFound message: key %q: %w, matching vault/env convention
- Move compile-time interface assertion var _ secrets.Provider = (*secrets.KeychainProvider)(nil) from cmd/wfctl/infra_secrets_test.go to secrets/keychain_provider_test.go
- Add godoc comments (name-first) to all exported methods: Name, Get, Set, Delete, List
- TestKeychainProvider_GetMissing: add errors.Is(err, secrets.ErrNotFound) check
- TestKeychainProvider_List: sort slice and compare with reflect.DeepEqual instead of len-only check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 16, 2026 04:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.

Comment on lines +79 to +82
if err := keyring.Delete(p.service, key); err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil // idempotent
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete returns nil on keyring.ErrNotFound, but it exits before removing the key from trackedKeys. That can leave List() reporting keys that were deleted (or never existed). Consider (1) returning secrets.ErrInvalidKey when key == "" and (2) deleting from trackedKeys even when the backend reports ErrNotFound to keep in-process state consistent with idempotent delete semantics.

Suggested change
if err := keyring.Delete(p.service, key); err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil // idempotent
}
if key == "" {
return ErrInvalidKey
}
if err := keyring.Delete(p.service, key); err != nil && !errors.Is(err, keyring.ErrNotFound) {

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +56
func (p *KeychainProvider) Get(ctx context.Context, key string) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
v, err := keyring.Get(p.service, key)
if err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return "", fmt.Errorf("secrets.keychain: key %q: %w", key, ErrNotFound)
}
return "", fmt.Errorf("secrets.keychain get %q: %w", key, err)
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeychainProvider methods don’t validate an empty key. Other secrets.Provider implementations return secrets.ErrInvalidKey when key == "" (e.g., EnvProvider/FileProvider/AWS/Vault). Add the same guard here so callers get a consistent error contract (and avoid passing empty keys down into go-keyring).

Copilot uses AI. Check for mistakes.
func (p *KeychainProvider) Set(ctx context.Context, key, value string) error {
if err := ctx.Err(); err != nil {
return err
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set should reject an empty key with secrets.ErrInvalidKey (consistent with other providers). Without this, an empty key may be stored in the OS keychain or produce backend-specific errors that don’t match the secrets.Provider contract.

Suggested change
}
}
if key == "" {
return ErrInvalidKey
}

Copilot uses AI. Check for mistakes.
intel352 and others added 2 commits April 16, 2026 09:51
…anup, go mod tidy

Address Copilot review feedback on PR #403:

- Add ErrInvalidKey guard on Get/Set/Delete for empty keys, matching
  VaultProvider/GitHubProvider/AWSProvider convention.
- Delete: clean up trackedKeys even on idempotent keyring.ErrNotFound
  path, so List() stays consistent with actual keychain state.
- go mod tidy: move go-keyring from indirect to direct (it's imported
  by non-test code).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The example/ sub-module's go.sum was missing transitive dependencies
introduced by go-keyring (danieljoos/wincred, godbus/dbus). CI's
"go mod tidy" check diffs against committed state, so these need
to be checked in.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 16, 2026 14:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 14 changed files in this pull request and generated 2 comments.

Comment on lines +34 to +40
// NewKeychainProvider returns a provider namespaced to the given service name.
func NewKeychainProvider(service string) *KeychainProvider {
return &KeychainProvider{
service: service,
trackedKeys: make(map[string]struct{}),
}
}

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewKeychainProvider allows an empty service value, but the provider’s doc comment says the service namespaces keys to avoid collisions. With service == "", secrets will be stored in a shared/empty namespace and can collide across apps. Consider validating service (e.g., return ErrProviderInit from Get/Set/Delete/List when service is empty, or add a validated constructor) so misconfiguration fails fast with a clear error.

Copilot uses AI. Check for mistakes.
Comment thread secrets/keychain_provider_test.go Outdated
…setup errors in tests

Address Copilot review feedback on PR #403:

- NewKeychainProvider now returns (*KeychainProvider, error) and rejects
  empty service names, preventing secrets stored in a shared namespace
  that can collide across applications.
- Test setup calls (Set/Delete) now assert err == nil instead of using
  _ = discard, so mock changes or underlying failures surface correctly.
- Add TestKeychainProvider_EmptyService for the new validation.
- Update all callers: infra_secrets.go and secrets_keychain.go.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@intel352 intel352 merged commit d32330a into main Apr 16, 2026
18 checks passed
@intel352 intel352 deleted the feat/secrets-keychain-provider branch April 16, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants