From 816038e97a5df3df52931ffcd40f7d7e3ff0517c Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 30 Jun 2026 18:54:57 -0700 Subject: [PATCH] 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 --- src/agents/auth-profiles.ts | 1 + src/agents/auth-profiles/persisted.ts | 4 +- src/agents/auth-profiles/source-check.test.ts | 125 ++++++++++ src/agents/auth-profiles/source-check.ts | 129 ++++++++++ src/agents/auth-profiles/store.ts | 6 +- src/commands/doctor-memory-search.test.ts | 232 ++++++++++++++++++ src/commands/doctor-memory-search.ts | 128 ++++++---- src/flows/doctor-health-contributions.test.ts | 69 ++++++ src/flows/doctor-health-contributions.ts | 65 +++++ 9 files changed, 714 insertions(+), 45 deletions(-) create mode 100644 src/agents/auth-profiles/source-check.test.ts diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index d5fb3269a8fa..ec514b03a3a7 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -59,6 +59,7 @@ export { ensureAuthProfileStore, ensureAuthProfileStoreWithoutExternalProfiles, getRuntimeAuthProfileStoreSnapshot, + hasAuthProfileStoreSourceForProvider, hasAnyAuthProfileStoreSource, hasLocalAuthProfileStoreSource, loadAuthProfileStoreForSecretsRuntime, diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 048f77dc0b08..3ce216dab701 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -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; } diff --git a/src/agents/auth-profiles/source-check.test.ts b/src/agents/auth-profiles/source-check.test.ts new file mode 100644 index 000000000000..b2f660e9aee5 --- /dev/null +++ b/src/agents/auth-profiles/source-check.test.ts @@ -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) { + 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) { + 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); + }); +}); diff --git a/src/agents/auth-profiles/source-check.ts b/src/agents/auth-profiles/source-check.ts index 36233ad1d4b2..7417d058f5c1 100644 --- a/src/agents/auth-profiles/source-check.ts +++ b/src/agents/auth-profiles/source-check.ts @@ -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 | 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); +} diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 64d5f9504c89..2876056d7c53 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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( diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 9eb635cd627f..ca7ce92812b3 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -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: { diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 6a4adbd67d99..7ffa0f618d52 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -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 { - 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 { 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; } diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index a6bd8b311f61..ea5a7b27406e 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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 = { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 43081b152d17..d7132f1a59c6 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -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[0], +): Promise { + 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 { 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({