Authoring A Provider

September 21, 2026 · View on GitHub

Read this when:

  • adding a new Crabbox provider end to end;
  • porting a hosted runner or sandbox service into Crabbox;
  • learning what core owns versus what your backend owns.

This page is the step-by-step guide. The contract reference for backend interfaces, registration, and the review checklist lives in Provider backends. Read this page first, then keep that reference open as a checklist while you implement.

What A Provider Does

A Crabbox provider answers four questions:

  1. What execution model does the provider expose?
  2. What targets and capabilities can it satisfy?
  3. How does it acquire, resolve, list, and release a runner?
  4. What flags and config does it own that core does not?

Everything else — command parsing, sync, command streaming, recorded runs, heartbeats, slugs, claims, list/status rendering, JSON output — belongs to core. A provider that needs to fork those concerns is fighting the design.

The interfaces below live in internal/cli/provider_backend.go. The provider package imports that package (conventionally aliased core) and implements against it.

Step 1. Pick The Backend Shape

Two execution models exist, selected by ProviderSpec.Kind:

  • ProviderKindSSHLease — the provider hands Crabbox a real SSH target. Core owns sync, command streaming, results, heartbeats, and release. Use this when you can populate SSHTarget with host, port, user, key, work root, and target OS.
  • ProviderKindDelegatedRun — the provider owns command execution and streams output back to Crabbox. Use this when you cannot give Crabbox a stable SSH contract (for example Blacksmith Testbox, E2B, Modal, Upstash).

For hosted APIs that need a purpose-built runner model, deployment, or sandbox image, define that runner against the Delegated runner contract before adding the provider. A built-in provider should not advertise a usable integration when the remote runner schema, upload path, command result mapping, and live proof bar are still undecided.

If you can give Crabbox SSH, prefer the SSH lease backend. The CLI has more invested in the SSH path, including Actions hydration, VNC, code-server, screenshot, and cache stats/warm/purge. A delegated backend cannot reuse those without a stable connection contract.

CapabilitySSH leaseDelegated run
crabbox runyesyes
crabbox warmupyesyes
crabbox sshyesonly if you implement short-lived SSH
crabbox vnc / codeyes (Linux + capability)no
crabbox webvnccoordinator-backed or local-container onlyno
crabbox actions hydrateyes (Linux)no
crabbox cache stats / purge / warmyesno
Crabbox-owned syncyesno — your backend owns sync
Coordinator supportoptionalnot used

Step 2. Lay Out The Package

Built-in providers live under internal/providers/<name>:

internal/providers/example/
  provider.go      # Provider type, init() registration, Spec()
  backend.go       # SSH lease or delegated run implementation
  flags.go         # provider-specific flag struct (optional)
  example.go       # API client, helpers, types
  example_test.go  # backend tests, no live calls

Then add the side-effect import in internal/providers/all/all.go:

import _ "github.com/openclaw/crabbox/internal/providers/example"

cmd/crabbox/main.go already imports internal/providers/all, so nothing else needs to change for the binary to see the new provider.

Same-package tests in internal/cli cannot import provider adapters because that creates an import cycle. Register a synthetic provider there only to test core dispatch. To test actual provider policy, use an external cli_test package, which can import and register the real adapter without an import cycle. Use scoped backend injection when needed to keep execution local; do not reproduce the adapter's policy in a fake provider.

Step 3. Register The Provider

A provider is a small struct that satisfies core.Provider:

package example

import (
	"flag"

	core "github.com/openclaw/crabbox/internal/cli"
)

func init() {
	core.RegisterProvider(Provider{})
}

type Provider struct{}

func (Provider) Spec() core.ProviderSpec {
	return core.ProviderSpec{
		Name:    "example",
		Family:  "example",
		Kind:    core.ProviderKindSSHLease,
		Targets: []core.TargetSpec{{OS: core.TargetLinux}},
		Features: core.FeatureSet{
			core.FeatureSSH,
			core.FeatureCrabboxSync,
			core.FeatureCleanup,
		},
		Coordinator: core.CoordinatorNever,
	}
}

func (Provider) RegisterFlags(*flag.FlagSet, core.Config) any {
	return core.NoProviderFlags()
}

