test: keep provider auth onboarding tests off runtime auth

This commit is contained in:
Peter Steinberger
2026-04-08 09:43:53 +01:00
parent 54f078dc86
commit a5b54e7c01
4 changed files with 368 additions and 111 deletions

View File

@@ -26,6 +26,59 @@ const TEST_AUTH_STORE_VERSION = 1;
const TEST_MAIN_AUTH_STORE_KEY = "__main__";
const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {}));
const readConfigFileSnapshotMock = vi.hoisted(() =>
vi.fn(async () => {
const [{ default: fs }, { default: path }, { default: crypto }] = await Promise.all([
import("node:fs/promises"),
import("node:path"),
import("node:crypto"),
]);
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH must be set for provider auth onboarding tests");
}
let raw: string | null = null;
try {
raw = await fs.readFile(configPath, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const hash = raw === null ? undefined : crypto.createHash("sha256").update(raw).digest("hex");
return {
path: path.resolve(configPath),
exists: raw !== null,
valid: true,
raw,
hash,
config: structuredClone(parsed),
sourceConfig: structuredClone(parsed),
runtimeConfig: structuredClone(parsed),
};
}),
);
const replaceConfigFileMock = vi.hoisted(() =>
vi.fn(async (params: { nextConfig: unknown }) => {
const [{ default: fs }, { default: path }] = await Promise.all([
import("node:fs/promises"),
import("node:path"),
]);
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH must be set for provider auth onboarding tests");
}
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(params.nextConfig, null, 2)}\n`, "utf-8");
return {
path: configPath,
previousHash: null,
snapshot: {},
nextConfig: params.nextConfig,
};
}),
);
const testAuthProfileStores = vi.hoisted(
() => new Map<string, { version: number; profiles: Record<string, Record<string, unknown>> }>(),
);
@@ -88,6 +141,15 @@ function upsertAuthProfile(params: {
writeRuntimeAuthSnapshots();
}
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
replaceConfigFile: replaceConfigFileMock,
};
});
vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async () => {
const [
{ resolveDefaultAgentId, resolveAgentDir, resolveAgentWorkspaceDir },
@@ -782,7 +844,6 @@ const NON_INTERACTIVE_DEFAULT_OPTIONS = {
let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup;
let clearRuntimeAuthProfileStoreSnapshots: typeof import("../agents/auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots;
let ensureAuthProfileStore: typeof import("../agents/auth-profiles.js").ensureAuthProfileStore;
let replaceRuntimeAuthProfileStoreSnapshots: typeof import("../agents/auth-profiles.js").replaceRuntimeAuthProfileStoreSnapshots;
let resetFileLockStateForTest: typeof import("../infra/file-lock.js").resetFileLockStateForTest;
let clearPluginDiscoveryCache: typeof import("../plugins/discovery.js").clearPluginDiscoveryCache;
@@ -980,7 +1041,7 @@ async function expectApiKeyProfile(params: {
key: string;
metadata?: Record<string, string>;
}): Promise<void> {
const store = ensureAuthProfileStore();
const store = getOrCreateTestAuthStore();
const profile = store.profiles[params.profileId];
expect(profile?.type).toBe("api_key");
if (profile?.type === "api_key") {
@@ -994,11 +1055,8 @@ async function expectApiKeyProfile(params: {
async function loadProviderAuthOnboardModules(): Promise<void> {
({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js"));
({
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
replaceRuntimeAuthProfileStoreSnapshots,
} = await import("../agents/auth-profiles.js"));
({ clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots } =
await import("../agents/auth-profiles.js"));
({ resetFileLockStateForTest } = await import("../infra/file-lock.js"));
({ clearPluginDiscoveryCache } = await import("../plugins/discovery.js"));
({ clearPluginManifestRegistryCache } = await import("../plugins/manifest-registry.js"));
@@ -1230,8 +1288,7 @@ describe("onboard (non-interactive): provider auth", () => {
const token = `${cleanToken.slice(0, 30)}\r${cleanToken.slice(30)}`;
await runNonInteractiveSetupWithDefaults(runtime, {
authChoice: "token",
tokenProvider: "anthropic",
authChoice: "setup-token",
token,
tokenProfileId: "anthropic:default",
});
@@ -1240,7 +1297,7 @@ describe("onboard (non-interactive): provider auth", () => {
expect(cfg.auth?.profiles?.["anthropic:default"]?.provider).toBe("anthropic");
expect(cfg.auth?.profiles?.["anthropic:default"]?.mode).toBe("token");
expect(cfg.agents?.defaults?.model?.primary).toBe("anthropic/claude-sonnet-4-6");
expect(ensureAuthProfileStore().profiles["anthropic:default"]).toMatchObject({
expect(getOrCreateTestAuthStore().profiles["anthropic:default"]).toMatchObject({
provider: "anthropic",
type: "token",
token: cleanToken,
@@ -1358,7 +1415,7 @@ describe("onboard (non-interactive): provider auth", () => {
skipSkills: true,
});
const store = ensureAuthProfileStore();
const store = getOrCreateTestAuthStore();
for (const profileId of ["opencode:default", "opencode-go:default"]) {
const profile = store.profiles[profileId];
expect(profile?.type).toBe("api_key");
@@ -1376,66 +1433,6 @@ describe("onboard (non-interactive): provider auth", () => {
});
});
it("configures vLLM via the provider plugin in non-interactive mode", async () => {
await withOnboardEnv("openclaw-onboard-vllm-non-interactive-", async (env) => {
const cfg = await runOnboardingAndReadConfig(env, {
authChoice: "vllm",
customBaseUrl: "http://127.0.0.1:8100/v1",
customApiKey: "vllm-test-key", // pragma: allowlist secret
customModelId: "Qwen/Qwen3-8B",
});
expect(cfg.auth?.profiles?.["vllm:default"]?.provider).toBe("vllm");
expect(cfg.auth?.profiles?.["vllm:default"]?.mode).toBe("api_key");
expect(cfg.models?.providers?.vllm).toEqual({
baseUrl: "http://127.0.0.1:8100/v1",
api: "openai-completions",
apiKey: "VLLM_API_KEY",
models: [
expect.objectContaining({
id: "Qwen/Qwen3-8B",
}),
],
});
expect(cfg.agents?.defaults?.model?.primary).toBe("vllm/Qwen/Qwen3-8B");
await expectApiKeyProfile({
profileId: "vllm:default",
provider: "vllm",
key: "vllm-test-key",
});
});
});
it("configures SGLang via the provider plugin in non-interactive mode", async () => {
await withOnboardEnv("openclaw-onboard-sglang-non-interactive-", async (env) => {
const cfg = await runOnboardingAndReadConfig(env, {
authChoice: "sglang",
customBaseUrl: "http://127.0.0.1:31000/v1",
customApiKey: "sglang-test-key", // pragma: allowlist secret
customModelId: "Qwen/Qwen3-32B",
});
expect(cfg.auth?.profiles?.["sglang:default"]?.provider).toBe("sglang");
expect(cfg.auth?.profiles?.["sglang:default"]?.mode).toBe("api_key");
expect(cfg.models?.providers?.sglang).toEqual({
baseUrl: "http://127.0.0.1:31000/v1",
api: "openai-completions",
apiKey: "SGLANG_API_KEY",
models: [
expect.objectContaining({
id: "Qwen/Qwen3-32B",
}),
],
});
expect(cfg.agents?.defaults?.model?.primary).toBe("sglang/Qwen/Qwen3-32B");
await expectApiKeyProfile({
profileId: "sglang:default",
provider: "sglang",
key: "sglang-test-key",
});
});
});
it("stores LiteLLM API key in the default auth profile", async () => {
await withOnboardEnv("openclaw-onboard-litellm-", async (env) => {
const cfg = await runOnboardingAndReadConfig(env, {
@@ -1646,24 +1643,6 @@ describe("onboard (non-interactive): provider auth", () => {
});
});
it("uses matching profile fallback for non-interactive custom provider auth", async () => {
await withOnboardEnv(
"openclaw-onboard-custom-provider-profile-fallback-",
async ({ configPath, runtime }) => {
upsertAuthProfile({
profileId: `${CUSTOM_LOCAL_PROVIDER_ID}:default`,
credential: {
type: "api_key",
provider: CUSTOM_LOCAL_PROVIDER_ID,
key: "custom-profile-key",
},
});
await runCustomLocalNonInteractive(runtime);
expect(await readCustomLocalProviderApiKey(configPath)).toBe("custom-profile-key");
},
);
});
it("fails custom provider auth when compatibility is invalid", async () => {
await withOnboardEnv(
"openclaw-onboard-custom-provider-invalid-compat-",

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveNonInteractiveApiKey } from "./api-keys.js";
const resolveEnvApiKey = vi.hoisted(() => vi.fn());
vi.mock("../../agents/model-auth.js", () => ({
resolveEnvApiKey,
}));
const authStore = vi.hoisted(
() =>
({
version: 1,
profiles: {} as Record<string, { type: "api_key"; provider: string; key: string }>,
}) as const,
);
const resolveApiKeyForProfile = vi.hoisted(() =>
vi.fn(async (params: { profileId: string }) => {
const profile = authStore.profiles[params.profileId];
return profile?.type === "api_key" ? { apiKey: profile.key, source: "profile" } : null;
}),
);
vi.mock("../../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: vi.fn(() => authStore),
resolveApiKeyForProfile,
resolveAuthProfileOrder: vi.fn(() => Object.keys(authStore.profiles)),
}));
beforeEach(() => {
vi.clearAllMocks();
for (const profileId of Object.keys(authStore.profiles)) {
delete authStore.profiles[profileId];
}
});
function createRuntime() {
return {
error: vi.fn(),
exit: vi.fn(),
};
}
describe("resolveNonInteractiveApiKey", () => {
it("returns explicit flag keys before resolving env or plugin-backed setup", async () => {
const runtime = createRuntime();
resolveEnvApiKey.mockImplementation(() => {
throw new Error("env lookup should not run for an explicit plaintext flag");
});
const result = await resolveNonInteractiveApiKey({
provider: "xai",
cfg: {},
flagValue: "xai-flag-key",
flagName: "--xai-api-key",
envVar: "XAI_API_KEY",
runtime: runtime as never,
});
expect(result).toEqual({ key: "xai-flag-key", source: "flag" });
expect(resolveEnvApiKey).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
it("rejects flag input in secret-ref mode without broad env discovery", async () => {
const runtime = createRuntime();
resolveEnvApiKey.mockReturnValue(null);
const result = await resolveNonInteractiveApiKey({
provider: "xai",
cfg: {},
flagValue: "xai-flag-key",
flagName: "--xai-api-key",
envVar: "XAI_API_KEY",
runtime: runtime as never,
secretInputMode: "ref",
});
expect(result).toBeNull();
expect(resolveEnvApiKey).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--secret-input-mode ref"));
});
it("falls back to a matching API-key profile after flag and env are absent", async () => {
const runtime = createRuntime();
authStore.profiles["custom-models-custom-local:default"] = {
type: "api_key",
provider: "custom-models-custom-local",
key: "custom-profile-key",
};
resolveEnvApiKey.mockReturnValue(null);
const result = await resolveNonInteractiveApiKey({
provider: "custom-models-custom-local",
cfg: {},
flagName: "--custom-api-key",
envVar: "CUSTOM_API_KEY",
runtime: runtime as never,
});
expect(result).toEqual({ key: "custom-profile-key", source: "profile" });
expect(resolveApiKeyForProfile).toHaveBeenCalledWith(
expect.objectContaining({
profileId: "custom-models-custom-local:default",
}),
);
});
});

View File

@@ -62,28 +62,39 @@ export async function resolveNonInteractiveApiKey(params: {
secretInputMode?: SecretInputMode;
}): Promise<{ key: string; source: NonInteractiveApiKeySource; envVarName?: string } | null> {
const flagKey = normalizeOptionalSecretInput(params.flagValue);
const envResolved = resolveEnvApiKey(params.provider);
const explicitEnvVar = params.envVarName?.trim();
const explicitEnvKey = explicitEnvVar
? normalizeOptionalSecretInput(process.env[explicitEnvVar])
: undefined;
const resolvedEnvKey = envResolved?.apiKey ?? explicitEnvKey;
const resolvedEnvVarName = parseEnvVarNameFromSourceLabel(envResolved?.source) ?? explicitEnvVar;
const explicitEnvVar = params.envVarName?.trim() || params.envVar.trim();
const resolveExplicitEnvKey = () => normalizeOptionalSecretInput(process.env[explicitEnvVar]);
const resolveEnvKey = () => {
const envResolved = resolveEnvApiKey(params.provider);
const explicitEnvKey = explicitEnvVar
? normalizeOptionalSecretInput(process.env[explicitEnvVar])
: undefined;
return {
key: envResolved?.apiKey ?? explicitEnvKey,
envVarName: parseEnvVarNameFromSourceLabel(envResolved?.source) ?? explicitEnvVar,
};
};
const useSecretRefMode = params.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (!resolvedEnvKey && flagKey) {
params.runtime.error(
[
`${params.flagName} cannot be used with --secret-input-mode ref unless ${params.envVar} is set in env.`,
`Set ${params.envVar} in env and omit ${params.flagName}, or use --secret-input-mode plaintext.`,
].join("\n"),
);
params.runtime.exit(1);
return null;
if (useSecretRefMode && flagKey) {
const explicitEnvKey = resolveExplicitEnvKey();
if (explicitEnvKey) {
return { key: explicitEnvKey, source: "env", envVarName: explicitEnvVar };
}
if (resolvedEnvKey) {
if (!resolvedEnvVarName) {
params.runtime.error(
[
`${params.flagName} cannot be used with --secret-input-mode ref unless ${params.envVar} is set in env.`,
`Set ${params.envVar} in env and omit ${params.flagName}, or use --secret-input-mode plaintext.`,
].join("\n"),
);
params.runtime.exit(1);
return null;
}
if (useSecretRefMode) {
const resolvedEnv = resolveEnvKey();
if (resolvedEnv.key) {
if (!resolvedEnv.envVarName) {
params.runtime.error(
[
`--secret-input-mode ref requires an explicit environment variable for provider "${params.provider}".`,
@@ -93,7 +104,7 @@ export async function resolveNonInteractiveApiKey(params: {
params.runtime.exit(1);
return null;
}
return { key: resolvedEnvKey, source: "env", envVarName: resolvedEnvVarName };
return { key: resolvedEnv.key, source: "env", envVarName: resolvedEnv.envVarName };
}
}
@@ -101,8 +112,9 @@ export async function resolveNonInteractiveApiKey(params: {
return { key: flagKey, source: "flag" };
}
if (resolvedEnvKey) {
return { key: resolvedEnvKey, source: "env", envVarName: resolvedEnvVarName };
const resolvedEnv = resolveEnvKey();
if (resolvedEnv.key) {
return { key: resolvedEnv.key, source: "env", envVarName: resolvedEnv.envVarName };
}
if (params.allowProfile ?? true) {

View File

@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureOpenAICompatibleSelfHostedProviderNonInteractive } from "./provider-self-hosted-setup.js";
import type { ProviderAuthMethodNonInteractiveContext } from "./types.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => null));
vi.mock("../agents/auth-profiles/upsert-with-lock.js", () => ({
upsertAuthProfileWithLock,
}));
beforeEach(() => {
vi.clearAllMocks();
});
function createRuntime() {
return {
error: vi.fn(),
exit: vi.fn(),
log: vi.fn(),
};
}
function createContext(params: {
providerId: string;
baseUrl?: string;
apiKey?: string;
modelId?: string;
}): ProviderAuthMethodNonInteractiveContext {
const resolved = {
key: params.apiKey ?? "self-hosted-test-key",
source: "flag" as const,
};
return {
authChoice: params.providerId,
config: { agents: { defaults: {} } },
baseConfig: { agents: { defaults: {} } },
opts: {
customBaseUrl: params.baseUrl,
customApiKey: params.apiKey,
customModelId: params.modelId,
},
runtime: createRuntime() as never,
agentDir: "/tmp/openclaw-self-hosted-test-agent",
resolveApiKey: vi.fn<ProviderAuthMethodNonInteractiveContext["resolveApiKey"]>(
async () => resolved,
),
toApiKeyCredential: vi.fn<ProviderAuthMethodNonInteractiveContext["toApiKeyCredential"]>(
({ provider, resolved: apiKeyResult }) => ({
type: "api_key",
provider,
key: apiKeyResult.key,
}),
),
};
}
function readPrimaryModel(config: Awaited<ReturnType<typeof configureSelfHostedTestProvider>>) {
const model = config?.agents?.defaults?.model;
return model && typeof model === "object" ? model.primary : undefined;
}
async function configureSelfHostedTestProvider(params: {
ctx: ProviderAuthMethodNonInteractiveContext;
providerId: string;
providerLabel: string;
envVar: string;
}) {
return await configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx: params.ctx,
providerId: params.providerId,
providerLabel: params.providerLabel,
defaultBaseUrl: "http://127.0.0.1:8000/v1",
defaultApiKeyEnvVar: params.envVar,
modelPlaceholder: "Qwen/Qwen3-32B",
});
}
describe("configureOpenAICompatibleSelfHostedProviderNonInteractive", () => {
it.each([
{
providerId: "vllm",
providerLabel: "vLLM",
envVar: "VLLM_API_KEY",
baseUrl: "http://127.0.0.1:8100/v1/",
apiKey: "vllm-test-key",
modelId: "Qwen/Qwen3-8B",
},
{
providerId: "sglang",
providerLabel: "SGLang",
envVar: "SGLANG_API_KEY",
baseUrl: "http://127.0.0.1:31000/v1",
apiKey: "sglang-test-key",
modelId: "Qwen/Qwen3-32B",
},
])("configures $providerLabel config and auth profile", async (params) => {
const ctx = createContext(params);
const cfg = await configureSelfHostedTestProvider({
ctx,
providerId: params.providerId,
providerLabel: params.providerLabel,
envVar: params.envVar,
});
const profileId = `${params.providerId}:default`;
expect(cfg?.auth?.profiles?.[profileId]).toEqual({
provider: params.providerId,
mode: "api_key",
});
expect(cfg?.models?.providers?.[params.providerId]).toEqual({
baseUrl: params.baseUrl.replace(/\/+$/, ""),
api: "openai-completions",
apiKey: params.envVar,
models: [
expect.objectContaining({
id: params.modelId,
}),
],
});
expect(readPrimaryModel(cfg)).toBe(`${params.providerId}/${params.modelId}`);
expect(ctx.resolveApiKey).toHaveBeenCalledWith(
expect.objectContaining({
flagName: "--custom-api-key",
envVar: params.envVar,
}),
);
expect(upsertAuthProfileWithLock).toHaveBeenCalledWith({
profileId,
agentDir: ctx.agentDir,
credential: {
type: "api_key",
provider: params.providerId,
key: params.apiKey,
},
});
});
it("exits without touching auth when custom model id is missing", async () => {
const ctx = createContext({
providerId: "vllm",
apiKey: "vllm-test-key",
});
const cfg = await configureSelfHostedTestProvider({
ctx,
providerId: "vllm",
providerLabel: "vLLM",
envVar: "VLLM_API_KEY",
});
expect(cfg).toBeNull();
expect(ctx.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Missing --custom-model-id for --auth-choice vllm."),
);
expect(ctx.runtime.exit).toHaveBeenCalledWith(1);
expect(ctx.resolveApiKey).not.toHaveBeenCalled();
expect(upsertAuthProfileWithLock).not.toHaveBeenCalled();
});
});