mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
18
src/memory/secret-input.ts
Normal file
18
src/memory/secret-input.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
3
src/types/extension-api.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module "../../../dist/extensionAPI.js" {
|
||||
export const runEmbeddedPiAgent: (params: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user