mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
@@ -59,6 +59,7 @@ export {
|
||||
ensureAuthProfileStore,
|
||||
ensureAuthProfileStoreWithoutExternalProfiles,
|
||||
getRuntimeAuthProfileStoreSnapshot,
|
||||
hasAuthProfileStoreSourceForProvider,
|
||||
hasAnyAuthProfileStoreSource,
|
||||
hasLocalAuthProfileStoreSource,
|
||||
loadAuthProfileStoreForSecretsRuntime,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user