func (Provider) ApplyFlags(*core.Config, *flag.FlagSet, any) error {
	return nil
}

func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
	return newBackend(p.Spec(), cfg, rt)
}

ProviderSpec.Name is the canonical name used in docs, config (provider: example), and the --provider flag. RegisterProvider registers it plus every entry in ProviderSpec.Aliases and panics on a duplicate, so keep names unique. Aliases are for compatibility — Blacksmith uses blacksmith as an alias for blacksmith-testbox. Do not invent aliases for new providers; pick one canonical name.

Spec() owns identity and capabilities. Return stable metadata without side effects: core reads it during registration and selection before configuration.

Step 4. Be Honest In Spec

ProviderSpec is command-facing metadata. Help text, target validation, and feature gating all read from it.

type ProviderSpec struct {
	Name        string
	Family      string
	Kind        ProviderKind
	Targets     []TargetSpec
	Features    FeatureSet
	Coordinator CoordinatorMode
}

Rules:

  • Kind must match the real execution model. Do not declare ProviderKindSSHLease if you cannot return a usable SSHTarget.
  • Family groups related providers that share config and flag routing (for example azure and azure-dynamic-sessions share Family: "azure"). Set it to the canonical name when the provider stands alone.
  • Targets lists only OS combinations you support end to end, as TargetSpec{OS, WindowsMode}. OS values are core.TargetLinux, core.TargetMacOS, and core.TargetWindows; Windows entries use core.WindowsModeNormal or core.WindowsModeWSL2. Hetzner is linux only. AWS lists Linux, Windows (normal and wsl2), and macOS. Static SSH lists all three but does no setup; the host must already match.
  • Features lists concrete capabilities from the Feature constants:
    • FeatureSSH — plain SSH access works.
    • FeatureCrabboxSync — core can rsync a git manifest into the runner.
    • FeatureArchiveSync — delegated backend accepts an archive-based sync (relaxes some of the delegated sync-option rejections; see Step 6).
    • FeatureCleanup — implement CleanupBackend for orphan cleanup.
    • FeatureDesktop, FeatureBrowser, FeatureCode — lease can host a visible desktop, a browser, or a code-server instance. FeatureDesktop enables native crabbox vnc; WebVNC also needs the coordinator portal or the trusted local-container noVNC path.
    • FeatureTailscale — lease can join a tailnet via cloud-init / --tailscale.
    • FeatureURLBridge — delegated backend can expose a forwarded URL.
    • FeatureCheckpoint, FeatureFork, FeatureRestore, FeatureSnapshot — provider-native workspace/VM state operations beyond the generic local ledger (the constant values are workspace-checkpoint, workspace-fork, workspace-restore, provider-snapshot).
    • FeatureRunProof — delegated backend can return bounded stream/timing proof metadata.
    • FeatureRunSession — exposes a provider-neutral run-session handle. Delegated backends may return a validated handle in RunResult. An explicitly opted-in SSH-lease provider that also advertises FeatureSSH and FeatureCleanup may instead have core emit the handle after recording the exact lease claim. This SSH-lease contract is opt-in; AWS and local-container currently use it. Core owns handle construction and output timing. Brokered coordinator run IDs can resolve to signed terminal receipts; direct run IDs are correlation identifiers only.
    • FeatureMCP — delegated backend can attach MCP server references when it creates a sandbox. This is a create-time attachment contract, not a generic Crabbox MCP host.
  • Coordinator is CoordinatorSupported only when the shared coordinator provider adapter can provision your runners. Today that is aws, azure, daytona, gcp, and hetzner. Everything else — all delegated run backends and Static SSH — sets CoordinatorNever. Even a CoordinatorSupported provider runs direct from the CLI unless a broker URL is configured (see Coordinator).

--actions-runner requires an SSH lease backend on a linux or windows target. Set ProviderSpec.ActionsRunnerUnsupported when the provider cannot host the native GitHub Actions runner, as local-container, apple-container, and multipass do. This restriction does not disable ordinary Actions hydration. Set target=linux only on a backend that can actually satisfy it.

