Invoke an installed extension
Available in the development checkout; not yet part of published 0.3.0.
apizr.extension_runtime invokes a trusted, already installed Python module
in a fresh process. It is independent of the CLI, apizr.compiler, project
configuration and historical apizr.pipeline.v1 plugins.
Python call
The caller supplies an absolute interpreter from a separately prepared
environment, an importable module with a __main__ entry point (or runnable
module), an operation, JSON arguments, limits and an explicit environment:
from pathlib import Path
from apizr.extension_runtime import Limits, invoke_extension
response = invoke_extension(
Path("/absolute/extension-env/bin/python"),
"apizr_extension_probe.runtime",
"describe",
{"source_digest": "example-digest"},
limits=Limits(wall_time_ms=3000, max_stdout_bytes=4096, max_stderr_bytes=1024),
environment={},
)
assert response.operation == "describe"
assert response.result == {
"source_digest": "example-digest",
"message": "demo extension reached",
}
No download, installation, uv call, plugin discovery or plugin import in the
core occurs during invocation. The interpreter path is not resolved through its
symlink: a venv interpreter must retain its environment identity. Each call uses
python -I -B -u -m MODULE, without a shell, with closed inherited descriptors,
a new POSIX session/process group and a fresh temporary working directory.
environment is the complete environment passed to the process. Nothing is
copied automatically from the parent's environment, including credentials, PATH,
HOME and Python-specific variables. Callers may explicitly provide variables a
trusted extension needs. Python isolated mode ignores Python environment options
and user-site packages; Python itself may initialize locale defaults.
To cancel a running call, pass cancel=threading.Event() and set that event from
another thread. An event already set refuses the invocation before launch.
The synchronous supervisor checks cancellation at intervals of at most 50 ms
between bounded pipe operations, including after the plugin closes its pipes.
No persistent activation is recorded and apizr.toml cannot load extensions.
Wire protocol: apizr.extension/v1
This is a single-message JSON protocol, not MCP. The core writes one UTF-8 JSON object followed by a newline to stdin, then closes stdin. The plugin writes one UTF-8 JSON response to stdout and exits. Whitespace around that one response is allowed; logs belong on stderr. Duplicate object keys, non-finite numbers, invalid UTF-8, extra JSON documents, unknown fields and incompatible versions are rejected.
A request has exactly these fields:
{
"protocol": "apizr.extension/v1",
"request_id": "0123456789abcdef0123456789abcdef",
"operation": "describe",
"arguments": {"source_digest": "example-digest"}
}
The core creates a fresh random 32-character lowercase hexadecimal request ID. Operations use 1–128 characters, beginning with an ASCII letter and continuing with letters, digits, underscore, dot or hyphen. Arguments are a JSON object.
A successful response has exactly:
{
"protocol": "apizr.extension/v1",
"request_id": "0123456789abcdef0123456789abcdef",
"operation": "describe",
"status": "ok",
"result": {"source_digest": "example-digest", "message": "demo extension reached"}
}
An application failure replaces result with an error object:
{
"protocol": "apizr.extension/v1",
"request_id": "0123456789abcdef0123456789abcdef",
"operation": "describe",
"status": "error",
"error": {"code": "unknown_operation", "message": "Unsupported operation"}
}
Error codes contain 1–128 characters and messages at most 1024. The core requires
both the request ID and operation to match before accepting either response.
The process must exit successfully for a result to be accepted. Application
errors and nonzero exits raise PluginFailed; remote codes/messages and stderr
are not included in host diagnostics. result is finite JSON, with any
operation-specific shape checked by the caller. The demonstration additionally
checks the returned digest against the artifact produced by the core.
Limits and failures
Limits is a strict, immutable model; unknown fields and invalid types/bounds
are refused. Byte limits apply to the complete serialized request or each output
stream, not just the application value.
| Limit | Default | Allowed range |
|---|---|---|
wall_time_ms |
10000 | 1–600000 |
max_request_bytes |
1048576 | 1–67108864 |
max_stdout_bytes |
1048576 | 1–67108864 |
max_stderr_bytes |
65536 | 0–67108864 |
cleanup_time_ms |
1000 | 1–5000 |
Request encoding reuses the existing finite, bounded JSON encoder and rejects oversized arguments before launch. Nonblocking stdin writes prevent a plugin that does not read from blocking the host indefinitely. Stdout and stderr are read concurrently in chunks no larger than the remaining allowance plus one byte. The first excess byte aborts the call. Only bounded stdout is retained; stderr is counted and discarded. Neither output is captured without a limit.
All invocation errors derive from ExtensionError and expose a fixed code:
| Exception | Code / meaning |
|---|---|
InvalidInvocation |
invalid_invocation: invalid arguments, module, environment or tampered limits |
PrerequisiteMissing |
prerequisite_missing: nonabsolute/missing/nonexecutable interpreter, launch unavailable, or unsupported platform |
ProtocolInvalid |
protocol_invalid: malformed, incompatible or mismatched response |
PluginFailed |
plugin_failed: nonzero exit, missing module, application error or pipe failure |
SizeLimitExceeded |
size_limit, with stream equal to request, stdout or stderr |
InvocationTimeout |
timeout: execution deadline exceeded |
InvocationCancelled |
cancelled: explicit cancellation observed |
CleanupFailed |
cleanup_failed: group signalling or direct-child reaping could not be confirmed |
Diagnostics never interpolate arguments, environment values, output, plugin messages or executable paths. Sensitive arguments travel on stdin, not argv. The API neither prints diagnostics nor exits the host process.
Cleanup and trust boundary
On success or failure, the supervisor sends SIGKILL to the owned process group, closes all three streams and reaps the direct child within the cleanup budget. If the leader exits while ordinary descendants retain stdout/stderr, the group is terminated and remaining bounded pipe data is drained. Cleanup failure is reported even when another error first ended the exchange.
This is not a sandbox. Extensions run with the user's permissions and can access files, network and processes available to that user, including the core's files if they deliberately choose to. There is no filesystem, memory, CPU or network isolation. Explicit environment transmission prevents accidental secret inheritance; it does not prevent trusted code from accessing other user resources.
Descendants that detach into another session/group escape group cleanup. If they retain a pipe, the host still stops at the deadline, closes its descriptors and reports timeout, but does not claim to have killed the detached process. Orphaned grandchildren are reaped by the OS, not by this API. Kernel tasks that cannot be interrupted, OS process creation and temporary-directory filesystem cleanup are not hard real-time operations. The execution deadline plus bounded child wait is not an absolute wall-clock guarantee for those OS operations. POSIX Linux and macOS are supported; no weaker Windows fallback is provided.
The existing worker supervisors are unchanged. This supervisor follows their nonblocking-pipe/process-group pattern but separately handles stderr, cancellation and bounded reaping rather than changing existing worker guarantees.
Installed proof
examples/extension-probe/invoke.py
is the short runnable example. On disposable CI runners,
scripts/smoke_extension_packaging.py builds and installs the real core wheel
and the dependency-free demonstration wheel into distinct environments, copies
the example outside the checkout and runs:
/absolute/core-env/bin/python -I -B /temporary/invoke.py /absolute/extension-env/bin/python
The existing uv and Homebrew packaging jobs retain the original prototype proof and additionally exercise the reusable API. Their evidence records the response, incompatible-protocol refusal, installed distributions, and before/after core inventories including bytes, permissions, links and added/deleted files. The core cannot import the extension. Installers run only during this explicit setup on disposable runners, never during invocation; do not update workstation Homebrew dependencies to reproduce the CI job.