diff --git a/src/secrets/runtime.integration.test.ts b/src/secrets/runtime.auth.integration.test.ts similarity index 52% rename from src/secrets/runtime.integration.test.ts rename to src/secrets/runtime.auth.integration.test.ts index 382c1b196b4..14fab14f47c 100644 --- a/src/secrets/runtime.integration.test.ts +++ b/src/secrets/runtime.auth.integration.test.ts @@ -2,21 +2,24 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js"; +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { clearConfigCache, clearRuntimeConfigSnapshot, loadConfig, - type OpenClawConfig, writeConfigFile, } from "../config/config.js"; import { withTempHome } from "../config/home-env.test-harness.js"; -import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; -import { clearPluginLoaderCache } from "../plugins/loader.js"; -import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; -import { __testing as webFetchProvidersTesting } from "../plugins/web-fetch-providers.runtime.js"; -import { __testing as webSearchProvidersTesting } from "../plugins/web-search-providers.runtime.js"; import { captureEnv, withEnvAsync } from "../test-utils/env.js"; +import { + asConfig, + createOpenAIFileRuntimeConfig, + createOpenAIFileRuntimeFixture, + expectResolvedOpenAIRuntime, + loadAuthStoreWithProfiles, + OPENAI_ENV_KEY_REF, + OPENAI_FILE_KEY_REF, +} from "./runtime.integration.test-helpers.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot, @@ -26,104 +29,12 @@ import { vi.unmock("../version.js"); -const OPENAI_ENV_KEY_REF = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const; -const OPENAI_FILE_KEY_REF = { - source: "file", - provider: "default", - id: "/providers/openai/apiKey", -} as const; -const SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS = 300_000; -const allowInsecureTempSecretFile = process.platform === "win32"; - -function asConfig(value: unknown): OpenClawConfig { - return value as OpenClawConfig; -} - -function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore { - return { - version: 1, - profiles, - }; -} - -async function createOpenAIFileRuntimeFixture(home: string) { - const configDir = path.join(home, ".openclaw"); - const secretFile = path.join(configDir, "secrets.json"); - const agentDir = path.join(configDir, "agents", "main", "agent"); - const authStorePath = path.join(agentDir, "auth-profiles.json"); - - await fs.mkdir(agentDir, { recursive: true }); - await fs.chmod(configDir, 0o700).catch(() => {}); - await fs.writeFile( - secretFile, - `${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, - { encoding: "utf8", mode: 0o600 }, - ); - await fs.writeFile( - authStorePath, - `${JSON.stringify( - { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - keyRef: OPENAI_FILE_KEY_REF, - }, - }, - }, - null, - 2, - )}\n`, - { encoding: "utf8", mode: 0o600 }, - ); - - return { - configDir, - secretFile, - agentDir, - }; -} - -function createOpenAIFileRuntimeConfig(secretFile: string): OpenClawConfig { - return asConfig({ - secrets: { - providers: { - default: { - source: "file", - path: secretFile, - mode: "json", - ...(allowInsecureTempSecretFile ? { allowInsecurePath: true } : {}), - }, - }, - }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: OPENAI_FILE_KEY_REF, - models: [], - }, - }, - }, - }); -} - -function expectResolvedOpenAIRuntime(agentDir: string) { - expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime"); - expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({ - type: "api_key", - key: "sk-file-runtime", - }); -} - -describe("secrets runtime snapshot integration", () => { +describe("secrets runtime snapshot auth integration", () => { let envSnapshot: ReturnType; beforeEach(() => { envSnapshot = captureEnv([ "OPENCLAW_BUNDLED_PLUGINS_DIR", - "OPENCLAW_DISABLE_BUNDLED_PLUGINS", "OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE", "OPENCLAW_VERSION", ]); @@ -138,11 +49,6 @@ describe("secrets runtime snapshot integration", () => { clearSecretsRuntimeSnapshot(); clearRuntimeConfigSnapshot(); clearConfigCache(); - clearPluginLoaderCache(); - clearPluginDiscoveryCache(); - clearPluginManifestRegistryCache(); - webSearchProvidersTesting.resetWebSearchProviderSnapshotCacheForTests(); - webFetchProvidersTesting.resetWebFetchProviderSnapshotCacheForTests(); }); it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => { @@ -159,7 +65,7 @@ describe("secrets runtime snapshot integration", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + apiKey: OPENAI_ENV_KEY_REF, models: [], }, }, @@ -265,97 +171,6 @@ describe("secrets runtime snapshot integration", () => { }); }); - it("fails fast at startup when gateway auth SecretRef is active and unresolved", async () => { - await withEnvAsync( - { - OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_VERSION: undefined, - }, - async () => { - await expect( - prepareSecretsRuntimeSnapshot({ - config: asConfig({ - gateway: { - auth: { - mode: "token", - token: { - source: "env", - provider: "default", - id: "MISSING_GATEWAY_AUTH_TOKEN", - }, - }, - }, - }), - env: {}, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }), - ).rejects.toThrow(/MISSING_GATEWAY_AUTH_TOKEN/i); - }, - ); - }); - - it( - "keeps last-known-good runtime snapshot active when reload introduces unresolved active gateway auth refs", - async () => { - await withTempHome("openclaw-secrets-runtime-gateway-auth-reload-lkg-", async (home) => { - const initialTokenRef = { - source: "env", - provider: "default", - id: "GATEWAY_AUTH_TOKEN", - } as const; - const missingTokenRef = { - source: "env", - provider: "default", - id: "MISSING_GATEWAY_AUTH_TOKEN", - } as const; - - const prepared = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - gateway: { - auth: { - mode: "token", - token: initialTokenRef, - }, - }, - }), - env: { - GATEWAY_AUTH_TOKEN: "gateway-runtime-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - activateSecretsRuntimeSnapshot(prepared); - expect(loadConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); - - await expect( - writeConfigFile({ - ...loadConfig(), - gateway: { - auth: { - mode: "token", - token: missingTokenRef, - }, - }, - }), - ).rejects.toThrow(/runtime snapshot refresh failed: .*MISSING_GATEWAY_AUTH_TOKEN/i); - - const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); - expect(activeAfterFailure).not.toBeNull(); - expect(loadConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); - expect(activeAfterFailure?.sourceConfig.gateway?.auth?.token).toEqual(initialTokenRef); - - const persistedConfig = JSON.parse( - await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), - ) as OpenClawConfig; - expect(persistedConfig.gateway?.auth?.token).toEqual(missingTokenRef); - }); - }, - SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS, - ); - it("recomputes config-derived agent dirs when refreshing active secrets runtime snapshots", async () => { await withTempHome("openclaw-secrets-runtime-agent-dirs-", async (home) => { const mainAgentDir = path.join(home, ".openclaw", "agents", "main", "agent"); @@ -371,7 +186,7 @@ describe("secrets runtime snapshot integration", () => { "openai:default": { type: "api_key", provider: "openai", - keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + keyRef: OPENAI_ENV_KEY_REF, }, }, }, diff --git a/src/secrets/runtime.gateway-auth.integration.test.ts b/src/secrets/runtime.gateway-auth.integration.test.ts new file mode 100644 index 00000000000..7cedba4198f --- /dev/null +++ b/src/secrets/runtime.gateway-auth.integration.test.ts @@ -0,0 +1,139 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, + loadConfig, + writeConfigFile, +} from "../config/config.js"; +import { withTempHome } from "../config/home-env.test-harness.js"; +import { captureEnv, withEnvAsync } from "../test-utils/env.js"; +import { + asConfig, + loadAuthStoreWithProfiles, + SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS, +} from "./runtime.integration.test-helpers.js"; +import { + activateSecretsRuntimeSnapshot, + clearSecretsRuntimeSnapshot, + getActiveSecretsRuntimeSnapshot, + prepareSecretsRuntimeSnapshot, +} from "./runtime.js"; + +vi.unmock("../version.js"); + +describe("secrets runtime snapshot gateway-auth integration", () => { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv([ + "OPENCLAW_BUNDLED_PLUGINS_DIR", + "OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE", + "OPENCLAW_VERSION", + ]); + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + process.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE = "1"; + delete process.env.OPENCLAW_VERSION; + }); + + afterEach(() => { + vi.restoreAllMocks(); + envSnapshot.restore(); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it("fails fast at startup when gateway auth SecretRef is active and unresolved", async () => { + await withEnvAsync( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_VERSION: undefined, + }, + async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + gateway: { + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "MISSING_GATEWAY_AUTH_TOKEN", + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }), + ).rejects.toThrow(/MISSING_GATEWAY_AUTH_TOKEN/i); + }, + ); + }); + + it( + "keeps last-known-good runtime snapshot active when reload introduces unresolved active gateway auth refs", + async () => { + await withTempHome("openclaw-secrets-runtime-gateway-auth-reload-lkg-", async (home) => { + const initialTokenRef = { + source: "env", + provider: "default", + id: "GATEWAY_AUTH_TOKEN", + } as const; + const missingTokenRef = { + source: "env", + provider: "default", + id: "MISSING_GATEWAY_AUTH_TOKEN", + } as const; + + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + gateway: { + auth: { + mode: "token", + token: initialTokenRef, + }, + }, + }), + env: { + GATEWAY_AUTH_TOKEN: "gateway-runtime-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + activateSecretsRuntimeSnapshot(prepared); + expect(loadConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); + + await expect( + writeConfigFile({ + ...loadConfig(), + gateway: { + auth: { + mode: "token", + token: missingTokenRef, + }, + }, + }), + ).rejects.toThrow(/runtime snapshot refresh failed: .*MISSING_GATEWAY_AUTH_TOKEN/i); + + const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); + expect(activeAfterFailure).not.toBeNull(); + expect(loadConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); + expect(activeAfterFailure?.sourceConfig.gateway?.auth?.token).toEqual(initialTokenRef); + + const persistedConfig = JSON.parse( + await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), + ) as OpenClawConfig; + expect(persistedConfig.gateway?.auth?.token).toEqual(missingTokenRef); + }); + }, + SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS, + ); +}); diff --git a/src/secrets/runtime.integration.test-helpers.ts b/src/secrets/runtime.integration.test-helpers.ts new file mode 100644 index 00000000000..60e77198ff7 --- /dev/null +++ b/src/secrets/runtime.integration.test-helpers.ts @@ -0,0 +1,106 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect } from "vitest"; +import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; + +export const OPENAI_ENV_KEY_REF = { + source: "env", + provider: "default", + id: "OPENAI_API_KEY", +} as const; + +export const OPENAI_FILE_KEY_REF = { + source: "file", + provider: "default", + id: "/providers/openai/apiKey", +} as const; + +export const SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS = 300_000; + +const allowInsecureTempSecretFile = process.platform === "win32"; + +export function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +export function loadAuthStoreWithProfiles( + profiles: AuthProfileStore["profiles"], +): AuthProfileStore { + return { + version: 1, + profiles, + }; +} + +export async function createOpenAIFileRuntimeFixture(home: string) { + const configDir = path.join(home, ".openclaw"); + const secretFile = path.join(configDir, "secrets.json"); + const agentDir = path.join(configDir, "agents", "main", "agent"); + const authStorePath = path.join(agentDir, "auth-profiles.json"); + + await fs.mkdir(agentDir, { recursive: true }); + await fs.chmod(configDir, 0o700).catch(() => {}); + await fs.writeFile( + secretFile, + `${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + await fs.writeFile( + authStorePath, + `${JSON.stringify( + { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: OPENAI_FILE_KEY_REF, + }, + }, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + + return { + configDir, + secretFile, + agentDir, + }; +} + +export function createOpenAIFileRuntimeConfig(secretFile: string): OpenClawConfig { + return asConfig({ + secrets: { + providers: { + default: { + source: "file", + path: secretFile, + mode: "json", + ...(allowInsecureTempSecretFile ? { allowInsecurePath: true } : {}), + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: OPENAI_FILE_KEY_REF, + models: [], + }, + }, + }, + }); +} + +export function expectResolvedOpenAIRuntime(agentDir: string) { + expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime"); + expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({ + type: "api_key", + key: "sk-file-runtime", + }); +} diff --git a/src/secrets/runtime.web.integration.test.ts b/src/secrets/runtime.web.integration.test.ts new file mode 100644 index 00000000000..08a70166c8c --- /dev/null +++ b/src/secrets/runtime.web.integration.test.ts @@ -0,0 +1,150 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, + loadConfig, + writeConfigFile, +} from "../config/config.js"; +import { withTempHome } from "../config/home-env.test-harness.js"; +import { captureEnv } from "../test-utils/env.js"; +import { + asConfig, + loadAuthStoreWithProfiles, + SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS, +} from "./runtime.integration.test-helpers.js"; +import { + activateSecretsRuntimeSnapshot, + clearSecretsRuntimeSnapshot, + getActiveRuntimeWebToolsMetadata, + getActiveSecretsRuntimeSnapshot, + prepareSecretsRuntimeSnapshot, +} from "./runtime.js"; + +vi.unmock("../version.js"); + +describe("secrets runtime snapshot web integration", () => { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv([ + "OPENCLAW_BUNDLED_PLUGINS_DIR", + "OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE", + "OPENCLAW_VERSION", + ]); + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + process.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE = "1"; + delete process.env.OPENCLAW_VERSION; + }); + + afterEach(() => { + vi.restoreAllMocks(); + envSnapshot.restore(); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it( + "keeps last-known-good web runtime snapshot when reload introduces unresolved active web refs", + async () => { + await withTempHome("openclaw-secrets-runtime-web-reload-lkg-", async (home) => { + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + tools: { + web: { + search: { + provider: "gemini", + }, + }, + }, + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: { + source: "env", + provider: "default", + id: "WEB_SEARCH_GEMINI_API_KEY", + }, + }, + }, + }, + }, + }, + }), + env: { + WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-runtime-key", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + activateSecretsRuntimeSnapshot(prepared); + + await expect( + writeConfigFile({ + ...loadConfig(), + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", + }, + }, + }, + }, + }, + }, + tools: { + web: { + search: { + provider: "gemini", + }, + }, + }, + }), + ).rejects.toThrow( + /runtime snapshot refresh failed: .*WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK/i, + ); + + const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); + expect(activeAfterFailure).not.toBeNull(); + const loadedGoogleWebSearchConfig = loadConfig().plugins?.entries?.google?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined; + expect(loadedGoogleWebSearchConfig?.webSearch?.apiKey).toBe( + "web-search-gemini-runtime-key", + ); + const activeSourceGoogleWebSearchConfig = activeAfterFailure?.sourceConfig.plugins?.entries + ?.google?.config as { webSearch?: { apiKey?: unknown } } | undefined; + expect(activeSourceGoogleWebSearchConfig?.webSearch?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "WEB_SEARCH_GEMINI_API_KEY", + }); + expect(getActiveRuntimeWebToolsMetadata()?.search.selectedProvider).toBe("gemini"); + + const persistedConfig = JSON.parse( + await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), + ) as OpenClawConfig; + const persistedGoogleWebSearchConfig = persistedConfig.plugins?.entries?.google?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined; + expect(persistedGoogleWebSearchConfig?.webSearch?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", + }); + }); + }, + SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS, + ); +});