Add Remote controller capture IPC to the host and both SDKs

remote.focus lets a controller keyboard-capture lease ask core to enter
or leave remote focus; the reply acknowledges admission only. Core
reports the actual state with remote.focus events and forwards routed
transitions as remote.input events carrying the source keycode and, on
fresh presses, a canonical key name. A full focus mailbox revokes the
lease; revocation drains pending focus work. No native capture 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 27017bef11
commit 2d5327adf4
10 files changed
+583 -10

No files matched your search

+12
View File
@@ -191,6 +191,16 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
}
}
}
if session.remoteCapture != nil {
drainCapture:
for {
select {
case <-session.remoteCapture:
default:
break drainCapture
}
}
}
if session.remoteKeys != nil {
drainKeys:
for {
@@ -280,6 +290,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage
return session.remote_present_locked(params)
case "remote.key":
return session.remote_key_locked(params)
case "remote.focus":
return session.remote_focus_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) {
+107
View File
@@ -0,0 +1,107 @@
// 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"
)
// RemoteFocusDelivery is an admitted controller focus request. The reader's
// capture owner must recheck Context and RemoteContext(keyboard-capture) and
// its own focus/security policy before acquiring capture, and must report the
// actual result with PublishRemoteFocus. A child request never proves that
// capture is physically active.
type RemoteFocusDelivery struct {
Context context.Context
Lease tentaclewire.RemoteLease
Sequence uint64
Remote bool
}
// EnableRemoteCapture attaches one reader-owned controller capture mailbox.
// It performs no native setup and grants no authority.
func (session *Session) EnableRemoteCapture() <-chan RemoteFocusDelivery {
session.mutex.Lock()
defer session.mutex.Unlock()
if session.remoteCapture == nil {
session.remoteCapture = make(chan RemoteFocusDelivery, tentaclewire.RemoteFocusQueueSize)
}
return session.remoteCapture
}
func (session *Session) remote_focus_locked(params json.RawMessage) (any, *RPCError) {
var request tentaclewire.RemoteFocusRequest
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, "keyboard-capture")
if access == nil || access.role != "controller" || request.Sequence <= access.sequence {
return nil, &RPCError{Code: -32001, Message: "Session permission denied"}
}
if session.remoteCapture == nil {
return nil, &RPCError{Code: -32601, Message: "Remote keyboard capture unavailable"}
}
access.sequence = request.Sequence
select {
case session.remoteCapture <- RemoteFocusDelivery{Context: access.ctx, Lease: lease, Sequence: request.Sequence, Remote: request.Remote}:
return struct{}{}, nil
default:
// A lost "local" request must never leave capture active.
session.revoke_remote_locked("local", true)
return nil, &RPCError{Code: -32002, Message: "Remote focus queue full"}
}
}
func (session *Session) capture_access_locked(lease tentaclewire.RemoteLease) *remoteAccess {
access := session.remote_access_locked(lease, "keyboard-capture")
if access == nil || access.role != "controller" {
return nil
}
return access
}
// PublishRemoteInput forwards one captured transition. Origin is the reader's
// exclusive capture owner, never child RPC or network input. A full event
// queue fails the session: losing a release must not leave a healthy stream.
func (session *Session) PublishRemoteInput(lease tentaclewire.RemoteLease, key tentaclewire.RemoteCapturedKey) error {
if !tentaclewire.ValidRemoteCapturedKey(key) {
return ErrPermission
}
session.mutex.Lock()
defer session.mutex.Unlock()
if session.capture_access_locked(lease) == nil {
return ErrPermission
}
event := tentaclewire.SessionEvent{Kind: "remote.input", Lease: lease.Lease, Epoch: lease.Epoch, Input: &key}
if !session.remote_event_locked(&event) {
session.fail_locked(ErrLimit)
return ErrLimit
}
return nil
}
// PublishRemoteFocus reports the actual capture state for this lease.
// Reporting local focus is allowed after the lease was revoked only through
// the revocation event itself, so a retired lease returns ErrPermission.
func (session *Session) PublishRemoteFocus(lease tentaclewire.RemoteLease, focus, reason string) error {
if !tentaclewire.ValidRemoteFocusEvent(focus, reason) {
return ErrPermission
}
session.mutex.Lock()
defer session.mutex.Unlock()
if session.capture_access_locked(lease) == nil {
return ErrPermission
}
event := tentaclewire.SessionEvent{Kind: "remote.focus", Lease: lease.Lease, Epoch: lease.Epoch, Focus: focus, Reason: reason}
if !session.remote_event_locked(&event) {
session.fail_locked(ErrLimit)
return ErrLimit
}
return nil
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"encoding/json"
"errors"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
func focusRequest(lease tentaclewire.RemoteLease, sequence uint64, remote bool) tentaclewire.RemoteFocusRequest {
return tentaclewire.RemoteFocusRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Remote: remote}
}
func nextSDKEvent(t *testing.T, session *Session) tentaclewire.SessionEvent {
t.Helper()
var event tentaclewire.SessionEvent
if err := json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event); err != nil {
t.Fatal(err)
}
return event
}
func TestRemoteCaptureSDKFocusAndInputEvents(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, "capture", "keyboard-capture")
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present", "keyboard-capture"})
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, true)); code != -32601 {
t.Fatal("focus admitted without capture owner", code)
}
deliveries := session.EnableRemoteCapture()
if session.EnableRemoteCapture() != deliveries {
t.Fatal("attachment created another capture mailbox")
}
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, true)); code != 0 {
t.Fatal("valid focus request refused", code)
}
delivery := <-deliveries
if !delivery.Remote || delivery.Lease != lease || delivery.Sequence != 1 || delivery.Context.Err() != nil {
t.Fatal("focus delivery changed identity or lifetime", delivery)
}
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, false)); code != -32001 {
t.Fatal("replayed focus sequence accepted", code)
}
if err := session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusRemote, "requested"); err != nil {
t.Fatal(err)
}
if event := nextSDKEvent(t, session); event.Kind != "remote.focus" || event.Focus != "remote" || event.Reason != "requested" || event.Lease != lease.Lease {
t.Fatal("bad focus event", event)
}
keys := []tentaclewire.RemoteCapturedKey{
{Code: 50, Name: "Shift_L", Pressed: true},
{Code: 38, Name: "a", Pressed: true},
{Code: 38, Pressed: true, Repeat: true},
{Code: 38},
{Code: 50},
}
for _, key := range keys {
if err := session.PublishRemoteInput(lease, key); err != nil {
t.Fatal(err)
}
}
for _, key := range keys {
event := nextSDKEvent(t, session)
if event.Kind != "remote.input" || event.Input == nil || *event.Input != key || event.Epoch != lease.Epoch {
t.Fatal("captured key changed in transit", event)
}
}
for _, invalid := range []tentaclewire.RemoteCapturedKey{
{Code: 7, Name: "a", Pressed: true},
{Code: 38, Name: "A", Pressed: true},
{Code: 38, Name: "a"}, // Release carries no name.
{Code: 38, Repeat: true},
} {
if !errors.Is(session.PublishRemoteInput(lease, invalid), ErrPermission) {
t.Fatal("invalid captured key published", invalid)
}
}
if !errors.Is(session.PublishRemoteFocus(lease, "remote", "network"), ErrPermission) {
t.Fatal("unknown focus reason published")
}
session.RevokeRemote()
if event := nextSDKEvent(t, session); event.Kind != "remote.revoked" {
t.Fatal("missing revocation", event)
}
if !errors.Is(session.PublishRemoteInput(lease, keys[0]), ErrPermission) || !errors.Is(session.PublishRemoteFocus(lease, "local", "local"), ErrPermission) {
t.Fatal("retired lease published capture events")
}
})
}
}
func TestRemoteCaptureSDKPermissionAndParameters(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, "capture", "keyboard-capture", "keyboard-inject")
deliveries := session.EnableRemoteCapture()
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"})
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, true)); code != -32001 {
t.Fatal("speech-only controller requested capture", code)
}
if !errors.Is(session.PublishRemoteInput(lease, tentaclewire.RemoteCapturedKey{Code: 38, Name: "a", Pressed: true}), ErrPermission) {
t.Fatal("input published without keyboard-capture")
}
lease = speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 1, true)); code != -32001 {
t.Fatal("controlled role requested capture", code)
}
lease = speech_fixture_lease(t, session, "controller", []string{"keyboard-capture"})
data, _ := json.Marshal(focusRequest(lease, 1, true))
for _, raw := range []string{
strings.Replace(string(data), `"remote":true`, `"remote":null`, 1),
strings.Replace(string(data), `,"remote":true`, ``, 1),
strings.Replace(string(data), `"sequence":1`, `"sequence":1,"origin":"local"`, 1),
} {
if _, code := remoteCall(t, session, "remote.focus", json.RawMessage(raw)); code != -32602 {
t.Fatal("invalid focus parameters admitted", code, raw)
}
}
if len(deliveries) != 0 {
t.Fatal("denied focus reached the owner")
}
for sequence := uint64(1); sequence <= tentaclewire.RemoteFocusQueueSize; sequence++ {
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, sequence, sequence%2 == 1)); code != 0 {
t.Fatal("focus request refused before saturation", code)
}
}
if _, code := remoteCall(t, session, "remote.focus", focusRequest(lease, 99, false)); code != -32002 {
t.Fatal("full focus queue did not fail", code)
}
if _, err := session.RemoteContext(lease, "keyboard-capture"); err == nil {
t.Fatal("full focus queue left capture consent active")
}
if len(deliveries) != 0 {
t.Fatal("revocation did not drain pending focus work")
}
})
}
}
+1
View File
@@ -78,6 +78,7 @@ type Session struct {
remoteApproval *RemoteApproval
remoteSpeech chan RemoteSpeechDelivery
remoteKeys chan RemoteKeyDelivery
remoteCapture chan RemoteFocusDelivery
}
func (session *Session) Ready() <-chan struct{} { return session.ready }
+100
View File
@@ -0,0 +1,100 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"encoding/json"
"git.stormux.org/storm/cthulhu/internal/input"
)
// RemoteFocusQueueSize bounds pending controller focus requests per session.
const RemoteFocusQueueSize = 4
// Focus states and the reasons a controller focus event can report.
const (
RemoteFocusLocal = "local"
RemoteFocusRemote = "remote"
)
var remoteFocusReasons = map[string]bool{
"requested": true, // The child asked for this state.
"escape": true, // The core escape returned to local focus.
"local": true, // Reader policy (lock, handoff, reader UI) ended capture.
"unavailable": true, // Capture could not be acquired.
}
// RemoteCapturedKey is one controller transition from the reader's exclusive
// capture, after repeat normalization and local-chord routing. Code is the
// source Xorg keycode, used only to pair repeats and releases. Name is the
// canonical unshifted key name and is present only on a fresh press; releases
// and repeats are matched by Code so a layout change cannot strand a key.
type RemoteCapturedKey struct {
Code int `json:"code"`
Name string `json:"name,omitempty"`
Pressed bool `json:"pressed"`
Repeat bool `json:"repeat,omitempty"`
}
func ValidRemoteCapturedKey(key RemoteCapturedKey) bool {
if key.Code < 8 || key.Code > 255 {
return false
}
if key.Pressed && !key.Repeat {
return input.ValidRemoteKeyName(key.Name)
}
return key.Name == "" && (key.Pressed || !key.Repeat)
}
func (key *RemoteCapturedKey) UnmarshalJSON(data []byte) error {
var fields map[string]json.RawMessage
if DecodeStrict(data, &fields) != nil || fields["code"] == nil || fields["pressed"] == nil {
return ErrProtocol
}
type plain RemoteCapturedKey
var value plain
if DecodeStrict(data, &value) != nil || !ValidRemoteCapturedKey(RemoteCapturedKey(value)) {
return ErrProtocol
}
*key = RemoteCapturedKey(value)
return nil
}
// RemoteFocusRequest asks core to enter or leave remote focus for the current
// controller keyboard-capture lease. The reply acknowledges admission only;
// the actual state is reported by a remote.focus event.
type RemoteFocusRequest struct {
RemoteOperation
Remote bool `json:"remote"`
}
func ValidRemoteFocusRequest(request RemoteFocusRequest) bool {
return len(request.Lease) == 32 && request.Epoch != 0 && request.Sequence != 0
}
func (request *RemoteFocusRequest) 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["remote"] == nil {
return ErrProtocol
}
var value struct {
RemoteOperation
Remote *bool `json:"remote"`
}
if DecodeStrict(data, &value) != nil || value.Remote == nil {
return ErrProtocol
}
result := RemoteFocusRequest{RemoteOperation: value.RemoteOperation, Remote: *value.Remote}
if !ValidRemoteFocusRequest(result) {
return ErrProtocol
}
*request = result
return nil
}
func ValidRemoteFocusEvent(focus, reason string) bool {
return (focus == RemoteFocusLocal || focus == RemoteFocusRemote) && remoteFocusReasons[reason]
}
@@ -0,0 +1,81 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"encoding/json"
"strings"
"testing"
)
func TestRemoteCapturedKeyValidation(t *testing.T) {
for _, key := range []RemoteCapturedKey{
{Code: 8, Name: "a", Pressed: true}, {Code: 255, Name: "Shift_R", Pressed: true},
{Code: 38, Pressed: true, Repeat: true}, {Code: 38},
} {
if !ValidRemoteCapturedKey(key) {
t.Fatal("valid key rejected", key)
}
data, _ := json.Marshal(key)
var decoded RemoteCapturedKey
if json.Unmarshal(data, &decoded) != nil || decoded != key {
t.Fatal("round trip changed key", key)
}
}
for _, raw := range []string{
`{"code":7,"name":"a","pressed":true}`, `{"code":256,"name":"a","pressed":true}`,
`{"code":38,"name":"Prior","pressed":true}`, `{"code":38,"name":"a","pressed":false}`,
`{"code":38,"pressed":false,"repeat":true}`, `{"code":38,"pressed":true}`,
`{"name":"a","pressed":true}`, `{"code":38,"name":"a"}`,
`{"code":38,"name":"a","pressed":true,"origin":"local"}`, `{"code":38,"code":39,"name":"a","pressed":true}`,
} {
var key RemoteCapturedKey
if json.Unmarshal([]byte(raw), &key) == nil {
t.Fatal("invalid key decoded", raw)
}
}
}
func TestRemoteFocusRequestAndEventValidation(t *testing.T) {
lease := strings.Repeat("a", 32)
var request RemoteFocusRequest
if json.Unmarshal([]byte(`{"lease":"`+lease+`","epoch":1,"sequence":2,"remote":false}`), &request) != nil || request.Remote || request.Sequence != 2 {
t.Fatal("valid focus request rejected", request)
}
for _, raw := range []string{
`{"lease":"` + lease + `","epoch":1,"sequence":2}`,
`{"lease":"` + lease + `","epoch":1,"sequence":0,"remote":true}`,
`{"lease":"short","epoch":1,"sequence":2,"remote":true}`,
`{"lease":"` + lease + `","epoch":1,"sequence":2,"remote":"yes"}`,
} {
if json.Unmarshal([]byte(raw), &request) == nil {
t.Fatal("invalid focus request decoded", raw)
}
}
key := RemoteCapturedKey{Code: 38, Name: "a", Pressed: true}
valid := []SessionEvent{
{Sequence: 1, Epoch: 1, Kind: "remote.input", Lease: lease, Input: &key},
{Sequence: 1, Epoch: 1, Kind: "remote.focus", Lease: lease, Focus: "remote", Reason: "requested"},
{Sequence: 1, Epoch: 1, Kind: "remote.focus", Lease: lease, Focus: "local", Reason: "escape"},
}
for _, event := range valid {
if !ValidSessionEvent(event) {
t.Fatal("valid event rejected", event)
}
}
invalid := []SessionEvent{
{Sequence: 1, Epoch: 1, Kind: "remote.input", Lease: lease},
{Sequence: 1, Epoch: 1, Kind: "remote.input", Lease: lease, Input: &key, Focus: "remote"},
{Sequence: 1, Epoch: 1, Kind: "remote.focus", Lease: lease, Focus: "remote", Reason: "network"},
{Sequence: 1, Epoch: 1, Kind: "remote.focus", Lease: lease, Focus: "elsewhere", Reason: "requested"},
{Sequence: 1, Epoch: 1, Kind: "remote.revoked", Lease: lease, Reason: "closed", Input: &key},
{Sequence: 1, Epoch: 1, Kind: "remote.revoked", Lease: lease, Reason: "closed", Focus: "local"},
}
for _, event := range invalid {
if ValidSessionEvent(event) {
t.Fatal("invalid event accepted", event)
}
}
}
+18 -9
View File
@@ -66,15 +66,17 @@ func ValidNegotiation(minor int, apis, capabilities []string) bool {
// 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"`
Speech *RemoteSpeech `json:"speech,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"`
Input *RemoteCapturedKey `json:"input,omitempty"`
Focus string `json:"focus,omitempty"`
}
func ValidRole(role string) bool { return role == "controller" || role == "controlled" }
@@ -95,11 +97,18 @@ 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 != "" {
return false
}
switch event.Kind {
case "remote.action":
return event.Speech == nil && len(event.Action) == 32 && event.Lease == "" && event.Reason == "" && ValidRoleGrants(event.Role, event.Grants)
case "remote.revoked":
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.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":
return event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech != nil && ValidRemoteSpeech(*event.Speech)
}