release: public alpha 0.6.0 baseline

This commit is contained in:
JWeBot Contributors
2026-10-15 03:58:31 +08:00
commit d4242ae07c
238 changed files with 37724 additions and 0 deletions
@@ -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(),
}