559 lines
22 KiB
Python
559 lines
22 KiB
Python
"""Portable access to the captured real FrameBridge tmux CUA-TTY engine.
|
|
|
|
The captured Manager/LocalServer are loaded from original/gerameg, never from
|
|
an installed ALPHA. _tty_backend.py preserves RealTerminalMixin with a recorded
|
|
portability diff. The public RPC uses same-UID sockets plus authorized=True.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
from dataclasses import asdict, dataclass, fields
|
|
import fcntl
|
|
import hashlib
|
|
import importlib
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import socketserver
|
|
import stat
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
import zlib
|
|
|
|
_SESSION = re.compile(r"[a-f0-9]{32}\Z")
|
|
_ERROR = re.compile(r"[A-Z][A-Z0-9_]{0,79}\Z")
|
|
_WIRE_CHARS = 1800
|
|
_STARTED_CHILDREN = {}
|
|
|
|
|
|
def _source_root():
|
|
here = Path(__file__).resolve().parents[2]
|
|
prefix = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", str(here)))
|
|
for p in (prefix / "original/gerameg", prefix / "sources/original-gerameg",
|
|
here / "original/gerameg", here / "sources/original-gerameg"):
|
|
if (p / "framebridge_cua_tty/core.py").is_file():
|
|
return p
|
|
raise RuntimeError("TTY_ORIGINAL_SOURCE_NOT_FOUND")
|
|
|
|
|
|
def _load_captured():
|
|
name = "framebridge_expanded._captured_cua_tty"
|
|
if name not in sys.modules:
|
|
path = _source_root() / "framebridge_cua_tty"
|
|
spec = importlib.util.spec_from_file_location(
|
|
name, path / "__init__.py", submodule_search_locations=[str(path)])
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
return (importlib.import_module(name + ".core"),
|
|
importlib.import_module(name + ".server"))
|
|
|
|
|
|
_core, _server = _load_captured()
|
|
TerminalDenied = _core.TerminalDenied
|
|
from ._tty_backend import RealTerminalMixin
|
|
from .tty_sudo_control import SudoControlError
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Limits:
|
|
max_sessions: int = 16
|
|
min_rows: int = 2
|
|
max_rows: int = 500
|
|
min_cols: int = 10
|
|
max_cols: int = 1000
|
|
max_text_bytes: int = 65536
|
|
max_screen_bytes: int = 262144
|
|
history_lines: int = 10000
|
|
idle_timeout_s: int = 0
|
|
command_timeout_s: int = 5
|
|
rpc_max_bytes: int = 1048576
|
|
|
|
|
|
def _private(path):
|
|
path = Path(path)
|
|
if path.is_symlink():
|
|
raise TerminalDenied("TTY_PRIVATE_PATH_IS_SYMLINK")
|
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
s = path.stat()
|
|
if s.st_uid != os.geteuid() or stat.S_IMODE(s.st_mode) != 0o700:
|
|
raise TerminalDenied("TTY_PRIVATE_DIRECTORY_REQUIRED")
|
|
return path
|
|
|
|
|
|
def state_path(value=None):
|
|
return Path(value or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE",
|
|
str(Path.home() / ".local/state/framebridge-expanded"))).expanduser().absolute()
|
|
|
|
|
|
def runtime_path(state):
|
|
state = state_path(state)
|
|
tag = hashlib.sha256(str(state).encode()).hexdigest()[:16]
|
|
base = Path("/run/user") / str(os.geteuid())
|
|
try:
|
|
safe = base.is_dir() and base.stat().st_uid == os.geteuid() and not base.is_symlink()
|
|
except OSError:
|
|
safe = False
|
|
if not safe:
|
|
base = Path("/tmp")
|
|
return base / ("fbe-tty-" + str(os.geteuid()) + "-" + tag)
|
|
|
|
|
|
def _config(state):
|
|
path = state / "tty.json"
|
|
data = {}
|
|
if path.exists():
|
|
if path.is_symlink() or path.stat().st_uid != os.geteuid():
|
|
raise TerminalDenied("TTY_CONFIG_OWNER_INVALID")
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except (ValueError, OSError):
|
|
raise TerminalDenied("TTY_CONFIG_INVALID") from None
|
|
if type(data) is not dict or set(data) - {"limits", "cwd"}:
|
|
raise TerminalDenied("TTY_CONFIG_INVALID")
|
|
values = data.get("limits", {})
|
|
allowed = {f.name for f in fields(Limits)}
|
|
if type(values) is not dict or set(values) - allowed:
|
|
raise TerminalDenied("TTY_LIMITS_INVALID")
|
|
limits = Limits(**values)
|
|
if any(type(getattr(limits, f.name)) is not int for f in fields(Limits)):
|
|
raise TerminalDenied("TTY_LIMITS_INVALID")
|
|
if not (1 <= limits.max_sessions <= 4096 and
|
|
1 <= limits.min_rows <= limits.max_rows <= 65535 and
|
|
1 <= limits.min_cols <= limits.max_cols <= 65535 and
|
|
1 <= limits.max_text_bytes <= limits.rpc_max_bytes // 2 and
|
|
1024 <= limits.max_screen_bytes <= limits.rpc_max_bytes - 4096 and
|
|
0 <= limits.history_lines <= 1000000 and
|
|
0 <= limits.idle_timeout_s and 1 <= limits.command_timeout_s <= 300 and
|
|
65536 <= limits.rpc_max_bytes <= 16777216):
|
|
raise TerminalDenied("TTY_LIMITS_INVALID")
|
|
cwd = Path(data.get("cwd", str(state / "home"))).expanduser().absolute()
|
|
if not cwd.exists():
|
|
cwd.mkdir(mode=0o700, parents=True)
|
|
if not cwd.is_dir():
|
|
raise TerminalDenied("TTY_CWD_INVALID")
|
|
return limits, cwd
|
|
|
|
|
|
def _identity(pid):
|
|
try:
|
|
p = Path("/proc") / str(pid)
|
|
parts = (p / "stat").read_text().rsplit(")", 1)[1].split()
|
|
return {"pid": int(pid), "birth": parts[19], "uid": p.stat().st_uid,
|
|
"state": parts[0]}
|
|
except (OSError, ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def _alive(record):
|
|
now = _identity(record.get("pid", 0))
|
|
return bool(now and now["birth"] == record.get("birth")
|
|
and now["uid"] == os.geteuid() and now["state"] != "Z")
|
|
|
|
|
|
def _write_json(path, value):
|
|
tmp = path.with_name(path.name + ".new")
|
|
tmp.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n")
|
|
tmp.chmod(0o600)
|
|
tmp.replace(path)
|
|
|
|
|
|
def _safe_error(exc):
|
|
name = str(exc.args[0]) if getattr(exc, "args", ()) else ""
|
|
if name.startswith("OWNER_"):
|
|
return "TTY_INPUT_CONTEXT_INVALID"
|
|
return name if _ERROR.fullmatch(name) else "TTY_INTERNAL_ERROR"
|
|
|
|
|
|
class PortableManager(RealTerminalMixin, _core.Manager):
|
|
"""The operative real-terminal mixin with portable runtime configuration."""
|
|
def __init__(self, state, limits, home):
|
|
self.profile = state
|
|
self.limits = limits
|
|
self.home = home
|
|
self.tmux_socket = runtime_path(state) / "tmux.sock"
|
|
self.tmux_bin = shutil.which("tmux")
|
|
if not self.tmux_bin:
|
|
raise TerminalDenied("TMUX_NOT_AVAILABLE")
|
|
# The original constructor initializes its lock and bookkeeping.
|
|
# Session policy is supplied by this profile, not its old staging cap.
|
|
super().__init__(owner_verifier=self._local_context, max_sessions=2,
|
|
idle_timeout_s=600)
|
|
self.max_sessions = limits.max_sessions
|
|
self._last_used = {}
|
|
|
|
@staticmethod
|
|
def _local_context(context):
|
|
return (type(context) is dict and context.get("program") == "bash"
|
|
and context.get("operation") in {"text.type", "key.send"}
|
|
and type(context.get("session_id")) is str
|
|
and _SESSION.fullmatch(context["session_id"]) is not None
|
|
and type(context.get("payload_sha256")) is str
|
|
and re.fullmatch(r"[a-f0-9]{64}", context["payload_sha256"]) is not None
|
|
and context.get("owner_assertion") == {})
|
|
|
|
def _size(self, rows, cols):
|
|
if (type(rows) is not int or type(cols) is not int or
|
|
not self.limits.min_rows <= rows <= self.limits.max_rows or
|
|
not self.limits.min_cols <= cols <= self.limits.max_cols):
|
|
raise TerminalDenied("SIZE_INVALID")
|
|
|
|
def open(self, program="bash", rows=24, cols=80):
|
|
if program != "bash":
|
|
raise TerminalDenied("PROGRAM_NOT_SUPPORTED")
|
|
self._size(rows, cols)
|
|
result = super().open("bash", rows, cols)
|
|
self._last_used[result["session_id"]] = time.monotonic()
|
|
return result
|
|
|
|
def reap(self):
|
|
super().reap()
|
|
if self.limits.idle_timeout_s:
|
|
now = time.monotonic()
|
|
for sid, last in list(self._last_used.items()):
|
|
if now - last > self.limits.idle_timeout_s and sid in self._real:
|
|
try:
|
|
super().abort(sid)
|
|
finally:
|
|
self._last_used.pop(sid, None)
|
|
|
|
def touch(self, sid):
|
|
if sid not in self._real:
|
|
raise TerminalDenied("SESSION_NOT_FOUND")
|
|
self._last_used[sid] = time.monotonic()
|
|
|
|
def abort(self, sid):
|
|
if sid not in self._real:
|
|
raise TerminalDenied("SESSION_NOT_FOUND")
|
|
result = super().abort(sid)
|
|
self._last_used.pop(sid, None)
|
|
return result
|
|
|
|
def status(self):
|
|
result = super().status()
|
|
for key in ("owner_device_connected", "owner_signature_required"):
|
|
result.pop(key, None)
|
|
result.update({"input_policy": "same-uid-authorized",
|
|
"input_enabled": True,
|
|
"limits": asdict(self.limits),
|
|
"real_idle_timeout_s": self.limits.idle_timeout_s or None})
|
|
return result
|
|
|
|
|
|
class PortableHandler(socketserver.StreamRequestHandler):
|
|
def send(self, value):
|
|
raw = (json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n").encode()
|
|
if len(raw) > self.server.limits.rpc_max_bytes:
|
|
raw = b'{"ok":false,"error":"TTY_RESPONSE_TOO_LARGE"}\n'
|
|
self.wfile.write(raw)
|
|
|
|
def handle(self):
|
|
try:
|
|
self.request.settimeout(self.server.limits.command_timeout_s + 5)
|
|
_, uid, _ = struct.unpack("3i", self.request.getsockopt(
|
|
socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
|
if uid != self.server.owner_uid:
|
|
self.send({"ok": False, "error": "SAME_UID_REQUIRED"})
|
|
return
|
|
raw = self.rfile.readline(self.server.limits.rpc_max_bytes + 1)
|
|
if len(raw) > self.server.limits.rpc_max_bytes or not raw.endswith(b"\n"):
|
|
raise TerminalDenied("TTY_REQUEST_TOO_LARGE_OR_TRUNCATED")
|
|
payload = json.loads(raw)
|
|
result = dispatch(self.server.manager, payload)
|
|
self.send(result)
|
|
except (TerminalDenied, SudoControlError, KeyError) as exc:
|
|
self.send({"ok": False, "error": _safe_error(exc)})
|
|
except (ValueError, TypeError, UnicodeError):
|
|
self.send({"ok": False, "error": "TTY_REQUEST_INVALID"})
|
|
except Exception:
|
|
self.send({"ok": False, "error": "TTY_INTERNAL_ERROR"})
|
|
|
|
|
|
class PortableServer(_server.LocalServer):
|
|
def __init__(self, path, manager):
|
|
p = Path(path).absolute()
|
|
_private(p.parent)
|
|
if p.exists() or p.is_symlink():
|
|
raise TerminalDenied("TTY_SOCKET_ALREADY_EXISTS")
|
|
self.owner_uid = os.geteuid()
|
|
self.manager = manager
|
|
self.limits = manager.limits
|
|
self._socket_path = p
|
|
old = os.umask(0o077)
|
|
try:
|
|
socketserver.UnixStreamServer.__init__(self, str(p), PortableHandler)
|
|
finally:
|
|
os.umask(old)
|
|
p.chmod(0o600)
|
|
|
|
|
|
_SHAPES = {
|
|
"status": (set(), set()),
|
|
"open": (set(), {"program", "rows", "cols"}),
|
|
"observe": ({"session_id"}, set()),
|
|
"key.send": ({"session_id", "key"}, set()),
|
|
"text.type": ({"session_id", "text"}, set()),
|
|
"resize": ({"session_id", "rows", "cols"}, set()),
|
|
"close": ({"session_id"}, set()),
|
|
"sudo": ({"session_id", "mode"}, set()),
|
|
}
|
|
|
|
|
|
def dispatch(manager, payload):
|
|
if type(payload) is not dict:
|
|
raise TerminalDenied("TTY_REQUEST_INVALID")
|
|
if payload.get("authorized") is not True:
|
|
raise TerminalDenied("AUTHORIZED_TRUE_REQUIRED")
|
|
op = payload.get("op")
|
|
if op == "abort":
|
|
op = "close"
|
|
if type(op) is not str or op not in _SHAPES:
|
|
raise TerminalDenied("TTY_OPERATION_INVALID")
|
|
required, optional = _SHAPES[op]
|
|
keys = set(payload) - {"op", "authorized"}
|
|
if not required <= keys or keys - required - optional:
|
|
raise TerminalDenied("TTY_REQUEST_INVALID")
|
|
sid = payload.get("session_id")
|
|
if sid is not None and (type(sid) is not str or not _SESSION.fullmatch(sid)):
|
|
raise TerminalDenied("SESSION_ID_INVALID")
|
|
with manager._lock:
|
|
manager.reap()
|
|
if op not in {"status", "open"}:
|
|
manager.touch(sid)
|
|
if op == "status":
|
|
data = manager.status()
|
|
elif op == "open":
|
|
data = manager.open(payload.get("program", "bash"),
|
|
payload.get("rows", 24), payload.get("cols", 80))
|
|
elif op == "sudo":
|
|
data = manager.sudo(sid, payload["mode"])
|
|
if payload["mode"] != "status" and data.get("cleanup_pending"):
|
|
return {"ok": False, "error": "TTY_SUDO_TRANSITION_CLEANUP_PENDING",
|
|
"result": data, "transport": "same-uid-unix"}
|
|
elif op == "observe":
|
|
data = manager.observe(sid)
|
|
data = dict(data, observation_kind="terminal_transcript", authentic_pixels=False, full_screen=False)
|
|
elif op == "key.send":
|
|
data = manager.key(sid, payload["key"], approved_context={})
|
|
elif op == "text.type":
|
|
data = manager.text(sid, payload["text"], approved_context={})
|
|
elif op == "resize":
|
|
manager._size(payload["rows"], payload["cols"])
|
|
data = manager.resize(sid, payload["rows"], payload["cols"])
|
|
else:
|
|
data = manager.abort(sid)
|
|
return {"ok": True, "result": data, "transport": "same-uid-unix"}
|
|
|
|
|
|
def request(state, payload):
|
|
"""Send one authorized operation to this profile's real TTY daemon."""
|
|
try:
|
|
if type(payload) is not dict or payload.get("authorized") is not True:
|
|
return {"ok": False, "error": "AUTHORIZED_TRUE_REQUIRED"}
|
|
state = state_path(state)
|
|
path = runtime_path(state) / "agent.sock"
|
|
if not path.exists():
|
|
return {"ok": False, "error": "TTY_NOT_RUNNING"}
|
|
s = path.lstat()
|
|
if (not stat.S_ISSOCK(s.st_mode) or s.st_uid != os.geteuid()
|
|
or stat.S_IMODE(s.st_mode) != 0o600):
|
|
return {"ok": False, "error": "TTY_SOCKET_UNTRUSTED"}
|
|
limits, _ = _config(state)
|
|
raw = (json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n").encode()
|
|
if len(raw) > limits.rpc_max_bytes:
|
|
return {"ok": False, "error": "TTY_REQUEST_TOO_LARGE_OR_TRUNCATED"}
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
client.settimeout(limits.command_timeout_s + 10)
|
|
client.connect(str(path))
|
|
client.sendall(raw)
|
|
client.shutdown(socket.SHUT_WR)
|
|
received = bytearray()
|
|
while True:
|
|
part = client.recv(65536)
|
|
if not part:
|
|
break
|
|
received.extend(part)
|
|
if len(received) > limits.rpc_max_bytes:
|
|
return {"ok": False, "error": "TTY_RESPONSE_TOO_LARGE"}
|
|
value = json.loads(received)
|
|
return value if type(value) is dict else {"ok": False, "error": "TTY_RESPONSE_INVALID"}
|
|
except TerminalDenied as exc:
|
|
return {"ok": False, "error": _safe_error(exc)}
|
|
except (OSError, TimeoutError):
|
|
return {"ok": False, "error": "TTY_UNAVAILABLE"}
|
|
except (ValueError, TypeError, UnicodeError):
|
|
return {"ok": False, "error": "TTY_RESPONSE_INVALID"}
|
|
|
|
|
|
def status(state=None):
|
|
return request(state, {"op": "status", "authorized": True})
|
|
|
|
|
|
def serve(state=None):
|
|
state = _private(state_path(state))
|
|
limits, home = _config(state)
|
|
rt = _private(runtime_path(state))
|
|
lock = (state / "tty.lock").open("a")
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
path = rt / "agent.sock"
|
|
if path.exists() or path.is_symlink():
|
|
old = status(state)
|
|
if old.get("ok"):
|
|
raise TerminalDenied("TTY_ALREADY_RUNNING")
|
|
st = path.lstat()
|
|
if not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.geteuid():
|
|
raise TerminalDenied("TTY_SOCKET_UNTRUSTED")
|
|
path.unlink()
|
|
manager = PortableManager(state, limits, home)
|
|
def stopped(_signum, _frame):
|
|
raise SystemExit(0)
|
|
signal.signal(signal.SIGTERM, stopped)
|
|
signal.signal(signal.SIGINT, stopped)
|
|
pidfile = state / "tty-process.json"
|
|
_write_json(pidfile, {**_identity(os.getpid()), "socket": str(path), "state_dir": str(state)})
|
|
try:
|
|
with PortableServer(path, manager) as server:
|
|
print(json.dumps({"ok": True, "ready": True, "socket": str(path),
|
|
"policy": "same-uid-authorized", "x11_required": False}), flush=True)
|
|
server.serve_forever(poll_interval=0.2)
|
|
finally:
|
|
pidfile.unlink(missing_ok=True)
|
|
|
|
|
|
def start(state=None):
|
|
state = _private(state_path(state))
|
|
current = status(state)
|
|
if current.get("ok"):
|
|
return current
|
|
env = os.environ.copy()
|
|
root = str(Path(__file__).resolve().parents[1])
|
|
env["PYTHONPATH"] = root + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
|
log = (state / "tty.log").open("ab")
|
|
proc = subprocess.Popen([sys.executable, "-m", "framebridge_expanded.tty",
|
|
"serve", "--state", str(state)], env=env,
|
|
stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT,
|
|
start_new_session=True, umask=0o077)
|
|
log.close()
|
|
_STARTED_CHILDREN[str(state)] = proc
|
|
deadline = time.monotonic() + 5
|
|
while proc.poll() is None and time.monotonic() < deadline:
|
|
current = status(state)
|
|
if current.get("ok"):
|
|
return current
|
|
time.sleep(0.1)
|
|
return {"ok": False, "error": "TTY_START_FAILED"}
|
|
|
|
|
|
def stop(state=None):
|
|
state = state_path(state)
|
|
p = state / "tty-process.json"
|
|
if not p.is_file() or p.is_symlink():
|
|
return {"ok": True, "state": "STOPPED", "already_stopped": True}
|
|
try:
|
|
record = json.loads(p.read_text())
|
|
if not _alive(record):
|
|
return {"ok": True, "state": "STOPPED", "already_stopped": True}
|
|
os.kill(record["pid"], signal.SIGTERM)
|
|
deadline = time.monotonic() + 10
|
|
while _alive(record) and time.monotonic() < deadline:
|
|
time.sleep(0.1)
|
|
return {"ok": not _alive(record), "state": "STOPPED" if not _alive(record) else "STOP_TIMEOUT"}
|
|
except (ValueError, OSError, KeyError):
|
|
return {"ok": False, "error": "TTY_STOP_FAILED"}
|
|
finally:
|
|
child = _STARTED_CHILDREN.get(str(state))
|
|
if child is not None:
|
|
try:
|
|
child.wait(timeout=2)
|
|
_STARTED_CHILDREN.pop(str(state), None)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
|
|
def _wire_reply(state, result):
|
|
cache = _private(state_path(state) / "tty-replies")
|
|
for old in cache.glob("*.json"):
|
|
if time.time() - old.stat().st_mtime > 3600:
|
|
old.unlink(missing_ok=True)
|
|
raw = json.dumps(result, ensure_ascii=True, separators=(",", ":")).encode()
|
|
encoded = base64.b64encode(zlib.compress(raw, 6)).decode("ascii")
|
|
chunks = [encoded[i:i + _WIRE_CHARS] for i in range(0, len(encoded), _WIRE_CHARS)]
|
|
ticket = uuid.uuid4().hex
|
|
item = {"ticket": ticket, "sha256": hashlib.sha256(raw).hexdigest(),
|
|
"bytes": len(raw), "chunks": chunks}
|
|
_write_json(cache / (ticket + ".json"), item)
|
|
return {"ok": True, "wire": "zlib-base64-v1", "ticket": ticket,
|
|
"sha256": item["sha256"], "bytes": len(raw), "chunks": len(chunks),
|
|
"index": 0, "data": chunks[0]}
|
|
|
|
|
|
def read_reply(state, ticket, index):
|
|
if type(ticket) is not str or not _SESSION.fullmatch(ticket) or type(index) is not int or index < 0:
|
|
return {"ok": False, "error": "TTY_REPLY_INVALID"}
|
|
p = state_path(state) / "tty-replies" / (ticket + ".json")
|
|
try:
|
|
st = p.lstat()
|
|
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.geteuid() or st.st_mode & 0o077:
|
|
raise ValueError()
|
|
item = json.loads(p.read_text())
|
|
return {"ok": True, "ticket": ticket, "index": index, "data": item["chunks"][index],
|
|
"chunks": len(item["chunks"]), "sha256": item["sha256"]}
|
|
except (OSError, ValueError, KeyError, IndexError):
|
|
return {"ok": False, "error": "TTY_REPLY_NOT_FOUND"}
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description="Real persistent Bash CUA-TTY over a same-UID Unix socket.")
|
|
parser.add_argument("command", choices=("serve", "start", "stop", "status", "request", "read"))
|
|
parser.add_argument("--state", default=None)
|
|
parser.add_argument("--payload-b64", default=None)
|
|
parser.add_argument("--wire", action="store_true", help="Return a compressed, chunked response for bounded transports.")
|
|
parser.add_argument("--ticket")
|
|
parser.add_argument("--index", type=int, default=0)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
if args.command == "serve":
|
|
serve(args.state)
|
|
return 0
|
|
if args.command == "start":
|
|
result = start(args.state)
|
|
elif args.command == "stop":
|
|
result = stop(args.state)
|
|
elif args.command == "status":
|
|
result = status(args.state)
|
|
elif args.command == "read":
|
|
result = read_reply(args.state, args.ticket, args.index)
|
|
else:
|
|
if args.payload_b64:
|
|
raw = base64.b64decode(args.payload_b64, validate=True)
|
|
else:
|
|
raw = sys.stdin.buffer.read(16777217)
|
|
if len(raw) > 16777216:
|
|
raise TerminalDenied("TTY_REQUEST_TOO_LARGE_OR_TRUNCATED")
|
|
result = request(args.state, json.loads(raw))
|
|
if args.wire:
|
|
result = _wire_reply(args.state, result)
|
|
print(json.dumps(result, ensure_ascii=True, separators=(",", ":")))
|
|
return 0 if result.get("ok") else 1
|
|
except TerminalDenied as exc:
|
|
print(json.dumps({"ok": False, "error": _safe_error(exc)}))
|
|
return 1
|
|
except (ValueError, TypeError, UnicodeError):
|
|
print(json.dumps({"ok": False, "error": "TTY_REQUEST_INVALID"}))
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|