diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index ade4e78036b..987703fc15c 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -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) : {}; + 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> }>(), ); @@ -88,6 +141,15 @@ function upsertAuthProfile(params: { writeRuntimeAuthSnapshots(); } +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../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; }): Promise { - 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 { ({ 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-", diff --git a/src/commands/onboard-non-interactive/api-keys.test.ts b/src/commands/onboard-non-interactive/api-keys.test.ts new file mode 100644 index 00000000000..29fea84cfe9 --- /dev/null +++ b/src/commands/onboard-non-interactive/api-keys.test.ts @@ -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, + }) 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", + }), + ); + }); +}); diff --git a/src/commands/onboard-non-interactive/api-keys.ts b/src/commands/onboard-non-interactive/api-keys.ts index 1ee88e678dd..fc1db6d06a5 100644 --- a/src/commands/onboard-non-interactive/api-keys.ts +++ b/src/commands/onboard-non-interactive/api-keys.ts @@ -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) { diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts new file mode 100644 index 00000000000..6cb2e2569df --- /dev/null +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -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( + async () => resolved, + ), + toApiKeyCredential: vi.fn( + ({ provider, resolved: apiKeyResult }) => ({ + type: "api_key", + provider, + key: apiKeyResult.key, + }), + ), + }; +} + +function readPrimaryModel(config: Awaited>) { + 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(); + }); +});