diff --git a/docs/tentacle-remote-keyboard.md b/docs/tentacle-remote-keyboard.md new file mode 100644 index 0000000..fcece77 --- /dev/null +++ b/docs/tentacle-remote-keyboard.md @@ -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. diff --git a/docs/tentacle-sessions.md b/docs/tentacle-sessions.md index c319892..4d188b2 100644 --- a/docs/tentacle-sessions.md +++ b/docs/tentacle-sessions.md @@ -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 diff --git a/internal/tentacle/remote.go b/internal/tentacle/remote.go index 13e89cf..02234c3 100644 --- a/internal/tentacle/remote.go +++ b/internal/tentacle/remote.go @@ -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) { diff --git a/internal/tentacle/remote_key.go b/internal/tentacle/remote_key.go new file mode 100644 index 0000000..21a609b --- /dev/null +++ b/internal/tentacle/remote_key.go @@ -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"} + } +} diff --git a/internal/tentacle/remote_key_test.go b/internal/tentacle/remote_key_test.go new file mode 100644 index 0000000..30d9030 --- /dev/null +++ b/internal/tentacle/remote_key_test.go @@ -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") + } + }) + } + } +} diff --git a/internal/tentacle/remote_test.go b/internal/tentacle/remote_test.go index bab1e21..746acb7 100644 --- a/internal/tentacle/remote_test.go +++ b/internal/tentacle/remote_test.go @@ -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) diff --git a/internal/tentacle/session.go b/internal/tentacle/session.go index 09b6512..544e520 100644 --- a/internal/tentacle/session.go +++ b/internal/tentacle/session.go @@ -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 } diff --git a/internal/tentaclewire/remote_key.go b/internal/tentaclewire/remote_key.go new file mode 100644 index 0000000..bed6594 --- /dev/null +++ b/internal/tentaclewire/remote_key.go @@ -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 +} diff --git a/internal/tentaclewire/remote_key_test.go b/internal/tentaclewire/remote_key_test.go new file mode 100644 index 0000000..53227ae --- /dev/null +++ b/internal/tentaclewire/remote_key_test.go @@ -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) + } +} diff --git a/sdk/go/tentacle/remote_key.go b/sdk/go/tentacle/remote_key.go new file mode 100644 index 0000000..cf37eb1 --- /dev/null +++ b/sdk/go/tentacle/remote_key.go @@ -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) +} diff --git a/sdk/go/tentacle/session.go b/sdk/go/tentacle/session.go index c7cf8da..4e92d37 100644 --- a/sdk/go/tentacle/session.go +++ b/sdk/go/tentacle/session.go @@ -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") diff --git a/sdk/python/cthulhu_tentacle/session.py b/sdk/python/cthulhu_tentacle/session.py index 0412d31..c471bf5 100644 --- a/sdk/python/cthulhu_tentacle/session.py +++ b/sdk/python/cthulhu_tentacle/session.py @@ -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))