doctor: add memory search lint findings (#97137)

* doctor: add memory search lint findings

* fix(doctor): quiet memory lint for auth-profile sources

* fix(doctor): require provider-specific auth source for memory lint

* fix(doctor): preserve qmd memory lint warnings

* fix(doctor): validate auth source credentials
This commit is contained in:
Gio Della-Libera
2026-06-30 18:54:57 -07:00
committed by GitHub
parent 92a2681a0d
commit 816038e97a
9 changed files with 714 additions and 45 deletions
+1
View File
@@ -59,6 +59,7 @@ export {
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
getRuntimeAuthProfileStoreSnapshot,
hasAuthProfileStoreSourceForProvider,
hasAnyAuthProfileStoreSource,
hasLocalAuthProfileStoreSource,
loadAuthProfileStoreForSecretsRuntime,
+2 -2
View File
@@ -212,7 +212,7 @@ function parseCredentialEntry(
if (!AUTH_PROFILE_TYPES.has(typed.type as AuthProfileCredential["type"])) {
return { ok: false, reason: "invalid_type" };
}
const provider = typed.provider ?? fallbackProvider;
const provider = typed.provider || fallbackProvider;
const normalizedProvider = typeof provider === "string" ? normalizeProviderId(provider) : "";
if (!normalizedProvider) {
return { ok: false, reason: "missing_provider" };
@@ -246,7 +246,7 @@ function warnRejectedCredentialEntries(source: string, rejected: RejectedCredent
});
}
function coerceLegacyAuthStore(raw: unknown): LegacyAuthStore | null {
export function coerceLegacyAuthStore(raw: unknown): LegacyAuthStore | null {
if (!isRecord(raw)) {
return null;
}
@@ -0,0 +1,125 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { hasAuthProfileStoreSourceForProvider } from "./source-check.js";
describe("hasAuthProfileStoreSourceForProvider", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
async function withAgentStore(profiles: Record<string, unknown>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-source-"));
const stateDir = path.join(root, "state");
const agentDir = path.join(root, "agent");
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(stateDir, { recursive: true });
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
JSON.stringify({ version: 1, profiles }),
);
return { agentDir };
}
async function withLegacyAuthStore(profiles: Record<string, unknown>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-source-"));
const stateDir = path.join(root, "state");
const agentDir = path.join(root, "agent");
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(stateDir, { recursive: true });
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
await fs.writeFile(path.join(agentDir, "auth.json"), JSON.stringify(profiles));
return { agentDir };
}
it("counts provider-specific usable credentials", async () => {
const { agentDir } = await withAgentStore({
"openai:default": { type: "api_key", provider: "openai", key: "sk-test" },
});
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(true);
});
it("counts legacy auth stores with alias fields and fallback providers", async () => {
const { agentDir } = await withLegacyAuthStore({
openai: { mode: "apiKey", apiKey: "sk-test" },
});
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(true);
});
it("ignores malformed provider fields instead of throwing", async () => {
const { agentDir } = await withAgentStore({
"openai:default": { type: "api_key", key: "sk-test" },
"openai:other": { type: "api_key", provider: 123, key: "sk-test" },
});
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(false);
});
it("does not count profile ids that are bound to a different credential provider", async () => {
const { agentDir } = await withAgentStore({
"openai:default": { type: "api_key", provider: "anthropic", key: "sk-test" },
});
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(false);
});
it("honors configured profile order constraints", async () => {
const { agentDir } = await withAgentStore({
"openai:default": { type: "api_key", provider: "openai", key: "sk-test" },
"openai:expired": {
type: "token",
provider: "openai",
token: "expired-token",
expires: Date.now() - 1000,
},
});
expect(
hasAuthProfileStoreSourceForProvider("openai", agentDir, {
profileIds: ["openai:expired"],
}),
).toBe(false);
expect(
hasAuthProfileStoreSourceForProvider("openai", agentDir, {
profileIds: ["openai:default"],
}),
).toBe(true);
});
it("treats explicit empty profile order as no usable profile", async () => {
const { agentDir } = await withAgentStore({
"openai:default": { type: "api_key", provider: "openai", key: "sk-test" },
});
expect(
hasAuthProfileStoreSourceForProvider("openai", agentDir, {
profileIds: [],
}),
).toBe(false);
});
it("does not count empty provider profiles as credential evidence", async () => {
const { agentDir } = await withAgentStore({
"openai:default": { type: "api_key", provider: "openai" },
});
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(false);
});
it("does not count expired token profiles as credential evidence", async () => {
const { agentDir } = await withAgentStore({
"openai:token": {
type: "token",
provider: "openai",
token: "expired-token",
expires: Date.now() - 1000,
},
});
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(false);
});
});
+129
View File
@@ -3,16 +3,19 @@
* These checks intentionally avoid loading secret-bearing credential payloads.
*/
import fs from "node:fs";
import { evaluateStoredCredentialEligibility } from "./credential-state.js";
import {
resolveAuthStatePath,
resolveAuthStorePath,
resolveLegacyAuthStorePath,
} from "./path-resolve.js";
import { coerceLegacyAuthStore, coercePersistedAuthProfileStore } from "./persisted.js";
import {
getRuntimeAuthProfileStoreSnapshot,
hasAnyRuntimeAuthProfileStoreSource,
} from "./runtime-snapshots.js";
import { readPersistedAuthProfileStateRaw, readPersistedAuthProfileStoreRaw } from "./sqlite.js";
import type { AuthProfileCredential, AuthProfileStore } from "./types.js";
// Auth-profile source checks look at runtime snapshots, JSON compatibility
// files, legacy files, and SQLite stores without materializing secret values.
@@ -24,6 +27,72 @@ function hasStoredAuthProfileFiles(agentDir?: string): boolean {
);
}
function readJsonFile(pathname: string): unknown {
try {
return JSON.parse(fs.readFileSync(pathname, "utf8")) as unknown;
} catch {
return null;
}
}
function normalizeProvider(provider: string): string {
return provider.trim().toLowerCase();
}
function isAuthProfileCredential(value: unknown): value is AuthProfileCredential {
if (!value || typeof value !== "object") {
return false;
}
const credential = value as { provider?: unknown; type?: unknown };
const type = credential.type;
return (
typeof credential.provider === "string" &&
(type === "api_key" || type === "token" || type === "oauth")
);
}
function isEligibleProviderCredential(rawCredential: unknown, expectedProvider: string): boolean {
if (!isAuthProfileCredential(rawCredential)) {
return false;
}
return (
normalizeProvider(rawCredential.provider) === expectedProvider &&
evaluateStoredCredentialEligibility({ credential: rawCredential }).eligible
);
}
function coerceRawStoreProfiles(raw: unknown): Record<string, AuthProfileCredential> | null {
return coercePersistedAuthProfileStore(raw)?.profiles ?? coerceLegacyAuthStore(raw);
}
function rawStoreHasProviderProfile(
raw: unknown,
provider: string,
profileIds?: readonly string[],
): boolean {
const profiles = coerceRawStoreProfiles(raw);
if (!profiles) {
return false;
}
const expected = normalizeProvider(provider);
const credentials =
profileIds?.map((profileId) => profiles[profileId]) ?? Object.values(profiles);
for (const rawCredential of credentials) {
if (isEligibleProviderCredential(rawCredential, expected)) {
return true;
}
}
return false;
}
function runtimeStoreHasProviderProfile(
store: AuthProfileStore | undefined,
provider: string,
profileIds?: readonly string[],
): boolean {
return rawStoreHasProviderProfile(store, provider, profileIds);
}
/** Returns true when any local/runtime/main auth profile source exists. */
export function hasAnyAuthProfileStoreSource(agentDir?: string): boolean {
if (hasLocalAuthProfileStoreSource(agentDir)) {
@@ -60,3 +129,63 @@ export function hasLocalAuthProfileStoreSource(agentDir?: string): boolean {
readPersistedAuthProfileStoreRaw(agentDir) || readPersistedAuthProfileStateRaw(agentDir),
);
}
type AuthProfileSourceForProviderOptions = {
/** Optional hard order/profile constraint from config auth.order. */
profileIds?: readonly string[];
};
/** Returns true when a read-only auth-profile source contains a profile for a provider. */
export function hasAuthProfileStoreSourceForProvider(
provider: string,
agentDir?: string,
options?: AuthProfileSourceForProviderOptions,
): boolean {
if (!normalizeProvider(provider)) {
return false;
}
const profileIds = options?.profileIds;
if (profileIds?.length === 0) {
return false;
}
const localRuntimeStore = getRuntimeAuthProfileStoreSnapshot(agentDir);
if (runtimeStoreHasProviderProfile(localRuntimeStore, provider, profileIds)) {
return true;
}
if (
rawStoreHasProviderProfile(readJsonFile(resolveAuthStorePath(agentDir)), provider, profileIds)
) {
return true;
}
if (
rawStoreHasProviderProfile(
readJsonFile(resolveLegacyAuthStorePath(agentDir)),
provider,
profileIds,
)
) {
return true;
}
if (
rawStoreHasProviderProfile(readPersistedAuthProfileStoreRaw(agentDir), provider, profileIds)
) {
return true;
}
if (!agentDir) {
return false;
}
const mainRuntimeStore = getRuntimeAuthProfileStoreSnapshot();
if (runtimeStoreHasProviderProfile(mainRuntimeStore, provider, profileIds)) {
return true;
}
if (rawStoreHasProviderProfile(readJsonFile(resolveAuthStorePath()), provider, profileIds)) {
return true;
}
if (
rawStoreHasProviderProfile(readJsonFile(resolveLegacyAuthStorePath()), provider, profileIds)
) {
return true;
}
return rawStoreHasProviderProfile(readPersistedAuthProfileStoreRaw(), provider, profileIds);
}
+5 -1
View File
@@ -1010,7 +1010,11 @@ export function ensureAuthProfileStoreForLocalUpdate(agentDir?: string): AuthPro
});
}
export { hasAnyAuthProfileStoreSource, hasLocalAuthProfileStoreSource } from "./source-check.js";
export {
hasAnyAuthProfileStoreSource,
hasAuthProfileStoreSourceForProvider,
hasLocalAuthProfileStoreSource,
} from "./source-check.js";
/** Return the current runtime auth-profile snapshot for an agent dir. */
export function getRuntimeAuthProfileStoreSnapshot(
+232
View File
@@ -10,6 +10,8 @@ const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-defaul
const resolveMemorySearchConfig = vi.hoisted(() => vi.fn());
const resolveApiKeyForProvider = vi.hoisted(() => vi.fn());
const hasAnyAuthProfileStoreSource = vi.hoisted(() => vi.fn(() => true));
const hasAuthProfileStoreSourceForProvider = vi.hoisted(() => vi.fn(() => true));
const isConfiguredAwsSdkAuthProfileForProvider = vi.hoisted(() => vi.fn(() => false));
const getActiveMemorySearchManager = vi.hoisted(() => vi.fn());
const resolveActiveMemoryBackendConfig = vi.hoisted(() => vi.fn());
type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn;
@@ -45,6 +47,8 @@ vi.mock("../agents/model-auth.js", () => ({
vi.mock("../agents/auth-profiles.js", () => ({
hasAnyAuthProfileStoreSource,
hasAuthProfileStoreSourceForProvider,
isConfiguredAwsSdkAuthProfileForProvider,
}));
vi.mock("../plugins/memory-runtime.js", () => ({
@@ -172,6 +176,10 @@ describe("noteMemorySearchHealth", () => {
resolveApiKeyForProvider.mockRejectedValue(new Error("missing key"));
hasAnyAuthProfileStoreSource.mockReset();
hasAnyAuthProfileStoreSource.mockReturnValue(true);
hasAuthProfileStoreSourceForProvider.mockReset();
hasAuthProfileStoreSourceForProvider.mockReturnValue(true);
isConfiguredAwsSdkAuthProfileForProvider.mockReset();
isConfiguredAwsSdkAuthProfileForProvider.mockReturnValue(false);
getActiveMemorySearchManager.mockReset();
resolveActiveMemoryBackendConfig.mockReset();
resolveActiveMemoryBackendConfig.mockImplementation(
@@ -206,6 +214,27 @@ describe("noteMemorySearchHealth", () => {
expect(message).toContain("openclaw plugins install @openclaw/llama-cpp-provider");
});
it("supports silent structured collection through an injected note sink", async () => {
const noteFn = vi.fn();
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
includeWorkspaceMemoryHealth: false,
noteFn,
});
expect(noteWorkspaceMemoryHealth).not.toHaveBeenCalled();
expect(note).not.toHaveBeenCalled();
expect(noteFn).toHaveBeenCalledWith(
expect.stringContaining('Memory search provider is set to "local"'),
"Memory search",
);
});
it("warns when local provider with default model but gateway probe reports not ready", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
@@ -477,6 +506,27 @@ describe("noteMemorySearchHealth", () => {
});
});
it("skips QMD binary probing while preserving QMD session export warnings", async () => {
const qmdCfg = { memory: { backend: "qmd", qmd: { command: "custom-qmd" } } } as OpenClawConfig;
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
sources: ["memory", "sessions"],
experimental: { sessionMemory: true },
local: {},
remote: {},
});
await noteMemorySearchHealth(qmdCfg, {
includeWorkspaceMemoryHealth: false,
skipQmdBinaryProbe: true,
});
expect(noteWorkspaceMemoryHealth).not.toHaveBeenCalled();
expect(checkQmdBinaryAvailability).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledTimes(1);
expect(firstNoteMessage()).toContain("QMD session transcript export is not enabled");
});
it("warns when QMD backend is active but the qmd binary is unavailable", async () => {
const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } } } as OpenClawConfig;
checkQmdBinaryAvailability.mockResolvedValueOnce({
@@ -910,6 +960,188 @@ describe("noteMemorySearchHealth", () => {
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("does not warn for auth-profile-backed credentials when lint skips profile resolution", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "openai",
model: "text-embedding-3-small",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
skipAuthProfileResolution: true,
gatewayMemoryProbe: { checked: false, ready: false, skipped: true },
});
expect(note).not.toHaveBeenCalled();
expect(hasAuthProfileStoreSourceForProvider).toHaveBeenCalledWith(
"openai",
"/tmp/agent-default",
);
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("honors configured auth order when lint skips profile resolution", async () => {
hasAuthProfileStoreSourceForProvider.mockReturnValue(false);
resolveMemorySearchConfig.mockReturnValue({
provider: "openai",
model: "text-embedding-3-small",
local: {},
remote: {},
});
const orderedCfg = {
...cfg,
auth: { order: { openai: ["openai:expired"] } },
} as OpenClawConfig;
await noteMemorySearchHealth(orderedCfg, {
skipAuthProfileResolution: true,
gatewayMemoryProbe: { checked: false, ready: false, skipped: true },
});
expect(hasAuthProfileStoreSourceForProvider).toHaveBeenCalledWith(
"openai",
"/tmp/agent-default",
{ profileIds: ["openai:expired"] },
);
expect(firstNoteMessage()).toContain('provider is set to "openai"');
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("warns for explicit empty auth order when lint skips profile resolution", async () => {
hasAuthProfileStoreSourceForProvider.mockReturnValue(false);
resolveMemorySearchConfig.mockReturnValue({
provider: "openai",
model: "text-embedding-3-small",
local: {},
remote: {},
});
const orderedCfg = {
...cfg,
auth: { order: { openai: [] } },
} as OpenClawConfig;
await noteMemorySearchHealth(orderedCfg, {
skipAuthProfileResolution: true,
gatewayMemoryProbe: { checked: false, ready: false, skipped: true },
});
expect(hasAuthProfileStoreSourceForProvider).toHaveBeenCalledWith(
"openai",
"/tmp/agent-default",
{ profileIds: [] },
);
expect(firstNoteMessage()).toContain('provider is set to "openai"');
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("does not warn for Bedrock aws-sdk provider auth when lint skips profile resolution", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0",
local: {},
remote: {},
});
const bedrockCfg = {
...cfg,
models: {
providers: {
"amazon-bedrock": { auth: "aws-sdk", models: [] },
},
},
} as unknown as OpenClawConfig;
await noteMemorySearchHealth(bedrockCfg, {
skipAuthProfileResolution: true,
gatewayMemoryProbe: { checked: false, ready: false, skipped: true },
});
expect(note).not.toHaveBeenCalled();
expect(hasAuthProfileStoreSourceForProvider).not.toHaveBeenCalled();
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("does not warn for ordered Bedrock aws-sdk auth profiles when lint skips profile resolution", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0",
local: {},
remote: {},
});
const bedrockCfg = {
...cfg,
models: {
providers: {
"amazon-bedrock": { auth: "aws-sdk", models: [] },
},
},
auth: {
profiles: {
"amazon-bedrock:default": {
provider: "amazon-bedrock",
mode: "aws-sdk",
},
},
order: { "amazon-bedrock": ["amazon-bedrock:default"] },
},
} as unknown as OpenClawConfig;
await noteMemorySearchHealth(bedrockCfg, {
skipAuthProfileResolution: true,
gatewayMemoryProbe: { checked: false, ready: false, skipped: true },
});
expect(note).not.toHaveBeenCalled();
expect(hasAuthProfileStoreSourceForProvider).not.toHaveBeenCalled();
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("warns for empty auth profile sources when lint skips profile resolution", async () => {
hasAnyAuthProfileStoreSource.mockReturnValue(true);
hasAuthProfileStoreSourceForProvider.mockReturnValue(false);
resolveMemorySearchConfig.mockReturnValue({
provider: "openai",
model: "text-embedding-3-small",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
skipAuthProfileResolution: true,
gatewayMemoryProbe: { checked: false, ready: false, skipped: true },
});
const message = firstNoteMessage();
expect(message).toContain('provider is set to "openai"');
expect(message).toContain("OPENAI_API_KEY");
expect(hasAuthProfileStoreSourceForProvider).toHaveBeenCalledWith(
"openai",
"/tmp/agent-default",
);
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("warns without resolving auth profiles when lint skips profile resolution and no auth store exists", async () => {
hasAnyAuthProfileStoreSource.mockReturnValue(false);
hasAuthProfileStoreSourceForProvider.mockReturnValue(false);
resolveMemorySearchConfig.mockReturnValue({
provider: "openai",
model: "text-embedding-3-small",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
skipAuthProfileResolution: true,
gatewayMemoryProbe: { checked: false, ready: false, skipped: true },
});
const message = firstNoteMessage();
expect(message).toContain('provider is set to "openai"');
expect(message).toContain("OPENAI_API_KEY");
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("does not treat built-in OpenAI as key-optional just because models.providers.openai has baseUrl", async () => {
const openaiCfg = {
models: {
+86 -42
View File
@@ -10,7 +10,11 @@ import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { hasAnyAuthProfileStoreSource } from "../agents/auth-profiles.js";
import {
hasAnyAuthProfileStoreSource,
hasAuthProfileStoreSourceForProvider,
isConfiguredAwsSdkAuthProfileForProvider,
} from "../agents/auth-profiles.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import {
resolveApiKeyForProvider,
@@ -139,6 +143,19 @@ function resolveSuggestedRemoteMemoryProvider(): string | undefined {
)?.providerId;
}
function hasConfiguredAwsSdkAuthForProvider(provider: string, cfg: OpenClawConfig): boolean {
const providerConfig = findNormalizedProviderValue(cfg.models?.providers, provider);
if (providerConfig?.auth === "aws-sdk") {
return true;
}
const orderedProfileIds = findNormalizedProviderValue(cfg.auth?.order, provider);
const profileIds =
orderedProfileIds ?? (cfg.auth?.profiles ? Object.keys(cfg.auth.profiles) : []);
return profileIds.some((profileId) =>
isConfiguredAwsSdkAuthProfileForProvider({ cfg, provider, profileId }),
);
}
function isOpenAICompatibleMemoryProvider(providerId: string, cfg: OpenClawConfig): boolean {
const normalizedProviderId = normalizeProviderId(providerId);
if (normalizedProviderId === OPENAI_COMPATIBLE_MEMORY_EMBEDDING_PROVIDER) {
@@ -403,9 +420,16 @@ export async function noteMemorySearchHealth(
error?: string;
skipped?: boolean;
};
noteFn?: typeof note;
includeWorkspaceMemoryHealth?: boolean;
skipQmdBinaryProbe?: boolean;
skipAuthProfileResolution?: boolean;
},
): Promise<void> {
await noteWorkspaceMemoryHealth(cfg);
const noteFn = opts?.noteFn ?? note;
if (opts?.includeWorkspaceMemoryHealth !== false) {
await noteWorkspaceMemoryHealth(cfg);
}
const agentId = resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, agentId);
@@ -413,7 +437,7 @@ export async function noteMemorySearchHealth(
const hasRemoteApiKey = hasConfiguredMemorySecretInput(resolved?.remote?.apiKey);
if (!resolved) {
note("Memory search is explicitly disabled (enabled: false).", "Memory search");
noteFn("Memory search is explicitly disabled (enabled: false).", "Memory search");
return;
}
const provider =
@@ -429,43 +453,46 @@ export async function noteMemorySearchHealth(
if (hasActiveAlternateMemoryPluginSlot(cfg)) {
return;
}
note("No active memory plugin is registered for the current config.", "Memory search");
noteFn("No active memory plugin is registered for the current config.", "Memory search");
return;
}
if (backendConfig.backend === "qmd") {
const qmdCheck = await checkQmdBinaryAvailability({
command: backendConfig.qmd?.command ?? "qmd",
env: process.env,
cwd: resolveAgentWorkspaceDir(cfg, agentId),
});
if (!qmdCheck.available) {
const workspaceProbeFailed = resolveQmdBinaryUnavailableReason(qmdCheck) === "workspace-cwd";
const probeError = qmdCheck.error.trim();
note(
[
workspaceProbeFailed
? "QMD memory backend is configured, but the agent workspace directory could not be used for the QMD startup probe."
: `QMD memory backend is configured, but the qmd binary could not be started (${backendConfig.qmd?.command ?? "qmd"}).`,
probeError ? `Probe error: ${probeError}` : null,
"",
"Fix (pick one):",
workspaceProbeFailed
? "- Create the missing workspace directory or update the agent workspace path to an existing directory."
: "- Install the supported QMD package: npm install -g @tobilu/qmd (or bun install -g @tobilu/qmd)",
workspaceProbeFailed
? "- Verify the resolved workspace path for the affected agent before retrying."
: `- Set an explicit binary path: ${formatCliCommand("openclaw config set memory.qmd.command /absolute/path/to/qmd")}`,
`- Or switch back to builtin memory: ${formatCliCommand("openclaw config set memory.backend builtin")}`,
"",
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
]
.filter(Boolean)
.join("\n"),
"Memory search",
);
if (opts?.skipQmdBinaryProbe !== true) {
const qmdCheck = await checkQmdBinaryAvailability({
command: backendConfig.qmd?.command ?? "qmd",
env: process.env,
cwd: resolveAgentWorkspaceDir(cfg, agentId),
});
if (!qmdCheck.available) {
const workspaceProbeFailed =
resolveQmdBinaryUnavailableReason(qmdCheck) === "workspace-cwd";
const probeError = qmdCheck.error.trim();
noteFn(
[
workspaceProbeFailed
? "QMD memory backend is configured, but the agent workspace directory could not be used for the QMD startup probe."
: `QMD memory backend is configured, but the qmd binary could not be started (${backendConfig.qmd?.command ?? "qmd"}).`,
probeError ? `Probe error: ${probeError}` : null,
"",
"Fix (pick one):",
workspaceProbeFailed
? "- Create the missing workspace directory or update the agent workspace path to an existing directory."
: "- Install the supported QMD package: npm install -g @tobilu/qmd (or bun install -g @tobilu/qmd)",
workspaceProbeFailed
? "- Verify the resolved workspace path for the affected agent before retrying."
: `- Set an explicit binary path: ${formatCliCommand("openclaw config set memory.qmd.command /absolute/path/to/qmd")}`,
`- Or switch back to builtin memory: ${formatCliCommand("openclaw config set memory.backend builtin")}`,
"",
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
]
.filter(Boolean)
.join("\n"),
"Memory search",
);
}
}
if (resolved.sources?.includes("sessions") && cfg.memory?.qmd?.sessions?.enabled !== true) {
note(
noteFn(
[
"QMD memory backend is configured and the default agent resolves memorySearch.sources with sessions,",
"but QMD session transcript export is not enabled (memory.qmd.sessions.enabled is not true).",
@@ -497,7 +524,7 @@ export async function noteMemorySearchHealth(
return;
}
const detail = opts?.gatewayMemoryProbe?.error?.trim();
note(
noteFn(
[
hasExplicitLocalModel
? 'Memory search provider is set to "local" and a local model path is configured, but local embeddings are not confirmed ready.'
@@ -524,7 +551,7 @@ export async function noteMemorySearchHealth(
isOpenAICompatibleMemoryProvider(provider, cfg) &&
!resolveOpenAICompatibleMemoryBaseUrl(provider, cfg, resolved.remote?.baseUrl)
) {
note(
noteFn(
[
`Memory search provider is set to "${provider}" but no OpenAI-compatible embeddings endpoint was configured.`,
"Set agents.defaults.memorySearch.remote.baseUrl to the /v1 endpoint for your embeddings server.",
@@ -540,7 +567,7 @@ export async function noteMemorySearchHealth(
}
if (isOpenAICompatibleMemoryProvider(provider, cfg) && !normalizeOptionalString(resolved.model)) {
note(
noteFn(
[
`Memory search provider is set to "${provider}" but no OpenAI-compatible embedding model was configured.`,
"Set agents.defaults.memorySearch.model to the embedding model id your server expects.",
@@ -570,7 +597,7 @@ export async function noteMemorySearchHealth(
return;
}
const gatewayProbeWarning = buildGatewayProbeWarning(opts?.gatewayMemoryProbe);
note(
noteFn(
[
gatewayProbeWarning
? `Memory search provider "${provider}" is configured, but the gateway reports embeddings are not ready.`
@@ -586,12 +613,17 @@ export async function noteMemorySearchHealth(
}
// Remote provider — check for API key. Legacy provider: "auto" resolves to OpenAI.
if (hasRemoteApiKey || (await hasApiKeyForProvider(provider, cfg, agentDir))) {
if (
hasRemoteApiKey ||
(await hasApiKeyForProvider(provider, cfg, agentDir, {
skipProfileResolution: opts?.skipAuthProfileResolution === true,
}))
) {
return;
}
if (opts?.gatewayMemoryProbe?.checked && opts.gatewayMemoryProbe.ready) {
note(
noteFn(
[
`Memory search provider is set to "${provider}" but the API key was not found in the CLI environment.`,
"The running gateway reports memory embeddings are ready for the default agent.",
@@ -604,7 +636,7 @@ export async function noteMemorySearchHealth(
const gatewayProbeWarning = buildGatewayProbeWarning(opts?.gatewayMemoryProbe);
const envVar = resolvePrimaryMemoryProviderEnvVar(provider);
note(
noteFn(
[
`Memory search provider is set to "${provider}" but no API key was found.`,
`Semantic recall will not work without a valid API key.`,
@@ -648,6 +680,7 @@ async function hasApiKeyForProvider(
provider: string,
cfg: OpenClawConfig,
agentDir: string,
opts?: { skipProfileResolution?: boolean },
): Promise<boolean> {
const metadata = resolveMemoryEmbeddingProviderDoctorMetadata(provider);
const authProviderId = metadata?.authProviderId ?? provider;
@@ -657,6 +690,17 @@ async function hasApiKeyForProvider(
) {
return true;
}
if (opts?.skipProfileResolution === true) {
if (authProviderId === "amazon-bedrock") {
return hasConfiguredAwsSdkAuthForProvider(authProviderId, cfg);
}
const orderedProfileIds = findNormalizedProviderValue(cfg.auth?.order, authProviderId);
return orderedProfileIds === undefined
? hasAuthProfileStoreSourceForProvider(authProviderId, agentDir)
: hasAuthProfileStoreSourceForProvider(authProviderId, agentDir, {
profileIds: orderedProfileIds,
});
}
if (authProviderId !== "amazon-bedrock" && !hasAnyAuthProfileStoreSource(agentDir)) {
return false;
}
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
collectAuthProfileHealthFindings: vi.fn(async () => []),
noteAuthProfileHealth: vi.fn().mockResolvedValue(undefined),
noteLegacyCodexProviderOverride: vi.fn(),
noteMemorySearchHealth: vi.fn().mockResolvedValue(undefined),
buildGatewayConnectionDetails: vi.fn(() => ({ message: "gateway details" })),
resolveSecretInputRef: vi.fn((params: { value?: unknown }) => ({
ref:
@@ -151,6 +152,12 @@ vi.mock("../commands/doctor-auth.js", () => ({
noteLegacyCodexProviderOverride: mocks.noteLegacyCodexProviderOverride,
}));
vi.mock("../commands/doctor-memory-search.js", () => ({
maybeRepairMemoryRecallHealth: vi.fn().mockResolvedValue(undefined),
noteMemoryRecallHealth: vi.fn().mockResolvedValue(undefined),
noteMemorySearchHealth: mocks.noteMemorySearchHealth,
}));
vi.mock("../gateway/call.js", () => ({
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
}));
@@ -334,6 +341,8 @@ describe("doctor health contributions", () => {
mocks.noteAuthProfileHealth.mockClear();
mocks.noteAuthProfileHealth.mockResolvedValue(undefined);
mocks.noteLegacyCodexProviderOverride.mockClear();
mocks.noteMemorySearchHealth.mockClear();
mocks.noteMemorySearchHealth.mockResolvedValue(undefined);
mocks.buildGatewayConnectionDetails.mockClear();
mocks.buildGatewayConnectionDetails.mockReturnValue({ message: "gateway details" });
mocks.resolveSecretInputRef.mockClear();
@@ -1119,6 +1128,66 @@ describe("doctor health contributions", () => {
});
});
it("collects memory-search notes as structured findings", async () => {
const contribution = requireDoctorContribution("doctor:memory-search");
const check = contribution.healthChecks[0] as HealthCheck;
mocks.noteMemorySearchHealth.mockImplementationOnce(async (_cfg, opts) => {
opts.noteFn(
[
'Memory search provider is set to "openai" but no API key was found.',
"Semantic recall will not work without a valid API key.",
"Fix (pick one):",
"- Set OPENAI_API_KEY in your environment",
].join("\n"),
"Memory search",
);
});
const findings = await check.detect({
mode: "lint",
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
cfg: {},
});
expect(contribution.healthCheckIds).toEqual(["core/doctor/memory-search"]);
expect((check as HealthCheck & { defaultEnabled?: boolean }).defaultEnabled).toBe(false);
expect(mocks.noteMemorySearchHealth).toHaveBeenCalledWith(
{},
expect.objectContaining({
includeWorkspaceMemoryHealth: false,
skipQmdBinaryProbe: true,
skipAuthProfileResolution: true,
gatewayMemoryProbe: { checked: false, ready: false, skipped: true },
noteFn: expect.any(Function),
}),
);
expect(findings).toEqual([
expect.objectContaining({
checkId: "core/doctor/memory-search",
severity: "warning",
path: "agents.defaults.memorySearch.provider",
message: 'Memory search provider is set to "openai" but no API key was found.',
fixHint: expect.stringContaining("OPENAI_API_KEY"),
}),
]);
});
it("does not report disabled memory search as a lint warning", async () => {
const contribution = requireDoctorContribution("doctor:memory-search");
const check = contribution.healthChecks[0] as HealthCheck;
mocks.noteMemorySearchHealth.mockImplementationOnce(async (_cfg, opts) => {
opts.noteFn("Memory search is explicitly disabled (enabled: false).", "Memory search");
});
const findings = await check.detect({
mode: "lint",
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
cfg: {},
});
expect(findings).toEqual([]);
});
it("uses legacy run when a contribution also declares structured health", async () => {
const legacyRun = vi.fn();
const healthChecks = {
+65
View File
@@ -1034,6 +1034,66 @@ async function runMemorySearchHealthContribution(ctx: DoctorHealthFlowContext):
}
}
function memorySearchNoteToFinding(message: string): HealthFinding | null {
const lines = message.split("\n");
const firstLine = (lines[0] ?? message).trim();
if (firstLine === "Memory search is explicitly disabled (enabled: false).") {
return null;
}
const fixHint = lines
.slice(1)
.map((line) => line.trimEnd())
.join("\n")
.trim();
return {
checkId: "core/doctor/memory-search",
severity: "warning",
message: firstLine,
path: inferMemorySearchFindingPath(firstLine),
...(fixHint ? { fixHint } : {}),
};
}
function inferMemorySearchFindingPath(message: string): string {
if (message.includes("No active memory plugin")) {
return "plugins.slots.memory";
}
if (message.includes("QMD memory backend")) {
return "memory.backend";
}
if (message.includes("OpenAI-compatible embeddings endpoint")) {
return "agents.defaults.memorySearch.remote.baseUrl";
}
if (message.includes("OpenAI-compatible embedding model")) {
return "agents.defaults.memorySearch.model";
}
return "agents.defaults.memorySearch.provider";
}
async function collectMemorySearchHealthFindings(
ctx: Parameters<HealthCheck["detect"]>[0],
): Promise<readonly HealthFinding[]> {
const { noteMemorySearchHealth } = await import("../commands/doctor-memory-search.js");
const notes: string[] = [];
await noteMemorySearchHealth(ctx.cfg, {
includeWorkspaceMemoryHealth: false,
skipQmdBinaryProbe: true,
skipAuthProfileResolution: true,
gatewayMemoryProbe: {
checked: false,
ready: false,
skipped: true,
},
noteFn: (message) => {
notes.push(String(message));
},
});
return notes.flatMap((message) => {
const finding = memorySearchNoteToFinding(message);
return finding ? [finding] : [];
});
}
async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteDevicePairingHealth } = await import("../commands/doctor-device-pairing.js");
await noteDevicePairingHealth({
@@ -1658,6 +1718,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
createDoctorHealthContribution({
id: "doctor:memory-search",
label: "Memory search",
healthChecks: {
description: "Memory search provider and backend readiness are captured as findings.",
defaultEnabled: false,
detect: collectMemorySearchHealthFindings,
},
run: runMemorySearchHealthContribution,
}),
createDoctorHealthContribution({