feat(cloud-workers): add crabbox worker provider plugin and profile-aware lease lifecycle (#104465)

* feat(cloud-workers): add Crabbox worker provider

* docs(cloud-workers): document Crabbox profiles

* chore(cloud-workers): drop changelog entry (release-only file)

* refactor(plugin-sdk): pass profiles to worker lease lifecycle

* docs(plugin-sdk): document worker lifecycle profiles

* chore(docs): regenerate plugin inventory, docs map, and sdk baseline after rebase

* fix(cloud-workers): state crabbox key-ref gap without warning-comment suppressions
This commit is contained in:
Peter Steinberger
2026-07-11 05:51:56 -07:00
committed by GitHub
parent a66d2959e9
commit e681646834
23 changed files with 1670 additions and 92 deletions

4
.github/labeler.yml vendored
View File

@@ -380,6 +380,10 @@
- any-glob-to-any-file:
- "extensions/clawrouter/**"
- "docs/providers/clawrouter.md"
"extensions: crabbox":
- changed-files:
- any-glob-to-any-file:
- "extensions/crabbox/**"
"extensions: deepseek":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1,2 +1,2 @@
96c016603dd132e8a255554825758330b59669a74e66a940b4f489eeffbddbfd plugin-sdk-api-baseline.json
92494514d548bca63dd95b2b72991ba61fca8cd4c33a779da11d246c0b4b5485 plugin-sdk-api-baseline.jsonl
f2f5f7f14e8ec9be0ce9f59350f5f58f16e1218cd2a2ed61e5175d424649130d plugin-sdk-api-baseline.json
3fe1b2d75bcabff080590b882f3e4055e769bb1dee083a575421bf8c1c8a2f53 plugin-sdk-api-baseline.jsonl

View File

@@ -1450,5 +1450,13 @@
{
"source": "openclaw nodes approve",
"target": "openclaw nodes approve"
},
{
"source": "Cloud workers plan",
"target": "云端工作节点计划"
},
{
"source": "Crabbox plugin",
"target": "Crabbox 插件"
}
]

View File

@@ -3246,6 +3246,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: gateway.tls
- H3: gateway.reload
- H2: Cloud worker environments
- H3: Crabbox profile
- H3: Static SSH development profile
- H2: Hooks
- H3: Gmail integration
- H2: Canvas plugin host
@@ -6018,6 +6020,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Surface
- H2: Related docs
## plugins/reference/crabbox.md
- Route: /plugins/reference/crabbox
- Headings:
- H1: Crabbox plugin
- H2: Distribution
- H2: Surface
- H2: Configure
## plugins/reference/deepgram.md
- Route: /plugins/reference/deepgram
@@ -6949,6 +6960,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Surface
- H2: Related docs
## plugins/reference/workspaces.md
- Route: /plugins/reference/workspaces
- Headings:
- H1: Workspaces plugin
- H2: Distribution
- H2: Surface
## plugins/reference/xai.md
- Route: /plugins/reference/xai

View File

@@ -736,6 +736,47 @@ See [Multiple Gateways](/gateway/multiple-gateways).
Cloud workers are opt-in. If `cloudWorkers` is absent, or `profiles` is empty, OpenClaw accepts no new worker creation. Durable records created earlier still reconcile and remain visible; the existing gateway/node projection is unchanged.
### Crabbox profile
The bundled `crabbox` provider provisions an SSH-capable lease through the local Crabbox CLI. The inner `settings.provider` selects the Crabbox backend; it is separate from the outer OpenClaw provider id.
```json5
{
cloudWorkers: {
profiles: {
production: {
provider: "crabbox",
settings: {
provider: "aws",
class: "standard",
ttl: "24h",
idleTimeout: "60m",
// Optional absolute path. Default: sibling ../crabbox/bin/crabbox, then PATH.
binary: "/usr/local/bin/crabbox",
},
lifetime: {
idleTimeoutMinutes: 60,
maxLifetimeMinutes: 1440,
},
},
},
},
}
```
- `settings.provider` (required): Crabbox backend passed through `--provider`. Use a backend whose inspect output includes an SSH endpoint; `aws` selects the direct AWS backend.
- `settings.class` (required): Crabbox machine class passed to `--class`.
- `settings.ttl` and `settings.idleTimeout` (required): positive Go duration strings passed to `--ttl` and `--idle-timeout`. These provider-side failsafes are distinct from OpenClaw's stored `lifetime` policy below.
- `settings.binary`: optional absolute Crabbox executable path. Without it, OpenClaw checks the sibling Crabbox checkout, then executable entries on `PATH`, and finally invokes `crabbox` so a missing CLI remains a visible provider error.
Unknown settings are rejected. Crabbox credentials and backend-specific account configuration remain owned by Crabbox; do not place them in `settings`. OpenClaw invokes only the local CLI and makes no provider network calls from this plugin. Provisioning always passes `--keep=true`; OpenClaw owns the external lifecycle and destroys the lease with `crabbox stop`.
<Warning>
This milestone surfaces the SSH endpoint and a file `SecretRef`, but the current generic file-secret contract does not resolve Crabbox's dynamic key path, and Crabbox `inspect` does not expose host-key material. The later tunnel milestone must define direct-file key resolution and host-key pinning before connecting; this profile alone is not yet a complete SSH trust boundary.
</Warning>
### Static SSH development profile
```json5
{
cloudWorkers: {
@@ -763,12 +804,14 @@ Cloud workers are opt-in. If `cloudWorkers` is absent, or `profiles` is empty, O
```
- `profiles`: named worker profiles with non-empty, whitespace-trimmed ids. Each profile selects a provider registered by a plugin.
- `provider`: non-empty worker provider id. The example uses the `static-ssh` provider from the QA Lab plugin.
- Bundled provider plugins are enabled automatically when selected. External provider plugins must be installed and explicitly enabled (and included in `plugins.allow` when that allowlist is set).
- `provider`: non-empty worker provider id. The examples use the bundled `crabbox` provider and the QA Lab `static-ssh` provider.
- Bundled provider plugins are selected automatically when configured, but explicit disables and `plugins.allow` still apply. Include the provider id (for example, `crabbox`) when an allowlist is configured. External provider plugins must also be installed and explicitly enabled.
- `settings`: provider-owned bounded JSON. The selected plugin defines and validates its keys; use [SecretRef objects](/gateway/secrets) for secret-bearing values. The static SSH provider requires `host`, `user`, and `keyRef`; `port` defaults to `22`.
- `lifetime.idleTimeoutMinutes`: positive integer minutes stored for later idle-reclamation policy.
- `lifetime.maxLifetimeMinutes`: positive integer minutes stored for later lifecycle policy.
Each durable environment record retains its validated provider settings snapshot for later inspection and destruction. Changing or removing a named profile affects new creates; existing records continue lifecycle reconciliation with their creation-time settings, provided the owning plugin remains available.
Lifetime values are data only in the first cloud-worker release; automatic enforcement lands with later lifecycle work. Profile changes require a gateway restart.
<Warning>

View File

