fix: restore provider auth and build checks

This commit is contained in:
Peter Steinberger
2026-03-27 20:17:00 +00:00
parent c28e76c490
commit d73dbb6753
29 changed files with 328 additions and 109 deletions

View File

@@ -65,7 +65,7 @@ describe("compaction identifier-preservation instructions", () => {
}
function firstSummaryInstructions() {
return mockGenerateSummary.mock.calls[0]?.[6];
return mockGenerateSummary.mock.calls[0]?.[5];
}
it("injects identifier-preservation guidance even without custom instructions", async () => {
@@ -101,7 +101,7 @@ describe("compaction identifier-preservation instructions", () => {
expect(mockGenerateSummary.mock.calls.length).toBeGreaterThan(1);
for (const call of mockGenerateSummary.mock.calls) {
expect(call[6]).toContain("Preserve all opaque identifiers exactly as written");
expect(call[5]).toContain("Preserve all opaque identifiers exactly as written");
}
});
@@ -114,7 +114,7 @@ describe("compaction identifier-preservation instructions", () => {
});
const mergedCall = mockGenerateSummary.mock.calls.at(-1);
const instructions = mergedCall?.[6] ?? "";
const instructions = mergedCall?.[5] ?? "";
expect(instructions).toContain("Merge these partial summaries into a single cohesive summary.");
expect(instructions).toContain("Prioritize customer-visible regressions.");
expect((instructions.match(/Additional focus:/g) ?? []).length).toBe(1);

View File

@@ -56,7 +56,7 @@ describe("compaction retry integration", () => {
} as unknown as NonNullable<ExtensionContext["model"]>;
const invokeGenerateSummary = (signal = new AbortController().signal) =>
mockGenerateSummary(testMessages, testModel, 1000, "test-api-key", undefined, signal);
mockGenerateSummary(testMessages, testModel, 1000, "test-api-key", signal);
const runSummaryRetry = (options: Parameters<typeof retryAsync>[1]) =>
retryAsync(() => invokeGenerateSummary(), options);

View File

@@ -19,7 +19,13 @@ vi.mock("@mariozechner/pi-coding-agent", async () => {
};
});
import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js";
let isOversizedForSummary: typeof import("./compaction.js").isOversizedForSummary;
let summarizeWithFallback: typeof import("./compaction.js").summarizeWithFallback;
async function loadFreshCompactionModuleForTest() {
vi.resetModules();
({ isOversizedForSummary, summarizeWithFallback } = await import("./compaction.js"));
}
function makeAssistantToolCall(timestamp: number): AssistantMessage {
return makeAgentAssistantMessage({
@@ -43,8 +49,12 @@ function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw:
}
describe("compaction toolResult details stripping", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(async () => {
await loadFreshCompactionModuleForTest();
piCodingAgentMocks.generateSummary.mockReset();
piCodingAgentMocks.generateSummary.mockResolvedValue("summary");
piCodingAgentMocks.estimateTokens.mockReset();
piCodingAgentMocks.estimateTokens.mockImplementation((_message: unknown) => 1);
});
it("does not pass toolResult.details into generateSummary", async () => {

View File

@@ -1649,7 +1649,7 @@ describe("compaction-safeguard extension model fallback", () => {
messageText: "test message",
tokensBefore: 1000,
});
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
const { result, getApiKeyMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: null,
@@ -1660,7 +1660,7 @@ describe("compaction-safeguard extension model fallback", () => {
// KEY ASSERTION: Prove the fallback path was exercised
// The handler should have resolved request auth with runtime.model
// (via ctx.model ?? runtime?.model).
expect(getApiKeyAndHeadersMock).toHaveBeenCalledWith(model);
expect(getApiKeyMock).toHaveBeenCalledWith(model);
// Verify runtime.model is still available (for completeness)
const retrieved = getCompactionSafeguardRuntime(sessionManager);
@@ -1676,7 +1676,7 @@ describe("compaction-safeguard extension model fallback", () => {
messageText: "test",
tokensBefore: 500,
});
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
const { result, getApiKeyMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: null,
@@ -1685,7 +1685,7 @@ describe("compaction-safeguard extension model fallback", () => {
expect(result).toEqual({ cancel: true });
// Verify early return: request auth should NOT have been resolved when both models are missing.
expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled();
expect(getApiKeyMock).not.toHaveBeenCalled();
});
});
@@ -1706,7 +1706,7 @@ describe("compaction-safeguard double-compaction guard", () => {
customInstructions: "",
signal: new AbortController().signal,
};
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
const { result, getApiKeyMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "sk-test", // pragma: allowlist secret
@@ -1720,7 +1720,7 @@ describe("compaction-safeguard double-compaction guard", () => {
expect(compaction.summary).toContain("## Open TODOs");
expect(compaction.firstKeptEntryId).toBe("entry-1");
expect(compaction.tokensBefore).toBe(1500);
expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled();
expect(getApiKeyMock).not.toHaveBeenCalled();
});
it("returns compaction result with structured fallback summary sections", async () => {
@@ -1831,13 +1831,13 @@ describe("compaction-safeguard double-compaction guard", () => {
messageText: "real message",
tokensBefore: 1500,
});
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
const { result, getApiKeyMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: null,
});
expect(result).toEqual({ cancel: true });
expect(getApiKeyAndHeadersMock).toHaveBeenCalled();
expect(getApiKeyMock).toHaveBeenCalled();
});
it("treats tool results as real conversation only when linked to a meaningful user ask", async () => {

View File

@@ -1,5 +1,5 @@
import type { Skill } from "@mariozechner/pi-coding-agent";
export function resolveSkillSource(skill: Skill): string {
return skill.sourceInfo.source;
return skill.source;
}

View File

@@ -1,4 +1,4 @@
import { normalizeWhatsAppTarget } from "../../../plugin-sdk/whatsapp-shared.js";
import { normalizeWhatsAppTarget } from "../../../plugin-sdk/whatsapp-targets.js";
import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js";
export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined {

View File

@@ -53,6 +53,7 @@ function createPromptAndCredentialSpies(params?: { confirmResult?: boolean; text
async function ensureMinimaxApiKey(params: {
config?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["config"];
env?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["env"];
confirm: WizardPrompter["confirm"];
note?: WizardPrompter["note"];
select?: WizardPrompter["select"];
@@ -62,6 +63,7 @@ async function ensureMinimaxApiKey(params: {
}) {
return await ensureMinimaxApiKeyInternal({
config: params.config,
env: params.env,
prompter: createPrompter({
confirm: params.confirm,
note: params.note,
@@ -75,12 +77,14 @@ async function ensureMinimaxApiKey(params: {
async function ensureMinimaxApiKeyInternal(params: {
config?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["config"];
env?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["env"];
prompter: WizardPrompter;
secretInputMode?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["secretInputMode"];
setCredential: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["setCredential"];
}) {
return await ensureApiKeyFromEnvOrPrompt({
config: params.config ?? {},
env: params.env,
provider: "minimax",
envLabel: "MINIMAX_API_KEY",
promptMessage: "Enter key",
@@ -94,6 +98,7 @@ async function ensureMinimaxApiKeyInternal(params: {
async function ensureMinimaxApiKeyWithEnvRefPrompter(params: {
config?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["config"];
env?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["env"];
note: WizardPrompter["note"];
select: WizardPrompter["select"];
setCredential: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["setCredential"];
@@ -101,6 +106,7 @@ async function ensureMinimaxApiKeyWithEnvRefPrompter(params: {
}) {
return await ensureMinimaxApiKeyInternal({
config: params.config,
env: params.env,
prompter: createPrompter({ select: params.select, text: params.text, note: params.note }),
secretInputMode: "ref", // pragma: allowlist secret
setCredential: params.setCredential,
@@ -287,6 +293,28 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
expect(setCredential).not.toHaveBeenCalled();
});
it("uses explicit env for ref fallback instead of host process env", async () => {
process.env.MINIMAX_API_KEY = "host-key"; // pragma: allowlist secret
delete process.env.MINIMAX_OAUTH_TOKEN;
const env = { MINIMAX_API_KEY: "explicit-key" } as NodeJS.ProcessEnv;
const { confirm, text, setCredential } = createPromptAndCredentialSpies({
confirmResult: true,
textResult: "prompt-key",
});
const result = await ensureMinimaxApiKey({
confirm,
text,
env,
secretInputMode: "ref", // pragma: allowlist secret
setCredential,
});
expect(result).toBe("explicit-key");
expectMinimaxEnvRefCredentialStored(setCredential);
});
it("re-prompts after provider ref validation failure and succeeds with env ref", async () => {
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
delete process.env.MINIMAX_OAUTH_TOKEN;

View File

@@ -11,6 +11,7 @@ import type { AuthChoice, OnboardOptions } from "./onboard-types.js";
export type ApplyAuthChoiceParams = {
authChoice: AuthChoice;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
prompter: WizardPrompter;
runtime: RuntimeEnv;
agentDir?: string;
@@ -30,13 +31,13 @@ export async function applyAuthChoice(
const normalizedAuthChoice =
normalizeLegacyOnboardAuthChoice(params.authChoice, {
config: params.config,
env: process.env,
env: params.env,
}) ?? params.authChoice;
const normalizedProviderAuthChoice = normalizeApiKeyTokenProviderAuthChoice({
authChoice: normalizedAuthChoice,
tokenProvider: params.opts?.tokenProvider,
config: params.config,
env: process.env,
env: params.env,
});
const normalizedParams =
normalizedProviderAuthChoice === params.authChoice

View File

@@ -67,9 +67,38 @@ describe("resolvePreferredProviderForAuthChoice", () => {
expect(resolvePluginProviders).not.toHaveBeenCalled();
});
it("falls back to static core choices when no provider plugin claims the choice", async () => {
it("passes explicit env through legacy auth normalization", async () => {
const env = { OPENCLAW_AUTH_CHOICE_TEST: "1" } as NodeJS.ProcessEnv;
resolveManifestDeprecatedProviderAuthChoice.mockReturnValue({
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
});
resolveManifestProviderAuthChoice.mockReturnValue({
pluginId: "anthropic",
providerId: "anthropic",
methodId: "cli",
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
});
await expect(
resolvePreferredProviderForAuthChoice({ choice: "claude-cli", env }),
).resolves.toBe("anthropic");
expect(resolveManifestDeprecatedProviderAuthChoice).toHaveBeenCalledWith("claude-cli", { env });
});
it("uses manifest metadata for plugin-owned choices", async () => {
resolveManifestProviderAuthChoice.mockReturnValue({
pluginId: "chutes",
providerId: "chutes",
methodId: "oauth",
choiceId: "chutes",
choiceLabel: "Chutes OAuth",
});
await expect(resolvePreferredProviderForAuthChoice({ choice: "chutes" })).resolves.toBe(
"chutes",
);
expect(resolvePluginProviders).not.toHaveBeenCalled();
});
});

View File

@@ -1394,6 +1394,42 @@ describe("applyAuthChoice", () => {
});
});
it("uses explicit env for plugin auth resolution instead of host env", async () => {
await setupTempState();
process.env.OPENAI_API_KEY = "sk-openai-host"; // pragma: allowlist secret
const env = { OPENAI_API_KEY: "sk-openai-explicit" } as NodeJS.ProcessEnv; // pragma: allowlist secret
const text = vi.fn().mockResolvedValue("should-not-be-used");
const confirm = vi.fn(async () => true);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "openai-api-key",
config: {},
env,
prompter,
runtime,
setDefaultModel: false,
});
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
config: {},
env,
}),
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("OPENAI_API_KEY"),
}),
);
expect(text).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.["openai:default"]).toMatchObject({
provider: "openai",
mode: "api_key",
});
expect((await readAuthProfile("openai:default"))?.key).toBe("sk-openai-explicit");
});
it("keeps existing default model for explicit provider keys when setDefaultModel=false", async () => {
const scenarios: Array<{
authChoice: "xai-api-key" | "opencode-zen" | "opencode-go";

View File

@@ -276,6 +276,7 @@ async function runProviderAuthMethod(params: {
const result = await params.method.run({
config: params.config,
env: process.env,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
prompter: params.prompter,

View File

@@ -13,7 +13,7 @@ import {
resolveSessionDeliveryTarget,
} from "../../infra/outbound/targets.js";
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
import { normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-shared.js";
import { normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-targets.js";
import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js";
import { buildChannelAccountBindings } from "../../routing/bindings.js";
import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js";

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js";
import type { OpenClawConfig } from "../../config/config.js";
import { parseTelegramTarget } from "../../plugin-sdk/telegram.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-shared.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-targets.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { resolveOutboundTarget } from "./targets.js";

View File

@@ -3,7 +3,7 @@ import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbou
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-shared.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../plugin-sdk/whatsapp-targets.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import {

View File

@@ -71,7 +71,7 @@ export function withBundledPluginVitestCompat(params: {
env?: PluginLoadOptions["env"];
}): PluginLoadOptions["config"] {
const env = params.env ?? process.env;
const isVitest = Boolean(env.VITEST || process.env.VITEST);
const isVitest = Boolean(env.VITEST);
if (
!isVitest ||
hasExplicitPluginConfig(params.config?.plugins) ||
@@ -80,12 +80,20 @@ export function withBundledPluginVitestCompat(params: {
return params.config;
}
const entries = Object.fromEntries(
params.pluginIds.map((pluginId) => [pluginId, { enabled: true } satisfies PluginEntryConfig]),
);
return {
...params.config,
plugins: {
...params.config?.plugins,
enabled: true,
allow: [...params.pluginIds],
entries: {
...entries,
...params.config?.plugins?.entries,
},
slots: {
...params.config?.plugins?.slots,
memory: "none",

View File

@@ -46,10 +46,7 @@ export function resolvePluginSnapshotCacheTtlMs(env: NodeJS.ProcessEnv): number
return Math.min(discoveryCacheMs, manifestCacheMs);
}
export function buildPluginSnapshotCacheEnvKey(
env: NodeJS.ProcessEnv,
options: { includeProcessVitestFallback?: boolean } = {},
) {
export function buildPluginSnapshotCacheEnvKey(env: NodeJS.ProcessEnv) {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "",
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "",
@@ -61,8 +58,6 @@ export function buildPluginSnapshotCacheEnvKey(
OPENCLAW_CONFIG_PATH: env.OPENCLAW_CONFIG_PATH ?? "",
HOME: env.HOME ?? "",
USERPROFILE: env.USERPROFILE ?? "",
VITEST: options.includeProcessVitestFallback
? (env.VITEST ?? process.env.VITEST ?? "")
: (env.VITEST ?? ""),
VITEST: env.VITEST ?? "",
};
}

View File

@@ -63,6 +63,17 @@ function writePluginPackageManifest(params: {
);
}
function writePluginManifest(params: { pluginDir: string; id: string }) {
fs.writeFileSync(
path.join(params.pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: params.id,
configSchema: { type: "object" },
}),
"utf-8",
);
}
function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) {
expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe(
true,
@@ -205,6 +216,7 @@ describe("discoverOpenClawPlugins", () => {
packageName: "@openclaw/ollama-provider",
extensions: ["./src/index.ts"],
});
writePluginManifest({ pluginDir: globalExt, id: "ollama" });
fs.writeFileSync(
path.join(globalExt, "src", "index.ts"),
"export default function () {}",
@@ -232,11 +244,13 @@ describe("discoverOpenClawPlugins", () => {
packageName: "@openclaw/elevenlabs-speech",
extensions: ["./src/index.ts"],
});
writePluginManifest({ pluginDir: elevenlabsDir, id: "elevenlabs" });
writePluginPackageManifest({
packageDir: microsoftDir,
packageName: "@openclaw/microsoft-speech",
extensions: ["./src/index.ts"],
});
writePluginManifest({ pluginDir: microsoftDir, id: "microsoft" });
fs.writeFileSync(
path.join(elevenlabsDir, "src", "index.ts"),

View File

@@ -10,6 +10,7 @@ import {
import {
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
getPackageManifestMetadata,
loadPluginManifest,
type PluginManifest,
resolvePackageExtensionEntries,
type OpenClawPackageManifest,
@@ -21,14 +22,6 @@ import type { PluginBundleFormat, PluginDiagnostic, PluginFormat, PluginOrigin }
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
const CANONICAL_PACKAGE_ID_ALIASES: Record<string, string> = {
"elevenlabs-speech": "elevenlabs",
"microsoft-speech": "microsoft",
"ollama-provider": "ollama",
"sglang-provider": "sglang",
"vllm-provider": "vllm",
};
export type PluginCandidate = {
idHint: string;
source: string;
@@ -338,10 +331,15 @@ function readPackageManifest(dir: string, rejectHardlinks = true): PackageManife
function deriveIdHint(params: {
filePath: string;
manifestId?: string;
packageName?: string;
hasMultipleExtensions: boolean;
}): string {
const base = path.basename(params.filePath, path.extname(params.filePath));
const rawManifestId = params.manifestId?.trim();
if (rawManifestId) {
return params.hasMultipleExtensions ? `${rawManifestId}/${base}` : rawManifestId;
}
const rawPackageName = params.packageName?.trim();
if (!rawPackageName) {
return base;
@@ -352,11 +350,10 @@ function deriveIdHint(params: {
const unscoped = rawPackageName.includes("/")
? (rawPackageName.split("/").pop() ?? rawPackageName)
: rawPackageName;
const canonicalPackageId = CANONICAL_PACKAGE_ID_ALIASES[unscoped] ?? unscoped;
const normalizedPackageId =
canonicalPackageId.endsWith("-provider") && canonicalPackageId.length > "-provider".length
? canonicalPackageId.slice(0, -"-provider".length)
: canonicalPackageId;
unscoped.endsWith("-provider") && unscoped.length > "-provider".length
? unscoped.slice(0, -"-provider".length)
: unscoped;
if (!params.hasMultipleExtensions) {
return normalizedPackageId;
@@ -364,6 +361,11 @@ function deriveIdHint(params: {
return `${normalizedPackageId}/${base}`;
}
function resolveIdHintManifestId(rootDir: string, rejectHardlinks: boolean): string | undefined {
const manifest = loadPluginManifest(rootDir, rejectHardlinks);
return manifest.ok ? manifest.manifest.id : undefined;
}
function addCandidate(params: {
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
@@ -558,6 +560,7 @@ function discoverInDirectory(params: {
const manifest = readPackageManifest(fullPath, rejectHardlinks);
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const manifestId = resolveIdHintManifestId(fullPath, rejectHardlinks);
const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry;
const setupSource =
typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0
@@ -588,6 +591,7 @@ function discoverInDirectory(params: {
seen: params.seen,
idHint: deriveIdHint({
filePath: resolved,
manifestId,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
@@ -688,6 +692,7 @@ function discoverFromPath(params: {
const manifest = readPackageManifest(resolved, rejectHardlinks);
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const manifestId = resolveIdHintManifestId(resolved, rejectHardlinks);
const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry;
const setupSource =
typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0
@@ -718,6 +723,7 @@ function discoverFromPath(params: {
seen: params.seen,
idHint: deriveIdHint({
filePath: source,
manifestId,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),

View File

@@ -111,6 +111,7 @@ export function createProviderApiKeyAuthMethod(
? (ctx.secretInputMode ?? "plaintext")
: ctx.secretInputMode,
config: ctx.config,
env: ctx.env,
expectedProviders: params.expectedProviders ?? [params.providerId],
provider: params.providerId,
envLabel: params.envVar,

View File

@@ -2,13 +2,8 @@ import { normalizeLegacyOnboardAuthChoice } from "../commands/auth-choice-legacy
import type { OpenClawConfig } from "../config/config.js";
import { resolveManifestProviderAuthChoice } from "./provider-auth-choices.js";
const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<string, string>> = {
chutes: "chutes",
"custom-api-key": "custom",
};
function normalizeLegacyAuthChoice(choice: string): string {
return normalizeLegacyOnboardAuthChoice(choice, { env: process.env }) ?? choice;
function normalizeLegacyAuthChoice(choice: string, env?: NodeJS.ProcessEnv): string {
return normalizeLegacyOnboardAuthChoice(choice, { env }) ?? choice;
}
export async function resolvePreferredProviderForAuthChoice(params: {
@@ -17,7 +12,7 @@ export async function resolvePreferredProviderForAuthChoice(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
const choice = normalizeLegacyAuthChoice(params.choice) ?? params.choice;
const choice = normalizeLegacyAuthChoice(params.choice, params.env) ?? params.choice;
const manifestResolved = resolveManifestProviderAuthChoice(choice, params);
if (manifestResolved) {
return manifestResolved.providerId;
@@ -40,5 +35,8 @@ export async function resolvePreferredProviderForAuthChoice(params: {
return pluginResolved.provider.id;
}
return PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice];
if (choice === "custom-api-key") {
return "custom";
}
return undefined;
}

View File

@@ -24,6 +24,7 @@ import type { ProviderAuthMethod, ProviderAuthOptionBag } from "./types.js";
export type ApplyProviderAuthChoiceParams = {
authChoice: string;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
prompter: WizardPrompter;
runtime: RuntimeEnv;
agentDir?: string;
@@ -83,6 +84,7 @@ async function loadPluginProviderRuntime() {
export async function runProviderPluginAuthMethod(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
runtime: RuntimeEnv;
prompter: WizardPrompter;
method: ProviderAuthMethod;
@@ -108,6 +110,7 @@ export async function runProviderPluginAuthMethod(params: {
const result = await params.method.run({
config: params.config,
env: params.env,
agentDir,
workspaceDir,
prompter: params.prompter,
@@ -170,6 +173,7 @@ export async function applyAuthChoiceLoadedPluginProvider(
const providers = resolvePluginProviders({
config: params.config,
workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
@@ -183,6 +187,7 @@ export async function applyAuthChoiceLoadedPluginProvider(
const applied = await runProviderPluginAuthMethod({
config: params.config,
env: params.env,
runtime: params.runtime,
prompter: params.prompter,
method: resolved.method,
@@ -250,6 +255,7 @@ export async function applyAuthChoicePluginProvider(
const providers = resolvePluginProviders({
config: nextConfig,
workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
@@ -270,6 +276,7 @@ export async function applyAuthChoicePluginProvider(
const applied = await runProviderPluginAuthMethod({
config: nextConfig,
env: params.env,
runtime: params.runtime,
prompter: params.prompter,
method,

View File

@@ -119,6 +119,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
tokenProvider: string | undefined;
secretInputMode?: SecretInputMode;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
expectedProviders: string[];
provider: string;
envLabel: string;
@@ -148,6 +149,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
return await ensureApiKeyFromEnvOrPrompt({
config: params.config,
env: params.env,
provider: params.provider,
envLabel: params.envLabel,
promptMessage: params.promptMessage,
@@ -161,6 +163,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
export async function ensureApiKeyFromEnvOrPrompt(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
provider: string;
envLabel: string;
promptMessage: string;
@@ -174,7 +177,8 @@ export async function ensureApiKeyFromEnvOrPrompt(params: {
prompter: params.prompter,
explicitMode: params.secretInputMode,
});
const envKey = resolveEnvApiKey(params.provider);
const env = params.env ?? process.env;
const envKey = resolveEnvApiKey(params.provider, env);
if (selectedMode === "ref") {
if (typeof params.prompter.select !== "function") {
@@ -182,6 +186,7 @@ export async function ensureApiKeyFromEnvOrPrompt(params: {
config: params.config,
provider: params.provider,
preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined,
env,
});
await params.setCredential(fallback.ref, selectedMode);
return fallback.resolvedValue;
@@ -191,6 +196,7 @@ export async function ensureApiKeyFromEnvOrPrompt(params: {
config: params.config,
prompter: params.prompter,
preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined,
env,
});
await params.setCredential(resolved.ref, selectedMode);
return resolved.resolvedValue;

View File

@@ -57,6 +57,7 @@ export function resolveRefFallbackInput(params: {
config: OpenClawConfig;
provider: string;
preferredEnvVar?: string;
env?: NodeJS.ProcessEnv;
}): { ref: SecretRef; resolvedValue: string } {
const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider);
if (!fallbackEnvVar) {
@@ -64,7 +65,8 @@ export function resolveRefFallbackInput(params: {
`No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`,
);
}
const value = process.env[fallbackEnvVar]?.trim();
const env = params.env ?? process.env;
const value = env[fallbackEnvVar]?.trim();
if (!value) {
throw new Error(
`Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`,
@@ -88,7 +90,9 @@ async function promptEnvSecretRefForSetup(params: {
prompter: WizardPrompter;
defaultEnvVar: string;
copy?: SecretRefSetupPromptCopy;
env?: NodeJS.ProcessEnv;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const env = params.env ?? process.env;
const envVarRaw = await params.prompter.text({
message: params.copy?.envVarMessage ?? "Environment variable name",
initialValue: params.defaultEnvVar || undefined,
@@ -101,7 +105,7 @@ async function promptEnvSecretRefForSetup(params: {
'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'
);
}
if (!process.env[candidate]?.trim()) {
if (!env[candidate]?.trim()) {
return (
params.copy?.envVarMissingError?.(candidate) ??
`Environment variable "${candidate}" is missing or empty in this session.`
@@ -118,7 +122,7 @@ async function promptEnvSecretRefForSetup(params: {
`No valid environment variable name provided for provider "${params.provider}".`,
);
}
const resolvedValue = process.env[envVar]?.trim();
const resolvedValue = env[envVar]?.trim();
if (!resolvedValue) {
throw new Error(`Environment variable "${envVar}" is missing or empty in this session.`);
}
@@ -143,6 +147,7 @@ async function promptProviderSecretRefForSetup(params: {
prompter: WizardPrompter;
defaultFilePointer: string;
copy?: SecretRefSetupPromptCopy;
env?: NodeJS.ProcessEnv;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter(
([, provider]) => provider?.source === "file" || provider?.source === "exec",
@@ -229,7 +234,7 @@ async function promptProviderSecretRefForSetup(params: {
const { resolveSecretRefString } = await loadSecretResolve();
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: process.env,
env: params.env ?? process.env,
});
await params.prompter.note(
params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ??
@@ -256,6 +261,7 @@ export async function promptSecretRefForSetup(params: {
prompter: WizardPrompter;
preferredEnvVar?: string;
copy?: SecretRefSetupPromptCopy;
env?: NodeJS.ProcessEnv;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const defaultEnvVar =
params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? "";
@@ -289,6 +295,7 @@ export async function promptSecretRefForSetup(params: {
prompter: params.prompter,
defaultEnvVar,
copy: params.copy,
env: params.env,
});
}
@@ -299,6 +306,7 @@ export async function promptSecretRefForSetup(params: {
prompter: params.prompter,
defaultFilePointer,
copy: params.copy,
env: params.env,
});
} catch (error) {
if (error instanceof Error && error.message === "retry") {

View File

@@ -7,7 +7,6 @@ import {
writeOAuthCredentials,
type WriteOAuthCredentialsOptions,
} from "./provider-auth-helpers.js";
import { KILOCODE_DEFAULT_MODEL_REF } from "./provider-model-kilocode.js";
const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir();
const ZAI_DEFAULT_MODEL_REF = "zai/glm-5";
@@ -53,16 +52,21 @@ function createProviderApiKeySetter(
};
}
export {
HUGGINGFACE_DEFAULT_MODEL_REF,
KILOCODE_DEFAULT_MODEL_REF,
LITELLM_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
TOGETHER_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
XIAOMI_DEFAULT_MODEL_REF,
ZAI_DEFAULT_MODEL_REF,
type ProviderApiKeySetterSpec = {
provider: string;
resolveKey?: (key: SecretInput) => SecretInput;
};
function createProviderApiKeySetters<const T extends Record<string, ProviderApiKeySetterSpec>>(
specs: T,
): { [K in keyof T]: ProviderApiKeySetter } {
const entries = Object.entries(specs).map(([name, spec]) => [
name,
createProviderApiKeySetter(spec.provider, spec.resolveKey),
]);
return Object.fromEntries(entries) as { [K in keyof T]: ProviderApiKeySetter };
}
export {
buildApiKeyCredential,
type ApiKeyStorageOptions,
@@ -70,9 +74,78 @@ export {
type WriteOAuthCredentialsOptions,
};
export const setAnthropicApiKey = createProviderApiKeySetter("anthropic");
export const setOpenaiApiKey = createProviderApiKeySetter("openai");
export const setGeminiApiKey = createProviderApiKeySetter("google");
const {
setAnthropicApiKey,
setOpenaiApiKey,
setGeminiApiKey,
setMoonshotApiKey,
setKimiCodingApiKey,
setVolcengineApiKey,
setByteplusApiKey,
setSyntheticApiKey,
setVeniceApiKey,
setZaiApiKey,
setXiaomiApiKey,
setOpenrouterApiKey,
setLitellmApiKey,
setVercelAiGatewayApiKey,
setTogetherApiKey,
setHuggingfaceApiKey,
setQianfanApiKey,
setModelStudioApiKey,
setXaiApiKey,
setMistralApiKey,
setKilocodeApiKey,
} = createProviderApiKeySetters({
setAnthropicApiKey: { provider: "anthropic" },
setOpenaiApiKey: { provider: "openai" },
setGeminiApiKey: { provider: "google" },
setMoonshotApiKey: { provider: "moonshot" },
setKimiCodingApiKey: { provider: "kimi" },
setVolcengineApiKey: { provider: "volcengine" },
setByteplusApiKey: { provider: "byteplus" },
setSyntheticApiKey: { provider: "synthetic" },
setVeniceApiKey: { provider: "venice" },
setZaiApiKey: { provider: "zai" },
setXiaomiApiKey: { provider: "xiaomi" },
setOpenrouterApiKey: {
provider: "openrouter",
resolveKey: (key) => (typeof key === "string" && key === "undefined" ? "" : key),
},
setLitellmApiKey: { provider: "litellm" },
setVercelAiGatewayApiKey: { provider: "vercel-ai-gateway" },
setTogetherApiKey: { provider: "together" },
setHuggingfaceApiKey: { provider: "huggingface" },
setQianfanApiKey: { provider: "qianfan" },
setModelStudioApiKey: { provider: "modelstudio" },
setXaiApiKey: { provider: "xai" },
setMistralApiKey: { provider: "mistral" },
setKilocodeApiKey: { provider: "kilocode" },
});
export {
setAnthropicApiKey,
setOpenaiApiKey,
setGeminiApiKey,
setMoonshotApiKey,
setKimiCodingApiKey,
setVolcengineApiKey,
setByteplusApiKey,
setSyntheticApiKey,
setVeniceApiKey,
setZaiApiKey,
setXiaomiApiKey,
setOpenrouterApiKey,
setLitellmApiKey,
setVercelAiGatewayApiKey,
setTogetherApiKey,
setHuggingfaceApiKey,
setQianfanApiKey,
setModelStudioApiKey,
setXaiApiKey,
setMistralApiKey,
setKilocodeApiKey,
};
export async function setMinimaxApiKey(
key: SecretInput,
@@ -84,18 +157,6 @@ export async function setMinimaxApiKey(
upsertProviderApiKeyProfile({ provider, key, agentDir, options, profileId });
}
export const setMoonshotApiKey = createProviderApiKeySetter("moonshot");
export const setKimiCodingApiKey = createProviderApiKeySetter("kimi");
export const setVolcengineApiKey = createProviderApiKeySetter("volcengine");
export const setByteplusApiKey = createProviderApiKeySetter("byteplus");
export const setSyntheticApiKey = createProviderApiKeySetter("synthetic");
export const setVeniceApiKey = createProviderApiKeySetter("venice");
export const setZaiApiKey = createProviderApiKeySetter("zai");
export const setXiaomiApiKey = createProviderApiKeySetter("xiaomi");
export const setOpenrouterApiKey = createProviderApiKeySetter("openrouter", (key) =>
typeof key === "string" && key === "undefined" ? "" : key,
);
export async function setCloudflareAiGatewayConfig(
accountId: string,
gatewayId: string,
@@ -117,9 +178,6 @@ export async function setCloudflareAiGatewayConfig(
});
}
export const setLitellmApiKey = createProviderApiKeySetter("litellm");
export const setVercelAiGatewayApiKey = createProviderApiKeySetter("vercel-ai-gateway");
export async function setOpencodeZenApiKey(
key: SecretInput,
agentDir?: string,
@@ -145,11 +203,3 @@ async function setSharedOpencodeApiKey(
upsertProviderApiKeyProfile({ provider, key, agentDir, options });
}
}
export const setTogetherApiKey = createProviderApiKeySetter("together");
export const setHuggingfaceApiKey = createProviderApiKeySetter("huggingface");
export const setQianfanApiKey = createProviderApiKeySetter("qianfan");
export const setModelStudioApiKey = createProviderApiKeySetter("modelstudio");
export const setXaiApiKey = createProviderApiKeySetter("xai");
export const setMistralApiKey = createProviderApiKeySetter("mistral");
export const setKilocodeApiKey = createProviderApiKeySetter("kilocode");

View File

@@ -41,20 +41,19 @@ export function resolvePluginProviders(params: {
pluginIds: bundledProviderCompatPluginIds,
})
: params.config;
const maybeVitestCompat = params.bundledProviderVitestCompat
? withBundledProviderVitestCompat({
const allowlistCompatConfig = params.bundledProviderAllowlistCompat
? withBundledPluginEnablementCompat({
config: maybeAllowlistCompat,
pluginIds: bundledProviderCompatPluginIds,
})
: maybeAllowlistCompat;
const config = params.bundledProviderVitestCompat
? withBundledProviderVitestCompat({
config: allowlistCompatConfig,
pluginIds: bundledProviderCompatPluginIds,
env: params.env,
})
: maybeAllowlistCompat;
const config =
params.bundledProviderAllowlistCompat || params.bundledProviderVitestCompat
? withBundledPluginEnablementCompat({
config: maybeVitestCompat,
pluginIds: bundledProviderCompatPluginIds,
})
: maybeVitestCompat;
: allowlistCompatConfig;
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,

View File

@@ -106,6 +106,30 @@ describe("resolvePluginProviders", () => {
);
});
it("does not leak host Vitest env into an explicit non-Vitest env", () => {
const previousVitest = process.env.VITEST;
process.env.VITEST = "1";
try {
resolvePluginProviders({
env: {} as NodeJS.ProcessEnv,
bundledProviderVitestCompat: true,
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: undefined,
env: {},
}),
);
} finally {
if (previousVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = previousVitest;
}
}
});
it("does not reintroduce the retired google auth plugin id into compat allowlists", () => {
resolvePluginProviders({
config: {

View File

@@ -169,6 +169,7 @@ export type ProviderAuthResult = {
/** Interactive auth context passed to provider login/setup methods. */
export type ProviderAuthContext = {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
workspaceDir?: string;
prompter: WizardPrompter;

View File

@@ -276,7 +276,7 @@ describe("resolvePluginWebSearchProviders", () => {
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2);
});
it("invalidates the snapshot cache when global Vitest fallback changes", () => {
it("does not leak host Vitest env into an explicit non-Vitest cache key", () => {
const originalVitest = process.env.VITEST;
const config = {};
const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
@@ -305,7 +305,7 @@ describe("resolvePluginWebSearchProviders", () => {
}
}
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2);
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1);
});
it("expires web-search snapshot memoization after the shortest plugin cache ttl", () => {

View File

@@ -44,7 +44,6 @@ function buildWebSearchSnapshotCacheKey(params: {
onlyPluginIds?: readonly string[];
env: NodeJS.ProcessEnv;
}): string {
const effectiveVitest = params.env.VITEST ?? process.env.VITEST ?? "";
return JSON.stringify({
workspaceDir: params.workspaceDir ?? "",
bundledAllowlistCompat: params.bundledAllowlistCompat === true,
@@ -52,9 +51,7 @@ function buildWebSearchSnapshotCacheKey(params: {
left.localeCompare(right),
),
config: params.config ?? null,
env: buildPluginSnapshotCacheEnvKey(params.env, {
includeProcessVitestFallback: effectiveVitest !== (params.env.VITEST ?? ""),
}),
env: buildPluginSnapshotCacheEnvKey(params.env),
});
}