Versioned workspace features describe provider depth, not the presence of Crabbox checkpoint commands. Core can always record a generic checkpoint from repo metadata, logs, and artifacts. Set the checkpoint-related flags only when you can preserve or recreate state the generic ledger cannot, such as sandbox filesystem state, VM snapshot IDs, or copy-on-write forks.

The provider matrix normalizes those flags into a workspace capability array: checkpoint, fork, restore, and snapshot-ref. New providers should make that normalized contract true instead of adding provider-specific command branches. For example, a future forkable microVM runtime should declare the same capabilities as any other provider that can checkpoint and fork workspace state; its Firecracker, Kubernetes, or image identifiers belong inside the adapter and checkpoint metadata.

Run evidence and agent attachments follow the same rule. Declare FeatureRunProof, FeatureRunArtifacts, FeatureRunDownloads, FeatureURLBridge, FeatureRunSession, and FeatureMCP only when the provider really exposes those contracts. crabbox providers --json normalizes run evidence as proof, artifacts, downloads, preview-url, and session, and crabbox providers recommend run-evidence ignores session-only providers.

Step 5. Own Provider-Specific Flags

Go's flag package rejects unknown flags, so provider flags must be registered before parse and applied only after a provider is selected.

type exampleFlagValues struct {
	Region *string
}

func (Provider) RegisterFlags(fs *flag.FlagSet, defaults core.Config) any {
	return exampleFlagValues{
		Region: fs.String("example-region", defaults.Example.Region, "Example region"),
	}
}

func (Provider) ApplyFlags(cfg *core.Config, fs *flag.FlagSet, values any) error {
	v, ok := values.(exampleFlagValues)
	if !ok {
		return nil
	}
	if core.FlagWasSet(fs, "example-region") {
		cfg.Example.Region = *v.Region
	}
	return nil
}

Conventions:

  • Prefix every flag name with the provider name (--example-region, --aws-region). Crabbox does not gate flag visibility per provider, so the prefix is the only thing keeping namespaces clean.
  • RegisterFlags must be cheap and side-effect free. It runs for every registered provider on every command, even when that provider is not selected.
  • Apply only flags that were explicitly set, using core.FlagWasSet. Otherwise zero values from one command overwrite intentional config from another.
  • For providers that need rich config but no flags, return core.NoProviderFlags() from RegisterFlags and ignore the values in ApplyFlags.
  • Provider families that route between members can implement ProviderRouter (RouteConfig) and ProviderRoutingFlagProvider (RoutingFlagNames) so a family-shared flag selects the right sibling. Most new providers do not need this.
  • Providers with flags valid only while creating a lease should implement ProviderCreationOnlyFlagProvider (CreationOnlyFlagNames). Both routing and creation-only names must refer to flags registered by that provider; providers describe fails closed if the contracts drift.

crabbox providers describe observes each real RegisterFlags call while registering the complete run flag set against baseConfig(). Do not maintain a second flag inventory. Standard string, bool, int, int64, float64, and time.Duration values are discoverable automatically. A custom repeatable string value must implement flag.Getter and return a defensive []string copy:

type stringListFlag []string

func (f *stringListFlag) String() string { return strings.Join(*f, ",") }
func (f *stringListFlag) Set(value string) error {
	*f = append(*f, value)
	return nil
}
func (f *stringListFlag) Get() any {
	return append([]string{}, (*f)...)
}

Unsupported custom values make discovery fail rather than guessing a type or serializing String() output. Keep RegisterFlags side-effect free: discovery calls it for every provider, but never calls ApplyFlags, Configure, config loading, clients, credentials, or provider state.

When renaming a flag, register the compatibility spelling beside the canonical flag and call core.MarkFlagDeprecated(fs, "old-name", "new-name") immediately in the same registration function. The annotation validates that both real flags exist, preserves normal help and parsing behavior, and supplies deprecation/replacement metadata without a detached provider-name or flag table. Keep precedence in ApplyFlags; canonical-wins behavior is an execution contract, not metadata inference.

Never accept secrets as flag arguments. Pull them from environment variables, SDK config, the broker, or the operator's credential store. Flags are visible in shell history, process listings, and recorded run logs.

