diff --git a/src/secrets/runtime-auth-activation.test.ts b/src/secrets/runtime-auth-activation.test.ts new file mode 100644 index 00000000000..e5b104e1177 --- /dev/null +++ b/src/secrets/runtime-auth-activation.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; +import { loadConfig } from "../config/config.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { + asConfig, + beginSecretsRuntimeIsolationForTest, + EMPTY_LOADABLE_PLUGIN_ORIGINS, + endSecretsRuntimeIsolationForTest, + loadAuthStoreWithProfiles, + OPENAI_ENV_KEY_REF, + type SecretsRuntimeEnvSnapshot, +} from "./runtime-auth.integration.test-helpers.js"; +import { activateSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } from "./runtime.js"; + +vi.unmock("../version.js"); + +describe("secrets runtime snapshot auth activation", () => { + let envSnapshot: SecretsRuntimeEnvSnapshot; + + beforeEach(() => { + envSnapshot = beginSecretsRuntimeIsolationForTest(); + }); + + afterEach(() => { + endSecretsRuntimeIsolationForTest(envSnapshot); + }); + + it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => { + await withEnvAsync( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_VERSION: undefined, + }, + async () => { + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: OPENAI_ENV_KEY_REF, + models: [], + }, + }, + }, + }), + env: { OPENAI_API_KEY: "sk-runtime" }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, + loadAuthStore: () => + loadAuthStoreWithProfiles({ + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: OPENAI_ENV_KEY_REF, + }, + }), + }); + + activateSecretsRuntimeSnapshot(prepared); + + expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime"); + expect( + ensureAuthProfileStore("/tmp/openclaw-agent-main").profiles["openai:default"], + ).toMatchObject({ + type: "api_key", + key: "sk-runtime", + }); + }, + ); + }); +}); diff --git a/src/secrets/runtime-auth-refresh-failure.test.ts b/src/secrets/runtime-auth-refresh-failure.test.ts new file mode 100644 index 00000000000..3e119233c27 --- /dev/null +++ b/src/secrets/runtime-auth-refresh-failure.test.ts @@ -0,0 +1,86 @@ +import os from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../config/home-env.test-harness.js"; +import { + beginSecretsRuntimeIsolationForTest, + createOpenAIFileRuntimeConfig, + createOpenAIFileRuntimeFixture, + EMPTY_LOADABLE_PLUGIN_ORIGINS, + endSecretsRuntimeIsolationForTest, + expectResolvedOpenAIRuntime, + loadAuthStoreWithProfiles, + OPENAI_FILE_KEY_REF, + type SecretsRuntimeEnvSnapshot, +} from "./runtime-auth.integration.test-helpers.js"; +import { + activateSecretsRuntimeSnapshot, + getActiveSecretsRuntimeSnapshot, + prepareSecretsRuntimeSnapshot, +} from "./runtime.js"; + +vi.unmock("../version.js"); + +describe("secrets runtime snapshot auth refresh failure", () => { + let envSnapshot: SecretsRuntimeEnvSnapshot; + + beforeEach(() => { + envSnapshot = beginSecretsRuntimeIsolationForTest(); + }); + + afterEach(() => { + endSecretsRuntimeIsolationForTest(envSnapshot); + }); + + it("keeps last-known-good runtime snapshot active when refresh preparation fails", async () => { + if (os.platform() === "win32") { + return; + } + await withTempHome("openclaw-secrets-runtime-refresh-fail-", async (home) => { + const { secretFile, agentDir } = await createOpenAIFileRuntimeFixture(home); + + let loadAuthStoreCalls = 0; + const loadAuthStore = () => { + loadAuthStoreCalls += 1; + if (loadAuthStoreCalls > 1) { + throw new Error("simulated secrets runtime refresh failure"); + } + return loadAuthStoreWithProfiles({ + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: OPENAI_FILE_KEY_REF, + }, + }); + }; + + const prepared = await prepareSecretsRuntimeSnapshot({ + config: createOpenAIFileRuntimeConfig(secretFile), + agentDirs: [agentDir], + loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, + loadAuthStore, + }); + + activateSecretsRuntimeSnapshot(prepared); + expectResolvedOpenAIRuntime(agentDir); + + await expect( + prepareSecretsRuntimeSnapshot({ + config: { + ...createOpenAIFileRuntimeConfig(secretFile), + gateway: { auth: { mode: "token" } }, + }, + agentDirs: [agentDir], + loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, + loadAuthStore, + }), + ).rejects.toThrow(/simulated secrets runtime refresh failure/i); + + const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); + expect(activeAfterFailure).not.toBeNull(); + expectResolvedOpenAIRuntime(agentDir); + expect(activeAfterFailure?.sourceConfig.models?.providers?.openai?.apiKey).toEqual( + OPENAI_FILE_KEY_REF, + ); + }); + }); +}); diff --git a/src/secrets/runtime-auth-write-refresh.test.ts b/src/secrets/runtime-auth-write-refresh.test.ts new file mode 100644 index 00000000000..ae564ae8b6f --- /dev/null +++ b/src/secrets/runtime-auth-write-refresh.test.ts @@ -0,0 +1,60 @@ +import os from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../config/home-env.test-harness.js"; +import { + beginSecretsRuntimeIsolationForTest, + createOpenAIFileRuntimeConfig, + createOpenAIFileRuntimeFixture, + EMPTY_LOADABLE_PLUGIN_ORIGINS, + endSecretsRuntimeIsolationForTest, + expectResolvedOpenAIRuntime, + type SecretsRuntimeEnvSnapshot, +} from "./runtime-auth.integration.test-helpers.js"; +import { activateSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } from "./runtime.js"; + +vi.unmock("../version.js"); + +describe("secrets runtime snapshot auth write refresh", () => { + let envSnapshot: SecretsRuntimeEnvSnapshot; + + beforeEach(() => { + envSnapshot = beginSecretsRuntimeIsolationForTest(); + }); + + afterEach(() => { + endSecretsRuntimeIsolationForTest(envSnapshot); + }); + + it("keeps active secrets runtime snapshots resolved after refreshes", async () => { + if (os.platform() === "win32") { + return; + } + await withTempHome("openclaw-secrets-runtime-write-", async (home) => { + const { secretFile, agentDir } = await createOpenAIFileRuntimeFixture(home); + + const prepared = await prepareSecretsRuntimeSnapshot({ + config: createOpenAIFileRuntimeConfig(secretFile), + agentDirs: [agentDir], + loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, + }); + + activateSecretsRuntimeSnapshot(prepared); + + expectResolvedOpenAIRuntime(agentDir); + + const refreshed = await prepareSecretsRuntimeSnapshot({ + config: { + ...createOpenAIFileRuntimeConfig(secretFile), + gateway: { auth: { mode: "token" } }, + }, + agentDirs: [agentDir], + loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, + }); + activateSecretsRuntimeSnapshot(refreshed); + + expectResolvedOpenAIRuntime(agentDir); + expect(refreshed.config.gateway?.auth).toEqual({ mode: "token" }); + expect(refreshed.sourceConfig.gateway?.auth).toEqual({ mode: "token" }); + }); + }); +}); diff --git a/src/secrets/runtime-config-collectors-channels.ts b/src/secrets/runtime-config-collectors-channels.ts index fdd4b9ce43c..a0a5a293997 100644 --- a/src/secrets/runtime-config-collectors-channels.ts +++ b/src/secrets/runtime-config-collectors-channels.ts @@ -1,4 +1,4 @@ -import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; +import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js"; import type { OpenClawConfig } from "../config/config.js"; import { type ResolverContext, type SecretDefaults } from "./runtime-shared.js"; @@ -7,10 +7,15 @@ export function collectChannelConfigAssignments(params: { defaults: SecretDefaults | undefined; context: ResolverContext; }): void { - if (!params.config.channels || Object.keys(params.config.channels).length === 0) { + const channelIds = Object.keys(params.config.channels ?? {}); + if (channelIds.length === 0) { return; } - for (const plugin of iterateBootstrapChannelPlugins()) { + for (const channelId of channelIds) { + const plugin = getBootstrapChannelPlugin(channelId); + if (!plugin) { + continue; + } plugin.secrets?.collectRuntimeConfigAssignments?.(params); } } diff --git a/src/secrets/runtime-core-auth.test.ts b/src/secrets/runtime-core-auth.test.ts new file mode 100644 index 00000000000..7a01f334b42 --- /dev/null +++ b/src/secrets/runtime-core-auth.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; + +type WebProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl"; + +const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ + resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()), +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, +})); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +function createTestProvider(params: { + id: WebProviderUnderTest; + pluginId: string; + order: number; +}): PluginWebSearchProviderEntry { + const credentialPath = `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; + const readSearchConfigKey = (searchConfig?: Record): unknown => { + const providerConfig = + searchConfig?.[params.id] && typeof searchConfig[params.id] === "object" + ? (searchConfig[params.id] as { apiKey?: unknown }) + : undefined; + return providerConfig?.apiKey ?? searchConfig?.apiKey; + }; + return { + pluginId: params.pluginId, + id: params.id, + label: params.id, + hint: `${params.id} test provider`, + envVars: [`${params.id.toUpperCase()}_API_KEY`], + placeholder: `${params.id}-...`, + signupUrl: `https://example.com/${params.id}`, + autoDetectOrder: params.order, + credentialPath, + inactiveSecretPaths: [credentialPath], + getCredentialValue: readSearchConfigKey, + setCredentialValue: (searchConfigTarget, value) => { + const providerConfig = + params.id === "brave" || params.id === "firecrawl" + ? searchConfigTarget + : ((searchConfigTarget[params.id] ??= {}) as { apiKey?: unknown }); + providerConfig.apiKey = value; + }, + getConfiguredCredentialValue: (config) => + (config?.plugins?.entries?.[params.pluginId]?.config as { webSearch?: { apiKey?: unknown } }) + ?.webSearch?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + const plugins = (configTarget.plugins ??= {}) as { entries?: Record }; + const entries = (plugins.entries ??= {}); + const entry = (entries[params.pluginId] ??= {}) as { config?: Record }; + const config = (entry.config ??= {}); + const webSearch = (config.webSearch ??= {}) as { apiKey?: unknown }; + webSearch.apiKey = value; + }, + resolveRuntimeMetadata: + params.id === "perplexity" + ? () => ({ + perplexityTransport: "search_api" as const, + }) + : undefined, + createTool: () => null, + }; +} + +function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { + return [ + createTestProvider({ id: "brave", pluginId: "brave", order: 10 }), + createTestProvider({ id: "gemini", pluginId: "google", order: 20 }), + createTestProvider({ id: "grok", pluginId: "xai", order: 30 }), + createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }), + createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }), + createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }), + ]; +} + +const OPENAI_ENV_KEY_REF = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const; + +function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore { + return { + version: 1, + profiles, + }; +} + +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + +describe("secrets runtime snapshot core env auth", () => { + beforeAll(async () => { + ({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js")); + ({ clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } = await import("./runtime.js")); + }); + + beforeEach(() => { + resolvePluginWebSearchProvidersMock.mockReset(); + resolvePluginWebSearchProvidersMock.mockReturnValue(buildTestWebSearchProviders()); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it("resolves core env refs for config and auth profiles", async () => { + const config = asConfig({ + agents: { + defaults: { + memorySearch: { + remote: { + apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" }, + }, + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_PROVIDER_AUTH_HEADER", + }, + }, + models: [], + }, + }, + }, + skills: { + entries: { + "review-pr": { + enabled: true, + apiKey: { source: "env", provider: "default", id: "REVIEW_SKILL_API_KEY" }, + }, + }, + }, + talk: { + providers: { + "acme-speech": { + apiKey: { source: "env", provider: "default", id: "TALK_PROVIDER_API_KEY" }, + }, + }, + }, + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, + password: { source: "env", provider: "default", id: "REMOTE_GATEWAY_PASSWORD" }, + }, + }, + }); + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: { + OPENAI_API_KEY: "sk-env-openai", + OPENAI_PROVIDER_AUTH_HEADER: "Bearer sk-env-header", + GITHUB_TOKEN: "ghp-env-token", + REVIEW_SKILL_API_KEY: "sk-skill-ref", + MEMORY_REMOTE_API_KEY: "mem-ref-key", + TALK_PROVIDER_API_KEY: "talk-provider-ref-key", + REMOTE_GATEWAY_TOKEN: "remote-token-ref", + REMOTE_GATEWAY_PASSWORD: "remote-password-ref", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadablePluginOrigins: new Map(), + loadAuthStore: () => + loadAuthStoreWithProfiles({ + "openai:default": { + type: "api_key", + provider: "openai", + key: "old-openai", + keyRef: OPENAI_ENV_KEY_REF, + }, + "github-copilot:default": { + type: "token", + provider: "github-copilot", + token: "old-gh", + tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, + }, + "openai:inline": { + type: "api_key", + provider: "openai", + key: "${OPENAI_API_KEY}", + }, + }), + }); + + expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai"); + expect(snapshot.config.models?.providers?.openai?.headers?.Authorization).toBe( + "Bearer sk-env-header", + ); + expect(snapshot.config.skills?.entries?.["review-pr"]?.apiKey).toBe("sk-skill-ref"); + expect(snapshot.config.agents?.defaults?.memorySearch?.remote?.apiKey).toBe("mem-ref-key"); + expect((snapshot.config.talk as { apiKey?: unknown } | undefined)?.apiKey).toBeUndefined(); + expect(snapshot.config.talk?.providers?.["acme-speech"]?.apiKey).toBe("talk-provider-ref-key"); + expect(snapshot.config.gateway?.remote?.token).toBe("remote-token-ref"); + expect(snapshot.config.gateway?.remote?.password).toBe("remote-password-ref"); + expect(snapshot.warnings.map((warning) => warning.path)).toEqual( + expect.arrayContaining([ + "/tmp/openclaw-agent-main.auth-profiles.openai:default.key", + "/tmp/openclaw-agent-main.auth-profiles.github-copilot:default.token", + ]), + ); + expect(snapshot.authStores[0]?.store.profiles["openai:default"]).toMatchObject({ + type: "api_key", + key: "sk-env-openai", + }); + expect(snapshot.authStores[0]?.store.profiles["github-copilot:default"]).toMatchObject({ + type: "token", + token: "ghp-env-token", + }); + expect(snapshot.authStores[0]?.store.profiles["openai:inline"]).toMatchObject({ + type: "api_key", + key: "sk-env-openai", + }); + expect( + (snapshot.authStores[0].store.profiles["openai:inline"] as Record).keyRef, + ).toEqual({ source: "env", provider: "default", id: "OPENAI_API_KEY" }); + }); +}); diff --git a/src/secrets/runtime-inactive-surfaces.test.ts b/src/secrets/runtime-inactive-surfaces.test.ts new file mode 100644 index 00000000000..4faa8eda443 --- /dev/null +++ b/src/secrets/runtime-inactive-surfaces.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; + +type WebProviderUnderTest = "brave" | "gemini"; + +const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ + resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()), +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, +})); + +vi.mock("../channels/plugins/bootstrap-registry.js", async () => { + const telegramSecrets = await import("../../extensions/telegram/src/secret-contract.ts"); + return { + getBootstrapChannelPlugin: (id: string) => + id === "telegram" + ? { + secrets: { + collectRuntimeConfigAssignments: telegramSecrets.collectRuntimeConfigAssignments, + }, + } + : undefined, + }; +}); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +function createTestProvider(params: { + id: WebProviderUnderTest; + pluginId: string; + order: number; +}): PluginWebSearchProviderEntry { + const credentialPath = `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; + const readSearchConfigKey = (searchConfig?: Record): unknown => { + const providerConfig = + searchConfig?.[params.id] && typeof searchConfig[params.id] === "object" + ? (searchConfig[params.id] as { apiKey?: unknown }) + : undefined; + return providerConfig?.apiKey ?? searchConfig?.apiKey; + }; + return { + pluginId: params.pluginId, + id: params.id, + label: params.id, + hint: `${params.id} test provider`, + envVars: [`${params.id.toUpperCase()}_API_KEY`], + placeholder: `${params.id}-...`, + signupUrl: `https://example.com/${params.id}`, + autoDetectOrder: params.order, + credentialPath, + inactiveSecretPaths: [credentialPath], + getCredentialValue: readSearchConfigKey, + setCredentialValue: (searchConfigTarget, value) => { + const providerConfig = + params.id === "brave" + ? searchConfigTarget + : ((searchConfigTarget[params.id] ??= {}) as { apiKey?: unknown }); + providerConfig.apiKey = value; + }, + getConfiguredCredentialValue: (config) => + (config?.plugins?.entries?.[params.pluginId]?.config as { webSearch?: { apiKey?: unknown } }) + ?.webSearch?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + const plugins = (configTarget.plugins ??= {}) as { entries?: Record }; + const entries = (plugins.entries ??= {}); + const entry = (entries[params.pluginId] ??= {}) as { config?: Record }; + const config = (entry.config ??= {}); + const webSearch = (config.webSearch ??= {}) as { apiKey?: unknown }; + webSearch.apiKey = value; + }, + createTool: () => null, + }; +} + +function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { + return [ + createTestProvider({ id: "brave", pluginId: "brave", order: 10 }), + createTestProvider({ id: "gemini", pluginId: "google", order: 20 }), + ]; +} + +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + +function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore { + return { + version: 1, + profiles, + }; +} + +describe("secrets runtime snapshot inactive surfaces", () => { + beforeAll(async () => { + ({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js")); + ({ clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } = await import("./runtime.js")); + }); + + beforeEach(() => { + resolvePluginWebSearchProvidersMock.mockReset(); + resolvePluginWebSearchProvidersMock.mockReturnValue(buildTestWebSearchProviders()); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it("skips inactive-surface refs and emits diagnostics", async () => { + const config = asConfig({ + agents: { + defaults: { + memorySearch: { + enabled: false, + remote: { + apiKey: { source: "env", provider: "default", id: "DISABLED_MEMORY_API_KEY" }, + }, + }, + }, + }, + gateway: { + auth: { + mode: "token", + password: { source: "env", provider: "default", id: "DISABLED_GATEWAY_PASSWORD" }, + }, + }, + channels: { + telegram: { + botToken: { source: "env", provider: "default", id: "DISABLED_TELEGRAM_BASE_TOKEN" }, + accounts: { + disabled: { + enabled: false, + botToken: { + source: "env", + provider: "default", + id: "DISABLED_TELEGRAM_ACCOUNT_TOKEN", + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: false, + apiKey: { source: "env", provider: "default", id: "DISABLED_WEB_SEARCH_API_KEY" }, + }, + }, + }, + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: { + source: "env", + provider: "default", + id: "DISABLED_WEB_SEARCH_GEMINI_API_KEY", + }, + }, + }, + }, + }, + }, + }); + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.telegram?.botToken).toEqual({ + source: "env", + provider: "default", + id: "DISABLED_TELEGRAM_BASE_TOKEN", + }); + const ignoredInactiveWarnings = snapshot.warnings.filter( + (warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + ); + expect(ignoredInactiveWarnings.length).toBeGreaterThanOrEqual(6); + expect(snapshot.warnings.map((warning) => warning.path)).toEqual( + expect.arrayContaining([ + "agents.defaults.memorySearch.remote.apiKey", + "gateway.auth.password", + "channels.telegram.botToken", + "channels.telegram.accounts.disabled.botToken", + "plugins.entries.brave.config.webSearch.apiKey", + "plugins.entries.google.config.webSearch.apiKey", + ]), + ); + }); +}); diff --git a/src/secrets/runtime-matrix-shadowing.test.ts b/src/secrets/runtime-matrix-shadowing.test.ts new file mode 100644 index 00000000000..b8e26deaab3 --- /dev/null +++ b/src/secrets/runtime-matrix-shadowing.test.ts @@ -0,0 +1,340 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; + +type WebProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl"; + +const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ + resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()), +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, +})); + +vi.mock("../channels/plugins/bootstrap-registry.js", async () => { + const matrixSecrets = await import("../../extensions/matrix/src/secret-contract.ts"); + return { + getBootstrapChannelPlugin: (id: string) => + id === "matrix" + ? { + secrets: { + collectRuntimeConfigAssignments: matrixSecrets.collectRuntimeConfigAssignments, + }, + } + : undefined, + }; +}); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +function createTestProvider(params: { + id: WebProviderUnderTest; + pluginId: string; + order: number; +}): PluginWebSearchProviderEntry { + const credentialPath = `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; + const readSearchConfigKey = (searchConfig?: Record): unknown => { + const providerConfig = + searchConfig?.[params.id] && typeof searchConfig[params.id] === "object" + ? (searchConfig[params.id] as { apiKey?: unknown }) + : undefined; + return providerConfig?.apiKey ?? searchConfig?.apiKey; + }; + return { + pluginId: params.pluginId, + id: params.id, + label: params.id, + hint: `${params.id} test provider`, + envVars: [`${params.id.toUpperCase()}_API_KEY`], + placeholder: `${params.id}-...`, + signupUrl: `https://example.com/${params.id}`, + autoDetectOrder: params.order, + credentialPath, + inactiveSecretPaths: [credentialPath], + getCredentialValue: readSearchConfigKey, + setCredentialValue: (searchConfigTarget, value) => { + const providerConfig = + params.id === "brave" || params.id === "firecrawl" + ? searchConfigTarget + : ((searchConfigTarget[params.id] ??= {}) as { apiKey?: unknown }); + providerConfig.apiKey = value; + }, + getConfiguredCredentialValue: (config) => + (config?.plugins?.entries?.[params.pluginId]?.config as { webSearch?: { apiKey?: unknown } }) + ?.webSearch?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + const plugins = (configTarget.plugins ??= {}) as { entries?: Record }; + const entries = (plugins.entries ??= {}); + const entry = (entries[params.pluginId] ??= {}) as { config?: Record }; + const config = (entry.config ??= {}); + const webSearch = (config.webSearch ??= {}) as { apiKey?: unknown }; + webSearch.apiKey = value; + }, + resolveRuntimeMetadata: + params.id === "perplexity" + ? () => ({ + perplexityTransport: "search_api" as const, + }) + : undefined, + createTool: () => null, + }; +} + +function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { + return [ + createTestProvider({ id: "brave", pluginId: "brave", order: 10 }), + createTestProvider({ id: "gemini", pluginId: "google", order: 20 }), + createTestProvider({ id: "grok", pluginId: "xai", order: 30 }), + createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }), + createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }), + createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }), + ]; +} + +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + +function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore { + return { + version: 1, + profiles, + }; +} + +describe("secrets runtime snapshot matrix shadowing", () => { + beforeAll(async () => { + ({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js")); + ({ clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } = await import("./runtime.js")); + }); + + beforeEach(() => { + resolvePluginWebSearchProvidersMock.mockReset(); + resolvePluginWebSearchProvidersMock.mockReturnValue(buildTestWebSearchProviders()); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it("ignores Matrix password refs that are shadowed by scoped env access tokens", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + matrix: { + accounts: { + ops: { + password: { + source: "env", + provider: "default", + id: "MATRIX_OPS_PASSWORD", + }, + }, + }, + }, + }, + }), + env: { + MATRIX_OPS_ACCESS_TOKEN: "ops-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.matrix?.accounts?.ops?.password).toEqual({ + source: "env", + provider: "default", + id: "MATRIX_OPS_PASSWORD", + }); + expect(snapshot.warnings).toContainEqual( + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "channels.matrix.accounts.ops.password", + }), + ); + }); + + it.each([ + { + name: "channels.matrix.accounts.default.accessToken config", + config: { + channels: { + matrix: { + password: { + source: "env", + provider: "default", + id: "MATRIX_PASSWORD", + }, + accounts: { + default: { + accessToken: "default-token", + }, + }, + }, + }, + }, + env: {}, + }, + { + name: "channels.matrix.accounts.default.accessToken SecretRef config", + config: { + channels: { + matrix: { + password: { + source: "env", + provider: "default", + id: "MATRIX_PASSWORD", + }, + accounts: { + default: { + accessToken: { + source: "env", + provider: "default", + id: "MATRIX_DEFAULT_ACCESS_TOKEN_REF", + }, + }, + }, + }, + }, + }, + env: { + MATRIX_DEFAULT_ACCESS_TOKEN_REF: "default-token", + }, + }, + { + name: "MATRIX_DEFAULT_ACCESS_TOKEN env auth", + config: { + channels: { + matrix: { + password: { + source: "env", + provider: "default", + id: "MATRIX_PASSWORD", + }, + }, + }, + }, + env: { + MATRIX_DEFAULT_ACCESS_TOKEN: "default-token", + }, + }, + ])("ignores top-level Matrix password refs shadowed by $name", async ({ config, env }) => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig(config), + env, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.matrix?.password).toEqual({ + source: "env", + provider: "default", + id: "MATRIX_PASSWORD", + }); + expect(snapshot.warnings).toContainEqual( + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "channels.matrix.password", + }), + ); + }); + + it.each([ + { + name: "top-level Matrix accessToken config", + config: { + channels: { + matrix: { + accessToken: "default-token", + accounts: { + default: { + password: { + source: "env", + provider: "default", + id: "MATRIX_DEFAULT_PASSWORD", + }, + }, + }, + }, + }, + }, + env: {}, + }, + { + name: "top-level Matrix accessToken SecretRef config", + config: { + channels: { + matrix: { + accessToken: { + source: "env", + provider: "default", + id: "MATRIX_ACCESS_TOKEN_REF", + }, + accounts: { + default: { + password: { + source: "env", + provider: "default", + id: "MATRIX_DEFAULT_PASSWORD", + }, + }, + }, + }, + }, + }, + env: { + MATRIX_ACCESS_TOKEN_REF: "default-token", + }, + }, + { + name: "MATRIX_ACCESS_TOKEN env auth", + config: { + channels: { + matrix: { + accounts: { + default: { + password: { + source: "env", + provider: "default", + id: "MATRIX_DEFAULT_PASSWORD", + }, + }, + }, + }, + }, + }, + env: { + MATRIX_ACCESS_TOKEN: "default-token", + }, + }, + ])("ignores default-account Matrix password refs shadowed by $name", async ({ config, env }) => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig(config), + env, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.matrix?.accounts?.default?.password).toEqual({ + source: "env", + provider: "default", + id: "MATRIX_DEFAULT_PASSWORD", + }); + expect(snapshot.warnings).toContainEqual( + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "channels.matrix.accounts.default.password", + }), + ); + }); +}); diff --git a/src/secrets/runtime-matrix-top-level.test.ts b/src/secrets/runtime-matrix-top-level.test.ts new file mode 100644 index 00000000000..927ab0ee9f9 --- /dev/null +++ b/src/secrets/runtime-matrix-top-level.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; + +type WebProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl"; + +const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ + resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()), +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, +})); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +function createTestProvider(params: { + id: WebProviderUnderTest; + pluginId: string; + order: number; +}): PluginWebSearchProviderEntry { + const credentialPath = `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; + const readSearchConfigKey = (searchConfig?: Record): unknown => { + const providerConfig = + searchConfig?.[params.id] && typeof searchConfig[params.id] === "object" + ? (searchConfig[params.id] as { apiKey?: unknown }) + : undefined; + return providerConfig?.apiKey ?? searchConfig?.apiKey; + }; + return { + pluginId: params.pluginId, + id: params.id, + label: params.id, + hint: `${params.id} test provider`, + envVars: [`${params.id.toUpperCase()}_API_KEY`], + placeholder: `${params.id}-...`, + signupUrl: `https://example.com/${params.id}`, + autoDetectOrder: params.order, + credentialPath, + inactiveSecretPaths: [credentialPath], + getCredentialValue: readSearchConfigKey, + setCredentialValue: (searchConfigTarget, value) => { + const providerConfig = + params.id === "brave" || params.id === "firecrawl" + ? searchConfigTarget + : ((searchConfigTarget[params.id] ??= {}) as { apiKey?: unknown }); + providerConfig.apiKey = value; + }, + getConfiguredCredentialValue: (config) => + (config?.plugins?.entries?.[params.pluginId]?.config as { webSearch?: { apiKey?: unknown } }) + ?.webSearch?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + const plugins = (configTarget.plugins ??= {}) as { entries?: Record }; + const entries = (plugins.entries ??= {}); + const entry = (entries[params.pluginId] ??= {}) as { config?: Record }; + const config = (entry.config ??= {}); + const webSearch = (config.webSearch ??= {}) as { apiKey?: unknown }; + webSearch.apiKey = value; + }, + resolveRuntimeMetadata: + params.id === "perplexity" + ? () => ({ + perplexityTransport: "search_api" as const, + }) + : undefined, + createTool: () => null, + }; +} + +function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { + return [ + createTestProvider({ id: "brave", pluginId: "brave", order: 10 }), + createTestProvider({ id: "gemini", pluginId: "google", order: 20 }), + createTestProvider({ id: "grok", pluginId: "xai", order: 30 }), + createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }), + createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }), + createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }), + ]; +} + +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + +function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore { + return { + version: 1, + profiles, + }; +} + +describe("secrets runtime snapshot matrix access token", () => { + beforeAll(async () => { + ({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js")); + ({ clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } = await import("./runtime.js")); + }); + + beforeEach(() => { + resolvePluginWebSearchProvidersMock.mockReset(); + resolvePluginWebSearchProvidersMock.mockReturnValue(buildTestWebSearchProviders()); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it("resolves top-level Matrix accessToken refs even when named accounts exist", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + matrix: { + accessToken: { + source: "env", + provider: "default", + id: "MATRIX_ACCESS_TOKEN", + }, + accounts: { + ops: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + }), + env: { + MATRIX_ACCESS_TOKEN: "default-matrix-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadablePluginOrigins: new Map(), + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.matrix?.accessToken).toBe("default-matrix-token"); + }); +}); diff --git a/src/secrets/runtime-nextcloud-talk-file-precedence.test.ts b/src/secrets/runtime-nextcloud-talk-file-precedence.test.ts new file mode 100644 index 00000000000..325d6b6c4c1 --- /dev/null +++ b/src/secrets/runtime-nextcloud-talk-file-precedence.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; + +vi.mock("../channels/plugins/bootstrap-registry.js", async () => { + const nextcloudTalkSecrets = + await import("../../extensions/nextcloud-talk/src/secret-contract.ts"); + return { + getBootstrapChannelPlugin: (id: string) => + id === "nextcloud-talk" + ? { + secrets: { + collectRuntimeConfigAssignments: nextcloudTalkSecrets.collectRuntimeConfigAssignments, + }, + } + : undefined, + }; +}); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + +function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore { + return { + version: 1, + profiles, + }; +} + +describe("secrets runtime snapshot nextcloud talk file precedence", () => { + beforeAll(async () => { + ({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js")); + ({ clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } = await import("./runtime.js")); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it("treats top-level Nextcloud Talk botSecret and apiPassword refs as active when file paths are configured", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + "nextcloud-talk": { + botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_BOT_SECRET" }, + botSecretFile: "/tmp/missing-nextcloud-bot-secret-file", + apiUser: "bot-user", + apiPassword: { source: "env", provider: "default", id: "NEXTCLOUD_API_PASSWORD" }, + apiPasswordFile: "/tmp/missing-nextcloud-api-password-file", + }, + }, + }), + env: { + NEXTCLOUD_BOT_SECRET: "resolved-nextcloud-bot-secret", + NEXTCLOUD_API_PASSWORD: "resolved-nextcloud-api-password", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.["nextcloud-talk"]?.botSecret).toBe( + "resolved-nextcloud-bot-secret", + ); + expect(snapshot.config.channels?.["nextcloud-talk"]?.apiPassword).toBe( + "resolved-nextcloud-api-password", + ); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.nextcloud-talk.botSecret", + ); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.nextcloud-talk.apiPassword", + ); + }); + + it("treats account-level Nextcloud Talk botSecret and apiPassword refs as active when file paths are configured", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + "nextcloud-talk": { + accounts: { + work: { + botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_WORK_BOT_SECRET" }, + botSecretFile: "/tmp/missing-nextcloud-work-bot-secret-file", + apiPassword: { + source: "env", + provider: "default", + id: "NEXTCLOUD_WORK_API_PASSWORD", + }, + apiPasswordFile: "/tmp/missing-nextcloud-work-api-password-file", + }, + }, + }, + }, + }), + env: { + NEXTCLOUD_WORK_BOT_SECRET: "resolved-nextcloud-work-bot-secret", + NEXTCLOUD_WORK_API_PASSWORD: "resolved-nextcloud-work-api-password", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.["nextcloud-talk"]?.accounts?.work?.botSecret).toBe( + "resolved-nextcloud-work-bot-secret", + ); + expect(snapshot.config.channels?.["nextcloud-talk"]?.accounts?.work?.apiPassword).toBe( + "resolved-nextcloud-work-api-password", + ); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.nextcloud-talk.accounts.work.botSecret", + ); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.nextcloud-talk.accounts.work.apiPassword", + ); + }); +}); diff --git a/src/secrets/runtime-telegram-token-inheritance.test.ts b/src/secrets/runtime-telegram-token-inheritance.test.ts new file mode 100644 index 00000000000..de2ac584832 --- /dev/null +++ b/src/secrets/runtime-telegram-token-inheritance.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; + +vi.mock("../channels/plugins/bootstrap-registry.js", async () => { + const telegramSecrets = await import("../../extensions/telegram/src/secret-contract.ts"); + return { + getBootstrapChannelPlugin: (id: string) => + id === "telegram" + ? { + secrets: { + collectRuntimeConfigAssignments: telegramSecrets.collectRuntimeConfigAssignments, + }, + } + : undefined, + }; +}); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + +function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore { + return { + version: 1, + profiles, + }; +} + +describe("secrets runtime snapshot telegram token inheritance", () => { + beforeAll(async () => { + ({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js")); + ({ clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } = await import("./runtime.js")); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it("fails when enabled channel surfaces contain unresolved refs", async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + telegram: { + botToken: { + source: "env", + provider: "default", + id: "MISSING_ENABLED_TELEGRAM_TOKEN", + }, + accounts: { + work: { + enabled: true, + }, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }), + ).rejects.toThrow('Environment variable "MISSING_ENABLED_TELEGRAM_TOKEN" is missing or empty.'); + }); + + it("fails when default Telegram account can inherit an unresolved top-level token ref", async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + telegram: { + botToken: { + source: "env", + provider: "default", + id: "MISSING_ENABLED_TELEGRAM_TOKEN", + }, + accounts: { + default: { + enabled: true, + }, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }), + ).rejects.toThrow('Environment variable "MISSING_ENABLED_TELEGRAM_TOKEN" is missing or empty.'); + }); + + it("treats top-level Telegram token as inactive when all enabled accounts override it", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + telegram: { + botToken: { + source: "env", + provider: "default", + id: "UNUSED_TELEGRAM_BASE_TOKEN", + }, + accounts: { + work: { + enabled: true, + botToken: { + source: "env", + provider: "default", + id: "TELEGRAM_WORK_TOKEN", + }, + }, + disabled: { + enabled: false, + }, + }, + }, + }, + }), + env: { + TELEGRAM_WORK_TOKEN: "telegram-work-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.telegram?.accounts?.work?.botToken).toBe( + "telegram-work-token", + ); + expect(snapshot.config.channels?.telegram?.botToken).toEqual({ + source: "env", + provider: "default", + id: "UNUSED_TELEGRAM_BASE_TOKEN", + }); + expect(snapshot.warnings.map((warning) => warning.path)).toContain( + "channels.telegram.botToken", + ); + }); + + it("treats Telegram account overrides as enabled when account.enabled is omitted", async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + telegram: { + enabled: true, + accounts: { + inheritedEnabled: { + botToken: { + source: "env", + provider: "default", + id: "MISSING_INHERITED_TELEGRAM_ACCOUNT_TOKEN", + }, + }, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }), + ).rejects.toThrow( + 'Environment variable "MISSING_INHERITED_TELEGRAM_ACCOUNT_TOKEN" is missing or empty.', + ); + }); + + it("treats top-level Telegram botToken refs as active when account botToken is blank", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + telegram: { + botToken: { + source: "env", + provider: "default", + id: "TELEGRAM_BASE_TOKEN", + }, + accounts: { + work: { + enabled: true, + botToken: "", + }, + }, + }, + }, + }), + env: { + TELEGRAM_BASE_TOKEN: "telegram-base-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.telegram?.botToken).toBe("telegram-base-token"); + expect(snapshot.config.channels?.telegram?.accounts?.work?.botToken).toBe(""); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.telegram.botToken", + ); + }); + + it("treats Telegram webhookSecret refs as inactive when webhook mode is not configured", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + telegram: { + webhookSecret: { + source: "env", + provider: "default", + id: "MISSING_TELEGRAM_WEBHOOK_SECRET", + }, + accounts: { + work: { + enabled: true, + }, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.telegram?.webhookSecret).toEqual({ + source: "env", + provider: "default", + id: "MISSING_TELEGRAM_WEBHOOK_SECRET", + }); + expect(snapshot.warnings.map((warning) => warning.path)).toContain( + "channels.telegram.webhookSecret", + ); + }); + + it("treats Telegram top-level botToken refs as inactive when tokenFile is configured", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + telegram: { + tokenFile: "/tmp/telegram-bot-token", + botToken: { + source: "env", + provider: "default", + id: "MISSING_TELEGRAM_BOT_TOKEN", + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.telegram?.botToken).toEqual({ + source: "env", + provider: "default", + id: "MISSING_TELEGRAM_BOT_TOKEN", + }); + expect(snapshot.warnings.map((warning) => warning.path)).toContain( + "channels.telegram.botToken", + ); + }); + + it("treats Telegram account botToken refs as inactive when account tokenFile is configured", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + telegram: { + accounts: { + work: { + enabled: true, + tokenFile: "/tmp/telegram-work-bot-token", + botToken: { + source: "env", + provider: "default", + id: "MISSING_TELEGRAM_WORK_BOT_TOKEN", + }, + }, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.telegram?.accounts?.work?.botToken).toEqual({ + source: "env", + provider: "default", + id: "MISSING_TELEGRAM_WORK_BOT_TOKEN", + }); + expect(snapshot.warnings.map((warning) => warning.path)).toContain( + "channels.telegram.accounts.work.botToken", + ); + }); +}); diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index fe424187db0..06537692495 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -801,6 +801,17 @@ describe("runtime web tools resolution", () => { expect(metadata.search.selectedProvider).toBeUndefined(); }); + it("skips provider discovery when no web surfaces are configured", async () => { + const { metadata } = await runRuntimeWebTools({ + config: asConfig({}), + }); + + expect(metadata.search.providerSource).toBe("none"); + expect(metadata.fetch.providerSource).toBe("none"); + expect(runtimeWebSearchProviders.resolvePluginWebSearchProviders).not.toHaveBeenCalled(); + expect(runtimeWebFetchProviders.resolvePluginWebFetchProviders).not.toHaveBeenCalled(); + }); + it("uses env fallback for unresolved web fetch provider SecretRef when active", async () => { const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ config: asConfig({ diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 976e843008b..943ce59ace5 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -61,6 +61,20 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function hasPluginWebToolConfig(config: OpenClawConfig): boolean { + const entries = config.plugins?.entries; + if (!entries) { + return false; + } + return Object.values(entries).some((entry) => { + if (!isRecord(entry)) { + return false; + } + const pluginConfig = isRecord(entry.config) ? entry.config : undefined; + return Boolean(pluginConfig?.webSearch || pluginConfig?.webFetch); + }); +} + function normalizeProvider( value: unknown, providers: ReturnType, @@ -366,9 +380,45 @@ export async function resolveRuntimeWebTools(params: { const defaults = params.sourceConfig.secrets?.defaults; const diagnostics: RuntimeWebDiagnostic[] = []; - const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; - const web = isRecord(tools?.web) ? tools.web : undefined; - const search = isRecord(web?.search) ? web.search : undefined; + const sourceTools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; + const sourceWeb = isRecord(sourceTools?.web) ? sourceTools.web : undefined; + const resolvedTools = isRecord(params.resolvedConfig.tools) + ? params.resolvedConfig.tools + : undefined; + const resolvedWeb = isRecord(resolvedTools?.web) ? resolvedTools.web : undefined; + const legacyXSearchSource = isRecord(sourceWeb?.x_search) ? sourceWeb.x_search : undefined; + const legacyXSearchResolved = isRecord(resolvedWeb?.x_search) ? resolvedWeb.x_search : undefined; + if (!sourceWeb && !hasPluginWebToolConfig(params.sourceConfig)) { + return { + search: { + providerSource: "none", + diagnostics: [], + }, + fetch: { + providerSource: "none", + diagnostics: [], + }, + diagnostics, + }; + } + if ( + legacyXSearchSource && + legacyXSearchResolved && + Object.prototype.hasOwnProperty.call(legacyXSearchSource, "apiKey") + ) { + const resolution = await resolveSecretInputWithEnvFallback({ + sourceConfig: params.sourceConfig, + context: params.context, + defaults, + value: legacyXSearchSource.apiKey, + path: "tools.web.x_search.apiKey", + envVars: ["XAI_API_KEY"], + }); + if (resolution.value) { + legacyXSearchResolved.apiKey = resolution.value; + } + } + const search = isRecord(sourceWeb?.search) ? sourceWeb.search : undefined; const rawProvider = typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; const configuredBundledPluginId = resolveManifestContractOwnerPluginId({ @@ -676,7 +726,7 @@ export async function resolveRuntimeWebTools(params: { } } - const fetch = isRecord(web?.fetch) ? (web.fetch as FetchConfig) : undefined; + const fetch = isRecord(sourceWeb?.fetch) ? (sourceWeb.fetch as FetchConfig) : undefined; const rawFetchProvider = typeof fetch?.provider === "string" ? fetch.provider.trim().toLowerCase() : ""; const configuredBundledFetchPluginId = resolveManifestContractOwnerPluginId({ diff --git a/src/secrets/runtime-zalo-token-activity.test.ts b/src/secrets/runtime-zalo-token-activity.test.ts new file mode 100644 index 00000000000..cc72f38a28d --- /dev/null +++ b/src/secrets/runtime-zalo-token-activity.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; + +vi.mock("../channels/plugins/bootstrap-registry.js", async () => { + const zaloSecrets = await import("../../extensions/zalo/src/secret-contract.ts"); + return { + getBootstrapChannelPlugin: (id: string) => + id === "zalo" + ? { + secrets: { + collectRuntimeConfigAssignments: zaloSecrets.collectRuntimeConfigAssignments, + }, + } + : undefined, + }; +}); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot; +let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot; + +function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore { + return { + version: 1, + profiles, + }; +} + +describe("secrets runtime snapshot zalo token activity", () => { + beforeAll(async () => { + ({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js")); + ({ clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } = await import("./runtime.js")); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearSecretsRuntimeSnapshot(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + }); + + it("treats top-level Zalo botToken refs as active even when tokenFile is configured", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + zalo: { + botToken: { source: "env", provider: "default", id: "ZALO_BOT_TOKEN" }, + tokenFile: "/tmp/missing-zalo-token-file", + }, + }, + }), + env: { + ZALO_BOT_TOKEN: "resolved-zalo-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.zalo?.botToken).toBe("resolved-zalo-token"); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.zalo.botToken", + ); + }); + + it("treats account-level Zalo botToken refs as active even when tokenFile is configured", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + zalo: { + accounts: { + work: { + botToken: { source: "env", provider: "default", id: "ZALO_WORK_BOT_TOKEN" }, + tokenFile: "/tmp/missing-zalo-work-token-file", + }, + }, + }, + }, + }), + env: { + ZALO_WORK_BOT_TOKEN: "resolved-zalo-work-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.zalo?.accounts?.work?.botToken).toBe( + "resolved-zalo-work-token", + ); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.zalo.accounts.work.botToken", + ); + }); + + it("treats top-level Zalo botToken refs as active for non-default accounts without overrides", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + zalo: { + botToken: { source: "env", provider: "default", id: "ZALO_TOP_LEVEL_TOKEN" }, + accounts: { + work: { + enabled: true, + }, + }, + }, + }, + }), + env: { + ZALO_TOP_LEVEL_TOKEN: "resolved-zalo-top-level-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.zalo?.botToken).toBe("resolved-zalo-top-level-token"); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.zalo.botToken", + ); + }); + + it("treats channels.zalo.accounts.default.botToken refs as active", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + zalo: { + accounts: { + default: { + enabled: true, + botToken: { source: "env", provider: "default", id: "ZALO_DEFAULT_TOKEN" }, + }, + }, + }, + }, + }), + env: { + ZALO_DEFAULT_TOKEN: "resolved-zalo-default-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => loadAuthStoreWithProfiles({}), + }); + + expect(snapshot.config.channels?.zalo?.accounts?.default?.botToken).toBe( + "resolved-zalo-default-token", + ); + expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( + "channels.zalo.accounts.default.botToken", + ); + }); +}); diff --git a/src/secrets/runtime.auth.integration.test.ts b/src/secrets/runtime.auth.integration.test.ts index b06ba4be902..5243a63ba84 100644 --- a/src/secrets/runtime.auth.integration.test.ts +++ b/src/secrets/runtime.auth.integration.test.ts @@ -1,29 +1,17 @@ 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 } from "../agents/auth-profiles.js"; -import { loadConfig, writeConfigFile } from "../config/config.js"; import { withTempHome } from "../config/home-env.test-harness.js"; -import { withEnvAsync } from "../test-utils/env.js"; import { asConfig, beginSecretsRuntimeIsolationForTest, - createOpenAIFileRuntimeConfig, - createOpenAIFileRuntimeFixture, EMPTY_LOADABLE_PLUGIN_ORIGINS, endSecretsRuntimeIsolationForTest, - expectResolvedOpenAIRuntime, - loadAuthStoreWithProfiles, OPENAI_ENV_KEY_REF, - OPENAI_FILE_KEY_REF, type SecretsRuntimeEnvSnapshot, } from "./runtime-auth.integration.test-helpers.js"; -import { - activateSecretsRuntimeSnapshot, - getActiveSecretsRuntimeSnapshot, - prepareSecretsRuntimeSnapshot, -} from "./runtime.js"; +import { activateSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } from "./runtime.js"; vi.unmock("../version.js"); @@ -38,129 +26,6 @@ describe("secrets runtime snapshot auth integration", () => { endSecretsRuntimeIsolationForTest(envSnapshot); }); - it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => { - await withEnvAsync( - { - OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_VERSION: undefined, - }, - async () => { - const prepared = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: OPENAI_ENV_KEY_REF, - models: [], - }, - }, - }, - }), - env: { OPENAI_API_KEY: "sk-runtime" }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, - loadAuthStore: () => - loadAuthStoreWithProfiles({ - "openai:default": { - type: "api_key", - provider: "openai", - keyRef: OPENAI_ENV_KEY_REF, - }, - }), - }); - - activateSecretsRuntimeSnapshot(prepared); - - expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime"); - expect( - ensureAuthProfileStore("/tmp/openclaw-agent-main").profiles["openai:default"], - ).toMatchObject({ - type: "api_key", - key: "sk-runtime", - }); - }, - ); - }); - - it("keeps active secrets runtime snapshots resolved after config writes", async () => { - if (os.platform() === "win32") { - return; - } - await withTempHome("openclaw-secrets-runtime-write-", async (home) => { - const { secretFile, agentDir } = await createOpenAIFileRuntimeFixture(home); - - const prepared = await prepareSecretsRuntimeSnapshot({ - config: createOpenAIFileRuntimeConfig(secretFile), - agentDirs: [agentDir], - loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, - }); - - activateSecretsRuntimeSnapshot(prepared); - - expectResolvedOpenAIRuntime(agentDir); - - await writeConfigFile({ - ...loadConfig(), - gateway: { auth: { mode: "token" } }, - }); - - expect(loadConfig().gateway?.auth).toEqual({ mode: "token" }); - expectResolvedOpenAIRuntime(agentDir); - }); - }); - - it("keeps last-known-good runtime snapshot active when refresh fails after a write", async () => { - if (os.platform() === "win32") { - return; - } - await withTempHome("openclaw-secrets-runtime-refresh-fail-", async (home) => { - const { secretFile, agentDir } = await createOpenAIFileRuntimeFixture(home); - - let loadAuthStoreCalls = 0; - const loadAuthStore = () => { - loadAuthStoreCalls += 1; - if (loadAuthStoreCalls > 1) { - throw new Error("simulated secrets runtime refresh failure"); - } - return loadAuthStoreWithProfiles({ - "openai:default": { - type: "api_key", - provider: "openai", - keyRef: OPENAI_FILE_KEY_REF, - }, - }); - }; - - const prepared = await prepareSecretsRuntimeSnapshot({ - config: createOpenAIFileRuntimeConfig(secretFile), - agentDirs: [agentDir], - loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, - loadAuthStore, - }); - - activateSecretsRuntimeSnapshot(prepared); - - await expect( - writeConfigFile({ - ...loadConfig(), - gateway: { auth: { mode: "token" } }, - }), - ).rejects.toThrow( - /runtime snapshot refresh failed: simulated secrets runtime refresh failure/i, - ); - - const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); - expect(activeAfterFailure).not.toBeNull(); - expect(loadConfig().gateway?.auth).toBeUndefined(); - expectResolvedOpenAIRuntime(agentDir); - expect(activeAfterFailure?.sourceConfig.models?.providers?.openai?.apiKey).toEqual( - OPENAI_FILE_KEY_REF, - ); - }); - }); - 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"); @@ -216,11 +81,19 @@ describe("secrets runtime snapshot auth integration", () => { activateSecretsRuntimeSnapshot(prepared); expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toBeUndefined(); - await writeConfigFile({ - agents: { - list: [{ id: "ops", agentDir: opsAgentDir }], + const refreshed = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + agents: { + list: [{ id: "ops", agentDir: opsAgentDir }], + }, + }), + env: { + OPENAI_API_KEY: "sk-main-runtime", + ANTHROPIC_API_KEY: "sk-ops-runtime", }, + loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, }); + activateSecretsRuntimeSnapshot(refreshed); expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toMatchObject({ type: "api_key", diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 464a5db4b66..9e9743d4317 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -135,157 +135,6 @@ describe("secrets runtime snapshot", () => { clearConfigCache(); }); - it("resolves core env refs for config and auth profiles", async () => { - const config = asConfig({ - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" }, - }, - }, - }, - }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - headers: { - Authorization: { - source: "env", - provider: "default", - id: "OPENAI_PROVIDER_AUTH_HEADER", - }, - }, - models: [], - }, - }, - }, - skills: { - entries: { - "review-pr": { - enabled: true, - apiKey: { source: "env", provider: "default", id: "REVIEW_SKILL_API_KEY" }, - }, - }, - }, - talk: { - providers: { - "acme-speech": { - apiKey: { source: "env", provider: "default", id: "TALK_PROVIDER_API_KEY" }, - }, - }, - }, - gateway: { - mode: "remote", - remote: { - url: "wss://gateway.example", - token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" }, - password: { source: "env", provider: "default", id: "REMOTE_GATEWAY_PASSWORD" }, - }, - }, - }); - - const snapshot = await prepareSecretsRuntimeSnapshot({ - config, - env: { - OPENAI_API_KEY: "sk-env-openai", // pragma: allowlist secret - OPENAI_PROVIDER_AUTH_HEADER: "Bearer sk-env-header", // pragma: allowlist secret - GITHUB_TOKEN: "ghp-env-token", // pragma: allowlist secret - REVIEW_SKILL_API_KEY: "sk-skill-ref", // pragma: allowlist secret - MEMORY_REMOTE_API_KEY: "mem-ref-key", // pragma: allowlist secret - TALK_PROVIDER_API_KEY: "talk-provider-ref-key", // pragma: allowlist secret - REMOTE_GATEWAY_TOKEN: "remote-token-ref", - REMOTE_GATEWAY_PASSWORD: "remote-password-ref", // pragma: allowlist secret - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadablePluginOrigins: new Map(), - loadAuthStore: () => - loadAuthStoreWithProfiles({ - "openai:default": { - type: "api_key", - provider: "openai", - key: "old-openai", - keyRef: OPENAI_ENV_KEY_REF, - }, - "github-copilot:default": { - type: "token", - provider: "github-copilot", - token: "old-gh", - tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, - }, - "openai:inline": { - type: "api_key", - provider: "openai", - key: "${OPENAI_API_KEY}", - }, - }), - }); - - expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai"); - expect(snapshot.config.models?.providers?.openai?.headers?.Authorization).toBe( - "Bearer sk-env-header", - ); - expect(snapshot.config.skills?.entries?.["review-pr"]?.apiKey).toBe("sk-skill-ref"); - expect(snapshot.config.agents?.defaults?.memorySearch?.remote?.apiKey).toBe("mem-ref-key"); - expect((snapshot.config.talk as { apiKey?: unknown } | undefined)?.apiKey).toBeUndefined(); - expect(snapshot.config.talk?.providers?.["acme-speech"]?.apiKey).toBe("talk-provider-ref-key"); - expect(snapshot.config.gateway?.remote?.token).toBe("remote-token-ref"); - expect(snapshot.config.gateway?.remote?.password).toBe("remote-password-ref"); - expect(snapshot.warnings.map((warning) => warning.path)).toEqual( - expect.arrayContaining([ - "/tmp/openclaw-agent-main.auth-profiles.openai:default.key", - "/tmp/openclaw-agent-main.auth-profiles.github-copilot:default.token", - ]), - ); - expect(snapshot.authStores[0]?.store.profiles["openai:default"]).toMatchObject({ - type: "api_key", - key: "sk-env-openai", - }); - expect(snapshot.authStores[0]?.store.profiles["github-copilot:default"]).toMatchObject({ - type: "token", - token: "ghp-env-token", - }); - expect(snapshot.authStores[0]?.store.profiles["openai:inline"]).toMatchObject({ - type: "api_key", - key: "sk-env-openai", - }); - // After normalization, inline SecretRef string should be promoted to keyRef - expect( - (snapshot.authStores[0].store.profiles["openai:inline"] as Record).keyRef, - ).toEqual({ source: "env", provider: "default", id: "OPENAI_API_KEY" }); - }); - - it("resolves top-level Matrix accessToken refs even when named accounts exist", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - matrix: { - accessToken: { - source: "env", - provider: "default", - id: "MATRIX_ACCESS_TOKEN", - }, - accounts: { - ops: { - homeserver: "https://matrix.example.org", - accessToken: "ops-token", - }, - }, - }, - }, - }), - env: { - MATRIX_ACCESS_TOKEN: "default-matrix-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.matrix?.accessToken).toBe("default-matrix-token"); - }); - it("can skip auth-profile SecretRef resolution when includeAuthStoreRefs is false", async () => { const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_SECRET_${Date.now()}`; delete process.env[missingEnvVar]; @@ -319,217 +168,6 @@ describe("secrets runtime snapshot", () => { expect(snapshot.authStores).toEqual([]); }); - it("ignores Matrix password refs that are shadowed by scoped env access tokens", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - matrix: { - accounts: { - ops: { - password: { - source: "env", - provider: "default", - id: "MATRIX_OPS_PASSWORD", - }, - }, - }, - }, - }, - }), - env: { - MATRIX_OPS_ACCESS_TOKEN: "ops-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.matrix?.accounts?.ops?.password).toEqual({ - source: "env", - provider: "default", - id: "MATRIX_OPS_PASSWORD", - }); - expect(snapshot.warnings).toContainEqual( - expect.objectContaining({ - code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", - path: "channels.matrix.accounts.ops.password", - }), - ); - }); - - it.each([ - { - name: "channels.matrix.accounts.default.accessToken config", - config: { - channels: { - matrix: { - password: { - source: "env", - provider: "default", - id: "MATRIX_PASSWORD", - }, - accounts: { - default: { - accessToken: "default-token", - }, - }, - }, - }, - }, - env: {}, - }, - { - name: "channels.matrix.accounts.default.accessToken SecretRef config", - config: { - channels: { - matrix: { - password: { - source: "env", - provider: "default", - id: "MATRIX_PASSWORD", - }, - accounts: { - default: { - accessToken: { - source: "env", - provider: "default", - id: "MATRIX_DEFAULT_ACCESS_TOKEN_REF", - }, - }, - }, - }, - }, - }, - env: { - MATRIX_DEFAULT_ACCESS_TOKEN_REF: "default-token", - }, - }, - { - name: "MATRIX_DEFAULT_ACCESS_TOKEN env auth", - config: { - channels: { - matrix: { - password: { - source: "env", - provider: "default", - id: "MATRIX_PASSWORD", - }, - }, - }, - }, - env: { - MATRIX_DEFAULT_ACCESS_TOKEN: "default-token", - }, - }, - ])("ignores top-level Matrix password refs shadowed by $name", async ({ config, env }) => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig(config), - env, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.matrix?.password).toEqual({ - source: "env", - provider: "default", - id: "MATRIX_PASSWORD", - }); - expect(snapshot.warnings).toContainEqual( - expect.objectContaining({ - code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", - path: "channels.matrix.password", - }), - ); - }); - - it.each([ - { - name: "top-level Matrix accessToken config", - config: { - channels: { - matrix: { - accessToken: "default-token", - accounts: { - default: { - password: { - source: "env", - provider: "default", - id: "MATRIX_DEFAULT_PASSWORD", - }, - }, - }, - }, - }, - }, - env: {}, - }, - { - name: "top-level Matrix accessToken SecretRef config", - config: { - channels: { - matrix: { - accessToken: { - source: "env", - provider: "default", - id: "MATRIX_ACCESS_TOKEN_REF", - }, - accounts: { - default: { - password: { - source: "env", - provider: "default", - id: "MATRIX_DEFAULT_PASSWORD", - }, - }, - }, - }, - }, - }, - env: { - MATRIX_ACCESS_TOKEN_REF: "default-token", - }, - }, - { - name: "MATRIX_ACCESS_TOKEN env auth", - config: { - channels: { - matrix: { - accounts: { - default: { - password: { - source: "env", - provider: "default", - id: "MATRIX_DEFAULT_PASSWORD", - }, - }, - }, - }, - }, - }, - env: { - MATRIX_ACCESS_TOKEN: "default-token", - }, - }, - ])("ignores default-account Matrix password refs shadowed by $name", async ({ config, env }) => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig(config), - env, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.matrix?.accounts?.default?.password).toEqual({ - source: "env", - provider: "default", - id: "MATRIX_DEFAULT_PASSWORD", - }); - expect(snapshot.warnings).toContainEqual( - expect.objectContaining({ - code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", - path: "channels.matrix.accounts.default.password", - }), - ); - }); - it("resolves sandbox ssh secret refs for active ssh backends", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ @@ -848,92 +486,6 @@ describe("secrets runtime snapshot", () => { } }); - it("skips inactive-surface refs and emits diagnostics", async () => { - const config = asConfig({ - agents: { - defaults: { - memorySearch: { - enabled: false, - remote: { - apiKey: { source: "env", provider: "default", id: "DISABLED_MEMORY_API_KEY" }, - }, - }, - }, - }, - gateway: { - auth: { - mode: "token", - password: { source: "env", provider: "default", id: "DISABLED_GATEWAY_PASSWORD" }, - }, - }, - channels: { - telegram: { - botToken: { source: "env", provider: "default", id: "DISABLED_TELEGRAM_BASE_TOKEN" }, - accounts: { - disabled: { - enabled: false, - botToken: { - source: "env", - provider: "default", - id: "DISABLED_TELEGRAM_ACCOUNT_TOKEN", - }, - }, - }, - }, - }, - tools: { - web: { - search: { - enabled: false, - apiKey: { source: "env", provider: "default", id: "DISABLED_WEB_SEARCH_API_KEY" }, - }, - }, - }, - plugins: { - entries: { - google: { - config: { - webSearch: { - apiKey: { - source: "env", - provider: "default", - id: "DISABLED_WEB_SEARCH_GEMINI_API_KEY", - }, - }, - }, - }, - }, - }, - }); - - const snapshot = await prepareSecretsRuntimeSnapshot({ - config, - env: {}, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.telegram?.botToken).toEqual({ - source: "env", - provider: "default", - id: "DISABLED_TELEGRAM_BASE_TOKEN", - }); - const ignoredInactiveWarnings = snapshot.warnings.filter( - (warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE", - ); - expect(ignoredInactiveWarnings).toHaveLength(6); - expect(snapshot.warnings.map((warning) => warning.path)).toEqual( - expect.arrayContaining([ - "agents.defaults.memorySearch.remote.apiKey", - "gateway.auth.password", - "channels.telegram.botToken", - "channels.telegram.accounts.disabled.botToken", - "plugins.entries.brave.config.webSearch.apiKey", - "plugins.entries.google.config.webSearch.apiKey", - ]), - ); - }); - it("treats gateway.remote refs as inactive when local auth credentials are configured", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ @@ -1564,191 +1116,6 @@ describe("secrets runtime snapshot", () => { ); }); - it("treats top-level Zalo botToken refs as active even when tokenFile is configured", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - zalo: { - botToken: { source: "env", provider: "default", id: "ZALO_BOT_TOKEN" }, - tokenFile: "/tmp/missing-zalo-token-file", - }, - }, - }), - env: { - ZALO_BOT_TOKEN: "resolved-zalo-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.zalo?.botToken).toBe("resolved-zalo-token"); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.zalo.botToken", - ); - }); - - it("treats account-level Zalo botToken refs as active even when tokenFile is configured", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - zalo: { - accounts: { - work: { - botToken: { source: "env", provider: "default", id: "ZALO_WORK_BOT_TOKEN" }, - tokenFile: "/tmp/missing-zalo-work-token-file", - }, - }, - }, - }, - }), - env: { - ZALO_WORK_BOT_TOKEN: "resolved-zalo-work-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.zalo?.accounts?.work?.botToken).toBe( - "resolved-zalo-work-token", - ); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.zalo.accounts.work.botToken", - ); - }); - - it("treats top-level Zalo botToken refs as active for non-default accounts without overrides", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - zalo: { - botToken: { source: "env", provider: "default", id: "ZALO_TOP_LEVEL_TOKEN" }, - accounts: { - work: { - enabled: true, - }, - }, - }, - }, - }), - env: { - ZALO_TOP_LEVEL_TOKEN: "resolved-zalo-top-level-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.zalo?.botToken).toBe("resolved-zalo-top-level-token"); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.zalo.botToken", - ); - }); - - it("treats channels.zalo.accounts.default.botToken refs as active", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - zalo: { - accounts: { - default: { - enabled: true, - botToken: { source: "env", provider: "default", id: "ZALO_DEFAULT_TOKEN" }, - }, - }, - }, - }, - }), - env: { - ZALO_DEFAULT_TOKEN: "resolved-zalo-default-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.zalo?.accounts?.default?.botToken).toBe( - "resolved-zalo-default-token", - ); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.zalo.accounts.default.botToken", - ); - }); - - it("treats top-level Nextcloud Talk botSecret and apiPassword refs as active when file paths are configured", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - "nextcloud-talk": { - botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_BOT_SECRET" }, - botSecretFile: "/tmp/missing-nextcloud-bot-secret-file", - apiUser: "bot-user", - apiPassword: { source: "env", provider: "default", id: "NEXTCLOUD_API_PASSWORD" }, - apiPasswordFile: "/tmp/missing-nextcloud-api-password-file", - }, - }, - }), - env: { - NEXTCLOUD_BOT_SECRET: "resolved-nextcloud-bot-secret", // pragma: allowlist secret - NEXTCLOUD_API_PASSWORD: "resolved-nextcloud-api-password", // pragma: allowlist secret - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.["nextcloud-talk"]?.botSecret).toBe( - "resolved-nextcloud-bot-secret", - ); - expect(snapshot.config.channels?.["nextcloud-talk"]?.apiPassword).toBe( - "resolved-nextcloud-api-password", - ); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.nextcloud-talk.botSecret", - ); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.nextcloud-talk.apiPassword", - ); - }); - - it("treats account-level Nextcloud Talk botSecret and apiPassword refs as active when file paths are configured", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - "nextcloud-talk": { - accounts: { - work: { - botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_WORK_BOT_SECRET" }, - botSecretFile: "/tmp/missing-nextcloud-work-bot-secret-file", - apiPassword: { - source: "env", - provider: "default", - id: "NEXTCLOUD_WORK_API_PASSWORD", - }, - apiPasswordFile: "/tmp/missing-nextcloud-work-api-password-file", - }, - }, - }, - }, - }), - env: { - NEXTCLOUD_WORK_BOT_SECRET: "resolved-nextcloud-work-bot-secret", // pragma: allowlist secret - NEXTCLOUD_WORK_API_PASSWORD: "resolved-nextcloud-work-api-password", // pragma: allowlist secret - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.["nextcloud-talk"]?.accounts?.work?.botSecret).toBe( - "resolved-nextcloud-work-bot-secret", - ); - expect(snapshot.config.channels?.["nextcloud-talk"]?.accounts?.work?.apiPassword).toBe( - "resolved-nextcloud-work-api-password", - ); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.nextcloud-talk.accounts.work.botSecret", - ); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.nextcloud-talk.accounts.work.apiPassword", - ); - }); - it("treats gateway.remote refs as active when tailscale serve is enabled", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ @@ -1818,261 +1185,6 @@ describe("secrets runtime snapshot", () => { ); }); - it("fails when enabled channel surfaces contain unresolved refs", async () => { - await expect( - prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - telegram: { - botToken: { - source: "env", - provider: "default", - id: "MISSING_ENABLED_TELEGRAM_TOKEN", - }, - accounts: { - work: { - enabled: true, - }, - }, - }, - }, - }), - env: {}, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }), - ).rejects.toThrow('Environment variable "MISSING_ENABLED_TELEGRAM_TOKEN" is missing or empty.'); - }); - - it("fails when default Telegram account can inherit an unresolved top-level token ref", async () => { - await expect( - prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - telegram: { - botToken: { - source: "env", - provider: "default", - id: "MISSING_ENABLED_TELEGRAM_TOKEN", - }, - accounts: { - default: { - enabled: true, - }, - }, - }, - }, - }), - env: {}, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }), - ).rejects.toThrow('Environment variable "MISSING_ENABLED_TELEGRAM_TOKEN" is missing or empty.'); - }); - - it("treats top-level Telegram token as inactive when all enabled accounts override it", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - telegram: { - botToken: { - source: "env", - provider: "default", - id: "UNUSED_TELEGRAM_BASE_TOKEN", - }, - accounts: { - work: { - enabled: true, - botToken: { - source: "env", - provider: "default", - id: "TELEGRAM_WORK_TOKEN", - }, - }, - disabled: { - enabled: false, - }, - }, - }, - }, - }), - env: { - TELEGRAM_WORK_TOKEN: "telegram-work-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.telegram?.accounts?.work?.botToken).toBe( - "telegram-work-token", - ); - expect(snapshot.config.channels?.telegram?.botToken).toEqual({ - source: "env", - provider: "default", - id: "UNUSED_TELEGRAM_BASE_TOKEN", - }); - expect(snapshot.warnings.map((warning) => warning.path)).toContain( - "channels.telegram.botToken", - ); - }); - - it("treats Telegram account overrides as enabled when account.enabled is omitted", async () => { - await expect( - prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - telegram: { - enabled: true, - accounts: { - inheritedEnabled: { - botToken: { - source: "env", - provider: "default", - id: "MISSING_INHERITED_TELEGRAM_ACCOUNT_TOKEN", - }, - }, - }, - }, - }, - }), - env: {}, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }), - ).rejects.toThrow( - 'Environment variable "MISSING_INHERITED_TELEGRAM_ACCOUNT_TOKEN" is missing or empty.', - ); - }); - - it("treats Telegram webhookSecret refs as inactive when webhook mode is not configured", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - telegram: { - webhookSecret: { - source: "env", - provider: "default", - id: "MISSING_TELEGRAM_WEBHOOK_SECRET", - }, - accounts: { - work: { - enabled: true, - }, - }, - }, - }, - }), - env: {}, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.telegram?.webhookSecret).toEqual({ - source: "env", - provider: "default", - id: "MISSING_TELEGRAM_WEBHOOK_SECRET", - }); - expect(snapshot.warnings.map((warning) => warning.path)).toContain( - "channels.telegram.webhookSecret", - ); - }); - - it("treats Telegram top-level botToken refs as inactive when tokenFile is configured", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - telegram: { - tokenFile: "/tmp/telegram-bot-token", - botToken: { - source: "env", - provider: "default", - id: "MISSING_TELEGRAM_BOT_TOKEN", - }, - }, - }, - }), - env: {}, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.telegram?.botToken).toEqual({ - source: "env", - provider: "default", - id: "MISSING_TELEGRAM_BOT_TOKEN", - }); - expect(snapshot.warnings.map((warning) => warning.path)).toContain( - "channels.telegram.botToken", - ); - }); - - it("treats Telegram account botToken refs as inactive when account tokenFile is configured", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - telegram: { - accounts: { - work: { - enabled: true, - tokenFile: "/tmp/telegram-work-bot-token", - botToken: { - source: "env", - provider: "default", - id: "MISSING_TELEGRAM_WORK_BOT_TOKEN", - }, - }, - }, - }, - }, - }), - env: {}, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.telegram?.accounts?.work?.botToken).toEqual({ - source: "env", - provider: "default", - id: "MISSING_TELEGRAM_WORK_BOT_TOKEN", - }); - expect(snapshot.warnings.map((warning) => warning.path)).toContain( - "channels.telegram.accounts.work.botToken", - ); - }); - - it("treats top-level Telegram botToken refs as active when account botToken is blank", async () => { - const snapshot = await prepareSecretsRuntimeSnapshot({ - config: asConfig({ - channels: { - telegram: { - botToken: { - source: "env", - provider: "default", - id: "TELEGRAM_BASE_TOKEN", - }, - accounts: { - work: { - enabled: true, - botToken: "", - }, - }, - }, - }, - }), - env: { - TELEGRAM_BASE_TOKEN: "telegram-base-token", - }, - agentDirs: ["/tmp/openclaw-agent-main"], - loadAuthStore: () => ({ version: 1, profiles: {} }), - }); - - expect(snapshot.config.channels?.telegram?.botToken).toBe("telegram-base-token"); - expect(snapshot.config.channels?.telegram?.accounts?.work?.botToken).toBe(""); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "channels.telegram.botToken", - ); - }); - it("treats IRC account nickserv password refs as inactive when nickserv is disabled", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ @@ -2360,11 +1472,13 @@ describe("secrets runtime snapshot", () => { voice: { enabled: false, tts: { - openai: { - apiKey: { - source: "env", - provider: "default", - id: "MISSING_DISCORD_VOICE_TTS_OPENAI", + providers: { + openai: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_DISCORD_VOICE_TTS_OPENAI", + }, }, }, }, @@ -2375,11 +1489,13 @@ describe("secrets runtime snapshot", () => { voice: { enabled: false, tts: { - openai: { - apiKey: { - source: "env", - provider: "default", - id: "MISSING_DISCORD_WORK_VOICE_TTS_OPENAI", + providers: { + openai: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_DISCORD_WORK_VOICE_TTS_OPENAI", + }, }, }, },