Tentacle enhancements.

This commit is contained in:
Storm Dragon committed 2026-11-04 13:44:13 -05:00
1 parent 4f9b89e38b
commit 9e15f8652d
12 files changed
+619 -22

No files matched your search

+106
View File
@@ -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
}
@@ -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")
}
}
+15 -12
View File
@@ -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
}