fix(memory-wiki): protect recall boundaries and damaged notes (#118375)

This commit is contained in:
Peter Steinberger
2026-08-02 19:48:02 -07:00
committed by GitHub
parent 0d366e7d39
commit b1c9f022ec
10 changed files with 301 additions and 28 deletions
+20 -18
View File
@@ -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 () => {
+1
View File
@@ -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 }) => {
+5 -1
View File
@@ -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 }) => {
+6 -1
View File
@@ -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;
+212
View File
@@ -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,
+23 -5
View File
@@ -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 -->";
+1
View File
@@ -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,