refactor(core): land plugin auth and startup cleanup

This commit is contained in:
Peter Steinberger 2026-03-15 20:02:24 -07:00
parent f71f44576a
commit 8ab01c5c93
No known key found for this signature in database
75 changed files with 736 additions and 383 deletions

View File

@ -19,6 +19,8 @@ For model selection rules, see [/concepts/models](/concepts/models).
- Provider plugins can inject model catalogs via `registerProvider({ catalog })`;
OpenClaw merges that output into `models.providers` before writing
`models.json`.
- Provider manifests can declare `providerAuthEnvVars` so generic env-based
auth probes do not need to load plugin runtime.
- Provider plugins can also own provider runtime behavior via
`resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`,
`capabilities`, `prepareExtraParams`, `wrapStreamFn`,

View File

@ -56,6 +56,9 @@ Optional keys:
- `kind` (string): plugin kind (examples: `"memory"`, `"context-engine"`).
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
- `providers` (array): provider ids registered by this plugin.
- `providerAuthEnvVars` (object): auth env vars keyed by provider id. Use this
when OpenClaw should resolve provider credentials from env without loading
plugin runtime first.
- `skills` (array): skill directories to load (relative to the plugin root).
- `name` (string): display name for the plugin.
- `description` (string): short plugin summary.
@ -84,6 +87,9 @@ Optional keys:
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
- Runtime still loads the plugin module separately; the manifest is only for
discovery + validation.
- `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker
validation, and similar provider-auth surfaces that should not boot plugin
runtime just to inspect env names.
- Exclusive plugin kinds are selected through `plugins.slots.*`.
- `kind: "memory"` is selected by `plugins.slots.memory`.
- `kind: "context-engine"` is selected by `plugins.slots.contextEngine`

View File

@ -217,6 +217,8 @@ Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
Provider plugins now have two layers:
- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before
runtime load
- config-time hooks: `catalog` / legacy `discovery`
- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot`
@ -224,6 +226,11 @@ OpenClaw still owns the generic agent loop, failover, transcript handling, and
tool policy. These hooks are the seam for provider-specific behavior without
needing a whole custom inference transport.
Use manifest `providerAuthEnvVars` when the provider has env-based credentials
that generic auth/status/model-picker paths should see without loading plugin
runtime. Keep provider runtime `envVars` for operator-facing hints such as
onboarding labels or OAuth client-id/client-secret setup vars.
### Hook order
For model/provider plugins, OpenClaw uses hooks in this rough order:

View File

