Add lease-bound Remote key IPC and SDK validation
This commit is contained in:
1 parent
9ac33560dc
commit
792cac5d0b
12 files changed
+807
-9
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
adds the core consent procedure, sensitive XI2 forms and lock/handoff policy.
|
||||||
The external source-built Remote command now requests that consent through a
|
The external source-built Remote command now requests that consent through a
|
||||||
typed interaction; physical-keyboard approval remains unverified. There is no
|
typed interaction; physical-keyboard approval remains unverified. There is no
|
||||||
output observer, keyboard service, clipboard service,
|
production keyboard, clipboard or Braille session service. Subsequent speech
|
||||||
or Braille session service. The unrestricted `Session.AuthorizeRemote` hook is
|
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.
|
limited to development managers; reader managers require a core approval ticket.
|
||||||
Packaging Remote itself remains premature.
|
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
|
Typed speech IPC now extends this transport; see
|
||||||
[the speech contract](tentacle-remote-speech.md). It uses the same ordered event
|
[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
|
queue for exported speech and a separate 64-item incoming mailbox. Limited
|
||||||
production speech observation is connected; incoming presentation ownership is
|
production speech observation and isolated incoming software speech are connected.
|
||||||
still pending. Keyboard/Braille/clipboard effects remain unsupported.
|
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
|
## Implemented lifecycle operations
|
||||||
|
|
||||||
@@ -192,6 +196,7 @@ Child calls:
|
|||||||
| `remote.heartbeat` | `lease`, `epoch`, `sequence` | `{}` |
|
| `remote.heartbeat` | `lease`, `epoch`, `sequence` | `{}` |
|
||||||
| `remote.close` | `lease`, `epoch`, `sequence` | `{}` |
|
| `remote.close` | `lease`, `epoch`, `sequence` | `{}` |
|
||||||
| `remote.present` | `lease`, `epoch`, `sequence`, typed `speech` | `{}` after mailbox admission; unavailable without reader owner |
|
| `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,
|
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
|
and grants must be a nonempty subset of the approved install grants. Controller
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ type remoteAccess struct {
|
|||||||
speechBatch *remoteSpeechBatch
|
speechBatch *remoteSpeechBatch
|
||||||
speechItems int
|
speechItems int
|
||||||
speechBytes int
|
speechBytes int
|
||||||
|
keyItems int
|
||||||
|
keyBytes int
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthorizeRemote is the unrestricted protocol-fixture hook. Reader managers
|
// 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.cancel()
|
||||||
access.speechBatch = nil
|
access.speechBatch = nil
|
||||||
access.speechItems, access.speechBytes = 0, 0
|
access.speechItems, access.speechBytes = 0, 0
|
||||||
|
access.keyItems, access.keyBytes = 0, 0
|
||||||
if session.remoteSpeech != nil {
|
if session.remoteSpeech != nil {
|
||||||
drain:
|
drain:
|
||||||
for {
|
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 {
|
if access.stopPolicy != nil {
|
||||||
access.stopPolicy()
|
access.stopPolicy()
|
||||||
}
|
}
|
||||||
@@ -265,6 +278,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage
|
|||||||
switch method {
|
switch method {
|
||||||
case "remote.present":
|
case "remote.present":
|
||||||
return session.remote_present_locked(params)
|
return session.remote_present_locked(params)
|
||||||
|
case "remote.key":
|
||||||
|
return session.remote_key_locked(params)
|
||||||
case "remote.open":
|
case "remote.open":
|
||||||
var request tentaclewire.RemoteOpen
|
var request tentaclewire.RemoteOpen
|
||||||
if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) {
|
if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) {
|
||||||
|
|||||||
@@ -0,0 +1,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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,9 +79,10 @@ func sessionSDKProgram(t *testing.T, language string) []string {
|
|||||||
return []string{python, "-B", peer}
|
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()
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -214,8 +215,11 @@ func TestSessionSDKLifecycleConformance(t *testing.T) {
|
|||||||
if _, err := session.RemoteContext(lease, "clipboard-exchange"); !errors.Is(err, ErrPermission) {
|
if _, err := session.RemoteContext(lease, "clipboard-exchange"); !errors.Is(err, ErrPermission) {
|
||||||
t.Fatal("install grant became session grant")
|
t.Fatal("install grant became session grant")
|
||||||
}
|
}
|
||||||
if _, code := remoteCall(t, session, "remote.key", map[string]any{}); code != -32601 {
|
if _, code := remoteCall(t, session, "remote.key", map[string]any{}); code != -32602 {
|
||||||
t.Fatal("unimplemented desktop operation accepted")
|
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.
|
// One blocked handler cannot block the endpoint receive loop or lifecycle calls.
|
||||||
waiting, err := session.Invoke(t.Context(), "wait", nil)
|
waiting, err := session.Invoke(t.Context(), "wait", nil)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ type Session struct {
|
|||||||
remote *remoteAccess
|
remote *remoteAccess
|
||||||
remoteApproval *RemoteApproval
|
remoteApproval *RemoteApproval
|
||||||
remoteSpeech chan RemoteSpeechDelivery
|
remoteSpeech chan RemoteSpeechDelivery
|
||||||
|
remoteKeys chan RemoteKeyDelivery
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) Ready() <-chan struct{} { return session.ready }
|
func (session *Session) Ready() <-chan struct{} { return session.ready }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -180,7 +180,8 @@ func (client *SessionClient) write(output io.Writer) {
|
|||||||
|
|
||||||
// Call has a two-second maximum deadline. Timeout/cancellation retires the
|
// Call has a two-second maximum deadline. Timeout/cancellation retires the
|
||||||
// session, so an abandoned effect cannot leave unbounded pending calls behind.
|
// 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 {
|
func (client *SessionClient) Call(ctx context.Context, method string, params any, result any) error {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return errors.New("nil session call context")
|
return errors.New("nil session call context")
|
||||||
|
|||||||
@@ -102,6 +102,45 @@ def valid_remote_presentation(request):
|
|||||||
return False
|
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):
|
class SessionError(Exception):
|
||||||
def __init__(self, code, message):
|
def __init__(self, code, message):
|
||||||
super().__init__("Tentacle session error " + str(code))
|
super().__init__("Tentacle session error " + str(code))
|
||||||
|
|||||||
Reference in new issue
Block a user