exlab_wizard.plugins.host#

Plugin host: spawns plugin worker subprocesses with strict isolation.

Backend Spec §6.3 (subprocess isolation, IPC envelope, resource limits, failure handling, security model) and §4.4.3 (the host’s place in the creation pipeline).

The host is the only piece of the plugin system that runs in the long-lived FastAPI server process. It owns:

  • Worker spawning via asyncio.create_subprocess_exec() (NEVER shell=True).

  • A sanitized environment passed to each worker (only PATH, HOME, LANG, and EXLAB_* allowlist variables are forwarded).

  • POSIX resource limits applied via preexec_fn (RLIMIT_AS, RLIMIT_CPU, RLIMIT_NOFILE).

  • A wall-clock timer per worker (asyncio.wait_for); on expiry the worker is sent SIGTERM, given WORKER_TIMEOUT_GRACE_SECONDS, and then SIGKILL-ed.

  • The IPC envelope: a JSON object on the worker’s stdin and a JSON object back on its stdout. The worker’s stderr is a structured-log side-channel that the host parses line-by-line and forwards into the canonical log chain (Backend Spec §16.8); any non-JSON stderr output is captured verbatim into <central_log_dir>/plugins/<plugin>/<run_id>.stderr.

  • The PluginInputRequired suspend/resume handshake (Backend Spec §6.4): on exit code 2 the host invokes the caller-supplied on_input_required coroutine, awaits the operator’s response, and re-spawns the worker with the new extra_inputs payload.

  • Forbidden-write enforcement (Backend Spec §6.1.5): the host snapshots the destination tree before each plugin runs, and reverts (and marks status="policy_violation") any write that touches README.md, the .exlab-wizard/ subtree, or .exlab-answers.yml.

Functions

applied_entry_as_json(entry)

Return entry as a JSON-friendly dict (deep-copied).

build_test_registry(records)

Return a tiny in-memory registry over records.

Classes

InputRequiredPayload(plugin, fields, reason)

Frame surfaced to the controller when a plugin needs more input.

PluginHost(registry, *[, central_log_dir])

Spawns plugin worker subprocesses with strict isolation.

PluginPassResult([applied, aborted])

Aggregate result returned by PluginHost.run_pass().

PluginRecord(name, version, package_path, ...)

One resolved plugin, ready to be spawned.

PluginRegistryProtocol(*args, **kwargs)

Read-only registry surface the host depends on.

class exlab_wizard.plugins.host.InputRequiredPayload(plugin, fields, reason)[source]#

Bases: object

Frame surfaced to the controller when a plugin needs more input.

Backend Spec §6.4.1. The controller forwards this to the WebSocket client as the input_required event; the operator’s response is handed back to the host via the on_input_required callback’s return value.

Parameters:
fields: list[dict[str, Any]]#
plugin: str#
reason: str#
class exlab_wizard.plugins.host.PluginHost(registry, *, central_log_dir=None)[source]#

Bases: object

Spawns plugin worker subprocesses with strict isolation.

Backend Spec §6.3 (subprocess model) / §4.4.3 (host-in-pipeline).

Parameters:
async run_pass(ctx, file_paths, plugin_order, on_input_required)[source]#

Drive the plugin pass for a single creation session.

Spawns one worker subprocess per plugin in plugin_order, feeding each its full set of matched files. On exit code 2 (PluginInputRequired) the host invokes on_input_required; if the awaitable resolves to a dict, the worker is re-spawned with that dict as extra_inputs; if it resolves to None, the session is aborted.

Parameters:
Return type:

PluginPassResult

class exlab_wizard.plugins.host.PluginPassResult(applied=<factory>, aborted=False)[source]#

Bases: object

Aggregate result returned by PluginHost.run_pass().

Backend Spec §6.2.4. applied is the per-plugin record list that the controller writes into creation.json’s plugins_applied block; aborted is set to True only when the operator cancelled an PluginInputRequired prompt.

Parameters:
aborted: bool = False#
applied: list[dict[str, Any]]#
class exlab_wizard.plugins.host.PluginRecord(name, version, package_path, module_name, timeout_seconds=30, memory_mb=512, supported_extensions=())[source]#

Bases: object

One resolved plugin, ready to be spawned. Backend Spec §6.2.1.

name#

Manifest name field; the plugin’s stable identifier.

version#

Manifest version field; surfaced into creation.json.

package_path#

Filesystem path to the plugin package directory (the directory containing manifest.yml and __init__.py). The host prepends the parent of this path to the worker’s sys.path so import <package_path.name> resolves.

module_name#

Importable module name – typically package_path.name.

timeout_seconds#

Manifest isolation.timeout_seconds (capped against PLUGIN_TIMEOUT_MAX_SECONDS by the registry).

memory_mb#

Manifest isolation.memory_mb (capped against PLUGIN_MEMORY_MAX_MB by the registry).

supported_extensions#

Manifest supported_extensions – the host uses this to filter file lists when the controller does not pre-filter.

Parameters:
memory_mb: int = 512#
module_name: str#
name: str#
package_path: Path#
supported_extensions: tuple[str, ...] = ()#
timeout_seconds: int = 30#
version: str#
class exlab_wizard.plugins.host.PluginRegistryProtocol(*args, **kwargs)[source]#

Bases: Protocol

Read-only registry surface the host depends on. Backend Spec §6.2.

The concrete implementation lives in plugins/registry.py (Agent A). The host only consumes a single method: get_record(name) which returns the resolved PluginRecord for a registered plugin.

get_record(name)[source]#
Parameters:

name (str)

Return type:

PluginRecord | None

exlab_wizard.plugins.host.applied_entry_as_json(entry)[source]#

Return entry as a JSON-friendly dict (deep-copied).

Parameters:

entry (dict[str, Any])

Return type:

dict[str, Any]

exlab_wizard.plugins.host.build_test_registry(records)[source]#

Return a tiny in-memory registry over records. Tests only.

Parameters:

records (Iterable[PluginRecord])

Return type:

PluginRegistryProtocol