Install and use extensions
For prerequisites and package availability, see Install Apizr.
Install an explicitly trusted wheel into its own Python
virtual environment. Installation does not activate or invoke it, and project
configuration cannot install or load extensions. Historical apizr.pipeline.v1
plugins keep their existing behavior.
Commands
With uv already installed and an expected SHA-256 obtained from a source you trust:
apizr plugins install ./extension.whl --sha256 EXPECTED_SHA256
apizr plugins install https://example.org/example_plugin-1.0.0-py3-none-any.whl \
--sha256 EXPECTED_SHA256
apizr plugins list
apizr plugins list --json
apizr plugins enable example-plugin --version 1.0.0
apizr plugins list --active --json
apizr plugins run example-plugin describe --arguments arguments.json
apizr plugins disable example-plugin
The digest checks integrity of the exact wheel bytes, not trust in its author.
The installer snapshots the local wheel before invoking uv. Both Apizr and uv's
--require-hashes check those bytes; changing the original file afterward cannot
change what is installed. A hash calculated from an untrusted file does not make
that file trustworthy.
--plugins-dir /absolute/user/directory explicitly overrides storage on every
command, particularly for disposable tests and CI. Defaults follow the packaging
ADR: $XDG_DATA_HOME/apizr/plugins (otherwise ~/.local/share/apizr/plugins) on
Linux, and ~/Library/Application Support/apizr/plugins on macOS. The store must
be user-owned, not writable by other users, and outside the core and the current
project (identified by an enclosing .git or pyproject.toml). An arbitrary
unmarked directory cannot be recognized as a project; choose a dedicated store.
Installation uses the running core's already installed interpreter to create a
new environment. --python /absolute/path/to/python explicitly selects another
installed interpreter. The interpreter symlink is not resolved before passing it
to uv, so an existing venv retains its identity. The new environment has no access
to the core's site-packages. Nothing is injected into uv tool, pipx or Homebrew.
The local path stays entirely offline. Neither path enables an index, Python
download, source build or installer fallback. Dependency resolution is limited
to explicitly locked local wheels when both dependency options are supplied. uv receives
an isolated environment and explicit
--offline, --no-config, --no-cache, --no-python-downloads, --no-index,
--no-deps, --no-build, --no-sources and hash checking where applicable.
See the uv command reference for those
backend switches. Parent UV_*, PIP_*, Python settings and credentials are not
forwarded. uv output is discarded; CLI diagnostics contain fixed failure codes.
A missing backend is detected before any filesystem modification.
Without --requirements and --wheelhouse, any Requires-Dist entry (including
conditional or extra dependencies) remains refused. Both paths reject directories,
non-HTTPS URLs, source archives, malformed/incompatible
manifests, startup .pth/sitecustomize.py/usercustomize.py files, and unsafe
archive paths or links. Native compatibility and Requires-Python are checked
by uv; an incompatible wheel is a failed installation, never an available record.
Wheels are limited to 64 MiB, expanded content to 256 MiB / 10,000 archive entries,
and individual metadata files to 64 KiB. The module must be a Python file or a
package with __main__.py in the wheel's site-packages root.
Locked local dependencies
Supply both options together; the plugin may be a local wheel or an HTTPS URL:
apizr plugins install ./plugin-1.0.0-py3-none-any.whl --sha256 EXPECTED_SHA256 \
--requirements requirements.lock --wheelhouse ./wheels
The accepted requirements subset is deliberately small: one name==version
with exactly one --hash=sha256:HEX per distribution, including the plugin and
all transitive dependencies. Blank lines, # comments and backslash line
continuations are supported. Names are normalized; duplicates are rejected.
Extras, markers, wildcard/range pins, multiple hashes, URLs, local paths,
editable installs, file inclusions and every other requirements option are
rejected. Prepare one lock/wheelhouse for the intended Python/platform target;
uv checks dependency constraints, wheel tags and Requires-Python.
example-plugin==1.0.0 --hash=sha256:PLUGIN_SHA256
example-helper==2.0.0 --hash=sha256:HELPER_SHA256
example-leaf==3.0.0 --hash=sha256:LEAF_SHA256
Use real 64-character digests instead of these placeholders. The wheelhouse
must contain exactly one wheel for each locked dependency. Unlocked or ambiguous
dependency wheels are rejected. The plugin's optional wheelhouse copy is ignored:
its explicit source and --sha256 determine the installed bytes. Other files are
ignored and never passed to uv. Dependency wheels do not need an Apizr manifest;
all wheels undergo the same archive and startup-file checks. Direct references
in wheel dependency metadata are also refused.
The lock is limited to 64 KiB and 128 distributions including the plugin.
Wheelhouse enumeration is bounded to 128 entries; selected wheels total at most
256 MiB compressed and 512 MiB expanded, in addition to the per-wheel limits.
Apizr copies validated bytes into a private snapshot. uv resolves only against
that snapshot with --require-hashes, --no-index, --offline, --no-build
and --strict; this path does not use --no-deps. Missing transitives,
conflicting versions and incompatible Python/platform constraints fail before
an installation becomes available. Validation does not import plugin code.
The recorded lock identity is SHA-256 of the original lock bytes (including
comments/whitespace); dependency records contain the exact installed versions
and wheel digests. Changing either the lock or dependencies for an existing
name/version raises installation_conflict. There are no implicit updates.
The existing activation remains unchanged, and a newly installed version starts
inactive. Files/distributions in the core environment remain untouched.
The Python operations install_extension, install_from_source and
install_from_url accept the same requirements=Path(...) and
wheelhouse=Path(...) keyword arguments. Their existing return types, errors,
HTTPS limits and interruption behavior are retained.
HTTPS wheels
The expected SHA-256 is mandatory and its format is checked before any network request. HTTPS acquisition downloads into a private temporary directory, checks the digest, and hands those same bytes to the existing local installer. Its manifest, protocol, package metadata and dependency checks still apply, and uv remains offline. An installation stays inactive; existing activation choices are never changed by downloading or installing another version.
Certificate chains and hostnames are verified using Python's default TLS trust
store (including explicitly configured SSL_CERT_FILE / SSL_CERT_DIR). There
is no insecure TLS option. At most five redirects are followed, including across
hosts, and every target must use HTTPS without embedded credentials. HTTP and
other schemes, URL user information, fragments, control characters and malformed
ports are refused. URLs must use ASCII encoding (percent-encoding for paths and
queries, IDNA for international hostnames). Invalid URLs never fall back to local
paths. Query parameters may be used, but diagnostics never echo the URL.
The initial URL must end in a safe wheel filename, such as
example_plugin-1.0.0-py3-none-any.whl; this name is checked against the package
metadata. Redirect filenames and Content-Disposition cannot choose a local
destination. Files are created exclusively in a private directory. Transfers
read at most 64 KiB at a time and enforce the existing 64 MiB wheel limit on
received bytes, even without Content-Length. Compressed HTTP content is refused;
the wheel's own ZIP compression is supported by the local validator.
Defaults are 5 seconds for connection/TLS handshake, 10 seconds per blocking read and 60 seconds for the whole transfer across redirects. A supervised stdlib worker makes the total deadline and cancellation effective even during DNS or slow response headers. On timeout or Ctrl-C, the worker is killed and reaped (up to 2 seconds for cleanup), closing its sockets; the temporary directory is removed before returning. No installation record or activation is written until the existing installer succeeds. As with local installs, abrupt host loss or SIGKILL can leave unregistered temporary files. OS operations stuck in an uninterruptible state remain outside the normal cleanup guarantee.
The client sends no inherited cookies, authentication, proxy credentials or
.netrc data. It ignores proxy environment variables and does not offer private
repository authentication. This is explicit user-directed network access, not
an SSRF boundary for untrusted URLs supplied by other users; private HTTPS hosts
are permitted. Trust in the author still requires an independent decision.
The Python API install_from_source(source, sha256, *, directory=None, python=None)
dispatches explicit paths or URLs. install_from_url(url, sha256, *, directory=None,
python=None, limits=DownloadLimits(), cancel=None, ca_file=None) exposes typed
timeouts, a threading.Event for transfer cancellation, and an optional explicitly
trusted CA file. It returns the existing Installation. Cancellation covers
acquisition; once installation begins, existing installer interruption rules
apply. DownloadCancelled and fixed PluginError codes such as
download_timeout, download_tls_failed, download_incomplete, wheel_too_large
or hash_mismatch contain no server messages or URL parameters. No download is
performed by list, enable, disable, run or uninstall, and apizr.toml is not consulted.
The transfer uses the standard library's
HTTPSConnection
and create_default_context;
no new runtime dependency is required.
Declarative wheel manifest
A wheel contains exactly one root apizr-extension.json:
{
"schema": "apizr.extension-manifest/v1",
"name": "apizr-extension-probe",
"version": "0.0.0",
"module": "apizr_extension_probe.runtime",
"protocol": "apizr.extension/v1"
}
No plugin import is needed to read it. Fields and types are strict; unknown fields,
duplicate JSON keys, unknown schema/protocol versions and invalid module names
are rejected. name is the canonical distribution name (lowercase, runs of
hyphens/underscores/dots normalized to a hyphen). Name/version must match wheel
filename and distribution metadata. The manifest version is the package version;
the schema and invocation protocol have independent versions.
For Hatch, the demonstration includes the file with:
[tool.hatch.build.targets.wheel.force-include]
"apizr-extension.json" = "apizr-extension.json"
Python operations and inventory
apizr.local_plugins.install_extension(wheel: Path, sha256: str, *,
directory: Path | None = None, python: Path | None = None,
requirements: Path | None = None, wheelhouse: Path | None = None) -> Installation
performs the operation. list_extensions(*, directory: Path | None = None) ->
Inventory reads local metadata only, without invoking uv or any interpreter.
Pass active=True to filter it to the explicitly selected versions. These
functions do not print or exit the host. PluginError exposes a fixed message
such as hash_mismatch, invalid_manifest, dependencies_not_supported,
installation_conflict, uv_not_found, uv_install_failed or uv_timeout.
The CLI returns 2 on failure, 130 on keyboard interruption, and 0 on success.
list --json emits apizr.installed-extensions/v1 with an installations array.
Records contain manifest fields plus sha256, environment_id and the absolute
python path. New records also include lock_sha256 (null for single-wheel
installs) and dependencies (name, version and wheel SHA-256). Existing records
without those fields remain readable. Listing is an inventory, not an interpreter health check: external
removal/upgrades of Python can invalidate an existing environment. No automatic
repair or download occurs. A corrupt local inventory fails closed.
Explicit activation and invocation
Every installation starts inactive. enable NAME --version VERSION selects an
exact installed version without running it. Names use the same normalization as
the manifest. Repeating the same activation is idempotent; installing another
version leaves the selection unchanged. Switching requires another explicit
enable with the desired version. disable NAME is also idempotent.
activations.json is separate from installations.json. Its
apizr.active-extensions/v1 document stores the selected installation records,
including name, version, SHA-256, environment ID, module, protocol and interpreter.
Updates use the existing store lock and atomic replacement. Before admitting a
call, Apizr checks that the selected record still matches the inventory and its
interpreter exists in the expected environment. Missing, inactive, inconsistent
or corrupt records fail closed, without repair. list --active --json validates
the bindings and emits the unchanged inventory format; it does not run plugins,
check interpreter health or contact the network.
The lock is released once an invocation is admitted. Disabling blocks later admissions; an already admitted invocation may start or finish afterward. It does not hold the lock for the plugin's execution or cancel an existing call.
run accepts a regular UTF-8 JSON file containing an object. Reads are bounded;
special files, duplicate keys, non-finite numbers and malformed JSON are refused.
It uses the recorded interpreter/module and the existing
invocation runtime, passing an empty environment.
Parent secrets are not forwarded. No plugin discovery, installation, download
or automatic apizr.toml loading takes place.
The CLI uses runtime defaults: a 10-second invocation deadline, 1 MiB request and stdout limits, 64 KiB stderr limit and a 1-second cleanup deadline. The JSON file itself is limited to 1 MiB; the complete encoded request must also fit the request limit. These runtime deadlines begin after admission; acquiring the store lock has its own 30-second limit. A successful structured protocol response goes to stdout. Failures emit only fixed codes on stderr and return 2; Ctrl-C cleans up through the runtime and returns 130. Plugin diagnostics are not echoed.
The Python API offers enable_extension(name, version, *, directory=None),
disable_extension(name, *, directory=None) and
run_extension(name, operation, arguments, *, directory=None, limits=Limits(),
cancel=None). The latter returns the existing Response, raises the existing
runtime errors, and accepts a threading.Event for cancellation. read_arguments
provides the same bounded JSON reader independently of the CLI. No operation
prints or exits Python. For example, after explicitly enabling the demo:
from pathlib import Path
from apizr.extension_runtime import Limits
from apizr.local_plugins import run_extension
response = run_extension(
"apizr-extension-probe",
"describe",
{"source_digest": "explicit-active-example"},
directory=Path("/temporary/apizr-plugins"),
limits=Limits(wall_time_ms=3000),
)
Activation is permission to use trusted code, not a sandbox. It does not
authenticate the author or continuously verify installed file contents. The
lower-level invoke_extension API remains an explicit caller-managed operation;
it does not consult the activation store.
Atomic visibility and interruption
A POSIX file lock serializes writers (30-second acquisition limit). Each install
gets a unique final environments/ID/venv path; the venv is never moved. uv commands
have a 120-second deadline each, with process-group cleanup and a bounded child
wait. A successful install is committed by an atomic replacement of
installations.json; readers see either complete inventory, without taking a
write lock. At most 1,000 records / 1 MiB of inventory are supported.
The same canonical name, version, digest and lock identity is idempotent. Different bytes under an existing name/version are refused. A different version gets another independent environment, without activation, switching or an implicit upgrade.
Ordinary failures and Ctrl-C remove unpublished environments and preserve earlier
records. SIGKILL, machine loss or filesystem errors may leave unregistered files;
these do not appear in list. Cleanup does not delete a fully installed environment
whose record became visible just before an interruption. There is no garbage
collection; use the explicit version removal described below. Atomic visibility is not a
promise of durability across power loss or a broken filesystem.
This is not a sandbox: approved extensions, uv and Python have the user's permissions. Storage is a local user trust boundary, not protection from a hostile process running as the same user. Detached subprocesses and uninterruptible OS operations have the limits documented for extension invocation. Only POSIX Linux/macOS are supported.
Installed demonstration
On disposable runners, the existing extension-packaging workflow builds real wheels, installs the minimal core separately (uv tool or Homebrew), then uses that installed core outside the checkout to run:
apizr plugins install ./apizr_extension_probe-0.0.0-py3-none-any.whl \
--sha256 EXPECTED_SHA256 --plugins-dir /temporary/apizr-plugins
apizr plugins list --plugins-dir /temporary/apizr-plugins
apizr plugins list --json --plugins-dir /temporary/apizr-plugins
printf '%s\n' '{"source_digest":"explicit-active-example"}' > arguments.json
# Refused: the installation is inactive.
apizr plugins run apizr-extension-probe describe --arguments arguments.json \
--plugins-dir /temporary/apizr-plugins
apizr plugins enable apizr-extension-probe --version 0.0.0 \
--plugins-dir /temporary/apizr-plugins
apizr plugins list --active --json --plugins-dir /temporary/apizr-plugins
apizr plugins run apizr-extension-probe describe --arguments arguments.json \
--plugins-dir /temporary/apizr-plugins
apizr plugins disable apizr-extension-probe --plugins-dir /temporary/apizr-plugins
# Refused again after disabling.
apizr plugins run apizr-extension-probe describe --arguments arguments.json \
--plugins-dir /temporary/apizr-plugins
The fixture's digest is calculated by the test after building its reviewed local
source. CI executes the CLI as CORE_PYTHON -I -B -m apizr.cli to exclude checkout
imports and bytecode writes in the measured core. Before/after inventories include
core file bytes, permissions, symlinks, additions/deletions and installed distributions.
The proof retains the original packaging/runtime checks, exercises idempotence,
and verifies both refusals and the successful activated call. While enabled, it
also copies and runs
invoke_active.py
outside the checkout, using the Python API example above.
The same workflow also starts a disposable localhost HTTPS server, generates its
test certificate with OpenSSL, and explicitly trusts that certificate via
SSL_CERT_FILE for one installed-core command. It downloads and installs the demo
into a fresh store, verifies that invocation is refused while inactive, then
enables and invokes it. The server is stopped before activation and invocation.
The final core snapshot covers both the local and HTTPS paths. No test disables
TLS verification, and no workstation Homebrew installation is changed.
For a wheel at a trusted HTTPS endpoint, the equivalent user commands are:
apizr plugins install https://example.org/apizr_extension_probe-0.0.0-py3-none-any.whl \
--sha256 EXPECTED_SHA256 --plugins-dir /temporary/apizr-plugins
apizr plugins list --active --json --plugins-dir /temporary/apizr-plugins
# The new installation is inactive; enable it explicitly before run.
apizr plugins enable apizr-extension-probe --version 0.0.0 \
--plugins-dir /temporary/apizr-plugins
apizr plugins run apizr-extension-probe describe --arguments arguments.json \
--plugins-dir /temporary/apizr-plugins
examples/extension-probe/invoke_installed.py
is copied outside the checkout and run by that installed core:
CORE_PYTHON -I -B /temporary/invoke_installed.py /temporary/apizr-plugins
It explicitly selects the demonstration record and calls invoke_extension with
that record's interpreter and module. Listing itself does not invoke anything.
Run installation proofs only in disposable environments; do not update workstation
Homebrew dependencies to reproduce the CI job. No publication is performed.
Example with transitive dependencies
The reviewed local fixture in examples/extension-locked contains three packages:
apizr-locked-probe imports apizr-locked-helper, which imports
apizr-locked-leaf. Neither dependency has a plugin manifest.
From the checkout, prepare these trusted example wheels and their lock:
uv run --locked python scripts/prepare_locked_extension.py \
--wheelhouse /tmp/apizr-locked-example/wheels
This explicit preparation step builds the three example projects and may fetch their build backend; plugin installation itself never builds or downloads dependencies. The script prints the plugin wheel, SHA-256 and lock paths. For other plugins, obtain trusted compatible wheels beforehand and write a lock containing every exact version and its independently verified SHA-256.
Using a separately installed Apizr wheel, run from outside the checkout:
apizr plugins install /tmp/apizr-locked-example/wheels/apizr_locked_probe-1.0.0-py3-none-any.whl \
--sha256 PRINTED_SHA256 --requirements /tmp/apizr-locked-example/requirements.lock \
--wheelhouse /tmp/apizr-locked-example/wheels --plugins-dir /tmp/apizr-locked-store
apizr plugins enable apizr-locked-probe --version 1.0.0 --plugins-dir /tmp/apizr-locked-store
printf '%s\n' '{}' > /tmp/apizr-locked-example/arguments.json
apizr plugins run apizr-locked-probe answer --arguments /tmp/apizr-locked-example/arguments.json \
--plugins-dir /tmp/apizr-locked-store
The response contains "result": {"answer": 42}. The existing installed-wheel
proof executes this sequence on disposable uv/Homebrew runners and records
locked_dependencies_verified, the dependency inventory and invocation response.
Its before/after core file and distribution comparisons cover this sequence too:
uv run --locked python scripts/smoke_extension_packaging.py \
--work-dir /tmp/apizr-locked-proof
Portable project locks and local preferences
Use project declarations, explicit user preferences and portable artifact locks to manage reproducible installations. Use plugins lock create/check to validate artifacts and plugins sync --project apizr.toml --lock apizr.plugins.lock.json --wheelhouse ./wheels --dry-run --json to preview additive installation. Remove --dry-run to install the missing versions explicitly. --user-config selects local store preferences on all plugin commands and mcp serve; explicit --plugins-dir takes precedence. Neither project declarations nor locks activate anything. Synchronization keeps existing versions and activations; uninstall is a separate explicit operation. See the linked page for partial results, resuming, cancellation and installed-wheel proofs.
Controlled locked updates
plugins update prepares one explicitly
locked version in a separate environment. It never modifies an existing
environment in place or removes an older version. There is no automatic version
selection, catalogue, dependency download or source build. A complete local
wheelhouse and an already installed Python are required; uv is needed only when
the target environment is missing.
apizr plugins update NAME --from-version INSTALLED_VERSION \
--project /path/next/apizr.toml --lock /path/next/apizr.plugins.lock.json \
--wheelhouse /path/wheels --dry-run --json
Remove --dry-run to prepare the target. Add --activate only when you also want
to select it. --plugins-dir overrides the explicitly selected --user-config
store, then the platform default. The project cannot choose the store or request
activation. There is no implicit user-file discovery. --timeout-ms retains the
sync default of 120000 ms and inclusive 1–600000 ms bounds; Python callers can
supply a cancellation Event.
The whole project/lock and artifact set must be coherent, but only NAME and its closure are installed. The source version must already be registered. The target comes exclusively from the project and lock. No version ordering is inferred, so an explicitly requested older version is allowed. A target equal to the source or already installed with the same complete identity is reused. Different hashes, manifest module/protocol or requirements/dependency identity under the same name/version are refused without replacement. Requirements retain their original byte identity; no normalization or rewriting takes place.
Dry-run creates no absent store, changes no persistent state and starts neither
uv nor a plugin interpreter. interpreter_verified: false records the remaining
apply-time check. Apply retains the validated private snapshot used by sync,
installs through its offline backend and probes the registered target using the
same fixed, bounded -I -S -B standard-library program. It never imports the
plugin or project and does not run a business-operation health check. Structural
viability and Python target agreement do not prove functional compatibility with
every project or cryptographic integrity of every virtualenv file.
Conditional activation and concurrent operators
Without --activate, even an inactive plugin or a third active version remains
exactly as selected. With --activate, the starting activation must match the
complete source binding or the already matching target. Inactive plugins must
first be selected with enable; a third selection is refused before installation.
Update captures the source and activation under the store lock, releases it while preparing/probing the target, then reacquires it. It compares the complete source and target records (including environment IDs) and the expected activation before atomically writing the target selection. Other plugin activations are preserved. A concurrent disable or different selection causes a refusal, not an override. Identical concurrent requests can converge when the same verified target has already been selected. Competing requests for different targets cannot both use the same original active source. These are current-record comparisons, not a history of intervening operator actions.
Installation and activation are separate steps, not a global transaction. An installer failure preserves A and its activation. If B installs successfully but the activation comparison fails, B remains installed and the current activation is retained. Correct the explicit precondition if appropriate and rerun the same command: the verified B is reused, including when uv is absent. Never rebuild or replace artifacts under the same locked identity to obtain a successful retry.
An already admitted invocation or MCP session can finish on A after B is selected;
A and its dependencies are retained. New admissions use the activation they
observe. Explicit rollback is plugins enable NAME --version OLD_VERSION; update
never initiates rollback based on executing a plugin operation.
Result and failure contract
The typed Python operation is independent of the CLI:
from pathlib import Path
from threading import Event
from apizr.plugin_update import update_plugin
cancel = Event()
result = update_plugin(
"apizr-update-probe",
"1.0.0",
Path("2.0.0/apizr.toml"),
Path("2.0.0/apizr.plugins.lock.json"),
Path("wheels"),
directory=Path("plugins"),
activate=True,
timeout_ms=120000,
cancel=cancel,
)
assert result.exit_code == 0, result.diagnostics
--json keeps stdout exclusively apizr.plugin-update/v1, even for recoverable
partial failure. The document includes:
mode,state,exit_code, plugin/source version, full capturedsourceinstallation, lockedtarget, observedtarget_installation,python_targetandlock_sha256. Installation records reuse the existing contract, including the local interpreter path and environment identity.installation:not_attempted,planned,reused,installed,failedorunconfirmed;interpreter_verifiedreports a completed target probe.activation_requested, distinctactivationoutcome, and the observedactive_before/active_afterrecords. A null activation withbefore_knownorafter_knowntrue means inactive; false means it could not be established.- Stable redacted
diagnostics; never raw uv output or the parent environment.
activation distinguishes not_requested, not_attempted, planned, changed,
unchanged, refused, observed_target and unconfirmed. After interruption,
observed_target only reports the selection seen during reconciliation; it does
not convert that interrupted command into success or claim which writer selected
it. On success, changed records this call's atomic write; the separate final
observation must still agree with the requested target. No selection is reserved
against future operator actions after the command returns.
| State | Exit | Meaning |
|---|---|---|
planned |
0 | Simulation without blocking divergence; no mutation |
complete |
0 | Target verified and, if requested, activation successful |
refused |
1 or 2 | Before mutation: divergence/precondition (1) or operational/input error (2) |
partial |
2 | Installation/activation work started but the full request failed |
interrupted |
130 | Cancellation; observed published progress retained |
unconfirmed |
2 or 130 | State/cleanup could not be confirmed; 130 retains user interruption |
The deadline spans preparation, bounded store waits, offline uv and the target probe. Existing per-command and size limits from sync apply. Cleanup has its separate two-second grace; reconciliation rereads published state under the lock with a further two-second budget, including around atomic activation writes. No successful environment is deleted on failure. Cancellation stops supervised work, reaps the direct process and releases the lock. SIGKILL, loss of the machine, detached descendants and noninterruptible OS operations retain the documented limits: a final report and immediate cleanup cannot be guaranteed in those cases.
Complete installed-wheel example
From the development checkout, use a fresh work directory outside it. Preparation may download the core/build dependencies; update itself is strictly offline. The trusted fixture uses the existing plugin → helper → leaf example, with versions A/B that return 42/73 through different transitive dependencies.
work=$(mktemp -d)
uv build --wheel --out-dir "$work/dist"
uv venv --no-python-downloads --python python3 "$work/core"
uv pip install --python "$work/core/bin/python" "$work"/dist/outerspace_apizr-*.whl
python3 scripts/prepare_update_extensions.py --work-dir "$work/update"
core_python="$work/core/bin/python"
cd "$work/update"
"$core_python" -I -B -m apizr.cli plugins lock create --project 1.0.0/apizr.toml --wheelhouse wheels --output 1.0.0/apizr.plugins.lock.json --json
"$core_python" -I -B -m apizr.cli plugins lock create --project 2.0.0/apizr.toml --wheelhouse wheels --output 2.0.0/apizr.plugins.lock.json --json
"$core_python" -I -B -m apizr.cli plugins sync --project 1.0.0/apizr.toml --lock 1.0.0/apizr.plugins.lock.json --wheelhouse wheels --user-config user.toml --json
"$core_python" -I -B -m apizr.cli plugins enable apizr-update-probe --version 1.0.0 --user-config user.toml
"$core_python" -I -B -m apizr.cli plugins run apizr-update-probe answer --arguments arguments.json --user-config user.toml
"$core_python" -I -B -m apizr.cli plugins update apizr-update-probe --from-version 1.0.0 --project 2.0.0/apizr.toml --lock 2.0.0/apizr.plugins.lock.json --wheelhouse wheels --user-config user.toml --dry-run --activate --json
"$core_python" -I -B -m apizr.cli plugins update apizr-update-probe --from-version 1.0.0 --project 2.0.0/apizr.toml --lock 2.0.0/apizr.plugins.lock.json --wheelhouse wheels --user-config user.toml --json
The first invocation returns 42. Preparing B still leaves A active. The two locks
have different output paths: lock create refuses to overwrite a different file.
Do not rebuild wheels or edit original requirements after locking.
"$core_python" -I -B -m apizr.cli plugins update apizr-update-probe --from-version 1.0.0 --project 2.0.0/apizr.toml --lock 2.0.0/apizr.plugins.lock.json --wheelhouse wheels --user-config user.toml --activate --json
"$core_python" -I -B -m apizr.cli plugins run apizr-update-probe answer --arguments arguments.json --user-config user.toml
B now returns 73. Repeat the update command: both installation and activation are reused. To return explicitly to A without reinstallation:
"$core_python" -I -B -m apizr.cli plugins enable apizr-update-probe --version 1.0.0 --user-config user.toml
"$core_python" -I -B -m apizr.cli plugins run apizr-update-probe answer --arguments arguments.json --user-config user.toml
The result is again 42. After an activation conflict, inspect plugins list
--active --json --user-config user.toml and respect the current operator choice.
If you intentionally restore A with that explicit enable command, rerunning
update reuses B and attempts the conditional transition again.
The existing packaging workflow runs these operations from installed wheels on
Linux 3.11–3.14, macOS 3.11/3.14 and disposable Homebrew. It retains
update-evidence.json, backend-network.jsonl, update-summary.json and
core-evidence.json: dry-run equality, real uv failure/retry, prepare-only behavior,
transition/repetition with uv absent, explicit rollback, original-environment and
core file/distribution equality. OS network isolation covers Python and uv children.
Concurrent activation races, interruption reconciliation and in-flight calls are
also covered by explicitly synchronized tests. No Homebrew changes are needed on
the developer machine. Catalog preparation remains explicit; there is no automatic version selection or update. --activate is not a general permission engine.
Remove one unused version
disable blocks future admissions; it does not stop an already admitted call or
MCP session. uninstall removes one exact version only after it is inactive and
no protected user remains. It never disables a plugin, kills its users, launches
an interpreter, contacts the network or invokes uv. There is no --force option.
For a plugin whose versions 1.0.0 and 2.0.0 are already installed, first select
and test the version you intend to retain, then preview and remove the old one:
apizr plugins enable my-plugin --version 2.0.0 --plugins-dir /absolute/plugin-store
apizr plugins uninstall my-plugin --version 1.0.0 --dry-run --json --plugins-dir /absolute/plugin-store
apizr plugins uninstall my-plugin --version 1.0.0 --json --plugins-dir /absolute/plugin-store
apizr plugins uninstall my-plugin --version 1.0.0 --json --plugins-dir /absolute/plugin-store
Replace my-plugin and the store with your installed plugin's actual name and
location. The preview reports planned; successful removal reports complete;
the repeated removal reports absent. To remove the active version instead,
explicitly run plugins disable my-plugin, close its sessions and wait for its
calls to finish. An ongoing use reports busy without terminating anything.
--user-config remains explicit, and --plugins-dir overrides its store setting.
Usage protection and migration
Apizr coordinates admission and removal under the store lock. A separate shared
lock belongs to each installed generation; the global store lock is released
before an invocation or session. The usage descriptor survives the MCP launcher's
execve and is passed to extension processes and interpreter probes. Removal
checks an exclusive usage lock without waiting for users to exit. Lock files
remain as small, stable metadata after removal to prevent lock-identity races.
This covers calls and sessions admitted by this implementation. Before first using uninstall after upgrading, stop sessions opened by older Apizr launchers and processes launched directly with a plugin interpreter. Restart managed sessions with the upgraded launcher. Apizr cannot universally detect these unprotected processes. Trusted plugins must not deliberately close the inherited usage descriptor. These locks coordinate cooperating local processes; they are not a sandbox or a defense against a user who can rewrite the store itself.
A supervisor cleanup failure leaves a conservative plugin_usage_unconfirmed
guard. Removal is refused until the actual process/store state has been reviewed;
there is no automatic repair or assumption that the process has stopped.
Interruption and exact cleanup
Removal writes a persistent cleanup intent containing the full installation record and the environment directory identity. It then retires the inventory record and erases that generation through directory descriptors. Internal symlinks are unlinked, never followed; the shared Python used by a virtualenv is not deleted. A redirected environment or inconsistent record is refused.
Inventory retirement and file deletion are separate steps, not an atomic
transaction. A pending generation cannot be enabled, invoked or reused by
install/sync/update, including after interruption between those steps. Rerun the
same uninstall NAME --version VERSION to resume. It refuses a changed generation
rather than deleting a new installation with the same name/version. Other plugins,
active versions, project declarations and locks are untouched. A later sync
can reinstall a removed version that remains declared in the project lock.
--timeout-ms bounds cooperative lock waiting and traversal (default 30000,
range 1–600000). One attempt traverses at most 100000 entries and 64 directory
levels; a limit leaves cleanup pending. A failure may use up to two additional
seconds to observe committed state. An OS filesystem call itself may block;
these are not hard real-time or filesystem quota guarantees.
--json returns apizr.plugin-uninstall/v1: mode, state, exit_code,
plugin, version, the exact target, inventory_removed,
environment_removed, cleanup_pending, effects and fixed-code diagnostics.
Preview effects describe intended changes; apply effects describe observed ones.
| State | Meaning | Exit |
|---|---|---|
planned |
Preview allowed; no removal performed | 0 |
complete |
Record retired, environment removed, intent cleared | 0 |
absent |
No matching installation or pending cleanup | 0 |
active / busy |
Explicit disable or end of current use required | 1 |
refused |
Invalid input, identity/state mismatch or unavailable storage | 2 |
incomplete |
Cleanup did not finish; inspect effects and retry | 2 |
interrupted |
Cancellation; inspect persisted progress | 130 |
unconfirmed |
Process cleanup or final store state cannot be established | 2, or 130 after cancellation |
The typed API performs the same operation without printing or exiting:
from pathlib import Path
from threading import Event
from apizr.local_plugins import uninstall_extension
cancel = Event() # Another thread can set this to cancel cooperatively.
result = uninstall_extension(
"my-plugin",
"1.0.0",
directory=Path("/absolute/plugin-store"),
dry_run=True,
timeout_ms=30000,
cancel=cancel,
)
print(result.model_dump_json())
The installed-wheel update proof above also exercises A → update to B → select B → remove A → invoke B → repeat removal, with OS network denial and before/after core inventories. The installed MCP proof uses a real SDK session: disable, refuse removal while connected, complete another call, close, then remove. Homebrew installation qualification runs only on disposable CI runners.
Prepare from a plugin catalog
Use catalog profiles to inspect official development artifacts and export declarations and locks before explicit sync and activation. Plugin authors can follow the minimal extension contract.