diff --git a/CHANGELOG.md b/CHANGELOG.md index 638ae7d25d3..5a4655431e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -229,6 +229,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: deliver media generated by tool-result replies while still suppressing text-only tool chatter. (#60968) Thanks @adaclaw. - Config/agents: accept `agents.list[].contextTokens` in strict config validation so per-agent overrides survive hot reload, letting `/status` reflect the configured model window instead of the 200k fallback. Fixes #70692. (#71247) Thanks @statxc. - Heartbeat: include async exec completion details in heartbeat prompts so command-finished notifications relay the actual output. (#71213) Thanks @GodsBoy. +- Memory search: apply session visibility and agent-to-agent policy to session transcript hits, and keep `corpus=sessions` ranking scoped to session collections before result limiting. (#70761) Thanks @nefainl. ## 2026.4.23 diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 3c3da4b5aec..451986e4612 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -44,6 +44,7 @@ export default definePluginEntry({ createMemorySearchTool({ config: ctx.config, agentSessionKey: ctx.sessionKey, + sandboxed: ctx.sandboxed, }), { names: ["memory_search"] }, ); diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index 5985fa3ef01..22ef640044e 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -280,8 +280,11 @@ export abstract class MemoryManagerSyncOps { } } - protected buildSourceFilter(alias?: string): { sql: string; params: MemorySource[] } { - const sources = Array.from(this.sources); + protected buildSourceFilter( + alias?: string, + sourcesOverride?: MemorySource[], + ): { sql: string; params: MemorySource[] } { + const sources = sourcesOverride ?? Array.from(this.sources); if (sources.length === 0) { return { sql: "", params: [] }; } diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index d9b62839135..4533d074b24 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -294,6 +294,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem sessionKey?: string; qmdSearchModeOverride?: "query" | "search" | "vsearch"; onDebug?: (debug: MemorySearchRuntimeDebug) => void; + /** When set, only these chunk sources are considered (must be enabled for this manager). */ + sources?: MemorySource[]; }, ): Promise { opts?.onDebug?.({ backend: "builtin" }); @@ -332,6 +334,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem } const minScore = opts?.minScore ?? this.settings.query.minScore; const maxResults = opts?.maxResults ?? this.settings.query.maxResults; + const searchSources = + opts?.sources && opts.sources.length > 0 + ? [...new Set(opts.sources)].filter((s) => this.sources.has(s)) + : undefined; + if (opts?.sources && opts.sources.length > 0 && (!searchSources || searchSources.length === 0)) { + return []; + } + const sourceFilterList = searchSources ?? [...this.sources]; const hybrid = this.settings.query.hybrid; const candidates = Math.min( 200, @@ -345,9 +355,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem return []; } - const fullQueryResults = await this.searchKeyword(cleaned, candidates, { - boostFallbackRanking: true, - }).catch(() => []); + const fullQueryResults = await this.searchKeyword( + cleaned, + candidates, + { + boostFallbackRanking: true, + }, + sourceFilterList, + ).catch(() => []); const resultSets = fullQueryResults.length > 0 ? [fullQueryResults] @@ -360,7 +375,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem }); const searchTerms = keywords.length > 0 ? keywords : [cleaned]; return searchTerms.map((term) => - this.searchKeyword(term, candidates, { boostFallbackRanking: true }).catch( + this.searchKeyword(term, candidates, { boostFallbackRanking: true }, sourceFilterList).catch( () => [], ), ); @@ -391,13 +406,13 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem // If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only. const keywordResults = hybrid.enabled && this.fts.enabled && this.fts.available - ? await this.searchKeyword(cleaned, candidates).catch(() => []) + ? await this.searchKeyword(cleaned, candidates, undefined, sourceFilterList).catch(() => []) : []; const queryVec = await this.embedQueryWithTimeout(cleaned); const hasVector = queryVec.some((v) => v !== 0); const vectorResults = hasVector - ? await this.searchVector(queryVec, candidates).catch(() => []) + ? await this.searchVector(queryVec, candidates, sourceFilterList).catch(() => []) : []; if (!hybrid.enabled || !this.fts.enabled || !this.fts.available) { @@ -473,6 +488,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem private async searchVector( queryVec: number[], limit: number, + sourceFilterList: MemorySource[], ): Promise> { // This method should never be called without a provider if (!this.provider) { @@ -486,8 +502,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem limit, snippetMaxChars: SNIPPET_MAX_CHARS, ensureVectorReady: async (dimensions) => await this.ensureVectorReady(dimensions), - sourceFilterVec: this.buildSourceFilter("c"), - sourceFilterChunks: this.buildSourceFilter(), + sourceFilterVec: this.buildSourceFilter("c", sourceFilterList), + sourceFilterChunks: this.buildSourceFilter(undefined, sourceFilterList), }); return results.map((entry) => entry as MemorySearchResult & { id: string }); } @@ -500,11 +516,12 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem query: string, limit: number, options?: { boostFallbackRanking?: boolean }, + sourceFilterList?: MemorySource[], ): Promise> { if (!this.fts.enabled || !this.fts.available) { return []; } - const sourceFilter = this.buildSourceFilter(); + const sourceFilter = this.buildSourceFilter(undefined, sourceFilterList); // In FTS-only mode (no provider), search all models; otherwise filter by current provider's model const providerModel = this.provider?.model; const results = await searchKeyword({ diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 3533fb4c516..5a1b4a9bcc6 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -4329,6 +4329,82 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("restricts qmd search to session collections before result limiting", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + sessions: { enabled: true }, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search" && args.includes("workspace-main")) { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([ + { + file: "qmd://workspace-main/notes.md", + score: 0.99, + snippet: "@@ -1,1\nmemory hit", + }, + ]), + ); + return child; + } + if (args[0] === "search" && args.includes("sessions-main")) { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([ + { + file: "qmd://sessions-main/session-1.md", + score: 0.8, + snippet: "@@ -2,1\nsession hit", + }, + ]), + ); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + const results = await manager.search("hit", { + sessionKey: "agent:main:slack:dm:u123", + sources: ["sessions"], + maxResults: 1, + }); + + expect(results).toEqual([ + { + path: "qmd/sessions-main/session-1.md", + startLine: 2, + endLine: 2, + score: 0.8, + snippet: "@@ -2,1\nsession hit", + source: "sessions", + }, + ]); + + const searchCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args) => args[0] === "search"); + expect(searchCalls).toHaveLength(1); + expect(searchCalls[0]).toContain("sessions-main"); + expect(searchCalls[0]).not.toContain("workspace-main"); + + await manager.close(); + }); + it("preserves multi-collection qmd search hits when results only include file URIs", async () => { cfg = { ...cfg, diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 5d4efc00a3c..c0a3b075737 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -1058,6 +1058,7 @@ export class QmdMemoryManager implements MemorySearchManager { sessionKey?: string; qmdSearchModeOverride?: "query" | "search" | "vsearch"; onDebug?: (debug: MemorySearchRuntimeDebug) => void; + sources?: MemorySource[]; }, ): Promise { if (!this.isScopeAllowed(opts?.sessionKey)) { @@ -1071,11 +1072,13 @@ export class QmdMemoryManager implements MemorySearchManager { await this.maybeWarmSession(opts?.sessionKey); await this.maybeSyncDirtySearchState(); await this.waitForPendingUpdateBeforeSearch(); - const limit = Math.min( + const resultLimit = Math.min( this.qmd.limits.maxResults, opts?.maxResults ?? this.qmd.limits.maxResults, ); - const collectionNames = this.listManagedCollectionNames(); + const requestedSources = opts?.sources?.length ? [...new Set(opts.sources)] : undefined; + const collectionNames = this.listManagedCollectionNames(requestedSources); + const limit = resultLimit; if (collectionNames.length === 0) { log.warn("qmd query skipped: no managed collections configured"); return []; @@ -1149,8 +1152,6 @@ export class QmdMemoryManager implements MemorySearchManager { } const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit); args.push(...this.buildCollectionFilterArgs(collectionNames)); - // Always scope to managed collections (default + custom). Even for `search`/`vsearch`, - // pass collection filters; if a given QMD build rejects these flags, we fall back to `query`. const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs }); return parseQmdQueryJson(result.stdout, result.stderr); } catch (err) { @@ -1229,7 +1230,12 @@ export class QmdMemoryManager implements MemorySearchManager { effectiveMode: effectiveSearchMode, fallback: searchFallbackReason, }); - return this.clampResultsByInjectedChars(this.diversifyResultsBySource(results, limit)); + let ranked = results; + if (opts?.sources?.length) { + const allow = new Set(opts.sources); + ranked = results.filter((r) => allow.has(r.source)); + } + return this.clampResultsByInjectedChars(this.diversifyResultsBySource(ranked, resultLimit)); } async sync(params?: { @@ -2974,8 +2980,15 @@ export class QmdMemoryManager implements MemorySearchManager { return [...bestByDocId.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0)); } - private listManagedCollectionNames(): string[] { - return this.managedCollectionNames; + private listManagedCollectionNames(sources?: MemorySource[]): string[] { + if (!sources?.length) { + return this.managedCollectionNames; + } + const allowed = new Set(sources); + return this.managedCollectionNames.filter((name) => { + const source = this.collectionRoots.get(name)?.kind; + return source ? allowed.has(source) : false; + }); } private computeManagedCollectionNames(): string[] { diff --git a/extensions/memory-core/src/memory/search-manager.ts b/extensions/memory-core/src/memory/search-manager.ts index a5ae613e329..0e6d11ce474 100644 --- a/extensions/memory-core/src/memory/search-manager.ts +++ b/extensions/memory-core/src/memory/search-manager.ts @@ -14,6 +14,7 @@ import { type MemoryEmbeddingProbeResult, type MemorySearchManager, type MemorySearchRuntimeDebug, + type MemorySource, type MemorySyncProgressUpdate, type ResolvedQmdConfig, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; @@ -258,6 +259,7 @@ class BorrowedMemoryManager implements MemorySearchManager { sessionKey?: string; qmdSearchModeOverride?: "query" | "search" | "vsearch"; onDebug?: (debug: MemorySearchRuntimeDebug) => void; + sources?: MemorySource[]; }, ) { return await this.inner.search(query, opts); @@ -334,6 +336,7 @@ class FallbackMemoryManager implements MemorySearchManager { sessionKey?: string; qmdSearchModeOverride?: "query" | "search" | "vsearch"; onDebug?: (debug: MemorySearchRuntimeDebug) => void; + sources?: MemorySource[]; }, ) { this.ensureOpen(); diff --git a/extensions/memory-core/src/session-search-visibility.test.ts b/extensions/memory-core/src/session-search-visibility.test.ts new file mode 100644 index 00000000000..eb67ef422c6 --- /dev/null +++ b/extensions/memory-core/src/session-search-visibility.test.ts @@ -0,0 +1,151 @@ +import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; +import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; +import { asOpenClawConfig } from "./tools.test-helpers.js"; + +const crossAgentStore = { + "agent:peer:only": { + sessionId: "w1", + updatedAt: 1, + sessionFile: "/tmp/sessions/w1.jsonl", + }, +}; + +vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadCombinedSessionStoreForGateway: vi.fn(() => ({ + storePath: "(test)", + store: crossAgentStore, + })), + }; +}); + +describe("filterMemorySearchHitsBySessionVisibility", () => { + afterEach(() => { + vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear(); + }); + + it("drops sessions-sourced hits when requester key is missing (fail closed)", async () => { + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); + const hits: MemorySearchResult[] = [ + { + path: "sessions/u1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }, + ]; + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: undefined, + sandboxed: false, + hits, + }); + expect(filtered).toEqual([]); + }); + + it("keeps non-session hits unchanged", async () => { + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); + const hits: MemorySearchResult[] = [ + { + path: "memory/foo.md", + source: "memory", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }, + ]; + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits, + }); + expect(filtered).toEqual(hits); + }); + + it("loads the combined session store once per filter pass", async () => { + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); + const hits: MemorySearchResult[] = [ + { + path: "sessions/w1.jsonl", + source: "sessions", + score: 1, + snippet: "a", + startLine: 1, + endLine: 2, + }, + { + path: "sessions/w1.jsonl", + source: "sessions", + score: 0.9, + snippet: "b", + startLine: 1, + endLine: 2, + }, + ]; + await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits, + }); + expect(sessionTranscriptHit.loadCombinedSessionStoreForGateway).toHaveBeenCalledTimes(1); + expect(sessionTranscriptHit.loadCombinedSessionStoreForGateway).toHaveBeenCalledWith(cfg); + }); + + it("allows cross-agent session hits when visibility=all and agent-to-agent is enabled", async () => { + const hit: MemorySearchResult = { + path: "sessions/w1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toEqual([hit]); + }); + + it("denies cross-agent session hits when agent-to-agent is disabled", async () => { + const hit: MemorySearchResult = { + path: "sessions/w1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: false }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toEqual([]); + }); +}); diff --git a/extensions/memory-core/src/session-search-visibility.ts b/extensions/memory-core/src/session-search-visibility.ts new file mode 100644 index 00000000000..3742b182d97 --- /dev/null +++ b/extensions/memory-core/src/session-search-visibility.ts @@ -0,0 +1,63 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; +import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; +import { + extractTranscriptStemFromSessionsMemoryHit, + loadCombinedSessionStoreForGateway, + resolveTranscriptStemToSessionKeys, +} from "openclaw/plugin-sdk/session-transcript-hit"; +import { + createAgentToAgentPolicy, + createSessionVisibilityGuard, + resolveEffectiveSessionToolsVisibility, +} from "openclaw/plugin-sdk/session-visibility"; + +export async function filterMemorySearchHitsBySessionVisibility(params: { + cfg: OpenClawConfig; + requesterSessionKey: string | undefined; + sandboxed: boolean; + hits: MemorySearchResult[]; +}): Promise { + const visibility = resolveEffectiveSessionToolsVisibility({ + cfg: params.cfg, + sandboxed: params.sandboxed, + }); + const a2aPolicy = createAgentToAgentPolicy(params.cfg); + const guard = params.requesterSessionKey + ? await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: params.requesterSessionKey, + visibility, + a2aPolicy, + }) + : null; + + const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(params.cfg); + + const next: MemorySearchResult[] = []; + for (const hit of params.hits) { + if (hit.source !== "sessions") { + next.push(hit); + continue; + } + if (!params.requesterSessionKey || !guard) { + continue; + } + const stem = extractTranscriptStemFromSessionsMemoryHit(hit.path); + if (!stem) { + continue; + } + const keys = resolveTranscriptStemToSessionKeys({ + store: combinedSessionStore, + stem, + }); + if (keys.length === 0) { + continue; + } + const allowed = keys.some((key) => guard.check(key).allowed); + if (!allowed) { + continue; + } + next.push(hit); + } + return next; +} diff --git a/extensions/memory-core/src/tools.shared.ts b/extensions/memory-core/src/tools.shared.ts index fd042bad528..65c5f4a7f5d 100644 --- a/extensions/memory-core/src/tools.shared.ts +++ b/extensions/memory-core/src/tools.shared.ts @@ -2,7 +2,6 @@ import { listMemoryCorpusSupplements, resolveMemorySearchConfig, resolveSessionAgentId, - type MemoryCorpusGetResult, type MemoryCorpusSearchResult, type AnyAgentTool, type OpenClawConfig, @@ -27,7 +26,12 @@ export const MemorySearchSchema = Type.Object({ maxResults: Type.Optional(Type.Number()), minScore: Type.Optional(Type.Number()), corpus: Type.Optional( - Type.Union([Type.Literal("memory"), Type.Literal("wiki"), Type.Literal("all")]), + Type.Union([ + Type.Literal("memory"), + Type.Literal("wiki"), + Type.Literal("all"), + Type.Literal("sessions"), + ]), ), }); @@ -145,9 +149,9 @@ export async function searchMemoryCorpusSupplements(params: { query: string; maxResults?: number; agentSessionKey?: string; - corpus?: "memory" | "wiki" | "all"; + corpus?: "memory" | "wiki" | "all" | "sessions"; }): Promise { - if (params.corpus === "memory") { + if (params.corpus === "memory" || params.corpus === "sessions") { return []; } const supplements = listMemoryCorpusSupplements(); @@ -174,9 +178,9 @@ export async function getMemoryCorpusSupplementResult(params: { fromLine?: number; lineCount?: number; agentSessionKey?: string; - corpus?: "memory" | "wiki" | "all"; -}): Promise { - if (params.corpus === "memory") { + corpus?: "memory" | "wiki" | "all" | "sessions"; +}) { + if (params.corpus === "memory" || params.corpus === "sessions") { return null; } for (const registration of listMemoryCorpusSupplements()) { diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index c6a929eafd6..d6a2b6a8b41 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -6,6 +6,7 @@ import { readStringParam, type OpenClawConfig, } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; +import type { MemorySource } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import type { MemorySearchResult, MemorySearchRuntimeDebug, @@ -14,6 +15,7 @@ import { resolveMemoryCorePluginConfig, resolveMemoryDeepDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; +import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; import { recordShortTermRecalls } from "./short-term-promotion.js"; import { clampResultsByInjectedChars, @@ -181,13 +183,14 @@ async function executeMemoryReadResult(params: { export function createMemorySearchTool(options: { config?: OpenClawConfig; agentSessionKey?: string; + sandboxed?: boolean; }) { return createMemoryTool({ options, label: "Memory Search", name: "memory_search", description: - "Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.", + "Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.", parameters: MemorySearchSchema, execute: ({ cfg, agentId }) => @@ -200,6 +203,7 @@ export function createMemorySearchTool(options: { | "memory" | "wiki" | "all" + | "sessions" | undefined; const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime(); const shouldQueryMemory = requestedCorpus !== "wiki"; @@ -239,6 +243,12 @@ export function createMemorySearchTool(options: { cfg, options.agentSessionKey, ); + const searchSources: MemorySource[] | undefined = + requestedCorpus === "sessions" + ? (["sessions"] as MemorySource[]) + : requestedCorpus === "memory" + ? (["memory"] as MemorySource[]) + : undefined; rawResults = await memory.manager.search(query, { maxResults, minScore, @@ -247,7 +257,19 @@ export function createMemorySearchTool(options: { onDebug: (debug) => { runtimeDebug.push(debug); }, + ...(searchSources ? { sources: searchSources } : {}), }); + rawResults = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: options.agentSessionKey, + sandboxed: options.sandboxed === true, + hits: rawResults, + }); + if (requestedCorpus === "sessions") { + rawResults = rawResults.filter((hit) => hit.source === "sessions"); + } else if (requestedCorpus === "memory") { + rawResults = rawResults.filter((hit) => hit.source === "memory"); + } const status = memory.manager.status(); const decorated = decorateCitations(rawResults, includeCitations); const resolved = resolveMemoryBackendConfig({ cfg, agentId }); diff --git a/package.json b/package.json index ed31e712e90..072695ffaac 100644 --- a/package.json +++ b/package.json @@ -737,6 +737,14 @@ "types": "./dist/plugin-sdk/session-store-runtime.d.ts", "default": "./dist/plugin-sdk/session-store-runtime.js" }, + "./plugin-sdk/session-transcript-hit": { + "types": "./dist/plugin-sdk/session-transcript-hit.d.ts", + "default": "./dist/plugin-sdk/session-transcript-hit.js" + }, + "./plugin-sdk/session-visibility": { + "types": "./dist/plugin-sdk/session-visibility.d.ts", + "default": "./dist/plugin-sdk/session-visibility.js" + }, "./plugin-sdk/ssrf-dispatcher": { "types": "./dist/plugin-sdk/ssrf-dispatcher.d.ts", "default": "./dist/plugin-sdk/ssrf-dispatcher.js" diff --git a/packages/memory-host-sdk/src/host/types.ts b/packages/memory-host-sdk/src/host/types.ts index 880384df71a..534a914e20a 100644 --- a/packages/memory-host-sdk/src/host/types.ts +++ b/packages/memory-host-sdk/src/host/types.ts @@ -61,7 +61,12 @@ export type MemoryProviderStatus = { export interface MemorySearchManager { search( query: string, - opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, + opts?: { + maxResults?: number; + minScore?: number; + sessionKey?: string; + sources?: MemorySource[]; + }, ): Promise; readFile(params: { relPath: string; diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 6cfd101598d..959973a80b0 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -170,6 +170,8 @@ "session-binding-runtime", "session-key-runtime", "session-store-runtime", + "session-transcript-hit", + "session-visibility", "ssrf-dispatcher", "string-coerce-runtime", "group-activation", diff --git a/src/agents/tools/sessions-access.ts b/src/agents/tools/sessions-access.ts index 3dfd052a736..18349790c5f 100644 --- a/src/agents/tools/sessions-access.ts +++ b/src/agents/tools/sessions-access.ts @@ -1,57 +1,33 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "../../shared/string-coerce.js"; import { + createAgentToAgentPolicy, + createSessionVisibilityChecker, + createSessionVisibilityGuard, listSpawnedSessionKeys, - resolveInternalSessionKey, - resolveMainSessionAlias, -} from "./sessions-resolution.js"; + resolveEffectiveSessionToolsVisibility, + resolveSandboxSessionToolsVisibility, + resolveSessionToolsVisibility, +} from "../../plugin-sdk/session-visibility.js"; +import { isSubagentSessionKey } from "../../routing/session-key.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-resolution.js"; -export type SessionToolsVisibility = "self" | "tree" | "agent" | "all"; +export type { + AgentToAgentPolicy, + SessionAccessAction, + SessionAccessResult, + SessionToolsVisibility, +} from "../../plugin-sdk/session-visibility.js"; -export type AgentToAgentPolicy = { - enabled: boolean; - matchesAllow: (agentId: string) => boolean; - isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean; -}; - -export type SessionAccessAction = "history" | "send" | "list" | "status"; - -export type SessionAccessResult = - | { allowed: true } - | { allowed: false; error: string; status: "forbidden" }; - -export function resolveSessionToolsVisibility(cfg: OpenClawConfig): SessionToolsVisibility { - const raw = (cfg.tools as { sessions?: { visibility?: unknown } } | undefined)?.sessions - ?.visibility; - const value = normalizeLowercaseStringOrEmpty(raw); - if (value === "self" || value === "tree" || value === "agent" || value === "all") { - return value; - } - return "tree"; -} - -export function resolveEffectiveSessionToolsVisibility(params: { - cfg: OpenClawConfig; - sandboxed: boolean; -}): SessionToolsVisibility { - const visibility = resolveSessionToolsVisibility(params.cfg); - if (!params.sandboxed) { - return visibility; - } - const sandboxClamp = params.cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; - if (sandboxClamp === "spawned" && visibility !== "tree") { - return "tree"; - } - return visibility; -} - -export function resolveSandboxSessionToolsVisibility(cfg: OpenClawConfig): "spawned" | "all" { - return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; -} +export { + createAgentToAgentPolicy, + createSessionVisibilityChecker, + createSessionVisibilityGuard, + listSpawnedSessionKeys, + resolveEffectiveSessionToolsVisibility, + resolveSandboxSessionToolsVisibility, + resolveSessionToolsVisibility, +} from "../../plugin-sdk/session-visibility.js"; export function resolveSandboxedSessionToolContext(params: { cfg: OpenClawConfig; @@ -90,169 +66,3 @@ export function resolveSandboxedSessionToolContext(params: { restrictToSpawned, }; } - -export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolicy { - const routingA2A = cfg.tools?.agentToAgent; - const enabled = routingA2A?.enabled === true; - const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; - const matchesAllow = (agentId: string) => { - if (allowPatterns.length === 0) { - return true; - } - return allowPatterns.some((pattern) => { - const raw = - normalizeOptionalString(typeof pattern === "string" ? pattern : String(pattern ?? "")) ?? - ""; - if (!raw) { - return false; - } - if (raw === "*") { - return true; - } - if (!raw.includes("*")) { - return raw === agentId; - } - const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); - return re.test(agentId); - }); - }; - const isAllowed = (requesterAgentId: string, targetAgentId: string) => { - if (requesterAgentId === targetAgentId) { - return true; - } - if (!enabled) { - return false; - } - return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId); - }; - return { enabled, matchesAllow, isAllowed }; -} - -function actionPrefix(action: SessionAccessAction): string { - if (action === "history") { - return "Session history"; - } - if (action === "send") { - return "Session send"; - } - if (action === "status") { - return "Session status"; - } - return "Session list"; -} - -function a2aDisabledMessage(action: SessionAccessAction): string { - if (action === "history") { - return "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access."; - } - if (action === "send") { - return "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends."; - } - if (action === "status") { - return "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access."; - } - return "Agent-to-agent listing is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent visibility."; -} - -function a2aDeniedMessage(action: SessionAccessAction): string { - if (action === "history") { - return "Agent-to-agent history denied by tools.agentToAgent.allow."; - } - if (action === "send") { - return "Agent-to-agent messaging denied by tools.agentToAgent.allow."; - } - if (action === "status") { - return "Agent-to-agent status denied by tools.agentToAgent.allow."; - } - return "Agent-to-agent listing denied by tools.agentToAgent.allow."; -} - -function crossVisibilityMessage(action: SessionAccessAction): string { - if (action === "history") { - return "Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; - } - if (action === "send") { - return "Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; - } - if (action === "status") { - return "Session status visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; - } - return "Session list visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; -} - -function selfVisibilityMessage(action: SessionAccessAction): string { - return `${actionPrefix(action)} visibility is restricted to the current session (tools.sessions.visibility=self).`; -} - -function treeVisibilityMessage(action: SessionAccessAction): string { - return `${actionPrefix(action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`; -} - -export async function createSessionVisibilityGuard(params: { - action: SessionAccessAction; - requesterSessionKey: string; - visibility: SessionToolsVisibility; - a2aPolicy: AgentToAgentPolicy; -}): Promise<{ - check: (targetSessionKey: string) => SessionAccessResult; -}> { - const requesterAgentId = resolveAgentIdFromSessionKey(params.requesterSessionKey); - const spawnedKeys = - params.visibility === "tree" - ? await listSpawnedSessionKeys({ requesterSessionKey: params.requesterSessionKey }) - : null; - - const check = (targetSessionKey: string): SessionAccessResult => { - const targetAgentId = resolveAgentIdFromSessionKey(targetSessionKey); - const isCrossAgent = targetAgentId !== requesterAgentId; - if (isCrossAgent) { - if (params.visibility !== "all") { - return { - allowed: false, - status: "forbidden", - error: crossVisibilityMessage(params.action), - }; - } - if (!params.a2aPolicy.enabled) { - return { - allowed: false, - status: "forbidden", - error: a2aDisabledMessage(params.action), - }; - } - if (!params.a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - return { - allowed: false, - status: "forbidden", - error: a2aDeniedMessage(params.action), - }; - } - return { allowed: true }; - } - - if (params.visibility === "self" && targetSessionKey !== params.requesterSessionKey) { - return { - allowed: false, - status: "forbidden", - error: selfVisibilityMessage(params.action), - }; - } - - if ( - params.visibility === "tree" && - targetSessionKey !== params.requesterSessionKey && - !spawnedKeys?.has(targetSessionKey) - ) { - return { - allowed: false, - status: "forbidden", - error: treeVisibilityMessage(params.action), - }; - } - - return { allowed: true }; - }; - - return { check }; -} diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts index 6d463c541fe..562d7affccd 100644 --- a/src/agents/tools/sessions-resolution.ts +++ b/src/agents/tools/sessions-resolution.ts @@ -1,6 +1,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { + listSpawnedSessionKeys, + sessionVisibilityGatewayTesting, +} from "../../plugin-sdk/session-visibility.js"; import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; import { looksLikeSessionId } from "../../sessions/session-id.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -47,31 +51,7 @@ export function resolveInternalSessionKey(params: { return params.key; } -export async function listSpawnedSessionKeys(params: { - requesterSessionKey: string; - limit?: number; -}): Promise> { - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? Math.max(1, Math.floor(params.limit)) - : undefined; - try { - const list = await sessionsResolutionDeps.callGateway<{ sessions: Array<{ key?: unknown }> }>({ - method: "sessions.list", - params: { - includeGlobal: false, - includeUnknown: false, - ...(limit !== undefined ? { limit } : {}), - spawnedBy: params.requesterSessionKey, - }, - }); - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - const keys = sessions.map((entry) => normalizeOptionalString(entry?.key) ?? "").filter(Boolean); - return new Set(keys); - } catch { - return new Set(); - } -} +export { listSpawnedSessionKeys }; export async function isRequesterSpawnedSessionVisible(params: { requesterSessionKey: string; @@ -462,5 +442,8 @@ export const __testing = { ...overrides, } : defaultSessionsResolutionDeps; + sessionVisibilityGatewayTesting.setCallGatewayForListSpawned( + overrides?.callGateway ?? defaultSessionsResolutionDeps.callGateway, + ); }, }; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 0942761d9f8..6586391fca0 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -1,3 +1,4 @@ +export * from "./sessions/combined-store-gateway.js"; export * from "./sessions/group.js"; export * from "./sessions/artifacts.js"; export * from "./sessions/metadata.js"; diff --git a/src/config/sessions/combined-store-gateway.ts b/src/config/sessions/combined-store-gateway.ts new file mode 100644 index 00000000000..abc446973b5 --- /dev/null +++ b/src/config/sessions/combined-store-gateway.ts @@ -0,0 +1,98 @@ +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + canonicalizeSpawnedByForAgent, + resolveStoredSessionKeyForAgentStore, +} from "../../gateway/session-store-key.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import type { OpenClawConfig } from "../types.openclaw.js"; +import { resolveStorePath } from "./paths.js"; +import { loadSessionStore } from "./store-load.js"; +import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js"; +import type { SessionEntry } from "./types.js"; + +function isStorePathTemplate(store?: string): boolean { + return typeof store === "string" && store.includes("{agentId}"); +} + +function mergeSessionEntryIntoCombined(params: { + cfg: OpenClawConfig; + combined: Record; + entry: SessionEntry; + agentId: string; + canonicalKey: string; +}) { + const { cfg, combined, entry, agentId, canonicalKey } = params; + const existing = combined[canonicalKey]; + + if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) { + combined[canonicalKey] = { + ...entry, + ...existing, + spawnedBy: canonicalizeSpawnedByForAgent(cfg, agentId, existing.spawnedBy ?? entry.spawnedBy), + }; + } else { + combined[canonicalKey] = { + ...existing, + ...entry, + spawnedBy: canonicalizeSpawnedByForAgent( + cfg, + agentId, + entry.spawnedBy ?? existing?.spawnedBy, + ), + }; + } +} + +export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { + storePath: string; + store: Record; +} { + const storeConfig = cfg.session?.store; + if (storeConfig && !isStorePathTemplate(storeConfig)) { + const storePath = resolveStorePath(storeConfig); + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const store = loadSessionStore(storePath); + const combined: Record = {}; + for (const [key, entry] of Object.entries(store)) { + const canonicalKey = resolveStoredSessionKeyForAgentStore({ + cfg, + agentId: defaultAgentId, + sessionKey: key, + }); + mergeSessionEntryIntoCombined({ + cfg, + combined, + entry, + agentId: defaultAgentId, + canonicalKey, + }); + } + return { storePath, store: combined }; + } + + const targets = resolveAllAgentSessionStoreTargetsSync(cfg); + const combined: Record = {}; + for (const target of targets) { + const agentId = target.agentId; + const storePath = target.storePath; + const store = loadSessionStore(storePath); + for (const [key, entry] of Object.entries(store)) { + const canonicalKey = resolveStoredSessionKeyForAgentStore({ + cfg, + agentId, + sessionKey: key, + }); + mergeSessionEntryIntoCombined({ + cfg, + combined, + entry, + agentId, + canonicalKey, + }); + } + } + + const storePath = + typeof storeConfig === "string" && storeConfig.trim() ? storeConfig.trim() : "(multiple)"; + return { storePath, store: combined }; +} diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 2dbd3a0c549..cdbc342e928 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -981,89 +981,7 @@ export function resolveGatewaySessionStoreTarget(params: { }; } -// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data. -function mergeSessionEntryIntoCombined(params: { - cfg: OpenClawConfig; - combined: Record; - entry: SessionEntry; - agentId: string; - canonicalKey: string; -}) { - const { cfg, combined, entry, agentId, canonicalKey } = params; - const existing = combined[canonicalKey]; - - if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) { - combined[canonicalKey] = { - ...entry, - ...existing, - spawnedBy: canonicalizeSpawnedByForAgent(cfg, agentId, existing.spawnedBy ?? entry.spawnedBy), - }; - } else { - combined[canonicalKey] = { - ...existing, - ...entry, - spawnedBy: canonicalizeSpawnedByForAgent( - cfg, - agentId, - entry.spawnedBy ?? existing?.spawnedBy, - ), - }; - } -} - -export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { - storePath: string; - store: Record; -} { - const storeConfig = cfg.session?.store; - if (storeConfig && !isStorePathTemplate(storeConfig)) { - const storePath = resolveStorePath(storeConfig); - const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); - const store = loadSessionStore(storePath); - const combined: Record = {}; - for (const [key, entry] of Object.entries(store)) { - const canonicalKey = resolveStoredSessionKeyForAgentStore({ - cfg, - agentId: defaultAgentId, - sessionKey: key, - }); - mergeSessionEntryIntoCombined({ - cfg, - combined, - entry, - agentId: defaultAgentId, - canonicalKey, - }); - } - return { storePath, store: combined }; - } - - const targets = resolveAllAgentSessionStoreTargetsSync(cfg); - const combined: Record = {}; - for (const target of targets) { - const agentId = target.agentId; - const storePath = target.storePath; - const store = loadSessionStore(storePath); - for (const [key, entry] of Object.entries(store)) { - const canonicalKey = resolveStoredSessionKeyForAgentStore({ - cfg, - agentId, - sessionKey: key, - }); - mergeSessionEntryIntoCombined({ - cfg, - combined, - entry, - agentId, - canonicalKey, - }); - } - } - - const storePath = - typeof storeConfig === "string" && storeConfig.trim() ? storeConfig.trim() : "(multiple)"; - return { storePath, store: combined }; -} +export { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js"; export function getSessionDefaults(cfg: OpenClawConfig): GatewaySessionsDefaults { const resolved = resolveConfiguredModelRef({ diff --git a/src/memory-host-sdk/host/types.ts b/src/memory-host-sdk/host/types.ts index 92ec371b240..b7a068fc3d8 100644 --- a/src/memory-host-sdk/host/types.ts +++ b/src/memory-host-sdk/host/types.ts @@ -83,6 +83,7 @@ export interface MemorySearchManager { sessionKey?: string; qmdSearchModeOverride?: "query" | "search" | "vsearch"; onDebug?: (debug: MemorySearchRuntimeDebug) => void; + sources?: MemorySource[]; }, ): Promise; readFile(params: { relPath: string; from?: number; lines?: number }): Promise; diff --git a/src/plugin-sdk/session-transcript-hit.test.ts b/src/plugin-sdk/session-transcript-hit.test.ts new file mode 100644 index 00000000000..90194b0bdb2 --- /dev/null +++ b/src/plugin-sdk/session-transcript-hit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../config/sessions/types.js"; +import { + extractTranscriptStemFromSessionsMemoryHit, + resolveTranscriptStemToSessionKeys, +} from "./session-transcript-hit.js"; + +describe("extractTranscriptStemFromSessionsMemoryHit", () => { + it("strips sessions/ and .jsonl for builtin paths", () => { + expect(extractTranscriptStemFromSessionsMemoryHit("sessions/abc-uuid.jsonl")).toBe("abc-uuid"); + }); + + it("handles plain basename jsonl", () => { + expect(extractTranscriptStemFromSessionsMemoryHit("def-topic-thread.jsonl")).toBe( + "def-topic-thread", + ); + }); + + it("uses .md basename for QMD exports", () => { + expect(extractTranscriptStemFromSessionsMemoryHit("qmd/sessions/x/y/z.md")).toBe("z"); + }); +}); + +describe("resolveTranscriptStemToSessionKeys", () => { + const baseEntry = (overrides: Partial = {}): SessionEntry => ({ + sessionId: "stem-a", + updatedAt: 1, + ...overrides, + }); + + it("returns keys for every agent whose store entry matches the stem", () => { + const store: Record = { + "agent:main:s1": baseEntry({ + sessionFile: "/data/sessions/stem-a.jsonl", + }), + "agent:peer:s2": baseEntry({ + sessionFile: "/other/volume/stem-a.jsonl", + }), + }; + const keys = resolveTranscriptStemToSessionKeys({ store, stem: "stem-a" }).toSorted(); + expect(keys).toEqual(["agent:main:s1", "agent:peer:s2"]); + }); +}); diff --git a/src/plugin-sdk/session-transcript-hit.ts b/src/plugin-sdk/session-transcript-hit.ts new file mode 100644 index 00000000000..dccd154ffc4 --- /dev/null +++ b/src/plugin-sdk/session-transcript-hit.ts @@ -0,0 +1,58 @@ +import path from "node:path"; +import { parseUsageCountedSessionIdFromFileName } from "../config/sessions/artifacts.js"; +import type { SessionEntry } from "../config/sessions/types.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; + +export { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js"; + +/** + * Derive transcript stem `S` from a memory search hit path for `source === "sessions"`. + * Builtin index uses `sessions/.jsonl`; QMD exports use `.md`. + */ +export function extractTranscriptStemFromSessionsMemoryHit(hitPath: string): string | null { + const normalized = hitPath.replace(/\\/g, "/"); + const trimmed = normalized.startsWith("sessions/") + ? normalized.slice("sessions/".length) + : normalized; + const base = path.basename(trimmed); + if (base.endsWith(".jsonl")) { + const stem = base.slice(0, -".jsonl".length); + return stem || null; + } + if (base.endsWith(".md")) { + const stem = base.slice(0, -".md".length); + return stem || null; + } + return null; +} + +/** + * Map transcript stem to canonical session store keys (all agents in the combined store). + * Session tools visibility and agent-to-agent policy are enforced by the caller (e.g. + * `createSessionVisibilityGuard`), including cross-agent cases. + */ +export function resolveTranscriptStemToSessionKeys(params: { + store: Record; + stem: string; +}): string[] { + const { store } = params; + const matches: string[] = []; + const stemAsFile = params.stem.endsWith(".jsonl") ? params.stem : `${params.stem}.jsonl`; + const parsedStemId = parseUsageCountedSessionIdFromFileName(stemAsFile); + + for (const [sessionKey, entry] of Object.entries(store)) { + const sessionFile = normalizeOptionalString(entry.sessionFile); + if (sessionFile) { + const base = path.basename(sessionFile); + const fileStem = base.endsWith(".jsonl") ? base.slice(0, -".jsonl".length) : base; + if (fileStem === params.stem) { + matches.push(sessionKey); + continue; + } + } + if (entry.sessionId === params.stem || (parsedStemId && entry.sessionId === parsedStemId)) { + matches.push(sessionKey); + } + } + return [...new Set(matches)]; +} diff --git a/src/plugin-sdk/session-visibility.ts b/src/plugin-sdk/session-visibility.ts new file mode 100644 index 00000000000..6a6015ccec9 --- /dev/null +++ b/src/plugin-sdk/session-visibility.ts @@ -0,0 +1,270 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { callGateway as defaultCallGateway } from "../gateway/call.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; + +type GatewayCaller = typeof defaultCallGateway; + +let callGatewayForListSpawned: GatewayCaller = defaultCallGateway; + +/** Test hook: must stay aligned with `sessions-resolution` `__testing.setDepsForTest`. */ +export const sessionVisibilityGatewayTesting = { + setCallGatewayForListSpawned(overrides?: GatewayCaller) { + callGatewayForListSpawned = overrides ?? defaultCallGateway; + }, +}; + +export type SessionToolsVisibility = "self" | "tree" | "agent" | "all"; + +export type AgentToAgentPolicy = { + enabled: boolean; + matchesAllow: (agentId: string) => boolean; + isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean; +}; + +export type SessionAccessAction = "history" | "send" | "list" | "status"; + +export type SessionAccessResult = + | { allowed: true } + | { allowed: false; error: string; status: "forbidden" }; + +export async function listSpawnedSessionKeys(params: { + requesterSessionKey: string; + limit?: number; +}): Promise> { + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? Math.max(1, Math.floor(params.limit)) + : undefined; + try { + const list = await callGatewayForListSpawned<{ sessions: Array<{ key?: unknown }> }>({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + ...(limit !== undefined ? { limit } : {}), + spawnedBy: params.requesterSessionKey, + }, + }); + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + const keys = sessions.map((entry) => normalizeOptionalString(entry?.key) ?? "").filter(Boolean); + return new Set(keys); + } catch { + return new Set(); + } +} + +export function resolveSessionToolsVisibility(cfg: OpenClawConfig): SessionToolsVisibility { + const raw = (cfg.tools as { sessions?: { visibility?: unknown } } | undefined)?.sessions + ?.visibility; + const value = normalizeLowercaseStringOrEmpty(raw); + if (value === "self" || value === "tree" || value === "agent" || value === "all") { + return value; + } + return "tree"; +} + +export function resolveEffectiveSessionToolsVisibility(params: { + cfg: OpenClawConfig; + sandboxed: boolean; +}): SessionToolsVisibility { + const visibility = resolveSessionToolsVisibility(params.cfg); + if (!params.sandboxed) { + return visibility; + } + const sandboxClamp = params.cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; + if (sandboxClamp === "spawned" && visibility !== "tree") { + return "tree"; + } + return visibility; +} + +export function resolveSandboxSessionToolsVisibility(cfg: OpenClawConfig): "spawned" | "all" { + return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; +} + +export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolicy { + const routingA2A = cfg.tools?.agentToAgent; + const enabled = routingA2A?.enabled === true; + const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; + const matchesAllow = (agentId: string) => { + if (allowPatterns.length === 0) { + return true; + } + return allowPatterns.some((pattern) => { + const raw = + normalizeOptionalString(typeof pattern === "string" ? pattern : String(pattern ?? "")) ?? + ""; + if (!raw) { + return false; + } + if (raw === "*") { + return true; + } + if (!raw.includes("*")) { + return raw === agentId; + } + const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i"); + return re.test(agentId); + }); + }; + const isAllowed = (requesterAgentId: string, targetAgentId: string) => { + if (requesterAgentId === targetAgentId) { + return true; + } + if (!enabled) { + return false; + } + return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId); + }; + return { enabled, matchesAllow, isAllowed }; +} + +function actionPrefix(action: SessionAccessAction): string { + if (action === "history") { + return "Session history"; + } + if (action === "send") { + return "Session send"; + } + if (action === "status") { + return "Session status"; + } + return "Session list"; +} + +function a2aDisabledMessage(action: SessionAccessAction): string { + if (action === "history") { + return "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access."; + } + if (action === "send") { + return "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends."; + } + if (action === "status") { + return "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access."; + } + return "Agent-to-agent listing is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent visibility."; +} + +function a2aDeniedMessage(action: SessionAccessAction): string { + if (action === "history") { + return "Agent-to-agent history denied by tools.agentToAgent.allow."; + } + if (action === "send") { + return "Agent-to-agent messaging denied by tools.agentToAgent.allow."; + } + if (action === "status") { + return "Agent-to-agent status denied by tools.agentToAgent.allow."; + } + return "Agent-to-agent listing denied by tools.agentToAgent.allow."; +} + +function crossVisibilityMessage(action: SessionAccessAction): string { + if (action === "history") { + return "Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; + } + if (action === "send") { + return "Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; + } + if (action === "status") { + return "Session status visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; + } + return "Session list visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; +} + +function selfVisibilityMessage(action: SessionAccessAction): string { + return `${actionPrefix(action)} visibility is restricted to the current session (tools.sessions.visibility=self).`; +} + +function treeVisibilityMessage(action: SessionAccessAction): string { + return `${actionPrefix(action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`; +} + +export function createSessionVisibilityChecker(params: { + action: SessionAccessAction; + requesterSessionKey: string; + visibility: SessionToolsVisibility; + a2aPolicy: AgentToAgentPolicy; + spawnedKeys: Set | null; +}): { check: (targetSessionKey: string) => SessionAccessResult } { + const requesterAgentId = resolveAgentIdFromSessionKey(params.requesterSessionKey); + const spawnedKeys = params.spawnedKeys; + + const check = (targetSessionKey: string): SessionAccessResult => { + const targetAgentId = resolveAgentIdFromSessionKey(targetSessionKey); + const isCrossAgent = targetAgentId !== requesterAgentId; + if (isCrossAgent) { + if (params.visibility !== "all") { + return { + allowed: false, + status: "forbidden", + error: crossVisibilityMessage(params.action), + }; + } + if (!params.a2aPolicy.enabled) { + return { + allowed: false, + status: "forbidden", + error: a2aDisabledMessage(params.action), + }; + } + if (!params.a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { + return { + allowed: false, + status: "forbidden", + error: a2aDeniedMessage(params.action), + }; + } + return { allowed: true }; + } + + if (params.visibility === "self" && targetSessionKey !== params.requesterSessionKey) { + return { + allowed: false, + status: "forbidden", + error: selfVisibilityMessage(params.action), + }; + } + + if ( + params.visibility === "tree" && + targetSessionKey !== params.requesterSessionKey && + !spawnedKeys?.has(targetSessionKey) + ) { + return { + allowed: false, + status: "forbidden", + error: treeVisibilityMessage(params.action), + }; + } + + return { allowed: true }; + }; + + return { check }; +} + +export async function createSessionVisibilityGuard(params: { + action: SessionAccessAction; + requesterSessionKey: string; + visibility: SessionToolsVisibility; + a2aPolicy: AgentToAgentPolicy; +}): Promise<{ + check: (targetSessionKey: string) => SessionAccessResult; +}> { + const spawnedKeys = + params.visibility === "tree" + ? await listSpawnedSessionKeys({ requesterSessionKey: params.requesterSessionKey }) + : null; + return createSessionVisibilityChecker({ + action: params.action, + requesterSessionKey: params.requesterSessionKey, + visibility: params.visibility, + a2aPolicy: params.a2aPolicy, + spawnedKeys, + }); +}