diff --git a/CHANGELOG.md b/CHANGELOG.md index f824449f51f..518743cd2ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,6 +156,7 @@ Docs: https://docs.openclaw.ai - Agents/current-time UTC anchor: append a machine-readable UTC suffix alongside local `Current time:` lines in shared cron-style prompt contexts so agents can compare UTC-stamped workspace timestamps without doing timezone math. (#32423) thanks @jriff. - TUI/webchat command-owner scope alignment: treat internal-channel gateway sessions with `operator.admin` as owner-authorized in command auth, restoring cron/gateway/connector tool access for affected TUI/webchat sessions while keeping external channels on identity-based owner checks. (from #35666, #35673, #35704) Thanks @Naylenv, @Octane0411, and @Sid-Qin. - Discord/inbound timeout isolation: separate inbound worker timeout tracking from listener timeout budgets so queued Discord replies are no longer dropped when listener watchdog windows expire mid-run. (#36602) Thanks @dutifulbob. +- Memory/doctor SecretRef handling: treat SecretRef-backed memory-search API keys as configured, and fail embedding setup with explicit unresolved-secret errors instead of crashing. (#36835) Thanks @joshavant. ## 2026.3.2 diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index 869e9f8351e..d762ec3e15d 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -26,7 +26,8 @@ async function loadRunEmbeddedPiAgent(): Promise { // Bundled install (built) // NOTE: there is no src/ tree in a packaged install. Prefer a stable internal entrypoint. - const mod = await import("../../../dist/extensionAPI.js"); + const distModulePath = "../../../dist/extensionAPI.js"; + const mod = await import(distModulePath); // oxlint-disable-next-line typescript/no-explicit-any const fn = (mod as any).runEmbeddedPiAgent; if (typeof fn !== "function") { diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 5fe1120cf58..6fab1dd3946 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -221,6 +221,48 @@ describe("memory search config", () => { }); }); + it("preserves SecretRef remote apiKey when merging defaults with agent overrides", () => { + const cfg = asConfig({ + agents: { + defaults: { + memorySearch: { + provider: "openai", + remote: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + headers: { "X-Default": "on" }, + }, + }, + }, + list: [ + { + id: "main", + default: true, + memorySearch: { + remote: { + baseUrl: "https://agent.example/v1", + }, + }, + }, + ], + }, + }); + + const resolved = resolveMemorySearchConfig(cfg, "main"); + + expect(resolved?.remote).toEqual({ + baseUrl: "https://agent.example/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + headers: { "X-Default": "on" }, + batch: { + enabled: false, + wait: true, + concurrency: 2, + pollIntervalMs: 2000, + timeoutMinutes: 60, + }, + }); + }); + it("gates session sources behind experimental flag", () => { const cfg = asConfig({ agents: { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index 7b4e40b1df6..e14fd5a0b3b 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -2,6 +2,7 @@ import os from "node:os"; import path from "node:path"; import type { OpenClawConfig, MemorySearchConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; +import type { SecretInput } from "../config/types.secrets.js"; import { clampInt, clampNumber, resolveUserPath } from "../utils.js"; import { resolveAgentConfig } from "./agent-scope.js"; @@ -12,7 +13,7 @@ export type ResolvedMemorySearchConfig = { provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto"; remote?: { baseUrl?: string; - apiKey?: string; + apiKey?: SecretInput; headers?: Record; batch?: { enabled: boolean; diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 26877ca92b2..232042271bb 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -135,10 +135,40 @@ describe("noteMemorySearchHealth", () => { await expectNoWarningWithConfiguredRemoteApiKey("openai"); }); + it("treats SecretRef remote apiKey as configured for explicit provider", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "openai", + local: {}, + remote: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }); + + await noteMemorySearchHealth(cfg, {}); + + expect(note).not.toHaveBeenCalled(); + expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); + }); + it("does not warn in auto mode when remote apiKey is configured", async () => { await expectNoWarningWithConfiguredRemoteApiKey("auto"); }); + it("treats SecretRef remote apiKey as configured in auto mode", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }); + + await noteMemorySearchHealth(cfg, {}); + + expect(note).not.toHaveBeenCalled(); + expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); + }); + it("resolves provider auth from the default agent directory", async () => { resolveMemorySearchConfig.mockReturnValue({ provider: "gemini", diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index eda33823ec8..4dd2914613f 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -6,6 +6,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMemoryBackendConfig } from "../memory/backend-config.js"; import { DEFAULT_LOCAL_MODEL } from "../memory/embeddings.js"; +import { hasConfiguredMemorySecretInput } from "../memory/secret-input.js"; import { note } from "../terminal/note.js"; import { resolveUserPath } from "../utils.js"; @@ -26,7 +27,7 @@ export async function noteMemorySearchHealth( const agentId = resolveDefaultAgentId(cfg); const agentDir = resolveAgentDir(cfg, agentId); const resolved = resolveMemorySearchConfig(cfg, agentId); - const hasRemoteApiKey = Boolean(resolved?.remote?.apiKey?.trim()); + const hasRemoteApiKey = hasConfiguredMemorySecretInput(resolved?.remote?.apiKey); if (!resolved) { note("Memory search is explicitly disabled (enabled: false).", "Memory search"); diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 956f116055a..c18f9a375fe 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -1,6 +1,7 @@ import type { ChatType } from "../channels/chat-type.js"; import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js"; +import type { SecretInput } from "./types.secrets.js"; export type MediaUnderstandingScopeMatch = { channel?: string; @@ -327,7 +328,7 @@ export type MemorySearchConfig = { provider?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama"; remote?: { baseUrl?: string; - apiKey?: string; + apiKey?: SecretInput; headers?: Record; batch?: { /** Enable batch API for embedding indexing (OpenAI/Gemini; default: true). */ diff --git a/src/memory/embeddings-gemini.ts b/src/memory/embeddings-gemini.ts index 01e7dbb23c1..1d5cc5876ea 100644 --- a/src/memory/embeddings-gemini.ts +++ b/src/memory/embeddings-gemini.ts @@ -8,6 +8,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { debugEmbeddingsLog } from "./embeddings-debug.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; import { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./remote-http.js"; +import { resolveMemorySecretInputString } from "./secret-input.js"; export type GeminiEmbeddingClient = { baseUrl: string; @@ -23,8 +24,11 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = "gemini-embedding-001"; const GEMINI_MAX_INPUT_TOKENS: Record = { "text-embedding-004": 2048, }; -function resolveRemoteApiKey(remoteApiKey?: string): string | undefined { - const trimmed = remoteApiKey?.trim(); +function resolveRemoteApiKey(remoteApiKey: unknown): string | undefined { + const trimmed = resolveMemorySecretInputString({ + value: remoteApiKey, + path: "agents.*.memorySearch.remote.apiKey", + }); if (!trimmed) { return undefined; } diff --git a/src/memory/embeddings-ollama.test.ts b/src/memory/embeddings-ollama.test.ts index 37b94490719..e29939dbacb 100644 --- a/src/memory/embeddings-ollama.test.ts +++ b/src/memory/embeddings-ollama.test.ts @@ -44,7 +44,7 @@ describe("embeddings-ollama", () => { providers: { ollama: { baseUrl: "http://127.0.0.1:11434/v1", - apiKey: "ollama-local", + apiKey: "ollama-\nlocal\r\n", headers: { "X-Provider-Header": "provider", }, @@ -71,4 +71,59 @@ describe("embeddings-ollama", () => { }), ); }); + + it("fails fast when memory-search remote apiKey is an unresolved SecretRef", async () => { + await expect( + createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { + baseUrl: "http://127.0.0.1:11434", + apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" }, + }, + }), + ).rejects.toThrow(/agents\.\*\.memorySearch\.remote\.apiKey: unresolved SecretRef/i); + }); + + it("falls back to env key when models.providers.ollama.apiKey is an unresolved SecretRef", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ embedding: [1, 0] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + vi.stubEnv("OLLAMA_API_KEY", "ollama-env"); + + const { provider } = await createOllamaEmbeddingProvider({ + config: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" }, + models: [], + }, + }, + }, + } as unknown as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434/api/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer ollama-env", + }), + }), + ); + }); }); diff --git a/src/memory/embeddings-ollama.ts b/src/memory/embeddings-ollama.ts index 50e511aec78..03e8a4de60b 100644 --- a/src/memory/embeddings-ollama.ts +++ b/src/memory/embeddings-ollama.ts @@ -4,6 +4,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; import { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./remote-http.js"; +import { resolveMemorySecretInputString } from "./secret-input.js"; export type OllamaEmbeddingClient = { baseUrl: string; @@ -46,7 +47,10 @@ function resolveOllamaApiBase(configuredBaseUrl?: string): string { } function resolveOllamaApiKey(options: EmbeddingProviderOptions): string | undefined { - const remoteApiKey = options.remote?.apiKey?.trim(); + const remoteApiKey = resolveMemorySecretInputString({ + value: options.remote?.apiKey, + path: "agents.*.memorySearch.remote.apiKey", + }); if (remoteApiKey) { return remoteApiKey; } diff --git a/src/memory/embeddings-remote-client.ts b/src/memory/embeddings-remote-client.ts index 790969bdf1e..a471d5a75b0 100644 --- a/src/memory/embeddings-remote-client.ts +++ b/src/memory/embeddings-remote-client.ts @@ -1,8 +1,8 @@ import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js"; -import { normalizeResolvedSecretInputString } from "../config/types.secrets.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { EmbeddingProviderOptions } from "./embeddings.js"; import { buildRemoteBaseUrlPolicy } from "./remote-http.js"; +import { resolveMemorySecretInputString } from "./secret-input.js"; export type RemoteEmbeddingProviderId = "openai" | "voyage" | "mistral"; @@ -12,7 +12,7 @@ export async function resolveRemoteEmbeddingBearerClient(params: { defaultBaseUrl: string; }): Promise<{ baseUrl: string; headers: Record; ssrfPolicy?: SsrFPolicy }> { const remote = params.options.remote; - const remoteApiKey = normalizeResolvedSecretInputString({ + const remoteApiKey = resolveMemorySecretInputString({ value: remote?.apiKey, path: "agents.*.memorySearch.remote.apiKey", }); diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index 91cfb567a37..c8cca71029e 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -210,6 +210,43 @@ describe("embedding provider remote overrides", () => { expect(headers["Content-Type"]).toBe("application/json"); }); + it("fails fast when Gemini remote apiKey is an unresolved SecretRef", async () => { + await expect( + createEmbeddingProvider({ + config: {} as never, + provider: "gemini", + remote: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, + }, + model: "text-embedding-004", + fallback: "openai", + }), + ).rejects.toThrow(/agents\.\*\.memorySearch\.remote\.apiKey:/i); + }); + + it("uses GEMINI_API_KEY env indirection for Gemini remote apiKey", async () => { + const fetchMock = createGeminiFetchMock(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("GEMINI_API_KEY", "env-gemini-key"); + + const result = await createEmbeddingProvider({ + config: {} as never, + provider: "gemini", + remote: { + apiKey: "GEMINI_API_KEY", + }, + model: "text-embedding-004", + fallback: "openai", + }); + + const provider = requireProvider(result); + await provider.embedQuery("hello"); + + const { init } = readFirstFetchRequest(fetchMock); + const headers = (init?.headers ?? {}) as Record; + expect(headers["x-goog-api-key"]).toBe("env-gemini-key"); + }); + it("builds Mistral embeddings requests with bearer auth", async () => { const fetchMock = createFetchMock(); vi.stubGlobal("fetch", fetchMock); diff --git a/src/memory/embeddings.ts b/src/memory/embeddings.ts index faf1c795b95..ca6b4046e2c 100644 --- a/src/memory/embeddings.ts +++ b/src/memory/embeddings.ts @@ -1,6 +1,7 @@ import fsSync from "node:fs"; import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp"; import type { OpenClawConfig } from "../config/config.js"; +import type { SecretInput } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; import { resolveUserPath } from "../utils.js"; import { createGeminiEmbeddingProvider, type GeminiEmbeddingClient } from "./embeddings-gemini.js"; @@ -64,7 +65,7 @@ export type EmbeddingProviderOptions = { provider: EmbeddingProviderRequest; remote?: { baseUrl?: string; - apiKey?: string; + apiKey?: SecretInput; headers?: Record; }; model: string; diff --git a/src/memory/secret-input.ts b/src/memory/secret-input.ts new file mode 100644 index 00000000000..873870fc58a --- /dev/null +++ b/src/memory/secret-input.ts @@ -0,0 +1,18 @@ +import { + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, +} from "../config/types.secrets.js"; + +export function hasConfiguredMemorySecretInput(value: unknown): boolean { + return hasConfiguredSecretInput(value); +} + +export function resolveMemorySecretInputString(params: { + value: unknown; + path: string; +}): string | undefined { + return normalizeResolvedSecretInputString({ + value: params.value, + path: params.path, + }); +} diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index ce2fee52a23..5e4a427c4a9 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -171,6 +171,8 @@ describe("tui session actions", () => { }); applySessionInfoFromPatch({ + ok: true, + path: "/tmp/sessions.json", key: "agent:main:main", entry: { sessionId: "session-1", diff --git a/src/types/extension-api.d.ts b/src/types/extension-api.d.ts new file mode 100644 index 00000000000..ca711425cab --- /dev/null +++ b/src/types/extension-api.d.ts @@ -0,0 +1,3 @@ +declare module "../../../dist/extensionAPI.js" { + export const runEmbeddedPiAgent: (params: Record) => Promise; +}