From a665216a4548ba7d23255fb056ebd9ea974eb854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 22 Oct 2026 20:48:42 +0200 Subject: [PATCH 1/3] fix(tui): resolve the /speak OpenAI key through the secret chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /speak read OPENAI_API_KEY with os.Getenv, so it ignored op:// references, --env-from-file, the docker-agent env file and every other source of the chain. The transcriber now takes an environment.Provider and reads the key when /speak starts, never at TUI construction, so launching the TUI cannot trigger an op unlock prompt. run passes its runtime env provider; other callers default to environment.NewDefaultProvider(). Signed-off-by: Arnaud Héritier --- cmd/root/run.go | 2 +- pkg/audio/transcribe/finish_test.go | 4 +-- pkg/audio/transcribe/transcribe.go | 16 ++++++---- pkg/audio/transcribe/transcribe_test.go | 41 +++++++++++++++++++++---- pkg/tui/tui.go | 15 ++++++++- 5 files changed, 62 insertions(+), 16 deletions(-) diff --git a/cmd/root/run.go b/cmd/root/run.go index 6df34b7b29..a88fac7b3a 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -1108,7 +1108,7 @@ func readInitialMessage(args []string) (*string, error) { // tuiOpts returns the TUI options derived from the current flags. args are // the run command's positional arguments, used to detect an initial message. func (f *runExecFlags) tuiOpts(args []string) []tui.Option { - var opts []tui.Option + opts := []tui.Option{tui.WithEnvProvider(f.runConfig.EnvProvider())} if f.lean { opts = append(opts, tui.WithLeanMode()) } diff --git a/pkg/audio/transcribe/finish_test.go b/pkg/audio/transcribe/finish_test.go index 1bb9cd8001..b894e99e44 100644 --- a/pkg/audio/transcribe/finish_test.go +++ b/pkg/audio/transcribe/finish_test.go @@ -218,7 +218,7 @@ func TestRun_TranscriptionFailure(t *testing.T) { func TestRun_FinishBeforeStart(t *testing.T) { t.Parallel() - transcriber := New("") + transcriber := New(keyEnv("")) transcriber.supported = true finish := make(chan struct{}) close(finish) @@ -235,7 +235,7 @@ func TestRun_FinishDuringUpgrade(t *testing.T) { })) t.Cleanup(server.Close) defer close(release) - transcriber := New("test-api-key") + transcriber := New(keyEnv("test-api-key")) transcriber.supported = true transcriber.endpoint = "ws" + strings.TrimPrefix(server.URL, "http") transcriber.capture = captureFunc(func(context.Context, string, capture.AudioHandler, func()) error { diff --git a/pkg/audio/transcribe/transcribe.go b/pkg/audio/transcribe/transcribe.go index 1d1fb21d7b..82d7c07142 100644 --- a/pkg/audio/transcribe/transcribe.go +++ b/pkg/audio/transcribe/transcribe.go @@ -17,6 +17,7 @@ import ( "github.com/gorilla/websocket" "github.com/docker/docker-agent/pkg/audio/capture" + "github.com/docker/docker-agent/pkg/environment" ) const ( @@ -37,17 +38,19 @@ type audioCapture interface { // Transcriber holds configuration; each Run owns its connection and audio capture. type Transcriber struct { - apiKey string + env environment.Provider endpoint string capture audioCapture supported bool finishTimeout time.Duration } -// New creates a transcriber without opening a connection or microphone. -func New(apiKey string) *Transcriber { +// New creates a transcriber without opening a connection or microphone. The +// OpenAI key is read from env on each Run, so building a TUI never triggers +// secret resolution such as an `op` unlock prompt. +func New(env environment.Provider) *Transcriber { return &Transcriber{ - apiKey: apiKey, + env: env, endpoint: openAIRealtimeURL, capture: capture.NewCapturer(capture.SampleRate24000), supported: audioSupported, @@ -77,7 +80,8 @@ func (t *Transcriber) Run(ctx context.Context, finish <-chan struct{}, handler T return nil default: } - if t.apiKey == "" { + apiKey, _ := t.env.Get(ctx, "OPENAI_API_KEY") + if apiKey == "" { return errors.New("/speak sends audio directly to OpenAI; set a separate OPENAI_API_KEY environment variable even when chat uses Docker's models gateway, which does not support /speak") } @@ -114,7 +118,7 @@ func (t *Transcriber) Run(ctx context.Context, finish <-chan struct{}, handler T }() conn, response, err := dial(captureCtx, t.endpoint, http.Header{ - "Authorization": []string{"Bearer " + t.apiKey}, + "Authorization": []string{"Bearer " + apiKey}, "OpenAI-Beta": []string{"realtime=v1"}, }) if err != nil { diff --git a/pkg/audio/transcribe/transcribe_test.go b/pkg/audio/transcribe/transcribe_test.go index 3662f74abf..1da8edfc0a 100644 --- a/pkg/audio/transcribe/transcribe_test.go +++ b/pkg/audio/transcribe/transcribe_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/docker/docker-agent/pkg/audio/capture" + "github.com/docker/docker-agent/pkg/environment" ) type captureFunc func(context.Context, string, capture.AudioHandler, func()) error @@ -50,7 +51,7 @@ func testTranscriber(t *testing.T, handler func(*websocket.Conn)) *Transcriber { connections.Wait() }) return &Transcriber{ - apiKey: "test-api-key", endpoint: "ws" + strings.TrimPrefix(server.URL, "http"), supported: true, + env: keyEnv("test-api-key"), endpoint: "ws" + strings.TrimPrefix(server.URL, "http"), supported: true, capture: captureFunc(func(ctx context.Context, _ string, _ capture.AudioHandler, ready func()) error { if ready != nil { ready() @@ -72,9 +73,37 @@ func waitRun(t *testing.T, done <-chan error) error { } } +func keyEnv(key string) environment.Provider { + return environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": key}) +} + +type countingEnv struct { + environment.Provider + + gets atomic.Int32 +} + +func (c *countingEnv) Get(ctx context.Context, name string) (string, bool) { + c.gets.Add(1) + return c.Provider.Get(ctx, name) +} + +func TestRun_ResolvesKeyLazily(t *testing.T) { + t.Parallel() + env := &countingEnv{Provider: keyEnv("test-api-key")} + transcriber := New(env) + assert.Zero(t, env.gets.Load(), "New must not resolve the key") + + transcriber.supported = true + transcriber.endpoint = ":invalid" + err := transcriber.Run(t.Context(), nil, nil, nil) + require.ErrorContains(t, err, "connect /speak directly to OpenAI Realtime API") + assert.Equal(t, int32(1), env.gets.Load()) +} + func TestRun_MissingAPIKey(t *testing.T) { t.Parallel() - transcriber := New("") + transcriber := New(keyEnv("")) transcriber.supported = true err := transcriber.Run(t.Context(), nil, nil, nil) require.EqualError(t, err, "/speak sends audio directly to OpenAI; set a separate OPENAI_API_KEY environment variable even when chat uses Docker's models gateway, which does not support /speak") @@ -84,21 +113,21 @@ func TestRun_CanceledBeforeStart(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) cancel() - transcriber := New("test-api-key") + transcriber := New(keyEnv("test-api-key")) transcriber.supported = true require.NoError(t, transcriber.Run(ctx, nil, nil, func(context.Context) { t.Error("canceled run became ready") })) } func TestRun_Unsupported(t *testing.T) { t.Parallel() - transcriber := New("test-api-key") + transcriber := New(keyEnv("test-api-key")) transcriber.supported = false require.ErrorIs(t, transcriber.Run(t.Context(), nil, nil, nil), ErrNotSupported) } func TestRun_ConnectionFailure(t *testing.T) { t.Parallel() - transcriber := New("test-api-key") + transcriber := New(keyEnv("test-api-key")) transcriber.supported = true transcriber.endpoint = ":invalid" err := transcriber.Run(t.Context(), nil, nil, nil) @@ -278,7 +307,7 @@ func TestRun_CancelDuringUpgrade(t *testing.T) { })) t.Cleanup(server.Close) defer close(release) - transcriber := New("test-api-key") + transcriber := New(keyEnv("test-api-key")) transcriber.supported = true transcriber.endpoint = "ws" + strings.TrimPrefix(server.URL, "http") ctx, cancel := context.WithCancel(t.Context()) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index a57d490227..85931e964d 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -20,6 +20,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/audio/transcribe" + "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/history" "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/plans" @@ -165,6 +166,7 @@ type appModel struct { completions completion.Manager // Speech-to-text + envProvider environment.Provider transcriber Transcriber speechMu sync.Mutex speechCtx context.Context //nolint:containedctx // Owns transcription across UI and external shutdown. @@ -399,6 +401,14 @@ func WithAppName(name string) Option { } } +// WithEnvProvider sets the secret chain used by /speak to read +// OPENAI_API_KEY. If not provided, defaults to environment.NewDefaultProvider(). +func WithEnvProvider(env environment.Provider) Option { + return func(m *appModel) { + m.envProvider = env + } +} + // WithVersion sets the application version. // // If not provided, defaults to version.Version. @@ -535,7 +545,6 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi dialogMgr: dialog.New(), completions: completion.New(), tour: tour.New(), - transcriber: transcribe.New(os.Getenv("OPENAI_API_KEY")), workingSpinner: spinner.New(ar, spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle), focusedPanel: PanelEditor, editorLines: 3, @@ -553,6 +562,10 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi for _, opt := range opts { opt(m) } + if m.envProvider == nil { + m.envProvider = environment.NewDefaultProvider() + } + m.transcriber = transcribe.New(m.envProvider) // Create initial editor (after options are applied so command builder is set) initialEditor := editor.New(historyStore, m.editorOpts()...) From c7e920cb5f089f9a034db3735bf26214249c3d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 22 Oct 2026 21:02:18 +0200 Subject: [PATCH 2/3] docs(secrets): /speak now resolves its key through the secret chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Arnaud Héritier --- docs/guides/secrets/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/secrets/index.md b/docs/guides/secrets/index.md index f0f37f453e..b2345b9064 100644 --- a/docs/guides/secrets/index.md +++ b/docs/guides/secrets/index.md @@ -62,7 +62,7 @@ export DOCKER_AGENT_ENV_ANTHROPIC_API_KEY="op://Personal/Anthropic/api-key" # us docker agent run agent.yaml ``` -- The prefix works for every variable Docker Agent resolves through its secret chain: provider API keys, `${VAR}` references in MCP `env`, `DOCKER_TOKEN`. Voice transcription in the TUI still reads `OPENAI_API_KEY` directly. +- The prefix works for every variable Docker Agent resolves through its secret chain: provider API keys, `${VAR}` references in MCP `env`, `DOCKER_TOKEN`, and the OpenAI key used by `/speak`. - `DOCKER_AGENT_ENV_` wins over `` across all [sources](#overview) and `--env-from-file`: an override in the Docker Agent env file beats a plain shell export. - An empty override is ignored: `export DOCKER_AGENT_ENV_ANTHROPIC_API_KEY=` falls back to an override in a later source, then to `ANTHROPIC_API_KEY`. - Docker Agent's own settings (`DOCKER_AGENT_*`) are never overridden this way. From 8859213fcd6ab870d1ee9597454913f1a2a48ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 22 Oct 2026 21:10:31 +0200 Subject: [PATCH 3/3] fix(tui): keep injected transcribers and let finish interrupt the key lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New built the default transcriber after the options, replacing one passed with WithTranscriber; it now only fills in a missing one. - The key lookup ran before the finish watcher and on the parent context, so finishing during an op unlock prompt hung, and cancelling reported a missing key. It now runs on the capture context after the watcher is set up, and an interrupted lookup ends the run quietly. Signed-off-by: Arnaud Héritier --- pkg/audio/transcribe/transcribe.go | 15 ++++++---- pkg/audio/transcribe/transcribe_test.go | 37 +++++++++++++++++++++++++ pkg/tui/tui.go | 8 ++++-- pkg/tui/tui_speak_test.go | 15 ++++++++++ 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/pkg/audio/transcribe/transcribe.go b/pkg/audio/transcribe/transcribe.go index 82d7c07142..b5b4bd7253 100644 --- a/pkg/audio/transcribe/transcribe.go +++ b/pkg/audio/transcribe/transcribe.go @@ -80,11 +80,6 @@ func (t *Transcriber) Run(ctx context.Context, finish <-chan struct{}, handler T return nil default: } - apiKey, _ := t.env.Get(ctx, "OPENAI_API_KEY") - if apiKey == "" { - return errors.New("/speak sends audio directly to OpenAI; set a separate OPENAI_API_KEY environment variable even when chat uses Docker's models gateway, which does not support /speak") - } - runCtx, cancel := context.WithCancel(ctx) captureCtx, stopCapture := context.WithCancel(runCtx) finished := make(chan struct{}) @@ -117,6 +112,16 @@ func (t *Transcriber) Run(ctx context.Context, finish <-chan struct{}, handler T err = errors.Join(err, finishErr) }() + // The lookup can block on an `op` unlock prompt; finish and cancel must + // interrupt it like any other setup step. + apiKey, _ := t.env.Get(captureCtx, "OPENAI_API_KEY") + if captureCtx.Err() != nil { + return nil + } + if apiKey == "" { + return errors.New("/speak sends audio directly to OpenAI; set a separate OPENAI_API_KEY environment variable even when chat uses Docker's models gateway, which does not support /speak") + } + conn, response, err := dial(captureCtx, t.endpoint, http.Header{ "Authorization": []string{"Bearer " + apiKey}, "OpenAI-Beta": []string{"realtime=v1"}, diff --git a/pkg/audio/transcribe/transcribe_test.go b/pkg/audio/transcribe/transcribe_test.go index 1da8edfc0a..181debfba6 100644 --- a/pkg/audio/transcribe/transcribe_test.go +++ b/pkg/audio/transcribe/transcribe_test.go @@ -101,6 +101,43 @@ func TestRun_ResolvesKeyLazily(t *testing.T) { assert.Equal(t, int32(1), env.gets.Load()) } +// blockingEnv blocks every lookup until ctx ends, like `op read` at an unlock prompt. +type blockingEnv struct{ entered chan struct{} } + +func (b blockingEnv) Get(ctx context.Context, _ string) (string, bool) { + close(b.entered) + <-ctx.Done() + return "", false +} + +func TestRun_FinishInterruptsKeyLookup(t *testing.T) { + t.Parallel() + env := blockingEnv{entered: make(chan struct{})} + transcriber := New(env) + transcriber.supported = true + finish := make(chan struct{}) + done := make(chan error, 1) + go func() { done <- transcriber.Run(t.Context(), finish, nil, nil) }() + + <-env.entered + close(finish) + require.NoError(t, waitRun(t, done)) +} + +func TestRun_CancelDuringKeyLookupIsNotAnError(t *testing.T) { + t.Parallel() + env := blockingEnv{entered: make(chan struct{})} + transcriber := New(env) + transcriber.supported = true + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- transcriber.Run(ctx, nil, nil, nil) }() + + <-env.entered + cancel() + require.NoError(t, waitRun(t, done)) +} + func TestRun_MissingAPIKey(t *testing.T) { t.Parallel() transcriber := New(keyEnv("")) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 85931e964d..f1fbb9ad1f 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -562,10 +562,12 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi for _, opt := range opts { opt(m) } - if m.envProvider == nil { - m.envProvider = environment.NewDefaultProvider() + if m.transcriber == nil { + if m.envProvider == nil { + m.envProvider = environment.NewDefaultProvider() + } + m.transcriber = transcribe.New(m.envProvider) } - m.transcriber = transcribe.New(m.envProvider) // Create initial editor (after options are applied so command builder is set) initialEditor := editor.New(historyStore, m.editorOpts()...) diff --git a/pkg/tui/tui_speak_test.go b/pkg/tui/tui_speak_test.go index b32bc993dc..d5580473d6 100644 --- a/pkg/tui/tui_speak_test.go +++ b/pkg/tui/tui_speak_test.go @@ -14,7 +14,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/audio/transcribe" + "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/tui/components/notification" "github.com/docker/docker-agent/pkg/tui/messages" ) @@ -426,3 +428,16 @@ func TestSpeech_LeavingAndReturningRevokesSend(t *testing.T) { assert.Equal(t, "keep draft", ed.text) assert.Empty(t, ed.sent) } + +func TestNew_KeepsInjectedTranscriber(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + ft := &fakeTranscriber{supported: true} + + a := app.New(t.Context(), stubRuntime{}, &session.Session{ID: "speak", Title: "speak"}) + m := New(t.Context(), nil, a, "", func() {}, WithTranscriber(ft)).(*appModel) + t.Cleanup(m.cleanupManagedResources) + + assert.Same(t, ft, m.transcriber) +}