Provider-native configuration defaults belong in ProviderConfigDefaulter's ApplyConfigDefaults hook. Core calls it after input parsing and portable-OS preprocessing, then normally normalizes and validates the target. Providers whose existing command boundary retains native defaults after target normalization implement ProviderConfigDefaultsPhase and return ProviderConfigDefaultsCallerFinalizes. This leaves finalization with the caller: config loading normalizes afterward, while command paths may already have normalized before defaults. The zero/default phase retains dispatcher normalization and validation. Both phases are config-only; neither may acquire runners or inspect native runtime state. Use core provenance accessors to preserve explicit inputs; ApplyLinuxConnectionDefaults restores explicit connection settings when applying Linux defaults across provider changes. Keep acquisition-only validation deferred: DigitalOcean and Linode preserve an unresolved explicit portable image until backend construction captures the error, before filling runtime fallbacks. Passive config-display hooks must not invoke configuration-default phases. Implement ProviderConfigShowNormalizer for narrow, selected-provider display projections; use ApplyConfigShowSSHDefaults when projecting conventional SSH defaults without changing explicit connection inputs or provider-native configuration. Provider-owned config-show sections may derive pure effective display values from the supplied Config, including inactive providers' displayed work roots. They must not call ApplyConfigDefaults, load configuration, read environment or native state, resolve credentials, or mutate the supplied configuration. Selected top-level projections still belong in ProviderConfigShowNormalizer and require actionable provider selection.

Step 6. Implement The Backend

Pick the interface that matches the kind you declared. Both embed Backend, which only requires Spec() ProviderSpec.

SSH Lease Backend

type SSHLeaseBackend interface {
	Backend
	Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error)
	Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error)
	List(ctx context.Context, req ListRequest) ([]LeaseView, error)
	ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error
	Touch(ctx context.Context, req TouchRequest) (Server, error)
}

Acquire is the heavy lifter. A complete implementation:

  1. validates direct-mode prerequisites (credentials, region, image);
  2. accepts the lease ID from req or generates one if the provider needs it;
  3. ensures or installs the per-lease SSH key with the provider;
  4. provisions the machine or sandbox with Crabbox labels/tags;
  5. waits for the provider to assign an address;
  6. populates SSHTarget with host, port, user, key, work root, target OS, and any Windows mode;
  7. waits for SSH readiness when the provider owns boot;
  8. flips provider labels/tags to ready;
  9. returns the populated LeaseTarget.

Resolve handles crabbox run --id, crabbox ssh --id, and similar reuse paths. Accept canonical lease IDs; accept slugs and provider-native IDs when you can. Return the stored per-lease SSH key when available so reuse does not need a fresh key.

Direct adapters with claim-only recovery may use shared.ResolveProviderClaimStrict for the provider/scope-bound local lookup: it checks an exact claim first and never lets a canonical lease ID fall through to an unrelated slug. Keep provider-native resource-ID lookup explicit in the adapter. Use shared.ValidateClaimBinding only for common structural fields and required labels, and carry the returned full claim as the exact snapshot for later fenced updates. Recovery phases, account or key authorization, live resource validation, and every deletion decision remain adapter-owned.

Optional core.AbsenceVerifier observes the exact claim-bound resource without mutating it. Return zero evidence for a present resource, an error for uncertain absence, or AbsenceEvidence containing the unchanged claim and both proof flags after verifying scope, exact structured not-found, and complete unfiltered inventory where available. Core owns local forgetting through ForgetAbsentLeaseClaim, including the exclusive claim fence and exclusions for fixed, checkpoint, coordinator, and adapter owners. Targeted stop --force uses this capability; OrdinaryStopAbsenceRecovery additionally opts in an existing ordinary-stop contract. Report ReleaseLeaseOutcome.ForgottenLocally when an adapter's release entry point delegates to this transaction, so core skips release cleanup and reports local forgetting distinctly.

Use shared.RemoveExactClaimAfterContext for exact-claim terminal cleanup and pass the same lifecycle context that its provider action uses. There is no implicit background-context variant: waiting for the claim fence must honor the operation's cancellation policy. An acquisition rollback that intentionally outlives the acquisition must choose its independent context explicitly, without silently changing the provider's existing cleanup budget. A successful action still completes durable claim retirement after cancellation; do not add a post-action cancellation check that strands already-confirmed cleanup.

List returns []LeaseView (a type alias for Server). Do not print from List — core renders the table.

