fix: enforce memory search session visibility (#70761) (thanks @nefainl)

* [EV-001] memory-core: filter memory_search session hits by visibility

- Move session visibility + listSpawnedSessionKeys to plugin-sdk; sync test
  hook with sessions-resolution __testing.setDepsForTest
- Extract loadCombinedSessionStoreForGateway to config/sessions; re-export
  from gateway session-utils
- Add session-transcript-hit stem resolver for builtin + QMD paths
- Post-filter memory_search results before citations/recall; fail closed when
  requester session key missing; optional corpus=sessions
- Tests: stem extraction, visibility filter smoke, existing suites green

* chore: sync plugin-sdk exports for session-transcript-hit and session-visibility

Run pnpm plugin-sdk:sync-exports so package.json exports match
scripts/lib/plugin-sdk-entrypoints.json. Fixes contract tests and
lint:plugins:plugin-sdk-subpaths-exported for memory-core imports.

* fix(EV-001): cross-agent session memory hits + hoist combined store load

- resolveTranscriptStemToSessionKeys: stop filtering by requester agentId so
  keys from other agents reach createSessionVisibilityGuard (a2a + visibility=all).
- Re-export loadCombinedSessionStoreForGateway from session-transcript-hit;
  filterMemorySearchHitsBySessionVisibility loads the combined store once per pass.
- Drop unused agentId from filter params; extend tests (Greptile/Codex review).

* fix(memory_search): honor corpus=sessions before maxResults cap

Pass sources into MemoryIndexManager.search so FTS/vector queries add
source IN (...) before ranking and top-N slice (Codex: non-session hits
could fill the window).

QMD path: oversample fetch limit for single-source recall, filter by
source, then diversify/clamp to the requested maxResults.

Wire corpus=sessions from tools; extend MemorySearchManager opts and
wrappers.

* fix(memory_search): apply corpus=memory source filter like sessions

Pass sources: ["memory"] into manager.search so maxResults applies only
within the memory index; post-filter for defense in depth. Document
corpus=memory in the tool description.

* fix: scope qmd session memory search

* fix: enforce memory search session visibility (#70761) (thanks @nefainl)

---------

Co-authored-by: NefAI <info@nefai.nl>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Michiel van den Donker
2026-04-25 06:00:21 +02:00
committed by GitHub
parent 978a50a3c5
commit 2c716f5677
23 changed files with 901 additions and 350 deletions

View File

@@ -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

View File

@@ -44,6 +44,7 @@ export default definePluginEntry({
createMemorySearchTool({
config: ctx.config,
agentSessionKey: ctx.sessionKey,
sandboxed: ctx.sandboxed,
}),
{ names: ["memory_search"] },
);

View File

@@ -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: [] };
}

View File

@@ -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<MemorySearchResult[]> {
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<Array<MemorySearchResult & { id: string }>> {
// 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<Array<MemorySearchResult & { id: string; textScore: number }>> {
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({

View File

@@ -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,

View File

@@ -1058,6 +1058,7 @@ export class QmdMemoryManager implements MemorySearchManager {
sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
},
): Promise<MemorySearchResult[]> {
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[] {

View File

@@ -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();

View File

@@ -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<typeof import("openclaw/plugin-sdk/session-transcript-hit")>();
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([]);
});
});

View File

@@ -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<MemorySearchResult[]> {
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;
}

View File

@@ -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<MemoryCorpusSearchResult[]> {
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<MemoryCorpusGetResult | null> {
if (params.corpus === "memory") {
corpus?: "memory" | "wiki" | "all" | "sessions";
}) {
if (params.corpus === "memory" || params.corpus === "sessions") {
return null;
}
for (const registration of listMemoryCorpusSupplements()) {

View File

@@ -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<T>(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 });

View File

@@ -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"

View File

@@ -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<MemorySearchResult[]>;
readFile(params: {
relPath: string;

View File

@@ -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",

View File

@@ -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 };
}

View File

@@ -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<Set<string>> {
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,
);
},
};

View File

@@ -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";

View File

@@ -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<string, SessionEntry>;
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<string, SessionEntry>;
} {
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<string, SessionEntry> = {};
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<string, SessionEntry> = {};
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 };
}

View File

@@ -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<string, SessionEntry>;
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<string, SessionEntry>;
} {
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<string, SessionEntry> = {};
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<string, SessionEntry> = {};
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({

View File

@@ -83,6 +83,7 @@ export interface MemorySearchManager {
sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
},
): Promise<MemorySearchResult[]>;
readFile(params: { relPath: string; from?: number; lines?: number }): Promise<MemoryReadResult>;

View File

@@ -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> = {}): SessionEntry => ({
sessionId: "stem-a",
updatedAt: 1,
...overrides,
});
it("returns keys for every agent whose store entry matches the stem", () => {
const store: Record<string, SessionEntry> = {
"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"]);
});
});

View File

@@ -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/<basename>.jsonl`; QMD exports use `<stem>.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<string, SessionEntry>;
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)];
}

View File

@@ -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<Set<string>> {
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<string> | 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,
});
}