From 076622ac2e369355d7954aa10fd4f3222ba5eadd Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:05:06 -0400 Subject: [PATCH 01/11] fix(dictation): redact API keys from streaming transcriber errors Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into websocket URLs/headers and echoed unredacted transport errors and server-side error payloads. Wrap those error endpoints with providerio.Redact and switch the connect-error format specifier from %w to %s so the raw error cannot leak via errors.Unwrap(). Adds regression tests asserting the API key is not present in the returned stream error for both transcribers. Co-authored-by: euxaristia <25621994+euxaristia@users.noreply.github.com> --- internal/dictation/transcriber_deepgram.go | 5 +- .../dictation/transcriber_openai_realtime.go | 7 ++- internal/dictation/transcriber_stream_test.go | 55 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index b8b9319aa..7f6cf7696 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/Gitlawb/zero/internal/providers/providerio" "github.com/coder/websocket" ) @@ -60,7 +61,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}}, }) if err != nil { - return "", fmt.Errorf("connecting to Deepgram: %w", err) + return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } defer conn.CloseNow() @@ -100,7 +101,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha } default: } - return compose(), fmt.Errorf("Deepgram stream error: %w", err) + return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } if typ != websocket.MessageText { continue diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 590cbc8b4..55d026f48 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/Gitlawb/zero/internal/providers/providerio" "github.com/coder/websocket" ) @@ -59,7 +60,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, }) if err != nil { - return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err) + return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } defer conn.CloseNow() @@ -113,7 +114,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks } default: } - return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err) + return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } if typ != websocket.MessageText { continue @@ -147,7 +148,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case realtimeCommitted: committed = true case realtimeError: - return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text) + return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey)) } // The writer signals commit completion out of band; observe it so a // stop with no further audio still flips `committed`. diff --git a/internal/dictation/transcriber_stream_test.go b/internal/dictation/transcriber_stream_test.go index 8ece95306..4b65dd5f0 100644 --- a/internal/dictation/transcriber_stream_test.go +++ b/internal/dictation/transcriber_stream_test.go @@ -229,3 +229,58 @@ func TestParseRealtimeEvent(t *testing.T) { t.Errorf("error parse: %+v", e) } } + +func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + defer c.Close(websocket.StatusNormalClosure, "") + for { + typ, _, err := c.Read(ctx) + if err != nil { + return + } + if typ != websocket.MessageText { + continue + } + _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} + +func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // close immediately with an error that simulates connection issue with API key + c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} From c0dcecaf5ab61465788c345ecdcd607ab4f957f0 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:28:44 -0400 Subject: [PATCH 02/11] fixup(dictation): address review: redact session/write errors, move redaction tests - Redact API key in OpenAI Realtime session configuration error. - Return redacted OpenAI Realtime write error from writeErrCh instead of discarding. - Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and TestDeepgramStreamTranscribeErrorRedaction next to their source files. Refs #710 --- .../dictation/transcriber_deepgram_test.go | 32 +++++++++++ .../dictation/transcriber_openai_realtime.go | 4 +- .../transcriber_openai_realtime_test.go | 41 ++++++++++++++ internal/dictation/transcriber_stream_test.go | 55 ------------------- 4 files changed, 76 insertions(+), 56 deletions(-) create mode 100644 internal/dictation/transcriber_deepgram_test.go create mode 100644 internal/dictation/transcriber_openai_realtime_test.go diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go new file mode 100644 index 000000000..c473ac5b9 --- /dev/null +++ b/internal/dictation/transcriber_deepgram_test.go @@ -0,0 +1,32 @@ +package dictation + +import ( + "context" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // close immediately with an error that simulates connection issue with API key + c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 55d026f48..6c8d0f25c 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -75,7 +75,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, } if err := writeJSON(ctx, conn, sessionUpdate); err != nil { - return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err) + return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } writeErrCh := make(chan error, 1) @@ -156,6 +156,8 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case werr := <-writeErrCh: if werr == nil { committed = true + } else { + return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey)) } default: } diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go new file mode 100644 index 000000000..aa28d67db --- /dev/null +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -0,0 +1,41 @@ +package dictation + +import ( + "context" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + defer c.Close(websocket.StatusNormalClosure, "") + for { + typ, _, err := c.Read(ctx) + if err != nil { + return + } + if typ != websocket.MessageText { + continue + } + _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_stream_test.go b/internal/dictation/transcriber_stream_test.go index 4b65dd5f0..8ece95306 100644 --- a/internal/dictation/transcriber_stream_test.go +++ b/internal/dictation/transcriber_stream_test.go @@ -229,58 +229,3 @@ func TestParseRealtimeEvent(t *testing.T) { t.Errorf("error parse: %+v", e) } } - -func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { - url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - defer c.Close(websocket.StatusNormalClosure, "") - for { - typ, _, err := c.Read(ctx) - if err != nil { - return - } - if typ != websocket.MessageText { - continue - } - _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) - } - }) - - tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) - if err != nil { - t.Fatal(err) - } - - chunks := make(chan []byte, 1) - chunks <- make([]byte, 480) - close(chunks) - _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) - if ferr == nil { - t.Fatal("expected error") - } - if strings.Contains(ferr.Error(), "sk-test-key") { - t.Errorf("API key leaked: %v", ferr) - } -} - -func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { - url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // close immediately with an error that simulates connection issue with API key - c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") - }) - - tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) - if err != nil { - t.Fatal(err) - } - - chunks := make(chan []byte, 1) - chunks <- make([]byte, 320) - close(chunks) - _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) - if ferr == nil { - t.Fatal("expected error") - } - if strings.Contains(ferr.Error(), "sk-test-key") { - t.Errorf("API key leaked: %v", ferr) - } -} From 12248b69a4870ca82053e20b009bfafb0cf9e021 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:37:14 -0400 Subject: [PATCH 03/11] fixup(dictation): sync Deepgram redaction test on CloseStream before close Address CodeRabbit nitpick: the server now drains the client's audio frames and waits for CloseStream before rejecting the connection with an API-key-bearing policy-violation close reason. This ensures StreamTranscribe observes the close reason (and redacts it) instead of failing earlier on a write error. Refs #710 --- internal/dictation/transcriber_deepgram_test.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index c473ac5b9..e431937e3 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -10,7 +10,19 @@ import ( func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // close immediately with an error that simulates connection issue with API key + // Drain the client's audio frames, then wait for CloseStream so the + // client has flushed its writes before we reject the connection. Closing + // immediately (before CloseStream) risks the client failing on a write + // error and never observing this API-key-bearing close reason. + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") }) From 04418ab206ea93e1b3367487641e5792d0d57bb3 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:33:52 -0400 Subject: [PATCH 04/11] fix(dictation): preserve context.Canceled through streaming error redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatting the read error with %s dropped the unwrap chain, so an Esc abort of a Deepgram or OpenAI Realtime dictation no longer matched errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious error toast on top of the cancellation notice. Return the cancellation sentinel itself before redacting โ€” it carries no key โ€” and keep the redacted flat string for genuine failures. Adds regression tests that cancel a live stream and assert the sentinel survives. Co-Authored-By: Claude Fable 5 --- internal/dictation/transcriber_deepgram.go | 7 ++++ .../dictation/transcriber_deepgram_test.go | 40 +++++++++++++++++++ .../dictation/transcriber_openai_realtime.go | 7 ++++ .../transcriber_openai_realtime_test.go | 40 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index 7f6cf7696..4e9d7b386 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -3,6 +3,7 @@ package dictation import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -101,6 +102,12 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha } default: } + // A user abort cancels the streaming context; the UI matches it + // with errors.Is(err, context.Canceled), so return the sentinel + // itself (it carries no key) instead of a flat redacted string. + if errors.Is(err, context.Canceled) { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } if typ != websocket.MessageText { diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index e431937e3..0df89aed8 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -2,7 +2,9 @@ package dictation import ( "context" + "errors" "strings" + "sync" "testing" "github.com/coder/websocket" @@ -42,3 +44,41 @@ func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { t.Errorf("API key leaked: %v", ferr) } } + +func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { + firstFrame := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Hold the connection open, never answering, so the client blocks in + // Read until its context is cancelled (the Esc-abort path). + var once sync.Once + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + once.Do(func() { close(firstFrame) }) + } + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + // The channel stays open: the session is live when the user aborts. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + <-firstFrame + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 6c8d0f25c..cc463ee3b 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -114,6 +115,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks } default: } + // A user abort cancels the streaming context; the UI matches it + // with errors.Is(err, context.Canceled), so return the sentinel + // itself (it carries no key) instead of a flat redacted string. + if errors.Is(err, context.Canceled) { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } if typ != websocket.MessageText { diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index aa28d67db..9ac682053 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -2,7 +2,9 @@ package dictation import ( "context" + "errors" "strings" + "sync" "testing" "github.com/coder/websocket" @@ -39,3 +41,41 @@ func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { t.Errorf("API key leaked: %v", ferr) } } + +func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { + firstFrame := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Hold the connection open, never answering, so the client blocks in + // Read until its context is cancelled (the Esc-abort path). + var once sync.Once + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + once.Do(func() { close(firstFrame) }) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + // The channel stays open: the session is live when the user aborts. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + <-firstFrame + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } +} From 7ae37e7b65748a92c3f7acbadc3028c9b4d004fa Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:49:32 -0400 Subject: [PATCH 05/11] fix(dictation): close remaining cancellation and redaction gaps Key cancellation detection on ctx.Err() instead of errors.Is(err, context.Canceled), which was racing against writer-goroutine transport errors that could overwrite the error value right before the check. Applied consistently across both transcriber implementations. Fix goroutine leaks and a possible hang in the cancellation regression tests. --- internal/dictation/transcriber_deepgram.go | 15 +++++++++++-- .../dictation/transcriber_deepgram_test.go | 15 ++++++++----- .../dictation/transcriber_openai_realtime.go | 22 +++++++++++++++++-- .../transcriber_openai_realtime_test.go | 15 ++++++++----- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index 4e9d7b386..2f25b386a 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -3,7 +3,6 @@ package dictation import ( "context" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -62,6 +61,14 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}}, }) if err != nil { + // Preserve the context.Canceled sentinel (it carries no key) so an + // Esc-abort during dial still matches the UI's errors.Is check. Key + // on ctx.Err() itself rather than unwrapping err: a cancellation can + // surface as a plain transport error (e.g. "closed network + // connection") rather than context.Canceled directly. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } defer conn.CloseNow() @@ -105,7 +112,11 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha // A user abort cancels the streaming context; the UI matches it // with errors.Is(err, context.Canceled), so return the sentinel // itself (it carries no key) instead of a flat redacted string. - if errors.Is(err, context.Canceled) { + // Key on ctx.Err() rather than unwrapping err: the writeErrCh + // swap above can replace err with a plain transport error (e.g. + // "closed network connection") that doesn't itself unwrap to + // context.Canceled even though the cancellation is what caused it. + if ctx.Err() != nil { return compose(), ctx.Err() } return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 0df89aed8..dd8b7c4ee 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -67,6 +67,7 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() chunks := make(chan []byte, 1) + defer close(chunks) chunks <- make([]byte, 320) // The channel stays open: the session is live when the user aborts. errCh := make(chan error, 1) @@ -75,10 +76,14 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { errCh <- ferr }() - <-firstFrame - cancel() - ferr := <-errCh - if !errors.Is(ferr, context.Canceled) { - t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + select { + case <-firstFrame: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index cc463ee3b..e64021326 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -61,6 +60,14 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, }) if err != nil { + // Preserve the context.Canceled sentinel (it carries no key) so an + // Esc-abort during dial still matches the UI's errors.Is check. Key + // on ctx.Err() itself rather than unwrapping err: a cancellation can + // surface as a plain transport error (e.g. "closed network + // connection") rather than context.Canceled directly. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } defer conn.CloseNow() @@ -76,6 +83,11 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, } if err := writeJSON(ctx, conn, sessionUpdate); err != nil { + // Same cancellation short-circuit as the dial above: an Esc-abort + // while the session update is in flight must stay context.Canceled. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } @@ -118,7 +130,11 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks // A user abort cancels the streaming context; the UI matches it // with errors.Is(err, context.Canceled), so return the sentinel // itself (it carries no key) instead of a flat redacted string. - if errors.Is(err, context.Canceled) { + // Key on ctx.Err() rather than unwrapping err: the writeErrCh + // swap above can replace err with a plain transport error (e.g. + // "closed network connection") that doesn't itself unwrap to + // context.Canceled even though the cancellation is what caused it. + if ctx.Err() != nil { return compose(), ctx.Err() } return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) @@ -163,6 +179,8 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case werr := <-writeErrCh: if werr == nil { committed = true + } else if ctx.Err() != nil { + return compose(), ctx.Err() } else { return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey)) } diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 9ac682053..4f394da6b 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -64,6 +64,7 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() chunks := make(chan []byte, 1) + defer close(chunks) chunks <- make([]byte, 480) // The channel stays open: the session is live when the user aborts. errCh := make(chan error, 1) @@ -72,10 +73,14 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { errCh <- ferr }() - <-firstFrame - cancel() - ferr := <-errCh - if !errors.Is(ferr, context.Canceled) { - t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + select { + case <-firstFrame: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } From 3eda0ad69e60afa06b258b1df7a8dc5d310761e7 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:19:01 -0400 Subject: [PATCH 06/11] test(dictation): cover dial and startup cancel identity Add regression tests for Esc-abort during WebSocket dial and right after accept (session update / first write) so those redaction sites keep returning context.Canceled for the TUI errors.Is check. --- .../dictation/transcriber_deepgram_test.go | 63 ++++++++++ .../transcriber_openai_realtime_test.go | 113 ++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index dd8b7c4ee..2d18b6b69 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -87,3 +87,66 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } + +// Esc can cancel while the WebSocket dial is still pending. The dial error is +// redacted with %s, so we must return ctx.Err() rather than a flat string. +func TestDeepgramStreamTranscribeDialCancelKeepsSentinel(t *testing.T) { + tr, err := NewDeepgramTranscriber(DeepgramConfig{ + APIKey: "sk-test-key", + BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + chunks := make(chan []byte) + defer close(chunks) + + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr) + } +} + +// Esc right after accept can race the first write or the first Read; both +// setup/write redaction sites must still yield context.Canceled. +func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { + accepted := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + close(accepted) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + // No frames yet: the writer blocks on chunks or the reader blocks on Read. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-accepted: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before accept: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 4f394da6b..5b2d3b317 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -84,3 +84,116 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } + +// Esc can cancel while the WebSocket dial is still pending. The dial error is +// redacted with %s, so we must return ctx.Err() rather than a flat string. +func TestOpenAIRealtimeStreamTranscribeDialCancelKeepsSentinel(t *testing.T) { + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{ + APIKey: "sk-test-key", + BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + chunks := make(chan []byte) + defer close(chunks) + + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr) + } +} + +// Esc right after accept can hit the session-update write or the first Read; +// both redaction sites must still yield context.Canceled. +func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { + accepted := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + close(accepted) + // Do not read: leave the client in session-update write or first Read. + <-ctx.Done() + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-accepted: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before accept: %v", ferr) + } +} + +// After a server event the client selects writeErrCh. Cancel while the writer +// is blocked on more chunks so that path observes a cancelled write and must +// still return context.Canceled rather than a redacted flat string. +func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { + deltaSent := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Session update, then a delta. Leave the socket open so the client + // stays in the event loop rather than failing on Read first. + if _, _, err := c.Read(ctx); err != nil { + return + } + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, + )) + close(deltaSent) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // One frame so the writer can progress past session setup; channel stays + // open so a later Write is still pending when we cancel. + chunks := make(chan []byte, 1) + defer close(chunks) + chunks <- make([]byte, 480) + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-deltaSent: + // Give the client a moment to process the delta and re-enter Read or + // select writeErrCh, then cancel so the blocked writer fails. + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before delta: %v", ferr) + } +} From 9826b8fa3c25c9e0f8402188888706a6bcff802e Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:58:36 -0400 Subject: [PATCH 07/11] fix(dictation): prefer cancel over a racing OpenAI realtime error Esc can cancel after conn.Read already has an error frame. Check ctx.Err() after partial callbacks and on the realtimeError path so the stream still returns context.Canceled instead of a redacted failure notice. Refs Gitlawb/zero#710 --- .../dictation/transcriber_openai_realtime.go | 17 ++++++++ .../transcriber_openai_realtime_test.go | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index e64021326..64ee56f3a 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -149,6 +149,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks if onPartial != nil { onPartial(compose(), false) } + // onPartial may deliver the partial that makes the TUI cancel + // (Esc). Observe that before the next Read, which can already + // have a racing OpenAI error frame buffered. + if ctx.Err() != nil { + return compose(), ctx.Err() + } case realtimeCompleted: // A completed item replaces the in-progress delta buffer with the // server's authoritative transcript for that segment. @@ -162,6 +168,9 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks if onPartial != nil { onPartial(compose(), true) } + if ctx.Err() != nil { + return compose(), ctx.Err() + } // Once we've committed the buffer (user stopped) and the server has // returned a completed transcription, the utterance is done. if committed { @@ -171,6 +180,14 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case realtimeCommitted: committed = true case realtimeError: + // Esc can race an incoming error event: conn.Read may already have + // the OpenAI error in hand by the time cancellation lands. Key on + // ctx.Err() first, same as every other return point in this loop, + // so a cancel that wins the race still surfaces as context.Canceled + // instead of a spurious redacted failure alongside it. + if ctx.Err() != nil { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey)) } // The writer signals commit completion out of band; observe it so a diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 5b2d3b317..6b9075c3c 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -197,3 +197,45 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed before delta: %v", ferr) } } + +// jatmn (review, PR #710): Esc can race an incoming OpenAI error event. +// conn.Read may already have the error frame in hand by the time the +// cancellation lands. The server fires a delta immediately followed by +// the error, back to back. Cancel from inside the delta callback (same +// path the TUI uses when a partial triggers Esc handling) so the cancel +// is observed before the next event is processed. The returned error +// must still be context.Canceled, not the redacted OpenAI error. +func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + if _, _, err := c.Read(ctx); err != nil { + return + } + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, + )) + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"error","error":{"message":"invalid API key sk-test-key"}}`, + )) + <-ctx.Done() + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + chunks <- make([]byte, 480) + var once sync.Once + _, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) { + // Cancel synchronously from the partial callback, before the + // stream loop continues to the already-buffered error frame. + once.Do(cancel) + }) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("StreamTranscribe error = %v, want context.Canceled (cancel must win over a racing OpenAI error event)", ferr) + } +} From 56c6a738245b63f647dff5fc51145fbb079af314 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:57:57 -0400 Subject: [PATCH 08/11] fix(dictation): prevent auto-submit on cancelled realtime race handleDictationTranscribed suppressed context.Canceled but then fell through to the msg.submit branch. A delta -> Esc -> already-buffered realtime error race returns a nonempty transcript alongside context.Canceled, so with stt.autoSubmit on, Esc could submit the composer's restored pre-existing text instead of doing nothing. Treat a canceled result as terminal before the auto-submit path is reached, and add a TUI-level test covering the race. --- internal/tui/dictation.go | 12 ++++++++++- internal/tui/dictation_test.go | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 3d96d82f6..4e6c5221b 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -392,7 +392,17 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode } m.dictation.reset() - if msg.err != nil && !errors.Is(msg.err, context.Canceled) { + if errors.Is(msg.err, context.Canceled) { + // cancelDictation already discarded the live region, reset the + // session, and posted "Dictation cancelled." A streaming transcriber + // can still race a buffered event past that cancel and report back a + // nonempty compose()+context.Canceled; treat that as terminal here, + // before the auto-submit branch below, so Esc can never fall through + // to msg.submit and fire the composer's restored pre-existing text. + return m, nil + } + + if msg.err != nil { // A cloud auth failure (missing/invalid key) is fixable in place: reopen the // API-key prompt for the current provider so the user can paste a key and // retry, instead of hitting a dead-end "run zero auth" line. diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index 5101cbd64..c6a226407 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -136,6 +136,44 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) { } } +func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) { + // Esc can race an already-buffered realtime event: cancelDictation discards + // the live region and resets state synchronously, but the streaming + // goroutine's dictationTranscribedMsg (a nonempty compose() alongside + // context.Canceled) can still arrive afterward. With stt.autoSubmit on, + // that must never fall through to msg.submit and fire the composer's + // restored pre-existing text. + m := model{} + m.setComposerState(composerState{text: "existing prompt", cursor: len("existing prompt")}) + m.dictation.phase = dictRecording + m.dictation.streaming = true + + m = m.handleDictationPartial(sttPartialMsg{text: "half-formed transcript"}) + if m.composer.text != "existing prompt half-formed transcript" { + t.Fatalf("partial did not render into composer: %q", m.composer.text) + } + + m, _ = m.cancelDictation() + if m.composer.text != "existing prompt" { + t.Fatalf("cancel should restore the pre-existing composer text, got %q", m.composer.text) + } + + // The already-buffered event's message arrives after the cancel above. + next, cmd := m.handleDictationTranscribed(dictationTranscribedMsg{ + text: "half-formed transcript", + err: context.Canceled, + submit: true, + streaming: true, + }) + got := next.(model) + if got.composer.text != "existing prompt" { + t.Errorf("a raced cancellation must not auto-submit the restored composer text, got %q", got.composer.text) + } + if cmd != nil { + t.Error("a raced cancellation must not return a submit command") + } +} + func TestDictationCommitKeepsStreamedText(t *testing.T) { m := model{} m.setComposerState(composerState{text: "", cursor: 0}) From 84f21116623fbd1c1c620e0cf199f8ddb625e660 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:57:19 -0400 Subject: [PATCH 09/11] test(dictation): exercise writeErrCh cancellation branch in realtime transcriber --- .../transcriber_openai_realtime_test.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 6b9075c3c..c15518245 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -150,8 +150,7 @@ func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { deltaSent := make(chan struct{}) url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // Session update, then a delta. Leave the socket open so the client - // stays in the event loop rather than failing on Read first. + // Session update, then a delta. if _, _, err := c.Read(ctx); err != nil { return } @@ -159,11 +158,8 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, )) close(deltaSent) - for { - if _, _, err := c.Read(ctx); err != nil { - return - } - } + // Close socket so the next client Write fails and sends to writeErrCh + _ = c.Close(websocket.StatusAbnormalClosure, "closed") }) tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) @@ -173,9 +169,7 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // One frame so the writer can progress past session setup; channel stays - // open so a later Write is still pending when we cancel. - chunks := make(chan []byte, 1) + chunks := make(chan []byte, 2) defer close(chunks) chunks <- make([]byte, 480) errCh := make(chan error, 1) @@ -186,8 +180,9 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { select { case <-deltaSent: - // Give the client a moment to process the delta and re-enter Read or - // select writeErrCh, then cancel so the blocked writer fails. + // Push another chunk so writer tries to write to the closed socket + chunks <- make([]byte, 480) + // Cancel context so writeErrCh observes ctx.Err() != nil cancel() ferr := <-errCh if !errors.Is(ferr, context.Canceled) { From cffc46db78dea2c45c7411086a6c18588cc17643 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:03:14 -0400 Subject: [PATCH 10/11] fix(dictation): add custom key header redaction and single-pass redaction tests --- .../dictation/transcriber_deepgram_test.go | 44 +++++++++++++++++++ internal/providers/providerio/providerio.go | 11 ++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 2d18b6b69..73998555d 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -150,3 +150,47 @@ func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed before accept: %v", ferr) } } + +func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-custom-secret-key-1234567890", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-custom-secret-key-1234567890") { + t.Errorf("Custom header API key leaked: %v", ferr) + } +} + +func TestDeepgramSinglePassRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-key1234567890abcdef1234", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "[REDACTED:[REDACTED") { + t.Errorf("nested redaction marker created: %v", ferr) + } +} diff --git a/internal/providers/providerio/providerio.go b/internal/providers/providerio/providerio.go index 374269f65..ab967719b 100644 --- a/internal/providers/providerio/providerio.go +++ b/internal/providers/providerio/providerio.go @@ -429,16 +429,17 @@ func looksLikeToken(w string) bool { // Redact removes known API-key and bearer-token forms from provider messages. func Redact(message string, secrets ...string) string { for _, secret := range secrets { - if secret != "" { + if secret != "" && !strings.HasPrefix(secret, "[REDACTED") { message = strings.ReplaceAll(message, secret, "[REDACTED]") } } words := strings.Fields(message) for index := 0; index < len(words)-1; index++ { - // Only redact the word after "Bearer" when it is actually token-shaped, so the - // provider's own help text ("use Bearer authentication", "Bearer token") is no - // longer corrupted into "authorization [REDACTED]". (AUDIT-H7) - if strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") && looksLikeToken(words[index+1]) { + hdr := strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "Token") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "X-Api-Key") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "api-key") + if hdr && !strings.HasPrefix(words[index+1], "[REDACTED") && looksLikeToken(words[index+1]) { words[index+1] = "[REDACTED]" } } From cb9ba5ab8ce9e44edba5ea21ec3dd952fdcea956 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:10:05 -0400 Subject: [PATCH 11/11] fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test --- .../dictation/transcriber_deepgram_test.go | 18 ++++++++++++ .../transcriber_openai_realtime_test.go | 27 ++++++++--------- internal/tui/dictation.go | 14 ++++++--- internal/tui/dictation_stream.go | 13 ++++++--- internal/tui/dictation_test.go | 29 +++++++++++++++++++ 5 files changed, 78 insertions(+), 23 deletions(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 73998555d..09bc3c03c 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -153,6 +153,15 @@ func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed") }) @@ -175,6 +184,15 @@ func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { func TestDeepgramSinglePassRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234") }) diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index c15518245..c46f91287 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -148,18 +148,17 @@ func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) // is blocked on more chunks so that path observes a cancelled write and must // still return context.Canceled rather than a redacted flat string. func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { - deltaSent := make(chan struct{}) + sessionReceived := make(chan struct{}) url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // Session update, then a delta. if _, _, err := c.Read(ctx); err != nil { return } - _ = c.Write(ctx, websocket.MessageText, []byte( - `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, - )) - close(deltaSent) - // Close socket so the next client Write fails and sends to writeErrCh - _ = c.Close(websocket.StatusAbnormalClosure, "closed") + close(sessionReceived) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } }) tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) @@ -169,9 +168,10 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - chunks := make(chan []byte, 2) - defer close(chunks) + chunks := make(chan []byte, 1) chunks <- make([]byte, 480) + defer close(chunks) + errCh := make(chan error, 1) go func() { _, ferr := tr.StreamTranscribe(ctx, chunks, nil) @@ -179,17 +179,14 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { }() select { - case <-deltaSent: - // Push another chunk so writer tries to write to the closed socket - chunks <- make([]byte, 480) - // Cancel context so writeErrCh observes ctx.Err() != nil + case <-sessionReceived: cancel() ferr := <-errCh if !errors.Is(ferr, context.Canceled) { t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr) } case ferr := <-errCh: - t.Fatalf("StreamTranscribe failed before delta: %v", ferr) + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 4e6c5221b..b995ceac7 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -61,6 +61,7 @@ type dictationController struct { browseVariants []dictation.ModelVariant // in-flight session state + sessionID int64 streaming bool recorder Recorder transcriber Transcriber @@ -108,6 +109,7 @@ type dictationStartedMsg struct { // dictationTranscribedMsg carries the final transcript of a batch (or a // completed streaming) recording. type dictationTranscribedMsg struct { + sessionID int64 text string err error submit bool @@ -276,7 +278,7 @@ func (m model) stopDictation() (model, tea.Cmd) { } return m, nil // final text arrives via the streaming command already running } - return m, transcribeBatchCmd(m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled()) + return m, transcribeBatchCmd(m.dictation.sessionID, m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled()) } // cancelDictation aborts an in-flight recording without transcribing. Bound to @@ -311,6 +313,7 @@ func (d dictationController) maxDuration() time.Duration { } func (d *dictationController) reset() { + d.sessionID++ d.phase = dictIdle d.recorder = nil d.transcriber = nil @@ -335,17 +338,17 @@ func startBatchRecordingCmd(rec Recorder) tea.Cmd { // transcribeBatchCmd stops the recording, transcribes the audio, and reports the // final text. Runs off the UI goroutine โ€” Stop waits on the capture tool and // Transcribe does a network round-trip or a local exec. -func transcribeBatchCmd(ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd { +func transcribeBatchCmd(sessionID int64, ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd { if ctx == nil { ctx = context.Background() } return func() tea.Msg { audio, err := rec.Stop() if err != nil { - return dictationTranscribedMsg{err: err} + return dictationTranscribedMsg{sessionID: sessionID, err: err} } text, err := transcriber.Transcribe(ctx, audio) - return dictationTranscribedMsg{text: text, err: err, submit: submit} + return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit} } } @@ -375,6 +378,9 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) // handleDictationTranscribed inserts the final transcript into the composer (or // submits it when stt.autoSubmit is on), then returns to idle. func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Model, tea.Cmd) { + if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + return m, nil + } streaming := m.dictation.streaming || msg.streaming m = m.commitDictationRegion() // A streaming session can end via a transcriber error (not just a user stop), so diff --git a/internal/tui/dictation_stream.go b/internal/tui/dictation_stream.go index 90b74c321..85e73814c 100644 --- a/internal/tui/dictation_stream.go +++ b/internal/tui/dictation_stream.go @@ -14,8 +14,9 @@ import ( // mechanism agent text deltas use (ยง6). text is the cumulative best transcript // so far (not a delta); final marks a settled segment. type sttPartialMsg struct { - text string - final bool + sessionID int64 + text string + final bool } // startStreamingDictation begins continuous capture and launches the streaming @@ -38,6 +39,7 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { } transcriber := m.dictation.transcriber submit := m.dictation.cfg.AutoSubmitEnabled() + sessionID := m.dictation.sessionID // Tap the audio: compute a mic level per chunk for the live waveform, then // forward the chunk to the transcriber. A small buffer keeps the tap from @@ -63,11 +65,11 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { streamCmd := func() tea.Msg { onPartial := func(text string, final bool) { if sink != nil { - sink(sttPartialMsg{text: text, final: final}) + sink(sttPartialMsg{sessionID: sessionID, text: text, final: final}) } } text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial) - return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true} + return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit, streaming: true} } // Streaming drives the waveform from real levels (no synthetic tick needed). return m, streamCmd @@ -77,6 +79,9 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { // composer, replacing the previously-rendered live region wholesale so the text // builds up in place as the user keeps talking. func (m model) handleDictationPartial(msg sttPartialMsg) model { + if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + return m + } // Ignore stragglers that arrive after the session ended (cancel/final). if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing { return m diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index c6a226407..fcfabea9e 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -340,3 +340,32 @@ func TestCurrentModelLabel(t *testing.T) { t.Errorf("no-model label = %q", got) } } + +func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) { + m := model{} + m.setComposerState(composerState{text: "original composer text", cursor: 22}) + m.dictation.sessionID = 1 + m.dictation.phase = dictRecording + + m, _ = m.cancelDictation() + + if m.composer.text != "original composer text" { + t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text) + } + + staleMsg := dictationTranscribedMsg{ + sessionID: 1, + text: "stale text that should be ignored", + submit: true, + streaming: true, + } + afterStale, cmd := m.handleDictationTranscribed(staleMsg) + got := afterStale.(model) + + if got.composer.text != "original composer text" { + t.Fatalf("composer text changed to %q after stale completion", got.composer.text) + } + if cmd != nil { + t.Fatalf("expected no command for stale completion, got %v", cmd) + } +}