Claim-publication helpers initialize a missing idle policy, but preserve an already-recorded positive idle duration during ordinary direct-lease preparation, repository reclaim, and endpoint publication. Their duration argument is not implicit replacement intent. Explicit idle changes belong to the run/Touch policy path; managed coordinator projections use the resolved server's idle_timeout_secs label rather than the command's configured default. This also keeps acquisition finalization from reinitializing a policy already published by the provider's first acquisition step.

Touch updates idle/state metadata on the provider when possible. Use the internal/cli/provider_labels.go helpers for safe label encoding. The optional TouchRequest.IdleTimeoutOverride carries replacement intent: nil preserves the current lease timeout, while a non-nil value replaces it. Do not infer replacement intent from the effective IdleTimeout fallback.

The adapter owns the metadata-write client and resource identifier; do not infer a different provider from an identifier's shape. For existing best-effort touch paths, shared.DirectSSHBackend.Touch accepts the complete touch request, preserves its explicit idle-timeout replacement intent, and shares label updates and warning output while its callback performs the provider-specific write.

Static providers must commit touched lifecycle labels and any explicit timeout replacement to their durable local claim. Resolve must reconstruct lifecycle state from that claim, and Touch must compare-and-swap the exact canonical claim under its claim lock after revalidating provider, scope, resource, and host identity. Updating only the returned in-memory Server makes heartbeat success disappear in a fresh process and is not sufficient.

Providers whose exact ownership scope is captured from a runtime rather than derived statically from config may implement the narrow optional capability:

type StatusTouchClaimAuthorizer interface {
	AuthorizeStatusTouchClaim(context.Context, LeaseTarget, LeaseClaim) error
}

Core first resolves an exact claim for the canonical provider, then delegates the complete status/heartbeat authorization decision to this method. The backend must fail closed unless the lease ID, provider, non-empty current scope, live resource, and every provider-owned runtime identity field match. If authorization requires hydrating a recorded context or endpoint, validate that captured route and its live immutable identity before returning nil. This hook must not adopt, rewrite, or otherwise repair a claim.

Provider lease operations that must serialize across processes should use shared.LockLeaseOperation. Keep provider-specific lease ID validation and any claim-namespace preparation in the adapter; the shared helper preserves the claim-locks/<leaseID>.<provider>-operation.lock location and locking mechanics.

ReleaseLease is called when a lease ends or expires. Make it idempotent; treat "not found" as success. Remove local claims and the per-lease key directory after the provider release succeeds.

Repeated lifecycle reads should use shared.Poll from internal/providers/shared. The caller owns any bounded or detached context and maps its deadline or cancellation cause to provider diagnostics. The helper only retains the last successful typed value, counts attempts, waits between reads, and invokes adapter checks and progress. Keep state strings, retryability, normalization, ownership checks, provider actions, claim updates, cleanup, and error wording in the adapter.

For bounded acquisition reads that stop at the first fetch error and return no partial value on failure, use shared.PollReady. It owns the child timeout and distinguishes that deadline from caller cancellation and immediate client deadlines. Supply the provider's readiness predicate and nonnil timeout error; the interval, request construction, state interpretation, and diagnostic remain adapter-owned. Use Poll directly when retryability, progress, last-observation retention, or detached-context policy differs.

Vanilla provider HTTP redirect guards should use shared.SecureHTTPClient and core.SameHTTPOrigin. The shared policy compares scheme and hostname case-insensitively, normalizes the default HTTP and HTTPS ports, preserves an injected redirect hook, and otherwise retains the standard 10-redirect cap. The adapter still builds its exact provider-specific refusal error. Keep a provider-local guard when it enforces additional path, method, transport, or previous-hop policy, or when its origin semantics differ.

If cleanup is meaningful, also implement:

type CleanupBackend interface {
	Backend
	Cleanup(ctx context.Context, req CleanupRequest) error
}

Adapters using the common crabbox.provider, crabbox.scope and crabbox.claim sandbox metadata can use shared.ValidateSandboxOwnershipMetadata. It preserves exact marker comparisons and the existing missing-ID and mismatch errors. Endpoint admission, requested-resource ID binding and different provider marker schemes remain adapter-owned; this check is not a replacement for those policies.

