Memory: handle SecretRef keys in doctor embeddings (#36835)

Merged via squash.

Prepared head SHA: c1a3d0caae
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Reviewed-by: @joshavant
This commit is contained in:
Josh Avant
2026-03-05 20:05:59 -06:00
committed by GitHub
parent cec5535096
commit fb289b7a79
16 changed files with 212 additions and 11 deletions

View File

@@ -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

View File

@@ -26,7 +26,8 @@ async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
// 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") {

View File

@@ -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: {

View File

@@ -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<string, string>;
batch?: {
enabled: boolean;

View File

@@ -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",

View File

@@ -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");

View File

@@ -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<string, string>;
batch?: {
/** Enable batch API for embedding indexing (OpenAI/Gemini; default: true). */

View File

@@ -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<string, number> = {
"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;
}

View File

@@ -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",
}),
}),
);
});
});

View File

@@ -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;
}

View File

@@ -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<string, string>; ssrfPolicy?: SsrFPolicy }> {
const remote = params.options.remote;
const remoteApiKey = normalizeResolvedSecretInputString({
const remoteApiKey = resolveMemorySecretInputString({
value: remote?.apiKey,
path: "agents.*.memorySearch.remote.apiKey",
});

View File

@@ -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<string, string>;
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);

View File

@@ -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<string, string>;
};
model: string;

View File

@@ -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,
});
}

View File

@@ -171,6 +171,8 @@ describe("tui session actions", () => {
});
applySessionInfoFromPatch({
ok: true,
path: "/tmp/sessions.json",
key: "agent:main:main",
entry: {
sessionId: "session-1",

3
src/types/extension-api.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module "../../../dist/extensionAPI.js" {
export const runEmbeddedPiAgent: (params: Record<string, unknown>) => Promise<unknown>;
}