223 lines
6.8 KiB
Python
223 lines
6.8 KiB
Python
"""Single-request JSON-line entrypoint for the JWeBot extension host."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
from jwebot_kernel import RuntimeTelemetry
|
|
|
|
from .runtime import (
|
|
CapabilityUnavailable,
|
|
ExtensionRuntime,
|
|
HostError,
|
|
process_request,
|
|
)
|
|
|
|
|
|
MAX_REQUEST_BYTES = 256 * 1024
|
|
MAX_SERVICE_CALLS = 8
|
|
|
|
|
|
def _scrub_environment() -> None:
|
|
retained = {
|
|
key: value
|
|
for key, value in os.environ.items()
|
|
if key in {"LANG", "LC_ALL", "TZ"}
|
|
}
|
|
os.environ.clear()
|
|
os.environ.update(retained)
|
|
|
|
|
|
def _read_message(stream: object) -> dict[str, object]:
|
|
raw = stream.readline(MAX_REQUEST_BYTES + 1)
|
|
if not raw or len(raw) > MAX_REQUEST_BYTES:
|
|
raise HostError("request payload is empty or too large")
|
|
try:
|
|
value = json.loads(raw)
|
|
except json.JSONDecodeError as error:
|
|
raise HostError("request payload is invalid") from error
|
|
if not isinstance(value, dict):
|
|
raise HostError("request payload must be an object")
|
|
return value
|
|
|
|
|
|
def _write_message(
|
|
value: Mapping[str, object],
|
|
stream: object,
|
|
) -> None:
|
|
payload = (
|
|
json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
+ "\n"
|
|
)
|
|
encoded = payload.encode("utf-8")
|
|
if len(encoded) > MAX_REQUEST_BYTES:
|
|
raise HostError("response payload is too large")
|
|
stream.write(encoded)
|
|
stream.flush()
|
|
|
|
|
|
class _ProtocolServiceBroker:
|
|
def __init__(
|
|
self,
|
|
input_stream: object,
|
|
output_stream: object,
|
|
telemetry: RuntimeTelemetry,
|
|
) -> None:
|
|
self.calls = 0
|
|
self.input_stream = input_stream
|
|
self.output_stream = output_stream
|
|
self.telemetry = telemetry
|
|
|
|
def __call__(
|
|
self,
|
|
profile: str,
|
|
operation: str,
|
|
payload: Mapping[str, object],
|
|
) -> Mapping[str, object]:
|
|
started = time.monotonic()
|
|
self.calls += 1
|
|
if self.calls > MAX_SERVICE_CALLS:
|
|
raise CapabilityUnavailable("service call limit exceeded")
|
|
call_id = str(self.calls)
|
|
_write_message(
|
|
{
|
|
"status": "service_request",
|
|
"call_id": call_id,
|
|
"profile": profile,
|
|
"operation": operation,
|
|
"payload": dict(payload),
|
|
},
|
|
self.output_stream,
|
|
)
|
|
response = _read_message(self.input_stream)
|
|
if response.get("call_id") != call_id:
|
|
raise CapabilityUnavailable("service response id mismatch")
|
|
if response.get("op") == "service_error":
|
|
self.telemetry.elapsed(
|
|
"host.service.wait",
|
|
started,
|
|
status="error",
|
|
plugin_id=profile,
|
|
operation=operation,
|
|
error_type=str(response.get("error", "ServiceError")),
|
|
)
|
|
raise CapabilityUnavailable("kernel service call failed")
|
|
if response.get("op") != "service_result":
|
|
raise CapabilityUnavailable("kernel service response is invalid")
|
|
result = response.get("result")
|
|
if not isinstance(result, Mapping):
|
|
raise CapabilityUnavailable("kernel service result is invalid")
|
|
self.telemetry.elapsed(
|
|
"host.service.wait",
|
|
started,
|
|
plugin_id=profile,
|
|
operation=operation,
|
|
)
|
|
return dict(result)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--release-dir", type=Path, required=True)
|
|
parser.add_argument("--allowed-signers", type=Path)
|
|
parser.add_argument("--publisher")
|
|
parser.add_argument("--allow-unsigned-local", action="store_true")
|
|
args = parser.parse_args()
|
|
if args.allow_unsigned_local:
|
|
if args.allowed_signers is not None or args.publisher is not None:
|
|
parser.error(
|
|
"--allow-unsigned-local cannot include publisher trust options"
|
|
)
|
|
elif args.allowed_signers is None:
|
|
parser.error("signed host requires --allowed-signers")
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
)
|
|
_scrub_environment()
|
|
telemetry = RuntimeTelemetry(component="extension-host")
|
|
process_started = time.monotonic()
|
|
input_stream = sys.stdin.buffer
|
|
output_stream = sys.stdout.buffer
|
|
try:
|
|
service_broker = _ProtocolServiceBroker(
|
|
input_stream,
|
|
output_stream,
|
|
telemetry,
|
|
)
|
|
load_started = time.monotonic()
|
|
runtime = ExtensionRuntime.load(
|
|
args.release_dir,
|
|
allowed_signers=args.allowed_signers,
|
|
expected_publisher=args.publisher,
|
|
allow_unsigned=args.allow_unsigned_local,
|
|
service_broker=service_broker,
|
|
)
|
|
telemetry.elapsed(
|
|
"host.runtime.load",
|
|
load_started,
|
|
plugin_id=runtime.manifest.plugin_id,
|
|
process="extension-host",
|
|
)
|
|
request = _read_message(input_stream)
|
|
event_value = request.get("event")
|
|
event_id = (
|
|
event_value.get("event_id", "")
|
|
if isinstance(event_value, Mapping)
|
|
else ""
|
|
)
|
|
telemetry.emit(
|
|
"host.request.received",
|
|
event_id=event_id,
|
|
plugin_id=runtime.manifest.plugin_id,
|
|
operation=str(request.get("op", "")),
|
|
process="extension-host",
|
|
)
|
|
response = process_request(runtime, request)
|
|
except HostError as error:
|
|
logging.warning("extension host request failed error_type=%s", type(error).__name__)
|
|
telemetry.elapsed(
|
|
"host.process.complete",
|
|
process_started,
|
|
status="error",
|
|
process="extension-host",
|
|
error_type=type(error).__name__,
|
|
)
|
|
response = {"status": "error", "error": type(error).__name__}
|
|
except Exception as error:
|
|
logging.error("extension host failed error_type=%s", type(error).__name__)
|
|
telemetry.elapsed(
|
|
"host.process.complete",
|
|
process_started,
|
|
status="error",
|
|
process="extension-host",
|
|
error_type=type(error).__name__,
|
|
)
|
|
response = {"status": "error", "error": "HostUnavailable"}
|
|
else:
|
|
telemetry.elapsed(
|
|
"host.process.complete",
|
|
process_started,
|
|
plugin_id=str(response.get("plugin_id", "")),
|
|
operation=str(request.get("op", "")),
|
|
process="extension-host",
|
|
)
|
|
_write_message(response, output_stream)
|
|
return 0 if response.get("status") == "ok" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|