Add Remote Braille IPC to the host and both SDKs

remote.braille requests and events carry a typed op under a
braille-stream lease: the controller's width and gestures for a
controlled reader, rendered cells back to its child, and cells, local
geometry and display gestures for a controller. Direction is validated
per role; a full mailbox revokes the lease. No display owner is
attached yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chase CichorzandClaude Opus 5.5 committed 2026-11-06 16:06:38 -05:00
1 parent 477d860fb8
commit 2651b5e9bd
10 files changed
+476 -3

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Remote Braille IPC
Protocol 1.1 carries Remote Braille through one child-to-host method,
`remote.braille`, and one host-to-child event kind of the same name. Both need
the current process session's live lease with the separately approved
`braille-stream` grant; requests share the operation sequence. Every message
carries one `braille` object whose `op` selects its form:
| `op` | Direction and role | Fields |
| --- | --- | --- |
| `size` | controlled child to host | `width` 0–4,096: the controller's display; 0 stops export |
| `gesture` | controlled child to host; host to controller child | `gesture` `pan-left`, `pan-right` or `route`, with `index` for route |
| `cells` | host to controlled child; controller child to host | `cells`: 1–4,096 final dot values 0–255, never text |
| `geometry` | controller child to host (no width); host reply event with `width` | 0 means no local display |
The request reply acknowledges admission into a 16-item reader mailbox; a full
mailbox revokes the lease. Without an attached reader owner the method returns
owner-unavailable. No AT-SPI identity, object path, routing map or native
handle crosses this boundary: routes are cell indexes into the latest exported
frame only. `Session.EnableRemoteBraille`, `PublishRemoteBraille`, the Go
`RemoteBraille` types and validators and the Python `valid_remote_braille` and
`valid_remote_braille_request` validators implement the contract. Go/Python
SDK-process tests cover each direction, role denial, invalid values,
saturation and revocation.
+12
View File
@@ -191,6 +191,16 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
}
}
}
if session.remoteBraille != nil {
drainBraille:
for {
select {
case <-session.remoteBraille:
default:
break drainBraille
}
}
}
if session.remoteCapture != nil {
drainCapture:
for {
@@ -292,6 +302,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage
return session.remote_key_locked(params)
case "remote.focus":
return session.remote_focus_locked(params)
case "remote.braille":
return session.remote_braille_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) {
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"context"
"encoding/json"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
// RemoteBrailleDelivery is admitted Braille work for the reader's owner.
// The owner must recheck Context and RemoteContext(braille-stream) and its own
// display policy before any effect. Role is the lease's core-assigned role.
type RemoteBrailleDelivery struct {
Context context.Context
Lease tentaclewire.RemoteLease
Role string
Sequence uint64
Braille tentaclewire.RemoteBraille
}
// EnableRemoteBraille attaches one reader-owned Braille mailbox. It performs
// no display setup and grants no authority.
func (session *Session) EnableRemoteBraille() <-chan RemoteBrailleDelivery {
session.mutex.Lock()
defer session.mutex.Unlock()
if session.remoteBraille == nil {
session.remoteBraille = make(chan RemoteBrailleDelivery, tentaclewire.RemoteBrailleQueueSize)
}
return session.remoteBraille
}
func (session *Session) remote_braille_locked(params json.RawMessage) (any, *RPCError) {
var request tentaclewire.RemoteBrailleRequest
if decode_strict(params, &request) != nil {
return nil, &RPCError{Code: -32602, Message: "Invalid session parameters"}
}
lease := tentaclewire.RemoteLease{Lease: request.Lease, Epoch: request.Epoch}
access := session.remote_access_locked(lease, "braille-stream")
if access == nil || request.Sequence <= access.sequence || !tentaclewire.ValidRemoteBrailleFor(access.role, true, request.Braille) {
return nil, &RPCError{Code: -32001, Message: "Session permission denied"}
}
if session.remoteBraille == nil {
return nil, &RPCError{Code: -32601, Message: "Remote Braille owner unavailable"}
}
access.sequence = request.Sequence
select {
case session.remoteBraille <- RemoteBrailleDelivery{Context: access.ctx, Lease: lease, Role: access.role, Sequence: request.Sequence, Braille: request.Braille}:
return struct{}{}, nil
default:
session.revoke_remote_locked("local", true)
return nil, &RPCError{Code: -32002, Message: "Remote Braille queue full"}
}
}
// PublishRemoteBraille sends one Braille operation to the child. The owner
// supplies only rendered cells (controlled), local geometry or local display
// gestures (controller); never text, routing identities or native handles.
func (session *Session) PublishRemoteBraille(lease tentaclewire.RemoteLease, value tentaclewire.RemoteBraille) error {
session.mutex.Lock()
defer session.mutex.Unlock()
access := session.remote_access_locked(lease, "braille-stream")
if access == nil || !tentaclewire.ValidRemoteBrailleFor(access.role, false, value) {
return ErrPermission
}
value.Cells = append([]int(nil), value.Cells...)
if len(value.Cells) == 0 {
value.Cells = nil
}
event := tentaclewire.SessionEvent{Kind: "remote.braille", Lease: lease.Lease, Epoch: lease.Epoch, Braille: &value}
if !session.remote_event_locked(&event) {
session.fail_locked(ErrLimit)
return ErrLimit
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
func brailleRequest(lease tentaclewire.RemoteLease, sequence uint64, value tentaclewire.RemoteBraille) tentaclewire.RemoteBrailleRequest {
return tentaclewire.RemoteBrailleRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Braille: value}
}
func TestRemoteBrailleSDKDirectionsAndEvents(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, "braille", "braille-stream")
lease := speech_fixture_lease(t, session, "controlled", []string{"output-observe", "braille-stream"})
size := tentaclewire.RemoteBraille{Op: "size", Width: 40}
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 1, size)); code != -32601 {
t.Fatal("Braille admitted without owner", code)
}
deliveries := session.EnableRemoteBraille()
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 1, size)); code != 0 {
t.Fatal("size refused", code)
}
if delivery := <-deliveries; delivery.Role != "controlled" || !reflect.DeepEqual(delivery.Braille, size) || delivery.Lease != lease {
t.Fatal("size delivery changed", delivery)
}
route := tentaclewire.RemoteBraille{Op: "gesture", Gesture: "route", Index: 7}
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 2, route)); code != 0 {
t.Fatal("gesture refused", code)
}
<-deliveries
cells := tentaclewire.RemoteBraille{Op: "cells", Cells: []int{1, 255, 0}}
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 3, cells)); code != -32001 {
t.Fatal("controlled child sent display cells to its reader", code)
}
if err := session.PublishRemoteBraille(lease, cells); err != nil {
t.Fatal(err)
}
if event := nextSDKEvent(t, session); event.Kind != "remote.braille" || event.Braille == nil || !reflect.DeepEqual(*event.Braille, cells) {
t.Fatal("cells event changed", event)
}
if !errors.Is(session.PublishRemoteBraille(lease, route), ErrPermission) {
t.Fatal("controlled reader published a gesture")
}
session.RevokeRemote()
nextSDKEvent(t, session)
lease = speech_fixture_lease(t, session, "controller", []string{"remote-present", "braille-stream"})
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 1, cells)); code != 0 {
t.Fatal("controller cells refused", code)
}
if delivery := <-deliveries; delivery.Role != "controller" || !reflect.DeepEqual(delivery.Braille.Cells, cells.Cells) {
t.Fatal("controller cells changed", delivery)
}
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 2, size)); code != -32001 {
t.Fatal("controller sent a size request", code)
}
for _, value := range []tentaclewire.RemoteBraille{{Op: "geometry", Width: 32}, {Op: "gesture", Gesture: "pan-right"}} {
if err := session.PublishRemoteBraille(lease, value); err != nil {
t.Fatal(err)
}
if event := nextSDKEvent(t, session); event.Braille == nil || !reflect.DeepEqual(*event.Braille, value) {
t.Fatal("controller event changed", event)
}
}
data, _ := json.Marshal(brailleRequest(lease, 3, cells))
for _, raw := range []string{
strings.Replace(string(data), `[1,255,0]`, `[1,256,0]`, 1),
strings.Replace(string(data), `"op":"cells"`, `"op":"text"`, 1),
strings.Replace(string(data), `"op":"cells"`, `"op":"cells","width":3`, 1),
strings.Replace(string(data), `"sequence":3`, `"sequence":3,"origin":"local"`, 1),
} {
if _, code := remoteCall(t, session, "remote.braille", json.RawMessage(raw)); code != -32602 {
t.Fatal("invalid Braille admitted", code, raw)
}
}
session.RevokeRemote()
nextSDKEvent(t, session)
if !errors.Is(session.PublishRemoteBraille(lease, cells), ErrPermission) {
t.Fatal("retired lease published Braille")
}
})
}
}
func TestRemoteBrailleSDKGrantAndSaturation(t *testing.T) {
manager := test_manager(t, DefaultLimits(), nil)
session := startSessionSDK(t, manager, "go", "braille", "braille-stream")
deliveries := session.EnableRemoteBraille()
lease := speech_fixture_lease(t, session, "controlled", []string{"output-observe"})
size := tentaclewire.RemoteBraille{Op: "size", Width: 40}
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 1, size)); code != -32001 {
t.Fatal("Braille admitted without grant", code)
}
session.RevokeRemote()
invokeSessionSDK(t, session, "event", nil)
lease = speech_fixture_lease(t, session, "controlled", []string{"braille-stream"})
for sequence := uint64(1); sequence <= tentaclewire.RemoteBrailleQueueSize; sequence++ {
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, sequence, size)); code != 0 {
t.Fatal("refused before saturation", code)
}
}
if _, code := remoteCall(t, session, "remote.braille", brailleRequest(lease, 99, size)); code != -32002 {
t.Fatal("saturation did not fail", code)
}
if _, err := session.RemoteContext(lease, "braille-stream"); err == nil || len(deliveries) != 0 {
t.Fatal("saturation left the lease or queue")
}
}
+1
View File
@@ -79,6 +79,7 @@ type Session struct {
remoteSpeech chan RemoteSpeechDelivery
remoteKeys chan RemoteKeyDelivery
remoteCapture chan RemoteFocusDelivery
remoteBraille chan RemoteBrailleDelivery
}
func (session *Session) Ready() <-chan struct{} { return session.ready }
+121
View File
@@ -0,0 +1,121 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import "encoding/json"
const (
RemoteBrailleMaxCells = 4096
RemoteBrailleQueueSize = 16
)
// RemoteBraille carries one Braille operation. Exactly one form is valid per
// direction and role:
//
// - "size" (controlled child to host): Width is the controller's display
// width; zero stops export.
// - "gesture" (controlled child to host, or host to controller child):
// Gesture is "pan-left", "pan-right" or "route"; Index is the route cell.
// - "cells" (controller child to host, or host to controlled child): final
// dot cells, 1 to 4,096 values of 0 to 255, never text to translate.
// - "geometry" (controller child to host asks; host to controller child
// answers with Width, zero meaning no local display).
type RemoteBraille struct {
Op string `json:"op"`
Width int `json:"width,omitempty"`
Gesture string `json:"gesture,omitempty"`
Index int `json:"index,omitempty"`
Cells []int `json:"cells,omitempty"`
}
type RemoteBrailleRequest struct {
RemoteOperation
Braille RemoteBraille `json:"braille"`
}
func validCells(cells []int) bool {
if len(cells) == 0 || len(cells) > RemoteBrailleMaxCells {
return false
}
for _, cell := range cells {
if cell < 0 || cell > 255 {
return false
}
}
return true
}
// ValidRemoteBraille checks representation only. Role and direction are
// checked by ValidRemoteBrailleFor.
func ValidRemoteBraille(value RemoteBraille) bool {
switch value.Op {
case "size":
return value.Width >= 0 && value.Width <= RemoteBrailleMaxCells && value.Gesture == "" && value.Index == 0 && value.Cells == nil
case "geometry":
return value.Width >= 0 && value.Width <= RemoteBrailleMaxCells && value.Gesture == "" && value.Index == 0 && value.Cells == nil
case "gesture":
switch value.Gesture {
case "pan-left", "pan-right":
return value.Index == 0 && value.Width == 0 && value.Cells == nil
case "route":
return value.Index >= 0 && value.Index < RemoteBrailleMaxCells && value.Width == 0 && value.Cells == nil
}
return false
case "cells":
return validCells(value.Cells) && value.Width == 0 && value.Gesture == "" && value.Index == 0
}
return false
}
// ValidRemoteBrailleFor checks direction: request is true for child-to-host.
func ValidRemoteBrailleFor(role string, request bool, value RemoteBraille) bool {
if !ValidRemoteBraille(value) {
return false
}
switch {
case role == "controlled" && request:
return value.Op == "size" || value.Op == "gesture"
case role == "controlled":
return value.Op == "cells"
case role == "controller" && request:
return value.Op == "cells" || value.Op == "geometry" && value.Width == 0
case role == "controller":
return value.Op == "gesture" || value.Op == "geometry"
}
return false
}
func ValidRemoteBrailleRequest(request RemoteBrailleRequest) bool {
return len(request.Lease) == 32 && request.Epoch != 0 && request.Sequence != 0 && ValidRemoteBraille(request.Braille)
}
func (value *RemoteBraille) UnmarshalJSON(data []byte) error {
var fields map[string]json.RawMessage
if DecodeStrict(data, &fields) != nil || fields["op"] == nil {
return ErrProtocol
}
type plain RemoteBraille
var decoded plain
if DecodeStrict(data, &decoded) != nil || !ValidRemoteBraille(RemoteBraille(decoded)) {
return ErrProtocol
}
*value = RemoteBraille(decoded)
return nil
}
func (request *RemoteBrailleRequest) UnmarshalJSON(data []byte) error {
var fields map[string]json.RawMessage
if DecodeStrict(data, &fields) != nil || len(fields) != 4 || fields["lease"] == nil || fields["epoch"] == nil ||
fields["sequence"] == nil || fields["braille"] == nil {
return ErrProtocol
}
type plain RemoteBrailleRequest
var decoded plain
if DecodeStrict(data, &decoded) != nil || !ValidRemoteBrailleRequest(RemoteBrailleRequest(decoded)) {
return ErrProtocol
}
*request = RemoteBrailleRequest(decoded)
return nil
}
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"encoding/json"
"testing"
)
func TestRemoteBrailleDirections(t *testing.T) {
size := RemoteBraille{Op: "size", Width: 40}
route := RemoteBraille{Op: "gesture", Gesture: "route", Index: 3}
cells := RemoteBraille{Op: "cells", Cells: []int{0, 255}}
query := RemoteBraille{Op: "geometry"}
answer := RemoteBraille{Op: "geometry", Width: 32}
for _, test := range []struct {
role string
request bool
value RemoteBraille
want bool
}{
{"controlled", true, size, true}, {"controlled", true, route, true}, {"controlled", true, cells, false},
{"controlled", false, cells, true}, {"controlled", false, route, false},
{"controller", true, cells, true}, {"controller", true, query, true}, {"controller", true, answer, false},
{"controller", true, size, false}, {"controller", false, route, true}, {"controller", false, answer, true},
{"controller", false, cells, false}, {"observer", true, size, false},
} {
if got := ValidRemoteBrailleFor(test.role, test.request, test.value); got != test.want {
t.Fatalf("%s request=%t %+v = %t", test.role, test.request, test.value, got)
}
}
for _, raw := range []string{
`{"op":"cells","cells":[]}`, `{"op":"cells","cells":[-1]}`, `{"op":"size","width":4097}`,
`{"op":"gesture","gesture":"route","index":4096}`, `{"op":"gesture","gesture":"script"}`,
`{"op":"gesture","gesture":"pan-left","index":1}`, `{"width":3}`, `{"op":"size","width":3,"text":"x"}`,
} {
var value RemoteBraille
if json.Unmarshal([]byte(raw), &value) == nil {
t.Fatal("invalid Braille decoded", raw)
}
}
}
+7 -1
View File
@@ -77,6 +77,7 @@ type SessionEvent struct {
Speech *RemoteSpeech `json:"speech,omitempty"`
Input *RemoteCapturedKey `json:"input,omitempty"`
Focus string `json:"focus,omitempty"`
Braille *RemoteBraille `json:"braille,omitempty"`
}
func ValidRole(role string) bool { return role == "controller" || role == "controlled" }
@@ -97,7 +98,7 @@ func ValidSessionEvent(event SessionEvent) bool {
if event.Sequence == 0 || event.Epoch == 0 {
return false
}
if event.Kind != "remote.input" && event.Input != nil || event.Kind != "remote.focus" && event.Focus != "" {
if event.Kind != "remote.input" && event.Input != nil || event.Kind != "remote.focus" && event.Focus != "" || event.Kind != "remote.braille" && event.Braille != nil {
return false
}
switch event.Kind {
@@ -107,6 +108,11 @@ func ValidSessionEvent(event SessionEvent) bool {
return event.Speech == nil && ((event.Action == "" && len(event.Lease) == 32) || (len(event.Action) == 32 && event.Lease == "")) && event.Role == "" && len(event.Grants) == 0 && slices.Contains([]string{"closed", "expired", "local", "replaced"}, event.Reason)
case "remote.input":
return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech == nil && event.Input != nil && ValidRemoteCapturedKey(*event.Input)
case "remote.braille":
// Role is not carried on events; either direction-valid form is accepted
// and the receiving child applies its own role.
return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech == nil && event.Braille != nil &&
(ValidRemoteBrailleFor("controlled", false, *event.Braille) || ValidRemoteBrailleFor("controller", false, *event.Braille))
case "remote.focus":
return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Speech == nil && ValidRemoteFocusEvent(event.Focus, event.Reason)
case "remote.output":
+16
View File
@@ -0,0 +1,16 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import "git.stormux.org/storm/cthulhu/internal/tentaclewire"
type RemoteBraille = tentaclewire.RemoteBraille
type RemoteBrailleRequest = tentaclewire.RemoteBrailleRequest
func ValidRemoteBraille(value RemoteBraille) bool { return tentaclewire.ValidRemoteBraille(value) }
func ValidRemoteBrailleRequest(request RemoteBrailleRequest) bool {
return tentaclewire.ValidRemoteBrailleRequest(request)
}
+53 -2
View File
@@ -166,6 +166,48 @@ def valid_remote_focus_request(request):
return False
def valid_remote_braille(value, role=None, request=None):
"""Validate one Remote Braille operation; role/request also check direction."""
try:
_fields(value, ("op",), ("width", "gesture", "index", "cells"))
op = value["op"]
width, gesture, index = value.get("width", 0), value.get("gesture", ""), value.get("index", 0)
cells = value.get("cells")
if type(width) is not int or type(index) is not int or not isinstance(gesture, str):
return False
if op in ("size", "geometry"):
ok = 0 <= width <= 4096 and gesture == "" and index == 0 and cells is None
elif op == "gesture":
ok = (width == 0 and cells is None
and (gesture in ("pan-left", "pan-right") and index == 0
or gesture == "route" and 0 <= index < 4096))
elif op == "cells":
ok = (isinstance(cells, list) and 1 <= len(cells) <= 4096
and all(type(cell) is int and 0 <= cell <= 255 for cell in cells)
and width == 0 and gesture == "" and index == 0)
else:
return False
if not ok or role is None:
return ok
if role == "controlled":
return op in ("size", "gesture") if request else op == "cells"
if role == "controller":
return (op == "cells" or op == "geometry" and width == 0) if request else op in ("gesture", "geometry")
return False
except (ProtocolError, TypeError, ValueError):
return False
def valid_remote_braille_request(request):
"""Validate remote.braille parameters; display effects remain owned by core."""
try:
_fields(request, ("lease", "epoch", "sequence", "braille"))
return (_token(request["lease"]) and _positive(request["epoch"])
and _positive(request["sequence"]) and valid_remote_braille(request["braille"]))
except (ProtocolError, TypeError, ValueError, UnicodeError):
return False
REMOTE_FOCUS_REASONS = frozenset(("requested", "escape", "local", "unavailable"))
@@ -358,11 +400,12 @@ class SessionClient:
waiter.put_nowait(value)
def _event(self, event):
_fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason", "speech", "input", "focus"))
_fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason", "speech", "input", "focus", "braille"))
if not _positive(event["sequence"]) or not _positive(event["epoch"]):
raise ProtocolError("Invalid event sequence")
if (event["kind"] != "remote.input" and "input" in event
or event["kind"] != "remote.focus" and event.get("focus", "") != ""):
or event["kind"] != "remote.focus" and event.get("focus", "") != ""
or event["kind"] != "remote.braille" and "braille" in event):
raise ProtocolError("Invalid session event")
if event["kind"] == "remote.action":
grants = event.get("grants")
@@ -386,6 +429,14 @@ class SessionClient:
or event.get("reason", "") != "" or "speech" in event
or not valid_remote_captured_key(event.get("input"))):
raise ProtocolError("Invalid input event")
elif event["kind"] == "remote.braille":
braille = event.get("braille")
if (not _token(event.get("lease")) or event.get("action", "") != ""
or event.get("role", "") != "" or event.get("grants", []) != []
or event.get("reason", "") != "" or "speech" in event
or not (valid_remote_braille(braille, "controlled", False)
or valid_remote_braille(braille, "controller", False))):
raise ProtocolError("Invalid Braille event")
elif event["kind"] == "remote.focus":
if (not _token(event.get("lease")) or event.get("action", "") != ""
or event.get("role", "") != "" or event.get("grants", []) != []