Add lease-bound Remote key IPC and SDK validation

This commit is contained in:
ak47 committed 2026-11-05 21:28:29 -05:00
1 parent 9ac33560dc
commit 792cac5d0b
12 files changed
+807 -9

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
# Experimental Remote keyboard IPC prerequisite
The protocol 1.1 host recognizes `remote.key`, with public Go types and matching
Python validation. Reader code can explicitly attach a bounded incoming-key
mailbox. No production input owner is attached: ordinary reader sessions return
owner-unavailable, and the external Remote command still discards keyboard
payloads. This is not desktop keyboard control.
## Wire contract
One child-to-host request carries one logical transition:
```json
{"lease":"0123456789abcdef0123456789abcdef","epoch":1,"sequence":2,
"key":{"name":"Control_R","pressed":false}}
```
All fields are required, including boolean `pressed` on release. Field names
are exact; unknown fields, duplicate keys, nulls, missing values, nonboolean
press state and invalid numeric identities are rejected. Epoch and sequence
are positive uint64 values. Lease identifiers have the existing 32-byte bound;
only the actual token issued to this process can authorize work.
`name` identifies one canonical unshifted logical key. It is neither text to
type nor an X keycode, Windows VK/scan code, script path or command. The bounded
allowlist matches the external mapper's canonical subset:
- Lowercase Latin letter names, top-row digits, F1–F24 and KP_0–KP_9.
- Left/right Shift, Control, Alt and Super; Menu, lock keys, Pause and Print.
- Backspace, Tab, Return, Escape and Space; main navigation, Insert and Delete.
- Keypad Enter, navigation, Begin, Insert, Delete and arithmetic/separator keys.
- The eleven explicit punctuation identities supported by the external US
profile. The owner must resolve the destination layout; this does not grant
permission to guess a matching physical key or change the global keymap.
Aliases such as `Prior`, uppercase text, shifted character names, AltGr,
dead/IME keys, arbitrary Unicode and unrecognized names remain unsupported.
For exact case/spelling, see `ValidRemoteKey` in
`internal/tentaclewire/remote_key.go`. No implicit modifier transitions, text
insertion or fallback commands are synthesized.
Go exposes `RemoteKey`, `RemoteKeyRequest`, `ValidRemoteKey` and
`ValidRemoteKeyRequest` through `sdk/go/tentacle`. Python exposes
`valid_remote_key` and `valid_remote_key_request` from
`cthulhu_tentacle.session`. They validate representation, not consent or native
availability. Generic `SessionClient.Call`/`call` still permits method-specific
host errors; callers may use the validators before submitting work. Older hosts
return unsupported-operation for this addition.
## Authorization and admission
The host requires the current process session's live controlled-role lease and
the separately approved `keyboard-inject` grant. Installation grants, observer
consent, controller role, relay membership and a child's claimed origin cannot
authorize injection. Parameters contain no local-origin or consent override.
`remote.key` shares the strictly increasing operation sequence with heartbeat,
close and other lease operations. An accepted key advances it without renewing
the heartbeat deadline. Replayed, foreign, expired or cancelled operations are
denied. Invalid parameters and missing-owner errors do not consume the lease
sequence or enqueue work.
`Session.EnableRemoteKeys` is a reader API, not a child RPC. It attaches one
256-item mailbox. Retained queued plus in-flight keys are bounded by 256 items
and 256 KiB of canonical encoded request bytes, including lease/sequence
metadata. Receiving a delivery does not return capacity. Its idempotent
`Release` returns capacity after delivery or discard, only to the original
lease. A release from a retired worker cannot alter replacement accounting.
The RPC result `{}` acknowledges mailbox admission only. It does not acknowledge
XTEST, application delivery or key recovery. Saturation revokes the lease and
drains pending work rather than dropping a key-up and continuing. Heartbeat,
revocation and shutdown do not wait for a native worker.
## Ownership and retirement
`RemoteKeyDelivery` retains the original lease, operation sequence, typed key
and cancellation context. A future input owner must check that context and
`Session.RemoteContext(lease, "keyboard-inject")` immediately before applying
each effect, alongside current focus, lock/handoff and native policy. Context
alone does not replace the deadline check. No native callback is invoked while
the host session mutex is held.
Revoke, explicit close, replacement, expiry, policy cancellation, disable and
session failure cancel accepted work and clear pending keys. Local policy
cancellation invalidates the delivery context synchronously, before asynchronous
notification/drain callbacks acquire the session lock. Restoring availability
never revives the original lease. A new explicit approval creates a new epoch.
The native owner must keep its own destination key ledger, resolve repeats and
lock state, reject physical/injected collisions and guarantee cleanup even if
the Tentacle, reader or network disappears. Cleanup must not depend on this
mailbox admitting a last up. The external outgoing encoder's transport write
acknowledgements and this API's mailbox admission are separate contracts;
neither establishes actual destination input completion.
## Verification and remaining gates
Strict representation tests compare Go and Python validators, including both
transitions, all letter/digit/function ranges, uint64 boundaries, missing/null
values, exact field names and rejected metadata. Real Go/Python SDK child
processes exercise owner-unavailable, admission, permission/role/process checks,
shared heartbeat ordering, replay, malformed requests, capacity retention,
concurrent idempotent release, overflow and retirement/replacement. The external
bridge verifies that every canonical mapper result satisfies the public SDK
contract. None of these tests injects a desktop key.
No capture event API, production Xorg input owner, independent native cleanup
helper, core escape/bypass integration or installed Remote keyboard command is
provided. Physical-source approval, physical collision/recovery, audible speech,
Braille and real NVDA/Orca interoperability remain unverified. Private IPC and
relay checks close no live release gate.
This increment passed the full wire/host/both-SDK race suites (host: about
128 seconds), focused application Remote connection/speech race tests, the
existing speech regression harness, affected-package vet, a separate reader
build and Tentacles `make check`. The lifecycle regression now checks malformed
typed keys and keeps its unsupported-operation check on Braille. No full reader
suite or native/hardware acceptance was performed; no active reader was replaced.
+9 -4
View File
@@ -11,8 +11,10 @@ remote control. A later [reader authorization increment](tentacle-remote-authori
adds the core consent procedure, sensitive XI2 forms and lock/handoff policy.
The external source-built Remote command now requests that consent through a
typed interaction; physical-keyboard approval remains unverified. There is no
output observer, keyboard service, clipboard service,
or Braille session service. The unrestricted `Session.AuthorizeRemote` hook is
production keyboard, clipboard or Braille session service. Subsequent speech
integration supplies limited observation and isolated incoming software speech;
the [keyboard IPC prerequisite](tentacle-remote-keyboard.md) adds a typed mailbox
without a native input owner. The unrestricted `Session.AuthorizeRemote` hook is
limited to development managers; reader managers require a core approval ticket.
Packaging Remote itself remains premature.
@@ -167,8 +169,10 @@ The process supervisor still enforces cleanup for a child ignoring cancellation.
Typed speech IPC now extends this transport; see
[the speech contract](tentacle-remote-speech.md). It uses the same ordered event
queue for exported speech and a separate 64-item incoming mailbox. Limited
production speech observation is connected; incoming presentation ownership is
still pending. Keyboard/Braille/clipboard effects remain unsupported.
production speech observation and isolated incoming software speech are connected.
The [keyboard IPC prerequisite](tentacle-remote-keyboard.md) adds `remote.key`
and a separate bounded mailbox, unavailable without a reader input owner.
Production keyboard/Braille/clipboard effects remain unsupported.
## Implemented lifecycle operations
@@ -192,6 +196,7 @@ Child calls:
| `remote.heartbeat` | `lease`, `epoch`, `sequence` | `{}` |
| `remote.close` | `lease`, `epoch`, `sequence` | `{}` |
| `remote.present` | `lease`, `epoch`, `sequence`, typed `speech` | `{}` after mailbox admission; unavailable without reader owner |
| `remote.key` | `lease`, `epoch`, `sequence`, typed `key` | `{}` after mailbox admission; controlled `keyboard-inject` only; unavailable without reader input owner |
The action token must match the unconsumed core approval. Role must match exactly,
and grants must be a nonempty subset of the approved install grants. Controller
+15
View File
@@ -41,6 +41,8 @@ type remoteAccess struct {
speechBatch *remoteSpeechBatch
speechItems int
speechBytes int
keyItems int
keyBytes int
}
// AuthorizeRemote is the unrestricted protocol-fixture hook. Reader managers
@@ -178,6 +180,7 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
access.cancel()
access.speechBatch = nil
access.speechItems, access.speechBytes = 0, 0
access.keyItems, access.keyBytes = 0, 0
if session.remoteSpeech != nil {
drain:
for {
@@ -188,6 +191,16 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
}
}
}
if session.remoteKeys != nil {
drainKeys:
for {
select {
case <-session.remoteKeys:
default:
break drainKeys
}
}
}
if access.stopPolicy != nil {
access.stopPolicy()
}
@@ -265,6 +278,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage
switch method {
case "remote.present":
return session.remote_present_locked(params)
case "remote.key":
return session.remote_key_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) {
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"context"
"encoding/json"
"sync"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
// RemoteKeyDelivery is accepted work, not evidence of native injection. A
// reader input owner must recheck Context, RemoteContext(keyboard-inject),
// focus/security policy and its own key ledger immediately before delivery.
// Revocation must independently release native-owned keys, without relying on
// this queue or a final key-up from the network child.
type RemoteKeyDelivery struct {
Context context.Context
Lease tentaclewire.RemoteLease
Sequence uint64
Key tentaclewire.RemoteKey
// Release returns retained capacity after delivery/discard. Dequeuing does
// not free capacity. It is idempotent and scoped to the original lease.
Release func()
}
// EnableRemoteKeys attaches one reader-owned mailbox. It performs no native
// setup and grants no authority. No production owner is attached yet, so
// remote.key remains unavailable in ordinary reader sessions.
func (session *Session) EnableRemoteKeys() <-chan RemoteKeyDelivery {
session.mutex.Lock()
defer session.mutex.Unlock()
if session.remoteKeys == nil {
session.remoteKeys = make(chan RemoteKeyDelivery, tentaclewire.RemoteKeyQueueSize)
}
return session.remoteKeys
}
func (session *Session) remote_key_locked(params json.RawMessage) (any, *RPCError) {
var request tentaclewire.RemoteKeyRequest
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-inject")
if access == nil || access.role != "controlled" || request.Sequence <= access.sequence {
return nil, &RPCError{Code: -32001, Message: "Session permission denied"}
}
if session.remoteKeys == nil {
return nil, &RPCError{Code: -32601, Message: "Remote keyboard owner unavailable"}
}
// Account canonical full request bytes, including the lease/sequence. Both
// admission counts include queued and in-flight work until Release.
data, _ := json.Marshal(request)
bytes := len(data)
if access.keyItems == tentaclewire.RemoteKeyQueueSize || access.keyBytes+bytes > tentaclewire.RemoteKeyQueueBytes {
session.revoke_remote_locked("local", true)
return nil, &RPCError{Code: -32002, Message: "Remote keyboard queue full"}
}
access.keyItems++
access.keyBytes += bytes
access.sequence = request.Sequence
var released sync.Once
delivery := RemoteKeyDelivery{Context: access.ctx, Lease: lease, Sequence: request.Sequence, Key: request.Key, Release: func() {
released.Do(func() {
session.mutex.Lock()
defer session.mutex.Unlock()
if session.remote == access {
access.keyItems--
access.keyBytes -= bytes
}
})
}}
select {
case session.remoteKeys <- delivery:
return struct{}{}, nil
default:
// Losing a release must never leave the input lease active.
session.revoke_remote_locked("local", true)
return nil, &RPCError{Code: -32002, Message: "Remote keyboard queue full"}
}
}
+270
View File
@@ -0,0 +1,270 @@
// 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"
"sync"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
func keyRequest(lease tentaclewire.RemoteLease, sequence uint64, pressed bool) tentaclewire.RemoteKeyRequest {
return tentaclewire.RemoteKeyRequest{RemoteOperation: tentaclewire.RemoteOperation{
Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence},
Key: tentaclewire.RemoteKey{Name: "Control_R", Pressed: pressed}}
}
func TestRemoteKeySDKMailboxAndSequence(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, "keys", "keyboard-inject")
lease := speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
request := keyRequest(lease, 1, true)
if _, code := remoteCall(t, session, "remote.key", request); code != -32601 {
t.Fatal("accepted keys without reader input owner", code)
}
deliveries := session.EnableRemoteKeys()
if session.EnableRemoteKeys() != deliveries {
t.Fatal("attachment created another owner mailbox")
}
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
t.Fatal("valid key was not admitted", code)
}
first := <-deliveries
if first.Key != request.Key || first.Sequence != 1 || first.Lease != lease || first.Context.Err() != nil {
t.Fatal("delivery changed key identity, sequence or lifetime")
}
if _, err := session.RemoteContext(first.Lease, "keyboard-inject"); err != nil {
t.Fatal("delivery lost original consent", err)
}
if _, code := remoteCall(t, session, "remote.key", request); code != -32001 {
t.Fatal("replayed key was accepted", code)
}
request.Sequence++
if _, code := remoteCall(t, session, "remote.heartbeat", request.RemoteOperation); code != 0 {
t.Fatal("heartbeat did not share the operation sequence", code)
}
if _, code := remoteCall(t, session, "remote.key", request); code != -32001 {
t.Fatal("key reused heartbeat sequence", code)
}
request.Sequence++
request.Key.Pressed = false
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
t.Fatal("key release was not admitted", code)
}
second := <-deliveries
if second.Key != request.Key || second.Sequence != request.Sequence {
t.Fatal("lost release ordering")
}
firstBytes, _ := json.Marshal(keyRequest(lease, 1, true))
secondBytes, _ := json.Marshal(request)
session.mutex.Lock()
items, bytes := session.remote.keyItems, session.remote.keyBytes
session.mutex.Unlock()
if items != 2 || bytes != len(firstBytes)+len(secondBytes) {
t.Fatal("in-flight accounting omitted request metadata", items, bytes)
}
var releases sync.WaitGroup
for range 8 {
releases.Add(1)
go func() { defer releases.Done(); first.Release(); second.Release() }()
}
releases.Wait()
session.mutex.Lock()
items, bytes = session.remote.keyItems, session.remote.keyBytes
session.mutex.Unlock()
if items != 0 || bytes != 0 {
t.Fatal("idempotent concurrent release retained capacity", items, bytes)
}
})
}
}
func TestRemoteKeySDKPermissionAndParameters(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, "keys", "keyboard-inject")
deliveries := session.EnableRemoteKeys()
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"})
if _, code := remoteCall(t, session, "remote.key", keyRequest(lease, 1, true)); code != -32001 {
t.Fatal("controller role injected input", code)
}
lease = speech_fixture_lease(t, session, "controlled", []string{"output-observe"})
if _, code := remoteCall(t, session, "remote.key", keyRequest(lease, 1, true)); code != -32001 {
t.Fatal("output grant allowed input", code)
}
lease = speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
request := keyRequest(lease, 1, true)
for _, foreign := range []tentaclewire.RemoteKeyRequest{
keyRequest(tentaclewire.RemoteLease{Lease: strings.Repeat("f", 32), Epoch: lease.Epoch}, 1, true),
keyRequest(tentaclewire.RemoteLease{Lease: lease.Lease, Epoch: lease.Epoch + 1}, 1, true),
} {
if _, code := remoteCall(t, session, "remote.key", foreign); code != -32001 {
t.Fatal("foreign lease/epoch admitted input", code)
}
}
other := startSessionSDK(t, manager, language, "other-keys", "keyboard-inject")
other.EnableRemoteKeys()
if _, code := remoteCall(t, other, "remote.key", request); code != -32001 {
t.Fatal("foreign process used another session's lease", code)
}
data, _ := json.Marshal(request)
for _, raw := range []string{
strings.Replace(string(data), `"pressed":true`, `"pressed":null`, 1),
strings.Replace(string(data), `"name":"Control_R"`, `"name":"unknown private value"`, 1),
strings.Replace(string(data), `"sequence":1`, `"sequence":1,"origin":"local"`, 1),
strings.Replace(string(data), `"sequence":1`, `"sequence":0`, 1),
} {
if _, code := remoteCall(t, session, "remote.key", json.RawMessage(raw)); code != -32602 {
t.Fatal("invalid key parameters were admitted", code)
}
}
if len(deliveries) != 0 {
t.Fatal("denied or invalid input reached the owner")
}
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
t.Fatal("denied requests consumed the live operation sequence", code)
}
(<-deliveries).Release()
})
}
}
func TestRemoteKeySDKOverflowRetiresInFlightWork(t *testing.T) {
for _, language := range []string{"go", "python"} {
t.Run(language, func(t *testing.T) {
session := startSessionSDK(t, test_manager(t, DefaultLimits(), nil), language, "keys", "keyboard-inject")
lease := speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
deliveries := session.EnableRemoteKeys()
request := keyRequest(lease, 1, true)
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
t.Fatal(code)
}
inFlight := <-deliveries // Dequeue must not free input capacity.
session.mutex.Lock()
old := session.remote
session.mutex.Unlock()
for index := 2; index <= tentaclewire.RemoteKeyQueueSize; index++ {
request.Sequence++
if index%32 == 0 {
if _, code := remoteCall(t, session, "remote.heartbeat", request.RemoteOperation); code != 0 {
t.Fatal(code)
}
request.Sequence++
}
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
t.Fatal("input capacity ended early", index, code)
}
}
request.Sequence++
request.Key.Pressed = false
if _, code := remoteCall(t, session, "remote.key", request); code != -32002 {
t.Fatal("full queue silently lost a release", code)
}
if inFlight.Context.Err() == nil {
t.Fatal("overflow retained in-flight authority")
}
if _, err := session.RemoteContext(lease, "keyboard-inject"); !errors.Is(err, ErrPermission) {
t.Fatal("overflow retained input consent", err)
}
inFlight.Release()
inFlight.Release()
session.mutex.Lock()
defer session.mutex.Unlock()
if session.remote != nil || old.keyItems != 0 || old.keyBytes != 0 || len(deliveries) != 0 {
t.Fatal("overflow retained queued input or accounting")
}
})
}
}
func TestRemoteKeySDKRetirementAndReplacement(t *testing.T) {
for _, language := range []string{"go", "python"} {
for _, reason := range []string{"local", "close", "replace", "expire", "disable", "failure", "policy"} {
t.Run(language+"/"+reason, func(t *testing.T) {
manager := test_manager(t, DefaultLimits(), nil)
policy := NewRemotePolicy()
if err := manager.UseRemotePolicy(policy); err != nil {
t.Fatal(err)
}
session := startSessionSDK(t, manager, language, "keys", "keyboard-inject")
lease := speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
deliveries := session.EnableRemoteKeys()
for sequence := uint64(1); sequence <= 2; sequence++ {
if _, code := remoteCall(t, session, "remote.key", keyRequest(lease, sequence, true)); code != 0 {
t.Fatal(code)
}
}
inFlight := <-deliveries
session.mutex.Lock()
old := session.remote
session.mutex.Unlock()
switch reason {
case "local":
session.RevokeRemote()
case "close":
if _, code := remoteCall(t, session, "remote.close", keyRequest(lease, 3, false).RemoteOperation); code != 0 {
t.Fatal(code)
}
case "replace":
fresh := speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
if _, code := remoteCall(t, session, "remote.key", keyRequest(fresh, 1, true)); code != 0 {
t.Fatal(code)
}
replacement := <-deliveries
inFlight.Release() // Must not release the replacement budget.
session.mutex.Lock()
count := session.remote.keyItems
session.mutex.Unlock()
if count != 1 || replacement.Context.Err() != nil {
t.Fatal("stale release affected replacement work")
}
replacement.Release()
case "expire", "policy":
session.mutex.Lock()
if reason == "expire" {
session.refresh_remote_locked(time.Millisecond)
} else {
policy.SetAvailable(false)
if inFlight.Context.Err() == nil {
session.mutex.Unlock()
t.Fatal("policy cancellation waited for a session worker")
}
policy.SetAvailable(true)
}
session.mutex.Unlock()
var event tentaclewire.SessionEvent
if json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event) != nil || event.Kind != "remote.revoked" || event.Epoch != lease.Epoch {
t.Fatal("missing original-epoch retirement notification")
}
case "disable":
session.Disable()
case "failure":
session.fail(ErrProtocol)
}
if inFlight.Context.Err() == nil {
t.Fatal("retired input survived")
}
if _, err := session.RemoteContext(lease, "keyboard-inject"); !errors.Is(err, ErrPermission) {
t.Fatal("retired input passed delivery-time consent check")
}
inFlight.Release()
inFlight.Release()
session.mutex.Lock()
defer session.mutex.Unlock()
if old.keyItems != 0 || old.keyBytes != 0 || len(deliveries) != 0 {
t.Fatal("retirement retained pending input/accounting")
}
})
}
}
}
+8 -4
View File
@@ -79,9 +79,10 @@ func sessionSDKProgram(t *testing.T, language string) []string {
return []string{python, "-B", peer}
}
func startSessionSDK(t *testing.T, manager *Manager, language, id string) *Session {
func startSessionSDK(t *testing.T, manager *Manager, language, id string, extraGrants ...string) *Session {
t.Helper()
session, err := manager.Start(t.Context(), Spec{ID: id, Program: sessionSDKProgram(t, language), SessionEvents: true, RemoteGrants: []string{"output-observe", "remote-present", "clipboard-exchange"}})
grants := append([]string{"output-observe", "remote-present", "clipboard-exchange"}, extraGrants...)
session, err := manager.Start(t.Context(), Spec{ID: id, Program: sessionSDKProgram(t, language), SessionEvents: true, RemoteGrants: grants})
if err != nil {
t.Fatal(err)
}
@@ -214,8 +215,11 @@ func TestSessionSDKLifecycleConformance(t *testing.T) {
if _, err := session.RemoteContext(lease, "clipboard-exchange"); !errors.Is(err, ErrPermission) {
t.Fatal("install grant became session grant")
}
if _, code := remoteCall(t, session, "remote.key", map[string]any{}); code != -32601 {
t.Fatal("unimplemented desktop operation accepted")
if _, code := remoteCall(t, session, "remote.key", map[string]any{}); code != -32602 {
t.Fatal("malformed typed key operation accepted", code)
}
if _, code := remoteCall(t, session, "remote.braille", map[string]any{}); code != -32601 {
t.Fatal("unimplemented desktop operation accepted", code)
}
// One blocked handler cannot block the endpoint receive loop or lifecycle calls.
waiting, err := session.Invoke(t.Context(), "wait", nil)
+1
View File
@@ -77,6 +77,7 @@ type Session struct {
remote *remoteAccess
remoteApproval *RemoteApproval
remoteSpeech chan RemoteSpeechDelivery
remoteKeys chan RemoteKeyDelivery
}
func (session *Session) Ready() <-chan struct{} { return session.ready }
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"encoding/json"
"strconv"
)
const RemoteKeyQueueSize = 256
const RemoteKeyQueueBytes = 256 * 1024
// RemoteKey identifies one logical, unshifted key, never an X keycode, Windows
// VK, scan code, text to type or command to execute. Native ownership and
// destination-layout resolution belong to a reader input owner.
type RemoteKey struct {
Name string `json:"name"`
Pressed bool `json:"pressed"`
}
type RemoteKeyRequest struct {
RemoteOperation
Key RemoteKey `json:"key"`
}
func ValidRemoteKey(key RemoteKey) bool {
name := key.Name
if len(name) == 1 && (name[0] >= 'a' && name[0] <= 'z' || name[0] >= '0' && name[0] <= '9') {
return true
}
if len(name) >= 2 && len(name) <= 3 && name[0] == 'F' {
number, err := strconv.Atoi(name[1:])
return err == nil && number >= 1 && number <= 24 && strconv.Itoa(number) == name[1:]
}
if len(name) == 4 && name[:3] == "KP_" && name[3] >= '0' && name[3] <= '9' {
return true
}
switch name {
case "BackSpace", "Tab", "Return", "Escape", "space", "Pause", "Print", "Menu",
"Caps_Lock", "Num_Lock", "Scroll_Lock", "Shift_L", "Shift_R", "Control_L", "Control_R",
"Alt_L", "Alt_R", "Super_L", "Super_R", "Page_Up", "Page_Down", "End", "Home",
"Left", "Up", "Right", "Down", "Insert", "Delete",
"KP_Begin", "KP_Enter", "KP_Page_Up", "KP_Page_Down", "KP_End", "KP_Home",
"KP_Left", "KP_Up", "KP_Right", "KP_Down", "KP_Insert", "KP_Delete",
"KP_Multiply", "KP_Add", "KP_Separator", "KP_Subtract", "KP_Decimal", "KP_Divide",
"semicolon", "equal", "comma", "minus", "period", "slash", "grave", "bracketleft",
"backslash", "bracketright", "apostrophe":
return true
}
return false
}
func ValidRemoteKeyRequest(request RemoteKeyRequest) bool {
return len(request.Lease) == 32 && request.Epoch != 0 && request.Sequence != 0 && ValidRemoteKey(request.Key)
}
func (key *RemoteKey) UnmarshalJSON(data []byte) error {
var fields map[string]json.RawMessage
if DecodeStrict(data, &fields) != nil || len(fields) != 2 || fields["name"] == nil || fields["pressed"] == nil {
return ErrProtocol
}
var value struct {
Name string `json:"name"`
Pressed *bool `json:"pressed"`
}
if DecodeStrict(data, &value) != nil || value.Pressed == nil {
return ErrProtocol
}
result := RemoteKey{Name: value.Name, Pressed: *value.Pressed}
if !ValidRemoteKey(result) {
return ErrProtocol
}
*key = result
return nil
}
func (request *RemoteKeyRequest) 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["key"] == nil {
return ErrProtocol
}
type plain RemoteKeyRequest
var value plain
if DecodeStrict(data, &value) != nil || !ValidRemoteKeyRequest(RemoteKeyRequest(value)) {
return ErrProtocol
}
*request = RemoteKeyRequest(value)
return nil
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestRemoteKeySDKValidation(t *testing.T) {
cases := []struct {
data string
valid bool
}{
{`{"name":"a","pressed":true}`, true},
{`{"name":"Control_R","pressed":false}`, true},
{`{"name":"KP_Enter","pressed":true}`, true},
{`{"name":"semicolon","pressed":false}`, true},
{`{"name":"Prior","pressed":true}`, false},
{`{"name":"A","pressed":true}`, false},
{`{"name":"F01","pressed":true}`, false},
{`{"name":"F+1","pressed":true}`, false},
{`{"name":"F25","pressed":true}`, false},
{`{"name":"F1","pressed":true}`, false},
{`{"name":"KP_01","pressed":true}`, false},
{`{"name":"ISO_Level3_Shift","pressed":true}`, false},
{`{"name":"dead_acute","pressed":true}`, false},
{`{"name":"U20AC","pressed":true}`, false},
{`{"name":"../anything","pressed":true}`, false},
{`{"name":"a"}`, false},
{`{"name":"a","pressed":null}`, false},
{`{"name":"a","pressed":0}`, false},
{`{"name":"a","pressed":"false"}`, false},
{`{"Name":"a","pressed":true}`, false},
{`{"name":"a","Pressed":true}`, false},
{`{"name":"a","pressed":false,"origin":"local"}`, false},
{`{"name":"a","pressed":true,"scan_code":38}`, false},
{`{"pressed":false}`, false},
{`{"name":null,"pressed":false}`, false},
{`{"name":"","pressed":false}`, false},
{`{"name":1,"pressed":false}`, false},
{`{"name":"a","name":"b","pressed":false}`, false},
{`null`, false}, {`[]`, false}, {`{}`, false},
}
for _, group := range []struct {
first, last int
name func(int) string
}{
{'a', 'z', func(code int) string { return string(rune(code)) }},
{'0', '9', func(code int) string { return string(rune(code)) }},
{1, 24, func(code int) string { return fmt.Sprintf("F%d", code) }},
{0, 9, func(code int) string { return fmt.Sprintf("KP_%d", code) }},
} {
for code := group.first; code <= group.last; code++ {
for _, pressed := range []bool{false, true} {
data, _ := json.Marshal(RemoteKey{Name: group.name(code), Pressed: pressed})
cases = append(cases, struct {
data string
valid bool
}{string(data), true})
}
}
}
var probes []any
for _, test := range cases {
var key RemoteKey
if valid := DecodeStrict([]byte(test.data), &key) == nil && ValidRemoteKey(key); valid != test.valid {
t.Fatalf("Go key validation %s: %v", test.data, valid)
}
probes = append(probes, map[string]any{"raw": test.data, "valid": test.valid})
}
checkRemoteKeyPython(t, "valid_remote_key", probes)
}
func TestRemoteKeyRequestSDKValidation(t *testing.T) {
base := `{"lease":"` + strings.Repeat("a", 32) + `","epoch":1,"sequence":1,"key":{"name":"a","pressed":false}}`
cases := []struct {
data string
valid bool
}{
{base, true},
{strings.Replace(base, `"sequence":1`, `"sequence":18446744073709551615`, 1), true},
{strings.Replace(base, `"epoch":1`, `"epoch":18446744073709551615`, 1), true},
}
for _, field := range []string{"epoch", "sequence"} {
for _, invalid := range []string{"0", "-1", "1.0", "true", "null", "18446744073709551616"} {
cases = append(cases, struct {
data string
valid bool
}{strings.Replace(base, `"`+field+`":1`, `"`+field+`":`+invalid, 1), false})
}
}
for _, data := range []string{
strings.Replace(base, `"lease":"`+strings.Repeat("a", 32)+`"`, `"lease":null`, 1),
strings.Replace(base, strings.Repeat("a", 32), "short", 1),
strings.Replace(base, `"key":{"name":"a","pressed":false}`, `"key":null`, 1),
strings.Replace(base, `"key":{"name":"a","pressed":false}`, `"key":{}`, 1),
strings.Replace(base, `"epoch":1,`, "", 1),
strings.Replace(base, `,"key":{"name":"a","pressed":false}`, "", 1),
strings.Replace(base, `"epoch":1`, `"epoch":1,"epoch":2`, 1),
strings.Replace(base, `"epoch":1`, `"epoch":1,"origin":"local"`, 1),
strings.Replace(base, `"epoch":1`, `"epoch":1,"keys":[]`, 1),
strings.Replace(base, `"epoch":1`, `"Epoch":1`, 1),
`null`, `[]`, `{}`,
} {
cases = append(cases, struct {
data string
valid bool
}{data, false})
}
var probes []any
for _, test := range cases {
var request RemoteKeyRequest
if valid := DecodeStrict([]byte(test.data), &request) == nil && ValidRemoteKeyRequest(request); valid != test.valid {
t.Fatalf("Go key request validation %s: %v", test.data, valid)
}
probes = append(probes, map[string]any{"raw": test.data, "valid": test.valid})
}
checkRemoteKeyPython(t, "valid_remote_key_request", probes)
}
func checkRemoteKeyPython(t *testing.T, validator string, probes []any) {
t.Helper()
path, err := filepath.Abs("../../sdk/python")
if err != nil {
t.Fatal(err)
}
script := `import json, sys
sys.path.insert(0, sys.argv[1])
from cthulhu_tentacle import _object, ProtocolError
from cthulhu_tentacle import session
validate = getattr(session, sys.argv[2])
for case in json.load(sys.stdin):
try:
actual = validate(json.loads(case['raw'], object_pairs_hook=_object))
except (ProtocolError, ValueError):
actual = False
assert actual == case['valid'], str(case)[:180]
`
data, _ := json.Marshal(probes)
command := exec.Command("python3", "-B", "-c", script, path, validator)
command.Stdin = strings.NewReader(string(data))
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("Python key validation: %v: %s", err, 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 RemoteKey = tentaclewire.RemoteKey
type RemoteKeyRequest = tentaclewire.RemoteKeyRequest
func ValidRemoteKey(key RemoteKey) bool { return tentaclewire.ValidRemoteKey(key) }
func ValidRemoteKeyRequest(request RemoteKeyRequest) bool {
return tentaclewire.ValidRemoteKeyRequest(request)
}
+2 -1
View File
@@ -180,7 +180,8 @@ func (client *SessionClient) write(output io.Writer) {
// Call has a two-second maximum deadline. Timeout/cancellation retires the
// session, so an abandoned effect cannot leave unbounded pending calls behind.
// The host currently implements remote.open, remote.close and remote.heartbeat.
// Typed desktop operations require separately attached reader owners; a
// successful call acknowledges admission, not delivery to a desktop/backend.
func (client *SessionClient) Call(ctx context.Context, method string, params any, result any) error {
if ctx == nil {
return errors.New("nil session call context")
+39
View File
@@ -102,6 +102,45 @@ def valid_remote_presentation(request):
return False
def valid_remote_key(key):
"""Validate one logical key identity and transition, without desktop access."""
try:
_fields(key, ("name", "pressed"))
name = key["name"]
if not isinstance(name, str) or type(key["pressed"]) is not bool:
return False
if len(name) == 1 and name in "abcdefghijklmnopqrstuvwxyz0123456789":
return True
if (2 <= len(name) <= 3 and name[0] == "F" and name[1:].isascii()
and name[1:].isdecimal()):
number = int(name[1:])
return 1 <= number <= 24 and str(number) == name[1:]
if len(name) == 4 and name[:3] == "KP_" and name[3] in "0123456789":
return True
return name in (
"BackSpace", "Tab", "Return", "Escape", "space", "Pause", "Print", "Menu",
"Caps_Lock", "Num_Lock", "Scroll_Lock", "Shift_L", "Shift_R", "Control_L", "Control_R",
"Alt_L", "Alt_R", "Super_L", "Super_R", "Page_Up", "Page_Down", "End", "Home",
"Left", "Up", "Right", "Down", "Insert", "Delete",
"KP_Begin", "KP_Enter", "KP_Page_Up", "KP_Page_Down", "KP_End", "KP_Home",
"KP_Left", "KP_Up", "KP_Right", "KP_Down", "KP_Insert", "KP_Delete",
"KP_Multiply", "KP_Add", "KP_Separator", "KP_Subtract", "KP_Decimal", "KP_Divide",
"semicolon", "equal", "comma", "minus", "period", "slash", "grave", "bracketleft",
"backslash", "bracketright", "apostrophe")
except (ProtocolError, TypeError, ValueError):
return False
def valid_remote_key_request(request):
"""Validate remote.key parameters; consent and delivery checks belong to core."""
try:
_fields(request, ("lease", "epoch", "sequence", "key"))
return (_token(request["lease"]) and _positive(request["epoch"])
and _positive(request["sequence"]) and valid_remote_key(request["key"]))
except (ProtocolError, TypeError, ValueError, UnicodeError):
return False
class SessionError(Exception):
def __init__(self, code, message):
super().__init__("Tentacle session error " + str(code))