How to Use the Plugin Architecture for Extensible Runtime in apple/container
The apple/container repository provides a plugin system that discovers extensions placed in the user-plugins directory, automatically registers them with launchd, and exposes their capabilities via XPC services without requiring daemon restarts.
This guide explains how to leverage the plugin architecture for extensible runtime in apple/container to add custom runtime, network, core, or auxiliary services. The system allows you to drop bundles into a designated directory and have them automatically loaded by the daemon, making it ideal for extending container functionality without recompiling the core engine.
Core Concepts of the Plugin System
The plugin implementation is contained within the ContainerPlugin module and consists of five primary abstractions that work together to enable dynamic extension.
Plugin
The Plugin struct in Sources/ContainerPlugin/Plugin.swift is the value type representing a discovered extension. It holds the binary URL, an optional PluginConfig instance, and resource paths. This type provides helper methods to compute launchd labels, generate Mach-service names, and launch the binary process.
PluginConfig
Defined in Sources/ContainerPlugin/PluginConfig.swift, this Codable struct mirrors the config.toml or config.json file found in each plugin directory. It captures metadata such as the abstract description and author, plus a critical ServicesConfig block that declares which service types the plugin publishes (runtime, network, core, or auxiliary).
PluginFactory
The PluginFactory protocol in Sources/ContainerPlugin/PluginFactory.swift abstracts the discovery logic for different bundle formats. The repository ships with two concrete implementations:
- DefaultPluginFactory: Expects a plain folder containing a
bin/<name>executable. - AppBundlePluginFactory: Handles macOS-style
.appbundles.
PluginLoader
Located in Sources/ContainerPlugin/PluginLoader.swift, this orchestrator handles the full lifecycle. It scans directories via findPlugins(), performs lookups via findPlugin(), and manages launchd registration through registerWithLaunchd(). The loader also creates filtered environment variables and generates launchd plists under <app-root>/plugin-state/<name>/service.plist.
PluginsHarness
The PluginsHarness class in Sources/Services/ContainerAPIService/Server/Plugin/PluginsHarness.swift serves as the runtime holder used by the API server to maintain a collection of active plugins and route incoming requests to the appropriate extension.
Plugin Discovery Mechanism
When the daemon initializes, the PluginLoader executes a multi-stage discovery process:
- Directory scanning:
PluginLoader.findPlugins()walks every path supplied inpluginDirectories, defaulting to<install-root>/libexec/container-plugins. - Factory resolution: For each subdirectory, the loader attempts instantiation through registered factories, trying
DefaultPluginFactoryfirst, thenAppBundlePluginFactory. - Validation: The first factory that successfully produces a
Pluginwins; if none succeed, the directory is skipped and a warning is logged. - Deduplication: Shadowed plugins (same name appearing earlier in the search order) are automatically skipped to prevent conflicts.
Launchd Registration and XPC Services
When a plugin advertises services via servicesConfig, the loader invokes registerWithLaunchd to create a launchd job:
- Label: Automatically generated as
com.apple.container.<name>(or…/<instanceId>for multi-instance plugins). - Arguments: Includes the binary path, optional
--resources <path>, optional--debugflag, and anyservicesConfig.defaultArguments. - Mach services: Generated by
Plugin.getMachServices, producing entries likecom.apple.container.runtime.<name>for runtime plugins.
The generated plist is written to <app-root>/plugin-state/<name>/service.plist and handed to ServiceManager.register, which communicates directly with launchd to establish the XPC endpoint.
Implementing a Custom Runtime Plugin
To extend the container runtime with a custom plugin, follow this directory structure and configuration:
Filesystem Layout
Place your plugin under the user-plugins directory:
/usr/local/libexec/container-plugins/my-runtime/
├── bin/
│ └── my-runtime # executable binary
└── config.toml
Configuration File
Create a config.toml that declares the runtime service:
abstract = "My custom runtime"
author = "Acme Corp"
[servicesConfig]
loadAtBoot = true
runAtLoad = true
defaultArguments = []
[[servicesConfig.services]]
type = "runtime"
description = "XPC API for a single container"
When the daemon restarts, it discovers the plugin, creates the launchd job, and exposes the XPC API at com.apple.container.runtime.my-runtime.
Loading Plugins Programmatically
You can interact with the plugin system directly from Swift code to manually load and register extensions:
import ContainerPlugin
import Logging
let logger = Logger(label: "example")
let loader = try PluginLoader(
appRoot: URL(fileURLWithPath: "/Applications/Container"),
installRoot: URL(fileURLWithPath: "/usr/local"),
logRoot: nil,
pluginDirectories: [PluginLoader.userPluginsDir(installRoot: URL(fileURLWithPath: "/usr/local"))],
pluginFactories: [DefaultPluginFactory(logger: logger), AppBundlePluginFactory(logger: logger)],
log: logger)
let plugins = loader.findPlugins() // Discovers all valid plugins
let runtime = plugins.first { $0.hasType(.runtime) }
if let runtime = runtime {
try loader.registerWithLaunchd(plugin: runtime) // Creates launchd job
}
To connect to the plugin's XPC service from a client:
import XPC
import Foundation
let serviceName = "com.apple.container.runtime.my-runtime"
let connection = xpc_connection_create_mach_service(serviceName, nil, UInt32(XPC_CONNECTION_MACH_SERVICE_PRIVILEGED))
xpc_connection_set_event_handler(connection) { event in
// Handle reply and errors
}
xpc_connection_resume(connection)
let request = xpc_dictionary_create(nil, nil, 0)
xpc_dictionary_set_uint64(request, "operation", 1)
xpc_connection_send_message_with_reply(connection, request, DispatchQueue.main) { reply in
// Process reply
}
Advanced: Custom Plugin Factories
For non-standard bundle layouts, implement the PluginFactory protocol:
struct MySpecialFactory: PluginFactory {
func create(installURL: URL) throws -> Plugin? {
// Custom discovery logic for installURL
return nil
}
func create(parentURL: URL, name: String) throws -> Plugin? {
// Alternative creation path
return nil
}
}
Register your custom factory when constructing the PluginLoader:
let loader = try PluginLoader(
appRoot: appRoot,
installRoot: installRoot,
logRoot: nil,
pluginDirectories: pluginDirs,
pluginFactories: [
DefaultPluginFactory(logger: logger),
AppBundlePluginFactory(logger: logger),
MySpecialFactory()
],
log: logger)
Summary
- Plugin discovery is handled by
PluginLoader.findPlugins()inSources/ContainerPlugin/PluginLoader.swift, which scans directories and delegates to registered factories. - Configuration uses the
PluginConfigstruct to parseconfig.tomlfiles, supporting runtime, network, core, and auxiliary service types. - Registration occurs automatically via
registerWithLaunchd(), which generates launchd plists and Mach service names based on the plugin configuration. - Extensibility requires no daemon recompilation; simply place a binary and config file in the user-plugins directory and restart the daemon.
- Customization is possible by implementing the
PluginFactoryprotocol to support alternative bundle formats beyond the default directory or.appstructures.
Frequently Asked Questions
Where does the daemon look for plugins?
By default, the daemon scans <install-root>/libexec/container-plugins (the userPluginsDir). You can specify additional paths by passing custom URLs to the PluginLoader initializer. The PluginLoader class searches these directories in order, with earlier paths taking precedence in cases of name collisions.
What service types can a plugin declare?
According to the PluginConfig implementation in Sources/ContainerPlugin/PluginConfig.swift, a plugin can declare services of type runtime, network, core, or auxiliary. Each type determines the Mach service name prefix and how the PluginsHarness routes requests to the plugin.
Do I need to restart the daemon after adding a plugin?
Yes. The PluginLoader performs discovery during initialization. While the system supports dynamic registration with launchd via registerWithLaunchd(), the initial discovery phase that populates the plugin registry requires a daemon restart to recognize new bundles in the filesystem.
How do I debug a plugin that fails to load?
Check the daemon logs for warnings emitted during findPlugins(). Common failures include missing config.toml files, binaries not found at the expected bin/<name> path, or factory validation errors. Enable the --debug argument in your servicesConfig.defaultArguments to receive verbose output from the plugin process once it is registered with launchd.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →