From 9e15f8652d3e64484a28bc45fd67a2daa8259f1c Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 4 Nov 2026 13:44:13 -0500 Subject: [PATCH] Tentacle enhancements. --- docs/tentacle-remote-speech.md | 71 +++++++++ docs/tentacle-sessions.md | 13 +- internal/tentacle/remote.go | 16 ++- internal/tentacle/remote_speech.go | 90 ++++++++++++ internal/tentacle/remote_speech_test.go | 151 ++++++++++++++++++++ internal/tentacle/remote_test.go | 6 + internal/tentacle/session.go | 1 + internal/tentaclewire/remote_speech.go | 106 ++++++++++++++ internal/tentaclewire/remote_speech_test.go | 92 ++++++++++++ internal/tentaclewire/session.go | 27 ++-- sdk/go/tentacle/session.go | 7 + sdk/python/cthulhu_tentacle/session.py | 61 +++++++- 12 files changed, 619 insertions(+), 22 deletions(-) create mode 100644 docs/tentacle-remote-speech.md create mode 100644 internal/tentacle/remote_speech.go create mode 100644 internal/tentacle/remote_speech_test.go create mode 100644 internal/tentaclewire/remote_speech.go create mode 100644 internal/tentaclewire/remote_speech_test.go diff --git a/docs/tentacle-remote-speech.md b/docs/tentacle-remote-speech.md new file mode 100644 index 0000000..111fa37 --- /dev/null +++ b/docs/tentacle-remote-speech.md @@ -0,0 +1,71 @@ +# Remote speech transport prerequisite + +This increment adds typed speech IPC and an explicit reader-owned mailbox. It +does not connect the speech worker, observe local output, or make Remote speak. +The external source command remains relay-only. Base reader revision: `4f9b89e` +on `testing`; matching Tentacles base: `0c74038` on `master`, plus the pending +connection-command changes. No active or installed reader was replaced. + +## Wire data and ownership + +`RemoteSpeech` has `kind` (`speak`, `cancel`, `pause`), optional `priority` +(normal=0, next=1, now=2), `parts`, and optional `paused` boolean. Speak requires +1–128 parts and forbids paused. Cancel forbids parts, nonzero priority and paused. +Pause requires paused and forbids parts and nonzero priority. Unknown fields, +nulls and malformed types are rejected by both SDKs. + +Each part is either text with resolved `characters` and `language` state, or +`end: true` alone. Whitespace is significant. Language is empty for the configured +default, or up to 64 ASCII letters, digits, hyphens and underscores. No voice +path, backend command, sound file, object identity or arbitrary method is accepted. +The canonical Go JSON representation is limited to 6144 bytes including escaping; +the enclosing call retains its existing 8192-byte parameter and 64 KiB frame +bounds. Python validates the same canonical byte bound. + +Child `remote.present` takes `RemotePresentation`: lease, epoch, monotonically +increasing operation sequence and speech. The sequence is shared with heartbeat +and close. Admission requires a live controller lease with `remote-present`. +Without a reader owner calling `Session.EnableRemoteSpeech`, it returns `-32601`. +The queue holds at most 64 items, never blocks the IPC reader, and preserves FIFO +speech/cancel/pause order. Overflow revokes the entire lease and returns `-32002`; +it cannot drop a cancel while admitting newer speech. Speech does not refresh +the heartbeat deadline. + +The owner receives `RemoteSpeechDelivery` containing a cancellation context, +lease and validated speech. It must select session shutdown, check the context +and `RemoteContext` at delivery, apply focus/secure-input/speech policy, keep +remote cancellation separate from local status, and mark remote origins so they +cannot be observed again. Revocation immediately cancels contexts and drains +pending mailbox items. A context may be cancelled after dequeue: dequeue is not +authorization to speak later. + +For export, core calls `PublishRemoteSpeech` with a live controlled lease, +`output-observe`, and trusted origin `local`. Other origins are rejected. Core +must exclude password/secret output and supply only policy-resolved local output; +there is no child RPC that publishes an observed event. The method queues +`remote.output` with lease, epoch and speech using the existing ordered sequence. +Full IPC queues fail the process session, cancelling its lease. A receiving +Tentacle must recheck lease/epoch and peer readiness before sending over a relay. + +## Verification and remaining work + +Run from this checkout: + +```sh +GOCACHE=/tmp/cthulhu-cache GOPROXY=off go test -race ./internal/tentaclewire ./internal/tentacle ./sdk/... -count=1 +``` + +Real Go/Python child processes cover typed speech and observed events, absent +owners, role/grant denial, replay, ordering, revocation and queue overflow. +Shared validation cases cover null/type/unknown-field rejection and escaped +byte bounds. Existing lifecycle/SDK tests exercise old 1.0 behavior, process +failure, timeout, event overflow and installation contracts. + +Next: connect the policy-resolved speech worker and the external coordinator, +with origin ownership, secure/secret exclusion, delivery-time policy, local +priority, cancellation generations and atomic treatment of split utterances. +Pause support or cancel-on-pause behavior and NVDA priority fidelity must be +resolved there. The codec transports metadata without claiming backend behavior. +Then verify normal reading, key echo, read-all, spelling, speech modes and local +status while connected; lock/handoff and disconnect must discard remote work. +Physical consent, audible speech and real NVDA/Orca acceptance remain pending. diff --git a/docs/tentacle-sessions.md b/docs/tentacle-sessions.md index 3ff6d81..9512b23 100644 --- a/docs/tentacle-sessions.md +++ b/docs/tentacle-sessions.md @@ -141,9 +141,10 @@ abandoned work. Go also respects a shorter caller context; Python accepts a shorter explicit timeout. Shutdown/EOF wakes pending calls and event consumers. The process supervisor still enforces cleanup for a child ignoring cancellation. -These are lifecycle queues only. Separate key/speech queues, latest-frame Braille -coalescing, typed effect validation, and their delivery-time owner checks remain -unimplemented. Do not send those payloads through lifecycle events. +Typed speech IPC now extends this transport; see +[the speech contract](tentacle-remote-speech.md). It uses the same ordered event +queue for exported speech and a separate 64-item incoming mailbox. No production +output owner is connected. Keyboard/Braille/clipboard effects remain unsupported. ## Implemented lifecycle operations @@ -164,6 +165,7 @@ Child calls: | `remote.open` | `action`, `epoch`, `role`, `grants` | `lease`, `epoch` | | `remote.heartbeat` | `lease`, `epoch`, `sequence` | `{}` | | `remote.close` | `lease`, `epoch`, `sequence` | `{}` | +| `remote.present` | `lease`, `epoch`, `sequence`, typed `speech` | `{}` after mailbox admission; unavailable without reader owner | The action token must match the unconsumed core approval. Role must match exactly, and grants must be a nonempty subset of the approved install grants. Controller @@ -187,8 +189,9 @@ does not implement all effect-owner policies. No production effects currently us Errors are generic and contain no parameters: `-32001` permission denied, `-32602` invalid parameters, `-32601` unsupported operation, `-32603` unavailable. -State/focus/presentation/key/Braille/clipboard methods deliberately return -unsupported-operation errors. Malformed envelopes, replayed IDs or frame/queue +State/focus/key/Braille/clipboard methods deliberately return +unsupported-operation errors. Speech mailbox overflow returns `-32002` and +revokes its lease. Malformed envelopes, replayed IDs or frame/queue violations terminate the process session instead of attempting recovery. ## Verification diff --git a/internal/tentacle/remote.go b/internal/tentacle/remote.go index 1e2a353..94ba6b4 100644 --- a/internal/tentacle/remote.go +++ b/internal/tentacle/remote.go @@ -134,8 +134,8 @@ func (session *Session) RevokeRemoteEpoch(epoch uint64) { func (session *Session) RemoteContext(lease tentaclewire.RemoteLease, grant string) (context.Context, error) { session.mutex.Lock() defer session.mutex.Unlock() - access := session.remote - if session.state != Active || access == nil || access.lease == "" || access.lease != lease.Lease || access.epoch != lease.Epoch || !time.Now().Before(access.deadline) || access.ctx.Err() != nil || !slices.Contains(access.grants, grant) { + access := session.remote_access_locked(lease, grant) + if access == nil { return nil, ErrPermission } return access.ctx, nil @@ -160,6 +160,16 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) { } session.remote = nil access.cancel() + if session.remoteSpeech != nil { + drain: + for { + select { + case <-session.remoteSpeech: + default: + break drain + } + } + } if access.stopPolicy != nil { access.stopPolicy() } @@ -235,6 +245,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage denied := &RPCError{Code: -32001, Message: "Session permission denied"} invalid := &RPCError{Code: -32602, Message: "Invalid session parameters"} switch method { + case "remote.present": + return session.remote_present_locked(params) case "remote.open": var request tentaclewire.RemoteOpen if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) { diff --git a/internal/tentacle/remote_speech.go b/internal/tentacle/remote_speech.go new file mode 100644 index 0000000..8fc791b --- /dev/null +++ b/internal/tentacle/remote_speech.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "encoding/json" + "slices" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +// RemoteSpeechDelivery is reader-owned work. The owner must check Context and +// RemoteContext again at delivery, apply focus/security/output policy, and tag +// the result as remote so neither observation nor remote cancel affects local +// status. Admission is not evidence of backend delivery or audible speech. +type RemoteSpeechDelivery struct { + Context context.Context + Lease tentaclewire.RemoteLease + Speech tentaclewire.RemoteSpeech +} + +// EnableRemoteSpeech installs one bounded mailbox, explicitly from reader code. +// Without an owner, remote.present is unavailable. No production speech worker +// is bound yet. The owner selects Session.Done as well as this channel. +func (session *Session) EnableRemoteSpeech() <-chan RemoteSpeechDelivery { + session.mutex.Lock() + defer session.mutex.Unlock() + if session.remoteSpeech == nil { + session.remoteSpeech = make(chan RemoteSpeechDelivery, tentaclewire.RemoteSpeechQueueSize) + } + return session.remoteSpeech +} + +func (session *Session) remote_access_locked(lease tentaclewire.RemoteLease, grant string) *remoteAccess { + access := session.remote + if session.state != Active || access == nil || access.lease == "" || access.lease != lease.Lease || access.epoch != lease.Epoch || !time.Now().Before(access.deadline) || access.ctx.Err() != nil || !slices.Contains(access.grants, grant) { + return nil + } + return access +} + +// PublishRemoteSpeech accepts only policy-resolved local output from a reader +// owner. The origin is supplied by core, never by child RPC or network input. +// Remote output, local status, and sensitive output must use other origins. +// The owner must exclude password/secret output before calling this method. +func (session *Session) PublishRemoteSpeech(lease tentaclewire.RemoteLease, origin string, speech tentaclewire.RemoteSpeech) error { + if origin != "local" || !tentaclewire.ValidRemoteSpeech(speech) { + return ErrPermission + } + session.mutex.Lock() + defer session.mutex.Unlock() + access := session.remote_access_locked(lease, "output-observe") + if access == nil || access.role != "controlled" { + return ErrPermission + } + event := tentaclewire.SessionEvent{Kind: "remote.output", Lease: lease.Lease, Epoch: lease.Epoch, Speech: &speech} + if !session.remote_event_locked(&event) { + // Shared lifecycle IPC cannot lose revocation or cancel order. + session.fail_locked(ErrLimit) + return ErrLimit + } + return nil +} + +func (session *Session) remote_present_locked(params json.RawMessage) (any, *RPCError) { + var request tentaclewire.RemotePresentation + if decode_strict(params, &request) != nil || !tentaclewire.ValidRemoteSpeech(request.Speech) || len(request.Lease) != 32 || request.Epoch == 0 || request.Sequence == 0 { + return nil, &RPCError{Code: -32602, Message: "Invalid session parameters"} + } + lease := tentaclewire.RemoteLease{Lease: request.Lease, Epoch: request.Epoch} + access := session.remote_access_locked(lease, "remote-present") + if access == nil || access.role != "controller" || request.Sequence <= access.sequence { + return nil, &RPCError{Code: -32001, Message: "Session permission denied"} + } + if session.remoteSpeech == nil { + return nil, &RPCError{Code: -32601, Message: "Remote speech owner unavailable"} + } + select { + case session.remoteSpeech <- RemoteSpeechDelivery{Context: access.ctx, Lease: lease, Speech: request.Speech}: + access.sequence = request.Sequence + return struct{}{}, nil + default: + // A dropped cancel would let stale speech outlive the intended stream. + session.revoke_remote_locked("local", true) + return nil, &RPCError{Code: -32002, Message: "Remote speech queue full"} + } +} diff --git a/internal/tentacle/remote_speech_test.go b/internal/tentacle/remote_speech_test.go new file mode 100644 index 0000000..abb0f86 --- /dev/null +++ b/internal/tentacle/remote_speech_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "encoding/json" + "testing" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +func speech_fixture_lease(t *testing.T, session *Session, role string, grants []string) tentaclewire.RemoteLease { + t.Helper() + action, err := session.AuthorizeRemote(role, grants) + if err != nil { + t.Fatal(err) + } + invokeSessionSDK(t, session, "event", nil) + raw, code := remoteCall(t, session, "remote.open", tentaclewire.RemoteOpen{Action: action.Action, Epoch: action.Epoch, Role: role, Grants: grants}) + if code != 0 { + t.Fatal("open", code) + } + var lease tentaclewire.RemoteLease + if err := json.Unmarshal(raw, &lease); err != nil { + t.Fatal(err) + } + return lease +} + +func TestRemoteSpeechSDKDeliveryAndRevocation(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") + lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"}) + request := tentaclewire.RemotePresentation{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}, Speech: tentaclewire.RemoteSpeech{Kind: "speak", Parts: []tentaclewire.RemoteSpeechPart{{Text: " text\t世界 ", Characters: true, Language: "en_US"}}}} + if _, code := remoteCall(t, session, "remote.present", request); code != -32601 { + t.Fatal("accepted without reader owner", code) + } + deliveries := session.EnableRemoteSpeech() + if _, code := remoteCall(t, session, "remote.present", request); code != 0 { + t.Fatal(code) + } + first := <-deliveries + if first.Speech.Parts[0] != request.Speech.Parts[0] || first.Context.Err() != nil { + t.Fatal("changed speech or invalid lifetime") + } + if _, code := remoteCall(t, session, "remote.present", request); code != -32001 { + t.Fatal("accepted replay", code) + } + request.Sequence++ + request.Speech = tentaclewire.RemoteSpeech{Kind: "cancel"} + if _, code := remoteCall(t, session, "remote.present", request); code != 0 { + t.Fatal(code) + } + request.Sequence++ + paused := true + request.Speech = tentaclewire.RemoteSpeech{Kind: "pause", Paused: &paused} + if _, code := remoteCall(t, session, "remote.present", request); code != 0 { + t.Fatal(code) + } + if (<-deliveries).Speech.Kind != "cancel" || (<-deliveries).Speech.Kind != "pause" { + t.Fatal("lost cancel ordering") + } + // Heartbeat shares the operation sequence, but speech is not a heartbeat. + if _, code := remoteCall(t, session, "remote.heartbeat", request.RemoteOperation); code != -32001 { + t.Fatal("heartbeat replay", code) + } + request.Sequence++ + if _, code := remoteCall(t, session, "remote.present", request); code != 0 { + t.Fatal(code) + } + session.RevokeRemote() + if first.Context.Err() == nil { + t.Fatal("accepted work survived revoke") + } + if _, err := session.RemoteContext(lease, "remote-present"); err == nil { + t.Fatal("delivery-time authorization survived revoke") + } + select { + case <-deliveries: + t.Fatal("retained revoked work") + default: + } + request.Sequence++ + if _, code := remoteCall(t, session, "remote.present", request); code != -32001 { + t.Fatal("accepted stale lease", code) + } + }) + } +} + +func TestRemoteSpeechSDKOutputAndOrigins(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") + lease := speech_fixture_lease(t, session, "controlled", []string{"output-observe"}) + value := tentaclewire.RemoteSpeech{Kind: "speak", Priority: 2, Parts: []tentaclewire.RemoteSpeechPart{{Text: " whitespace \n "}}} + for _, origin := range []string{"remote", "status", "secret", ""} { + if err := session.PublishRemoteSpeech(lease, origin, value); err == nil { + t.Fatal("exported origin", origin) + } + } + if err := session.PublishRemoteSpeech(lease, "local", value); err != nil { + t.Fatal(err) + } + var event tentaclewire.SessionEvent + if err := json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event); err != nil { + t.Fatal(err) + } + if event.Kind != "remote.output" || event.Lease != lease.Lease || event.Epoch != lease.Epoch || event.Speech == nil || event.Speech.Parts[0] != value.Parts[0] { + t.Fatal("changed output event", event) + } + if _, code := remoteCall(t, session, "remote.present", tentaclewire.RemotePresentation{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}, Speech: value}); code != -32001 { + t.Fatal("wrong-direction grant", code) + } + session.RevokeRemote() + if err := session.PublishRemoteSpeech(lease, "local", value); err == nil { + t.Fatal("exported after revoke") + } + }) + } +} + +func TestRemoteSpeechQueueOverflowRetiresLease(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + session := startSessionSDK(t, manager, "go", "speech") + lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"}) + deliveries := session.EnableRemoteSpeech() + request := tentaclewire.RemotePresentation{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch}, Speech: tentaclewire.RemoteSpeech{Kind: "cancel"}} + for index := 1; index <= tentaclewire.RemoteSpeechQueueSize; index++ { + request.Sequence = uint64(index) + if _, code := remoteCall(t, session, "remote.present", request); code != 0 { + t.Fatal(index, code) + } + } + request.Sequence++ + if _, code := remoteCall(t, session, "remote.present", request); code != -32002 { + t.Fatal("queue did not fail closed", code) + } + if _, err := session.RemoteContext(lease, "remote-present"); err == nil { + t.Fatal("overflow retained authorization") + } + select { + case <-deliveries: + t.Fatal("overflow retained pending speech") + default: + } +} diff --git a/internal/tentacle/remote_test.go b/internal/tentacle/remote_test.go index 58640cf..9c3f361 100644 --- a/internal/tentacle/remote_test.go +++ b/internal/tentacle/remote_test.go @@ -433,6 +433,12 @@ func TestSessionSDKRejectsMalformedHostMessages(t *testing.T) { overflow += strings.Replace(event, `"sequence":1`, `"sequence":`+strconv.Itoa(sequence), 1) } cases["event overflow"] = overflow + outputEvent := `{"jsonrpc":"2.0","method":"session.event","params":{"sequence":1,"kind":"remote.output","lease":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","epoch":1,"speech":{"kind":"cancel"}}}` + "\n" + cases["output without lease"] = initialize + activate + strings.Replace(outputEvent, `"lease":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",`, "", 1) + cases["output with role"] = initialize + activate + strings.Replace(outputEvent, `"epoch":1`, `"epoch":1,"role":"controller"`, 1) + cases["null speech"] = initialize + activate + strings.Replace(outputEvent, `{"kind":"cancel"}`, `null`, 1) + cases["malformed speech"] = initialize + activate + strings.Replace(outputEvent, `{"kind":"cancel"}`, `{"kind":"pause","paused":null}`, 1) + cases["speech on action"] = initialize + activate + strings.Replace(event, `"epoch":1`, `"epoch":1,"speech":{"kind":"cancel"}`, 1) for _, language := range []string{"go", "python"} { for name, input := range cases { t.Run(language+"/"+name, func(t *testing.T) { diff --git a/internal/tentacle/session.go b/internal/tentacle/session.go index f7635a4..09b6512 100644 --- a/internal/tentacle/session.go +++ b/internal/tentacle/session.go @@ -76,6 +76,7 @@ type Session struct { remoteEpoch uint64 remote *remoteAccess remoteApproval *RemoteApproval + remoteSpeech chan RemoteSpeechDelivery } func (session *Session) Ready() <-chan struct{} { return session.ready } diff --git a/internal/tentaclewire/remote_speech.go b/internal/tentaclewire/remote_speech.go new file mode 100644 index 0000000..9821610 --- /dev/null +++ b/internal/tentaclewire/remote_speech.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentaclewire + +import ( + "bytes" + "encoding/json" + "unicode/utf8" +) + +// Leave room for lease metadata inside the existing 8192-byte call bound. +const MaxRemoteSpeechBytes = 6144 +const RemoteSpeechQueueSize = 64 + +// RemoteSpeechPart carries resolved character/language state for each fragment. +// End is an utterance boundary, not text. Whitespace is significant. +type RemoteSpeechPart struct { + Text string `json:"text,omitempty"` + Characters bool `json:"characters,omitempty"` + Language string `json:"language,omitempty"` + End bool `json:"end,omitempty"` +} + +type RemoteSpeech struct { + Kind string `json:"kind"` // speak, cancel, pause + Priority int `json:"priority,omitempty"` // normal=0, next=1, now=2 + Parts []RemoteSpeechPart `json:"parts,omitempty"` + Paused *bool `json:"paused,omitempty"` +} + +type RemotePresentation struct { + RemoteOperation + Speech RemoteSpeech `json:"speech"` +} + +func reject_speech_nulls(data []byte) error { + var fields map[string]json.RawMessage + if json.Unmarshal(data, &fields) != nil || fields == nil { + return ErrProtocol + } + for _, value := range fields { + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return ErrProtocol + } + } + return nil +} + +func (part *RemoteSpeechPart) UnmarshalJSON(data []byte) error { + type plain RemoteSpeechPart + var value plain + if reject_speech_nulls(data) != nil || DecodeStrict(data, &value) != nil { + return ErrProtocol + } + *part = RemoteSpeechPart(value) + return nil +} + +func (speech *RemoteSpeech) UnmarshalJSON(data []byte) error { + type plain RemoteSpeech + var value plain + if reject_speech_nulls(data) != nil || DecodeStrict(data, &value) != nil || !ValidRemoteSpeech(RemoteSpeech(value)) { + return ErrProtocol + } + *speech = RemoteSpeech(value) + return nil +} + +// ValidRemoteSpeech also limits encoded bytes: escaped text must fit in IPC. +func ValidRemoteSpeech(speech RemoteSpeech) bool { + switch speech.Kind { + case "speak": + if speech.Priority < 0 || speech.Priority > 2 || speech.Paused != nil || len(speech.Parts) == 0 || len(speech.Parts) > 128 { + return false + } + for _, part := range speech.Parts { + if part.End { + if part.Text != "" || part.Characters || part.Language != "" { + return false + } + continue + } + if part.Text == "" || !utf8.ValidString(part.Text) || len(part.Language) > 64 { + return false + } + for _, char := range part.Language { + if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '-' || char == '_') { + return false + } + } + } + case "cancel": + if speech.Priority != 0 || len(speech.Parts) != 0 || speech.Paused != nil { + return false + } + case "pause": + if speech.Priority != 0 || len(speech.Parts) != 0 || speech.Paused == nil { + return false + } + default: + return false + } + data, err := json.Marshal(speech) + return err == nil && len(data) <= MaxRemoteSpeechBytes +} diff --git a/internal/tentaclewire/remote_speech_test.go b/internal/tentaclewire/remote_speech_test.go new file mode 100644 index 0000000..c425125 --- /dev/null +++ b/internal/tentaclewire/remote_speech_test.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentaclewire + +import ( + "encoding/json" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestRemoteSpeechSDKValidation(t *testing.T) { + cases := []struct { + data string + valid bool + }{ + {`{"kind":"speak","parts":[{"text":" hello\t世界 ","characters":true,"language":"en_US"},{"end":true}]}`, true}, + {`{"kind":"cancel"}`, true}, + {`{"kind":"pause","paused":false}`, true}, + {`{"kind":"pause","paused":true}`, true}, + {`{"kind":"speak","priority":2,"parts":[{"text":"x"}]}`, true}, + {`{"kind":"speak","priority":3,"parts":[{"text":"x"}]}`, false}, + {`{"kind":"speak","priority":true,"parts":[{"text":"x"}]}`, false}, + {`{"kind":"speak","parts":[]}`, false}, + {`{"kind":"speak","parts":[{}]}`, false}, + {`{"kind":"speak","parts":[{"text":null}]}`, false}, + {`{"kind":"speak","parts":[{"text":"x","end":true}]}`, false}, + {`{"kind":"speak","parts":[{"text":"x","language":"../en"}]}`, false}, + {`{"kind":"speak","parts":[{"text":"x","voice":"peer path"}]}`, false}, + {`{"kind":"speak","parts":[{"text":"x"}],"paused":false}`, false}, + {`{"kind":"cancel","parts":null}`, false}, + {`{"kind":"cancel","parts":[{"text":"x"}]}`, false}, + {`{"kind":"cancel","paused":false}`, false}, + {`{"kind":"pause"}`, false}, + {`{"kind":"pause","paused":null}`, false}, + {`{"kind":"pause","paused":0}`, false}, + {`{"kind":"wave","path":"anything"}`, false}, + {`{"kind":"cancel","priority":null}`, false}, + {`{"kind":"cancel","extra":1}`, false}, + {`{"kind":"speak","parts":[{"text":"` + strings.Repeat("x", MaxRemoteSpeechBytes) + `"}]}`, false}, + {`{"kind":"speak","parts":[{"text":"` + strings.Repeat("<", 1100) + `"}]}`, false}, + {`{"kind":"speak","parts":[{"text":"` + strings.Repeat("\u2028", 1100) + `"}]}`, false}, + } + var pythonCases []any + for _, item := range cases { + var speech RemoteSpeech + valid := DecodeStrict([]byte(item.data), &speech) == nil && ValidRemoteSpeech(speech) + if valid != item.valid { + t.Fatalf("Go validation: %.100s: got %v", item.data, valid) + } + var value any + if err := json.Unmarshal([]byte(item.data), &value); err != nil { + t.Fatal(err) + } + pythonCases = append(pythonCases, map[string]any{"speech": value, "valid": item.valid}) + } + path, err := filepath.Abs("../../sdk/python") + if err != nil { + t.Fatal(err) + } + script := `import json, sys +sys.path.insert(0, sys.argv[1]) +from cthulhu_tentacle.session import valid_remote_speech +for case in json.load(sys.stdin): + assert valid_remote_speech(case['speech']) == case['valid'], str(case)[:150] +` + data, _ := json.Marshal(pythonCases) + command := exec.Command("python3", "-B", "-c", script, path) + command.Stdin = strings.NewReader(string(data)) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("Python validation: %v: %s", err, output) + } +} + +func TestRemoteSpeechEncodedBoundary(t *testing.T) { + value := RemoteSpeech{Kind: "speak", Parts: []RemoteSpeechPart{{Text: "x"}}} + data, _ := json.Marshal(value) + value.Parts[0].Text = strings.Repeat("x", MaxRemoteSpeechBytes-len(data)+1) + if !ValidRemoteSpeech(value) { + t.Fatal("rejected exact bound") + } + value.Parts[0].Text += "x" + if ValidRemoteSpeech(value) { + t.Fatal("accepted above bound") + } + value.Parts[0].Text = string([]byte{0xff}) + if ValidRemoteSpeech(value) { + t.Fatal("accepted invalid UTF-8") + } +} diff --git a/internal/tentaclewire/session.go b/internal/tentaclewire/session.go index 656af62..a1e8891 100644 --- a/internal/tentaclewire/session.go +++ b/internal/tentaclewire/session.go @@ -63,17 +63,18 @@ func ValidNegotiation(minor int, apis, capabilities []string) bool { return a["remote-access"] == hasRemote } -// SessionEvent is deliberately limited to lifecycle metadata. Output/input -// payloads will require their own typed validators before they can be enabled. +// SessionEvent carries lifecycle metadata and explicitly typed output. Output +// observation still requires a reader owner and a live role-bound lease. type SessionEvent struct { - Sequence uint64 `json:"sequence"` - Kind string `json:"kind"` - Action string `json:"action,omitempty"` - Lease string `json:"lease,omitempty"` - Epoch uint64 `json:"epoch"` - Role string `json:"role,omitempty"` - Grants []string `json:"grants,omitempty"` - Reason string `json:"reason,omitempty"` + Sequence uint64 `json:"sequence"` + Kind string `json:"kind"` + Action string `json:"action,omitempty"` + Lease string `json:"lease,omitempty"` + Epoch uint64 `json:"epoch"` + Role string `json:"role,omitempty"` + Grants []string `json:"grants,omitempty"` + Reason string `json:"reason,omitempty"` + Speech *RemoteSpeech `json:"speech,omitempty"` } func ValidRole(role string) bool { return role == "controller" || role == "controlled" } @@ -96,9 +97,11 @@ func ValidSessionEvent(event SessionEvent) bool { } switch event.Kind { case "remote.action": - return len(event.Action) == 32 && event.Lease == "" && event.Reason == "" && ValidRoleGrants(event.Role, event.Grants) + return event.Speech == nil && len(event.Action) == 32 && event.Lease == "" && event.Reason == "" && ValidRoleGrants(event.Role, event.Grants) case "remote.revoked": - return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && slices.Contains([]string{"closed", "expired", "local", "replaced"}, event.Reason) + return event.Speech == nil && event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && slices.Contains([]string{"closed", "expired", "local", "replaced"}, event.Reason) + case "remote.output": + return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech != nil && ValidRemoteSpeech(*event.Speech) } return false } diff --git a/sdk/go/tentacle/session.go b/sdk/go/tentacle/session.go index cd4baea..1cb26ce 100644 --- a/sdk/go/tentacle/session.go +++ b/sdk/go/tentacle/session.go @@ -21,6 +21,13 @@ type SessionEvent = tentaclewire.SessionEvent type RemoteOpen = tentaclewire.RemoteOpen type RemoteLease = tentaclewire.RemoteLease type RemoteOperation = tentaclewire.RemoteOperation +type RemoteSpeechPart = tentaclewire.RemoteSpeechPart +type RemoteSpeech = tentaclewire.RemoteSpeech +type RemotePresentation = tentaclewire.RemotePresentation + +const MaxRemoteSpeechBytes = tentaclewire.MaxRemoteSpeechBytes + +func ValidRemoteSpeech(speech RemoteSpeech) bool { return tentaclewire.ValidRemoteSpeech(speech) } type SessionError struct { Code int `json:"code"` diff --git a/sdk/python/cthulhu_tentacle/session.py b/sdk/python/cthulhu_tentacle/session.py index 5767c44..aa5f705 100644 --- a/sdk/python/cthulhu_tentacle/session.py +++ b/sdk/python/cthulhu_tentacle/session.py @@ -37,6 +37,56 @@ def _token(value): return isinstance(value, str) and len(value.encode("utf-8")) == 32 +def valid_remote_speech(speech): + """Validate typed speech including its encoded IPC bound; no delivery.""" + try: + _fields(speech, ("kind",), ("priority", "parts", "paused")) + priority, parts = speech.get("priority", 0), speech.get("parts", []) + if type(priority) is not int or not isinstance(parts, list): + return False + if speech["kind"] == "speak": + if not 0 <= priority <= 2 or "paused" in speech or not 1 <= len(parts) <= 128: + return False + for part in parts: + _fields(part, (), ("text", "characters", "language", "end")) + text, language = part.get("text", ""), part.get("language", "") + characters, end = part.get("characters", False), part.get("end", False) + if (not isinstance(text, str) or not isinstance(language, str) + or type(characters) is not bool or type(end) is not bool): + return False + if end: + if text or language or characters: + return False + elif (not text or len(language) > 64 + or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" for char in language)): + return False + elif speech["kind"] == "cancel": + if priority or parts or "paused" in speech: + return False + elif speech["kind"] == "pause": + if priority or parts or type(speech.get("paused")) is not bool: + return False + else: + return False + # Match Go's canonical omitempty representation and JSON HTML escaping + # so both SDKs enforce the same payload bound, including escaped text. + canonical = {"kind": speech["kind"]} + if priority: + canonical["priority"] = priority + if parts: + canonical["parts"] = [{key: value for key, value in part.items() if value} + for part in parts] + if "paused" in speech: + canonical["paused"] = speech["paused"] + encoded = _encode(canonical) + for raw, escaped in ((b"<", b"\\u003c"), (b">", b"\\u003e"), (b"&", b"\\u0026"), + (b"\xe2\x80\xa8", b"\\u2028"), (b"\xe2\x80\xa9", b"\\u2029")): + encoded = encoded.replace(raw, escaped) + return len(encoded) <= 6144 + except (ProtocolError, TypeError, ValueError, UnicodeError): + return False + + class SessionError(Exception): def __init__(self, code, message): super().__init__("Tentacle session error " + str(code)) @@ -226,13 +276,13 @@ class SessionClient: waiter.put_nowait(value) def _event(self, event): - _fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason")) + _fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason", "speech")) if not _positive(event["sequence"]) or not _positive(event["epoch"]): raise ProtocolError("Invalid event sequence") if event["kind"] == "remote.action": grants = event.get("grants") role = event.get("role") - if (not _token(event.get("action")) or event.get("lease", "") != "" or event.get("reason", "") != "" + if ("speech" in event or not _token(event.get("action")) or event.get("lease", "") != "" or event.get("reason", "") != "" or role not in ("controller", "controlled") or not isinstance(grants, list) or not grants or any(not isinstance(grant, str) for grant in grants) or len(set(grants)) != len(grants) or not set(grants) <= REMOTE_GRANTS @@ -240,9 +290,14 @@ class SessionClient: or role == "controlled" and set(grants) & {"remote-present", "keyboard-capture"}): raise ProtocolError("Invalid action event") elif event["kind"] == "remote.revoked": - if (not _token(event.get("lease")) or event.get("action", "") != "" or event.get("role", "") != "" + if ("speech" in event or not _token(event.get("lease")) or event.get("action", "") != "" or event.get("role", "") != "" or event.get("grants", []) != [] or event.get("reason") not in ("closed", "expired", "local", "replaced")): raise ProtocolError("Invalid revoke event") + elif event["kind"] == "remote.output": + if (not _token(event.get("lease")) or event.get("action", "") != "" + or event.get("role", "") != "" or event.get("grants", []) != [] + or event.get("reason", "") != "" or not valid_remote_speech(event.get("speech"))): + raise ProtocolError("Invalid output event") else: raise ProtocolError("Unknown session event") with self._lock: