From 2d5327adf4366af82e29782997c57e2f82bded20 Mon Sep 17 00:00:00 2001 From: Chase Cichorz Date: Fri, 6 Nov 2026 14:03:38 -0600 Subject: [PATCH] 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 --- docs/tentacle-remote-capture.md | 50 +++++++ internal/tentacle/remote.go | 12 ++ internal/tentacle/remote_capture.go | 107 ++++++++++++++ internal/tentacle/remote_capture_test.go | 147 +++++++++++++++++++ internal/tentacle/session.go | 1 + internal/tentaclewire/remote_capture.go | 100 +++++++++++++ internal/tentaclewire/remote_capture_test.go | 81 ++++++++++ internal/tentaclewire/session.go | 27 ++-- sdk/go/tentacle/remote_capture.go | 23 +++ sdk/python/cthulhu_tentacle/session.py | 45 +++++- 10 files changed, 583 insertions(+), 10 deletions(-) create mode 100644 docs/tentacle-remote-capture.md create mode 100644 internal/tentacle/remote_capture.go create mode 100644 internal/tentacle/remote_capture_test.go create mode 100644 internal/tentaclewire/remote_capture.go create mode 100644 internal/tentaclewire/remote_capture_test.go create mode 100644 sdk/go/tentacle/remote_capture.go diff --git a/docs/tentacle-remote-capture.md b/docs/tentacle-remote-capture.md new file mode 100644 index 0000000..e453d8c --- /dev/null +++ b/docs/tentacle-remote-capture.md @@ -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. diff --git a/internal/tentacle/remote.go b/internal/tentacle/remote.go index 02234c3..3aaeab6 100644 --- a/internal/tentacle/remote.go +++ b/internal/tentacle/remote.go @@ -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) { diff --git a/internal/tentacle/remote_capture.go b/internal/tentacle/remote_capture.go new file mode 100644 index 0000000..4e48b5d --- /dev/null +++ b/internal/tentacle/remote_capture.go @@ -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 +} diff --git a/internal/tentacle/remote_capture_test.go b/internal/tentacle/remote_capture_test.go new file mode 100644 index 0000000..c78e386 --- /dev/null +++ b/internal/tentacle/remote_capture_test.go @@ -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") + } + }) + } +} diff --git a/internal/tentacle/session.go b/internal/tentacle/session.go index 544e520..7b7ae0e 100644 --- a/internal/tentacle/session.go +++ b/internal/tentacle/session.go @@ -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 } diff --git a/internal/tentaclewire/remote_capture.go b/internal/tentaclewire/remote_capture.go new file mode 100644 index 0000000..a90822c --- /dev/null +++ b/internal/tentaclewire/remote_capture.go @@ -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] +} diff --git a/internal/tentaclewire/remote_capture_test.go b/internal/tentaclewire/remote_capture_test.go new file mode 100644 index 0000000..703269a --- /dev/null +++ b/internal/tentaclewire/remote_capture_test.go @@ -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) + } + } +} diff --git a/internal/tentaclewire/session.go b/internal/tentaclewire/session.go index f11ee58..549cb58 100644 --- a/internal/tentaclewire/session.go +++ b/internal/tentaclewire/session.go @@ -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) } diff --git a/sdk/go/tentacle/remote_capture.go b/sdk/go/tentacle/remote_capture.go new file mode 100644 index 0000000..7e13cd4 --- /dev/null +++ b/sdk/go/tentacle/remote_capture.go @@ -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) +} diff --git a/sdk/python/cthulhu_tentacle/session.py b/sdk/python/cthulhu_tentacle/session.py index c471bf5..ccf68e4 100644 --- a/sdk/python/cthulhu_tentacle/session.py +++ b/sdk/python/cthulhu_tentacle/session.py @@ -141,6 +141,34 @@ def valid_remote_key_request(request): 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): def __init__(self, code, message): super().__init__("Tentacle session error " + str(code)) @@ -330,9 +358,12 @@ class SessionClient: waiter.put_nowait(value) 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"]): 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": grants = event.get("grants") role = event.get("role") @@ -349,6 +380,18 @@ class SessionClient: 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")): 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": if (not _token(event.get("lease")) or event.get("action", "") != "" or event.get("role", "") != "" or event.get("grants", []) != []