Closed capability registry
apps/backend/internal/plugins/hostcaps/registry.go ↗The registry gives every capability one versioned row and rejects duplicate, unknown, out-of-range, and retired identities before a provider can use them.
Entry
type Entry struct {
Name string
Min int
Max int
IntroducedIn string
DeprecatedIn string
RetireIn string
Replacement string
Requires []string
}
Validation
func ValidateRequirements(reqs []Requirement) []error {
if len(reqs) > len(registry) {
return []error{fmt.Errorf("requires_host_capabilities must list at most %d capabilities", len(registry))}
}
var errs []error
declared := make(map[string]bool, len(reqs))
for _, req := range reqs {
entry, known := Lookup(req.Name)
switch {
case declared[req.Name]:
errs = append(errs, fmt.Errorf("duplicate capability %q", req.Name))
continue
case req.Version < 1:
errs = append(errs, fmt.Errorf("capability %q version must be an integer of at least 1", req.Name))
}
declared[req.Name] = true
if pair, isRetired := retiredPair(req.Name, req.Version); isRetired {
errs = append(errs, fmt.Errorf("capability_retired: capability %q version %d was retired in %s", pair.Name, pair.Version, pair.RetiredIn))
continue
}
if !known {
errs = append(errs, fmt.Errorf("unknown capability %q", req.Name))
continue
}
if req.Version >= 1 && (req.Version < entry.Min || req.Version > entry.Max) {
errs = append(errs, fmt.Errorf("capability %q version %d is outside the supported range %d-%d", req.Name, req.Version, entry.Min, entry.Max))
}
}
return errs
}
Manifest limits
apps/backend/internal/plugins/manifest/executor_provider_capabilities.go ↗The manifest validation applies the capability registry and the cross-field rules that decide when provider workspace preparation, API forwarding, or a longer destroy deadline is legal.
Budgets and defaults
const (
// DefaultExecutorStateBudgetBytes is the provider state budget applied to a
// provider that declares a capability and no state_budget_bytes.
DefaultExecutorStateBudgetBytes = 8192
// MinExecutorStateBudgetBytes and MaxExecutorStateBudgetBytes bound
// state_budget_bytes; the maximum plus the 20480-byte host reserve is the
// 32768-byte envelope cap.
MinExecutorStateBudgetBytes = 1024
MaxExecutorStateBudgetBytes = 12288
// RuntimeAPIURLHostPublic and RuntimeAPIURLProviderForwarded are the two
// values of a provider's runtime_api_url.
RuntimeAPIURLHostPublic = "host_public"
RuntimeAPIURLProviderForwarded = "provider_forwarded"
// WorkspacePreparationCore and WorkspacePreparationProvider are the two
// values of a provider's workspace_preparation.
WorkspacePreparationCore = "core"
WorkspacePreparationProvider = "provider"
// DefaultExecutorDestroyTimeoutSeconds is the destroy deadline; a provider
// that requires drain_receipt can raise it up to MaxExecutorDestroyTimeoutSeconds.
DefaultExecutorDestroyTimeoutSeconds = 30
MaxExecutorDestroyTimeoutSeconds = 300
maxLaunchPolicyIDs = 16
maxStateSchemaVersion = 16
)
Workspace preparation
func validateWorkspacePreparation(prefix string, provider *ExecutorProvider) []error {
switch provider.WorkspacePreparation {
case "":
provider.WorkspacePreparation = WorkspacePreparationCore
case WorkspacePreparationCore:
case WorkspacePreparationProvider:
if !declaresCapability(provider, "repository_intent") || !declaresCapability(provider, "workspace_descriptor") {
return []error{fmt.Errorf("%s.workspace_preparation provider requires the repository_intent and workspace_descriptor capabilities", prefix)}
}
default:
return []error{fmt.Errorf("%s.workspace_preparation must be %s or %s", prefix, WorkspacePreparationCore, WorkspacePreparationProvider)}
}
return nil
}
Allocation gate
apps/backend/internal/agent/runtime/lifecycle/executor_plugin_capability_gate.go ↗The gate refuses to allocate for a capability-declaring provider unless the installed manifest, host registry, and running provider echo agree. A provider that declares nothing remains ungated.
Gate
// PluginExecutorCapabilityConfirmer is implemented by the plugin service. It
// confirms that the installed manifest, the host registry, and the accepted
// echo of the running provider agree before any provider allocation call.
type PluginExecutorCapabilityConfirmer interface {
ConfirmExecutorProviderCapabilities(ctx context.Context, pluginID, providerKey string, profile models.ExecutorProviderProfile) error
}
// negotiatedCapabilities returns the sorted `name@version` snapshot pinned to
// an environment, or nil for a provider that declares no capability.
func negotiatedCapabilities(provider models.ExecutorProvider) []string {
if len(provider.RequiredCapabilities) == 0 {
return nil
}
out := make([]string, 0, len(provider.RequiredCapabilities))
for _, requirement := range provider.RequiredCapabilities {
out = append(out, requirement.Name+"@"+strconv.Itoa(requirement.Version))
}
slices.Sort(out)
return out
}
// confirmPluginExecutorCapabilities gates one launch on capability agreement.
// A provider declaring nothing is not gated; one declaring capabilities with no
// confirmer available is refused, never admitted unchecked.
func (r *PluginRemoteExecutor) confirmPluginExecutorCapabilities(ctx context.Context, profile models.ExecutorProviderLaunchProfile) error {
if len(profile.Provider.RequiredCapabilities) == 0 {
return nil
}
confirmer, ok := r.operations.(PluginExecutorCapabilityConfirmer)
if !ok {
return fmt.Errorf("%w: capability confirmation is unavailable", models.ErrExecutorCapabilityMismatch)
}
return confirmer.ConfirmExecutorProviderCapabilities(ctx, profile.Provider.PluginID, profile.Provider.Key, models.ExecutorProviderProfile{
ProfileID: profile.ProfileID, Config: profile.Config, SecretValues: profile.SecretValues,
})
}
Drain evidence
apps/backend/internal/agent/runtime/lifecycle/executor_plugin_drain.go ↗Cleanup chooses one closed drain outcome, binds a receipt to the cleanup claim, and treats an ambiguous failure as uncertain rather than assuming the runtime stopped.
Outcome set
const (
pluginExecutorCapabilityDrainReceipt = "drain_receipt"
// The closed set of drain outcomes the destroy request carries.
pluginDrainOutcomeDrained = "drained"
pluginDrainOutcomeUnreachable = "unreachable"
pluginDrainOutcomeUncertain = "uncertain"
pluginDrainOutcomeNotAttempted = "not_attempted"
pluginExecutorDrainDeadline = 60 * time.Second
// pluginExecutorCleanupClaimWait bounds how long a failed launch waits for a
// concurrent cleanup to release the environment's cleanup claim.
pluginExecutorCleanupClaimWait = 20 * time.Second
pluginExecutorCleanupClaimRetry = 250 * time.Millisecond
// pluginExecutorPersistGrace is the time left for inventory writes after
// the provider calls of a cleanup have used their deadlines.
pluginExecutorPersistGrace = 30 * time.Second
pluginDrainReceiptDigestDomain = "kandev.plugin.drain-receipt.v1"
// pluginDrainReceiptNeverHeldCredential is the first drain_receipt version
// whose not_attempted means the host never held an agentctl credential.
pluginDrainReceiptNeverHeldCredential = 2
)
Failure classification
func classifyPluginDrainFailure(err error) string {
if errors.Is(err, syscall.ECONNREFUSED) {
return pluginDrainOutcomeUnreachable
}
var rejected *agentctl.RuntimeDrainRejectedError
if errors.As(err, &rejected) {
if rejected.StatusCode == http.StatusPreconditionFailed {
return pluginDrainOutcomeUnreachable
}
}
return pluginDrainOutcomeUncertain
}
Seal-and-drain endpoint
apps/backend/internal/agentctl/server/api/runtime_drain.go ↗The runtime accepts one drain owner, makes a retry of that request wait, and returns the stored receipt after sealing without stopping the runtime again.
Handler
func (m *ControlServer) handleRuntimeSealAndDrain(c *gin.Context) {
// Control auth permits unauthenticated local use when no credential was
// configured. A receipt is evidence, so it requires a credential.
if m.credentials.Latest() == "" {
c.JSON(http.StatusUnauthorized, gin.H{errKey: "runtime drain requires control credentials"})
return
}
req, ok := m.decodeRuntimeDrainRequest(c)
if !ok {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.DeadlineMS)*time.Millisecond)
defer cancel()
for {
done, handled := m.claimRuntimeDrain(c, ctx, req)
if handled {
return
}
if done == nil {
continue
}
m.runDrainAndRespond(c, ctx, req, done)
return
}
}
Claim
func (m *ControlServer) claimRuntimeDrain(c *gin.Context, ctx context.Context, req agentctltypes.RuntimeDrainRequest) (done chan struct{}, handled bool) {
st := &m.runtimeDrain
st.mu.Lock()
if receipt, ok := st.acks[req.RequestID]; ok {
st.mu.Unlock()
c.JSON(http.StatusOK, receipt)
return nil, true
}
if st.sealed {
receipt := runtimeDrainReceipt(req)
st.acks[req.RequestID] = receipt
st.mu.Unlock()
c.JSON(http.StatusOK, receipt)
return nil, true
}
if st.running != "" {
if st.running != req.RequestID {
st.mu.Unlock()
c.JSON(http.StatusConflict, gin.H{errKey: "runtime drain already running for another request"})
return nil, true
}
wait := st.done
st.mu.Unlock()
select {
case <-wait:
return nil, false
case <-ctx.Done():
c.JSON(http.StatusGatewayTimeout, gin.H{errKey: "runtime drain deadline exceeded"})
return nil, true
}
}
st.running = req.RequestID
st.done = make(chan struct{})
done = st.done
st.mu.Unlock()
return done, false
}
Unrecorded handoff
apps/backend/internal/agent/runtime/lifecycle/executor_plugin_unrecorded.go ↗A failed launch records a provisioned resource that has no inventory row or cleanup claim. The environment cleanup adopts it and keeps the record until destroy is confirmed.
Record the handoff
// handOffUnrecordedPluginExecutorResource durably records a resource that has
// neither an inventory row nor a cleanup claim to hold it, as when an archive
// holds the environment while the provider returns. A provider mutates a
// resource only under a persisted claim, so the launch never destroys it: the
// environment cleanup adopts the record, destroys the resource under its own
// claim, and deletes the record only after the destroy is confirmed.
func (r *PluginRemoteExecutor) handOffUnrecordedPluginExecutorResource(
ctx context.Context,
req *ExecutorCreateRequest,
inventory pluginExecutorInventory,
resource *pluginsdk.ExecutorResourceDescriptor,
stage string,
cause error,
) error {
inventory.Resource = resource
inventory.Phase = pluginExecutorPhaseCleanupPending
var recordErr error
if req.RecordUnrecordedRuntimeInventory == nil {
recordErr = errors.New("durable handoff is unavailable")
} else {
recordErr = req.RecordUnrecordedRuntimeInventory(ctx, map[string]interface{}{MetadataKeyPluginExecutor: inventory})
}
fields := []zap.Field{
zap.String("execution_id", req.InstanceID), zap.String("environment_id", inventory.EnvironmentID),
zap.String("stage", stage), zap.String("resource_handle", resource.GetResourceHandle()), zap.NamedError("launch_cause", cause),
}
if recordErr != nil {
r.logger.Error("plugin executor resource is unrecorded and its handoff failed",
append(fields, zap.String("cleanup_outcome", "handoff_failed"), zap.Error(recordErr))...)
return withPluginExecutorLaunchCode(cause, errPluginExecutorHandoffUnavailable)
}
r.logger.Error("plugin executor resource is unrecorded; its removal is left to the environment cleanup",
append(fields, zap.String("cleanup_outcome", "handed_off"))...)
return withPluginExecutorLaunchCode(cause, errors.New("plugin executor launch failed; allocated resource was not recorded and will be removed by the environment cleanup"))
}
Keep unsettled handoffs
// remainingUnrecordedPluginExecutors names the handoffs still recorded for the
// environment after the cleanup ran: a resource nothing has confirmed destroyed
// must keep the environment from being reported cleaned.
func (r *PluginRemoteExecutor) remainingUnrecordedPluginExecutors(ctx context.Context, environment *models.TaskEnvironment) []error {
remaining, err := r.inventoryStore.ListUnrecordedPluginExecutors(ctx, environment.TaskID, environment.ID)
if err != nil {
return []error{fmt.Errorf("re-read unrecorded plugin executors: %w", err)}
}
unsettled := make([]error, 0, len(remaining))
for _, item := range remaining {
unsettled = append(unsettled, fmt.Errorf("plugin executor launch %s provisioned a resource that is not confirmed destroyed; cleanup cannot settle", item.ExecutionID))
}
return unsettled
}