mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
committed by
GitHub
parent
978a50a3c5
commit
2c716f5677
@@ -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
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export default definePluginEntry({
|
||||
createMemorySearchTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
sandboxed: ctx.sandboxed,
|
||||
}),
|
||||
{ names: ["memory_search"] },
|
||||
);
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
151
extensions/memory-core/src/session-search-visibility.test.ts
Normal file
151
extensions/memory-core/src/session-search-visibility.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
63
extensions/memory-core/src/session-search-visibility.ts
Normal file
63
extensions/memory-core/src/session-search-visibility.ts
Normal 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;
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
98
src/config/sessions/combined-store-gateway.ts
Normal file
98
src/config/sessions/combined-store-gateway.ts
Normal 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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
43
src/plugin-sdk/session-transcript-hit.test.ts
Normal file
43
src/plugin-sdk/session-transcript-hit.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
58
src/plugin-sdk/session-transcript-hit.ts
Normal file
58
src/plugin-sdk/session-transcript-hit.ts
Normal 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)];
|
||||
}
|
||||
270
src/plugin-sdk/session-visibility.ts
Normal file
270
src/plugin-sdk/session-visibility.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user