Observe authorized local speech for Remote sessions
This commit is contained in:
1 parent
9bc74dbb13
commit
b60cf5d5f2
12 files changed
+927
-12
No files matched your search
@@ -235,6 +235,7 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
|
||||
browseContext = len(context) != 0
|
||||
}
|
||||
speech.Enqueue(speechRequest{
|
||||
origin: accessibility_speech_origin(event.Source),
|
||||
Context: context, ResetContext: !browseContext, VoiceType: ancestor_voice(event.Source.SpeechContext),
|
||||
Window: eventWindow(event),
|
||||
IntroducesWindow: (event.Type == atspi.WindowActivatedEvent || event.Type == atspi.WindowCreateEvent) &&
|
||||
|
||||
@@ -57,6 +57,7 @@ func (worker *speechWorker) deliver_phonetic_hint(ctx context.Context, request s
|
||||
return true, nil
|
||||
}
|
||||
hint := speechRequest{
|
||||
origin: request.origin, observation: request.observation,
|
||||
// The character already presented its capital cues. This hint only
|
||||
// clarifies its identity, using the normal voice even in links or Wine.
|
||||
Text: phoneticLetter(letter), VoiceType: "default", Role: request.Role, Cause: "phonetic-character-hint", Append: true,
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
@@ -137,6 +138,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
|
||||
return reader.SpeakAndWait(ctx, text, nil)
|
||||
}
|
||||
speak := worker.speaker.Speak
|
||||
characters := false
|
||||
kind := request.VoiceType
|
||||
if kind == "" {
|
||||
switch request.Role {
|
||||
@@ -156,6 +158,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
|
||||
SpeakCharacter(context.Context, string) error
|
||||
}); ok {
|
||||
speak = character.SpeakCharacter
|
||||
characters = true
|
||||
}
|
||||
}
|
||||
if request.CustomEcho && request.Character {
|
||||
@@ -163,6 +166,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
|
||||
SpeakEchoCharacter(context.Context, string) error
|
||||
}); ok {
|
||||
speak = echo.SpeakEchoCharacter
|
||||
characters = true
|
||||
}
|
||||
} else if request.CustomEcho {
|
||||
if echo, ok := worker.speaker.(interface {
|
||||
@@ -188,6 +192,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
|
||||
speak = func(ctx context.Context, text string) error {
|
||||
return voiced.SpeakVoiceAndWait(ctx, text, kind, nil)
|
||||
}
|
||||
characters = false // This path submits resolved text, not a CHAR command.
|
||||
}
|
||||
}
|
||||
chunks := request.Chunks
|
||||
@@ -223,6 +228,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
|
||||
part := request
|
||||
part.Append = request.Append || index > 0
|
||||
worker.record_delivery(ctx, part, text)
|
||||
worker.observe_speech(ctx, part, text, characters && utf8.RuneCountInString(text) == 1 && !strings.ContainsAny(text, "\r\n\x00"))
|
||||
if err := speak(ctx, text); err != nil {
|
||||
return fmt.Errorf("speak: %w", err)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ func (worker *speechWorker) interruptLocked() {
|
||||
worker.pending = nil
|
||||
worker.generation++
|
||||
worker.interrupting = true
|
||||
worker.observe_cancel_locked()
|
||||
|
||||
select {
|
||||
case worker.interrupt <- struct{}{}:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
// Unclassified output is private. Origins are core metadata, never inferred
|
||||
// from network text, a Tentacle's request, or a user-visible cause string.
|
||||
type speechOrigin uint8
|
||||
|
||||
const (
|
||||
speechOriginUnknown speechOrigin = iota
|
||||
speechOriginLocal
|
||||
speechOriginRemote
|
||||
speechOriginStatus
|
||||
speechOriginSecret
|
||||
)
|
||||
|
||||
func accessibility_speech_origin(source atspi.Snapshot) speechOrigin {
|
||||
if source.Role == "password text" {
|
||||
return speechOriginSecret
|
||||
}
|
||||
// Presentation may filter/reset context. Classify the original accessible
|
||||
// ancestry before doing that, even when its labels will not be spoken.
|
||||
for _, ancestor := range source.SpeechContext {
|
||||
if ancestor.Role == "password text" {
|
||||
return speechOriginSecret
|
||||
}
|
||||
}
|
||||
return speechOriginLocal
|
||||
}
|
||||
|
||||
func exportable_speech(request speechRequest) bool {
|
||||
if request.origin != speechOriginLocal || request.Role == "password text" ||
|
||||
request.ExcludeFromHistory || request.Confirmation || request.Uninterruptible || request.ForceSpeech {
|
||||
return false
|
||||
}
|
||||
for _, ancestor := range request.Context {
|
||||
if ancestor.Role == "password text" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (worker *speechWorker) observe_remote_output(manager *tentacle.Manager) {
|
||||
worker.mutex.Lock()
|
||||
defer worker.mutex.Unlock()
|
||||
worker.remoteOutput = manager.CaptureRemoteSpeechOutput
|
||||
}
|
||||
|
||||
// Serialize observation with user interruption. No event may be published after
|
||||
// that interruption's cancel using an older delivery generation. This performs
|
||||
// bounded IPC queue admission only, never pipe, relay or backend I/O.
|
||||
func (worker *speechWorker) observe_speech(ctx context.Context, request speechRequest, text string, characters bool) {
|
||||
if request.observation == nil || !exportable_speech(request) || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
worker.mutex.Lock()
|
||||
defer worker.mutex.Unlock()
|
||||
if !worker.running || !worker.enabled || worker.secure || worker.generation != request.deliveryGeneration ||
|
||||
worker.mode != speechModeSpeech || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Failures retire only the remote stream. Local speech remains available.
|
||||
_ = request.observation.PublishText(text, characters)
|
||||
}
|
||||
|
||||
func (worker *speechWorker) observe_cancel_locked() {
|
||||
if worker.remoteOutput != nil && !worker.secure {
|
||||
if output := worker.remoteOutput(); output != nil {
|
||||
_ = output.Cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle"
|
||||
)
|
||||
|
||||
func TestRemoteSpeechObservationPeer(t *testing.T) {
|
||||
if os.Args[len(os.Args)-1] != "remote-observation-peer" {
|
||||
return
|
||||
}
|
||||
client := sdk.NewSessionClient()
|
||||
next := func(ctx context.Context) (sdk.SessionEvent, error) {
|
||||
select {
|
||||
case event := <-client.Events():
|
||||
return event, nil
|
||||
case <-ctx.Done():
|
||||
return sdk.SessionEvent{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
commands := []sdk.Command{
|
||||
{ID: "open", Title: "Open", Handle: func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
event, err := next(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lease sdk.RemoteLease
|
||||
err = client.Call(ctx, "remote.open", sdk.RemoteOpen{Action: event.Action, Epoch: event.Epoch, Role: event.Role, Grants: event.Grants}, &lease)
|
||||
return lease, err
|
||||
}},
|
||||
{ID: "event", Title: "Event", Handle: func(ctx context.Context, _ json.RawMessage) (any, error) { return next(ctx) }},
|
||||
}
|
||||
if err := sdk.ServeSession(os.Stdin, os.Stdout, commands, nil, client); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
type observationSpeaker struct{ fakeSpeaker }
|
||||
|
||||
func (speaker *observationSpeaker) SpeakCharacter(ctx context.Context, text string) error {
|
||||
return speaker.Speak(ctx, text)
|
||||
}
|
||||
|
||||
func observation_fixture(t *testing.T) (*speechWorker, *tentacle.Session, *tentacle.Manager, *tentacle.RemotePolicy) {
|
||||
t.Helper()
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := tentacle.NewManager(tentacle.DefaultLimits(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := manager.Shutdown(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
policy := tentacle.NewRemotePolicy()
|
||||
if err := manager.UseRemotePolicy(policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := manager.Start(t.Context(), tentacle.Spec{ID: "observation-probe",
|
||||
Program: []string{executable, "-test.run=^TestRemoteSpeechObservationPeer$", "--", "remote-observation-peer"},
|
||||
SessionEvents: true, RemoteGrants: []string{"output-observe"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-session.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("observation peer did not become ready")
|
||||
}
|
||||
if err := session.Activate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 := &observationSpeaker{fakeSpeaker{spoken: make(chan string, 64)}}
|
||||
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
||||
worker.observe_remote_output(manager)
|
||||
if err := worker.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := worker.Stop(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return worker, session, manager, policy
|
||||
}
|
||||
|
||||
func observation_call(t *testing.T, session *tentacle.Session, command string) json.RawMessage {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
result, err := session.Invoke(ctx, command, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case value := <-result:
|
||||
if value.Err != nil {
|
||||
t.Fatal(value.Err)
|
||||
}
|
||||
return value.Value
|
||||
case <-ctx.Done():
|
||||
t.Fatal("observation command timed out")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func observed_speech(t *testing.T, session *tentacle.Session) tentaclewire.RemoteSpeech {
|
||||
t.Helper()
|
||||
var event tentaclewire.SessionEvent
|
||||
if err := json.Unmarshal(observation_call(t, session, "event"), &event); err != nil || event.Kind != "remote.output" || event.Speech == nil {
|
||||
t.Fatal("expected observed speech event", event.Kind, err)
|
||||
}
|
||||
return *event.Speech
|
||||
}
|
||||
|
||||
func TestRemoteSpeechObservationResolvedOutputAndPrivateOrigins(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
||||
for _, request := range []speechRequest{
|
||||
{Text: "colour", origin: speechOriginLocal},
|
||||
{Text: "A", origin: speechOriginLocal, Character: true},
|
||||
{Text: "ignored parent", Chunks: []string{"one", "two"}, origin: speechOriginLocal},
|
||||
{Spelling: []string{"a", "b"}, Character: true, origin: speechOriginLocal},
|
||||
} {
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for index, text := range []string{"color", "A", "one", "two", "a", "b"} {
|
||||
speech := observed_speech(t, session)
|
||||
if speech.Kind != "speak" || len(speech.Parts) != 2 || speech.Parts[0].Text != text ||
|
||||
speech.Parts[0].Characters != (index == 1 || index >= 4) || !speech.Parts[1].End {
|
||||
t.Fatal("observation preceded policy/character resolution", speech)
|
||||
}
|
||||
}
|
||||
for _, request := range []speechRequest{
|
||||
{Text: "unclassified"},
|
||||
{Text: "local status", origin: speechOriginStatus},
|
||||
{Text: "remote text", origin: speechOriginRemote},
|
||||
{Text: "secret", origin: speechOriginSecret},
|
||||
{Text: "password", Role: "password text", origin: speechOriginLocal},
|
||||
{Text: "private ancestor", Context: []atspi.Snapshot{{Role: "password text"}}, origin: speechOriginLocal},
|
||||
{Text: "history", ExcludeFromHistory: true, origin: speechOriginLocal},
|
||||
{Text: "mode announcement", ForceSpeech: true, origin: speechOriginLocal},
|
||||
{Text: "form value", Cause: "internal-ui"},
|
||||
} {
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
if speech := observed_speech(t, session); speech.Kind != "cancel" {
|
||||
t.Fatal("exported private output before cancellation", speech)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechObservationAccessibilityPresenter(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
state := &eventLoopState{}
|
||||
state.presentationOptions = presentation.DefaultOptions()
|
||||
presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: worker}
|
||||
presenter.speak(atspi.Event{Type: atspi.CaretEvent,
|
||||
Source: atspi.Snapshot{Path: "/entry", Role: "entry", CaretCharacter: "a"}})
|
||||
speech := observed_speech(t, session)
|
||||
if speech.Kind != "speak" || speech.Parts[0].Text != "a" || !speech.Parts[0].Characters {
|
||||
t.Fatal("ordinary accessibility speech did not reach observation", speech)
|
||||
}
|
||||
// Context presentation may discard ancestors outside browse mode. Privacy
|
||||
// classification must still inspect the original accessible ancestry.
|
||||
presenter.speak(atspi.Event{Type: "focus:", Source: atspi.Snapshot{
|
||||
Path: "/nested-entry", Role: "entry", Name: "private descendant",
|
||||
SpeechContext: []atspi.Snapshot{{Role: "password text"}},
|
||||
}})
|
||||
if err := worker.EnqueueAndWait(t.Context(), speechRequest{Text: "private completion barrier"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
if speech := observed_speech(t, session); speech.Kind != "cancel" {
|
||||
t.Fatal("filtered presentation context hid a private ancestor", speech)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechObservationModesAndGeneration(t *testing.T) {
|
||||
worker, session, manager, _ := observation_fixture(t)
|
||||
for _, mode := range []speechMode{speechModeOff, speechModeBeeps} {
|
||||
worker.mutex.Lock()
|
||||
worker.mode = mode
|
||||
worker.beep = func(context.Context) error { return nil }
|
||||
worker.mutex.Unlock()
|
||||
if err := worker.EnqueueAndWait(t.Context(), speechRequest{Text: "suppressed", origin: speechOriginLocal}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
worker.mutex.Lock()
|
||||
worker.mode = speechModeSpeech
|
||||
old := speechRequest{origin: speechOriginLocal, observation: manager.CaptureRemoteSpeechOutput(), deliveryGeneration: worker.generation}
|
||||
worker.mutex.Unlock()
|
||||
worker.InterruptForUser()
|
||||
worker.observe_speech(t.Context(), old, "stale generation", false)
|
||||
if speech := observed_speech(t, session); speech.Kind != "cancel" {
|
||||
t.Fatal("exported suppressed speech", speech)
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
if speech := observed_speech(t, session); speech.Kind != "cancel" {
|
||||
t.Fatal("older generation overtook cancellation", speech)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechObservationRevokedBeforeDelivery(t *testing.T) {
|
||||
for _, change := range []string{"revoke", "lock", "handoff"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
worker, session, manager, policy := observation_fixture(t)
|
||||
request := speechRequest{origin: speechOriginLocal, observation: manager.CaptureRemoteSpeechOutput()}
|
||||
if request.observation == nil {
|
||||
t.Fatal("no observation ticket")
|
||||
}
|
||||
switch change {
|
||||
case "revoke":
|
||||
session.RevokeRemote()
|
||||
case "lock":
|
||||
gate := &focusGate{remotePolicy: policy}
|
||||
gate.SetLocked(true, nil, nil, nil, worker, &brailleWorker{})
|
||||
gate.SetLocked(false, nil, nil, nil, worker, &brailleWorker{})
|
||||
case "handoff":
|
||||
gate := &focusGate{remotePolicy: policy}
|
||||
gate.SetSuspended(true)
|
||||
gate.SetSuspended(false)
|
||||
}
|
||||
worker.observe_speech(t.Context(), request, "retired", false)
|
||||
var event tentaclewire.SessionEvent
|
||||
if err := json.Unmarshal(observation_call(t, session, "event"), &event); err != nil || event.Kind != "remote.revoked" {
|
||||
t.Fatal("retirement exported text", event.Kind, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type observationBarrierSpeaker struct {
|
||||
fakeSpeaker
|
||||
began chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (speaker *observationBarrierSpeaker) Speak(ctx context.Context, text string) error {
|
||||
if text == "old active" {
|
||||
close(speaker.began)
|
||||
select {
|
||||
case <-speaker.release:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return speaker.fakeSpeaker.Speak(ctx, text)
|
||||
}
|
||||
|
||||
func TestRemoteSpeechObservationCancellationBarrier(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
speaker := &observationBarrierSpeaker{fakeSpeaker: fakeSpeaker{spoken: make(chan string, 4)},
|
||||
began: make(chan struct{}), release: make(chan struct{})}
|
||||
worker.speaker = speaker // No requests have reached this worker yet.
|
||||
worker.Enqueue(speechRequest{Text: "old active", origin: speechOriginLocal})
|
||||
select {
|
||||
case <-speaker.began:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backend did not block")
|
||||
}
|
||||
if speech := observed_speech(t, session); speech.Parts[0].Text != "old active" {
|
||||
t.Fatal("missing first attempt")
|
||||
}
|
||||
worker.Enqueue(speechRequest{Text: "old pending", origin: speechOriginLocal})
|
||||
worker.InterruptForUser()
|
||||
if speech := observed_speech(t, session); speech.Kind != "cancel" {
|
||||
t.Fatal("cancel overtaken by pending output", speech)
|
||||
}
|
||||
close(speaker.release)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||
defer cancel()
|
||||
if err := worker.EnqueueAndWait(ctx, speechRequest{Text: "new generation", origin: speechOriginLocal}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if speech := observed_speech(t, session); speech.Parts[0].Text != "new generation" {
|
||||
t.Fatal("old pending speech crossed interruption", speech)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechObservationQueuedAuthorityDoesNotFollowReconnect(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
speaker := &observationBarrierSpeaker{fakeSpeaker: fakeSpeaker{spoken: make(chan string, 8)},
|
||||
began: make(chan struct{}), release: make(chan struct{})}
|
||||
worker.speaker = speaker
|
||||
// Hold delivery while output with three distinct authority lifetimes enters
|
||||
// the real queue. Neither the old lease nor no lease may follow reconnect.
|
||||
worker.Enqueue(speechRequest{Text: "old active"})
|
||||
select {
|
||||
case <-speaker.began:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backend did not block")
|
||||
}
|
||||
defer func() {
|
||||
select {
|
||||
case <-speaker.release:
|
||||
default:
|
||||
close(speaker.release)
|
||||
}
|
||||
}()
|
||||
worker.Enqueue(speechRequest{Text: "old consent", origin: speechOriginLocal})
|
||||
session.RevokeRemote()
|
||||
var event tentaclewire.SessionEvent
|
||||
if err := json.Unmarshal(observation_call(t, session, "event"), &event); err != nil || event.Kind != "remote.revoked" {
|
||||
t.Fatal("expected old lease revocation", err)
|
||||
}
|
||||
worker.Enqueue(speechRequest{Text: "before consent", origin: speechOriginLocal})
|
||||
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")
|
||||
worker.Enqueue(speechRequest{Text: "new consent", origin: speechOriginLocal})
|
||||
close(speaker.release)
|
||||
if speech := observed_speech(t, session); speech.Kind != "speak" || speech.Parts[0].Text != "new consent" {
|
||||
t.Fatal("queued output acquired replacement consent", speech)
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
if speech := observed_speech(t, session); speech.Kind != "cancel" {
|
||||
t.Fatal("unexpected output after replacement delivery", speech)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechObservationOverflowPreservesLocalSpeech(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
// Exceed the complete utterance's frame budget. Remote must retire without
|
||||
// making local speech depend on a healthy child or exposing a prefix.
|
||||
text := strings.Repeat("x", 768*tentaclewire.RemoteSpeechQueueSize+1)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||
defer cancel()
|
||||
if err := worker.EnqueueAndWait(ctx, speechRequest{Text: text, origin: speechOriginLocal}); err != nil {
|
||||
t.Fatal("remote overflow interrupted local delivery", err)
|
||||
}
|
||||
if state, err := session.Status(); state != tentacle.Failed || !errors.Is(err, tentacle.ErrLimit) {
|
||||
t.Fatal("remote overflow retained a healthy session", state, err)
|
||||
}
|
||||
if err := worker.EnqueueAndWait(ctx, speechRequest{Text: "local reading continues", origin: speechOriginLocal}); err != nil {
|
||||
t.Fatal("retired remote session blocked local reading", err)
|
||||
}
|
||||
speaker := worker.speaker.(*observationSpeaker)
|
||||
for _, expected := range []string{text, "local reading continues"} {
|
||||
select {
|
||||
case actual := <-speaker.spoken:
|
||||
if actual != expected {
|
||||
t.Fatal("remote failure changed local speech")
|
||||
}
|
||||
default:
|
||||
t.Fatal("missing local speech after remote overflow")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/sound"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
// speechQueueCapacity bounds work retained while the speech backend is slow.
|
||||
@@ -38,6 +39,9 @@ import (
|
||||
const speechQueueCapacity = 64
|
||||
|
||||
type speechRequest struct {
|
||||
origin speechOrigin
|
||||
observation *tentacle.RemoteSpeechOutput // Captured at admission, never refreshed during delivery.
|
||||
|
||||
presentationGeneration *uint64 // Checked under the speech owner mutex.
|
||||
presentationRevision *uint64
|
||||
|
||||
@@ -86,6 +90,7 @@ type readAllPart struct {
|
||||
// have separate goroutines and joins; activeDone and generation form their
|
||||
// barrier so cancellation cannot overtake newly accepted output.
|
||||
type speechWorker struct {
|
||||
remoteOutput func() *tentacle.RemoteSpeechOutput // Owned under mutex; no runtime/output locks in callback.
|
||||
logger *slog.Logger
|
||||
speaker Speaker
|
||||
history *speechHistory
|
||||
@@ -197,6 +202,10 @@ func (worker *speechWorker) Enqueue(request speechRequest) {
|
||||
completeSpeechRequest(request, errors.New("speech worker is not running"))
|
||||
return
|
||||
}
|
||||
request.observation = nil
|
||||
if worker.remoteOutput != nil && exportable_speech(request) && !worker.secure {
|
||||
request.observation = worker.remoteOutput()
|
||||
}
|
||||
request.Append = true
|
||||
// A Tentacle ticket gates admission, not the lifetime of accepted speech.
|
||||
// Passive focus changes can retire the ticket while this output finishes.
|
||||
|
||||
@@ -240,6 +240,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
}
|
||||
defer listener.Close()
|
||||
probed, joined, closed := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
relayOutput := make(chan []byte, 64)
|
||||
go func() {
|
||||
probe, err := listener.Accept()
|
||||
if err != nil {
|
||||
@@ -289,12 +290,33 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
}})
|
||||
}
|
||||
close(joined)
|
||||
io.Copy(io.Discard, reader)
|
||||
for {
|
||||
frame, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
relayOutput <- frame
|
||||
}
|
||||
close(closed)
|
||||
}()
|
||||
runtime, forms, _ := remote_command_fixture(t)
|
||||
// Only this fixture supplies a reader owner. Production remains
|
||||
// disabled until output arbitration/security policy is implemented.
|
||||
if role == "controlled" {
|
||||
worker := runtime.output.speech
|
||||
worker.speaker = &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)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := worker.Stop(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Incoming speech still needs a test owner; production observation
|
||||
// is attached through makeManager on the controlled side.
|
||||
var deliveries <-chan tentacle.RemoteSpeechDelivery
|
||||
if role == "controller" {
|
||||
runtime.mutex.Lock()
|
||||
@@ -367,6 +389,71 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
delivery.Release()
|
||||
} else {
|
||||
// Joining at the relay is earlier than the Tentacle admitting the
|
||||
// peer. Poll status before testing actual output rather than
|
||||
// depending on a scheduling delay or replaying pre-peer output.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
encoded, _ := json.Marshal(status.form)
|
||||
if strings.Contains(string(encoded), "One opposite-role peer has joined") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("peer did not become ready")
|
||||
}
|
||||
status.reply <- internalUIResult{Activated: "refresh"}
|
||||
status = next_remote_form(t, forms)
|
||||
}
|
||||
worker := runtime.output.speech
|
||||
for _, request := range []speechRequest{
|
||||
{Text: "private form", Cause: "internal-ui"},
|
||||
{Text: "password", Role: "password text", origin: speechOriginLocal},
|
||||
{Text: "local status", origin: speechOriginStatus},
|
||||
{Text: "colour " + remoteText, origin: speechOriginLocal},
|
||||
} {
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
var received strings.Builder
|
||||
ends, frames := 0, 0
|
||||
readOutput:
|
||||
for {
|
||||
select {
|
||||
case frame := <-relayOutput:
|
||||
var message struct {
|
||||
Type string `json:"type"`
|
||||
Sequence []json.RawMessage `json:"sequence"`
|
||||
}
|
||||
if err := json.Unmarshal(frame, &message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.Type == "cancel" {
|
||||
break readOutput
|
||||
}
|
||||
if message.Type != "speak" {
|
||||
t.Fatal("unexpected observed relay message", message.Type)
|
||||
}
|
||||
frames++
|
||||
for _, raw := range message.Sequence {
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) == nil {
|
||||
received.WriteString(text)
|
||||
} else if strings.Contains(string(raw), "EndUtteranceCommand") {
|
||||
ends++
|
||||
} else {
|
||||
t.Fatal("unexpected observed speech command", string(raw))
|
||||
}
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("reader output did not reach the relay in order")
|
||||
}
|
||||
}
|
||||
if received.String() != "color "+remoteText || frames < 2 || ends != 1 {
|
||||
t.Fatal("observation leaked private text or lost resolved output/boundaries")
|
||||
}
|
||||
}
|
||||
status.reply <- internalUIResult{Activated: "cancel"}
|
||||
select {
|
||||
|
||||
@@ -426,6 +426,9 @@ func (runtime *tentacleRuntime) makeManager() (*tentacle.Manager, error) {
|
||||
if err == nil && runtime.remotePolicy != nil {
|
||||
err = manager.UseRemotePolicy(runtime.remotePolicy)
|
||||
}
|
||||
if err == nil && runtime.output != nil && runtime.output.speech != nil {
|
||||
runtime.output.speech.observe_remote_output(manager)
|
||||
}
|
||||
return manager, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentacle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
// RemoteSpeechOutput is a reader-only observation ticket. Capture it when local
|
||||
// output is admitted, then retain it through delivery. It cannot follow a lease
|
||||
// replacement or authorize output queued before consent. No child RPC exposes it.
|
||||
type RemoteSpeechOutput struct {
|
||||
session *Session
|
||||
lease tentaclewire.RemoteLease
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (manager *Manager) CaptureRemoteSpeechOutput() *RemoteSpeechOutput {
|
||||
manager.remoteMutex.Lock()
|
||||
session := manager.remoteOwner
|
||||
manager.remoteMutex.Unlock()
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
access := session.remote
|
||||
if access == nil {
|
||||
return nil
|
||||
}
|
||||
lease := tentaclewire.RemoteLease{Lease: access.lease, Epoch: access.epoch}
|
||||
if session.remote_access_locked(lease, "output-observe") == nil || access.role != "controlled" {
|
||||
return nil
|
||||
}
|
||||
return &RemoteSpeechOutput{session: session, lease: lease, ctx: access.ctx}
|
||||
}
|
||||
|
||||
// PublishText exports one resolved backend utterance. The caller must establish
|
||||
// local origin and exclude sensitive/status output. Conservative UTF-8 chunks
|
||||
// fit even six-byte JSON escaping; only the final frame ends the utterance.
|
||||
// The complete set is reserved before any frame becomes visible to the child.
|
||||
func (output *RemoteSpeechOutput) PublishText(text string, characters bool) error {
|
||||
if output == nil || output.ctx.Err() != nil {
|
||||
return ErrPermission
|
||||
}
|
||||
const chunkBytes = 768
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if len(text) > tentaclewire.RemoteSpeechQueueBytes || !utf8.ValidString(text) {
|
||||
return output.publish(nil, true)
|
||||
}
|
||||
var speeches []tentaclewire.RemoteSpeech
|
||||
for len(text) != 0 {
|
||||
if len(speeches) == tentaclewire.RemoteSpeechQueueSize {
|
||||
return output.publish(nil, true)
|
||||
}
|
||||
end := min(len(text), chunkBytes)
|
||||
for end < len(text) && !utf8.RuneStart(text[end]) {
|
||||
end--
|
||||
}
|
||||
parts := []tentaclewire.RemoteSpeechPart{{Text: text[:end], Characters: characters}}
|
||||
text = text[end:]
|
||||
if text == "" {
|
||||
parts = append(parts, tentaclewire.RemoteSpeechPart{End: true})
|
||||
}
|
||||
speeches = append(speeches, tentaclewire.RemoteSpeech{Kind: "speak", Parts: parts})
|
||||
}
|
||||
return output.publish(speeches, false)
|
||||
}
|
||||
|
||||
func (output *RemoteSpeechOutput) Cancel() error {
|
||||
if output == nil {
|
||||
return ErrPermission
|
||||
}
|
||||
return output.publish([]tentaclewire.RemoteSpeech{{Kind: "cancel"}}, false)
|
||||
}
|
||||
|
||||
func (output *RemoteSpeechOutput) publish(speeches []tentaclewire.RemoteSpeech, overflow bool) error {
|
||||
session := output.session
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
// Serialize production admission with lock/handoff cancellation. Policy
|
||||
// cancellation never takes a session lock, so this follows consent's order.
|
||||
if policy := session.manager.remotePolicy; policy != nil {
|
||||
policy.mutex.Lock()
|
||||
defer policy.mutex.Unlock()
|
||||
}
|
||||
access := session.remote_access_locked(output.lease, "output-observe")
|
||||
if access == nil || access.role != "controlled" || output.ctx.Err() != nil {
|
||||
return ErrPermission
|
||||
}
|
||||
frames := make([]outgoing, 0, len(speeches))
|
||||
bytes := 0
|
||||
for index, speech := range speeches {
|
||||
event := tentaclewire.SessionEvent{Sequence: session.eventSequence + uint64(index) + 1,
|
||||
Kind: "remote.output", Lease: output.lease.Lease, Epoch: output.lease.Epoch, Speech: &speech}
|
||||
frame, err := encode_request("", "session.event", event)
|
||||
if err != nil || !tentaclewire.ValidRemoteSpeech(speech) {
|
||||
overflow = true
|
||||
break
|
||||
}
|
||||
frames = append(frames, outgoing{frame: frame})
|
||||
bytes += len(frame)
|
||||
}
|
||||
if overflow || len(frames) > cap(session.outbound)-len(session.outbound) ||
|
||||
session.queuedBytes+bytes > tentaclewire.SessionQueueBytes {
|
||||
// Losing a cancel or a suffix must never leave an apparently healthy
|
||||
// speech stream. Match the existing observed-output overflow policy.
|
||||
session.fail_locked(ErrLimit)
|
||||
return ErrLimit
|
||||
}
|
||||
for _, frame := range frames {
|
||||
session.outbound <- frame // All producers reserve under session.mutex.
|
||||
session.queuedBytes += len(frame.frame)
|
||||
}
|
||||
session.eventSequence += uint64(len(frames))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentacle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
func TestRemoteSpeechOutputSDKTicket(t *testing.T) {
|
||||
for _, language := range []string{"go", "python"} {
|
||||
t.Run(language, func(t *testing.T) {
|
||||
manager := test_manager(t, DefaultLimits(), nil)
|
||||
session := startSessionSDK(t, manager, language, "speech")
|
||||
if manager.CaptureRemoteSpeechOutput() != nil {
|
||||
t.Fatal("captured output without consent")
|
||||
}
|
||||
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"})
|
||||
if manager.CaptureRemoteSpeechOutput() != nil {
|
||||
t.Fatal("controller acquired observation")
|
||||
}
|
||||
session.RevokeRemote()
|
||||
invokeSessionSDK(t, session, "event", nil) // Revocation.
|
||||
lease = speech_fixture_lease(t, session, "controlled", []string{"output-observe"})
|
||||
output := manager.CaptureRemoteSpeechOutput()
|
||||
if output == nil {
|
||||
t.Fatal("live controlled output unavailable")
|
||||
}
|
||||
text := " " + strings.Repeat("é\x00世界\t", 170) + " "
|
||||
if err := output.PublishText(text, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var received strings.Builder
|
||||
frames, ends := 0, 0
|
||||
for ends == 0 {
|
||||
var event tentaclewire.SessionEvent
|
||||
if err := json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frames++
|
||||
if event.Kind != "remote.output" || event.Lease != lease.Lease || event.Epoch != lease.Epoch ||
|
||||
event.Speech == nil || !tentaclewire.ValidRemoteSpeech(*event.Speech) {
|
||||
t.Fatal("invalid observed event", event)
|
||||
}
|
||||
for _, part := range event.Speech.Parts {
|
||||
if part.End {
|
||||
ends++
|
||||
} else {
|
||||
if !part.Characters || part.Language != "" {
|
||||
t.Fatal("changed character/default-language state")
|
||||
}
|
||||
received.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if frames < 2 || ends != 1 || received.String() != text {
|
||||
t.Fatal("changed split text or boundaries")
|
||||
}
|
||||
if err := output.Cancel(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var event tentaclewire.SessionEvent
|
||||
if json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event) != nil || event.Speech.Kind != "cancel" {
|
||||
t.Fatal("lost cancellation order")
|
||||
}
|
||||
session.RevokeRemote()
|
||||
invokeSessionSDK(t, session, "event", nil)
|
||||
_ = speech_fixture_lease(t, session, "controlled", []string{"output-observe"})
|
||||
if !errors.Is(output.PublishText("old", false), ErrPermission) || !errors.Is(output.Cancel(), ErrPermission) {
|
||||
t.Fatal("old ticket followed replacement lease")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// No pipe writer: prove that a split output reserves all queue space before
|
||||
// publishing a prefix, including encoded-byte and in-flight accounting.
|
||||
func TestRemoteSpeechOutputAdmissionAndPolicy(t *testing.T) {
|
||||
for _, mode := range []string{"slots", "bytes", "frames", "invalid", "policy", "expired", "pending"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
manager := test_manager(t, DefaultLimits(), nil)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
outputContext, revoke := context.WithCancel(ctx)
|
||||
defer revoke()
|
||||
session := &Session{manager: manager, spec: Spec{SessionEvents: true}, ctx: ctx, cancel: cancel,
|
||||
outputContext: outputContext, revokeOutput: revoke, state: Active,
|
||||
outbound: make(chan outgoing, 2), revoked: make(chan struct{})}
|
||||
accessContext, retire := context.WithCancel(ctx)
|
||||
defer retire()
|
||||
session.remote = &remoteAccess{lease: strings.Repeat("a", 32), epoch: 1, role: "controlled",
|
||||
grants: []string{"output-observe"}, ctx: accessContext, cancel: retire, deadline: time.Now().Add(time.Minute)}
|
||||
manager.remoteOwner = session
|
||||
output := manager.CaptureRemoteSpeechOutput()
|
||||
text := strings.Repeat("x", 800)
|
||||
want := ErrLimit
|
||||
switch mode {
|
||||
case "slots":
|
||||
session.outbound <- outgoing{frame: []byte("existing")}
|
||||
case "bytes":
|
||||
session.queuedBytes = tentaclewire.SessionQueueBytes - 1
|
||||
case "frames":
|
||||
text = strings.Repeat("x", 768*tentaclewire.RemoteSpeechQueueSize+1)
|
||||
case "invalid":
|
||||
text = "\xff"
|
||||
case "policy":
|
||||
manager.remotePolicy = NewRemotePolicy()
|
||||
session.remote.ctx, session.remote.cancel = context.WithCancel(manager.remotePolicy.Context())
|
||||
output.ctx = session.remote.ctx
|
||||
defer session.remote.cancel()
|
||||
manager.remotePolicy.SetAvailable(false)
|
||||
manager.remotePolicy.SetAvailable(true)
|
||||
want = ErrPermission
|
||||
case "expired":
|
||||
session.remote.deadline = time.Now().Add(-time.Second)
|
||||
want = ErrPermission
|
||||
case "pending":
|
||||
session.remote.lease = ""
|
||||
want = ErrPermission
|
||||
}
|
||||
before := len(session.outbound)
|
||||
if err := output.PublishText(text, false); !errors.Is(err, want) {
|
||||
t.Fatalf("publish = %v, want %v", err, want)
|
||||
}
|
||||
if len(session.outbound) != before || session.eventSequence != 0 {
|
||||
t.Fatal("failed admission published a prefix or consumed event sequence")
|
||||
}
|
||||
if want == ErrLimit && (session.state != Failed || accessContext.Err() == nil) {
|
||||
t.Fatal("overflow retained live stream")
|
||||
}
|
||||
if want == ErrPermission && manager.CaptureRemoteSpeechOutput() != nil {
|
||||
t.Fatal("captured invalid output authority")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user