@ -1,6 +1,9 @@
{
"id": "anthropic",
"providers": ["anthropic"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "byteplus",
"providers": ["byteplus", "byteplus-plan"],
"providerAuthEnvVars": {
"byteplus": ["BYTEPLUS_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "cloudflare-ai-gateway",
"providers": ["cloudflare-ai-gateway"],
"providerAuthEnvVars": {
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -0,0 +1,7 @@
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { feishuPlugin } from "./channel.js";
export const feishuOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({
plugin: feishuPlugin,
wizard: feishuPlugin.setupWizard!,
});

View File

@ -8,11 +8,8 @@ import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles
import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { coerceSecretRef } from "../../src/config/types.secrets.js";
import { fetchCopilotUsage } from "../../src/infra/provider-usage.fetch.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../../src/providers/github-copilot-token.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
import { fetchCopilotUsage } from "./usage.js";
const PROVIDER_ID = "github-copilot";
const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];

View File

@ -1,6 +1,9 @@
{
"id": "github-copilot",
"providers": ["github-copilot"],
"providerAuthEnvVars": {
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,8 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deriveCopilotApiBaseUrlFromToken,
resolveCopilotApiToken,
} from "./github-copilot-token.js";
import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js";
describe("github-copilot token", () => {
const loadJsonFile = vi.fn();
@ -58,7 +55,7 @@ describe("github-copilot token", () => {
}),
});
const { resolveCopilotApiToken } = await import("./github-copilot-token.js");
const { resolveCopilotApiToken } = await import("./token.js");
const res = await resolveCopilotApiToken({
githubToken: "gh",

View File

@ -1,6 +1,6 @@
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { resolveStateDir } from "../../src/config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../src/infra/json-file.js";
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";

View File

@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js";
import { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
import {
createProviderUsageFetch,
makeResponse,
} from "../../src/test-utils/provider-usage-fetch.js";
import { fetchCopilotUsage } from "./usage.js";
describe("fetchCopilotUsage", () => {
it("returns HTTP errors for failed requests", async () => {

View File

@ -1,6 +1,9 @@
import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
import {
buildUsageHttpErrorSnapshot,
fetchJson,
} from "../../src/infra/provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageWindow } from "../../src/infra/provider-usage.types.js";
type CopilotUsageResponse = {
quota_snapshots?: {

View File

@ -1,6 +1,9 @@
{
"id": "huggingface",
"providers": ["huggingface"],
"providerAuthEnvVars": {
"huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "kilocode",
"providers": ["kilocode"],
"providerAuthEnvVars": {
"kilocode": ["KILOCODE_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "kimi-coding",
"providers": ["kimi-coding"],
"providerAuthEnvVars": {
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,6 @@
import {
DEFAULT_ACCOUNT_ID,
applySetupAccountConfigPatch,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";

View File

@ -175,6 +175,7 @@ const minimaxPlugin = {
id: PORTAL_PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/minimax",
envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
catalog: {
run: async (ctx) => resolvePortalCatalog(ctx),
},

View File

@ -1,6 +1,10 @@
{
"id": "minimax",
"providers": ["minimax", "minimax-portal"],
"providerAuthEnvVars": {
"minimax": ["MINIMAX_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "mistral",
"providers": ["mistral"],
"providerAuthEnvVars": {
"mistral": ["MISTRAL_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "modelstudio",
"providers": ["modelstudio"],
"providerAuthEnvVars": {
"modelstudio": ["MODELSTUDIO_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "moonshot",
"providers": ["moonshot"],
"providerAuthEnvVars": {
"moonshot": ["MOONSHOT_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "nvidia",
"providers": ["nvidia"],
"providerAuthEnvVars": {
"nvidia": ["NVIDIA_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "ollama",
"providers": ["ollama"],
"providerAuthEnvVars": {
"ollama": ["OLLAMA_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "openai",
"providers": ["openai", "openai-codex"],
"providerAuthEnvVars": {
"openai": ["OPENAI_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "opencode-go",
"providers": ["opencode-go"],
"providerAuthEnvVars": {
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "opencode",
"providers": ["opencode"],
"providerAuthEnvVars": {
"opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "openrouter",
"providers": ["openrouter"],
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "qianfan",
"providers": ["qianfan"],
"providerAuthEnvVars": {
"qianfan": ["QIANFAN_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -94,6 +94,7 @@ const qwenPortalPlugin = {
label: PROVIDER_LABEL,
docsPath: "/providers/qwen",
aliases: ["qwen"],
envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"],
catalog: {
run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx),
},

View File

@ -1,6 +1,9 @@
{
"id": "qwen-portal-auth",
"providers": ["qwen-portal"],
"providerAuthEnvVars": {
"qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "sglang",
"providers": ["sglang"],
"providerAuthEnvVars": {
"sglang": ["SGLANG_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "synthetic",
"providers": ["synthetic"],
"providerAuthEnvVars": {
"synthetic": ["SYNTHETIC_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "together",
"providers": ["together"],
"providerAuthEnvVars": {
"together": ["TOGETHER_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "venice",
"providers": ["venice"],
"providerAuthEnvVars": {
"venice": ["VENICE_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "vercel-ai-gateway",
"providers": ["vercel-ai-gateway"],
"providerAuthEnvVars": {
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "vllm",
"providers": ["vllm"],
"providerAuthEnvVars": {
"vllm": ["VLLM_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "volcengine",
"providers": ["volcengine", "volcengine-plan"],
"providerAuthEnvVars": {
"volcengine": ["VOLCANO_ENGINE_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "xiaomi",
"providers": ["xiaomi"],
"providerAuthEnvVars": {
"xiaomi": ["XIAOMI_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "zai",
"providers": ["zai"],
"providerAuthEnvVars": {
"zai": ["ZAI_API_KEY", "Z_AI_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,45 +1,10 @@
export const PROVIDER_ENV_API_KEY_CANDIDATES: Record<string, string[]> = {
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
"qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],
"volcengine-plan": ["VOLCANO_ENGINE_API_KEY"],
byteplus: ["BYTEPLUS_API_KEY"],
"byteplus-plan": ["BYTEPLUS_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
openai: ["OPENAI_API_KEY"],
google: ["GEMINI_API_KEY"],
voyage: ["VOYAGE_API_KEY"],
groq: ["GROQ_API_KEY"],
deepgram: ["DEEPGRAM_API_KEY"],
cerebras: ["CEREBRAS_API_KEY"],
xai: ["XAI_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
litellm: ["LITELLM_API_KEY"],
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
moonshot: ["MOONSHOT_API_KEY"],
minimax: ["MINIMAX_API_KEY"],
nvidia: ["NVIDIA_API_KEY"],
xiaomi: ["XIAOMI_API_KEY"],
synthetic: ["SYNTHETIC_API_KEY"],
venice: ["VENICE_API_KEY"],
mistral: ["MISTRAL_API_KEY"],
together: ["TOGETHER_API_KEY"],
qianfan: ["QIANFAN_API_KEY"],
modelstudio: ["MODELSTUDIO_API_KEY"],
ollama: ["OLLAMA_API_KEY"],
sglang: ["SGLANG_API_KEY"],
vllm: ["VLLM_API_KEY"],
kilocode: ["KILOCODE_API_KEY"],
};
import {
PROVIDER_AUTH_ENV_VAR_CANDIDATES,
listKnownProviderAuthEnvVarNames,
} from "../secrets/provider-env-vars.js";
export const PROVIDER_ENV_API_KEY_CANDIDATES = PROVIDER_AUTH_ENV_VAR_CANDIDATES;
export function listKnownProviderEnvApiKeyNames(): string[] {
return [...new Set(Object.values(PROVIDER_ENV_API_KEY_CANDIDATES).flat())];
return listKnownProviderAuthEnvVarNames();
}

View File

@ -426,4 +426,45 @@ describe("getApiKeyForModel", () => {
},
);
});
it("resolveEnvApiKey('qwen-portal') accepts QWEN_OAUTH_TOKEN", async () => {
await withEnvAsync(
{
QWEN_OAUTH_TOKEN: "qwen-oauth-token",
QWEN_PORTAL_API_KEY: undefined,
},
async () => {
const resolved = resolveEnvApiKey("qwen");
expect(resolved?.apiKey).toBe("qwen-oauth-token");
expect(resolved?.source).toContain("QWEN_OAUTH_TOKEN");
},
);
});
it("resolveEnvApiKey('minimax-portal') accepts MINIMAX_OAUTH_TOKEN", async () => {
await withEnvAsync(
{
MINIMAX_OAUTH_TOKEN: "minimax-oauth-token",
MINIMAX_API_KEY: undefined,
},
async () => {
const resolved = resolveEnvApiKey("minimax-portal");
expect(resolved?.apiKey).toBe("minimax-oauth-token");
expect(resolved?.source).toContain("MINIMAX_OAUTH_TOKEN");
},
);
});
it("resolveEnvApiKey('volcengine-plan') uses volcengine auth candidates", async () => {
await withEnvAsync(
{
VOLCANO_ENGINE_API_KEY: "volcengine-plan-key",
},
async () => {
const resolved = resolveEnvApiKey("volcengine-plan");
expect(resolved?.apiKey).toBe("volcengine-plan-key");
expect(resolved?.source).toContain("VOLCANO_ENGINE_API_KEY");
},
);
});
});

View File

@ -25,7 +25,7 @@ import {
isNonSecretApiKeyMarker,
OLLAMA_LOCAL_AUTH_MARKER,
} from "./model-auth-markers.js";
import { normalizeProviderId } from "./model-selection.js";
import { normalizeProviderId, normalizeProviderIdForAuth } from "./model-selection.js";
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
@ -400,7 +400,7 @@ export function resolveEnvApiKey(
provider: string,
env: NodeJS.ProcessEnv = process.env,
): EnvApiKeyResult | null {
const normalized = normalizeProviderId(provider);
const normalized = normalizeProviderIdForAuth(provider);
const applied = new Set(getShellEnvAppliedKeys());
const pick = (envVar: string): EnvApiKeyResult | null => {
const value = normalizeOptionalSecretInput(env[envVar]);

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token.js";
import { DEFAULT_COPILOT_API_BASE_URL } from "../../extensions/github-copilot/token.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
installModelsConfigTestHooks,

View File

@ -33,7 +33,7 @@ vi.mock("../infra/backoff.js", () => ({
sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal),
}));
vi.mock("../providers/github-copilot-token.js", () => ({
vi.mock("../../extensions/github-copilot/token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args),
}));

View File

@ -4,13 +4,18 @@ import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
export type ChannelOnboardingSetupPlugin = Pick<
ChannelPlugin,
"id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard"
>;
export type SetupChannelsOptions = {
allowDisable?: boolean;
allowSignalInstall?: boolean;
onSelection?: (selection: ChannelId[]) => void;
accountIds?: Partial<Record<ChannelId, string>>;
onAccountId?: (channel: ChannelId, accountId: string) => void;
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void;
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelOnboardingSetupPlugin) => void;
promptAccountIds?: boolean;
whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean;

View File

@ -25,10 +25,15 @@ const EMPTY_REGISTRY_FALLBACK_PLUGINS = [
linePlugin,
];
export type ChannelOnboardingSetupPlugin = Pick<
ChannelPlugin,
"id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard"
>;
const setupWizardAdapters = new WeakMap<object, ChannelOnboardingAdapter>();
export function resolveChannelOnboardingAdapterForPlugin(
plugin?: ChannelPlugin,
plugin?: ChannelOnboardingSetupPlugin,
): ChannelOnboardingAdapter | undefined {
if (plugin?.setupWizard) {
const cached = setupWizardAdapters.get(plugin);
@ -74,7 +79,7 @@ export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] {
export async function loadBundledChannelOnboardingPlugin(
channel: ChannelChoice,
): Promise<ChannelPlugin | undefined> {
): Promise<ChannelOnboardingSetupPlugin | undefined> {
switch (channel) {
case "discord":
return discordPlugin as ChannelPlugin;

View File

@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/onboarding-types.js";
import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js";
import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js";
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
@ -56,7 +57,7 @@ export async function channelsAddCommand(
const prompter = createClackPrompter();
let selection: ChannelChoice[] = [];
const accountIds: Partial<Record<ChannelChoice, string>> = {};
const resolvedPlugins = new Map<ChannelChoice, ChannelPlugin>();
const resolvedPlugins = new Map<ChannelChoice, ChannelOnboardingSetupPlugin>();
await prompter.intro("Channel setup");
let nextConfig = await setupChannels(cfg, runtime, prompter, {
allowDisable: false,

View File

@ -1,11 +1,11 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/onboarding-types.js";
import {
getChannelSetupPlugin,
listChannelSetupPlugins,
} from "../channels/plugins/setup-registry.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import {
formatChannelPrimerLine,
formatChannelSelectionLine,
@ -94,7 +94,7 @@ async function promptRemovalAccountId(params: {
prompter: WizardPrompter;
label: string;
channel: ChannelChoice;
plugin?: ChannelPlugin;
plugin?: ChannelOnboardingSetupPlugin;
}): Promise<string> {
const { cfg, prompter, label, channel } = params;
const plugin = params.plugin ?? getChannelSetupPlugin(channel);
@ -121,7 +121,7 @@ async function collectChannelStatus(params: {
cfg: OpenClawConfig;
options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChannelChoice, string>>;
installedPlugins?: ChannelPlugin[];
installedPlugins?: ChannelOnboardingSetupPlugin[];
resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined;
}): Promise<ChannelStatusSummary> {
const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins();
@ -279,7 +279,7 @@ async function maybeConfigureDmPolicies(params: {
const { selection, prompter, accountIdsByChannel } = params;
const resolve = params.resolveAdapter ?? (() => undefined);
const dmPolicies = selection
.map((channel) => resolve(channel)?.dmPolicy)
.map((channel) => resolve?.(channel)?.dmPolicy)
.filter(Boolean) as ChannelOnboardingDmPolicy[];
if (dmPolicies.length === 0) {
return params.cfg;
@ -350,17 +350,19 @@ export async function setupChannels(
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
...options?.accountIds,
};
const scopedPluginsById = new Map<ChannelChoice, ChannelPlugin>();
const scopedPluginsById = new Map<ChannelChoice, ChannelOnboardingSetupPlugin>();
const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
const rememberScopedPlugin = (plugin: ChannelPlugin) => {
const rememberScopedPlugin = (plugin: ChannelOnboardingSetupPlugin) => {
const channel = plugin.id;
scopedPluginsById.set(channel, plugin);
options?.onResolvedPlugin?.(channel, plugin);
};
const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelPlugin | undefined =>
const getVisibleChannelPlugin = (
channel: ChannelChoice,
): ChannelOnboardingSetupPlugin | undefined =>
scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel);
const listVisibleInstalledPlugins = (): ChannelPlugin[] => {
const merged = new Map<string, ChannelPlugin>();
const listVisibleInstalledPlugins = (): ChannelOnboardingSetupPlugin[] => {
const merged = new Map<string, ChannelOnboardingSetupPlugin>();
for (const plugin of listChannelSetupPlugins()) {
merged.set(plugin.id, plugin);
}
@ -372,7 +374,7 @@ export async function setupChannels(
const loadScopedChannelPlugin = async (
channel: ChannelChoice,
pluginId?: string,
): Promise<ChannelPlugin | undefined> => {
): Promise<ChannelOnboardingSetupPlugin | undefined> => {
const existing = getVisibleChannelPlugin(channel);
if (existing) {
return existing;

View File

@ -1,6 +1,10 @@
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const childProcessMocks = vi.hoisted(() => ({
execFileSync: vi.fn(),
}));
const fsMocks = vi.hoisted(() => ({
access: vi.fn(),
realpath: vi.fn(),
@ -12,6 +16,10 @@ vi.mock("node:fs/promises", () => ({
realpath: fsMocks.realpath,
}));
vi.mock("node:child_process", () => ({
execFileSync: childProcessMocks.execFileSync,
}));
import { resolveGatewayProgramArguments } from "./program-args.js";
const originalArgv = [...process.argv];
@ -87,4 +95,28 @@ describe("resolveGatewayProgramArguments", () => {
"18789",
]);
});
it("uses src/entry.ts for bun dev mode", async () => {
const repoIndexPath = path.resolve("/repo/src/index.ts");
const repoEntryPath = path.resolve("/repo/src/entry.ts");
process.argv = ["/usr/local/bin/node", repoIndexPath];
fsMocks.realpath.mockResolvedValue(repoIndexPath);
fsMocks.access.mockResolvedValue(undefined);
childProcessMocks.execFileSync.mockReturnValue("/usr/local/bin/bun\n");
const result = await resolveGatewayProgramArguments({
dev: true,
port: 18789,
runtime: "bun",
});
expect(result.programArguments).toEqual([
"/usr/local/bin/bun",
repoEntryPath,
"gateway",
"--port",
"18789",
]);
expect(result.workingDirectory).toBe(path.resolve("/repo"));
});
});

View File

@ -123,7 +123,7 @@ function resolveRepoRootForDev(): string {
const parts = normalized.split(path.sep);
const srcIndex = parts.lastIndexOf("src");
if (srcIndex === -1) {
throw new Error("Dev mode requires running from repo (src/index.ts)");
throw new Error("Dev mode requires running from repo (src/entry.ts)");
}
return parts.slice(0, srcIndex).join(path.sep);
}
@ -180,7 +180,7 @@ async function resolveCliProgramArguments(params: {
if (runtime === "bun") {
if (params.dev) {
const repoRoot = resolveRepoRootForDev();
const devCliPath = path.join(repoRoot, "src", "index.ts");
const devCliPath = path.join(repoRoot, "src", "entry.ts");
await fs.access(devCliPath);
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
return {
@ -213,7 +213,7 @@ async function resolveCliProgramArguments(params: {
// Dev mode: use bun to run TypeScript directly
const repoRoot = resolveRepoRootForDev();
const devCliPath = path.join(repoRoot, "src", "index.ts");
const devCliPath = path.join(repoRoot, "src", "entry.ts");
await fs.access(devCliPath);
// If already running under bun, use current execPath

46
src/index.test.ts Normal file
View File

@ -0,0 +1,46 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
const runtimeMocks = vi.hoisted(() => ({
runCli: vi.fn(async () => {}),
}));
vi.mock("./cli/run-main.js", () => ({
runCli: runtimeMocks.runCli,
}));
describe("legacy root entry", () => {
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it("routes the package root export to the pure library entry", () => {
const packageJson = JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as {
exports?: Record<string, unknown>;
main?: string;
};
expect(packageJson.main).toBe("dist/index.js");
expect(packageJson.exports?.["."]).toBe("./dist/index.js");
});
it("does not run CLI bootstrap when imported as a library dependency", async () => {
const mod = await import("./index.js");
expect(typeof mod.runLegacyCliEntry).toBe("function");
expect(runtimeMocks.runCli).not.toHaveBeenCalled();
});
it("delegates legacy direct-entry execution to run-main", async () => {
const mod = await import("./index.js");
const argv = ["node", "dist/index.js", "status"];
await mod.runLegacyCliEntry(argv);
expect(runtimeMocks.runCli).toHaveBeenCalledOnce();
expect(runtimeMocks.runCli).toHaveBeenCalledWith(argv);
});
});

View File

@ -1,76 +1,40 @@
#!/usr/bin/env node
import process from "node:process";
import { fileURLToPath } from "node:url";
import { getReplyFromConfig } from "./auto-reply/reply.js";
import { applyTemplate } from "./auto-reply/templating.js";
import { monitorWebChannel } from "./channel-web.js";
import { createDefaultDeps } from "./cli/deps.js";
import { promptYesNo } from "./cli/prompt.js";
import { waitForever } from "./cli/wait.js";
import { loadConfig } from "./config/config.js";
import {
deriveSessionKey,
loadSessionStore,
resolveSessionKey,
resolveStorePath,
saveSessionStore,
} from "./config/sessions.js";
import { ensureBinary } from "./infra/binaries.js";
import { loadDotEnv } from "./infra/dotenv.js";
import { normalizeEnv } from "./infra/env.js";
import { formatUncaughtError } from "./infra/errors.js";
import { isMainModule } from "./infra/is-main.js";
import { ensureOpenClawCliOnPath } from "./infra/path-env.js";
import {
describePortOwner,
ensurePortAvailable,
handlePortError,
PortInUseError,
} from "./infra/ports.js";
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
import { enableConsoleCapture } from "./logging.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js";
import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js";
loadDotEnv({ quiet: true });
normalizeEnv();
ensureOpenClawCliOnPath();
const library = await import("./library.js");
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
export const assertWebChannel = library.assertWebChannel;
export const applyTemplate = library.applyTemplate;
export const createDefaultDeps = library.createDefaultDeps;
export const deriveSessionKey = library.deriveSessionKey;
export const describePortOwner = library.describePortOwner;
export const ensureBinary = library.ensureBinary;
export const ensurePortAvailable = library.ensurePortAvailable;
export const getReplyFromConfig = library.getReplyFromConfig;
export const handlePortError = library.handlePortError;
export const loadConfig = library.loadConfig;
export const loadSessionStore = library.loadSessionStore;
export const monitorWebChannel = library.monitorWebChannel;
export const normalizeE164 = library.normalizeE164;
export const PortInUseError = library.PortInUseError;
export const promptYesNo = library.promptYesNo;
export const resolveSessionKey = library.resolveSessionKey;
export const resolveStorePath = library.resolveStorePath;
export const runCommandWithTimeout = library.runCommandWithTimeout;
export const runExec = library.runExec;
export const saveSessionStore = library.saveSessionStore;
export const toWhatsappJid = library.toWhatsappJid;
export const waitForever = library.waitForever;
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
import { buildProgram } from "./cli/program.js";
const program = buildProgram();
export {
assertWebChannel,
applyTemplate,
createDefaultDeps,
deriveSessionKey,
describePortOwner,
ensureBinary,
ensurePortAvailable,
getReplyFromConfig,
handlePortError,
loadConfig,
loadSessionStore,
monitorWebChannel,
normalizeE164,
PortInUseError,
promptYesNo,
resolveSessionKey,
resolveStorePath,
runCommandWithTimeout,
runExec,
saveSessionStore,
toWhatsappJid,
waitForever,
};
// Legacy direct file entrypoint only. Package root exports now live in library.ts.
export async function runLegacyCliEntry(argv: string[] = process.argv): Promise<void> {
const { runCli } = await import("./cli/run-main.js");
await runCli(argv);
}
const isMain = isMainModule({
currentFile: fileURLToPath(import.meta.url),
@ -86,7 +50,7 @@ if (isMain) {
process.exit(1);
});
void program.parseAsync(process.argv).catch((err) => {
void runLegacyCliEntry(process.argv).catch((err) => {
console.error("[openclaw] CLI failed:", formatUncaughtError(err));
process.exit(1);
});

View File

@ -26,6 +26,7 @@ describe("isGatewayArgv", () => {
expect(isGatewayArgv(["NODE", "C:\\OpenClaw\\DIST\\ENTRY.JS", "gateway"])).toBe(true);
expect(isGatewayArgv(["bun", "/srv/openclaw/scripts/run-node.mjs", "gateway"])).toBe(true);
expect(isGatewayArgv(["node", "/srv/openclaw/openclaw.mjs", "gateway"])).toBe(true);
expect(isGatewayArgv(["tsx", "/srv/openclaw/src/entry.ts", "gateway"])).toBe(true);
expect(isGatewayArgv(["tsx", "/srv/openclaw/src/index.ts", "gateway"])).toBe(true);
});

View File

@ -20,6 +20,7 @@ export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: bool
"dist/entry.js",
"openclaw.mjs",
"scripts/run-node.mjs",
"src/entry.ts",
"src/index.ts",
];
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {

View File

@ -1,6 +1,3 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
dedupeProfileIds,
ensureAuthProfileStore,
@ -14,7 +11,6 @@ import { normalizeProviderId } from "../agents/model-selection.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { resolveRequiredHomeDir } from "./home-dir.js";
import type { UsageProviderId } from "./provider-usage.types.js";
export type ProviderAuth = {
@ -32,46 +28,6 @@ type UsageAuthState = {
agentDir?: string;
};
const LEGACY_OAUTH_USAGE_PROVIDERS = new Set<UsageProviderId>([
"anthropic",
"github-copilot",
"google-gemini-cli",
"openai-codex",
]);
function parseGoogleToken(apiKey: string): { token: string } | null {
try {
const parsed = JSON.parse(apiKey) as { token?: unknown };
if (parsed && typeof parsed.token === "string") {
return { token: parsed.token };
}
} catch {
// ignore
}
return null;
}
function resolveLegacyZaiApiKey(state: UsageAuthState): string | undefined {
try {
const authPath = path.join(
resolveRequiredHomeDir(state.env, os.homedir),
".pi",
"agent",
"auth.json",
);
if (!fs.existsSync(authPath)) {
return undefined;
}
const data = JSON.parse(fs.readFileSync(authPath, "utf-8")) as Record<
string,
{ access?: string }
>;
return data["z-ai"]?.access || data.zai?.access;
} catch {
return undefined;
}
}
function resolveProviderApiKeyFromConfigAndStore(params: {
state: UsageAuthState;
providerIds: string[];
@ -236,66 +192,7 @@ export async function resolveProviderAuths(params: {
});
if (pluginAuth) {
auths.push(pluginAuth);
continue;
}
if (provider === "zai") {
const apiKey =
resolveProviderApiKeyFromConfigAndStore({
state,
providerIds: ["zai", "z-ai"],
envDirect: [state.env.ZAI_API_KEY, state.env.Z_AI_API_KEY],
}) ?? resolveLegacyZaiApiKey(state);
if (apiKey) {
auths.push({ provider, token: apiKey });
}
continue;
}
if (provider === "minimax") {
const apiKey = resolveProviderApiKeyFromConfigAndStore({
state,
providerIds: ["minimax"],
envDirect: [state.env.MINIMAX_CODE_PLAN_KEY, state.env.MINIMAX_API_KEY],
});
if (apiKey) {
auths.push({ provider, token: apiKey });
}
continue;
}
if (provider === "xiaomi") {
const apiKey = resolveProviderApiKeyFromConfigAndStore({
state,
providerIds: ["xiaomi"],
envDirect: [state.env.XIAOMI_API_KEY],
});
if (apiKey) {
auths.push({ provider, token: apiKey });
}
continue;
}
if (!LEGACY_OAUTH_USAGE_PROVIDERS.has(provider)) {
continue;
}
const auth = await resolveOAuthToken({
state,
provider,
});
if (!auth) {
continue;
}
if (provider === "google-gemini-cli") {
const parsed = parseGoogleToken(auth.token);
auths.push({
...auth,
token: parsed?.token ?? auth.token,
});
continue;
}
auths.push(auth);
}
return auths;

View File

@ -1,6 +1,5 @@
export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
export { fetchCodexUsage } from "./provider-usage.fetch.codex.js";
export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js";
export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js";
export { fetchZaiUsage } from "./provider-usage.fetch.zai.js";

View File

@ -22,7 +22,7 @@ describe("provider-usage.load plugin seam", () => {
resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null);
});
it("prefers plugin-owned usage snapshots before the legacy core switch", async () => {
it("prefers plugin-owned usage snapshots", async () => {
resolveProviderUsageSnapshotWithPluginMock.mockResolvedValueOnce({
provider: "github-copilot",
displayName: "Copilot",

View File

@ -2,14 +2,6 @@ import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js";
import { resolveFetch } from "./fetch.js";
import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js";
import {
fetchClaudeUsage,
fetchCodexUsage,
fetchCopilotUsage,
fetchGeminiUsage,
fetchMinimaxUsage,
fetchZaiUsage,
} from "./provider-usage.fetch.js";
import {
DEFAULT_TIMEOUT_MS,
ignoredErrors,
@ -64,44 +56,12 @@ async function fetchProviderUsageSnapshot(params: {
if (pluginSnapshot) {
return pluginSnapshot;
}
switch (params.auth.provider) {
case "anthropic":
return await fetchClaudeUsage(params.auth.token, params.timeoutMs, params.fetchFn);
case "github-copilot":
return await fetchCopilotUsage(params.auth.token, params.timeoutMs, params.fetchFn);
case "google-gemini-cli":
return await fetchGeminiUsage(
params.auth.token,
params.timeoutMs,
params.fetchFn,
params.auth.provider,
);
case "openai-codex":
return await fetchCodexUsage(
params.auth.token,
params.auth.accountId,
params.timeoutMs,
params.fetchFn,
);
case "minimax":
return await fetchMinimaxUsage(params.auth.token, params.timeoutMs, params.fetchFn);
case "xiaomi":
return {
provider: "xiaomi",
displayName: PROVIDER_LABELS.xiaomi,
windows: [],
};
case "zai":
return await fetchZaiUsage(params.auth.token, params.timeoutMs, params.fetchFn);
default:
return {
provider: params.auth.provider,
displayName: PROVIDER_LABELS[params.auth.provider],
windows: [],
error: "Unsupported provider",
};
}
return {
provider: params.auth.provider,
displayName: PROVIDER_LABELS[params.auth.provider],
windows: [],
error: "Unsupported provider",
};
}
export async function loadProviderUsageSummary(

48
src/library.ts Normal file
View File

@ -0,0 +1,48 @@
import { getReplyFromConfig } from "./auto-reply/reply.js";
import { applyTemplate } from "./auto-reply/templating.js";
import { monitorWebChannel } from "./channel-web.js";
import { createDefaultDeps } from "./cli/deps.js";
import { promptYesNo } from "./cli/prompt.js";
import { waitForever } from "./cli/wait.js";
import { loadConfig } from "./config/config.js";
import {
deriveSessionKey,
loadSessionStore,
resolveSessionKey,
resolveStorePath,
saveSessionStore,
} from "./config/sessions.js";
import { ensureBinary } from "./infra/binaries.js";
import {
describePortOwner,
ensurePortAvailable,
handlePortError,
PortInUseError,
} from "./infra/ports.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js";
import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js";
export {
assertWebChannel,
applyTemplate,
createDefaultDeps,
deriveSessionKey,
describePortOwner,
ensureBinary,
ensurePortAvailable,
getReplyFromConfig,
handlePortError,
loadConfig,
loadSessionStore,
monitorWebChannel,
normalizeE164,
PortInUseError,
promptYesNo,
resolveSessionKey,
resolveStorePath,
runCommandWithTimeout,
runExec,
saveSessionStore,
toWhatsappJid,
waitForever,
};

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js";
describe("bundled provider auth env vars", () => {
it("reads bundled provider auth env vars from plugin manifests", () => {
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["github-copilot"]).toEqual([
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
]);
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["qwen-portal"]).toEqual([
"QWEN_OAUTH_TOKEN",
"QWEN_PORTAL_API_KEY",
]);
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["minimax-portal"]).toEqual([
"MINIMAX_OAUTH_TOKEN",
"MINIMAX_API_KEY",
]);
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.openai).toEqual(["OPENAI_API_KEY"]);
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["openai-codex"]).toBeUndefined();
});
});

View File

@ -0,0 +1,91 @@
import ANTHROPIC_MANIFEST from "../../extensions/anthropic/openclaw.plugin.json" with { type: "json" };
import BYTEPLUS_MANIFEST from "../../extensions/byteplus/openclaw.plugin.json" with { type: "json" };
import CLOUDFLARE_AI_GATEWAY_MANIFEST from "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json" with { type: "json" };
import COPILOT_PROXY_MANIFEST from "../../extensions/copilot-proxy/openclaw.plugin.json" with { type: "json" };
import GITHUB_COPILOT_MANIFEST from "../../extensions/github-copilot/openclaw.plugin.json" with { type: "json" };
import GOOGLE_MANIFEST from "../../extensions/google/openclaw.plugin.json" with { type: "json" };
import HUGGINGFACE_MANIFEST from "../../extensions/huggingface/openclaw.plugin.json" with { type: "json" };
import KILOCODE_MANIFEST from "../../extensions/kilocode/openclaw.plugin.json" with { type: "json" };
import KIMI_CODING_MANIFEST from "../../extensions/kimi-coding/openclaw.plugin.json" with { type: "json" };
import MINIMAX_MANIFEST from "../../extensions/minimax/openclaw.plugin.json" with { type: "json" };
import MISTRAL_MANIFEST from "../../extensions/mistral/openclaw.plugin.json" with { type: "json" };
import MODELSTUDIO_MANIFEST from "../../extensions/modelstudio/openclaw.plugin.json" with { type: "json" };
import MOONSHOT_MANIFEST from "../../extensions/moonshot/openclaw.plugin.json" with { type: "json" };
import NVIDIA_MANIFEST from "../../extensions/nvidia/openclaw.plugin.json" with { type: "json" };
import OLLAMA_MANIFEST from "../../extensions/ollama/openclaw.plugin.json" with { type: "json" };
import OPENAI_MANIFEST from "../../extensions/openai/openclaw.plugin.json" with { type: "json" };
import OPENCODE_GO_MANIFEST from "../../extensions/opencode-go/openclaw.plugin.json" with { type: "json" };
import OPENCODE_MANIFEST from "../../extensions/opencode/openclaw.plugin.json" with { type: "json" };
import OPENROUTER_MANIFEST from "../../extensions/openrouter/openclaw.plugin.json" with { type: "json" };
import QIANFAN_MANIFEST from "../../extensions/qianfan/openclaw.plugin.json" with { type: "json" };
import QWEN_PORTAL_AUTH_MANIFEST from "../../extensions/qwen-portal-auth/openclaw.plugin.json" with { type: "json" };
import SGLANG_MANIFEST from "../../extensions/sglang/openclaw.plugin.json" with { type: "json" };
import SYNTHETIC_MANIFEST from "../../extensions/synthetic/openclaw.plugin.json" with { type: "json" };
import TOGETHER_MANIFEST from "../../extensions/together/openclaw.plugin.json" with { type: "json" };
import VENICE_MANIFEST from "../../extensions/venice/openclaw.plugin.json" with { type: "json" };
import VERCEL_AI_GATEWAY_MANIFEST from "../../extensions/vercel-ai-gateway/openclaw.plugin.json" with { type: "json" };
import VLLM_MANIFEST from "../../extensions/vllm/openclaw.plugin.json" with { type: "json" };
import VOLCENGINE_MANIFEST from "../../extensions/volcengine/openclaw.plugin.json" with { type: "json" };
import XIAOMI_MANIFEST from "../../extensions/xiaomi/openclaw.plugin.json" with { type: "json" };
import ZAI_MANIFEST from "../../extensions/zai/openclaw.plugin.json" with { type: "json" };
type ProviderAuthEnvVarManifest = {
id?: string;
providerAuthEnvVars?: Record<string, string[]>;
};
function collectBundledProviderAuthEnvVars(
manifests: readonly ProviderAuthEnvVarManifest[],
): Record<string, readonly string[]> {
const entries: Record<string, readonly string[]> = {};
for (const manifest of manifests) {
const providerAuthEnvVars = manifest.providerAuthEnvVars;
if (!providerAuthEnvVars) {
continue;
}
for (const [providerId, envVars] of Object.entries(providerAuthEnvVars)) {
const normalizedProviderId = providerId.trim();
const normalizedEnvVars = envVars.map((value) => value.trim()).filter(Boolean);
if (!normalizedProviderId || normalizedEnvVars.length === 0) {
continue;
}
entries[normalizedProviderId] = normalizedEnvVars;
}
}
return entries;
}
// Read bundled provider auth env metadata from manifests so env-based auth
// lookup stays cheap and does not need to boot plugin runtime code.
export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = collectBundledProviderAuthEnvVars([
ANTHROPIC_MANIFEST,
BYTEPLUS_MANIFEST,
CLOUDFLARE_AI_GATEWAY_MANIFEST,
COPILOT_PROXY_MANIFEST,
GITHUB_COPILOT_MANIFEST,
GOOGLE_MANIFEST,
HUGGINGFACE_MANIFEST,
KILOCODE_MANIFEST,
KIMI_CODING_MANIFEST,
MINIMAX_MANIFEST,
MISTRAL_MANIFEST,
MODELSTUDIO_MANIFEST,
MOONSHOT_MANIFEST,
NVIDIA_MANIFEST,
OLLAMA_MANIFEST,
OPENAI_MANIFEST,
OPENCODE_GO_MANIFEST,
OPENCODE_MANIFEST,
OPENROUTER_MANIFEST,
QIANFAN_MANIFEST,
QWEN_PORTAL_AUTH_MANIFEST,
SGLANG_MANIFEST,
SYNTHETIC_MANIFEST,
TOGETHER_MANIFEST,
VENICE_MANIFEST,
VERCEL_AI_GATEWAY_MANIFEST,
VLLM_MANIFEST,
VOLCENGINE_MANIFEST,
XIAOMI_MANIFEST,
ZAI_MANIFEST,
]);

View File

@ -213,4 +213,9 @@ describe("resolveEnableState", () => {
reason: "workspace plugin (disabled by default)",
});
});
it("keeps bundled provider plugins enabled when they are bundled-default providers", () => {
const state = resolveEnableState("google", "bundled", normalizePluginsConfig({}));
expect(state).toEqual({ enabled: true });
});
});

View File

@ -29,6 +29,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"cloudflare-ai-gateway",
"device-pair",
"github-copilot",
"google",
"huggingface",
"kilocode",
"kimi-coding",

View File

@ -28,7 +28,7 @@ import { isPathInside, safeStatSync } from "./path-safety.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { setActivePluginRegistry } from "./runtime.js";
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js";
import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
import type { PluginRuntime } from "./runtime/types.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
import type {
@ -163,6 +163,25 @@ const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string
return null;
};
function resolvePluginRuntimeModulePath(params: { modulePath?: string } = {}): string | null {
try {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const moduleDir = path.dirname(modulePath);
const candidates = [
path.join(moduleDir, "runtime", "index.ts"),
path.join(moduleDir, "runtime", "index.js"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
}
const cachedPluginSdkExportedSubpaths = new Map<string, string[]>();
function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] {
@ -747,11 +766,58 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
clearPluginInteractiveHandlers();
}
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
let jitiLoader: ReturnType<typeof createJiti> | null = null;
const getJiti = () => {
if (jitiLoader) {
return jitiLoader;
}
const pluginSdkAlias = resolvePluginSdkAlias();
const extensionApiAlias = resolveExtensionApiAlias();
const aliasMap = {
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap(),
};
jitiLoader = createJiti(import.meta.url, {
interopDefault: true,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
...(Object.keys(aliasMap).length > 0
? {
alias: aliasMap,
}
: {}),
});
return jitiLoader;
};
let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null =
null;
const resolveCreatePluginRuntime = (): ((
options?: CreatePluginRuntimeOptions,
) => PluginRuntime) => {
if (createPluginRuntimeFactory) {
return createPluginRuntimeFactory;
}
const runtimeModulePath = resolvePluginRuntimeModulePath();
if (!runtimeModulePath) {
throw new Error("Unable to resolve plugin runtime module");
}
const runtimeModule = getJiti()(runtimeModulePath) as {
createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime;
};
if (typeof runtimeModule.createPluginRuntime !== "function") {
throw new Error("Plugin runtime module missing createPluginRuntime export");
}
createPluginRuntimeFactory = runtimeModule.createPluginRuntime;
return createPluginRuntimeFactory;
};
// Lazily initialize the runtime so startup paths that discover/skip plugins do
// not eagerly load every channel runtime dependency.
// not eagerly load every channel/runtime dependency tree.
let resolvedRuntime: PluginRuntime | null = null;
const resolveRuntime = (): PluginRuntime => {
resolvedRuntime ??= createPluginRuntime(options.runtimeOptions);
resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions);
return resolvedRuntime;
};
const runtime = new Proxy({} as PluginRuntime, {
@ -780,6 +846,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
return Reflect.getPrototypeOf(resolveRuntime() as object);
},
});
const { registry, createApi } = createPluginRegistry({
logger,
runtime,
@ -823,31 +890,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
env,
});
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
let jitiLoader: ReturnType<typeof createJiti> | null = null;
const getJiti = () => {
if (jitiLoader) {
return jitiLoader;
}
const pluginSdkAlias = resolvePluginSdkAlias();
const extensionApiAlias = resolveExtensionApiAlias();
const aliasMap = {
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap(),
};
jitiLoader = createJiti(import.meta.url, {
interopDefault: true,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
...(Object.keys(aliasMap).length > 0
? {
alias: aliasMap,
}
: {}),
});
return jitiLoader;
};
const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
);

View File

@ -199,6 +199,28 @@ describe("loadPluginManifestRegistry", () => {
).toBe(true);
});
it("preserves provider auth env metadata from plugin manifests", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "openai",
providers: ["openai", "openai-codex"],
providerAuthEnvVars: {
openai: ["OPENAI_API_KEY"],
},
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "openai",
rootDir: dir,
origin: "bundled",
});
expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({
openai: ["OPENAI_API_KEY"],
});
});
it("reports bundled plugins as the duplicate winner for auto-discovered globals", () => {
const bundledDir = makeTempDir();
const globalDir = makeTempDir();

View File

@ -41,6 +41,7 @@ export type PluginManifestRecord = {
kind?: PluginKind;
channels: string[];
providers: string[];
providerAuthEnvVars?: Record<string, string[]>;
skills: string[];
settingsFiles?: string[];
hooks: string[];
@ -152,6 +153,7 @@ function buildRecord(params: {
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
providerAuthEnvVars: params.manifest.providerAuthEnvVars,
skills: params.manifest.skills ?? [],
settingsFiles: [],
hooks: [],

View File

@ -14,6 +14,7 @@ export type PluginManifest = {
kind?: PluginKind;
channels?: string[];
providers?: string[];
providerAuthEnvVars?: Record<string, string[]>;
skills?: string[];
name?: string;
description?: string;
@ -32,6 +33,25 @@ function normalizeStringList(value: unknown): string[] {
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
function normalizeStringListRecord(value: unknown): Record<string, string[]> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string[]> = {};
for (const [key, rawValues] of Object.entries(value)) {
const providerId = typeof key === "string" ? key.trim() : "";
if (!providerId) {
continue;
}
const values = normalizeStringList(rawValues);
if (values.length === 0) {
continue;
}
normalized[providerId] = values;
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
export function resolvePluginManifestPath(rootDir: string): string {
for (const filename of PLUGIN_MANIFEST_FILENAMES) {
const candidate = path.join(rootDir, filename);
@ -93,6 +113,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 providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars);
const skills = normalizeStringList(raw.skills);
let uiHints: Record<string, PluginConfigUiHint> | undefined;
@ -108,6 +129,7 @@ export function loadPluginManifest(
kind,
channels,
providers,
providerAuthEnvVars,
skills,
name,
description,

View File

@ -2,9 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js";
const resolvePluginProvidersMock = vi.fn((_: unknown) => [] as ProviderPlugin[]);
const resolveOwningPluginIdsForProviderMock = vi.fn(
(_: unknown) => undefined as string[] | undefined,
);
vi.mock("./providers.js", () => ({
resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never),
resolveOwningPluginIdsForProvider: (params: unknown) =>
resolveOwningPluginIdsForProviderMock(params as never),
}));
import {
@ -41,6 +46,8 @@ describe("provider-runtime", () => {
beforeEach(() => {
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue([]);
resolveOwningPluginIdsForProviderMock.mockReset();
resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined);
});
it("matches providers by alias for runtime hook lookup", () => {
@ -56,9 +63,13 @@ describe("provider-runtime", () => {
const plugin = resolveProviderRuntimePlugin({ provider: "Open Router" });
expect(plugin?.id).toBe("openrouter");
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "Open Router",
}),
);
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect.objectContaining({
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
}),

View File

@ -1,6 +1,6 @@
import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolvePluginProviders } from "./providers.js";
import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js";
import type {
ProviderAugmentModelCatalogContext,
ProviderBuildMissingAuthMessageContext,
@ -60,9 +60,15 @@ export function resolveProviderRuntimePlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin | undefined {
return resolveProviderPluginsForHooks(params).find((plugin) =>
matchesProviderId(plugin, params.provider),
);
return resolveProviderPluginsForHooks({
...params,
onlyPluginIds: resolveOwningPluginIdsForProvider({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}),
}).find((plugin) => matchesProviderId(plugin, params.provider));
}
export function runProviderDynamicModel(params: {

View File

@ -1,18 +1,28 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolvePluginProviders } from "./providers.js";
import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js";
const loadOpenClawPluginsMock = vi.fn();
const loadPluginManifestRegistryMock = vi.fn();
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args),
}));
vi.mock("./manifest-registry.js", () => ({
loadPluginManifestRegistry: (...args: unknown[]) => loadPluginManifestRegistryMock(...args),
}));
describe("resolvePluginProviders", () => {
beforeEach(() => {
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock.mockReturnValue({
providers: [{ pluginId: "google", provider: { id: "demo-provider" } }],
});
loadPluginManifestRegistryMock.mockReset();
loadPluginManifestRegistryMock.mockReturnValue({
plugins: [],
diagnostics: [],
});
});
it("forwards an explicit env to plugin loading", () => {
@ -86,4 +96,18 @@ describe("resolvePluginProviders", () => {
expect(allow).toContain("google");
expect(allow).not.toContain("google-gemini-cli-auth");
});
it("maps provider ids to owning plugin ids via manifests", () => {
loadPluginManifestRegistryMock.mockReturnValue({
plugins: [
{ id: "minimax", providers: ["minimax", "minimax-portal"] },
{ id: "openai", providers: ["openai", "openai-codex"] },
],
diagnostics: [],
});
expect(resolveOwningPluginIdsForProvider({ provider: "minimax-portal" })).toEqual(["minimax"]);
expect(resolveOwningPluginIdsForProvider({ provider: "openai-codex" })).toEqual(["openai"]);
expect(resolveOwningPluginIdsForProvider({ provider: "gemini-cli" })).toBeUndefined();
});
});

View File

@ -1,7 +1,9 @@
import { normalizeProviderId } from "../agents/model-selection.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { withBundledPluginAllowlistCompat } from "./bundled-compat.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { ProviderPlugin } from "./types.js";
const log = createSubsystemLogger("plugins");
@ -86,6 +88,32 @@ function withBundledProviderVitestCompat(params: {
},
};
}
export function resolveOwningPluginIdsForProvider(params: {
provider: string;
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] | undefined {
const normalizedProvider = normalizeProviderId(params.provider);
if (!normalizedProvider) {
return undefined;
}
const registry = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const pluginIds = registry.plugins
.filter((plugin) =>
plugin.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider),
)
.map((plugin) => plugin.id);
return pluginIds.length > 0 ? pluginIds : undefined;
}
export function resolvePluginProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;

View File

@ -337,8 +337,6 @@ export type ProviderResolvedUsageAuth = {
* This hook runs after `resolveUsageAuth` succeeds. Core still owns summary
* fan-out, timeout wrapping, filtering, and formatting; the provider plugin
* owns the provider-specific HTTP request + response normalization.
*
* Return `null`/`undefined` to fall back to legacy core fetchers.
*/
export type ProviderFetchUsageSnapshotContext = {
config: OpenClawConfig;
@ -499,6 +497,12 @@ export type ProviderPlugin = {
label: string;
docsPath?: string;
aliases?: string[];
/**
* Provider-related env vars shown in onboarding/search/help surfaces.
*
* Keep entries in preferred display order. This can include direct auth env
* vars or setup inputs such as OAuth client id/secret vars.
*/
envVars?: string[];
auth: ProviderAuthMethod[];
/**
@ -584,10 +588,9 @@ export type ProviderPlugin = {
/**
* Usage/billing auth resolution hook.
*
* Called by provider-usage surfaces (`/usage`, status snapshots, reporting)
* before OpenClaw falls back to legacy core auth resolution. Use this when a
* provider's usage endpoint needs provider-owned token extraction, blob
* parsing, or alias handling.
* Called by provider-usage surfaces (`/usage`, status snapshots, reporting).
* Use this when a provider's usage endpoint needs provider-owned token
* extraction, blob parsing, or alias handling.
*/
resolveUsageAuth?: (
ctx: ProviderResolveUsageAuthContext,

View File

@ -10,10 +10,12 @@ describe("provider env vars", () => {
expect(listKnownProviderAuthEnvVarNames()).toEqual(
expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]),
);
expect(listKnownSecretEnvVarNames()).not.toEqual(listKnownProviderAuthEnvVarNames());
expect(listKnownSecretEnvVarNames()).not.toEqual(
expect(listKnownSecretEnvVarNames()).toEqual(
expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]),
);
expect(listKnownProviderAuthEnvVarNames()).toEqual(
expect.arrayContaining(["MINIMAX_CODE_PLAN_KEY"]),
);
expect(listKnownSecretEnvVarNames()).not.toContain("OPENCLAW_API_KEY");
});

View File

@ -1,50 +1,42 @@
export const PROVIDER_ENV_VARS: Record<string, readonly string[]> = {
openai: ["OPENAI_API_KEY"],
anthropic: ["ANTHROPIC_API_KEY"],
import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "../plugins/bundled-provider-auth-env-vars.js";
const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"],
google: ["GEMINI_API_KEY"],
minimax: ["MINIMAX_API_KEY"],
"minimax-cn": ["MINIMAX_API_KEY"],
moonshot: ["MOONSHOT_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
synthetic: ["SYNTHETIC_API_KEY"],
venice: ["VENICE_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
xiaomi: ["XIAOMI_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
voyage: ["VOYAGE_API_KEY"],
groq: ["GROQ_API_KEY"],
deepgram: ["DEEPGRAM_API_KEY"],
cerebras: ["CEREBRAS_API_KEY"],
litellm: ["LITELLM_API_KEY"],
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
together: ["TOGETHER_API_KEY"],
huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
qianfan: ["QIANFAN_API_KEY"],
xai: ["XAI_API_KEY"],
mistral: ["MISTRAL_API_KEY"],
kilocode: ["KILOCODE_API_KEY"],
modelstudio: ["MODELSTUDIO_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],
byteplus: ["BYTEPLUS_API_KEY"],
} as const;
/**
* Provider auth env candidates used by generic auth resolution.
*
* Order matters: the first non-empty value wins for helpers such as
* `resolveEnvApiKey()`. Bundled providers source this from plugin manifest
* metadata so auth probes do not need to load plugin runtime.
*/
export const PROVIDER_AUTH_ENV_VAR_CANDIDATES: Record<string, readonly string[]> = {
...BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES,
...CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES,
};
const EXTRA_PROVIDER_AUTH_ENV_VARS = [
"VOYAGE_API_KEY",
"GROQ_API_KEY",
"DEEPGRAM_API_KEY",
"CEREBRAS_API_KEY",
"NVIDIA_API_KEY",
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
"ANTHROPIC_OAUTH_TOKEN",
"CHUTES_OAUTH_TOKEN",
"CHUTES_API_KEY",
"QWEN_OAUTH_TOKEN",
"QWEN_PORTAL_API_KEY",
"MINIMAX_OAUTH_TOKEN",
"OLLAMA_API_KEY",
"VLLM_API_KEY",
] as const;
/**
* Provider env vars used for onboarding/default secret refs and broad secret
* scrubbing. This can include non-model providers and may intentionally choose
* a different preferred first env var than auth resolution.
*/
export const PROVIDER_ENV_VARS: Record<string, readonly string[]> = {
...PROVIDER_AUTH_ENV_VAR_CANDIDATES,
anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"],
google: ["GEMINI_API_KEY"],
"minimax-cn": ["MINIMAX_API_KEY"],
xai: ["XAI_API_KEY"],
};
const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY"] as const;
const KNOWN_SECRET_ENV_VARS = [
...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys)),
@ -53,7 +45,11 @@ const KNOWN_SECRET_ENV_VARS = [
// OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must
// remain available to child bridge/runtime processes.
const KNOWN_PROVIDER_AUTH_ENV_VARS = [
...new Set([...KNOWN_SECRET_ENV_VARS, ...EXTRA_PROVIDER_AUTH_ENV_VARS]),
...new Set([
...Object.values(PROVIDER_AUTH_ENV_VAR_CANDIDATES).flatMap((keys) => keys),
...KNOWN_SECRET_ENV_VARS,
...EXTRA_PROVIDER_AUTH_ENV_VARS,
]),
];
export function listKnownProviderAuthEnvVarNames(): string[] {