From b1c9f022ec32aeb56467c946fd91bd856d5f4ece Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 19:48:02 -0700 Subject: [PATCH] fix(memory-wiki): protect recall boundaries and damaged notes (#118375) --- extensions/memory-wiki/index.test.ts | 38 ++-- extensions/memory-wiki/index.ts | 1 + .../memory-wiki/src/cli.agent-scope.test.ts | 19 ++ extensions/memory-wiki/src/cli.ts | 6 +- .../src/ingest-human-notes.test.ts | 5 + extensions/memory-wiki/src/markdown.ts | 7 +- extensions/memory-wiki/src/query.test.ts | 212 ++++++++++++++++++ extensions/memory-wiki/src/query.ts | 28 ++- .../src/source-sync-malformed-notes.test.ts | 12 +- extensions/memory-wiki/src/tool.ts | 1 + 10 files changed, 301 insertions(+), 28 deletions(-) diff --git a/extensions/memory-wiki/index.test.ts b/extensions/memory-wiki/index.test.ts index 3ccb7011e939..71ab78c2f9ec 100644 --- a/extensions/memory-wiki/index.test.ts +++ b/extensions/memory-wiki/index.test.ts @@ -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 () => { diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index be5684da2a92..3b85490f7f7d 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -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" }, diff --git a/extensions/memory-wiki/src/cli.agent-scope.test.ts b/extensions/memory-wiki/src/cli.agent-scope.test.ts index 9bfc5c3e49d4..cec6e2e99382 100644 --- a/extensions/memory-wiki/src/cli.agent-scope.test.ts +++ b/extensions/memory-wiki/src/cli.agent-scope.test.ts @@ -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 }) => { diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 228c08c701b8..34c0d63b6994 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -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 { diff --git a/extensions/memory-wiki/src/ingest-human-notes.test.ts b/extensions/memory-wiki/src/ingest-human-notes.test.ts index 3882f272a15b..ed04380e2596 100644 --- a/extensions/memory-wiki/src/ingest-human-notes.test.ts +++ b/extensions/memory-wiki/src/ingest-human-notes.test.ts @@ -50,6 +50,11 @@ describe("ingestMemoryWikiSource human notes", () => { malformedNotes: "HANDWRITTEN NOTE MUST SURVIVE\n", 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 }) => { diff --git a/extensions/memory-wiki/src/markdown.ts b/extensions/memory-wiki/src/markdown.ts index f3a3116cc63d..5c7fedb048b2 100644 --- a/extensions/memory-wiki/src/markdown.ts +++ b/extensions/memory-wiki/src/markdown.ts @@ -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; diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index 72ffe6fca64b..8dee032c9f21 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -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, diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index a81c7aac160f..67546df74cbf 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -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 { - 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 { + 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)) diff --git a/extensions/memory-wiki/src/source-sync-malformed-notes.test.ts b/extensions/memory-wiki/src/source-sync-malformed-notes.test.ts index c16a0b9449ad..0888caeaf36c 100644 --- a/extensions/memory-wiki/src/source-sync-malformed-notes.test.ts +++ b/extensions/memory-wiki/src/source-sync-malformed-notes.test.ts @@ -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" ? [] : [""]), + ...(missingMarker === "opening" || missingMarker === "both" + ? [] + : [""]), "human annotations must survive malformed markers", - ...(missingMarker === "closing" ? [] : [""]), + ...(missingMarker === "closing" || missingMarker === "both" + ? [] + : [""]), "", ].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" ? "" : ""; diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index 2e0259015c33..8be1545fe13c 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -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,