fix: auto-load bundled plugin capabilities from config refs

This commit is contained in:
Peter Steinberger
2026-03-26 19:14:34 +00:00
parent 8f1716ae5a
commit ab4de18982
16 changed files with 403 additions and 5 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: preserve the post-compaction AGENTS refresh on stale-usage preflight compaction for both immediate replies and queued followups. (#49479) Thanks @jared596.
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Plugins/CLI backends: move bundled Claude CLI, Codex CLI, and Gemini CLI inference defaults onto the plugin surface, add bundled Gemini CLI backend support, and replace `gateway run --claude-cli-logs` with generic `--cli-backend-logs` while keeping the old flag as a compatibility alias.
- Plugins/startup: auto-load bundled provider and CLI-backend plugins from explicit config refs, so bundled Claude CLI, Codex CLI, and Gemini CLI message-provider setups no longer need manual `plugins.allow` entries.
### Fixes

View File

@@ -54,6 +54,11 @@ command path:
Thats it. No keys, no extra auth config needed beyond the CLI itself.
If you use a bundled CLI backend as the **primary message provider** on a
gateway host, OpenClaw now auto-loads the owning bundled plugin when your config
explicitly references that backend in a model ref or under
`agents.defaults.cliBackends`.
## Using it as a fallback
Add a CLI backend to your fallback list so it only runs when primary models fail:

View File

@@ -78,6 +78,7 @@ Those belong in your plugin code and `package.json`.
"description": "OpenRouter provider plugin",
"version": "1.0.0",
"providers": ["openrouter"],
"cliBackends": ["openrouter-cli"],
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},
@@ -125,6 +126,7 @@ Those belong in your plugin code and `package.json`.
| `kind` | No | `"memory"` \| `"context-engine"` | Declares an exclusive plugin kind used by `plugins.slots.*`. |
| `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. |
| `providers` | No | `string[]` | Provider ids owned by this plugin. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
@@ -234,8 +236,8 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
- `kind: "memory"` is selected by `plugins.slots.memory`.
- `kind: "context-engine"` is selected by `plugins.slots.contextEngine`
(default: built-in `legacy`).
- `channels`, `providers`, and `skills` can be omitted when a plugin does not
need them.
- `channels`, `providers`, `cliBackends`, and `skills` can be omitted when a
plugin does not need them.
- If your plugin depends on native modules, document the build steps and any
package-manager allowlist requirements (for example, pnpm `allow-build-scripts`
- `pnpm rebuild <package>`).

View File

@@ -1,8 +1,9 @@
---
summary: "Use Anthropic Claude via API keys or setup-token in OpenClaw"
summary: "Use Anthropic Claude via API keys, setup-token, or Claude CLI in OpenClaw"
read_when:
- You want to use Anthropic models in OpenClaw
- You want setup-token instead of API keys
- You want to reuse Claude CLI subscription auth on the gateway host
title: "Anthropic"
---
@@ -186,7 +187,90 @@ Note: Anthropic currently rejects `context-1m-*` beta requests when using
OAuth/subscription tokens (`sk-ant-oat-*`). OpenClaw automatically skips the
context1m beta header for OAuth auth and keeps the required OAuth betas.
## Option B: Claude setup-token
## Option B: Claude CLI as the message provider
**Best for:** a single-user gateway host that already has Claude CLI installed
and signed in with a Claude subscription.
This path uses the local `claude` binary for model inference instead of calling
the Anthropic API directly. OpenClaw treats it as a **CLI backend provider**
with model refs like:
- `claude-cli/claude-sonnet-4-6`
- `claude-cli/claude-opus-4-6`
How it works:
1. OpenClaw launches `claude -p --output-format json ...` on the **gateway
host**.
2. The first turn sends `--session-id <uuid>`.
3. Follow-up turns reuse the stored Claude session via `--resume <sessionId>`.
4. Your chat messages still go through the normal OpenClaw message pipeline, but
the actual model reply is produced by Claude CLI.
### Requirements
- Claude CLI installed on the gateway host and available on PATH, or configured
with an absolute command path.
- Claude CLI already authenticated on that same host:
```bash
claude auth status
```
- OpenClaw auto-loads the bundled Anthropic plugin at gateway startup when your
config explicitly references `claude-cli/...` or `claude-cli` backend config.
### Config snippet
```json5
{
agents: {
defaults: {
model: {
primary: "claude-cli/claude-sonnet-4-6",
},
models: {
"claude-cli/claude-sonnet-4-6": {},
},
sandbox: { mode: "off" },
},
},
}
```
If the `claude` binary is not on the gateway host PATH:
```json5
{
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "/opt/homebrew/bin/claude",
},
},
},
},
}
```
### What you get
- Claude subscription auth reused from the local CLI
- Normal OpenClaw message/session routing
- Claude CLI session continuity across turns
### Important limits
- This is **not** the Anthropic API provider. It is the local CLI runtime.
- Tools are disabled on the OpenClaw side for CLI backend runs.
- Text in, text out. No OpenClaw streaming handoff.
- Best fit for a personal gateway host, not shared multi-user billing setups.
More details: [/gateway/cli-backends](/gateway/cli-backends)
## Option C: Claude setup-token
**Best for:** using your Claude subscription.

View File

@@ -1,6 +1,7 @@
{
"id": "anthropic",
"providers": ["anthropic"],
"cliBackends": ["claude-cli"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
},

View File

@@ -1,6 +1,7 @@
{
"id": "google",
"providers": ["google", "google-gemini-cli"],
"cliBackends": ["google-gemini-cli"],
"providerAuthEnvVars": {
"google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"]
},

View File

@@ -1,6 +1,7 @@
{
"id": "openai",
"providers": ["openai", "openai-codex"],
"cliBackends": ["codex-cli"],
"providerAuthEnvVars": {
"openai": ["OPENAI_API_KEY"]
},

View File

@@ -34,6 +34,7 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin
name: "ACPX Runtime",
channels: [],
providers: [],
cliBackends: [],
skills: ["./skills"],
hooks: [],
origin: "workspace",
@@ -46,6 +47,7 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin
name: "Helper",
channels: [],
providers: [],
cliBackends: [],
skills: ["./skills"],
hooks: [],
origin: "workspace",
@@ -71,6 +73,7 @@ function createSinglePluginRegistry(params: {
format: params.format,
channels: [],
providers: [],
cliBackends: [],
skills: params.skills,
hooks: [],
origin: "workspace",

View File

@@ -8,6 +8,7 @@ function manifest(id: string): PluginManifestRecord {
id,
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",

View File

@@ -13,6 +13,7 @@ function manifest(id: string): PluginManifestRecord {
id,
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",

View File

@@ -61,6 +61,7 @@ function makeRegistry(plugins: Array<{ id: string; channels: string[] }>): Plugi
id: p.id,
channels: p.channels,
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "config" as const,

View File

@@ -24,24 +24,40 @@ describe("resolveGatewayStartupPluginIds", () => {
channels: ["discord"],
origin: "bundled",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "amazon-bedrock",
channels: [],
origin: "bundled",
enabledByDefault: true,
providers: [],
cliBackends: [],
},
{
id: "anthropic",
channels: [],
origin: "bundled",
enabledByDefault: undefined,
providers: ["anthropic"],
cliBackends: ["claude-cli"],
},
{
id: "diagnostics-otel",
channels: [],
origin: "bundled",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "custom-sidecar",
channels: [],
origin: "global",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
],
diagnostics: [],
@@ -55,6 +71,14 @@ describe("resolveGatewayStartupPluginIds", () => {
"diagnostics-otel": { enabled: true },
},
},
agents: {
defaults: {
model: { primary: "claude-cli/claude-sonnet-4-6" },
models: {
"claude-cli/claude-sonnet-4-6": {},
},
},
},
} as OpenClawConfig;
expect(
@@ -63,7 +87,7 @@ describe("resolveGatewayStartupPluginIds", () => {
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["discord", "diagnostics-otel", "custom-sidecar"]);
).toEqual(["discord", "anthropic", "diagnostics-otel", "custom-sidecar"]);
});
it("does not pull default-on bundled non-channel plugins into startup", () => {
@@ -77,4 +101,25 @@ describe("resolveGatewayStartupPluginIds", () => {
}),
).toEqual(["discord", "custom-sidecar"]);
});
it("auto-loads bundled plugins referenced by configured provider ids", () => {
const config = {
models: {
providers: {
anthropic: {
baseUrl: "https://example.com",
models: [],
},
},
},
} as OpenClawConfig;
expect(
resolveGatewayStartupPluginIds({
config,
workspaceDir: "/tmp",
env: process.env,
}),
).toEqual(["discord", "anthropic", "custom-sidecar"]);
});
});

View File

@@ -1,8 +1,240 @@
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
buildModelAliasIndex,
normalizeProviderId,
resolveModelRefFromString,
} from "../agents/model-selection.js";
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../config/model-input.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
type ModelListLike = string | { primary?: string; fallbacks?: string[] } | undefined;
function addResolvedActivationId(params: {
raw: string | undefined;
activationIds: Set<string>;
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
}): void {
const raw = params.raw?.trim();
if (!raw) {
return;
}
const resolved = resolveModelRefFromString({
raw,
defaultProvider: DEFAULT_PROVIDER,
aliasIndex: params.aliasIndex,
});
if (!resolved) {
return;
}
params.activationIds.add(normalizeProviderId(resolved.ref.provider));
}
function addModelListActivationIds(params: {
value: ModelListLike;
activationIds: Set<string>;
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
}): void {
addResolvedActivationId({
raw: resolveAgentModelPrimaryValue(params.value),
activationIds: params.activationIds,
aliasIndex: params.aliasIndex,
});
for (const fallback of resolveAgentModelFallbackValues(params.value)) {
addResolvedActivationId({
raw: fallback,
activationIds: params.activationIds,
aliasIndex: params.aliasIndex,
});
}
}
function addProviderModelPairActivationId(params: {
provider: string | undefined;
model: string | undefined;
activationIds: Set<string>;
}): void {
const provider = normalizeProviderId(params.provider ?? "");
const model = params.model?.trim();
if (!provider || !model) {
return;
}
params.activationIds.add(provider);
}
function collectConfiguredActivationIds(config: OpenClawConfig): Set<string> {
const activationIds = new Set<string>();
const aliasIndex = buildModelAliasIndex({
cfg: config,
defaultProvider: DEFAULT_PROVIDER,
});
addModelListActivationIds({ value: config.agents?.defaults?.model, activationIds, aliasIndex });
addModelListActivationIds({
value: config.agents?.defaults?.imageModel,
activationIds,
aliasIndex,
});
addModelListActivationIds({
value: config.agents?.defaults?.imageGenerationModel,
activationIds,
aliasIndex,
});
addModelListActivationIds({
value: config.agents?.defaults?.pdfModel,
activationIds,
aliasIndex,
});
addResolvedActivationId({
raw: config.agents?.defaults?.compaction?.model,
activationIds,
aliasIndex,
});
addResolvedActivationId({
raw: config.agents?.defaults?.heartbeat?.model,
activationIds,
aliasIndex,
});
addModelListActivationIds({
value: config.agents?.defaults?.subagents?.model,
activationIds,
aliasIndex,
});
addResolvedActivationId({
raw: config.messages?.tts?.summaryModel,
activationIds,
aliasIndex,
});
addResolvedActivationId({
raw: config.hooks?.gmail?.model,
activationIds,
aliasIndex,
});
for (const modelRef of Object.keys(config.agents?.defaults?.models ?? {})) {
addResolvedActivationId({
raw: modelRef,
activationIds,
aliasIndex,
});
}
for (const providerId of Object.keys(config.agents?.defaults?.cliBackends ?? {})) {
const normalized = normalizeProviderId(providerId);
if (normalized) {
activationIds.add(normalized);
}
}
for (const providerId of Object.keys(config.models?.providers ?? {})) {
const normalized = normalizeProviderId(providerId);
if (normalized) {
activationIds.add(normalized);
}
}
for (const agent of config.agents?.list ?? []) {
addModelListActivationIds({ value: agent.model, activationIds, aliasIndex });
addModelListActivationIds({ value: agent.subagents?.model, activationIds, aliasIndex });
addResolvedActivationId({
raw: agent.heartbeat?.model,
activationIds,
aliasIndex,
});
}
for (const mapping of config.hooks?.mappings ?? []) {
addResolvedActivationId({
raw: mapping.model,
activationIds,
aliasIndex,
});
}
for (const channelMap of Object.values(config.channels?.modelByChannel ?? {})) {
if (!channelMap || typeof channelMap !== "object") {
continue;
}
for (const raw of Object.values(channelMap)) {
addResolvedActivationId({
raw: typeof raw === "string" ? raw : undefined,
activationIds,
aliasIndex,
});
}
}
addResolvedActivationId({
raw: config.tools?.subagents?.model
? resolveAgentModelPrimaryValue(config.tools?.subagents?.model)
: undefined,
activationIds,
aliasIndex,
});
if (config.tools?.subagents?.model) {
for (const fallback of resolveAgentModelFallbackValues(config.tools.subagents.model)) {
addResolvedActivationId({ raw: fallback, activationIds, aliasIndex });
}
}
addResolvedActivationId({
raw: config.tools?.web?.search?.gemini?.model,
activationIds,
aliasIndex,
});
addResolvedActivationId({
raw: config.tools?.web?.search?.grok?.model,
activationIds,
aliasIndex,
});
addResolvedActivationId({
raw: config.tools?.web?.search?.kimi?.model,
activationIds,
aliasIndex,
});
addResolvedActivationId({
raw: config.tools?.web?.search?.perplexity?.model,
activationIds,
aliasIndex,
});
for (const entry of config.tools?.media?.models ?? []) {
addProviderModelPairActivationId({
provider: entry.provider,
model: entry.model,
activationIds,
});
}
for (const entry of config.tools?.media?.image?.models ?? []) {
addProviderModelPairActivationId({
provider: entry.provider,
model: entry.model,
activationIds,
});
}
for (const entry of config.tools?.media?.audio?.models ?? []) {
addProviderModelPairActivationId({
provider: entry.provider,
model: entry.model,
activationIds,
});
}
for (const entry of config.tools?.media?.video?.models ?? []) {
addProviderModelPairActivationId({
provider: entry.provider,
model: entry.model,
activationIds,
});
}
return activationIds;
}
export function resolveChannelPluginIds(params: {
config: OpenClawConfig;
workspaceDir?: string;
@@ -69,6 +301,7 @@ export function resolveGatewayStartupPluginIds(params: {
workspaceDir: params.workspaceDir,
env: params.env,
});
const configuredActivationIds = collectConfiguredActivationIds(params.config);
return manifestRegistry.plugins
.filter((plugin) => {
if (plugin.channels.some((channelId) => configuredChannelIds.has(channelId))) {
@@ -90,6 +323,16 @@ export function resolveGatewayStartupPluginIds(params: {
if (plugin.origin !== "bundled") {
return true;
}
if (
plugin.providers.some((providerId) =>
configuredActivationIds.has(normalizeProviderId(providerId)),
) ||
plugin.cliBackends.some((backendId) =>
configuredActivationIds.has(normalizeProviderId(backendId)),
)
) {
return true;
}
return (
pluginsConfig.allow.includes(plugin.id) ||
pluginsConfig.entries[plugin.id]?.enabled === true ||

View File

@@ -223,6 +223,7 @@ describe("loadPluginManifestRegistry", () => {
id: "openai",
enabledByDefault: true,
providers: ["openai", "openai-codex"],
cliBackends: ["codex-cli"],
providerAuthEnvVars: {
openai: ["OPENAI_API_KEY"],
},
@@ -246,6 +247,7 @@ describe("loadPluginManifestRegistry", () => {
expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({
openai: ["OPENAI_API_KEY"],
});
expect(registry.plugins[0]?.cliBackends).toEqual(["codex-cli"]);
expect(registry.plugins[0]?.enabledByDefault).toBe(true);
expect(registry.plugins[0]?.providerAuthChoices).toEqual([
{

View File

@@ -45,6 +45,7 @@ export type PluginManifestRecord = {
kind?: PluginKind;
channels: string[];
providers: string[];
cliBackends: string[];
providerAuthEnvVars?: Record<string, string[]>;
providerAuthChoices?: PluginManifest["providerAuthChoices"];
skills: string[];
@@ -170,6 +171,7 @@ function buildRecord(params: {
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
cliBackends: params.manifest.cliBackends ?? [],
providerAuthEnvVars: params.manifest.providerAuthEnvVars,
providerAuthChoices: params.manifest.providerAuthChoices,
skills: params.manifest.skills ?? [],
@@ -224,6 +226,7 @@ function buildBundleRecord(params: {
bundleCapabilities: params.manifest.capabilities,
channels: [],
providers: [],
cliBackends: [],
skills: params.manifest.skills ?? [],
settingsFiles: params.manifest.settingsFiles ?? [],
hooks: params.manifest.hooks ?? [],

View File

@@ -15,6 +15,8 @@ export type PluginManifest = {
kind?: PluginKind;
channels?: string[];
providers?: string[];
/** Cheap startup activation lookup for plugin-owned CLI inference backends. */
cliBackends?: string[];
/** Cheap provider-auth env lookup without booting plugin runtime. */
providerAuthEnvVars?: Record<string, string[]>;
/**
@@ -203,6 +205,7 @@ export function loadPluginManifest(
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
const channels = normalizeStringList(raw.channels);
const providers = normalizeStringList(raw.providers);
const cliBackends = normalizeStringList(raw.cliBackends);
const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars);
const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices);
const skills = normalizeStringList(raw.skills);
@@ -221,6 +224,7 @@ export function loadPluginManifest(
kind,
channels,
providers,
cliBackends,
providerAuthEnvVars,
providerAuthChoices,
skills,