@@ -75,9 +75,9 @@ Provider contract (plugin-implemented; no provider names or policy in core):
type WorkerProvider = {
id: string;
provision(profile: WorkerProfile, opId: string): Promise<WorkerLease>; // → ssh host/port/user/key material
inspect(leaseId: string): Promise<LeaseStatus>; // adopt/health/orphan sweep
inspect(lease: { leaseId: string; profile: WorkerProfile }): Promise<LeaseStatus>; // adopt/health/orphan sweep
renew?(leaseId: string): Promise<void>; // long-lived sessions vs provider TTLs
destroy(leaseId: string): Promise<void>; // idempotent, returns only on proof of teardown
destroy(lease: { leaseId: string; profile: WorkerProfile }): Promise<void>; // idempotent, returns only on proof of teardown
};
```

View File

@@ -133,53 +133,53 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m
## Top-level field reference
| Field | Required | Type | What it means |
| ------------------------------------ | -------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes | `string` | Canonical plugin id. This is the id used in `plugins.entries.<id>`. |
| `configSchema` | Yes | `object` | Inline JSON Schema for this plugin's config. |
| `requiresPlugins` | No | `string[]` | Plugin ids that must also be installed for this plugin to have an effect. Discovery keeps the plugin loadable but warns when any required plugin is missing. |
| `enabledByDefault` | No | `true` | Marks a bundled plugin as enabled by default. Omit it, or set any non-`true` value, to leave the plugin disabled by default. |
| `enabledByDefaultOnPlatforms` | No | `string[]` | Marks a bundled plugin as enabled by default only on the listed Node.js platforms, for example `["darwin"]`. Explicit config still wins. |
| `legacyPluginIds` | No | `string[]` | Legacy ids that normalize to this canonical plugin id. |
| `autoEnableWhenConfiguredProviders` | No | `string[]` | Provider ids that should auto-enable this plugin when auth, config, or model refs mention them. |
| `kind` | No | `PluginKind \| PluginKind[]` | Declares one or more exclusive plugin kinds (`"memory"`, `"context-engine"`) used by `plugins.slots.*`. A plugin that owns both slots declares both kinds in one array. |
| `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. |
| `providers` | No | `string[]` | Provider ids owned by this plugin. |
| `providerCatalogEntry` | No | `string` | Lightweight provider-catalog module path, relative to the plugin root, for manifest-scoped provider catalog metadata that can be loaded without activating the full plugin runtime. |
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `modelCatalog` | No | `object` | Declarative model catalog metadata for providers owned by this plugin. This is the control-plane contract for future read-only listing, onboarding, model pickers, aliases, and suppression without loading plugin runtime. |
| `modelPricing` | No | `object` | Provider-owned external pricing lookup policy. Use it to opt local/self-hosted providers out of remote pricing catalogs or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. |
| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. |
| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. |
| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy before provider runtime loads. |
| `secretProviderIntegrations` | No | `Record<string, object>` | Declarative SecretRef exec provider presets that setup or install surfaces can offer without hardcoding provider-specific integrations in core. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. |
| `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. |
| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Deprecated compatibility env metadata for provider auth/status lookup. Prefer `setup.providers[].envVars` for new plugins; OpenClaw still reads this during the deprecation window. |
| `providerUsageAuthEnvVars` | No | `Record<string, string[]>` | Usage/billing-only provider credentials. OpenClaw uses these names for usage discovery and secret scrubbing but never for inference auth. |
| `providerAuthAliases` | No | `Record<string, string>` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. |
| `channelEnvVars` | No | `Record<string, string[]>` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. |
| `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. |
| `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. |
| `contracts` | No | `object` | Static capability ownership over external auth hooks, embeddings, speech, realtime transcription/voice, media-understanding, image/video/music generation, web fetch, web search, worker providers, document/web-content extraction, and tool ownership. |
| `configContracts` | No | `object` | Manifest-owned config behavior consumed by generic core helpers: dangerous-flag detection, SecretRef migration targets, and legacy config-path narrowing. See [configContracts reference](#configcontracts-reference). |
| `mediaUnderstandingProviderMetadata` | No | `Record<string, object>` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. |
| `imageGenerationProviderMetadata` | No | `Record<string, object>` | Cheap image-generation auth metadata for provider ids declared in `contracts.imageGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `videoGenerationProviderMetadata` | No | `Record<string, object>` | Cheap video-generation auth metadata for provider ids declared in `contracts.videoGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `musicGenerationProviderMetadata` | No | `Record<string, object>` | Cheap music-generation auth metadata for provider ids declared in `contracts.musicGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `toolMetadata` | No | `Record<string, object>` | Cheap availability metadata for plugin-owned tools declared in `contracts.tools`. Use it when a tool should not load runtime unless config, env, or auth evidence exists. |
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
| `name` | No | `string` | Human-readable plugin name. |
| `description` | No | `string` | Short summary shown in plugin surfaces. |
| `catalog` | No | `object` | Optional presentation hints for plugin catalog surfaces. This metadata does not install, enable, or grant trust to a plugin. |
| `icon` | No | `string` | HTTPS image URL for marketplace/catalog cards. ClawHub accepts any valid `https://` URL and falls back to the default plugin icon when this is omitted or invalid. |
| `version` | No | `string` | Informational plugin version. |
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |
| Field | Required | Type | What it means |
| ------------------------------------ | -------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes | `string` | Canonical plugin id. This is the id used in `plugins.entries.<id>`. |
| `configSchema` | Yes | `object` | Inline JSON Schema for this plugin's config. |
| `requiresPlugins` | No | `string[]` | Plugin ids that must also be installed for this plugin to have an effect. Discovery keeps the plugin loadable but warns when any required plugin is missing. |
| `enabledByDefault` | No | `true` | Marks a bundled plugin as enabled by default. Omit it, or set any non-`true` value, to leave the plugin disabled by default. |
| `enabledByDefaultOnPlatforms` | No | `string[]` | Marks a bundled plugin as enabled by default only on the listed Node.js platforms, for example `["darwin"]`. Explicit config still wins. |
| `legacyPluginIds` | No | `string[]` | Legacy ids that normalize to this canonical plugin id. |
| `autoEnableWhenConfiguredProviders` | No | `string[]` | Provider ids that should auto-enable this plugin when auth, config, or model refs mention them. |
| `kind` | No | `PluginKind \| PluginKind[]` | Declares one or more exclusive plugin kinds (`"memory"`, `"context-engine"`) used by `plugins.slots.*`. A plugin that owns both slots declares both kinds in one array. |
| `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. |
| `providers` | No | `string[]` | Provider ids owned by this plugin. |
| `providerCatalogEntry` | No | `string` | Lightweight provider-catalog module path, relative to the plugin root, for manifest-scoped provider catalog metadata that can be loaded without activating the full plugin runtime. |
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `modelCatalog` | No | `object` | Declarative model catalog metadata for providers owned by this plugin. This is the control-plane contract for future read-only listing, onboarding, model pickers, aliases, and suppression without loading plugin runtime. |
| `modelPricing` | No | `object` | Provider-owned external pricing lookup policy. Use it to opt local/self-hosted providers out of remote pricing catalogs or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. |
| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. |
| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. |
| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy before provider runtime loads. |
| `secretProviderIntegrations` | No | `Record<string, object>` | Declarative SecretRef exec provider presets that setup or install surfaces can offer without hardcoding provider-specific integrations in core. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. |
| `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. |
| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Deprecated compatibility env metadata for provider auth/status lookup. Prefer `setup.providers[].envVars` for new plugins; OpenClaw still reads this during the deprecation window. |
| `providerUsageAuthEnvVars` | No | `Record<string, string[]>` | Usage/billing-only provider credentials. OpenClaw uses these names for usage discovery and secret scrubbing but never for inference auth. |
| `providerAuthAliases` | No | `Record<string, string>` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. |
| `channelEnvVars` | No | `Record<string, string[]>` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. |
| `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. |
| `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. |
| `contracts` | No | `object` | Static capability ownership snapshot for external auth hooks, embeddings, speech, realtime transcription, realtime voice, media-understanding, image/video/music generation, web fetch, web search, worker providers, document/web-content extraction, and tool ownership. |
| `configContracts` | No | `object` | Manifest-owned config behavior consumed by generic core helpers: dangerous-flag detection, SecretRef migration targets, and legacy config-path narrowing. See [configContracts reference](#configcontracts-reference). |
| `mediaUnderstandingProviderMetadata` | No | `Record<string, object>` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. |
| `imageGenerationProviderMetadata` | No | `Record<string, object>` | Cheap image-generation auth metadata for provider ids declared in `contracts.imageGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `videoGenerationProviderMetadata` | No | `Record<string, object>` | Cheap video-generation auth metadata for provider ids declared in `contracts.videoGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `musicGenerationProviderMetadata` | No | `Record<string, object>` | Cheap music-generation auth metadata for provider ids declared in `contracts.musicGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `toolMetadata` | No | `Record<string, object>` | Cheap availability metadata for plugin-owned tools declared in `contracts.tools`. Use it when a tool should not load runtime unless config, env, or auth evidence exists. |
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
| `name` | No | `string` | Human-readable plugin name. |
| `description` | No | `string` | Short summary shown in plugin surfaces. |
| `catalog` | No | `object` | Optional presentation hints for plugin catalog surfaces. This metadata does not install, enable, or grant trust to a plugin. |
| `icon` | No | `string` | HTTPS image URL for marketplace/catalog cards. ClawHub accepts any valid `https://` URL and falls back to the default plugin icon when this is omitted or invalid. |
| `version` | No | `string` | Informational plugin version. |
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |
## catalog reference
@@ -610,7 +610,7 @@ Each list is optional:
| `webContentExtractors` | `string[]` | Web-page content-extraction provider ids this plugin owns. |
| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. |
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
| `workerProviders` | `string[]` | Cloud-worker lifecycle provider ids this plugin owns. |
| `workerProviders` | `string[]` | Cloud-worker provider ids this plugin owns for provisioning and profile-backed lease lifecycle. |
| `usageProviders` | `string[]` | Provider ids whose usage-auth and usage-snapshot hooks this plugin owns. |
| `migrationProviders` | `string[]` | Import provider ids this plugin owns for `openclaw migrate`. |
| `gatewayMethodDispatch` | `string[]` | Reserved entitlement for authenticated plugin HTTP routes that dispatch Gateway methods in-process. |
@@ -628,7 +628,7 @@ Provider plugins that implement both `resolveUsageAuth` and `fetchUsageSnapshot`
General embedding providers should declare `contracts.embeddingProviders` for each adapter registered with `api.registerEmbeddingProvider(...)`. Use the general contract for reusable vector generation, including providers consumed by memory search. `contracts.memoryEmbeddingProviders` is deprecated memory-specific compatibility and remains only while existing providers migrate to the generic embedding provider seam.
Worker providers must declare each `api.registerWorkerProvider(...)` id in `contracts.workerProviders`. Core persists durable intent before calling `provision`; providers validate their settings before external allocation, and repeated calls with the same operation id must adopt the same lease. Destruction is idempotent, inspection returns the closed `active` / `destroyed` / `unknown` status union, and SSH key material is referenced only through `SecretRef`. An authoritative `unknown` orphans an active local record; after a persisted destroy request it confirms teardown.
Worker providers must declare each `api.registerWorkerProvider(...)` id in `contracts.workerProviders`. Core persists durable intent before calling `provision`; providers validate their settings before external allocation, and repeated calls with the same operation id must adopt the same lease. Core also persists that validated settings snapshot and passes it with `leaseId` to `inspect({ leaseId, profile })` and `destroy({ leaseId, profile })`, including after the named profile is changed or removed. Destruction is idempotent, inspection returns the closed `active` / `destroyed` / `unknown` status union, and SSH key material is referenced only through `SecretRef`. An authoritative `unknown` orphans an active local record; after a persisted destroy request it confirms teardown.
`contracts.gatewayMethodDispatch` currently accepts `"authenticated-request"`. It is an API hygiene gate for native plugin HTTP routes that intentionally dispatch Gateway control-plane methods in-process, not a sandbox against malicious native plugins. Use it only for tightly reviewed bundled/operator surfaces that already require Gateway HTTP auth. An entitled route remains reachable while Gateway root-work admission is closed only when it also declares `auth: "gateway"` and the route-specific `gatewayRuntimeScopeSurface: "trusted-operator"`; ordinary sibling routes from the same plugin remain behind the admission boundary. This keeps suspension status and resume reachable without granting the whole plugin an admission bypass. Keep parsing and response shaping bounded outside dispatch; substantive or mutating work must go through Gateway method dispatch, which owns admission and scope enforcement.

View File

@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
62 plugins
64 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -77,6 +77,8 @@ Each entry lists the package, distribution route, and description.
- **[copilot-proxy](/plugins/reference/copilot-proxy)** (`@openclaw/copilot-proxy`) - included in OpenClaw. Adds Copilot Proxy model provider support to OpenClaw.
- **[crabbox](/plugins/reference/crabbox)** (`@openclaw/crabbox-provider`) - included in OpenClaw. Cloud worker provider backed by the Crabbox CLI.
- **[deepgram](/plugins/reference/deepgram)** (`@openclaw/deepgram-provider`) - included in OpenClaw. Adds media understanding provider support. Adds realtime transcription provider support.
- **[document-extract](/plugins/reference/document-extract)** (`@openclaw/document-extract-plugin`) - included in OpenClaw. Extract text and fallback page images from local document attachments.
@@ -173,6 +175,8 @@ Each entry lists the package, distribution route, and description.
- **[workboard](/plugins/reference/workboard)** (`@openclaw/workboard`) - included in OpenClaw. Dashboard workboard for agent-owned issues and sessions.
- **[workspaces](/plugins/reference/workspaces)** (`@openclaw/workspaces-plugin`) - included in OpenClaw. Agent-composable Workspaces document and control-plane backend.
- **[xai](/plugins/reference/xai)** (`@openclaw/xai-plugin`) - included in OpenClaw. Adds xAI model provider support to OpenClaw.
- **[xiaomi](/plugins/reference/xiaomi)** (`@openclaw/xiaomi-provider`) - included in OpenClaw. Adds Xiaomi, Xiaomi Token Plan model provider support to OpenClaw.

View File

@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 134
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 136
generated plugin reference pages by distribution, package, and description.

View File

@@ -0,0 +1,27 @@
---
summary: "Cloud worker provider backed by the Crabbox CLI."
read_when:
- You are installing, configuring, or auditing the crabbox plugin
title: "Crabbox plugin"
---
# Crabbox plugin
Cloud worker provider backed by the Crabbox CLI.
## Distribution
- Package: `@openclaw/crabbox-provider`
- Install route: included in OpenClaw
## Surface
contracts: workerProviders
<!-- openclaw-plugin-reference:manual-start -->
## Configure
See [Cloud worker environments](/gateway/configuration-reference#crabbox-profile) for the profile schema and lifecycle notes.
<!-- openclaw-plugin-reference:manual-end -->

View File

@@ -0,0 +1,19 @@
---
summary: "Agent-composable Workspaces document and control-plane backend."
read_when:
- You are installing, configuring, or auditing the workspaces plugin
title: "Workspaces plugin"
---
# Workspaces plugin
Agent-composable Workspaces document and control-plane backend.
## Distribution
- Package: `@openclaw/workspaces-plugin`
- Install route: included in OpenClaw
## Surface
contracts: tools

View File

@@ -110,8 +110,8 @@ methods:
| `api.registerCompactionProvider(...)` | Pluggable transcript-compaction backend |
Worker providers must also declare their id in `contracts.workerProviders`.
Core persists durable intent before `provision(profile, operationId)`. Providers validate settings before external allocation and throw `WorkerProviderError` for permanent profile rejection. `provision` must adopt the same lease when the operation id repeats. `destroy(leaseId)` must be idempotent;
`inspect(leaseId)` returns `active`, `destroyed`, or `unknown`. SSH endpoints use a `SecretRef` for `keyRef`, never inline key material.
Core persists durable intent before `provision(profile, operationId)`. Providers validate settings before external allocation and throw `WorkerProviderError` for permanent profile rejection. `provision` must adopt the same lease when the operation id repeats.
Core persists the validated profile settings with the lease and supplies that snapshot to `destroy({ leaseId, profile })`, which must be idempotent, and `inspect({ leaseId, profile })`, which returns `active`, `destroyed`, or `unknown`. This lets providers route lifecycle calls after a gateway restart or named-profile removal. SSH endpoints use a `SecretRef` for `keyRef`, never inline key material.
Providers with renewable leases can also implement `renew(leaseId)`.
`inspect` must throw on transient or indeterminate failures; return `unknown` only for authoritative absence. Core marks an active local record orphaned, or treats the absence as teardown completion after a persisted destroy request.

View File

@@ -0,0 +1,13 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createCrabboxWorkerProvider, resolveOpenClawRoot } from "./src/crabbox-worker-provider.js";
export default definePluginEntry({
id: "crabbox",
name: "Crabbox Worker Provider",
description: "Cloud worker provider backed by the Crabbox CLI",
register(api) {
api.registerWorkerProvider(
createCrabboxWorkerProvider({ openclawRoot: resolveOpenClawRoot(api.rootDir) }),
);
},
});

View File

@@ -0,0 +1,16 @@
{
"id": "crabbox",
"name": "Crabbox Worker Provider",
"description": "Cloud worker provider backed by the Crabbox CLI.",
"activation": {
"onStartup": false
},
"contracts": {
"workerProviders": ["crabbox"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@openclaw/crabbox-provider",
"version": "2026.7.2",
"private": true,
"description": "OpenClaw cloud worker provider backed by the Crabbox CLI",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,655 @@
import path from "node:path";
import type { WorkerProfile } from "openclaw/plugin-sdk/plugin-entry";
import type { SpawnResult } from "openclaw/plugin-sdk/process-runtime";
import { describe, expect, it } from "vitest";
import {
createCrabboxWorkerProvider,
type CrabboxCommandRunner,
resolveCrabboxBinary,
resolveOpenClawRoot,
} from "./crabbox-worker-provider.js";
const LEASE_ID = "cbx_012345abcdef";
const FALLBACK_LEASE_ID = "cbx_20260711123456123456";
const TESTBOX_LEASE_ID = "tbx_Test-123";
const OPENCLAW_ROOT = path.resolve(path.sep, "workspace", "openclaw");
const SIBLING_BINARY = path.resolve(OPENCLAW_ROOT, "../crabbox/bin/crabbox");
const PROFILE = {
provider: "aws",
class: "standard",
ttl: "24h",
idleTimeout: "60m",
};
function commandResult(overrides: Partial<SpawnResult> = {}): SpawnResult {
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
...overrides,
};
}
function inspectJson(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
id: LEASE_ID,
state: "running",
host: "fallback.example.test",
sshHost: "worker.example.test",
sshPort: "2222",
sshUser: "openclaw",
sshKey: "/tmp/crabbox-worker-key",
ready: true,
...overrides,
});
}
function lifecycleLease(leaseId = LEASE_ID, profile: WorkerProfile = PROFILE) {
return { leaseId, profile };
}
function providerWithRunner(runCommand: CrabboxCommandRunner) {
return createCrabboxWorkerProvider({
runCommand,
openclawRoot: OPENCLAW_ROOT,
pathEnv: "",
isExecutable: (candidate) => candidate === SIBLING_BINARY,
});
}
describe("Crabbox worker provider", () => {
it("provisions, inspects, and returns an SSH lease without invoking a real binary", async () => {
const calls: Array<{ argv: string[]; options: Parameters<CrabboxCommandRunner>[1] }> = [];
const runCommand: CrabboxCommandRunner = async (argv, options) => {
calls.push({ argv, options });
const command = argv[1];
if (command === "warmup") {
return commandResult({ stdout: `leased ${LEASE_ID} slug=test\n` });
}
if (command === "inspect" && argv.includes(LEASE_ID)) {
return commandResult({ stdout: inspectJson() });
}
return commandResult({
code: 4,
stderr: `lease/server not found: ${argv[argv.indexOf("--id") + 1]}`,
});
};
const provider = providerWithRunner(runCommand);
const lease = await provider.provision(PROFILE, "provision:operation-123");
expect(lease).toStrictEqual({
leaseId: LEASE_ID,
ssh: {
host: "worker.example.test",
port: 2222,
user: "openclaw",
keyRef: {
source: "file",
provider: "crabbox",
id: "/~1tmp~1crabbox-worker-key",
},
},
});
expect(calls).toHaveLength(3);
expect(calls[0]?.argv).toEqual([
SIBLING_BINARY,
"inspect",
"--provider",
"aws",
"--id",
expect.stringMatching(/^openclaw-[a-f0-9]{32}$/u),
"--json",
]);
expect(calls[1]?.argv).toEqual([
SIBLING_BINARY,
"warmup",
"--provider",
"aws",
"--class",
"standard",
"--ttl",
"24h",
"--idle-timeout",
"60m",
"--slug",
expect.stringMatching(/^openclaw-[a-f0-9]{32}$/u),
"--keep=true",
]);
expect(calls[1]?.options).toEqual({
timeoutMs: 240_000,
maxOutputBytes: 65_536,
killProcessTree: true,
});
expect(calls[2]?.argv).toEqual([
SIBLING_BINARY,
"inspect",
"--provider",
"aws",
"--id",
LEASE_ID,
"--json",
]);
await expect(provider.inspect(lifecycleLease(lease.leaseId))).resolves.toStrictEqual({
status: "active",
});
expect(calls.at(-1)?.argv).toEqual([
SIBLING_BINARY,
"inspect",
"--provider",
"aws",
"--id",
LEASE_ID,
"--json",
]);
});
it("adopts a lease from the deterministic operation slug on provision replay", async () => {
const calls: string[][] = [];
const runCommand: CrabboxCommandRunner = async (argv) => {
calls.push(argv);
return commandResult({ stdout: inspectJson() });
};
const provider = providerWithRunner(runCommand);
await expect(provider.provision(PROFILE, "provision:operation-replay")).resolves.toMatchObject({
leaseId: LEASE_ID,
});
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual([
SIBLING_BINARY,
"inspect",
"--provider",
"aws",
"--id",
expect.stringMatching(/^openclaw-[a-f0-9]{32}$/u),
"--json",
]);
});
it("accepts Crabbox's timestamp fallback lease id", async () => {
const provider = providerWithRunner(async (argv) => {
if (argv[1] === "warmup") {
return commandResult({ stdout: `leased ${FALLBACK_LEASE_ID} slug=test\n` });
}
if (argv.includes(FALLBACK_LEASE_ID)) {
return commandResult({ stdout: inspectJson({ id: FALLBACK_LEASE_ID }) });
}
return commandResult({
code: 4,
stderr: `lease/server not found: ${argv[argv.indexOf("--id") + 1]}`,
});
});
await expect(provider.provision(PROFILE, "provision:fallback-id")).resolves.toMatchObject({
leaseId: FALLBACK_LEASE_ID,
});
});
it("stops a lease whose Crabbox backend returns an unsupported id", async () => {
const calls: string[][] = [];
const staticLeaseId = "--custom-static-worker";
const runCommand: CrabboxCommandRunner = async (argv) => {
calls.push(argv);
if (argv[1] === "warmup") {
return commandResult({ stdout: `leased ${staticLeaseId} slug=test\n` });
}
if (argv[1] === "stop") {
return commandResult();
}
return commandResult({
code: 4,
stderr: `static lease not found: ${argv[argv.indexOf("--id") + 1]}`,
});
};
const provider = providerWithRunner(runCommand);
await expect(
provider.provision({ ...PROFILE, provider: "ssh" }, "provision:static-worker"),
).rejects.toMatchObject({ code: "invalid_profile" });
expect(calls.at(-1)).toEqual([
SIBLING_BINARY,
"stop",
"--provider",
"ssh",
"--id",
staticLeaseId,
]);
});
it.each([
{
provider: "e2b",
missing: (id: string) =>
commandResult({ code: 4, stderr: `e2b sandbox "${id}" is not claimed by Crabbox` }),
},
{
provider: "coder",
missing: (id: string) =>
commandResult({ code: 5, stderr: `coder workspace "${id}" not found` }),
},
])(
"provisions with $provider after its authoritative slug miss",
async ({ provider, missing }) => {
let warmed = false;
const runCommand: CrabboxCommandRunner = async (argv) => {
if (argv[1] === "warmup") {
warmed = true;
return commandResult({ stdout: `leased ${LEASE_ID} slug=test\n` });
}
if (argv.includes(LEASE_ID)) {
return commandResult({ stdout: inspectJson() });
}
return missing(argv[argv.indexOf("--id") + 1] ?? "");
};
const crabboxProvider = providerWithRunner(runCommand);
await expect(
crabboxProvider.provision({ ...PROFILE, provider }, `provision:${provider}`),
).resolves.toMatchObject({
leaseId: LEASE_ID,
});
expect(warmed).toBe(true);
},
);
it("cleans a terminal operation lease before provisioning its replacement", async () => {
const calls: string[][] = [];
let warmed = false;
const runCommand: CrabboxCommandRunner = async (argv) => {
calls.push(argv);
if (argv[1] === "stop") {
return commandResult();
}
if (argv[1] === "warmup") {
warmed = true;
return commandResult({ stdout: `leased ${LEASE_ID} slug=test\n` });
}
return commandResult({
stdout: inspectJson(warmed ? {} : { ready: false, state: "stopped" }),
});
};
const provider = providerWithRunner(runCommand);
await expect(provider.provision(PROFILE, "provision:replace-terminal")).resolves.toMatchObject({
leaseId: LEASE_ID,
});
expect(calls.map((argv) => argv[1])).toEqual(["inspect", "stop", "warmup", "inspect"]);
});
it("stops a delegated Testbox lease that cannot expose an SSH endpoint", async () => {
const calls: string[][] = [];
const runCommand: CrabboxCommandRunner = async (argv) => {
calls.push(argv);
if (argv[1] === "warmup") {
return commandResult({ stdout: `leased ${TESTBOX_LEASE_ID} slug=test\n` });
}
if (argv[1] === "inspect" && argv.includes(TESTBOX_LEASE_ID)) {
return commandResult({
stdout: inspectJson({
id: TESTBOX_LEASE_ID,
host: "",
sshHost: "",
sshKey: "",
sshPort: "",
sshUser: "",
}),
});
}
if (argv[1] === "stop") {
return commandResult();
}
return commandResult({
code: 4,
stderr: `unknown blacksmith testbox "${argv[argv.indexOf("--id") + 1]}"`,
});
};
const provider = providerWithRunner(runCommand);
await expect(
provider.provision(
{ ...PROFILE, provider: "blacksmith-testbox" },
"provision:testbox-operation",
),
).rejects.toMatchObject({
code: "invalid_profile",
});
expect(calls.at(-1)).toEqual([
SIBLING_BINARY,
"stop",
"--provider",
"blacksmith-testbox",
"--id",
TESTBOX_LEASE_ID,
]);
});
it("rejects a replayed operation lease until it becomes SSH-ready", async () => {
const provider = providerWithRunner(async () =>
commandResult({ stdout: inspectJson({ ready: false }) }),
);
await expect(provider.provision(PROFILE, "provision:operation-pending")).rejects.toThrow(
"lease is not ready",
);
});
it.each([
{ profile: {}, message: "provider" },
{ profile: { ...PROFILE, provider: " " }, message: "provider" },
{ profile: { ...PROFILE, class: 4 }, message: "class" },
{ profile: { ...PROFILE, ttl: "" }, message: "ttl" },
{ profile: { ...PROFILE, ttl: "garbage" }, message: "positive Go duration" },
{ profile: { ...PROFILE, ttl: "0.1ns" }, message: "positive Go duration" },
{
profile: { ...PROFILE, ttl: "999999999999999999999h" },
message: "positive Go duration",
},
{ profile: { ...PROFILE, idleTimeout: false }, message: "idleTimeout" },
{ profile: { ...PROFILE, idleTimeout: "0s" }, message: "positive Go duration" },
{ profile: { ...PROFILE, binary: " " }, message: "binary" },
{ profile: { ...PROFILE, binary: "crabbox" }, message: "absolute path" },
{ profile: { ...PROFILE, typo: true }, message: "unknown" },
])("rejects an invalid profile ($message)", async ({ profile, message }) => {
let invoked = false;
const provider = providerWithRunner(async () => {
invoked = true;
return commandResult();
});
await expect(provider.provision(profile, "provision:invalid")).rejects.toThrow(message);
await expect(provider.provision(profile, "provision:invalid")).rejects.toMatchObject({
code: "invalid_profile",
});
expect(invoked).toBe(false);
});
it("rejects a provider unknown to the Crabbox binary as an invalid profile", async () => {
const provider = providerWithRunner(async () =>
commandResult({ code: 2, stderr: 'unknown provider "missing-provider"' }),
);
await expect(
provider.provision(
{ ...PROFILE, provider: "missing-provider" },
"provision:unknown-provider",
),
).rejects.toMatchObject({
code: "invalid_profile",
});
});
it("rejects a Crabbox backend without warmup support as an invalid profile", async () => {
const provider = providerWithRunner(async (argv) => {
if (argv[1] === "warmup") {
return commandResult({ code: 2, stderr: "provider=wandb does not support warmup" });
}
return commandResult({
code: 4,
stderr: `wandb sandbox "${argv[argv.indexOf("--id") + 1]}" has no matching local ownership claim`,
});
});
await expect(
provider.provision({ ...PROFILE, provider: "wandb" }, "provision:unsupported-provider"),
).rejects.toMatchObject({
code: "invalid_profile",
});
});
it("rejects a Crabbox backend without persistent status as an invalid profile", async () => {
const provider = providerWithRunner(async () =>
commandResult({
code: 2,
stderr:
"provider=windows-sandbox does not expose persistent status; close the Windows Sandbox window",
}),
);
await expect(
provider.provision(
{ ...PROFILE, provider: "windows-sandbox" },
"provision:nonpersistent-provider",
),
).rejects.toMatchObject({
code: "invalid_profile",
});
});
it("rejects a machine class unsupported by the selected Crabbox backend", async () => {
const provider = providerWithRunner(async (argv) => {
if (argv[1] === "warmup") {
return commandResult({
code: 2,
stderr: "--class is not supported for provider=vast; use --vast-gpu-name",
});
}
return commandResult({
code: 4,
stderr: `lease/instance not found: ${argv[argv.indexOf("--id") + 1]}`,
});
});
await expect(
provider.provision({ ...PROFILE, provider: "vast" }, "provision:unsupported-class"),
).rejects.toMatchObject({
code: "invalid_profile",
});
});
it("rejects a one-shot Crabbox backend as an invalid worker profile", async () => {
const provider = providerWithRunner(async () =>
commandResult({
code: 2,
stderr: "provider=mxc is one-shot and does not support status",
}),
);
await expect(
provider.provision({ ...PROFILE, provider: "mxc" }, "provision:one-shot-provider"),
).rejects.toMatchObject({
code: "invalid_profile",
});
});
it("routes lifecycle calls from the passed profile context", async () => {
const binary = path.resolve(path.sep, "custom", "crabbox");
const calls: string[][] = [];
const provider = createCrabboxWorkerProvider({
runCommand: async (argv) => {
calls.push(argv);
return argv[1] === "inspect" ? commandResult({ stdout: inspectJson() }) : commandResult();
},
openclawRoot: OPENCLAW_ROOT,
pathEnv: "",
isExecutable: () => false,
});
const lease = lifecycleLease(LEASE_ID, { ...PROFILE, binary, provider: "coder" });
await expect(provider.inspect(lease)).resolves.toStrictEqual({ status: "active" });
await expect(provider.destroy(lease)).resolves.toBeUndefined();
expect(calls).toEqual([
[binary, "inspect", "--provider", "coder", "--id", LEASE_ID, "--json"],
[binary, "stop", "--provider", "coder", "--id", LEASE_ID],
]);
});
it("rejects non-Crabbox lifecycle lease ids before invoking the CLI", async () => {
let invoked = false;
const provider = providerWithRunner(async () => {
invoked = true;
return commandResult();
});
const lease = lifecycleLease("lease:not-crabbox");
await expect(provider.inspect(lease)).rejects.toThrow("lease id is invalid");
await expect(provider.destroy(lease)).rejects.toThrow("lease id is invalid");
expect(invoked).toBe(false);
});
it.each([
{ state: "running", ready: true, expected: "active" },
{ state: "provisioning", ready: false, expected: "active" },
{ state: "stopped", ready: false, expected: "destroyed" },
{ state: "released", ready: false, expected: "destroyed" },
{ state: "deleted", ready: false, expected: "destroyed" },
{ state: "destroyed", ready: false, expected: "destroyed" },
{ state: "deleting", ready: false, expected: "active" },
{ state: "failed", ready: false, expected: "active" },
])("maps inspect state $state to $expected", async ({ state, ready, expected }) => {
const provider = providerWithRunner(async () =>
commandResult({ stdout: inspectJson({ state, ready }) }),
);
await expect(provider.inspect(lifecycleLease())).resolves.toStrictEqual({
status: expected,
});
});
it("maps only authoritative lease absence to unknown", async () => {
const missing = providerWithRunner(async () =>
commandResult({ code: 4, stderr: `lease/droplet not found: ${LEASE_ID}` }),
);
const authFailure = providerWithRunner(async () =>
commandResult({
code: 4,
stderr: `credential profile not found while inspecting lease ${LEASE_ID}`,
}),
);
const noLongerExists = providerWithRunner(async () =>
commandResult({ code: 4, stderr: `unikraftcloud lease ${LEASE_ID} no longer exists` }),
);
const ambiguousVisibility = providerWithRunner(async () =>
commandResult({
code: 4,
stderr: `nomad job for lease ${LEASE_ID} is missing or inaccessible`,
}),
);
const cliMissing = providerWithRunner(async () => {
throw new Error("spawn ENOENT");
});
const lease = lifecycleLease();
await expect(missing.inspect(lease)).resolves.toStrictEqual({ status: "unknown" });
await expect(noLongerExists.inspect(lease)).resolves.toStrictEqual({ status: "unknown" });
await expect(authFailure.inspect(lease)).rejects.toThrow("inspect failed with exit code 4");
await expect(ambiguousVisibility.inspect(lease)).rejects.toThrow(
"inspect failed with exit code 4",
);
await expect(cliMissing.inspect(lease)).rejects.toThrow("inspect could not start");
});
it("rejects malformed inspect endpoint fields as transient CLI errors", async () => {
const provider = providerWithRunner(async () =>
commandResult({ stdout: inspectJson({ sshPort: true }) }),
);
await expect(provider.inspect(lifecycleLease())).rejects.toThrow("invalid sshPort");
});
it("encodes a Windows SSH key path as a canonical file SecretRef id", async () => {
const provider = providerWithRunner(async () =>
commandResult({
stdout: inspectJson({ sshKey: String.raw`C:\Users\worker\.ssh\id_ed25519` }),
}),
);
await expect(provider.provision(PROFILE, "provision:windows-key")).resolves.toMatchObject({
ssh: {
keyRef: {
id: String.raw`/C:\Users\worker\.ssh\id_ed25519`,
},
},
});
});
it("bounds and redacts CLI failure details", async () => {
const secret = ["sk", "abcdefghijklmnop"].join("-");
const provider = providerWithRunner(async () =>
commandResult({ code: 2, stderr: `${secret} ${"failure ".repeat(200)}` }),
);
const error = await provider.inspect(lifecycleLease()).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
const message = error instanceof Error ? error.message : "";
expect(message).not.toContain(secret);
expect(message.length).toBeLessThan(600);
});
it("destroys absent and already-stopped leases idempotently", async () => {
const calls: string[][] = [];
const runCommand: CrabboxCommandRunner = async (argv) => {
calls.push(argv);
return calls.length === 1
? commandResult({ code: 4, stderr: `lease/server not found: ${LEASE_ID}` })
: commandResult({ code: 4, stderr: `lease ${LEASE_ID} already stopped` });
};
const provider = providerWithRunner(runCommand);
const lease = lifecycleLease();
await expect(provider.destroy(lease)).resolves.toBeUndefined();
await expect(provider.destroy(lease)).resolves.toBeUndefined();
expect(calls).toEqual([
[SIBLING_BINARY, "stop", "--provider", "aws", "--id", LEASE_ID],
[SIBLING_BINARY, "stop", "--provider", "aws", "--id", LEASE_ID],
]);
});
});
describe("Crabbox binary resolution", () => {
it("prefers explicit, then sibling, then PATH, then the bare command", () => {
const toolsDir = path.resolve(path.sep, "tools");
const pathBinary = path.join(toolsDir, "crabbox");
const relativePathBinary = path.resolve("relative-tools", "crabbox");
const explicitBinary = path.resolve(path.sep, "custom", "crabbox");
expect(
resolveCrabboxBinary({
explicit: explicitBinary,
openclawRoot: OPENCLAW_ROOT,
isExecutable: () => false,
}),
).toBe(explicitBinary);
expect(
resolveCrabboxBinary({
openclawRoot: OPENCLAW_ROOT,
pathEnv: toolsDir,
isExecutable: (candidate) => candidate === SIBLING_BINARY || candidate === pathBinary,
}),
).toBe(SIBLING_BINARY);
expect(
resolveCrabboxBinary({
openclawRoot: OPENCLAW_ROOT,
pathEnv: [path.resolve(path.sep, "not-executable"), toolsDir].join(path.delimiter),
isExecutable: (candidate) => candidate === pathBinary,
}),
).toBe(pathBinary);
expect(
resolveCrabboxBinary({
openclawRoot: OPENCLAW_ROOT,
pathEnv: "relative-tools",
isExecutable: (candidate) => candidate === relativePathBinary,
}),
).toBe(relativePathBinary);
expect(
resolveCrabboxBinary({
openclawRoot: OPENCLAW_ROOT,
pathEnv: path.resolve(path.sep, "not-executable"),
isExecutable: () => false,
}),
).toBe("crabbox");
});
it("derives the package root from source and bundled plugin roots", () => {
expect(resolveOpenClawRoot(path.join(OPENCLAW_ROOT, "extensions", "crabbox"))).toBe(
OPENCLAW_ROOT,
);
expect(resolveOpenClawRoot(path.join(OPENCLAW_ROOT, "dist", "extensions", "crabbox"))).toBe(
OPENCLAW_ROOT,
);
});
});

View File

@@ -0,0 +1,726 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import {
WorkerProviderError,
type WorkerLease,
type WorkerLeaseStatus,
type WorkerProfile,
type WorkerProvider,
} from "openclaw/plugin-sdk/plugin-entry";
import { runCommandWithTimeout, type SpawnResult } from "openclaw/plugin-sdk/process-runtime";
export const CRABBOX_WORKER_PROVIDER_ID = "crabbox";
const WARMUP_TIMEOUT_MS = 240_000;
const LIFECYCLE_TIMEOUT_MS = 60_000;
const PROVISION_TIMEOUT_MS = 290_000;
const MAX_OUTPUT_BYTES = 64 * 1024;
const MAX_ERROR_DETAIL_CHARS = 512;
// Only states that prove the resource is gone or stopped map to `destroyed`. Crabbox also
// treats `deleting` and `failed` as unable to become ready, but those can retain resources
// that still need an explicit stop during teardown.
const DESTROYED_STATES = new Set([
"deleted",
"destroyed",
"expired",
"missing",
"released",
"stopped",
"stopped_with_code",
"terminated",
]);
const UNUSABLE_PROVISION_STATES = new Set([...DESTROYED_STATES, "deleting", "failed"]);
const PROFILE_KEYS = new Set(["binary", "class", "idleTimeout", "provider", "ttl"]);
const CRABBOX_LEASE_TOKEN_PATTERN = /^\S{1,128}$/u;
const LEASE_ID_PATTERN = /^(?:cbx_|tbx_)[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
const LEASE_TOKEN_IN_OUTPUT_PATTERN = /^leased\s+(\S{1,128})(?=\s|$)/mu;
const GO_DURATION_PATTERN = /^\+?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|μs|ms|s|m|h))+$/u;
const GO_DURATION_TOKEN_PATTERN = /(\d+(?:\.\d*)?|\.\d+)(ns|us|µs|μs|ms|s|m|h)/gu;
const MAX_GO_DURATION_NANOSECONDS = 9_223_372_036_854_775_807n;
const DURATION_UNIT_NANOSECONDS: Readonly<Record<string, bigint>> = {
h: 3_600_000_000_000n,
m: 60_000_000_000n,
s: 1_000_000_000n,
ms: 1_000_000n,
us: 1_000n,
µs: 1_000n,
μs: 1_000n,
ns: 1n,
};
export type CrabboxCommandRunner = typeof runCommandWithTimeout;
type CrabboxProfile = {
binary?: string;
class: string;
idleTimeout: string;
provider: string;
ttl: string;
};
type CrabboxInspect = {
host?: unknown;
id?: unknown;
ready?: unknown;
sshHost?: unknown;
sshKey?: unknown;
sshPort?: unknown;
sshUser?: unknown;
state?: unknown;
};
type ParsedInspect = {
host?: string;
id: string;
ready?: boolean;
sshKey?: string;
sshPort?: number;
sshUser?: string;
state: string;
};
type LeaseCommandContext = {
binary: string;
id: string;
provider: string;
};
type InspectCommandResult = { status: "found"; inspect: ParsedInspect } | { status: "unknown" };
type IsExecutable = (candidate: string) => boolean;
export type CrabboxWorkerProviderDependencies = {
isExecutable?: IsExecutable;
openclawRoot?: string;
pathEnv?: string;
platform?: NodeJS.Platform;
runCommand?: CrabboxCommandRunner;
};
function nonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function requirePositiveDuration(value: unknown, key: string): string {
const duration = nonEmptyString(value);
if (!duration || !isPositiveGoDuration(duration)) {
throw new WorkerProviderError(
`Crabbox profile ${key} must be a positive Go duration such as 60m`,
);
}
return duration;
}
function isPositiveGoDuration(duration: string): boolean {
if (!GO_DURATION_PATTERN.test(duration)) {
return false;
}
let total = 0n;
for (const match of duration.matchAll(GO_DURATION_TOKEN_PATTERN)) {
const numberText = match[1];
const unit = match[2] ? DURATION_UNIT_NANOSECONDS[match[2]] : undefined;
if (!numberText || unit === undefined) {
return false;
}
const [wholeText = "", fractionText = ""] = numberText.split(".", 2);
const whole = wholeText.replace(/^0+/u, "") || "0";
if (whole.length > 19) {
return false;
}
total += BigInt(whole) * unit;
const fraction = fractionText.slice(0, 18);
if (fraction) {
total += (BigInt(fraction) * unit) / 10n ** BigInt(fraction.length);
}
if (total > MAX_GO_DURATION_NANOSECONDS) {
return false;
}
}
return total > 0n;
}
function parseProfile(profile: WorkerProfile): CrabboxProfile {
for (const key of Object.keys(profile)) {
if (!PROFILE_KEYS.has(key)) {
throw new WorkerProviderError(`unknown Crabbox profile setting: ${key}`);
}
}
const provider = nonEmptyString(profile.provider);
const machineClass = nonEmptyString(profile.class);
if (!provider) {
throw new WorkerProviderError("Crabbox profile provider must be a non-empty string");
}
if (!machineClass) {
throw new WorkerProviderError("Crabbox profile class must be a non-empty string");
}
const ttl = requirePositiveDuration(profile.ttl, "ttl");
const idleTimeout = requirePositiveDuration(profile.idleTimeout, "idleTimeout");
const binaryValue = profile.binary;
const binary = binaryValue === undefined ? undefined : nonEmptyString(binaryValue);
if (binaryValue !== undefined && !binary) {
throw new WorkerProviderError("Crabbox profile binary must be a non-empty string");
}
if (binary && !path.isAbsolute(binary)) {
throw new WorkerProviderError("Crabbox profile binary must be an absolute path");
}
return { binary, class: machineClass, idleTimeout, provider, ttl };
}
function defaultIsExecutable(candidate: string, platform: NodeJS.Platform): boolean {
try {
if (!fs.statSync(candidate).isFile()) {
return false;
}
fs.accessSync(candidate, platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function binaryCandidates(base: string, platform: NodeJS.Platform): string[] {
if (platform !== "win32") {
return [base];
}
return [".exe", ".cmd", ".bat", ".com", ""].map((suffix) => `${base}${suffix}`);
}
export function resolveCrabboxBinary(params: {
explicit?: string;
isExecutable?: IsExecutable;
openclawRoot: string;
pathEnv?: string;
platform?: NodeJS.Platform;
}): string {
if (params.explicit) {
return params.explicit;
}
const platform = params.platform ?? process.platform;
const isExecutable =
params.isExecutable ?? ((candidate) => defaultIsExecutable(candidate, platform));
const siblingBase = path.resolve(params.openclawRoot, "../crabbox/bin/crabbox");
for (const candidate of binaryCandidates(siblingBase, platform)) {
if (isExecutable(candidate)) {
return candidate;
}
}
const delimiter = platform === "win32" ? ";" : ":";
const executableNames = binaryCandidates("crabbox", platform);
for (const directory of (params.pathEnv ?? "").split(delimiter)) {
if (!directory) {
continue;
}
for (const name of executableNames) {
const candidate = path.resolve(directory, name);
if (isExecutable(candidate)) {
return candidate;
}
}
}
return "crabbox";
}
export function resolveOpenClawRoot(pluginRoot: string | undefined): string {
if (!pluginRoot) {
return process.cwd();
}
const extensionsDir = path.dirname(pluginRoot);
if (path.basename(extensionsDir) !== "extensions") {
return process.cwd();
}
const extensionParent = path.dirname(extensionsDir);
return path.basename(extensionParent) === "dist" ||
path.basename(extensionParent) === "dist-runtime"
? path.dirname(extensionParent)
: extensionParent;
}
function operationSlug(operationId: string): string {
return `openclaw-${createHash("sha256").update(operationId).digest("hex").slice(0, 32)}`;
}
function commandDetail(result: SpawnResult): string {
const raw = (result.stderr || result.stdout).trim();
if (!raw) {
return "";
}
const redacted = redactSensitiveText(raw).replace(/\s+/gu, " ").slice(0, MAX_ERROR_DETAIL_CHARS);
return redacted ? `: ${redacted}` : "";
}
function commandError(action: string, result: SpawnResult): Error {
if (result.termination !== "exit") {
return new Error(`Crabbox ${action} did not exit normally (${result.termination})`);
}
const exitCode = result.code === null ? "unknown" : String(result.code);
return new Error(`Crabbox ${action} failed with exit code ${exitCode}${commandDetail(result)}`);
}
function provisionProfileError(result: SpawnResult): WorkerProviderError | undefined {
if (result.termination !== "exit" || result.code !== 2) {
return undefined;
}
const output = `${result.stderr}\n${result.stdout}`;
if (/\bunknown provider\s+"[^"\r\n]+"/u.test(output)) {
return new WorkerProviderError(
"Crabbox profile provider is not supported by this Crabbox binary",
);
}
if (/\bprovider=\S+\s+does not support warmup\b/u.test(output)) {
return new WorkerProviderError("Crabbox profile provider does not support warmup");
}
if (/\bprovider=\S+.*\bdoes not support status\b/u.test(output)) {
return new WorkerProviderError("Crabbox profile provider does not support worker leases");
}
if (/\bprovider=\S+\s+does not expose persistent status\b/u.test(output)) {
return new WorkerProviderError("Crabbox profile provider does not support worker leases");
}
if (/\bprovider=\S+\s+is one-shot; use crabbox run\b/u.test(output)) {
return new WorkerProviderError("Crabbox profile provider is run-only");
}
if (/\bprovider=\S+\s+requires module source; use crabbox run --script\b/u.test(output)) {
return new WorkerProviderError("Crabbox profile provider requires a run script");
}
if (/--class is not supported for provider=\S+/u.test(output)) {
return new WorkerProviderError("Crabbox profile class is not supported by its provider");
}
return undefined;
}
function authoritativeLeaseAbsence(result: SpawnResult, identifier: string): boolean {
const output = `${result.stderr}\n${result.stdout}`;
if (!output.includes(identifier)) {
return false;
}
if (
/\b(?:access\s+denied|authentication|authorization|credentials?|forbidden|permission|token|unauthorized)\b/iu.test(
output,
)
) {
return false;
}
return (
(result.code === 4 && /\b(?:was\s+)?not found\b/iu.test(output)) ||
(result.code === 4 && /\bno longer exists\b/iu.test(output)) ||
(result.code === 4 &&
/\b(?:points to|is bound to) (?:a )?missing (?:instance|sandbox)\b/iu.test(output)) ||
(result.code === 4 && /\bdisappeared before release\b/iu.test(output)) ||
(result.code === 4 && /\bunknown blacksmith testbox(?:\s|:)/iu.test(output)) ||
(result.code === 4 && /\bis not claimed by Crabbox\b/iu.test(output)) ||
(result.code === 4 &&
/\bwandb sandbox "[^"\r\n]+" has no matching local ownership claim\b/iu.test(output)) ||
(result.code === 5 && /\bcoder workspace "[^"\r\n]+" not found\b/iu.test(output)) ||
/\bcoordinator GET \S*\/v1\/leases\/\S+:\s*http 404\b/iu.test(output) ||
(result.code === 4 && /\bunknown lease(?:\s|:)/iu.test(output))
);
}
function alreadyStopped(result: SpawnResult, identifier: string): boolean {
const output = `${result.stderr}\n${result.stdout}`;
return (
output.includes(identifier) &&
/\balready (?:destroyed|released|stopped|terminated)\b/iu.test(output)
);
}
async function runCrabboxCommand(params: {
action: string;
args: string[];
binary: string;
runCommand: CrabboxCommandRunner;
timeoutMs: number;
}): Promise<SpawnResult> {
try {
return await params.runCommand([params.binary, ...params.args], {
timeoutMs: params.timeoutMs,
maxOutputBytes: MAX_OUTPUT_BYTES,
killProcessTree: true,
});
} catch {
throw new Error(`Crabbox ${params.action} could not start`);
}
}
function parseInspectJson(stdout: string): ParsedInspect {
let value: CrabboxInspect;
try {
const parsed: unknown = JSON.parse(stdout);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("inspect output is not an object");
}
value = parsed as CrabboxInspect;
} catch {
throw new Error("Crabbox inspect returned invalid JSON");
}
const id = nonEmptyString(value.id);
const state = nonEmptyString(value.state)?.toLowerCase();
if (!id || !CRABBOX_LEASE_TOKEN_PATTERN.test(id) || !state) {
throw new Error("Crabbox inspect returned an invalid lease identity or state");
}
if (value.ready !== undefined && typeof value.ready !== "boolean") {
throw new Error("Crabbox inspect returned an invalid ready state");
}
const sshHost = inspectString(value.sshHost, "sshHost");
const fallbackHost = inspectString(value.host, "host");
const host = sshHost ?? fallbackHost;
const sshUser = inspectString(value.sshUser, "sshUser");
const sshKey = inspectString(value.sshKey, "sshKey");
const sshPort = inspectPort(value.sshPort);
return {
id,
state,
...(host ? { host } : {}),
...(sshUser ? { sshUser } : {}),
...(sshKey ? { sshKey } : {}),
...(sshPort ? { sshPort } : {}),
...(typeof value.ready === "boolean" ? { ready: value.ready } : {}),
};
}
function inspectString(value: unknown, field: string): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`Crabbox inspect returned an invalid ${field}`);
}
return nonEmptyString(value);
}
function inspectPort(value: unknown): number | undefined {
if (value === undefined || value === "") {
return undefined;
}
if (typeof value !== "number" && (typeof value !== "string" || !/^\d+$/u.test(value))) {
throw new Error("Crabbox inspect returned an invalid sshPort");
}
const port = typeof value === "number" ? value : Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error("Crabbox inspect returned an invalid sshPort");
}
return port;
}
async function inspectWithContext(params: {
classifyProfileErrors?: boolean;
context: Omit<LeaseCommandContext, "id">;
expectedLeaseId?: string;
id: string;
runCommand: CrabboxCommandRunner;
timeoutMs?: number;
}): Promise<InspectCommandResult> {
const result = await runCrabboxCommand({
action: "inspect",
args: ["inspect", "--provider", params.context.provider, "--id", params.id, "--json"],
binary: params.context.binary,
runCommand: params.runCommand,
timeoutMs: params.timeoutMs ?? LIFECYCLE_TIMEOUT_MS,
});
if (result.termination === "exit" && result.code === 0) {
const inspect = parseInspectJson(result.stdout);
if (params.expectedLeaseId && inspect.id !== params.expectedLeaseId) {
throw new Error("Crabbox inspect returned a different lease id");
}
return { status: "found", inspect };
}
if (result.termination === "exit" && authoritativeLeaseAbsence(result, params.id)) {
return { status: "unknown" };
}
if (params.classifyProfileErrors) {
const profileError = provisionProfileError(result);
if (profileError) {
throw profileError;
}
}
throw commandError("inspect", result);
}
function remainingProvisionTimeout(deadline: number, maximum: number): number {
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new Error("Crabbox provision exceeded its provider deadline");
}
return Math.min(maximum, remaining);
}
async function stopWithContext(params: {
context: LeaseCommandContext;
runCommand: CrabboxCommandRunner;
timeoutMs?: number;
}): Promise<void> {
const result = await runCrabboxCommand({
action: "stop",
args: ["stop", "--provider", params.context.provider, "--id", params.context.id],
binary: params.context.binary,
runCommand: params.runCommand,
timeoutMs: params.timeoutMs ?? LIFECYCLE_TIMEOUT_MS,
});
if (result.termination === "exit" && result.code === 0) {
return;
}
if (
result.termination === "exit" &&
(authoritativeLeaseAbsence(result, params.context.id) ||
alreadyStopped(result, params.context.id))
) {
return;
}
throw commandError("stop", result);
}
function isTerminalState(state: string): boolean {
return DESTROYED_STATES.has(state.toLowerCase());
}
function isUnusableProvisionState(state: string): boolean {
return UNUSABLE_PROVISION_STATES.has(state.toLowerCase());
}
function statusFromInspect(inspect: ParsedInspect): WorkerLeaseStatus {
if (isTerminalState(inspect.state)) {
return { status: "destroyed" };
}
// `ready` is a short SSH probe, not lease existence. A recognized nonterminal lease remains
// active while it is provisioning or temporarily unreachable, even when ready is false.
return { status: "active" };
}
function toSecretRefId(filePath: string): string {
if (!path.isAbsolute(filePath) && !path.win32.isAbsolute(filePath)) {
throw new Error("Crabbox inspect returned a non-absolute SSH key path");
}
return `/${filePath.replace(/~/gu, "~0").replace(/\//gu, "~1")}`;
}
function leaseFromInspect(inspect: ParsedInspect): WorkerLease {
if (isTerminalState(inspect.state)) {
throw new Error("Crabbox operation lease is no longer active");
}
if (inspect.ready !== true) {
throw new Error("Crabbox operation lease is not ready");
}
if (!inspect.host || !inspect.sshUser || !inspect.sshPort || !inspect.sshKey) {
throw new WorkerProviderError(
"Crabbox profile provider does not expose a complete SSH worker endpoint",
);
}
return {
leaseId: inspect.id,
ssh: {
host: inspect.host,
port: inspect.sshPort,
user: inspect.sshUser,
// Inspect exposes the private-key path but no host-key material yet; the tunnel
// milestone (docs/plan/cloud-workers.md, PR 4) owns dynamic file-key resolution
// and host-key pinning for this SecretRef.
keyRef: {
source: "file",
provider: CRABBOX_WORKER_PROVIDER_ID,
id: toSecretRefId(inspect.sshKey),
},
},
};
}
async function leaseFromProvisionInspect(params: {
binary: string;
deadline: number;
inspect: ParsedInspect;
provider: string;
runCommand: CrabboxCommandRunner;
}): Promise<WorkerLease> {
try {
return leaseFromInspect(params.inspect);
} catch (error) {
if (!(error instanceof WorkerProviderError)) {
throw error;
}
await stopProvisionInspect(params);
throw error;
}
}
async function stopProvisionInspect(params: {
binary: string;
deadline: number;
inspect: ParsedInspect;
provider: string;
runCommand: CrabboxCommandRunner;
}): Promise<void> {
await stopWithContext({
context: { binary: params.binary, id: params.inspect.id, provider: params.provider },
runCommand: params.runCommand,
timeoutMs: remainingProvisionTimeout(params.deadline, LIFECYCLE_TIMEOUT_MS),
});
}
export function createCrabboxWorkerProvider(
dependencies: CrabboxWorkerProviderDependencies = {},
): WorkerProvider {
const runCommand = dependencies.runCommand ?? runCommandWithTimeout;
const openclawRoot = dependencies.openclawRoot ?? process.cwd();
let defaultBinary: string | undefined;
const resolveBinary = (explicit?: string) => {
if (explicit) {
return explicit;
}
defaultBinary ??= resolveCrabboxBinary({
explicit,
isExecutable: dependencies.isExecutable,
openclawRoot,
pathEnv: dependencies.pathEnv ?? process.env.PATH,
platform: dependencies.platform,
});
return defaultBinary;
};
const resolveLeaseContext = (
lease: Parameters<WorkerProvider["inspect"]>[0],
): LeaseCommandContext => {
const parsed = parseProfile(lease.profile);
if (!LEASE_ID_PATTERN.test(lease.leaseId)) {
throw new Error("Crabbox lease id is invalid");
}
return {
binary: resolveBinary(parsed.binary),
id: lease.leaseId,
provider: parsed.provider,
};
};
return {
id: CRABBOX_WORKER_PROVIDER_ID,
async provision(profile: WorkerProfile, operationId: string): Promise<WorkerLease> {
const deadline = Date.now() + PROVISION_TIMEOUT_MS;
const parsed = parseProfile(profile);
if (!operationId.trim()) {
throw new Error("Crabbox provision requires an operation id");
}
const binary = resolveBinary(parsed.binary);
const context = { binary, provider: parsed.provider };
const slug = operationSlug(operationId);
// Crabbox suffixes colliding slugs. Probe the deterministic operation slug first so a
// replay after a lost warmup reply adopts the allocated lease instead of duplicating it.
const existing = await inspectWithContext({
classifyProfileErrors: true,
context,
id: slug,
runCommand,
timeoutMs: remainingProvisionTimeout(deadline, LIFECYCLE_TIMEOUT_MS),
});
if (existing.status === "found") {
const existingParams = {
binary,
deadline,
inspect: existing.inspect,
provider: parsed.provider,
runCommand,
};
if (!LEASE_ID_PATTERN.test(existing.inspect.id)) {
await stopProvisionInspect(existingParams);
throw new WorkerProviderError(
"Crabbox profile provider returned an unsupported lease id",
);
}
if (isUnusableProvisionState(existing.inspect.state)) {
await stopProvisionInspect(existingParams);
} else {
return await leaseFromProvisionInspect(existingParams);
}
}
const warmup = await runCrabboxCommand({
action: "warmup",
args: [
"warmup",
"--provider",
parsed.provider,
"--class",
parsed.class,
"--ttl",
parsed.ttl,
"--idle-timeout",
parsed.idleTimeout,
"--slug",
slug,
"--keep=true",
],
binary,
runCommand,
timeoutMs: remainingProvisionTimeout(deadline, WARMUP_TIMEOUT_MS),
});
if (warmup.termination !== "exit" || warmup.code !== 0) {
const profileError = provisionProfileError(warmup);
if (profileError) {
throw profileError;
}
throw commandError("warmup", warmup);
}
const allocatedId = `${warmup.stdout}\n${warmup.stderr}`.match(
LEASE_TOKEN_IN_OUTPUT_PATTERN,
)?.[1];
if (!allocatedId) {
throw new Error("Crabbox warmup did not return a lease id");
}
if (!LEASE_ID_PATTERN.test(allocatedId)) {
await stopWithContext({
context: { binary, id: allocatedId, provider: parsed.provider },
runCommand,
timeoutMs: remainingProvisionTimeout(deadline, LIFECYCLE_TIMEOUT_MS),
});
throw new WorkerProviderError("Crabbox profile provider returned an unsupported lease id");
}
const inspected = await inspectWithContext({
context,
expectedLeaseId: allocatedId,
id: allocatedId,
runCommand,
timeoutMs: remainingProvisionTimeout(deadline, LIFECYCLE_TIMEOUT_MS),
});
if (inspected.status === "unknown") {
throw new Error("Crabbox warmup lease was not found during inspection");
}
const inspectedParams = {
binary,
deadline,
inspect: inspected.inspect,
provider: parsed.provider,
runCommand,
};
if (isUnusableProvisionState(inspected.inspect.state)) {
await stopProvisionInspect(inspectedParams);
throw new Error("Crabbox warmup lease entered a terminal state");
}
return await leaseFromProvisionInspect(inspectedParams);
},
async inspect(lease): Promise<WorkerLeaseStatus> {
const context = resolveLeaseContext(lease);
const inspected = await inspectWithContext({
context,
expectedLeaseId: context.id,
id: context.id,
runCommand,
});
if (inspected.status === "unknown") {
return { status: "unknown" };
}
return statusFromInspect(inspected.inspect);
},
async destroy(lease): Promise<void> {
const context = resolveLeaseContext(lease);
await stopWithContext({ context, runCommand });
},
};
}

