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.
275 lines
12 KiB
Python
275 lines
12 KiB
Python
# Copyright (c) 2026 Storm Dragon
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""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
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
|
|
MAX_FRAME = 64 * 1024
|
|
|
|
|
|
class ProtocolError(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class Command:
|
|
id: str
|
|
title: str
|
|
description: str
|
|
handle: object
|
|
bindings: list = field(default_factory=list)
|
|
presentation: bool = False
|
|
interaction: bool = False
|
|
settings: bool = False
|
|
reader: bool = False
|
|
|
|
def declaration(self):
|
|
declaration = {"id": self.id, "title": self.title, "description": self.description,
|
|
"bindings": self.bindings}
|
|
if self.presentation:
|
|
declaration["presentation"] = True
|
|
for name in ("interaction", "settings", "reader"):
|
|
if getattr(self, name):
|
|
declaration[name] = True
|
|
return declaration
|
|
|
|
|
|
def present(speech="", braille="", uninterruptible=False):
|
|
"""Return independent text messages from a presentation-enabled command."""
|
|
if type(uninterruptible) is not bool or (uninterruptible and not speech):
|
|
raise ValueError("Uninterruptible output requires speech and a boolean option")
|
|
result = {key: text for key, text in (("speech", speech), ("braille", braille)) if text}
|
|
if uninterruptible:
|
|
result["uninterruptible"] = True
|
|
return result
|
|
|
|
|
|
def _object(pairs):
|
|
result = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ProtocolError("Duplicate key")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _depth(value, depth=0):
|
|
if depth > 32:
|
|
raise ProtocolError("JSON nesting limit")
|
|
if isinstance(value, (dict, list)):
|
|
for child in value.values() if isinstance(value, dict) else value:
|
|
_depth(child, depth + 1)
|
|
|
|
|
|
def _constant(value):
|
|
raise ProtocolError("Invalid JSON number")
|
|
|
|
|
|
def _encode(value):
|
|
_depth(value)
|
|
return json.dumps(value, ensure_ascii=False, allow_nan=False,
|
|
separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def _fields(value, required, optional=()):
|
|
if (not isinstance(value, dict) or not set(required) <= value.keys()
|
|
or value.keys() - set(required) - set(optional)):
|
|
raise ProtocolError("Invalid fields")
|
|
|
|
|
|
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
|
|
run concurrently. Shutdown/EOF sets it and joins handlers; the host terminates
|
|
children that ignore cancellation. Exceptions become generic RPC errors.
|
|
Streams are binary, owned by this call, and closed on return.
|
|
"""
|
|
inputStream = inputStream if inputStream is not None else sys.stdin.buffer
|
|
outputStream = outputStream if outputStream is not None else sys.stdout.buffer
|
|
cancelled = threading.Event()
|
|
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}
|
|
if failed:
|
|
reply["error"] = {"code": -32603, "message": "Command failed"}
|
|
else:
|
|
reply["result"] = value
|
|
try:
|
|
frame = _encode(reply)
|
|
if len(frame) > MAX_FRAME:
|
|
raise ValueError()
|
|
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:
|
|
release()
|
|
if not cancelled.is_set():
|
|
outputStream.write(frame + b"\n")
|
|
outputStream.flush()
|
|
|
|
def invoke(requestId, handler, arguments):
|
|
released = False
|
|
|
|
def release_slot():
|
|
nonlocal released
|
|
if not released:
|
|
slots.release()
|
|
released = True
|
|
|
|
try:
|
|
value = handler(cancelled, arguments)
|
|
except BaseException:
|
|
value, failed = None, True
|
|
else:
|
|
failed = False
|
|
try:
|
|
write_reply(requestId, value, failed, release_slot)
|
|
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)
|
|
or any(not key or not callable(value) for key, value in handlers.items())):
|
|
raise ProtocolError("Invalid commands")
|
|
if (sum(command.settings for command in commands) > 1
|
|
or any(command.interaction and not command.presentation
|
|
or command.settings and not command.interaction
|
|
or command.reader and not command.interaction for command in commands)):
|
|
raise ProtocolError("Invalid command capabilities")
|
|
session, active, sequence = None, False, 0
|
|
while not cancelled.is_set():
|
|
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:
|
|
raise ProtocolError("Invalid frame size or termination")
|
|
try:
|
|
message = json.loads(frame.decode("utf-8"), object_pairs_hook=_object,
|
|
parse_constant=_constant)
|
|
_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")
|
|
requestId = message.get("id", "")
|
|
if requestId:
|
|
if (not isinstance(requestId, str) or not requestId.isascii()
|
|
or not requestId.isdecimal() or len(requestId) > 20
|
|
or not sequence < int(requestId) < 2**64
|
|
or str(int(requestId)) != requestId):
|
|
raise ProtocolError("Invalid request sequence")
|
|
sequence = int(requestId)
|
|
elif "id" in message:
|
|
raise ProtocolError("Invalid request ID")
|
|
method, params = message["method"], message.get("params")
|
|
if method == "initialize":
|
|
_fields(params, ("protocol", "session", "apis", "capabilities"))
|
|
version = params["protocol"]
|
|
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 not valid_apis
|
|
or (any(command.presentation for command in commands)
|
|
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 "reader-settings" not in apis)):
|
|
raise ProtocolError("Invalid initialization")
|
|
session = params["session"]
|
|
registration = {"protocol": version, "session": session,
|
|
"commands": [command.declaration() for command in commands]}
|
|
if extensions is not None:
|
|
registration["extensions"] = extensions
|
|
write_reply(requestId, registration)
|
|
elif method == "initialized":
|
|
_fields(params, ("session",))
|
|
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)
|
|
or params["command"] not in handlers
|
|
or len(_encode(params["arguments"])) > (48 if next(
|
|
command.reader for command in commands if command.id == params["command"]) else 16) * 1024):
|
|
raise ProtocolError("Invalid invocation")
|
|
workers = [worker for worker in workers if worker.is_alive()]
|
|
if not slots.acquire(blocking=False):
|
|
raise ProtocolError("Command limit")
|
|
worker = threading.Thread(target=invoke, args=(requestId,
|
|
handlers[params["command"]], params["arguments"]))
|
|
workers.append(worker)
|
|
worker.start()
|
|
elif method == "shutdown":
|
|
if requestId or params is not None:
|
|
raise ProtocolError("Invalid shutdown")
|
|
return
|
|
else:
|
|
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
|