Delegated adapters can use shared.RefreshRetainedLeaseActivity in their Retained callback to refresh an existing claim while still holding the provider operation lock. It preserves the stored scope, pond and repository, treats a missing claim as a no-op, and delegates idle-timeout policy to core. It does not perform admission or reclaim; provider-specific warnings and activation conditions stay in the adapter.

Delegated adapters that expire local claims by last activity should use shared.ClaimIdleCleanupDue. It preserves the shared idle-deadline decision and skip reasons, including disabled timeouts and invalid timestamps. Absolute TTL rules, recovery deadlines, ownership validation, and deletion authorization remain adapter-owned; an idle deadline alone does not authorize cleanup.

Cleanup must honor CleanupRequest.DryRun, log every skip/delete decision to rt.Stderr, and filter by Crabbox labels so it never touches unrelated machines. When a broker is configured, core refuses to call provider cleanup at all — brokered cleanup belongs to the coordinator scheduler.

Adapters with explicit cleanup decisions can use shared.DirectCleanupDecision to apply a server deletion, recovery continuation, or confirmed-missing claim retirement behind one dry-run boundary. Discover and validate candidates before applying the decision; put mutating provider preparation, recovery writes, and key removal inside its mutation callback. Missing-resource policy and recovery eligibility remain adapter-owned. Azure and GCP use this boundary without changing the ordinary DirectSSHBackend.CleanupServers contract.

For claim-authorized providers built on shared.DirectSSHBackend, use its opt-in PrepareCleanup hook after the shared expiration/keep gate. Preparation may read the account, live resource, and exact local claim even during dry-run, but it must not mutate claims or provider resources. Return a prepared Server carrying the full revisioned claim snapshot and a typed skip reason when it is not eligible. Shared cleanup conservatively reapplies the same expiration/keep gate to the refreshed server and the carried claim before delete or dry-run output. The transitional CleanupEligible hook is only for unmigrated providers and cannot be configured alongside PrepareCleanup.

Deletion must fail closed without that snapshot. Revalidate adapter-owned live identity and recovery rules, durably CAS any required pre-delete recovery bind, then call RemoveLeaseClaimIfUnchangedAfter with the updated exact claim and a provider-delete action. Never call a claim API from inside that action: the claim lock intentionally spans the provider mutation so renewal, reclaim, and other claim updates wait until deletion and durable claim removal finish. Provider failures must retain the claim and local key for retry. Avoid a validate-then-act sequence with an unlocked provider delete followed by claim removal; that sequence can delete a lease whose claim changed after validation.

Delegated Run Backend

type DelegatedRunBackend interface {
	Backend
	Warmup(ctx context.Context, req WarmupRequest) error
	Run(ctx context.Context, req RunRequest) (RunResult, error)
	List(ctx context.Context, req ListRequest) ([]LeaseView, error)
	Status(ctx context.Context, req StatusRequest) (StatusView, error)
	Stop(ctx context.Context, req StopRequest) error
}

Warmup should validate workflow/config, create or warm the provider resource, claim it locally with the provider name and slug, and print the standard warmup summary.

Run should:

  1. reject Crabbox sync options the provider cannot honor:
    if err := core.RejectDelegatedSyncOptionsForSpec(p.Spec(), req); err != nil {
        return core.RunResult{}, err
    }
    
    RejectDelegatedSyncOptionsForSpec reads Spec().Features, so declaring FeatureArchiveSync, FeatureRunProof, etc. relaxes the matching rejections. Pass a spec without those features for the strict behavior.
  2. acquire a resource or resolve an existing id/slug;
  3. claim or reclaim the resource for the calling repo;
  4. stream provider output through rt.Stdout and rt.Stderr;
  5. return RunResult with command duration, exit code, and SyncDelegated: true;
  6. stop temporary resources when Keep is false.

