mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(memory-wiki): protect recall boundaries and damaged notes (#118375)
This commit is contained in:
committed by
GitHub
parent
0d366e7d39
commit
b1c9f022ec
@@ -154,32 +154,34 @@ describe("memory-wiki plugin", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards protected recall authorization to wiki search", () => {
|
||||
it("forwards protected recall authorization to wiki search and get", () => {
|
||||
const { api, registerTool } = createPluginApi();
|
||||
plugin.register(api);
|
||||
const registration = registerTool.mock.calls.find((call) => call[1]?.name === "wiki_search");
|
||||
const factory = registration?.[0];
|
||||
const conversationRecall = {
|
||||
anchorSessionKey: "agent:main:telegram:direct:owner",
|
||||
scope: "same-agent-private",
|
||||
corpus: "sessions",
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
factory?.({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:owner:active-memory:abcdef123456",
|
||||
sandboxed: false,
|
||||
conversationRecall,
|
||||
}),
|
||||
).toMatchObject({
|
||||
testMemoryContext: {
|
||||
agentId: "main",
|
||||
agentSessionKey: "agent:main:telegram:direct:owner:active-memory:abcdef123456",
|
||||
sandboxed: false,
|
||||
conversationRecall,
|
||||
},
|
||||
});
|
||||
for (const toolName of ["wiki_search", "wiki_get"]) {
|
||||
const registration = registerTool.mock.calls.find((call) => call[1]?.name === toolName);
|
||||
const factory = registration?.[0];
|
||||
expect(
|
||||
factory?.({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:owner:active-memory:abcdef123456",
|
||||
sandboxed: false,
|
||||
conversationRecall,
|
||||
}),
|
||||
).toMatchObject({
|
||||
testMemoryContext: {
|
||||
agentId: "main",
|
||||
agentSessionKey: "agent:main:telegram:direct:owner:active-memory:abcdef123456",
|
||||
sandboxed: false,
|
||||
conversationRecall,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("activates an initialized legacy vault before an external compile", async () => {
|
||||
|
||||
@@ -217,6 +217,7 @@ export default definePluginEntry({
|
||||
agentId: resolved.config.agentId ?? ctx.agentId,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
sandboxed: ctx.sandboxed,
|
||||
conversationRecall: ctx.conversationRecall,
|
||||
});
|
||||
},
|
||||
{ name: "wiki_get" },
|
||||
|
||||
@@ -160,6 +160,25 @@ describe("memory-wiki agent-scoped cli", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ commandPath: ["search"], args: ["search", "query"] },
|
||||
{ commandPath: ["get"], args: ["get", "entity.alpha"] },
|
||||
])("keeps global-vault $commandPath scoped to the configured default agent", async (testCase) => {
|
||||
const { config } = await createCliVault();
|
||||
const appConfig = {
|
||||
agents: { entries: { support: { default: true }, marketing: {} } },
|
||||
};
|
||||
const { program, resolveConfig } = createAgentSelectionProgram({
|
||||
config,
|
||||
appConfig,
|
||||
commandPath: testCase.commandPath,
|
||||
});
|
||||
|
||||
await program.parseAsync(["wiki", ...testCase.args], { from: "user" });
|
||||
|
||||
expect(resolveConfig).toHaveBeenCalledWith("support", appConfig);
|
||||
});
|
||||
|
||||
it.each(AGENT_SCOPED_WIKI_COMMANDS)(
|
||||
"gives actionable missing-agent guidance for wiki $label",
|
||||
async ({ path: commandPath, args }) => {
|
||||
|
||||
@@ -994,7 +994,11 @@ export function registerWikiCli(program: Command, registration: MemoryWikiCliReg
|
||||
undefined;
|
||||
const currentAppConfig = registration.getAppConfig?.();
|
||||
let agentId = requestedAgentId;
|
||||
if (needsAgent && registration.config.vault.scope === "agent" && !agentId) {
|
||||
if (
|
||||
needsAgent &&
|
||||
!agentId &&
|
||||
(registration.config.vault.scope === "agent" || currentAppConfig)
|
||||
) {
|
||||
try {
|
||||
agentId = resolveDefaultAgentId(currentAppConfig ?? {});
|
||||
} catch {
|
||||
|
||||
@@ -50,6 +50,11 @@ describe("ingestMemoryWikiSource human notes", () => {
|
||||
malformedNotes: "HANDWRITTEN NOTE MUST SURVIVE\n<!-- openclaw:human:end -->",
|
||||
missingMarker: /openclaw:human:start/i,
|
||||
},
|
||||
{
|
||||
name: "opening and closing",
|
||||
malformedNotes: "HANDWRITTEN NOTE MUST SURVIVE",
|
||||
missingMarker: /openclaw:human:start/i,
|
||||
},
|
||||
])(
|
||||
"preserves the source page when handwritten Notes are missing the $name marker",
|
||||
async ({ malformedNotes, missingMarker }) => {
|
||||
|
||||
@@ -532,7 +532,12 @@ function findNotesHumanBlock(page: string): { start: number; end: number } | nul
|
||||
const start = page.indexOf(HUMAN_START_MARKER, searchFrom);
|
||||
const endMarker = page.lastIndexOf(HUMAN_END_MARKER);
|
||||
if (start === -1 && endMarker < searchFrom) {
|
||||
return null;
|
||||
const notesSection = /(?:^|\r?\n)## Notes[\t ]*(?:\r?\n|$)([\s\S]*)/u.exec(
|
||||
page.slice(searchFrom),
|
||||
);
|
||||
if (!notesSection?.[1]?.trim()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (start === -1 || endMarker < start) {
|
||||
const missingMarker = start === -1 ? HUMAN_START_MARKER : HUMAN_END_MARKER;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { MemoryWikiPluginConfig } from "./config.js";
|
||||
import { renderWikiMarkdown } from "./markdown.js";
|
||||
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
|
||||
import { createMemoryWikiTestHarness } from "./test-helpers.js";
|
||||
import { createWikiGetTool } from "./tool.js";
|
||||
|
||||
const {
|
||||
getActiveMemorySearchManagerMock,
|
||||
@@ -991,6 +992,82 @@ describe("searchMemoryWiki", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ configuredCorpus: "wiki" as const, requestedCorpus: undefined },
|
||||
{ configuredCorpus: "all" as const, requestedCorpus: undefined },
|
||||
{ configuredCorpus: "all" as const, requestedCorpus: "wiki" as const },
|
||||
])(
|
||||
"keeps protected session recall inside its trusted corpus ($configuredCorpus/$requestedCorpus)",
|
||||
async ({ configuredCorpus, requestedCorpus }) => {
|
||||
const { rootDir, config } = await createQueryVault({
|
||||
initialize: true,
|
||||
config: { search: { backend: "shared", corpus: configuredCorpus } },
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(rootDir, "sources", "alpha.md"),
|
||||
renderWikiMarkdown({
|
||||
frontmatter: { pageType: "source", id: "source.alpha", title: "Alpha" },
|
||||
body: "# Alpha\n\nalpha wiki secret\n",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const anchorSessionKey = "agent:main:telegram:direct:owner";
|
||||
const requesterSessionKey = `${anchorSessionKey}:active-memory:abcdef123456`;
|
||||
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||
storePath: "(test)",
|
||||
store: {
|
||||
[anchorSessionKey]: {
|
||||
sessionId: "current",
|
||||
updatedAt: 1,
|
||||
sessionFile: "/tmp/current.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = createMemoryManager({
|
||||
searchResults: [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "alpha durable secret",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
|
||||
const conversationRecall = {
|
||||
anchorSessionKey,
|
||||
scope: "same-agent-private",
|
||||
corpus: "sessions",
|
||||
} as const;
|
||||
|
||||
const results = await searchMemoryWiki({
|
||||
config,
|
||||
appConfig: createSessionVisibilityAppConfig(),
|
||||
agentId: "main",
|
||||
agentSessionKey: requesterSessionKey,
|
||||
conversationRecall,
|
||||
query: "alpha",
|
||||
...(requestedCorpus ? { searchCorpus: requestedCorpus } : {}),
|
||||
});
|
||||
|
||||
expect(results).toStrictEqual([]);
|
||||
expect(manager.search).toHaveBeenCalledWith("alpha", {
|
||||
maxResults: 10,
|
||||
sources: ["sessions"],
|
||||
sessionKey: requesterSessionKey,
|
||||
});
|
||||
expect(filterMemorySearchHitsBySessionVisibility).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationRecall,
|
||||
hits: [expect.objectContaining({ source: "memory" })],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps QMD archived session search hits inside visibility policy", async () => {
|
||||
const { config } = await createQueryVault({
|
||||
initialize: true,
|
||||
@@ -1108,6 +1185,63 @@ describe("searchMemoryWiki", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps context-free shared searches and reads inside the default agent", async () => {
|
||||
const { config } = await createQueryVault({
|
||||
initialize: true,
|
||||
config: { search: { backend: "shared", corpus: "memory" } },
|
||||
});
|
||||
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||
storePath: "(test)",
|
||||
store: {
|
||||
"agent:main:visible-session": {
|
||||
sessionId: "visible-session",
|
||||
updatedAt: 1,
|
||||
sessionFile: "/tmp/visible-session.jsonl",
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = createMemoryManager({
|
||||
searchResults: [
|
||||
{
|
||||
path: "sessions/visible-session.jsonl",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 2,
|
||||
snippet: "default agent transcript",
|
||||
source: "sessions",
|
||||
},
|
||||
{
|
||||
path: "qmd/sessions-secondary/private-session.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "other agent transcript",
|
||||
source: "sessions",
|
||||
},
|
||||
],
|
||||
readResult: {
|
||||
path: "qmd/sessions-secondary/private-session.md",
|
||||
text: "other agent transcript",
|
||||
},
|
||||
});
|
||||
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
|
||||
const appConfig = createAgentSessionVisibilityAppConfig();
|
||||
|
||||
const results = await searchMemoryWiki({ config, appConfig, query: "transcript" });
|
||||
const forbiddenPage = await getMemoryWikiPage({
|
||||
config,
|
||||
appConfig,
|
||||
lookup: "qmd/sessions-secondary/private-session.md",
|
||||
});
|
||||
|
||||
expect(results.map((result) => result.path)).toEqual(["sessions/visible-session.jsonl"]);
|
||||
expect(forbiddenPage).toBeNull();
|
||||
expect(manager.readFile).not.toHaveBeenCalled();
|
||||
expect(loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(appConfig, {
|
||||
agentId: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps gateway-style global session memory hits for non-default agents", async () => {
|
||||
const { config } = await createQueryVault({
|
||||
initialize: true,
|
||||
@@ -1832,6 +1966,84 @@ describe("getMemoryWikiPage", () => {
|
||||
expect(manager.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ visibility: "self" as const, groupAlias: false, allowed: true },
|
||||
{ visibility: "agent" as const, groupAlias: true, allowed: false },
|
||||
])(
|
||||
"applies protected recall authorization to session reads ($visibility, group alias: $groupAlias)",
|
||||
async ({ visibility, groupAlias, allowed }) => {
|
||||
const { config } = await createQueryVault({
|
||||
initialize: true,
|
||||
config: { search: { backend: "shared", corpus: "memory" } },
|
||||
});
|
||||
const anchorSessionKey = "agent:main:telegram:direct:owner";
|
||||
const requesterSessionKey = `${anchorSessionKey}:active-memory:abcdef123456`;
|
||||
const friendSessionKey = "agent:main:webchat:direct:friend";
|
||||
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||
storePath: "(test)",
|
||||
store: {
|
||||
[anchorSessionKey]: {
|
||||
sessionId: "current",
|
||||
updatedAt: 1,
|
||||
sessionFile: "/tmp/current.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
[friendSessionKey]: {
|
||||
sessionId: "friend",
|
||||
updatedAt: 2,
|
||||
sessionFile: "/tmp/friend.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
...(groupAlias
|
||||
? {
|
||||
"agent:main:telegram:group:team": {
|
||||
sessionId: "friend",
|
||||
updatedAt: 3,
|
||||
sessionFile: "/tmp/friend.jsonl",
|
||||
chatType: "group",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const manager = createMemoryManager({
|
||||
readResult: { path: "qmd/sessions-main/friend.md", text: "private transcript" },
|
||||
});
|
||||
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
|
||||
const request = {
|
||||
config,
|
||||
appConfig: {
|
||||
...createSessionVisibilityAppConfig(),
|
||||
tools: { sessions: { visibility } },
|
||||
},
|
||||
agentId: "main",
|
||||
agentSessionKey: requesterSessionKey,
|
||||
conversationRecall: {
|
||||
anchorSessionKey,
|
||||
scope: "same-agent-private",
|
||||
corpus: "sessions",
|
||||
} as const,
|
||||
lookup: "qmd/sessions-main/friend.md",
|
||||
};
|
||||
|
||||
const result = await getMemoryWikiPage(request);
|
||||
const tool = createWikiGetTool(config, request.appConfig, {
|
||||
agentId: request.agentId,
|
||||
agentSessionKey: request.agentSessionKey,
|
||||
conversationRecall: request.conversationRecall,
|
||||
});
|
||||
const toolResult = await tool.execute("protected-wiki-get", { lookup: request.lookup });
|
||||
expect(toolResult.details).toMatchObject({ found: allowed });
|
||||
|
||||
if (allowed) {
|
||||
expect(result?.content).toBe("private transcript");
|
||||
} else {
|
||||
expect(result).toBeNull();
|
||||
expect(manager.readFile).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not read ownerless QMD live orphan session paths", async () => {
|
||||
const { config } = await createQueryVault({
|
||||
initialize: true,
|
||||
|
||||
@@ -1259,7 +1259,7 @@ export function resolveQueryableWikiPageByLookup(
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchMemoryWiki(params: {
|
||||
export async function searchMemoryWiki(input: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
appConfig?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
@@ -1272,7 +1272,16 @@ export async function searchMemoryWiki(params: {
|
||||
searchCorpus?: WikiSearchCorpus;
|
||||
mode?: WikiSearchMode;
|
||||
}): Promise<WikiSearchResult[]> {
|
||||
const effectiveConfig = applySearchOverrides(params.config, params);
|
||||
const agentId = resolveActiveMemoryAgentId(input);
|
||||
const params = agentId ? { ...input, agentId } : input;
|
||||
const protectedSessionRecall = params.conversationRecall?.corpus === "sessions";
|
||||
// Recall scope is runtime-owned; model corpus/backend overrides cannot widen it.
|
||||
const effectiveConfig = applySearchOverrides(
|
||||
params.config,
|
||||
protectedSessionRecall
|
||||
? { searchBackend: params.config.search.backend, searchCorpus: "memory" }
|
||||
: params,
|
||||
);
|
||||
assertSessionVisibilityAppConfig({
|
||||
config: effectiveConfig,
|
||||
appConfig: params.appConfig,
|
||||
@@ -1306,12 +1315,17 @@ export async function searchMemoryWiki(params: {
|
||||
throw buildMemoryManagerContractError("search");
|
||||
}
|
||||
let rawMemoryResults = sharedMemoryManager
|
||||
? await sharedMemoryManager.search(params.query, { maxResults })
|
||||
? await sharedMemoryManager.search(params.query, {
|
||||
maxResults,
|
||||
...(protectedSessionRecall
|
||||
? { sources: ["sessions" as const], sessionKey: params.agentSessionKey }
|
||||
: {}),
|
||||
})
|
||||
: [];
|
||||
if (
|
||||
params.appConfig &&
|
||||
shouldEnforceSessionVisibility(params) &&
|
||||
rawMemoryResults.some((hit) => hit.source === "sessions")
|
||||
(params.conversationRecall || rawMemoryResults.some((hit) => hit.source === "sessions"))
|
||||
) {
|
||||
rawMemoryResults = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg: params.appConfig,
|
||||
@@ -1333,18 +1347,21 @@ export async function searchMemoryWiki(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMemoryWikiPage(params: {
|
||||
export async function getMemoryWikiPage(input: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
appConfig?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
conversationRecall?: ConversationRecallContext;
|
||||
lookup: string;
|
||||
fromLine?: number;
|
||||
lineCount?: number;
|
||||
searchBackend?: WikiSearchBackend;
|
||||
searchCorpus?: WikiSearchCorpus;
|
||||
}): Promise<WikiGetResult | null> {
|
||||
const agentId = resolveActiveMemoryAgentId(input);
|
||||
const params = agentId ? { ...input, agentId } : input;
|
||||
const effectiveConfig = applySearchOverrides(params.config, params);
|
||||
assertSessionVisibilityAppConfig({
|
||||
config: effectiveConfig,
|
||||
@@ -1421,6 +1438,7 @@ export async function getMemoryWikiPage(params: {
|
||||
agentId: params.agentId,
|
||||
requesterSessionKey: params.agentSessionKey,
|
||||
sandboxed: params.sandboxed === true,
|
||||
conversationRecall: params.conversationRecall,
|
||||
trustedAgentScope: !params.agentSessionKey && Boolean(params.agentId?.trim()),
|
||||
hits: lookupCandidates
|
||||
.filter((relPath) => isSessionMemoryPath(relPath))
|
||||
|
||||
@@ -55,8 +55,10 @@ describe("memory wiki source sync malformed human Notes", () => {
|
||||
it.each([
|
||||
{ group: "bridge" as const, missingMarker: "opening" as const },
|
||||
{ group: "bridge" as const, missingMarker: "closing" as const },
|
||||
{ group: "bridge" as const, missingMarker: "both" as const },
|
||||
{ group: "unsafe-local" as const, missingMarker: "opening" as const },
|
||||
{ group: "unsafe-local" as const, missingMarker: "closing" as const },
|
||||
{ group: "unsafe-local" as const, missingMarker: "both" as const },
|
||||
])(
|
||||
"preserves $group pages and persisted state when the $missingMarker marker is missing",
|
||||
async ({ group, missingMarker }) => {
|
||||
@@ -72,9 +74,13 @@ describe("memory wiki source sync malformed human Notes", () => {
|
||||
"generated content",
|
||||
"```",
|
||||
"## Notes",
|
||||
...(missingMarker === "opening" ? [] : ["<!-- openclaw:human:start -->"]),
|
||||
...(missingMarker === "opening" || missingMarker === "both"
|
||||
? []
|
||||
: ["<!-- openclaw:human:start -->"]),
|
||||
"human annotations must survive malformed markers",
|
||||
...(missingMarker === "closing" ? [] : ["<!-- openclaw:human:end -->"]),
|
||||
...(missingMarker === "closing" || missingMarker === "both"
|
||||
? []
|
||||
: ["<!-- openclaw:human:end -->"]),
|
||||
"",
|
||||
].join("\n");
|
||||
await fs.writeFile(pageAbsPath, pageContent, "utf8");
|
||||
@@ -90,7 +96,7 @@ describe("memory wiki source sync malformed human Notes", () => {
|
||||
const updatedEntry = { ...entry, sourceSize: entry.sourceSize + 1 };
|
||||
setImportedSourceEntry({ state, syncKey: "sync-key", entry: updatedEntry });
|
||||
const expectedMissingMarker =
|
||||
missingMarker === "opening"
|
||||
missingMarker === "opening" || missingMarker === "both"
|
||||
? "<!-- openclaw:human:start -->"
|
||||
: "<!-- openclaw:human:end -->";
|
||||
|
||||
|
||||
@@ -291,6 +291,7 @@ export function createWikiGetTool(
|
||||
agentId: memoryContext.agentId,
|
||||
agentSessionKey: memoryContext.agentSessionKey,
|
||||
sandboxed: memoryContext.sandboxed,
|
||||
conversationRecall: memoryContext.conversationRecall,
|
||||
lookup: params.lookup,
|
||||
fromLine: params.fromLine,
|
||||
lineCount: params.lineCount,
|
||||
|
||||
Reference in New Issue
Block a user