Add Remote session foundations and feasibility checks
Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates. Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
This commit is contained in:
1 parent
a47253d697
commit
573d15d7b4
45 files changed
+3634
-80
No files matched your search
@@ -1,6 +1,12 @@
|
||||
# Copyright (c) 2026 Storm Dragon
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Experimental command/presentation SDK. stdout is reserved for the protocol."""
|
||||
"""Experimental command/presentation SDK. stdout is reserved for the protocol.
|
||||
|
||||
Interaction form dictionaries may set secret=True on entries (kind=2). The host
|
||||
masks editing output and disallows copy/cut; successful submissions still carry
|
||||
plaintext values to the continuation. Never log or present those values. Secret
|
||||
entries cannot be used in persistent preference pages; older hosts reject them.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
@@ -80,7 +86,7 @@ def _fields(value, required, optional=()):
|
||||
raise ProtocolError("Invalid fields")
|
||||
|
||||
|
||||
def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
def serve(commands, inputStream=None, outputStream=None, extensions=None, session_client=None):
|
||||
"""Run in a dedicated child, then exit. Handlers receive (cancelled, arguments).
|
||||
|
||||
cancelled is a threading.Event shared by the session. Up to eight handlers
|
||||
@@ -94,6 +100,9 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
writeLock = threading.Lock()
|
||||
workers = []
|
||||
slots = threading.BoundedSemaphore(8)
|
||||
client_started = False
|
||||
if session_client is not None:
|
||||
from .session import valid_negotiation
|
||||
|
||||
def write_reply(requestId, value=None, failed=False, release=None):
|
||||
reply = {"jsonrpc": "2.0", "id": requestId}
|
||||
@@ -108,6 +117,11 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
except (ValueError, TypeError, UnicodeError, RecursionError, ProtocolError):
|
||||
frame = _encode({"jsonrpc": "2.0", "id": requestId, "error": {
|
||||
"code": -32603, "message": "Invalid or oversized result"}})
|
||||
if session_client is not None:
|
||||
if release is not None:
|
||||
release()
|
||||
session_client._enqueue(frame + b"\n")
|
||||
return
|
||||
with writeLock:
|
||||
# Bound eight handlers/waiting replies plus one serialized writer.
|
||||
if release is not None:
|
||||
@@ -133,12 +147,17 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
failed = False
|
||||
try:
|
||||
write_reply(requestId, value, failed, release_slot)
|
||||
except (OSError, ValueError):
|
||||
except (OSError, ValueError, ProtocolError, SessionError):
|
||||
cancelled.set()
|
||||
if session_client is not None:
|
||||
session_client._abort()
|
||||
finally:
|
||||
release_slot()
|
||||
|
||||
try:
|
||||
if session_client is not None:
|
||||
session_client._start(cancelled, inputStream, outputStream)
|
||||
client_started = True
|
||||
commands = list(commands)
|
||||
handlers = {command.id: command.handle for command in commands}
|
||||
if (not 1 <= len(commands) <= 32 or len(handlers) != len(commands)
|
||||
@@ -151,7 +170,7 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
raise ProtocolError("Invalid command capabilities")
|
||||
session, active, sequence = None, False, 0
|
||||
while not cancelled.is_set():
|
||||
frame = inputStream.readline(MAX_FRAME + 2)
|
||||
frame = session_client._readline() if session_client is not None else inputStream.readline(MAX_FRAME + 2)
|
||||
if not frame:
|
||||
return
|
||||
if not frame.endswith(b"\n") or len(frame) > MAX_FRAME + 1:
|
||||
@@ -162,6 +181,9 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
_depth(message)
|
||||
except (ValueError, UnicodeError, RecursionError) as error:
|
||||
raise ProtocolError("Invalid JSON") from error
|
||||
if session_client is not None and isinstance(message, dict) and "method" not in message:
|
||||
session_client._reply(message)
|
||||
continue
|
||||
_fields(message, ("jsonrpc", "method"), ("id", "params"))
|
||||
if message["jsonrpc"] != "2.0":
|
||||
raise ProtocolError("Invalid JSON-RPC version")
|
||||
@@ -179,19 +201,23 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
if method == "initialize":
|
||||
_fields(params, ("protocol", "session", "apis", "capabilities"))
|
||||
version = params["protocol"]
|
||||
if (session is not None or not requestId or version != {"major": 1, "minor": 0}
|
||||
expected_minor = 1 if session_client is not None else 0
|
||||
apis, capabilities = params["apis"], params["capabilities"]
|
||||
valid_apis = (valid_negotiation(apis, capabilities) if session_client is not None else
|
||||
apis in (["commands"], ["commands", "presentation"],
|
||||
["commands", "presentation", "interaction"],
|
||||
["commands", "presentation", "interaction", "reader-settings"])
|
||||
and capabilities == apis)
|
||||
if (session is not None or not requestId or version != {"major": 1, "minor": expected_minor}
|
||||
or any(type(value) is not int for value in version.values())
|
||||
or not isinstance(params["session"], str) or not params["session"]
|
||||
or len(params["session"].encode("utf-8")) > 128
|
||||
or params["apis"] not in (["commands"], ["commands", "presentation"],
|
||||
["commands", "presentation", "interaction"],
|
||||
["commands", "presentation", "interaction", "reader-settings"])
|
||||
or params["capabilities"] != params["apis"]
|
||||
or not valid_apis
|
||||
or (any(command.presentation for command in commands)
|
||||
and len(params["apis"]) < 2)
|
||||
or (any(command.interaction for command in commands) and len(params["apis"]) < 3)
|
||||
and "presentation" not in apis)
|
||||
or (any(command.interaction for command in commands) and "interaction" not in apis)
|
||||
or ((extensions is not None or any(command.reader for command in commands))
|
||||
and len(params["apis"]) != 4)):
|
||||
and "reader-settings" not in apis)):
|
||||
raise ProtocolError("Invalid initialization")
|
||||
session = params["session"]
|
||||
registration = {"protocol": version, "session": session,
|
||||
@@ -204,6 +230,13 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
if session is None or active or requestId or params["session"] != session:
|
||||
raise ProtocolError("Invalid activation")
|
||||
active = True
|
||||
if session_client is not None:
|
||||
with session_client._lock:
|
||||
session_client._active = True
|
||||
elif method == "session.event":
|
||||
if not active or requestId or session_client is None:
|
||||
raise ProtocolError("Unnegotiated event")
|
||||
session_client._event(params)
|
||||
elif method == "command.invoke":
|
||||
_fields(params, ("command", "arguments"))
|
||||
if (not active or not requestId or not isinstance(params["command"], str)
|
||||
@@ -226,9 +259,16 @@ def serve(commands, inputStream=None, outputStream=None, extensions=None):
|
||||
raise ProtocolError("Unknown method")
|
||||
finally:
|
||||
cancelled.set()
|
||||
if client_started:
|
||||
session_client._finish()
|
||||
# Join before closing buffered stdout: a blocked writer must remain
|
||||
# subject to the host deadline rather than race a Python buffer close.
|
||||
for worker in workers:
|
||||
worker.join()
|
||||
if client_started:
|
||||
session_client._close()
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
|
||||
# Imported after helpers to keep the protocol 1.0 API compatible.
|
||||
from .session import SessionClient, SessionError
|
||||
@@ -0,0 +1,256 @@
|
||||
# Copyright (c) 2026 Storm Dragon
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Opt-in protocol 1.1 lifecycle transport. No desktop effects are provided."""
|
||||
|
||||
import os
|
||||
import queue
|
||||
import select
|
||||
import threading
|
||||
|
||||
from . import MAX_FRAME, ProtocolError, _encode, _fields
|
||||
|
||||
REMOTE_GRANTS = frozenset(("output-observe", "remote-present", "keyboard-capture",
|
||||
"keyboard-inject", "braille-stream", "clipboard-exchange"))
|
||||
BASE_APIS = frozenset(("commands", "presentation", "interaction", "reader-settings"))
|
||||
|
||||
|
||||
def valid_negotiation(apis, capabilities):
|
||||
if (not isinstance(apis, list) or not isinstance(capabilities, list)
|
||||
or any(not isinstance(value, str) for value in apis + capabilities)):
|
||||
return False
|
||||
a, c = set(apis), set(capabilities)
|
||||
return (len(a) == len(apis) and len(c) == len(capabilities)
|
||||
and {"commands", "session-events"} <= a
|
||||
and a <= BASE_APIS | {"session-events", "remote-access"}
|
||||
and c <= (BASE_APIS - {"commands"}) | REMOTE_GRANTS
|
||||
and a & (BASE_APIS - {"commands"}) == c & (BASE_APIS - {"commands"})
|
||||
and ("interaction" not in a or "presentation" in a)
|
||||
and ("reader-settings" not in a or "interaction" in a)
|
||||
and ("remote-access" in a) == bool(c & REMOTE_GRANTS))
|
||||
|
||||
|
||||
def _positive(value):
|
||||
return type(value) is int and 0 < value < 2**64
|
||||
|
||||
|
||||
def _token(value):
|
||||
return isinstance(value, str) and len(value.encode("utf-8")) == 32
|
||||
|
||||
|
||||
class SessionError(Exception):
|
||||
def __init__(self, code, message):
|
||||
super().__init__("Tentacle session error " + str(code))
|
||||
self.code, self.message = code, message
|
||||
|
||||
|
||||
class SessionClient:
|
||||
"""Single-use client passed as session_client= to serve.
|
||||
|
||||
Call from handlers or owned background workers, never from the receive loop.
|
||||
next_event blocks until an event or session shutdown. Call timeout retires
|
||||
the session; the host revokes its lease. Binary streams must have pipe FDs.
|
||||
"""
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._events = queue.Queue(32)
|
||||
self._outbound = queue.Queue(32)
|
||||
self._pending = {}
|
||||
self._bytes = self._sequence = self._event_sequence = 0
|
||||
self._started = self._active = False
|
||||
self._stopped = threading.Event()
|
||||
self._wake_read = self._wake_write = None
|
||||
self._buffer = bytearray()
|
||||
|
||||
def _start(self, cancelled, input_stream, output_stream):
|
||||
with self._lock:
|
||||
if self._started:
|
||||
raise ProtocolError("Session client already used")
|
||||
self._started = True
|
||||
self._cancelled = cancelled
|
||||
self._input, self._output = input_stream.fileno(), output_stream.fileno()
|
||||
self._wake_read, self._wake_write = os.pipe()
|
||||
os.set_blocking(self._wake_write, False)
|
||||
os.set_blocking(self._input, False)
|
||||
os.set_blocking(self._output, False)
|
||||
self._writer = threading.Thread(target=self._write_loop)
|
||||
self._writer.start()
|
||||
|
||||
def _abort(self):
|
||||
# Called under _lock too; never acquire it here.
|
||||
self._stopped.set()
|
||||
self._cancelled.set()
|
||||
if self._wake_write is not None:
|
||||
try:
|
||||
os.write(self._wake_write, b"x")
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self._outbound.put_nowait(None)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def _finish(self):
|
||||
self._abort()
|
||||
self._writer.join()
|
||||
with self._lock:
|
||||
self._active = False
|
||||
for waiter in self._pending.values():
|
||||
waiter.put_nowait(SessionError(-32000, "Session closed"))
|
||||
self._pending.clear()
|
||||
while not self._events.empty():
|
||||
self._events.get_nowait()
|
||||
self._events.put_nowait(None)
|
||||
def _close(self):
|
||||
os.close(self._wake_read)
|
||||
os.close(self._wake_write)
|
||||
self._wake_read = self._wake_write = None
|
||||
|
||||
def _readline(self):
|
||||
while not self._stopped.is_set():
|
||||
end = self._buffer.find(b"\n")
|
||||
if end >= 0:
|
||||
frame = bytes(self._buffer[:end + 1])
|
||||
del self._buffer[:end + 1]
|
||||
if len(frame) > MAX_FRAME + 1:
|
||||
raise ProtocolError("Frame limit")
|
||||
return frame
|
||||
if len(self._buffer) > MAX_FRAME:
|
||||
raise ProtocolError("Frame limit")
|
||||
ready, _, _ = select.select([self._input, self._wake_read], [], [])
|
||||
if self._wake_read in ready:
|
||||
return b""
|
||||
try:
|
||||
data = os.read(self._input, 4096)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not data:
|
||||
if self._buffer:
|
||||
raise ProtocolError("Unterminated frame")
|
||||
return b""
|
||||
self._buffer.extend(data)
|
||||
return b""
|
||||
|
||||
def _enqueue_locked(self, frame):
|
||||
if self._stopped.is_set():
|
||||
raise SessionError(-32000, "Session closed")
|
||||
if self._bytes + len(frame) > 256 * 1024:
|
||||
self._abort()
|
||||
raise ProtocolError("Session queue limit")
|
||||
try:
|
||||
self._outbound.put_nowait(frame)
|
||||
except queue.Full:
|
||||
self._abort()
|
||||
raise ProtocolError("Session queue limit") from None
|
||||
self._bytes += len(frame)
|
||||
|
||||
def _enqueue(self, frame):
|
||||
with self._lock:
|
||||
self._enqueue_locked(frame)
|
||||
|
||||
def _write_loop(self):
|
||||
try:
|
||||
while not self._stopped.is_set():
|
||||
frame = self._outbound.get()
|
||||
if frame is None:
|
||||
return
|
||||
position = 0
|
||||
while position < len(frame) and not self._stopped.is_set():
|
||||
ready, writable, _ = select.select([self._wake_read], [self._output], [])
|
||||
if ready:
|
||||
return
|
||||
if writable:
|
||||
try:
|
||||
position += os.write(self._output, frame[position:])
|
||||
except BlockingIOError:
|
||||
continue
|
||||
with self._lock:
|
||||
self._bytes -= len(frame)
|
||||
except OSError:
|
||||
self._abort()
|
||||
|
||||
def call(self, method, params, timeout=2):
|
||||
if not isinstance(method, str) or not 1 <= len(method.encode("utf-8")) <= 128 or len(_encode(params)) > 8192:
|
||||
raise ProtocolError("Invalid session request")
|
||||
if not 0 < timeout <= 2:
|
||||
raise ValueError("Session call timeout must be in (0, 2]")
|
||||
with self._lock:
|
||||
if not self._active or self._stopped.is_set():
|
||||
raise SessionError(-32000, "Session inactive")
|
||||
if len(self._pending) >= 8:
|
||||
raise ProtocolError("Pending call limit")
|
||||
self._sequence += 1
|
||||
request_id = "c:" + str(self._sequence)
|
||||
waiter = queue.Queue(1)
|
||||
self._pending[request_id] = waiter
|
||||
try:
|
||||
self._enqueue_locked(_encode({"jsonrpc": "2.0", "id": request_id,
|
||||
"method": method, "params": params}) + b"\n")
|
||||
except BaseException:
|
||||
del self._pending[request_id]
|
||||
raise
|
||||
try:
|
||||
value = waiter.get(timeout=timeout)
|
||||
if isinstance(value, SessionError):
|
||||
raise value
|
||||
if self._stopped.is_set():
|
||||
raise SessionError(-32000, "Session closed")
|
||||
return value
|
||||
except queue.Empty:
|
||||
self._abort()
|
||||
raise TimeoutError("Session call expired") from None
|
||||
finally:
|
||||
with self._lock:
|
||||
self._pending.pop(request_id, None)
|
||||
|
||||
def next_event(self, timeout=None):
|
||||
if self._stopped.is_set():
|
||||
return None
|
||||
return self._events.get(timeout=timeout)
|
||||
|
||||
def _reply(self, message):
|
||||
_fields(message, ("jsonrpc", "id"), ("result", "error"))
|
||||
if (message["jsonrpc"] != "2.0" or not isinstance(message["id"], str)
|
||||
or ("result" in message) == ("error" in message)):
|
||||
raise ProtocolError("Invalid session reply")
|
||||
value = message.get("result")
|
||||
if "error" in message:
|
||||
error = message["error"]
|
||||
_fields(error, ("code", "message"))
|
||||
if type(error["code"]) is not int or not isinstance(error["message"], str) or len(error["message"].encode("utf-8")) > 1024:
|
||||
raise ProtocolError("Invalid session error")
|
||||
value = SessionError(error["code"], error["message"])
|
||||
with self._lock:
|
||||
waiter = self._pending.pop(message["id"], None)
|
||||
if not self._active or waiter is None:
|
||||
raise ProtocolError("Unexpected session reply")
|
||||
waiter.put_nowait(value)
|
||||
|
||||
def _event(self, event):
|
||||
_fields(event, ("sequence", "kind", "epoch"), ("action", "lease", "role", "grants", "reason"))
|
||||
if not _positive(event["sequence"]) or not _positive(event["epoch"]):
|
||||
raise ProtocolError("Invalid event sequence")
|
||||
if event["kind"] == "remote.action":
|
||||
grants = event.get("grants")
|
||||
role = event.get("role")
|
||||
if (not _token(event.get("action")) or event.get("lease", "") != "" or event.get("reason", "") != ""
|
||||
or role not in ("controller", "controlled") or not isinstance(grants, list)
|
||||
or not grants or any(not isinstance(grant, str) for grant in grants)
|
||||
or len(set(grants)) != len(grants) or not set(grants) <= REMOTE_GRANTS
|
||||
or role == "controller" and set(grants) & {"output-observe", "keyboard-inject"}
|
||||
or role == "controlled" and set(grants) & {"remote-present", "keyboard-capture"}):
|
||||
raise ProtocolError("Invalid action event")
|
||||
elif event["kind"] == "remote.revoked":
|
||||
if (not _token(event.get("lease")) or event.get("action", "") != "" or event.get("role", "") != ""
|
||||
or event.get("grants", []) != [] or event.get("reason") not in ("closed", "expired", "local", "replaced")):
|
||||
raise ProtocolError("Invalid revoke event")
|
||||
else:
|
||||
raise ProtocolError("Unknown session event")
|
||||
with self._lock:
|
||||
if not self._active or event["sequence"] != self._event_sequence + 1:
|
||||
raise ProtocolError("Out-of-order session event")
|
||||
self._event_sequence = event["sequence"]
|
||||
try:
|
||||
self._events.put_nowait(event)
|
||||
except queue.Full:
|
||||
self._abort()
|
||||
raise ProtocolError("Event queue limit") from None
|
||||
Reference in new issue
Block a user