Archive-based providers configure a core.ArchiveWorkspace using core.NewArchiveWorkspace(cfg, rt, req, providerName, workdir). Core owns preparation, guardrails, transfer timing, workspace replacement, and temporary-archive cleanup, using /tmp as the default remote archive directory. Adapters supply upload and execution callbacks and any provider-specific cleanup context or replacement behavior. Return this workspace from DelegatedSandboxLifecycle.Workspace; the run owner prepares the local archive before acquisition and calls Sync or Ensure after admission. Bind the upload and execution callbacks to the current resource inside the workspace factory, without contacting the provider during construction. Use CleanWorkdir for an adapter's path rules and Replace for mounted workspace replacement. Keep native synchronization or operation-wide claim fencing in shared.WorkspaceOperations when those contracts need a different sequence.

Status returns a normalized StatusView. If the provider only emits a table, parse it inside the backend and return structured fields — do not print the native table.

Stop should stop the provider resource, remove local claims, and remove per-resource keys the backend created.

Delegated backends should refuse crabbox ssh, vnc, webvnc, screenshot, code, and Actions hydration unless the provider can keep Crabbox's security boundary intact across those flows.

Optional Backends

  • DoctorBackend — add a Doctor method to the ordinary backend so crabbox doctor --provider <name> returns structured DoctorCheck items. Core discovers this capability automatically on the selected provider. Add a DoctorProvider.ConfigureDoctor override only when diagnostics must bypass acquisition-only validation or use a different backend.
  • JSONListBackend — add ListJSON only when a script-facing JSON shape already exists and callers depend on it. This is a compatibility escape hatch; new providers should return normalized []LeaseView from List and let core render JSON.

Step 7. Use The Runtime

Use core.ValidShellEnvName for the portable ASCII environment-name grammar and core.IsShellEnvAssignment to recognize a leading NAME=value argument. These helpers do not trim names or validate values. Keep the adapter's policy for invalid names, value restrictions, allowlists, quoting, and transport local.

Backends receive a narrow runtime instead of touching package-level state:

type Runtime struct {
	Stdout io.Writer
	Stderr io.Writer
	Clock  Clock
	HTTP   *http.Client
	Exec   CommandRunner
}

Rules:

  • Use rt.Exec.Run(ctx, core.LocalCommandRequest{...}) for every subprocess. Never call exec.CommandContext directly. Tests pass a fake CommandRunner to assert on argv without spawning real processes.
  • Use shared/procjson only for a strict, bounded subprocess that accepts one JSON request and returns exactly one JSON response. It is not for noisy CLI output, streaming or NDJSON, or operations whose side effects are ambiguous; adapters still own response validation, redaction, and error classification.
  • Use rt.Clock.Now() for timing inside the backend. The default is wall-clock; tests can pass a fake clock for deterministic timing assertions.
  • Use rt.Stdout and rt.Stderr for streaming and warnings. Do not write directly to os.Stdout / os.Stderr.
  • Use shared.LocalCommandError when a failed tool keeps its nonzero exit code (zero becomes one) and reports trimmed stderr before stdout. Providers with different exit, redaction, or cause-wrapping contracts retain their own policy.
  • Use rt.HTTP for outbound HTTP when the provider has a JSON API. Tests can inject a stubbed transport.

Anything that bypasses the runtime breaks tests and parallel safety.

Step 8. Hand-Off Boundaries

The most common review feedback on a new provider is "this belongs in core." Use this map:

ConcernOwned by
--provider, --target, --id, --profile parsingcore
Config precedence (flags → env → repo → user → defaults)core
Friendly slug generation, normalization, collisionscore
Local claim files and --reclaim behaviorcore
SSH key creation and storage under user configcore
crabbox-ready readiness waitcore
Repo manifest, fingerprints, rsync, sanity checkscore
Heartbeats, idle expiry mathcore (broker) or core direct labels
Recorded runs, retained logs, telemetry samplescore
List/status table rendering and JSON outputcore
Provider lifecycle (create, delete, list, label)provider
Provider-native auth (SDK config, env, CLI tokens)provider
Translating provider state into normalized lease viewsprovider
Rejecting unsupported delegated optionsprovider (via core helper)

If your provider needs to own one of the core-owned concerns, raise it in the PR description. The fix is usually a small core helper, not a fork.

Step 9. Test Without Live Credentials

