Execution Plugins
Execution plugins are the Release Orchestrator extension point for build, scan, deploy, verify, and custom execution steps. They replace host-specific hardcoded deployment capability registration with a single signed and isolated contract built on the existing Stella Ops Plugin SDK.
Status
Accepted by ADR-006 and implemented under docs/implplan/SPRINT_20260525_012_ReleaseOrchestrator_execution_plugin_framework.md.
Runtime bundle composition now follows the production-shaped root devops/plugins/execution/<profile>/<plugin-id>/ mounted read-only to /app/plugins/execution/<profile>/<plugin-id>/. The lab producer devops/scripts/build-lab-plugin-bundles.ps1 defaults to the harness profile, and docker-compose.lab-plugin-bundles.yml now mounts /app/plugins/execution/harness plus the execution trust root under /app/trust-roots/plugins/execution.
Sprint 20260604_007 adds the production-shaped first-party bundle producer devops/build/package-execution-plugins.ps1. The default profile emits build.docker, deploy.compose, deploy.exec, deploy.artifact-extract, and deploy.docker-plugin into the ignored runtime drop devops/plugins/execution/default/; the optional-build-script profile emits only build.script into devops/plugins/execution/optional-build-script/. The producer writes stellaops.execution.bundle-inventory.json beside each profile and uses the execution trust root under devops/etc/certificates/trust-roots/plugins/execution/. Local development uses the repo’s lab signer; release engineering must replace that signing material for production/offline-kit publication.
First-party execution plugin manifests must carry a signed Pattern Z requires block for the host-owned services their implementations consume. Agent Core admission fails closed for the known first-party execution plugin IDs when those service declarations are missing, weakened to optional for required services, scoped outside perPlugin, or versioned outside ^1.0.
The default compose overlay is devops/compose/docker-compose.plugins.execution.yml; the optional overlay is devops/compose/docker-compose.plugins.execution-optional.yml. Render them through devops/compose/scripts/compose-cli.ps1 with COMPOSE_PROFILES=agents so the profile-gated agent-core service is included. Full compose output can contain local environment values; persist only sanitized plugin-related env and volume summaries in QA evidence.
Composability inventory
| Capability / path | Source | Profile | Packaging and runtime note |
|---|---|---|---|
build.docker | src/ReleaseOrchestrator/__Agents/StellaOps.Agent.BuildDocker/plugin.manifest.json | default | Signed mounted bundle; requires declares IBuildxProcessRunner plus optional attestation/reachability producer and result-emitter contracts. |
build.script | src/ReleaseOrchestrator/__Agents/StellaOps.Agent.BuildScript/plugin.manifest.json | optional-build-script | Signed mounted bundle, opt-in only because it runs operator-supplied containerized code; requires declares ContainerExecutionPluginBackend. |
deploy.compose / deploy.compose.up | src/ReleaseOrchestrator/__Agents/StellaOps.Agent.DeployCompose/plugin.manifest.json | default | Signed mounted bundle wrapping ComposeCapability; requires declares the host-owned IAgentCapability bridge and legacy compose.* aliases remain compatible. |
deploy.exec | src/ReleaseOrchestrator/__Agents/StellaOps.Agent.DeployNative/plugin.manifest.exec.json | default | Signed mounted native deploy bundle; requires declares IDeployProcessRunner. |
deploy.artifact-extract | src/ReleaseOrchestrator/__Agents/StellaOps.Agent.DeployNative/plugin.manifest.artifact-extract.json | default | Signed mounted native artifact extraction bundle; requires declares IDeployProcessRunner and IArtifactFileSystem. |
deploy.docker-plugin | src/ReleaseOrchestrator/__Agents/StellaOps.Agent.DeployNative/plugin.manifest.docker-plugin.json | default | Signed mounted plugin-container deploy bundle; requires declares IDeployProcessRunner and IArtifactFileSystem; digest validation remains part of the task/payload path. |
ansible / deploy.ansible | src/ReleaseOrchestrator/__Agents/StellaOps.Agent.Ansible | optional capability | Host-owned optional capability today; not part of the default bundle producer because it depends on operator playbooks and runtime obligations. |
| Execution diagnostics/probe report | StellaOps.Agent.Core.Capability.ExecutionPluginProbeReporter | host-owned | Reports catalog and enabled capability state to the execution plugin scratch root. |
| Legacy compatibility paths | AgentCapabilityExecutionPlugin and ExecutionPluginAgentCapability | host-owned shims | Preserve existing task types, heartbeat capability reporting, and compose.* aliases while payloads move to signed bundles. |
| Cloud/provider executors | StellaOps.Agent.Ecs, StellaOps.Agent.Nomad | explicit opt-in only | Not mounted or enabled by the default execution overlay. |
Goals
- Keep deploy task payloads, task-type strings, and agent dispatch semantics behavior-compatible during migration.
- Let new execution behavior be added as a signed, manifest-described plugin instead of a host code change.
- Support two execution backends behind one contract: in-process .NET plugins for built-in capabilities and container-hosted plugins for multi-language steps.
- Preserve offline-first and fail-closed behavior for deployment, evidence, and policy-gated releases.
Contract
The neutral contract lives in src/Plugin/StellaOps.Plugin.Abstractions/Execution/IExecutionPlugin.cs. This keeps the execution contract next to IPlugin and avoids a dependency cycle: Release Orchestrator and later execution plugin assemblies can reference the Plugin SDK abstractions, while src/Plugin/** does not reference ReleaseOrchestrator internals.
An execution plugin implements IExecutionPlugin and also participates in the Plugin SDK IPlugin lifecycle. IExecutionPlugin.Capabilities intentionally hides the broad IPlugin.Capabilities flag property with execution capability strings; implementations that need both surfaces implement the SDK flag property explicitly.
public interface IExecutionPlugin : IPlugin
{
ExecutionKind Kind { get; }
new IReadOnlyList<string> Capabilities { get; }
Task<ExecutionResult> ExecuteAsync(
ExecutionContext context,
ExecutionInputs inputs,
CancellationToken cancellationToken);
}
ExecutionKind is one of Build, Scan, Deploy, Verify, or Custom. Capabilities are stable wire names such as deploy.compose, deploy.ssh, deploy.ansible, build.docker, or scan.sbom.
ExecutionContext carries host-controlled identity, tenant, target, release, correlation, timeout, and evidence context. ExecutionInputs carries scoped configuration, files, mounts, and secrets already resolved by the host or orchestrator. Plugins must not read ambient credentials. ExecutionResult returns status, structured outputs, emitted events, and raw-output proof where available.
Manifest Extensions
Execution plugins use the existing Plugin SDK manifest. Any execution-specific fields are carried under the optional execution object and are additive and backward compatible:
- execution kind
- declared execution capabilities
- input schema
- output schema
- required permissions
- resource limits
- trust level
- signature metadata
Existing integration plugin manifests remain valid.
Example:
{
"execution": {
"kind": "Deploy",
"capabilities": [ "compose.up", "compose.rollback" ],
"inputSchema": { "type": "object" },
"outputSchema": { "type": "object" }
}
}
Hosts
In-process host
Built-in .NET capabilities run in-process with BuiltIn trust. The agent host registers them as execution plugins, then exposes them through the existing capability registry. The migration keeps the current Docker, Compose, SSH, WinRM, ECS, and Nomad task types stable.
The first implementation keeps the existing IAgentCapability behavior behind two shims in StellaOps.Agent.Core:
AgentCapabilityExecutionPluginadapts existing capabilities toIExecutionPlugin.ExecutionPluginAgentCapabilityexposes an execution plugin back through the currentCapabilityRegistryso task polling, heartbeat capability reporting, credential resolution, and task payloads keep their existing shape.
StellaOps.Agent.Host no longer switches on capability names. It registers built-in execution plugins in ExecutionPluginCatalog and resolves EnabledCapabilities names or aliases from that catalog.
External in-process execution plugins are loaded through the Plugin SDK host from Plugins:PluginPaths. The agent enables strict SDK admission for this path:
- malformed manifests fail discovery instead of being skipped;
- plugin load errors fail startup;
- unsigned development fallback is disabled for the agent host;
- the host-observed trust level and signature verification result are carried on the loaded plugin metadata;
- an external execution plugin must declare an
executionmanifest section, implementIExecutionPlugin, match the manifest kind and capabilities, and have a verified signature before it is registered. - known first-party execution plugin IDs must also declare their expected Pattern Z host service contracts in the signed manifest
requiresblock, or Agent Core rejects registration with an actionable missing-service declaration error.
External plugins are loaded into ExecutionPluginCatalog under their plugin ID. Each declared execution capability is also accepted as an EnabledCapabilities alias, so operators can enable either com.example.execution.ansible or deploy.ansible. If no external plugin is explicitly enabled, it remains loaded but absent from the task CapabilityRegistry.
Container host
Trusted and untrusted multi-language plugins run through the generalized container execution backend derived from the release script executor. The host controls mounts, environment variables, scoped secrets, network policy, timeouts, resource limits, and raw-output proof capture.
The compiled backend lives in src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Scripts/ExecutionPlugins/. ContainerExecutionPlugin implements IExecutionPlugin for an image-packaged plugin descriptor, while ContainerExecutionPluginBackend prepares the container spec through IExecutionPluginRuntimeImageManager and IExecutionPluginContainerPoolManager. This keeps the release-script evidence path (ScriptRawOutputProof) as the proof format for multi-language execution plugins without forcing the older incomplete script Docker implementation into the build.
Container descriptors declare plugin identity, kind, execution capabilities, image reference, trust level, signature validation result, command, args, non-secret environment, mounts, network policy, and resource limits. For Trusted and Untrusted plugins, the backend fails closed unless the Plugin SDK signature result is accepted before any container is acquired.
Invocation inputs are projected into scoped container inputs only:
- host context appears as
STELLA_EXECUTION_ID, tenant/target/release, and correlation variables; ExecutionInputs.VariablesbecomeSTELLA_VAR_*;ExecutionInputs.SecretsbecomeSTELLA_SECRET_*;ExecutionInputs.FilesbecomeSTELLA_FILE_*;ExecutionInputs.Mountsare added as read-only input mounts.
The backend does not copy ambient process environment variables into the container spec, and descriptor environment keys prefixed with STELLA_ are rejected so a plugin cannot spoof host-scoped variables or secrets. Network is disabled by default and must be declared explicitly through the descriptor network policy.
Current verification uses a deterministic accepted-signature container fixture with a fake container pool. It proves backend loading, scoped input projection, timeout/mount/network/resource propagation, fail-closed signature handling, and ScriptRawOutputProof capture. A live signed directory-loaded plugin and real container execution remain gated by external plugin loading/signature integration and local container runtime availability.
Authoring Execution Plugins
In-process SDK plugins
Use in-process plugins for built-in or tightly controlled .NET execution code that can run inside the target-agent process. A new in-process plugin must:
- Reference
src/Plugin/StellaOps.Plugin.Abstractions/. - Implement
IExecutionPluginand the Plugin SDK lifecycle methods. - Return the exact execution
Kindand execution capability strings declared by its manifestexecutionsection. - Keep existing task-type strings stable when replacing a target-agent capability. The task payload JSON, secret keys, variables, and result shape are part of the agent wire contract.
- Avoid ambient credentials. Read only
ExecutionInputs.Variables,ExecutionInputs.Secrets,ExecutionInputs.Files, andExecutionInputs.Mounts. - Ship with a signed Plugin SDK manifest. Non-built-in in-process plugins load from
Plugins:PluginPaths, fail closed when unsigned or malformed, and are enabled by plugin ID or declared capability alias inAgent:EnabledCapabilities.
Built-in target-agent capability migration uses the lower-risk shim path: AgentCapabilityExecutionPlugin adapts an existing IAgentCapability to IExecutionPlugin, then ExecutionPluginAgentCapability exposes the plugin back through the current capability registry. This is the required path for docker, compose, ssh, winrm, ecs, and nomad until their payload builders move to a native plugin surface.
ECS and Nomad are optional built-in plugins. Operators enable them through Agent:EnabledCapabilities or the legacy CSV Agent:Capabilities value. ECS uses the AWS SDK credential chain and optional Agent:EcsRegion; Nomad uses Agent:NomadAddress, then NOMAD_ADDR, then http://127.0.0.1:4646.
Container-host plugins
Use container-host plugins for multi-language execution steps or untrusted operator extensions. A container-host plugin is described by a ContainerExecutionPluginDescriptor and wrapped by ContainerExecutionPlugin. Authors must package the runtime as an offline-available image reference, preferably pinned by digest, and declare:
- Plugin identity, execution kind, and exact capability strings.
- Entrypoint command and arguments.
- Non-secret environment variables. Keys beginning with
STELLA_are reserved for the host and are rejected. - Static mounts, with input mounts read-only by default.
- Network policy. The default is disabled network; any network access must be explicit and allow-listed.
- CPU, memory, and timeout resource limits.
- Trust level and accepted Plugin SDK signature validation result.
At invocation time, the host projects scoped inputs into the container: STELLA_EXECUTION_ID, STELLA_CAPABILITY, STELLA_PAYLOAD, optional context IDs, STELLA_VAR_* variables, STELLA_SECRET_* secrets, and STELLA_FILE_* file references. The process environment is not copied into the container. Secrets must not be written to stdout, stderr, or output proof.
Container plugins report structured outputs by emitting stdout lines in the form STELLA_OUTPUT: key=value. The backend captures stdout, stderr, exit code, duration, timeout state, image reference, digest, and signature metadata as ScriptRawOutputProof.
The non-live authoring contract is complete. Phase 3 still must prove a signed directory-loaded container plugin running on a real local/CI container runtime before EPF-004 can move from BLOCKED to DONE.
Trust And Security
- Built-in plugins are compiled and shipped with Stella Ops.
- Trusted and untrusted plugins require manifest validation and signature verification.
- Non-built-in plugins fail closed when signature, trust, permission, or resource-limit checks fail.
- Secrets are resolved outside the plugin and passed only as scoped inputs.
- Plugin output is captured as evidence; secret values must be redacted before logging or proof emission.
Capability Mapping
Deployment dispatch resolves task types to plugin capabilities through the registry instead of a hardcoded switch. The first migration preserves these existing task strings:
| Existing task type | Execution capability |
|---|---|
docker.run | deploy.docker |
compose.up | deploy.compose |
ssh.execute | deploy.ssh |
winrm.execute | deploy.winrm |
ecs.deploy | deploy.ecs |
nomad.deploy | deploy.nomad |
deploy.ansible | deploy.ansible |
docker_plugin_deploy | deploy.docker-plugin |
New capabilities, such as deploy.ansible, deploy.docker-plugin, build.docker, and build.script, are added by registering execution plugins rather than editing the agent host switch.
Built-In Ansible Plugin
deploy.ansible is the built-in Ansible deploy plugin. It is intentionally a thin launcher for operator-supplied automation:
- Target type:
AnsibleHost. - Target config: playbook base path, inventory path, optional default playbook path, SSH username, SSH private-key secret ref, Vault password secret ref, become method, and become-password secret ref.
- Target-managed non-secret text can be supplied through
ManagedArtifacts.managed://<path>resolves an inventory inside the task-scoped materialized root, and{managedArtifactRoot}expands in approved environment and extra-var values. Portable-path and size/count checks run before execution; cleanup runs after both success and failure. - Component config can select the playbook and inventory with
ansible.playbook/playbookandansible.inventory/inventory.ansible.limit/limitmaps to--limit, andansible.tags/tagsmaps to--tags. - Extra-vars come from release metadata plus component keys prefixed with
ansible.extraVars.,ansible.extraVar., orextraVars.. - Scoped credential keys delivered to the agent are
ansible.vaultPassword,ansible.sshPrivateKey, andansible.becomePassword. - Payload
Environmentis explicit and allow-listed. Recognition is by exact key against a built-in allow-list coveringVAULT_ADDR,VAULT_TOKEN, migration flags, approved deploy secret keys, non-secret operational keys (registry, app-server, environment and version values), and exactly one approved Ansible runtime key,ANSIBLE_HOST_KEY_CHECKING. Estates whose playbooks read their own namespaced variables opt in to key prefixes on the agent host throughAgent:Ansible:Environment:AllowedKeyPrefixes, which is empty by default — no key passes on a prefix alone. Prefixes are additive to the built-in allow-list and cannot widen the checks that run ahead of them: keys reserved for Stella Ops runtime internals (STELLA_*,STELLAOPS_*) and every otherANSIBLE_*key are rejected before any prefix is consulted, so a prefix inside either namespace would never match and configuring one fails the agent host at startup rather than being silently ignored. Environment values are redacted from stdout, stderr, raw-output proof, and process-runner error text.
For the API setup, secret-boundary rules, and live acceptance procedure, use the Ansible delivery environment onboarding runbook.
The plugin runs ansible-playbook with ProcessStartInfo.ArgumentList and does not invoke a shell. It does not generate, rewrite, template, or vendor playbooks or roles. The operator provides the playbook tree through the configured path or mount, and the plugin runs the selected playbook as-is. The standalone agent-core image carries ansible-core; the Ansible capability health check verifies ansible-playbook --version when the capability is enabled.
The BUSL-1.1 wrapper and GPLv3 Ansible runtime remain separate programs: Stella Ops invokes ansible-playbook as a separate process and does not link to or vendor Ansible code. The license posture is recorded in NOTICE.md and docs/legal/THIRD-PARTY-DEPENDENCIES.md.
Built-In Build Plugins (Sprint 017)
build.docker
- Project:
src/ReleaseOrchestrator/__Agents/StellaOps.Agent.BuildDocker. - Capability id:
build.docker. Kind:Build. Trust level:BuiltIn. - Inputs (payload — see
BuildDockerPayload):contextPath(required),dockerfile(required),imageName(required).buildArgs(string→string map),labels(string→string map).push(bool — default true),platform(optional, e.g.linux/amd64).emitAttestationEvidence(bool),releaseId(string — surfaced on the emitted attestation evidence).timeoutSeconds(int — default 600).- Scoped credentials:
registry.username/registry.passwordinjected via the standardSTELLA_SECRET_*projection and consumed throughdocker login --password-stdin; never placed on the command line.
- Outputs:
imageDigest(sha256:...),imageName,pushed(bool),rawOutputProof,rawOutputProofSchema. - Sample manifest:
plugin.manifest.build.docker.jsonalongside the plugin (entry-point classStellaOps.Agent.BuildDocker.BuildDockerExecutionPlugin). - Sample usage: build an image from
/tmp/build-context/Dockerfile, push toregistry.example.com/repo/app:1.2.3, and emit attestation evidence for releaserel-001. OptionalIBuildAttestationEvidenceEmitterfires only whenemitAttestationEvidence=trueand a real emitter is registered in the host.
Build-time co-production (Sprint 017, ADR-024)
When Stella both builds and pushes the image itself, it holds the source and the push rights at the same instant — the moment to co-produce SBOM + reachability referrers bundled with the pushed digest. This is opt-in and gated entirely on payload flags; every flag defaults false/null, so existing manifests load unchanged and the pre-sprint behaviour is preserved byte-for-byte.
- Additional opt-in inputs (payload — see
BuildDockerPayload):emitSbom(bool): request an SBOM referrer on the pushed digest.emitReachability(bool): request a reachability referrer on the pushed digest.signReferrers(bool): request opt-in DSSE signing (local keys) of the co-produced referrers.sourceContextPath(string): on-agent source tree for the Phase 2 agent-side reachability producer when it differs fromcontextPath(defaults tocontextPath).commit(string),sourceRepositoryUrl(string): SCM provenance pinned to the build.componentId(string),versionId(string): operator-specified build target (both optional, default null). For build-time co-production the operator triggeringbuild.dockerknows which component-version they are building; pinning both here forwards the explicit pair to the orchestrator ingest (which prefers it verbatim over the digest lookup), so co-production engages immediately for the just-built digest — without waiting for the async registry sync to discover it. Only the complete pair is forwarded; a half-supplied pair is dropped and the ingest falls back to the digest lookup.
- Phase 1 (zero scanner/agent toolchain change): the plugin forwards the SCM provenance + emit flags (and the execution’s
TenantId/ReleaseId/ExecutionId) on the emittedBuildAttestationEvidence. The orchestrator-side ingest (IBuildCoProductionService, endpointPOST /api/v1/release-orchestrator/build-coproduction/scm-info, agent mTLS-only) publishes aComponentVersionScmInfoAvailabledomain event keyed to the pushed digest. That event drives the existing centralScmInfoAvailableScanTrigger(scanner worker), which submits a source-derived scan that attaches the SBOM referrer + asource=scm-fetchreachability referrer on the pushed digest — through the same dual-write sink as a post-hoc registry scan.- Tenant source: for direct
ExecuteCapabilitydispatch, the execution tenant is the authenticated logical tenant string resolved by the control plane, not a payload field and not the agent row GUID fallback unless no logical tenant is available. - Agent emitter (
IBuildAttestationEvidenceEmitter): the stock agent registersNoOpBuildAttestationEvidenceEmitter(abuild.dockeremits nothing) so default/test behaviour is unchanged. SetAgent:BuildCoProductionEnabled=trueto wire the realHttpBuildAttestationEvidenceEmitter, which POSTs the co-production signal to the/build-coproduction/scm-infoendpoint over the same shared agent mTLSHttpClient(cert rotation flows through). The emitter sends the pushed digest + tenant + release/execution + SCM provenance + emit flags, plus the operator-specifiedcomponentId/versionIdwhen the operator pinned both on the payload (omitted otherwise). - Identity correlation (operator pin wins; pushed digest is the fallback key): when the operator pinned
componentId+versionIdon the build payload, the ingest uses that explicit pair verbatim (no registry lookup) — this is the path that makes co-production actually engage for a freshly-built image, whose brand-new digest the async registry sync has not recorded yet. When the pair is absent, the orchestrator ingest resolvesComponentId/VersionIdfrom the component-version registry by the pushed digest (IComponentVersionRegistryRepository.ListVersionsByDigestAsync). A pushed digest is unique per built image, so digest → component-version is unambiguous (and strictly better than guessing from the release in a multi-component build); an unknown digest degrades to SBOM-only (no fabricated correlation). NB: the onlybuild.dockerdispatch path today (the F4a adminExecuteCapability) is ad-hoc and records no execution→component-version mapping, so the operator pin is how a freshly-built image correlates deterministically; if a release-driven build dispatch later records that mapping per execution, it can pre-resolve identity server-side and the operator pin becomes optional.
- Tenant source: for direct
- Phase 2 (opt-in agent-side reachability): see
IBuildReachabilityProducerbelow — computes the genuinesource=build-contextgraph from the live build context.
2026-06-12 source-contract update: once buildx reports the pushed digest, BuildDockerExecutionPlugin composes a digest-pinned subject reference as imageName@sha256:<digest> and forwards it through BuildAttestationEvidence, HttpBuildAttestationEvidenceEmitter, BuildCoProductionRequest, BuildCoProductionSignal, and ComponentVersionScmInfoAvailable. The Scanner bridge consumes the event field as image.reference, so source-backed scans have the OCI coordinate needed for subject pulls and referrer publication even when a raw digest alone would not identify the registry repository.
Stella-built vs. externally-built boundary
Image built + pushed by Stella (build.docker) | Image built elsewhere, ingested post-hoc | |
|---|---|---|
| Trigger | build.docker --push success path | sync-versions discovers a digest; manual PATCH .../versions/{id} adds SCM |
| SBOM referrer | central scan (Phase 1) | post-hoc registry scan |
Reachability source | build-context (Phase 2) or scm-fetch (Phase 1) | scm-fetch (SCM available) or rootfs (rootfs-only) |
| Sink | dual-write coordinator (internal-first; opt-in external) | same dual-write coordinator |
The shape, sink, and attachment-state machine are identical across both columns — only the reachability source annotation differs. There is no separate code path for Stella-built images downstream of the event; both feed the same ScmInfoAvailableScanTrigger and the same reachability-referrer serializer.
Graceful degradation ladder (a build NEVER fails on a co-production miss)
- Neither
emitSbomnoremitReachabilityset → nothing published (opt-in). - No pushed digest → nothing published (the image was not pushed; nothing to attach to).
- No operator-pinned
componentId/versionIdAND the pushed digest does not resolve to a component-version (registry sync hasn’t recorded it yet, no registry, or registry outage) → SBOM-only (SkippedNoComponentForDigest): no fabricated correlation; the post-push scan still attaches an SBOM by digest. (An operator-pinned explicit pair bypasses this step entirely — it is used verbatim, so a freshly-built digest still engages co-production.) - No complete SCM info (
commit+sourceRepositoryUrl) → SBOM-only: the source-derived event is not published, but the post-push scan still produces an SBOM referrer by digest. - Phase 2 producer absent/disabled → degrade to Phase 1 (
source=scm-fetch). - Any referrer/reachability/transport error is swallowed + logged; the build result is unaffected.
Build reachability producer (Phase 2, BLD-003)
- Interface:
IBuildReachabilityProducer(in Agent.Core). No-op default (NoOpBuildReachabilityProducer), wired exactly likeIBuildAttestationEvidenceEmitter— the stock agent registersTryAddSingleton<IBuildReachabilityProducer>(NoOp)so it stays thin; the reachability-capable agent is a separate, explicitly-built image that callsAddBuildDockerReachabilityProducer(...)fromStellaOps.Agent.BuildDocker.Reachabilityto replace the no-op. - What the real producer does: the opt-in
BuildContextReachabilityProducerreuses Scanner’s standalone build-time reachability generator (ReachabilityCallGraphGenerator, backed by the callgraph extractors and ADR-013 lightweight.NETpath) over the on-agent build context (sourceContextPath ?? contextPath). It refuses missing/unsupported/stub graphs, returns onlynodes > 1 && edges > 0payloads, and computes asha256:resultDigestover the exact referrer payload bytes via the Stella crypto stack. - Local proof sample/harness: the reusable local proof harness is
tools/scripts/release-orchestrator/Invoke-BuildCoproductionGatewayProof.ps1; the offline multi-language sample isdevops/agents-targets/build-coproduction/multi-language-go-js. The sample contains JavaScript and Go source while keeping the image build itselfFROM scratch; the current build-time standalone generator auto-detects and analyzes JavaScript in that context. Go source-only extraction is not wired into the build-time producer yet, so Go files prove the multi-language build context but not Go call-graph extraction. - Determinism: source paths are normalized relative to the context root so the same source yields the same
resultDigestregardless of the agent’s absolute build dir; OCI timestamps stay out of the digested payload. - Agent result emission:
BuildDockerExecutionPluginforwards non-stub producer output toIBuildReachabilityResultEmitter.Agent.HostwiresHttpBuildReachabilityResultEmitteronly whenAgent:BuildCoProductionEnabled=trueandAgent:OrchestratorUrlis configured; otherwise it keepsNoOpBuildReachabilityResultEmitter, preserving stock-agent behavior. The HTTP emitter POSTs to/build-coproduction/reachabilityover the shared agent mTLS handler. - Orchestrator ingest:
POST /api/v1/release-orchestrator/build-coproduction/reachability(agent mTLS-only) validates the agent-supplied graph, enforces the stub guard (nodes > 1 && edges > 0; a stub/empty graph is never attached), and hands the validatedsource=build-contextpayload toIBuildContextReachabilitySinkfor attachment (optionally DSSE-signed). - Sink forwarding (the orchestrator→Scanner hop, Sprint 017 BLD-003):
IBuildContextReachabilitySink’s real implementation isEventPublishingBuildContextReachabilitySink. Rather than pullStellaOps.Scanner.Storage.Ociinto the orchestrator (wrong layer — the layering decision), it publishes aBuildContextReachabilityAvailabledomain event through the existingIEventPublisheroutbox (release_orchestrator.domain_events). This mirrors theComponentVersionScmInfoAvailable → ScmInfoAvailableScanTriggercross-module precedent: a Scanner Worker bridge (BuildContextReachabilityBridgeService, sibling of the SCM bridge) polls the outbox for thatevent_typeand hands each row toBuildContextReachabilityAttachHandler, which attaches thesource=build-contextreferrer through the Scanner’s existingReachabilityReferrerSerializer+DualWriteOciReferrerCoordinator+ opt-inIReachabilityReferrerSigner(no dual-write DI replicated; the Scanner stampsorg.stellaops.reachability.source=build-context). The canonical reachability JSON — the referrer’s compact layer-0 descriptor, the same size class the rootfs/scm-fetch paths already attach — rides base64-encoded in the event (a defensive 1 MiB cap degrades to Phase 1 / SBOM-only rather than overflowing the outbox row). - Status (Sprint 017): the source-level producer package, no-op defaults, plugin handoff, HTTP result emitter, orchestrator ingest endpoint,
IBuildCoProductionService.IngestReachabilityAsyncvalidation, the full degradation ladder, and the orchestrator→Scanner sink forwarding (above) are landed with focused unit tests. The 2026-06-13 multi-language proof underdocs/qa/feature-checks/runs/release-orchestrator/build-coproduction-gateway-multilang-go-js-20260613073755Z/pushed a Go+JS context image, generated a non-stub JavaScript build-context graph (nodes=7,edges=5), and settled an external registry referrer withorg.stellaops.reachability.source=build-context. The gateway request still hit the 30-second synchronous timeout, but the build-context referrer proof is closed for BLD-003; the remaining build co-production blocker is BLD-001’s central SBOM plussource=scm-fetchattachment.
2026-06-12 packaging update: Agent.Host can now publish a reachability-capable artifact with /p:IncludeBuildDockerReachability=true. At runtime, Agent:BuildReachabilityProducerEnabled=true loads StellaOps.Agent.BuildDocker.Reachability.dll and calls AddBuildDockerReachabilityProducer(...) to replace the no-op producer. If the runtime flag is enabled without that packaged DLL, startup fails with an explicit configuration error instead of silently falling back to the no-op. The live build -> push -> registry -> scan proof remains the open BLD-003 criterion. The local image helpers expose this as AGENT_CORE_INCLUDE_BUILD_REACHABILITY=true; pair it with AGENT_CORE_BUILD_COPRODUCTION_ENABLED=true and AGENT_CORE_BUILD_REACHABILITY_PRODUCER_ENABLED=true only for the live build-time co-production proof lane.
build.script
- Project:
src/ReleaseOrchestrator/__Agents/StellaOps.Agent.BuildScript. - Capability id:
build.script. Kind:Build. Trust level:BuiltIn. - Delegates to the existing
ContainerExecutionPluginBackendso any sandboxed image (any language) can run a build script with scoped inputs/secrets, declared mounts, and a network allow-list. - Inputs: image reference (pinned/digest), command + args, env, mounts, network allow-list, resource limits, declared outputs. Treats the operator payload as the trust boundary — host enforces sandbox guarantees.
- Outputs: structured outputs scraped from
STELLA_OUTPUT: key=valuelines plus fullScriptRawOutputProof(stdout, stderr, exit code, duration, image digest, signature metadata). - Sample manifest:
plugin.manifest.jsonalongside the plugin.
Native Deploy Plugins (Sprint 017)
These plugins are the “do not require an external deploy tool” path. Each is default-off until selected via a release template; the legacy compose.* capability strings continue to resolve through deploy.compose for backward compatibility.
deploy.compose
- Project:
src/ReleaseOrchestrator/__Agents/StellaOps.Agent.DeployCompose. - Capability ids:
deploy.compose,deploy.compose.up,deploy.compose.down,deploy.compose.rollback,deploy.compose.health-check(plus the legacycompose.*aliases the orchestrator already dispatches). Kind:Deploy. Trust level:BuiltIn. - Implementation pattern: wraps the existing
ComposeCapabilityviaIAgentCapabilityrather than re-implementing the lifecycle.deploy.compose.upmaps to the legacycompose.up, etc. - Inputs: compose project path, service set, environment, scoped registry credentials (for image pull during
up). - Outputs: per-service health, pulled image digests, raw output proof.
- Sample manifest:
plugin.manifest.jsonalongside the plugin. - Regression invariant: the existing 70
StellaOps.Agent.Compose.Testscontinue to pass against the underlying capability; the 13 plugin-level tests cover theIExecutionPluginprojection.
deploy.exec
- Project:
src/ReleaseOrchestrator/__Agents/StellaOps.Agent.DeployNative. - Capability id:
deploy.exec. Kind:Deploy. Trust level:BuiltIn. - Inputs (payload — see
DeployExecPayload):executable(absolute path on the target — required).args(string array),workingDirectory(optional),environment(string→string map).timeoutSeconds(int — default 1800).- Reserved env prefix: keys starting with
STELLA_are rejected at payload validation — the host owns that prefix. - Scoped credentials:
inputs.Secretsare projected asSTELLA_SECRET_<KEY>env vars (matching the container-backend convention).inputs.VariablesbecomeSTELLA_VAR_<KEY>.
- Outputs:
exitCode,stdout,stderr(all with secret values redacted),rawOutputProof,rawOutputProofSchema. - Sample manifest:
plugin.manifest.exec.json. - Sample usage: invoke an operator-supplied
/usr/local/bin/deploy.sh --env stagewith scoped API tokens projected as env vars.
deploy.artifact-extract
- Project:
src/ReleaseOrchestrator/__Agents/StellaOps.Agent.DeployNative. - Capability id:
deploy.artifact-extract. Kind:Deploy. Trust level:BuiltIn. - Implements the “pull an image, extract a file, drop it on disk, restart a named container” pattern that dominates production deploy roles — without depending on an external deploy tool.
- Inputs (payload — see
DeployArtifactExtractPayload):image(pinned@sha256:reference — required).sourcePath(path inside the image — required),destinationPath(absolute path on target — required and validated as absolute).restartContainer(optional container name todocker restartafter placement succeeds).backupPreviousVersion(bool — default true; keeps<destinationPath>.previousfor rollback).timeoutSeconds(int — default 600).
- Outputs:
image,destinationPath,artifactSha256(sha256:...),skipped(true when the destination already matched the extracted digest — idempotent no-op),restartedContainer(when applicable),previousBackupPath(when applicable). - Pipeline:
docker pull→docker create --name stellaops-extract-<exec-id>→docker cp <cid>:<source> <dest>.new→ SHA-256 over.new→ digest-compare against destination (skip if equal) → optional.previousbackup → atomic.new→destmove → optionaldocker restart. The created container is always force-removed in a finally block; a stale.newstaging file is cleaned up on failure. - Error codes:
docker_pull_failed,docker_create_failed,docker_cp_failed,artifact_missing_after_extract,docker_restart_failed(preserves partial outputs so the orchestrator can observe placement-vs-restart failure separately),invalid_payload. - Sample manifest:
plugin.manifest.artifact-extract.json.
deploy.docker-plugin
- Project:
src/ReleaseOrchestrator/__Agents/StellaOps.Agent.DeployNative. - Capability id:
deploy.docker-plugin. Kind:Deploy. Trust level:BuiltIn. - Implements the plugin-container deployment pattern: pull a digest-pinned image, extract the plugin binary tree from the image, place it on the target filesystem, and optionally restart one or more local Docker containers. This is release-controlled and audit-backed; it is not an ad-hoc
ExecuteCapabilitybypass. - Inputs (payload - see
DeployDockerPluginPayload):image(pinned@sha256:reference - required).sourcePath(path inside the image - default/app/PluginBinaries).sourceArchivePath(optional absolute path inside the image to a ZIP artifact, for images that carry/<plugin-image-name>.zipat image root).archiveContentPath(relative path insidesourceArchivePathto install - defaultapp/PluginBinaries; traversal and rooted paths are rejected).destinationPath(absolute target path - required and validated as absolute).restartContainers(optional container names to restart after placement succeeds).backupPreviousVersion(bool - default true; keeps<destinationPath>.previousfor rollback).pullPolicy(alwaysornever- defaultalways).neveris the offline/preloaded-image path: the agent runsdocker image inspectfor the digest-pinned reference and fails closed without pulling if the image is not already present.timeoutSeconds(int - default 900).
- Outputs:
image,sourcePath, optionalsourceArchivePathandarchiveContentPath,destinationPath,sourceMode,pluginTreeSha256(sha256:...),pullPolicy,skipped(true when the destination already matched the extracted tree digest),restartedContainers, andpreviousBackupPathwhen applicable. - Pipeline:
docker pull(ordocker image inspectwhenpullPolicy=never) ->docker create --name stellaops-plugin-<exec-id>-> eitherdocker cp <cid>:<sourcePath> <destination>.newfor directory-mode images ordocker cp <cid>:<sourceArchivePath> <destination>.archive.zipplus safe ZIP extraction ofarchiveContentPathto<destination>.newfor zip-carrier images -> deterministic tree SHA-256 over.new-> digest compare against destination (skip if equal) -> optional.previousbackup -> move.newto destination -> optionaldocker restartfor each configured container. The temporary container is force-removed in a finally block and stale staging trees are cleaned up on failure. - Error codes:
docker_pull_failed,docker_create_failed,image_not_preloaded,docker_cp_failed,plugin_archive_missing_after_extract,plugin_archive_extract_failed,plugin_archive_content_missing,plugin_tree_missing_after_extract,docker_restart_failed(preserves partial outputs so placement-vs-restart failure is visible), andinvalid_payload. - Release mapping: a DockerHost release component opts into this route with component config
deploy.kind=docker-plugin(ornative-docker-plugin). The kind is read from the first ofdeploy.kind,deployment.kind,stella.deployer,component.deploy.kindthat is set, and matched case-insensitively. The orchestrator emits aDockerPluginDeployagent task routed by the agent’s extended capabilitycom.stellaops.execution.deploy.dockerplugin. - Sample manifest:
plugin.manifest.docker-plugin.json.
The Ansible-retirement parity matrix and the go/no-go decision tied to these native plugins are recorded in docs/implplan/SPRINT_20260525_017_ReleaseOrchestrator_build_native_deploy_plugins.md (NDP-003).
Registry Diagnostics
IExecutionPluginRegistryDiagnostics lists loaded execution plugins for agent diagnostics and the FE plugin-list work. Each descriptor includes:
- agent registry name and plugin ID;
- execution kind and declared capability strings;
- source kind (
BuiltInorExternal); - effective host trust level;
- enabled state from the active
CapabilityRegistry; - signature verification status, signer thumbprint, and manifest digest when available.
Built-in adapters report signature status NotRequired. External plugins must report Verified; rejected unsigned or malformed plugins never enter the catalog.
Agent Core also writes the worker-style runtime-composition report at startup. ExecutionPluginProbeReporter reads the execution catalog and active CapabilityRegistry, probes enabled plugins through IExecutionPlugin health checks, and writes canonical PluginProbeReport JSON to /var/lib/stellaops/plugin-scratch/execution/probe-report.json by default. The scratch root is configurable with StellaOps:Plugins:ScratchRoot, matching the compose StellaOps__Plugins__ScratchRoot environment key. If the shared old-host bridge is adopted for Agent Core later, the bridge call point is the single write path in ExecutionPluginProbeReportWriter.
Verification Requirements
The framework is not complete until these gates pass:
- existing deployment integration tests pass unchanged through the new plugin path;
- Compose and SSH deployments succeed end to end through execution plugins;
- a signed sample container plugin loads, runs sandboxed, and returns raw-output proof;
- loaded plugins are enumerable with capability, kind, trust, and signature status;
src/ReleaseOrchestrator/AGENTS.mddescribes how to add and verify a new execution plugin.
