release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-extension-host"
|
||||
version = "0.6.0a0+1015"
|
||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.0a0+1015",
|
||||
"jwebot-kernel==0.6.0a0+1015",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
jwebot-extension-host = "jwebot_extension_host.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Single-release process host for reviewed JWeBot extensions."""
|
||||
|
||||
from .runtime import (
|
||||
CapabilityUnavailable,
|
||||
ExtensionExecutionError,
|
||||
ExtensionRuntime,
|
||||
HostError,
|
||||
process_request,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CapabilityUnavailable",
|
||||
"ExtensionExecutionError",
|
||||
"ExtensionRuntime",
|
||||
"HostError",
|
||||
"process_request",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
@@ -0,0 +1,222 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Load one verified release and execute bounded command requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jwebot_kernel import (
|
||||
ActivationError,
|
||||
PluginManifest,
|
||||
ReleaseError,
|
||||
load_manifest,
|
||||
validate_source_tree,
|
||||
verify_installed_release,
|
||||
verify_installed_release_signature,
|
||||
)
|
||||
from jwebot_sdk import (
|
||||
ContractViolation,
|
||||
Event,
|
||||
ExtensionResult,
|
||||
Fact,
|
||||
)
|
||||
|
||||
|
||||
MAX_ARGUMENT_CHARS = 1_000
|
||||
MAX_PLUGIN_OUTPUT_CHARS = 64 * 1024
|
||||
MAX_SERVICE_OPERATION_CHARS = 80
|
||||
|
||||
|
||||
class HostError(ValueError):
|
||||
"""Controlled extension-host setup or request failure."""
|
||||
|
||||
|
||||
class CapabilityUnavailable(HostError):
|
||||
"""Requested capability has no configured kernel broker."""
|
||||
|
||||
|
||||
class ExtensionExecutionError(HostError):
|
||||
"""Extension code failed without exposing private exception details."""
|
||||
|
||||
|
||||
class _BoundedTextSink:
|
||||
def __init__(self, target: Any, limit: int = MAX_PLUGIN_OUTPUT_CHARS) -> None:
|
||||
self.target = target
|
||||
self.remaining = limit
|
||||
|
||||
def write(self, value: object) -> int:
|
||||
text = str(value)
|
||||
accepted = text[: self.remaining]
|
||||
self.remaining -= len(accepted)
|
||||
if accepted:
|
||||
self.target.write(accepted)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.target.flush()
|
||||
|
||||
|
||||
class _CommandRegistry:
|
||||
def __init__(self, manifest: PluginManifest) -> None:
|
||||
self._declared = {
|
||||
command.casefold(): command
|
||||
for command in manifest.capabilities.commands
|
||||
}
|
||||
self._handlers: dict[str, Callable[[Event, str], ExtensionResult]] = {}
|
||||
self._frozen = False
|
||||
|
||||
def register(
|
||||
self,
|
||||
command: str,
|
||||
handler: Callable[[Event, str], ExtensionResult],
|
||||
) -> None:
|
||||
if self._frozen:
|
||||
raise HostError("command registry is frozen")
|
||||
key = command.casefold()
|
||||
if key not in self._declared:
|
||||
raise HostError(f"extension registered undeclared command: {command}")
|
||||
if key in self._handlers:
|
||||
raise HostError(f"extension registered command twice: {command}")
|
||||
if not callable(handler):
|
||||
raise HostError("command handler must be callable")
|
||||
self._handlers[key] = handler
|
||||
|
||||
def freeze(self) -> None:
|
||||
missing = set(self._declared) - set(self._handlers)
|
||||
if missing:
|
||||
names = ", ".join(self._declared[key] for key in sorted(missing))
|
||||
raise HostError(f"extension did not register declared commands: {names}")
|
||||
self._frozen = True
|
||||
|
||||
def handler(self, command: str) -> Callable[[Event, str], ExtensionResult]:
|
||||
if not self._frozen:
|
||||
raise HostError("command registry is not frozen")
|
||||
try:
|
||||
return self._handlers[command.casefold()]
|
||||
except KeyError as error:
|
||||
raise HostError("command is not registered by this extension") from error
|
||||
|
||||
|
||||
class _UnavailableStorage:
|
||||
def get(self, key: str) -> str | None:
|
||||
del key
|
||||
raise CapabilityUnavailable("storage broker is not configured")
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
del key, value
|
||||
raise CapabilityUnavailable("storage broker is not configured")
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
del key
|
||||
raise CapabilityUnavailable("storage broker is not configured")
|
||||
|
||||
|
||||
class _UnavailableHttp:
|
||||
def request(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
query: Mapping[str, str] | None = None,
|
||||
) -> bytes:
|
||||
del profile, method, path, query
|
||||
raise CapabilityUnavailable("HTTP broker is not configured")
|
||||
|
||||
|
||||
class _Services:
|
||||
def __init__(
|
||||
self,
|
||||
manifest: PluginManifest,
|
||||
broker: Callable[
|
||||
[str, str, Mapping[str, object]],
|
||||
Mapping[str, object],
|
||||
]
|
||||
| None,
|
||||
) -> None:
|
||||
self._profiles = frozenset(manifest.capabilities.service_profiles)
|
||||
self._broker = broker
|
||||
|
||||
def call(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
operation: str,
|
||||
payload: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
if profile not in self._profiles:
|
||||
raise CapabilityUnavailable("service profile is not declared")
|
||||
if (
|
||||
not isinstance(operation, str)
|
||||
or not operation
|
||||
or len(operation) > MAX_SERVICE_OPERATION_CHARS
|
||||
):
|
||||
raise HostError("service operation is invalid")
|
||||
if not isinstance(payload, Mapping):
|
||||
raise HostError("service payload must be an object")
|
||||
if self._broker is None:
|
||||
raise CapabilityUnavailable("service broker is not configured")
|
||||
result = self._broker(profile, operation, payload)
|
||||
if not isinstance(result, Mapping):
|
||||
raise HostError("service broker returned an invalid result")
|
||||
return dict(result)
|
||||
|
||||
|
||||
class _FactCollector:
|
||||
def __init__(self) -> None:
|
||||
self._facts: list[Fact] = []
|
||||
|
||||
def write(self, fact: Fact) -> None:
|
||||
if not isinstance(fact, Fact):
|
||||
raise HostError("fact writer accepts Fact objects only")
|
||||
self._facts.append(fact)
|
||||
if len(self._facts) > 12:
|
||||
raise HostError("extension emitted more than 12 facts")
|
||||
|
||||
def reset(self) -> None:
|
||||
self._facts.clear()
|
||||
|
||||
def values(self) -> tuple[Fact, ...]:
|
||||
return tuple(self._facts)
|
||||
|
||||
|
||||
class _UnavailableScheduler:
|
||||
def schedule(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
run_at: str,
|
||||
payload: Mapping[str, str],
|
||||
) -> None:
|
||||
del key, run_at, payload
|
||||
raise CapabilityUnavailable("scheduler broker is not configured")
|
||||
|
||||
|
||||
class _SanitizedLogger:
|
||||
def __init__(self, plugin_id: str) -> None:
|
||||
self.plugin_id = plugin_id
|
||||
|
||||
def _write(self, level: int, message: str, fields: Mapping[str, object]) -> None:
|
||||
digest = hashlib.sha256(str(message).encode("utf-8", "replace")).hexdigest()[:12]
|
||||
field_names = "\n".join(sorted(str(key) for key in fields))
|
||||
fields_digest = hashlib.sha256(
|
||||
field_names.encode("utf-8", "replace")
|
||||
).hexdigest()[:12]
|
||||
logging.log(
|
||||
level,
|
||||
"extension log plugin=%s message_hash=%s field_count=%d fields_hash=%s",
|
||||
self.plugin_id,
|
||||
digest,
|
||||
len(fields),
|
||||
fields_digest,
|
||||
)
|
||||
|
||||
def info(self, message: str, **fields: object) -> None:
|
||||
self._write(logging.INFO, message, fields)
|
||||
|
||||
def warning(self, message: str, **fields: object) -> None:
|
||||
self._write(logging.WARNING, message, fields)
|
||||
|
||||
|
||||
class _HostContext:
|
||||
def __init__(
|
||||
self,
|
||||
manifest: PluginManifest,
|
||||
service_broker: Callable[
|
||||
[str, str, Mapping[str, object]],
|
||||
Mapping[str, object],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
self.commands = _CommandRegistry(manifest)
|
||||
self.storage = _UnavailableStorage()
|
||||
self.http = _UnavailableHttp()
|
||||
self.services = _Services(manifest, service_broker)
|
||||
self.facts = _FactCollector()
|
||||
self.scheduler = _UnavailableScheduler()
|
||||
self.logger = _SanitizedLogger(manifest.plugin_id)
|
||||
|
||||
|
||||
class ExtensionRuntime:
|
||||
def __init__(
|
||||
self,
|
||||
release_dir: Path,
|
||||
manifest: PluginManifest,
|
||||
context: _HostContext,
|
||||
) -> None:
|
||||
self.release_dir = release_dir
|
||||
self.manifest = manifest
|
||||
self.context = context
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
release_dir: Path,
|
||||
*,
|
||||
manifest: PluginManifest | None = None,
|
||||
allowed_signers: Path | None = None,
|
||||
expected_publisher: str | None = None,
|
||||
allow_unsigned: bool = False,
|
||||
service_broker: Callable[
|
||||
[str, str, Mapping[str, object]],
|
||||
Mapping[str, object],
|
||||
]
|
||||
| None = None,
|
||||
) -> "ExtensionRuntime":
|
||||
if allow_unsigned:
|
||||
if allowed_signers is not None or expected_publisher is not None:
|
||||
raise HostError(
|
||||
"unsigned local host cannot include trusted publishers"
|
||||
)
|
||||
try:
|
||||
record = verify_installed_release(release_dir)
|
||||
except (ActivationError, ReleaseError) as error:
|
||||
raise HostError("installed release verification failed") from error
|
||||
else:
|
||||
if allowed_signers is None:
|
||||
raise HostError("signed extension release is required")
|
||||
try:
|
||||
record, _ = verify_installed_release_signature(
|
||||
release_dir,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
except (ActivationError, ReleaseError) as error:
|
||||
raise HostError("installed release verification failed") from error
|
||||
manifest = manifest or load_manifest(release_dir / "plugin.toml")
|
||||
if (
|
||||
record.plugin_id != manifest.plugin_id
|
||||
or record.author != manifest.author
|
||||
or record.plugin_version != manifest.version
|
||||
or record.api_version != manifest.api_version
|
||||
):
|
||||
raise HostError("installed release and plugin manifest disagree")
|
||||
validate_source_tree(release_dir, manifest)
|
||||
|
||||
module_name, _, attribute = manifest.entrypoint.partition(":")
|
||||
source_root = release_dir / "src"
|
||||
module_file = source_root.joinpath(*module_name.split(".")).with_suffix(".py")
|
||||
package_file = source_root.joinpath(*module_name.split(".")) / "__init__.py"
|
||||
if not module_file.is_file() and not package_file.is_file():
|
||||
raise HostError("extension entrypoint module is unavailable")
|
||||
|
||||
for loaded_name in tuple(sys.modules):
|
||||
if loaded_name == module_name or loaded_name.startswith(module_name + "."):
|
||||
del sys.modules[loaded_name]
|
||||
original_path = list(sys.path)
|
||||
original_dont_write_bytecode = sys.dont_write_bytecode
|
||||
sys.path.insert(0, str(source_root))
|
||||
sys.dont_write_bytecode = True
|
||||
sink = _BoundedTextSink(sys.stderr)
|
||||
try:
|
||||
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
||||
module = importlib.import_module(module_name)
|
||||
entrypoint = getattr(module, attribute, None)
|
||||
if not callable(entrypoint):
|
||||
raise HostError("extension entrypoint is not callable")
|
||||
context = _HostContext(manifest, service_broker)
|
||||
entrypoint(context)
|
||||
context.commands.freeze()
|
||||
except HostError:
|
||||
raise
|
||||
except BaseException as error:
|
||||
raise ExtensionExecutionError("extension setup failed") from error
|
||||
finally:
|
||||
sys.path[:] = original_path
|
||||
sys.dont_write_bytecode = original_dont_write_bytecode
|
||||
return cls(release_dir, manifest, context)
|
||||
|
||||
def invoke_command(
|
||||
self,
|
||||
command: str,
|
||||
event: Event,
|
||||
arguments: str,
|
||||
) -> ExtensionResult:
|
||||
if len(arguments) > MAX_ARGUMENT_CHARS:
|
||||
raise HostError("command arguments are too long")
|
||||
handler = self.context.commands.handler(command)
|
||||
self.context.facts.reset()
|
||||
sink = _BoundedTextSink(sys.stderr)
|
||||
try:
|
||||
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
||||
result = handler(event, arguments)
|
||||
except BaseException as error:
|
||||
raise ExtensionExecutionError("extension command failed") from error
|
||||
if not isinstance(result, ExtensionResult):
|
||||
raise ExtensionExecutionError(
|
||||
"extension command returned an invalid result"
|
||||
)
|
||||
collected = self.context.facts.values()
|
||||
if not collected:
|
||||
return result
|
||||
try:
|
||||
return ExtensionResult(
|
||||
reply=result.reply,
|
||||
facts=result.facts + collected,
|
||||
metadata=result.metadata,
|
||||
)
|
||||
except ContractViolation as error:
|
||||
raise ExtensionExecutionError(
|
||||
"extension command returned an invalid result"
|
||||
) from error
|
||||
|
||||
|
||||
def process_request(
|
||||
runtime: ExtensionRuntime,
|
||||
request: Mapping[str, Any],
|
||||
) -> dict[str, object]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise HostError("request must be an object")
|
||||
operation = request.get("op")
|
||||
if operation == "health":
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": runtime.manifest.api_version,
|
||||
"plugin_id": runtime.manifest.plugin_id,
|
||||
"author": runtime.manifest.author,
|
||||
"description": runtime.manifest.description,
|
||||
"help": list(runtime.manifest.help_entries),
|
||||
"commands": list(runtime.manifest.capabilities.commands),
|
||||
"service_profiles": list(
|
||||
runtime.manifest.capabilities.service_profiles
|
||||
),
|
||||
}
|
||||
if operation != "invoke_command":
|
||||
raise HostError("unsupported extension host operation")
|
||||
command = request.get("command")
|
||||
arguments = request.get("arguments", "")
|
||||
event_value = request.get("event")
|
||||
if not isinstance(command, str) or not command:
|
||||
raise HostError("command is required")
|
||||
if not isinstance(arguments, str):
|
||||
raise HostError("arguments must be a string")
|
||||
if not isinstance(event_value, Mapping):
|
||||
raise HostError("event must be an object")
|
||||
try:
|
||||
event = Event.from_mapping(event_value)
|
||||
except ContractViolation as error:
|
||||
raise HostError("event violates the SDK contract") from error
|
||||
result = runtime.invoke_command(command, event, arguments)
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": runtime.manifest.api_version,
|
||||
"plugin_id": runtime.manifest.plugin_id,
|
||||
"result": result.to_mapping(),
|
||||
}
|
||||
Reference in New Issue
Block a user