Land the provider with tests that prove the contract without hitting a real account. Cover:

  • Provider registration: the canonical name resolves through ProviderFor, declared aliases resolve, Spec() returns the right kind/targets/features, and flag values apply only when that provider is selected.
  • SSH lease backends: Acquire populates a complete LeaseTarget, partial failures release what they created, Resolve accepts the supported lookup shapes, List returns normalized views, Touch updates state/idle, and ReleaseLease is idempotent. If you implement Cleanup, assert dry-run prints decisions and does not call destructive APIs.
  • Delegated run backends: unsupported sync options are rejected, a fresh Run acquires/streams/stops, an existing --id resolves and reuses, List and Status parse provider output into normalized values, Stop removes claims, and every subprocess goes through rt.Exec.

Use the existing fakes:

  • a recording CommandRunner for argv assertions;
  • a fake Clock for timing;
  • an http.RoundTripper test transport for API calls;
  • a per-provider test client where the provider has a typed SDK.

Run at least:

go test -count=1 ./internal/cli ./internal/providers/...
go test -race -timeout=20m ./...
go vet ./...
scripts/check-docs.sh

Add a live smoke only when the provider can be exercised cheaply with explicit credentials. Wire it into scripts/live-smoke.sh so it runs in the same place as the others.

Step 10. Document The Provider

Three doc surfaces care about a new provider:

  • docs/providers/<name>.md — one page in the provider reference. Use the existing pages as a template: target matrix, config keys, env vars, sync behavior, expected failures.
  • docs/features/<name>.md — a feature page when the provider has interesting semantics worth a separate read (capacity fallback, sandbox lifecycle, workflow integration). Skip it when the reference page already covers it.
  • docs/source-map.md — add the new package paths under "Providers And Runner Bootstrap" so the source map keeps tracking implementation truth.

Also add the provider to:

  • docs/providers/provider-metadata.json, including selection, lifecycle, cleanup, and caveat metadata;
  • the index in docs/features/README.md if you added a feature page;
  • the related-doc lists at the bottom of any pages you cross-link from.

Run node scripts/generate-provider-matrix.mjs to regenerate the provider decision matrix in docs/providers/README.md; do not edit the generated table by hand. Then run scripts/check-docs.sh before pushing — it builds the CLI, validates the provider metadata and command/help surface, checks every internal link, and rebuilds the docs site.

Step 11. Ship The PR

A reviewable provider PR includes:

  • a folder under internal/providers/<name> with provider.go, backend.go, helpers, and tests;
  • registration in internal/providers/all/all.go;
  • doc pages in docs/providers/<name>.md and (optionally) docs/features/<name>.md;
  • index updates in docs/providers/README.md, docs/features/README.md, and docs/source-map.md;
  • tests that pass without live credentials;
  • a CHANGELOG entry under Unreleased describing the new provider.

Keep the diff focused. If you find yourself touching run.go, repo.go, coordinator.go, or provider_backend.go, stop and check whether the change is really provider-specific or whether it should be a shared helper landed in a separate PR.

External Process Plugins

External provider plugins are not implemented yet. Do not add a provider that depends on an undocumented stdio protocol. The intended direction is:

  • a built-in Go provider package configures and launches the external process;
  • the process speaks JSON over stdio for capabilities, acquire, resolve, list, release, touch, run, status, and stop;
  • the Go side adapts that to an SSH lease or delegated run backend;
  • core commands still own list/status rendering and SSH workflows where the provider exposes them.

When that protocol exists, a plugin will look like a normal registered provider to the rest of Crabbox.

Bounded ready-pool access

CloudProvider.poolAccess() is an optional capability separate from typed image identity. Core journals grants, generations, receipt hashes, immutable deadlines, and cleanup intent in transactions; adapters perform external mutations after those transactions commit. Implement enroll, install, and revoke from worker/src/ready-pool-access.ts. Bind every operation to the immutable resource and lease. A replay or delayed install must not restore a revoked generation.

revoke must prove both exact key removal and active-session fencing, or confirmed resource destruction. An accepted API request is insufficient. Enrollment must establish guest expiry enforcement that survives coordinator outages and guest reboot. AWS uses SSM, root-owned generation tombstones, persistent expiry timers, and observed boot-ID changes. Whole-instance reboot cannot preserve a replacement grant's sessions, so v1 has no in-place renewal. Unsupported adapters must leave this capability absent.