From 36da65d0503558e74fcee5abbc57efdd74a11929 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 06:20:30 +0100 Subject: [PATCH] fix(tests): relocate provider auth-literal parity test outside core source (#104995) The parity test loaded the test-only bundled-plugin public-surface helper from src/plugins, which the extension-test boundary forbids for core source files; every PR touching the build-artifacts shard now fails. Moving it to test/plugins keeps the dynamic-import lane protection and passes both the boundary and parity suites. --- ...ndled-provider-auth-literal-parity.test.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 test/plugins/bundled-provider-auth-literal-parity.test.ts diff --git a/test/plugins/bundled-provider-auth-literal-parity.test.ts b/test/plugins/bundled-provider-auth-literal-parity.test.ts new file mode 100644 index 000000000000..533059a03f72 --- /dev/null +++ b/test/plugins/bundled-provider-auth-literal-parity.test.ts @@ -0,0 +1,232 @@ +// Keeps manifest providerAuthChoices literals aligned with registered provider.auth methods. +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { listBundledPluginMetadata } from "../../src/plugins/bundled-plugin-metadata.js"; +import type { PluginManifestProviderAuthChoice } from "../../src/plugins/manifest.js"; +import type { + ProviderAuthMethod, + ProviderPlugin, + ProviderResolveNonInteractiveApiKeyParams, +} from "../../src/plugins/types.js"; +import { createNonExitingRuntime } from "../../src/runtime.js"; +import { createCapturedPluginRegistration } from "../../src/test-utils/plugin-registration.js"; + +const PARITY_TIMEOUT_MS = 120_000; +const SENTINEL_API_KEY = "parity-sentinel-api-key"; + +type ApiKeyStyleChoice = PluginManifestProviderAuthChoice & { + optionKey: string; + cliFlag: string; +}; + +type ParityCase = { + pluginId: string; + providerId: string; + methodId: string; + optionKey: string; + cliFlag: string; + setupEnvVars: readonly string[]; +}; + +type PluginEntryModule = { + default?: { + id?: string; + register?: (api: ReturnType["api"]) => void; + }; + register?: (api: ReturnType["api"]) => void; +}; + +function isApiKeyStyleChoice( + choice: PluginManifestProviderAuthChoice, +): choice is ApiKeyStyleChoice { + return Boolean(choice.optionKey?.trim() && choice.cliFlag?.trim()); +} + +function listParityCases(): ParityCase[] { + return listBundledPluginMetadata({ includeChannelConfigs: false }).flatMap((plugin) => { + const choices = plugin.manifest.providerAuthChoices ?? []; + if (choices.length === 0) { + return []; + } + const setupEnvByProvider = new Map( + (plugin.manifest.setup?.providers ?? []).map((entry) => [ + entry.id, + entry.envVars ?? ([] as readonly string[]), + ]), + ); + return choices.filter(isApiKeyStyleChoice).map((choice) => ({ + pluginId: plugin.manifest.id, + providerId: choice.provider, + methodId: choice.method, + optionKey: choice.optionKey, + cliFlag: choice.cliFlag, + setupEnvVars: setupEnvByProvider.get(choice.provider) ?? [], + })); + }); +} + +async function loadPluginRegister( + pluginId: string, +): Promise<(api: ReturnType["api"]) => void> { + // Dynamic import keeps this file out of the unit-fast lane: loading built + // plugin dists pulls large module graphs into the shared worker cache and + // breaks co-resident vi.mock-based unit tests (observed with memory-host-sdk). + const { loadBundledPluginPublicSurface, resolveBundledPluginPublicModulePath } = + await import("../../src/test-utils/bundled-plugin-public-surface.js"); + // Resolve first so unknown plugin ids fail with a clear path error before import. + resolveBundledPluginPublicModulePath({ + pluginId, + artifactBasename: "index.js", + }); + const mod = await loadBundledPluginPublicSurface({ + pluginId, + artifactBasename: "index.js", + }); + const register = mod.default?.register ?? mod.register; + if (!register) { + throw new Error(`bundled plugin ${pluginId} has no register() entry`); + } + return register; +} + +function findRegisteredProvider( + providers: readonly ProviderPlugin[], + providerId: string, +): ProviderPlugin | undefined { + return providers.find( + (provider) => provider.id === providerId || provider.hookAliases?.includes(providerId) === true, + ); +} + +async function probeRuntimeAuthLiterals(params: { + method: ProviderAuthMethod; + optionKey: string; + agentDir: string; +}): Promise { + if (!params.method.runNonInteractive) { + return undefined; + } + // The sentinel maps only to the expected optionKey so flagValue === sentinel + // proves the method read the right key. Other keys get distinct placeholders + // to satisfy provider-specific preflight opts (e.g. account/gateway ids) + // without weakening that proof. + const opts = new Proxy>( + { [params.optionKey]: SENTINEL_API_KEY }, + { + get: (target, key) => + typeof key === "string" ? (target[key] ?? `parity-extra-${key}`) : undefined, + }, + ); + let captured: ProviderResolveNonInteractiveApiKeyParams | undefined; + try { + await params.method.runNonInteractive({ + authChoice: "parity", + agentDir: params.agentDir, + config: {}, + baseConfig: {}, + opts, + runtime: createNonExitingRuntime(), + resolveApiKey: async (resolveParams) => { + if (!captured) { + captured = resolveParams; + } + return null; + }, + toApiKeyCredential: () => null, + }); + } catch { + // Some methods throw when credentials are incomplete; captured params still count. + } + return captured; +} + +const parityCases = listParityCases().toSorted((left, right) => { + const pluginOrder = left.pluginId.localeCompare(right.pluginId); + if (pluginOrder !== 0) { + return pluginOrder; + } + const providerOrder = left.providerId.localeCompare(right.providerId); + if (providerOrder !== 0) { + return providerOrder; + } + return left.methodId.localeCompare(right.methodId); +}); + +const probeAgentDir = mkdtempSync(path.join(tmpdir(), "openclaw-auth-parity-")); + +afterAll(() => { + rmSync(probeAgentDir, { recursive: true, force: true }); +}); + +describe("bundled provider manifest↔runtime auth literal parity", () => { + it("discovers api-key-style providerAuthChoices from bundled plugins", () => { + expect(parityCases.length).toBeGreaterThan(0); + expect(new Set(parityCases.map((entry) => entry.pluginId)).size).toBeGreaterThan(10); + }); + + it.each(parityCases)( + "$pluginId $providerId/$methodId optionKey=$optionKey", + { timeout: PARITY_TIMEOUT_MS }, + async (parityCase) => { + const register = await loadPluginRegister(parityCase.pluginId); + const captured = createCapturedPluginRegistration({ + id: parityCase.pluginId, + name: parityCase.pluginId, + source: `bundled:${parityCase.pluginId}`, + }); + register(captured.api); + + const provider = findRegisteredProvider(captured.providers, parityCase.providerId); + if (!provider) { + // Capability-only plugins (video/image onboard flags) register no text + // providers at all. A plugin that registers text providers but not the + // manifest-declared id has drifted — the exact mismatch this test guards. + expect( + captured.providers.map((entry) => entry.id), + `${parityCase.pluginId} manifest declares provider ${parityCase.providerId} but runtime registers different providers`, + ).toEqual([]); + return; + } + + const method = provider.auth.find((entry) => entry.id === parityCase.methodId); + expect( + method, + `${parityCase.pluginId} runtime auth missing method ${parityCase.methodId}`, + ).toBeDefined(); + if (!method) { + return; + } + + // methodId (manifest `method`) ↔ runtime auth id + expect(method.id).toBe(parityCase.methodId); + + const probed = await probeRuntimeAuthLiterals({ + method, + optionKey: parityCase.optionKey, + agentDir: probeAgentDir, + }); + // Fail closed: an api-key-style choice whose method cannot be probed + // would otherwise leave its flag/env literals unchecked while CI stays + // green — the same silent-drift hole this test exists to close. + expect( + probed, + `${parityCase.pluginId} auth method ${parityCase.methodId} did not resolve an API key non-interactively; flag/env literals unverifiable`, + ).toBeDefined(); + if (!probed) { + return; + } + + // cliFlag ↔ flagName; optionKey proven when opts[optionKey] becomes flagValue + expect(probed.flagName).toBe(parityCase.cliFlag); + expect(probed.flagValue).toBe(SENTINEL_API_KEY); + + // envVar ↔ setup.providers[].envVars and/or provider.envVars + const knownEnvVars = new Set([...parityCase.setupEnvVars, ...(provider.envVars ?? [])]); + if (knownEnvVars.size > 0) { + expect(knownEnvVars.has(probed.envVar)).toBe(true); + } + }, + ); +});