Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/secrets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>` wins over `<NAME>` 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.
Expand Down
4 changes: 2 additions & 2 deletions pkg/audio/transcribe/finish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
27 changes: 18 additions & 9 deletions pkg/audio/transcribe/transcribe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -77,10 +80,6 @@ func (t *Transcriber) Run(ctx context.Context, finish <-chan struct{}, handler T
return nil
default:
}
if t.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{})
Expand Down Expand Up @@ -113,8 +112,18 @@ 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 " + t.apiKey},
"Authorization": []string{"Bearer " + apiKey},
"OpenAI-Beta": []string{"realtime=v1"},
})
if err != nil {
Expand Down
78 changes: 72 additions & 6 deletions pkg/audio/transcribe/transcribe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -72,9 +73,74 @@ 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())
}

// 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("")
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")
Expand All @@ -84,21 +150,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)
Expand Down Expand Up @@ -278,7 +344,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())
Expand Down
17 changes: 16 additions & 1 deletion pkg/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -553,6 +562,12 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi
for _, opt := range opts {
opt(m)
}
if m.transcriber == nil {
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()...)
Expand Down
15 changes: 15 additions & 0 deletions pkg/tui/tui_speak_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
Loading