Export document reading with Remote lease safeguards

This commit is contained in:
ak47 committed 2026-11-04 19:24:58 -05:00
1 parent 228271b43b
commit 622d2f96a1
11 files changed
+299 -17

No files matched your search

+7 -2
View File
@@ -220,7 +220,7 @@ func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, resu
}
text = "blank"
}
request := speechRequest{Text: text, Role: result.DocumentPosition.Snapshot.Role, Cause: "document-caret"}
request := speechRequest{origin: document_speech_origin(result.DocumentPosition.Snapshot, result.DocumentCaretContext), Text: text, Role: result.DocumentPosition.Snapshot.Role, Cause: "document-caret"}
request.PhoneticHint = result.DocumentCaretCommand == review.CaretNextCharacter || result.DocumentCaretCommand == review.CaretPreviousCharacter
request.Context = result.DocumentCaretContext
if len(request.Context) > 0 && request.Context[0].ID() == result.DocumentPosition.Snapshot.ID() {
@@ -333,6 +333,7 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
"braille_read_error", result.DocumentBrailleErr)
}
text, fallbackText, role := "", "", "system"
origin := speechOriginUnknown
var spoken, fallbackSpeech presentation.Speech
var cues []sound.Cue
switch {
@@ -393,6 +394,10 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
text = "No more large objects."
}
default:
origin = document_speech_origin(result.DocumentPosition.Snapshot, result.DocumentCaretContext)
if link := result.DocumentPosition.LinkedHeading; link != nil && accessibility_speech_origin(*link) == speechOriginSecret {
origin = speechOriginSecret
}
if link := result.DocumentPosition.LinkedHeading; link != nil {
roleOnly := *link
roleOnly.Name, roleOnly.Text = "", ""
@@ -462,5 +467,5 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
if len(ancestors) > 0 && ancestors[0].ID() == result.DocumentPosition.Snapshot.ID() {
ancestors = ancestors[1:]
}
speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: role, Sound: cues, Cause: "document-navigation", Context: ancestors, VoiceType: voiceType})
speech.Enqueue(speechRequest{origin: origin, Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: role, Sound: cues, Cause: "document-navigation", Context: ancestors, VoiceType: voiceType})
}
+1 -1
View File
@@ -144,7 +144,7 @@ func (handler *eventReviewHandler) handle(result review.Result) {
if text == "" && len(cues) == 0 {
return
}
speech.Enqueue(speechRequest{Text: text, Chunks: spoken.Chunks, FallbackText: fallback.Text, FallbackChunks: fallback.Chunks, Sound: cues, Role: "text", Cause: "document-caret-line", Line: document_speech_line(result.DocumentLineData), Context: result.DocumentLineData.Context, VoiceType: document_line_voice(result.DocumentLineData)})
speech.Enqueue(speechRequest{origin: document_line_speech_origin(result.DocumentLineData), Text: text, Chunks: spoken.Chunks, FallbackText: fallback.Text, FallbackChunks: fallback.Chunks, Sound: cues, Role: "text", Cause: "document-caret-line", Line: document_speech_line(result.DocumentLineData), Context: result.DocumentLineData.Context, VoiceType: document_line_voice(result.DocumentLineData)})
return
}
if result.DocumentTarget != 0 || result.DocumentCaretCommand != 0 || result.DocumentFragment {
+9 -1
View File
@@ -19,11 +19,13 @@ func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result rev
return
}
text := ""
origin := speechOriginUnknown
var parts []readAllPart
if result.Err != nil {
logger.Warn("read all failed", "error", result.Err)
text = "Could not read from current location."
} else {
origin = speechOriginLocal
// Keep semantic roles audible in continuous reading, without firing all
// the page's sound cues before the speech has reached those objects.
options.RoleSoundPresentation = "sound_and_speech"
@@ -38,6 +40,12 @@ func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result rev
options.ReadFullRowInGUITable = false
for index, line := range result.ReadAllLines {
if spoken := presentation.DocumentLineSpeechWithOptions(line, options); spoken.Text != "" {
// One request owns the original observation ticket. Conservatively
// keep the entire reading private if any spoken line is sensitive
// or lacks source metadata, including sentence-mode continuations.
if source := document_line_speech_origin(line); source != speechOriginLocal {
origin = source
}
var began func()
if result.ReadAllProgress != nil {
began = func() { result.ReadAllProgress(index) }
@@ -61,7 +69,7 @@ func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result rev
}
}
if text != "" || len(parts) != 0 {
speech.Enqueue(speechRequest{Text: text, ReadAll: parts, Role: "system", Cause: "read-all", context: result.ReadAllContext})
speech.Enqueue(speechRequest{origin: origin, Text: text, ReadAll: parts, Role: "system", Cause: "read-all", context: result.ReadAllContext})
}
}
+1
View File
@@ -93,6 +93,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
delivery := request
delivery.VoiceType, delivery.CustomEcho = part.VoiceType, part.VoiceType == "echo"
worker.logDelivery(ctx, delivery, text, index)
worker.observe_speech(ctx, delivery, text, false)
if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" {
worker.history.add(text, request.Role)
}
@@ -0,0 +1,163 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
func document_observation_request(t *testing.T, kind string, result review.Result) speechRequest {
t.Helper()
queue := &fakeSpeechQueue{requests: make(chan speechRequest, 1), spoken: make(chan string, 1)}
logger := slog.New(slog.DiscardHandler)
options := presentation.DefaultOptions()
options.SayAllStyle = "sentence"
switch kind {
case "caret":
presentDocumentCaretResult(logger, queue, result, options)
case "navigation":
presentDocumentResult(logger, queue, result, options)
case "read-all":
presentReadAllResult(logger, queue, result, options)
}
select {
case request := <-queue.requests:
return request
default:
t.Fatal("document producer did not enqueue speech")
return speechRequest{}
}
}
func TestRemoteDocumentSpeechSourcePrivacy(t *testing.T) {
for _, kind := range []string{"caret", "navigation", "read-all"} {
for _, source := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor"} {
t.Run(kind+"/"+source, func(t *testing.T) {
snapshot := atspi.Snapshot{Path: "/text", Role: "text", Name: "Reading. Next sentence."}
ancestors := []atspi.Snapshot{}
switch source {
case "password":
snapshot.Role = "password text"
case "ancestor":
// Same identity is removed from caret/navigation context.
ancestors = []atspi.Snapshot{{Path: "/text", Role: "password text"}}
case "snapshot-ancestor":
snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}}
}
result := review.Result{Moved: true, DocumentCaretText: "Reading. Next sentence.",
DocumentCaretCommand: review.CaretNextLine, DocumentCaretContext: ancestors,
DocumentPosition: review.DocumentPosition{Snapshot: snapshot}, ReadAllContext: t.Context(),
ReadAllLines: []atspi.DocumentLine{{Context: ancestors, Contents: []atspi.DocumentContent{{Snapshot: snapshot, Text: "Reading. Next sentence."}}}}}
request := document_observation_request(t, kind, result)
if exportable_speech(request) != (source == "ordinary") {
t.Fatalf("source %s produced exportable=%v", source, exportable_speech(request))
}
})
}
}
for _, line := range []atspi.DocumentLine{
{},
{Contents: []atspi.DocumentContent{{Text: "text without source metadata"}}},
{Contents: []atspi.DocumentContent{{Snapshot: atspi.Snapshot{Role: "text"}}, {Snapshot: atspi.Snapshot{Role: "password text"}}}},
} {
if document_line_speech_origin(line) == speechOriginLocal {
t.Fatal("unclassified or mixed private line was exportable")
}
}
for _, kind := range []string{"caret", "navigation", "read-all"} {
request := document_observation_request(t, kind, review.Result{Err: errors.New("fixture failure"), ReadAllContext: t.Context()})
if exportable_speech(request) {
t.Fatal("local failure status was exported", kind)
}
}
}
func (speaker *observationSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
if began != nil {
began()
}
return speaker.Speak(ctx, text)
}
func TestRemoteReadAllObservationResolvedChunksAndPrivateBatch(t *testing.T) {
worker, session, _, _ := observation_fixture(t)
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: atspi.Snapshot{Role: "text"}, Text: "First colour. Second colour."}}}
request := document_observation_request(t, "read-all", review.Result{ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line}})
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
t.Fatal(err)
}
for _, want := range []string{"First color.", "Second color."} {
value := observed_speech(t, session)
if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || !value.Parts[1].End {
t.Fatal("Say All did not export resolved sentence boundaries", value)
}
}
private := line
private.Context = []atspi.Snapshot{{Role: "password text"}}
request = document_observation_request(t, "read-all", review.Result{ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line, private, line}})
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
t.Fatal(err)
}
worker.InterruptForUser()
if value := observed_speech(t, session); value.Kind != "cancel" {
t.Fatal("mixed private Say All batch was exported", value)
}
}
func TestRemoteReadAllObservationKeepsOriginalLease(t *testing.T) {
worker, session, _, _ := observation_fixture(t)
speaker := &continuousSpeaker{started: make(chan string, 2), finish: make(chan struct{}, 2)}
worker.speaker = speaker // No requests have been admitted yet.
worker.Enqueue(speechRequest{origin: speechOriginLocal, ReadAll: []readAllPart{{Text: "First"}, {Text: "Second"}}})
select {
case <-speaker.started:
case <-time.After(time.Second):
t.Fatal("Say All did not start")
}
if value := observed_speech(t, session); value.Parts[0].Text != "First" {
t.Fatal("first line missing", value)
}
session.RevokeRemote()
var event tentaclewire.SessionEvent
if json.Unmarshal(observation_call(t, session, "event"), &event) != nil || event.Kind != "remote.revoked" {
t.Fatal("missing revocation")
}
approval, err := session.BeginRemoteApproval("controlled", []string{"output-observe"})
if err != nil {
t.Fatal(err)
}
if _, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}); err != nil {
t.Fatal(err)
}
observation_call(t, session, "open")
speaker.finish <- struct{}{}
select {
case text := <-speaker.started:
if text != "Second" {
t.Fatal("local reading changed", text)
}
case <-time.After(time.Second):
t.Fatal("local reading stalled across reconnection")
}
speaker.finish <- struct{}{}
if err := worker.EnqueueAndWait(t.Context(), speechRequest{}); err != nil {
t.Fatal(err)
}
worker.InterruptForUser()
if value := observed_speech(t, session); value.Kind != "cancel" {
t.Fatal("old Say All continued under replacement consent", value)
}
}
+30
View File
@@ -36,6 +36,36 @@ func accessibility_speech_origin(source atspi.Snapshot) speechOrigin {
return speechOriginLocal
}
// Document producers may remove the current object from their presentation
// context. Inspect the original sources before that filtering, including every
// content object in an assembled line. A mixed sensitive line stays private.
func document_speech_origin(source atspi.Snapshot, ancestors []atspi.Snapshot) speechOrigin {
if accessibility_speech_origin(source) == speechOriginSecret {
return speechOriginSecret
}
for _, ancestor := range ancestors {
if accessibility_speech_origin(ancestor) == speechOriginSecret {
return speechOriginSecret
}
}
if source.Role == "" {
return speechOriginUnknown
}
return speechOriginLocal
}
func document_line_speech_origin(line atspi.DocumentLine) speechOrigin {
if len(line.Contents) == 0 {
return speechOriginUnknown
}
for _, content := range line.Contents {
if origin := document_speech_origin(content.Snapshot, line.Context); origin != speechOriginLocal {
return origin
}
}
return speechOriginLocal
}
func exportable_speech(request speechRequest) bool {
if request.origin != speechOriginLocal || request.Role == "password text" ||
request.ExcludeFromHistory || request.Confirmation || request.Uninterruptible || request.ForceSpeech {
@@ -22,8 +22,10 @@ import (
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/review"
"git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
@@ -308,7 +310,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
runtime, forms, shortcut := remote_command_fixture(t)
if role == "controlled" {
worker := runtime.output.speech
worker.speaker = &fakeSpeaker{spoken: make(chan string, 16)}
worker.speaker = &observationSpeaker{fakeSpeaker{spoken: make(chan string, 16)}}
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
if err := worker.Start(t.Context()); err != nil {
t.Fatal(err)
@@ -438,6 +440,14 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Fatal(err)
}
}
request := document_observation_request(t, "read-all", review.Result{
ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{{Contents: []atspi.DocumentContent{{
Snapshot: atspi.Snapshot{Role: "text"}, Text: "First colour. Second colour.",
}}}},
})
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
t.Fatal(err)
}
worker.InterruptForUser()
var received strings.Builder
ends, frames := 0, 0
@@ -473,7 +483,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Fatal("reader output did not reach the relay in order")
}
}
if received.String() != "color "+remoteText || frames < 2 || ends != 1 {
if received.String() != "color "+remoteText+"First color.Second color." || frames < 4 || ends != 3 {
t.Fatal("observation leaked private text or lost resolved output/boundaries")
}
}