View File

@@ -11,6 +11,11 @@ const KEY_REF = {
provider: "default",
id: "/cloud-workers/development/private-key",
};
const PROFILE = {
host: "worker.example.test",
user: "openclaw",
keyRef: KEY_REF,
};
describe("QA Lab static-SSH worker provider", () => {
it("provisions a deterministic logical lease with the default SSH port", async () => {
@@ -112,19 +117,24 @@ describe("QA Lab static-SSH worker provider", () => {
it("reports only its deterministic lease ids as active", async () => {
const provider = createStaticSshWorkerProvider();
await expect(provider.inspect("static-ssh:operation-123")).resolves.toStrictEqual({
await expect(
provider.inspect({ leaseId: "static-ssh:operation-123", profile: PROFILE }),
).resolves.toStrictEqual({
status: "active",
});
await expect(provider.inspect("static-ssh:")).resolves.toStrictEqual({ status: "unknown" });
await expect(provider.inspect("other:operation-123")).resolves.toStrictEqual({
status: "unknown",
});
await expect(
provider.inspect({ leaseId: "static-ssh:", profile: PROFILE }),
).resolves.toStrictEqual({ status: "unknown" });
await expect(
provider.inspect({ leaseId: "other:operation-123", profile: PROFILE }),
).resolves.toStrictEqual({ status: "unknown" });
});
it("destroys logical leases idempotently", async () => {
const provider = createStaticSshWorkerProvider();
await expect(provider.destroy("static-ssh:operation-123")).resolves.toBeUndefined();
await expect(provider.destroy("static-ssh:operation-123")).resolves.toBeUndefined();
const lease = { leaseId: "static-ssh:operation-123", profile: PROFILE };
await expect(provider.destroy(lease)).resolves.toBeUndefined();
await expect(provider.destroy(lease)).resolves.toBeUndefined();
});
});

View File

@@ -51,7 +51,7 @@ export function createStaticSshWorkerProvider(): WorkerProvider {
ssh: parseStaticSshWorkerSettings(profile),
};
},
async inspect(leaseId) {
async inspect({ leaseId }) {
const active =
leaseId.startsWith(STATIC_SSH_LEASE_PREFIX) &&
leaseId.length > STATIC_SSH_LEASE_PREFIX.length;
@@ -59,6 +59,6 @@ export function createStaticSshWorkerProvider(): WorkerProvider {
},
// Development-only: a static worker is a shared host, not an isolation boundary.
// Destroy releases the logical lease; it does not stop or clean the host.
async destroy() {},
async destroy(_lease) {},
};
}

6
pnpm-lock.yaml generated
View File

@@ -591,6 +591,12 @@ importers:
specifier: workspace:*
version: link:../..
extensions/crabbox:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/deepgram:
devDependencies:
'@openclaw/plugin-sdk':

View File

@@ -26,6 +26,8 @@ const SSH_ENDPOINT: WorkerSshEndpoint = {
keyRef: { source: "file", provider: "worker-keys", id: "/development-key" },
};
type WorkerLifecycleLease = Parameters<WorkerProvider["inspect"]>[0];
describe("worker environment service", () => {
let root: string;
let store: WorkerEnvironmentStore;
@@ -273,12 +275,13 @@ describe("worker environment service", () => {
},
);
it("adopts a persisted in-flight lease through provider inspection", async () => {
it("inspects a persisted lease with its profile snapshot after profile removal", async () => {
seedBootstrapping("worker-crash");
const inspected: string[] = [];
config.cloudWorkers!.profiles = {};
const inspected: WorkerLifecycleLease[] = [];
const provider = createProvider({
inspect: async (leaseId) => {
inspected.push(leaseId);
inspect: async (lease) => {
inspected.push(lease);
return { status: "active" };
},
provision: async () => {
@@ -288,7 +291,7 @@ describe("worker environment service", () => {
await createService(provider).reconcileOnce();
expect(inspected).toEqual(["lease:worker-crash"]);
expect(inspected).toEqual([{ leaseId: "lease:worker-crash", profile: { region: "test" } }]);
expect(store.get("worker-crash")).toMatchObject({
state: "ready",
});
@@ -310,7 +313,7 @@ describe("worker environment service", () => {
to: "destroying",
});
const provider = createProvider({
inspect: async (leaseId) => {
inspect: async ({ leaseId }) => {
if (leaseId !== "lease:worker-transient") {
return { status: "unknown" };
}
@@ -383,11 +386,12 @@ describe("worker environment service", () => {
it("keeps a failed destroy retryable and makes completed destroy idempotent", async () => {
seedReady("worker-destroy");
config.cloudWorkers!.profiles = {};
let fail = true;
const destroyed: string[] = [];
const destroyed: WorkerLifecycleLease[] = [];
const provider = createProvider({
destroy: async (leaseId) => {
destroyed.push(leaseId);
destroy: async (lease) => {
destroyed.push(lease);
if (fail) {
throw new Error("destroy timeout");
}
@@ -407,7 +411,10 @@ describe("worker environment service", () => {
await workerService.reconcileOnce();
expect(store.get("worker-destroy")).toMatchObject({ state: "destroyed" });
await workerService.destroy("worker-destroy");
expect(destroyed).toEqual(["lease:worker-destroy", "lease:worker-destroy"]);
expect(destroyed).toEqual([
{ leaseId: "lease:worker-destroy", profile: { region: "test" } },
{ leaseId: "lease:worker-destroy", profile: { region: "test" } },
]);
});
it("adopts an unpersisted provision result before destroying", async () => {
@@ -423,19 +430,19 @@ describe("worker environment service", () => {
from: "requested",
to: "provisioning",
});
const destroyed: string[] = [];
const destroyed: WorkerLifecycleLease[] = [];
const provider = createProvider({
provision: async () => {
expect(store.get(intent.environmentId)?.destroyRequestedAtMs).not.toBeNull();
return { leaseId: "lease-1", ssh: SSH_ENDPOINT };
},
destroy: async (leaseId) => void destroyed.push(leaseId),
destroy: async (lease) => void destroyed.push(lease),
});
const result = await createService(provider).destroy(intent.environmentId);
expect(result.state).toBe("destroyed");
expect(destroyed).toEqual(["lease-1"]);
expect(destroyed).toEqual([{ leaseId: "lease-1", profile: { region: "test" } }]);
});
it("retains teardown intent across an indeterminate provision failure", async () => {
@@ -452,7 +459,7 @@ describe("worker environment service", () => {
to: "provisioning",
});
let provisionFails = true;
const destroyed: string[] = [];
const destroyed: WorkerLifecycleLease[] = [];
const provider = createProvider({
provision: async () => {
if (provisionFails) {
@@ -460,7 +467,7 @@ describe("worker environment service", () => {
}
return { leaseId: "lease-retried", ssh: SSH_ENDPOINT };
},
destroy: async (leaseId) => void destroyed.push(leaseId),
destroy: async (lease) => void destroyed.push(lease),
});
const workerService = createService(provider);
@@ -482,7 +489,7 @@ describe("worker environment service", () => {
provisionFails = false;
await workerService.reconcileOnce();
expect(store.get(intent.environmentId)?.state).toBe("destroyed");
expect(destroyed).toEqual(["lease-retried"]);
expect(destroyed).toEqual([{ leaseId: "lease-retried", profile: { region: "test" } }]);
});
it("reconciles unrelated leases concurrently", async () => {
@@ -492,10 +499,10 @@ describe("worker environment service", () => {
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const inspected: string[] = [];
const inspected: WorkerLifecycleLease[] = [];
const provider = createProvider({
inspect: async (leaseId) => {
inspected.push(leaseId);
inspect: async (lease) => {
inspected.push(lease);
await blocked;
return { status: "active" };
},
@@ -509,7 +516,7 @@ describe("worker environment service", () => {
}
await reconciliation;
expect(new Set(inspected)).toEqual(
expect(new Set(inspected.map(({ leaseId }) => leaseId))).toEqual(
new Set(["lease:worker-concurrent-a", "lease:worker-concurrent-b"]),
);
});

View File

@@ -133,6 +133,12 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService
"Worker provider operation",
);
// Durable profile settings keep lifecycle routing stable across config edits and restarts.
const lifecycleLease = (record: WorkerEnvironmentRecord, leaseId: string) => ({
leaseId,
profile: requireWorkerProfile(record.profileSnapshot.settings),
});
const providerFor = (providerId: string): WorkerProvider => {
const provider = options.resolveProvider(providerId);
if (provider) {
@@ -192,7 +198,7 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService
const leaseId = r.leaseId;
const destroying = beginDestroy(r);
try {
await callProvider(() => provider.destroy(leaseId));
await callProvider(() => provider.destroy(lifecycleLease(r, leaseId)));
} catch (error) {
saveError(destroying, error);
throw serviceError("provider_failure", "Worker provider operation failed");
@@ -219,7 +225,7 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService
}
return;
}
const status = await callProvider(() => provider.inspect(leaseId))
const status = await callProvider(() => provider.inspect(lifecycleLease(record, leaseId)))
.then(inspectionStatus)
.catch((error: unknown) => {
saveError(record, error);

View File

@@ -1307,10 +1307,10 @@ export type WorkerProvider = {
*/
provision: (profile: WorkerProfile, operationId: string) => Promise<WorkerLease>;
/** Throws on transient/indeterminate failures; `unknown` means authoritative absence. */
inspect: (leaseId: string) => Promise<WorkerLeaseStatus>;
inspect: (lease: { leaseId: string; profile: WorkerProfile }) => Promise<WorkerLeaseStatus>;
renew?: (leaseId: string) => Promise<void>;
/** Idempotent; resolves only after the provider can prove teardown. */
destroy: (leaseId: string) => Promise<void>;
destroy: (lease: { leaseId: string; profile: WorkerProfile }) => Promise<void>;
};
/** Text-inference provider capability registered by a plugin. */