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:
1 parent
27017bef11
commit
2d5327adf4
10 files changed
+583
-10
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
# Remote controller keyboard capture IPC
|
||||||
|
|
||||||
|
Protocol 1.1 now carries controller keyboard capture between the reader and a
|
||||||
|
Remote Tentacle. This document describes the wire contract and host checks;
|
||||||
|
native capture is described in the following sections as it is implemented.
|
||||||
|
|
||||||
|
## Wire contract
|
||||||
|
|
||||||
|
Child-to-host `remote.focus` asks core to enter or leave remote focus:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"lease":"0123456789abcdef0123456789abcdef","epoch":1,"sequence":3,"remote":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
All four fields are required. It needs the current process session's live
|
||||||
|
controller lease with the separately approved `keyboard-capture` grant and
|
||||||
|
shares the strictly increasing operation sequence. The reply `{}` acknowledges
|
||||||
|
admission only. Without an attached reader capture owner it returns
|
||||||
|
owner-unavailable. A full four-request mailbox revokes the lease, so a lost
|
||||||
|
request to return to local focus can never leave capture active.
|
||||||
|
|
||||||
|
Host-to-child `remote.focus` events report the actual state, `focus` being
|
||||||
|
`local` or `remote` and `reason` one of `requested`, `escape`, `local` (reader
|
||||||
|
policy such as lock, handoff or reader UI) or `unavailable`. A child request
|
||||||
|
never proves that capture is physically active.
|
||||||
|
|
||||||
|
Host-to-child `remote.input` events carry one normalized, routed transition:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"code":38,"name":"a","pressed":true}
|
||||||
|
{"code":38,"pressed":true,"repeat":true}
|
||||||
|
{"code":38,"pressed":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
`code` is the source Xorg keycode (8–255), used only to pair repeats and
|
||||||
|
releases. `name` is a canonical unshifted key name from
|
||||||
|
`input.ValidRemoteKeyName` and is present only on a fresh press; releases and
|
||||||
|
repeats omit it so a layout change cannot strand a key. A full event queue
|
||||||
|
fails the session rather than losing a release.
|
||||||
|
|
||||||
|
Reader code attaches the mailbox with `Session.EnableRemoteCapture` and
|
||||||
|
publishes with `PublishRemoteInput` and `PublishRemoteFocus`; both require the
|
||||||
|
live controller `keyboard-capture` lease. Go exposes `RemoteCapturedKey`,
|
||||||
|
`RemoteFocusRequest` and validators through `sdk/go/tentacle`; Python exposes
|
||||||
|
`valid_remote_captured_key` and `valid_remote_focus_request` and validates both
|
||||||
|
event kinds. Revocation drains pending focus work.
|
||||||
|
|
||||||
|
Go/Python SDK-process tests cover owner absence, admission and replay,
|
||||||
|
role/grant denial, invalid parameters, saturation revocation, event delivery
|
||||||
|
and rejection after revocation.
|
||||||
@@ -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 {
|
if session.remoteKeys != nil {
|
||||||
drainKeys:
|
drainKeys:
|
||||||
for {
|
for {
|
||||||
@@ -280,6 +290,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage
|
|||||||
return session.remote_present_locked(params)
|
return session.remote_present_locked(params)
|
||||||
case "remote.key":
|
case "remote.key":
|
||||||
return session.remote_key_locked(params)
|
return session.remote_key_locked(params)
|
||||||
|
case "remote.focus":
|
||||||
|
return session.remote_focus_locked(params)
|
||||||
case "remote.open":
|
case "remote.open":
|
||||||
var request tentaclewire.RemoteOpen
|
var request tentaclewire.RemoteOpen
|
||||||
if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) {
|
if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ type Session struct {
|
|||||||
remoteApproval *RemoteApproval
|
remoteApproval *RemoteApproval
|
||||||
remoteSpeech chan RemoteSpeechDelivery
|
remoteSpeech chan RemoteSpeechDelivery
|
||||||
remoteKeys chan RemoteKeyDelivery
|
remoteKeys chan RemoteKeyDelivery
|
||||||
|
remoteCapture chan RemoteFocusDelivery
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) Ready() <-chan struct{} { return session.ready }
|
func (session *Session) Ready() <-chan struct{} { return session.ready }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,15 +66,17 @@ func ValidNegotiation(minor int, apis, capabilities []string) bool {
|
|||||||
// SessionEvent carries lifecycle metadata and explicitly typed output. Output
|
// SessionEvent carries lifecycle metadata and explicitly typed output. Output
|
||||||
// observation still requires a reader owner and a live role-bound lease.
|
// observation still requires a reader owner and a live role-bound lease.
|
||||||
type SessionEvent struct {
|
type SessionEvent struct {
|
||||||
Sequence uint64 `json:"sequence"`
|
Sequence uint64 `json:"sequence"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Action string `json:"action,omitempty"`
|
Action string `json:"action,omitempty"`
|
||||||
Lease string `json:"lease,omitempty"`
|
Lease string `json:"lease,omitempty"`
|
||||||
Epoch uint64 `json:"epoch"`
|
Epoch uint64 `json:"epoch"`
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
Grants []string `json:"grants,omitempty"`
|
Grants []string `json:"grants,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Speech *RemoteSpeech `json:"speech,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" }
|
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 {
|
if event.Sequence == 0 || event.Epoch == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if event.Kind != "remote.input" && event.Input != nil || event.Kind != "remote.focus" && event.Focus != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
switch event.Kind {
|
switch event.Kind {
|
||||||
case "remote.action":
|
case "remote.action":
|
||||||
return event.Speech == nil && 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":
|
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)
|
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":
|
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 event.Action == "" && len(event.Lease) == 32 && event.Role == "" && len(event.Grants) == 0 && event.Reason == "" && event.Speech != nil && ValidRemoteSpeech(*event.Speech)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// 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 RemoteCapturedKey = tentaclewire.RemoteCapturedKey
|
||||||
|
type RemoteFocusRequest = tentaclewire.RemoteFocusRequest
|
||||||
|
|
||||||
|
const (
|
||||||
|
RemoteFocusLocal = tentaclewire.RemoteFocusLocal
|
||||||
|
RemoteFocusRemote = tentaclewire.RemoteFocusRemote
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidRemoteCapturedKey(key RemoteCapturedKey) bool {
|
||||||
|
return tentaclewire.ValidRemoteCapturedKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidRemoteFocusRequest(request RemoteFocusRequest) bool {
|
||||||
|
return tentaclewire.ValidRemoteFocusRequest(request)
|
||||||
|
}
|
||||||
@@ -141,6 +141,34 @@ def valid_remote_key_request(request):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def valid_remote_captured_key(key):
|
||||||
|
"""Validate one controller capture transition from core, without desktop access."""
|
||||||
|
try:
|
||||||
|
_fields(key, ("code", "pressed"), ("name", "repeat"))
|
||||||
|
code, pressed = key["code"], key["pressed"]
|
||||||
|
name, repeat = key.get("name", ""), key.get("repeat", False)
|
||||||
|
if type(code) is not int or not 8 <= code <= 255 or type(pressed) is not bool or type(repeat) is not bool:
|
||||||
|
return False
|
||||||
|
if pressed and not repeat:
|
||||||
|
return valid_remote_key({"name": name, "pressed": True})
|
||||||
|
return name == "" and (pressed or not repeat)
|
||||||
|
except (ProtocolError, TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def valid_remote_focus_request(request):
|
||||||
|
"""Validate remote.focus parameters; capture remains owned by core."""
|
||||||
|
try:
|
||||||
|
_fields(request, ("lease", "epoch", "sequence", "remote"))
|
||||||
|
return (_token(request["lease"]) and _positive(request["epoch"])
|
||||||
|
and _positive(request["sequence"]) and type(request["remote"]) is bool)
|
||||||
|
except (ProtocolError, TypeError, ValueError, UnicodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
REMOTE_FOCUS_REASONS = frozenset(("requested", "escape", "local", "unavailable"))
|
||||||
|
|
||||||
|
|
||||||
class SessionError(Exception):
|
class SessionError(Exception):
|
||||||
def __init__(self, code, message):
|
def __init__(self, code, message):
|
||||||
super().__init__("Tentacle session error " + str(code))
|
super().__init__("Tentacle session error " + str(code))
|
||||||
@@ -330,9 +358,12 @@ class SessionClient:
|
|||||||
waiter.put_nowait(value)
|
waiter.put_nowait(value)
|
||||||
|
|
||||||
def _event(self, event):
|
def _event(self, event):
|
||||||
_fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason", "speech"))
|
_fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason", "speech", "input", "focus"))
|
||||||
if not _positive(event["sequence"]) or not _positive(event["epoch"]):
|
if not _positive(event["sequence"]) or not _positive(event["epoch"]):
|
||||||
raise ProtocolError("Invalid event sequence")
|
raise ProtocolError("Invalid event sequence")
|
||||||
|
if (event["kind"] != "remote.input" and "input" in event
|
||||||
|
or event["kind"] != "remote.focus" and event.get("focus", "") != ""):
|
||||||
|
raise ProtocolError("Invalid session event")
|
||||||
if event["kind"] == "remote.action":
|
if event["kind"] == "remote.action":
|
||||||
grants = event.get("grants")
|
grants = event.get("grants")
|
||||||
role = event.get("role")
|
role = event.get("role")
|
||||||
@@ -349,6 +380,18 @@ class SessionClient:
|
|||||||
if ("speech" in event or not identity or event.get("role", "") != ""
|
if ("speech" in event or not identity or event.get("role", "") != ""
|
||||||
or event.get("grants", []) != [] or event.get("reason") not in ("closed", "expired", "local", "replaced")):
|
or event.get("grants", []) != [] or event.get("reason") not in ("closed", "expired", "local", "replaced")):
|
||||||
raise ProtocolError("Invalid revoke event")
|
raise ProtocolError("Invalid revoke event")
|
||||||
|
elif event["kind"] == "remote.input":
|
||||||
|
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_captured_key(event.get("input"))):
|
||||||
|
raise ProtocolError("Invalid input event")
|
||||||
|
elif event["kind"] == "remote.focus":
|
||||||
|
if (not _token(event.get("lease")) or event.get("action", "") != ""
|
||||||
|
or event.get("role", "") != "" or event.get("grants", []) != []
|
||||||
|
or "speech" in event or event.get("focus") not in ("local", "remote")
|
||||||
|
or event.get("reason") not in REMOTE_FOCUS_REASONS):
|
||||||
|
raise ProtocolError("Invalid focus event")
|
||||||
elif event["kind"] == "remote.output":
|
elif event["kind"] == "remote.output":
|
||||||
if (not _token(event.get("lease")) or event.get("action", "") != ""
|
if (not _token(event.get("lease")) or event.get("action", "") != ""
|
||||||
or event.get("role", "") != "" or event.get("grants", []) != []
|
or event.get("role", "") != "" or event.get("grants", []) != []
|
||||||
|
|||||||
Reference in new issue
Block a user