From 1a03aad24649036a75d8abb351c9dc75061acd6a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 03:56:39 +0100 Subject: [PATCH] refactor(sessions): split access and resolution helpers --- src/agents/tools/sessions-access.ts | 240 ++++++++++++ src/agents/tools/sessions-helpers.ts | 365 ++---------------- src/agents/tools/sessions-history-tool.ts | 88 +---- src/agents/tools/sessions-list-tool.ts | 68 ++-- src/agents/tools/sessions-resolution.ts | 257 ++++++++++++ src/agents/tools/sessions-send-tool.ts | 128 ++---- .../bot.create-telegram-bot.test-harness.ts | 2 +- 7 files changed, 604 insertions(+), 544 deletions(-) create mode 100644 src/agents/tools/sessions-access.ts create mode 100644 src/agents/tools/sessions-resolution.ts diff --git a/src/agents/tools/sessions-access.ts b/src/agents/tools/sessions-access.ts new file mode 100644 index 00000000000..6574c2296cf --- /dev/null +++ b/src/agents/tools/sessions-access.ts @@ -0,0 +1,240 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { + listSpawnedSessionKeys, + resolveInternalSessionKey, + resolveMainSessionAlias, +} from "./sessions-resolution.js"; + +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"; + +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 = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + 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 resolveSandboxedSessionToolContext(params: { + cfg: OpenClawConfig; + agentSessionKey?: string; + sandboxed?: boolean; +}): { + mainKey: string; + alias: string; + visibility: "spawned" | "all"; + requesterInternalKey: string | undefined; + effectiveRequesterKey: string; + restrictToSpawned: boolean; +} { + const { mainKey, alias } = resolveMainSessionAlias(params.cfg); + const visibility = resolveSandboxSessionToolsVisibility(params.cfg); + const requesterInternalKey = + typeof params.agentSessionKey === "string" && params.agentSessionKey.trim() + ? resolveInternalSessionKey({ + key: params.agentSessionKey, + alias, + mainKey, + }) + : undefined; + const effectiveRequesterKey = requesterInternalKey ?? alias; + const restrictToSpawned = + params.sandboxed === true && + visibility === "spawned" && + !!requesterInternalKey && + !isSubagentSessionKey(requesterInternalKey); + return { + mainKey, + alias, + visibility, + requesterInternalKey, + effectiveRequesterKey, + 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 = String(pattern ?? "").trim(); + 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"; + } + 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."; + } + 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."; + } + 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."; + } + return "Session list visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access."; +} + +function selfVisibilityMessage(action: SessionAccessAction): string { + return `${actionPrefix(action)} visibility is restricted to the current session (tools.sessions.visibility=self).`; +} + +function treeVisibilityMessage(action: SessionAccessAction): string { + return `${actionPrefix(action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`; +} + +export async function createSessionVisibilityGuard(params: { + action: SessionAccessAction; + requesterSessionKey: string; + visibility: SessionToolsVisibility; + a2aPolicy: AgentToAgentPolicy; +}): Promise<{ + check: (targetSessionKey: string) => SessionAccessResult; +}> { + const requesterAgentId = resolveAgentIdFromSessionKey(params.requesterSessionKey); + const spawnedKeys = + params.visibility === "tree" + ? await listSpawnedSessionKeys({ requesterSessionKey: params.requesterSessionKey }) + : null; + + const check = (targetSessionKey: string): SessionAccessResult => { + const targetAgentId = resolveAgentIdFromSessionKey(targetSessionKey); + const isCrossAgent = targetAgentId !== requesterAgentId; + if (isCrossAgent) { + if (params.visibility !== "all") { + return { + allowed: false, + status: "forbidden", + error: crossVisibilityMessage(params.action), + }; + } + if (!params.a2aPolicy.enabled) { + return { + allowed: false, + status: "forbidden", + error: a2aDisabledMessage(params.action), + }; + } + if (!params.a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { + return { + allowed: false, + status: "forbidden", + error: a2aDeniedMessage(params.action), + }; + } + return { allowed: true }; + } + + if (params.visibility === "self" && targetSessionKey !== params.requesterSessionKey) { + return { + allowed: false, + status: "forbidden", + error: selfVisibilityMessage(params.action), + }; + } + + if ( + params.visibility === "tree" && + targetSessionKey !== params.requesterSessionKey && + !spawnedKeys?.has(targetSessionKey) + ) { + return { + allowed: false, + status: "forbidden", + error: treeVisibilityMessage(params.action), + }; + } + + return { allowed: true }; + }; + + return { check }; +} diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 15df1df8b7b..09c21e69998 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -1,10 +1,29 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import { callGateway } from "../../gateway/call.js"; -import { - isAcpSessionKey, - isSubagentSessionKey, - normalizeMainKey, -} from "../../routing/session-key.js"; +export type { + AgentToAgentPolicy, + SessionAccessAction, + SessionAccessResult, + SessionToolsVisibility, +} from "./sessions-access.js"; +export { + createAgentToAgentPolicy, + createSessionVisibilityGuard, + resolveEffectiveSessionToolsVisibility, + resolveSandboxSessionToolsVisibility, + resolveSandboxedSessionToolContext, + resolveSessionToolsVisibility, +} from "./sessions-access.js"; +export type { SessionReferenceResolution } from "./sessions-resolution.js"; +export { + isRequesterSpawnedSessionVisible, + listSpawnedSessionKeys, + looksLikeSessionId, + looksLikeSessionKey, + resolveDisplaySessionKey, + resolveInternalSessionKey, + resolveMainSessionAlias, + resolveSessionReference, + shouldResolveSessionIdInput, +} from "./sessions-resolution.js"; import { sanitizeUserFacingText } from "../pi-embedded-helpers.js"; import { stripDowngradedToolCallText, @@ -44,343 +63,11 @@ export type SessionListRow = { messages?: unknown[]; }; -export type SessionToolsVisibility = "self" | "tree" | "agent" | "all"; - -export function resolveSessionToolsVisibility(cfg: OpenClawConfig): SessionToolsVisibility { - const raw = (cfg.tools as { sessions?: { visibility?: unknown } } | undefined)?.sessions - ?.visibility; - const value = typeof raw === "string" ? raw.trim().toLowerCase() : ""; - 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 async function listSpawnedSessionKeys(params: { - requesterSessionKey: string; - limit?: number; -}): Promise> { - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? Math.max(1, Math.floor(params.limit)) - : 500; - try { - const list = await callGateway<{ sessions: Array<{ key?: unknown }> }>({ - method: "sessions.list", - params: { - includeGlobal: false, - includeUnknown: false, - limit, - spawnedBy: params.requesterSessionKey, - }, - }); - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - const keys = sessions - .map((entry) => (typeof entry?.key === "string" ? entry.key : "")) - .map((value) => value.trim()) - .filter(Boolean); - return new Set(keys); - } catch { - return new Set(); - } -} - function normalizeKey(value?: string) { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } -export function resolveMainSessionAlias(cfg: OpenClawConfig) { - const mainKey = normalizeMainKey(cfg.session?.mainKey); - const scope = cfg.session?.scope ?? "per-sender"; - const alias = scope === "global" ? "global" : mainKey; - return { mainKey, alias, scope }; -} - -export function resolveDisplaySessionKey(params: { key: string; alias: string; mainKey: string }) { - if (params.key === params.alias) { - return "main"; - } - if (params.key === params.mainKey) { - return "main"; - } - return params.key; -} - -export function resolveInternalSessionKey(params: { key: string; alias: string; mainKey: string }) { - if (params.key === "main") { - return params.alias; - } - return params.key; -} - -export function resolveSandboxSessionToolsVisibility(cfg: OpenClawConfig): "spawned" | "all" { - return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; -} - -export function resolveSandboxedSessionToolContext(params: { - cfg: OpenClawConfig; - agentSessionKey?: string; - sandboxed?: boolean; -}): { - mainKey: string; - alias: string; - visibility: "spawned" | "all"; - requesterInternalKey: string | undefined; - restrictToSpawned: boolean; -} { - const { mainKey, alias } = resolveMainSessionAlias(params.cfg); - const visibility = resolveSandboxSessionToolsVisibility(params.cfg); - const requesterInternalKey = - typeof params.agentSessionKey === "string" && params.agentSessionKey.trim() - ? resolveInternalSessionKey({ - key: params.agentSessionKey, - alias, - mainKey, - }) - : undefined; - const restrictToSpawned = - params.sandboxed === true && - visibility === "spawned" && - !!requesterInternalKey && - !isSubagentSessionKey(requesterInternalKey); - return { mainKey, alias, visibility, requesterInternalKey, restrictToSpawned }; -} - -export type AgentToAgentPolicy = { - enabled: boolean; - matchesAllow: (agentId: string) => boolean; - isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean; -}; - -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 = String(pattern ?? "").trim(); - 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 }; -} - -const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function looksLikeSessionId(value: string): boolean { - return SESSION_ID_RE.test(value.trim()); -} - -export function looksLikeSessionKey(value: string): boolean { - const raw = value.trim(); - if (!raw) { - return false; - } - // These are canonical key shapes that should never be treated as sessionIds. - if (raw === "main" || raw === "global" || raw === "unknown") { - return true; - } - if (isAcpSessionKey(raw)) { - return true; - } - if (raw.startsWith("agent:")) { - return true; - } - if (raw.startsWith("cron:") || raw.startsWith("hook:")) { - return true; - } - if (raw.startsWith("node-") || raw.startsWith("node:")) { - return true; - } - if (raw.includes(":group:") || raw.includes(":channel:")) { - return true; - } - return false; -} - -export function shouldResolveSessionIdInput(value: string): boolean { - // Treat anything that doesn't look like a well-formed key as a sessionId candidate. - return looksLikeSessionId(value) || !looksLikeSessionKey(value); -} - -export type SessionReferenceResolution = - | { - ok: true; - key: string; - displayKey: string; - resolvedViaSessionId: boolean; - } - | { ok: false; status: "error" | "forbidden"; error: string }; - -async function resolveSessionKeyFromSessionId(params: { - sessionId: string; - alias: string; - mainKey: string; - requesterInternalKey?: string; - restrictToSpawned: boolean; -}): Promise { - try { - // Resolve via gateway so we respect store routing and visibility rules. - const result = await callGateway<{ key?: string }>({ - method: "sessions.resolve", - params: { - sessionId: params.sessionId, - spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, - includeGlobal: !params.restrictToSpawned, - includeUnknown: !params.restrictToSpawned, - }, - }); - const key = typeof result?.key === "string" ? result.key.trim() : ""; - if (!key) { - throw new Error( - `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, - ); - } - return { - ok: true, - key, - displayKey: resolveDisplaySessionKey({ - key, - alias: params.alias, - mainKey: params.mainKey, - }), - resolvedViaSessionId: true, - }; - } catch (err) { - if (params.restrictToSpawned) { - return { - ok: false, - status: "forbidden", - error: `Session not visible from this sandboxed agent session: ${params.sessionId}`, - }; - } - const message = err instanceof Error ? err.message : String(err); - return { - ok: false, - status: "error", - error: - message || - `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, - }; - } -} - -async function resolveSessionKeyFromKey(params: { - key: string; - alias: string; - mainKey: string; - requesterInternalKey?: string; - restrictToSpawned: boolean; -}): Promise { - try { - // Try key-based resolution first so non-standard keys keep working. - const result = await callGateway<{ key?: string }>({ - method: "sessions.resolve", - params: { - key: params.key, - spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, - }, - }); - const key = typeof result?.key === "string" ? result.key.trim() : ""; - if (!key) { - return null; - } - return { - ok: true, - key, - displayKey: resolveDisplaySessionKey({ - key, - alias: params.alias, - mainKey: params.mainKey, - }), - resolvedViaSessionId: false, - }; - } catch { - return null; - } -} - -export async function resolveSessionReference(params: { - sessionKey: string; - alias: string; - mainKey: string; - requesterInternalKey?: string; - restrictToSpawned: boolean; -}): Promise { - const raw = params.sessionKey.trim(); - if (shouldResolveSessionIdInput(raw)) { - // Prefer key resolution to avoid misclassifying custom keys as sessionIds. - const resolvedByKey = await resolveSessionKeyFromKey({ - key: raw, - alias: params.alias, - mainKey: params.mainKey, - requesterInternalKey: params.requesterInternalKey, - restrictToSpawned: params.restrictToSpawned, - }); - if (resolvedByKey) { - return resolvedByKey; - } - return await resolveSessionKeyFromSessionId({ - sessionId: raw, - alias: params.alias, - mainKey: params.mainKey, - requesterInternalKey: params.requesterInternalKey, - restrictToSpawned: params.restrictToSpawned, - }); - } - - const resolvedKey = resolveInternalSessionKey({ - key: raw, - alias: params.alias, - mainKey: params.mainKey, - }); - const displayKey = resolveDisplaySessionKey({ - key: resolvedKey, - alias: params.alias, - mainKey: params.mainKey, - }); - return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false }; -} - export function classifySessionKind(params: { key: string; gatewayKind?: string | null; diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index fa9d8eac6fe..dae466b7230 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -3,15 +3,14 @@ import type { AnyAgentTool } from "./common.js"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { truncateUtf16Safe } from "../../utils.js"; import { jsonResult, readStringParam } from "./common.js"; import { + createSessionVisibilityGuard, createAgentToAgentPolicy, - listSpawnedSessionKeys, + isRequesterSpawnedSessionVisible, resolveEffectiveSessionToolsVisibility, resolveSessionReference, - SessionListRow, resolveSandboxedSessionToolContext, stripToolMessages, } from "./sessions-helpers.js"; @@ -149,26 +148,6 @@ function enforceSessionsHistoryHardCap(params: { return { items: placeholder, bytes: jsonUtf8Bytes(placeholder), hardCapped: true }; } -async function isSpawnedSessionAllowed(params: { - requesterSessionKey: string; - targetSessionKey: string; -}): Promise { - try { - const list = await callGateway<{ sessions: Array }>({ - method: "sessions.list", - params: { - includeGlobal: false, - includeUnknown: false, - limit: 500, - spawnedBy: params.requesterSessionKey, - }, - }); - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - return sessions.some((entry) => entry?.key === params.targetSessionKey); - } catch { - return false; - } -} export function createSessionsHistoryTool(opts?: { agentSessionKey?: string; sandboxed?: boolean; @@ -184,13 +163,12 @@ export function createSessionsHistoryTool(opts?: { required: true, }); const cfg = loadConfig(); - const { mainKey, alias, requesterInternalKey, restrictToSpawned } = + const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } = resolveSandboxedSessionToolContext({ cfg, agentSessionKey: opts?.agentSessionKey, sandboxed: opts?.sandboxed, }); - const effectiveRequesterKey = requesterInternalKey ?? alias; const resolvedSession = await resolveSessionReference({ sessionKey: sessionKeyParam, alias, @@ -206,7 +184,7 @@ export function createSessionsHistoryTool(opts?: { const displayKey = resolvedSession.displayKey; const resolvedViaSessionId = resolvedSession.resolvedViaSessionId; if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) { - const ok = await isSpawnedSessionAllowed({ + const ok = await isRequesterSpawnedSessionVisible({ requesterSessionKey: effectiveRequesterKey, targetSessionKey: resolvedKey, }); @@ -217,59 +195,25 @@ export function createSessionsHistoryTool(opts?: { }); } } + + const a2aPolicy = createAgentToAgentPolicy(cfg); const visibility = resolveEffectiveSessionToolsVisibility({ cfg, sandboxed: opts?.sandboxed === true, }); - - const a2aPolicy = createAgentToAgentPolicy(cfg); - const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey); - const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey); - const isCrossAgent = requesterAgentId !== targetAgentId; - if (isCrossAgent && visibility !== "all") { + const visibilityGuard = await createSessionVisibilityGuard({ + action: "history", + requesterSessionKey: effectiveRequesterKey, + visibility, + a2aPolicy, + }); + const access = visibilityGuard.check(resolvedKey); + if (!access.allowed) { return jsonResult({ - status: "forbidden", - error: - "Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.", + status: access.status, + error: access.error, }); } - if (isCrossAgent) { - if (!a2aPolicy.enabled) { - return jsonResult({ - status: "forbidden", - error: - "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", - }); - } - if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - return jsonResult({ - status: "forbidden", - error: "Agent-to-agent history denied by tools.agentToAgent.allow.", - }); - } - } - - if (!isCrossAgent) { - if (visibility === "self" && resolvedKey !== effectiveRequesterKey) { - return jsonResult({ - status: "forbidden", - error: - "Session history visibility is restricted to the current session (tools.sessions.visibility=self).", - }); - } - if (visibility === "tree" && resolvedKey !== effectiveRequesterKey) { - const spawned = await listSpawnedSessionKeys({ - requesterSessionKey: effectiveRequesterKey, - }); - if (!spawned.has(resolvedKey)) { - return jsonResult({ - status: "forbidden", - error: - "Session history visibility is restricted to the current session tree (tools.sessions.visibility=tree).", - }); - } - } - } const limit = typeof params.limit === "number" && Number.isFinite(params.limit) diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index ba05a93a59f..277b95f3c27 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -7,10 +7,10 @@ import { callGateway } from "../../gateway/call.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { jsonResult, readStringArrayParam } from "./common.js"; import { + createSessionVisibilityGuard, createAgentToAgentPolicy, classifySessionKind, deriveChannel, - listSpawnedSessionKeys, resolveDisplaySessionKey, resolveEffectiveSessionToolsVisibility, resolveInternalSessionKey, @@ -86,12 +86,14 @@ export function createSessionsListTool(opts?: { const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const storePath = typeof list?.path === "string" ? list.path : undefined; const a2aPolicy = createAgentToAgentPolicy(cfg); - const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey); + const visibilityGuard = await createSessionVisibilityGuard({ + action: "list", + requesterSessionKey: effectiveRequesterKey, + visibility, + a2aPolicy, + }); const rows: SessionListRow[] = []; - const spawnedKeys = - visibility === "tree" - ? await listSpawnedSessionKeys({ requesterSessionKey: effectiveRequesterKey }) - : null; + const historyTargets: Array<{ row: SessionListRow; resolvedKey: string }> = []; for (const entry of sessions) { if (!entry || typeof entry !== "object") { @@ -101,23 +103,9 @@ export function createSessionsListTool(opts?: { if (!key) { continue; } - - const entryAgentId = resolveAgentIdFromSessionKey(key); - const crossAgent = entryAgentId !== requesterAgentId; - if (crossAgent) { - if (visibility !== "all") { - continue; - } - if (!a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) { - continue; - } - } else { - if (visibility === "self" && key !== effectiveRequesterKey) { - continue; - } - if (visibility === "tree" && key !== effectiveRequesterKey && !spawnedKeys?.has(key)) { - continue; - } + const access = visibilityGuard.check(key); + if (!access.allowed) { + continue; } if (key === "unknown") { @@ -211,25 +199,41 @@ export function createSessionsListTool(opts?: { lastAccountId, transcriptPath, }; - if (messageLimit > 0) { const resolvedKey = resolveInternalSessionKey({ key: displayKey, alias, mainKey, }); - const history = await callGateway<{ messages: Array }>({ - method: "chat.history", - params: { sessionKey: resolvedKey, limit: messageLimit }, - }); - const rawMessages = Array.isArray(history?.messages) ? history.messages : []; - const filtered = stripToolMessages(rawMessages); - row.messages = filtered.length > messageLimit ? filtered.slice(-messageLimit) : filtered; + historyTargets.push({ row, resolvedKey }); } - rows.push(row); } + if (messageLimit > 0 && historyTargets.length > 0) { + const maxConcurrent = Math.min(4, historyTargets.length); + let index = 0; + const worker = async () => { + while (true) { + const next = index; + index += 1; + if (next >= historyTargets.length) { + return; + } + const target = historyTargets[next]; + const history = await callGateway<{ messages: Array }>({ + method: "chat.history", + params: { sessionKey: target.resolvedKey, limit: messageLimit }, + }); + const rawMessages = Array.isArray(history?.messages) ? history.messages : []; + const filtered = stripToolMessages(rawMessages); + target.row.messages = + filtered.length > messageLimit ? filtered.slice(-messageLimit) : filtered; + } + }; + await Promise.all(Array.from({ length: maxConcurrent }, () => worker())); + } + return jsonResult({ count: rows.length, sessions: rows, diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts new file mode 100644 index 00000000000..b3539d08d8f --- /dev/null +++ b/src/agents/tools/sessions-resolution.ts @@ -0,0 +1,257 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { callGateway } from "../../gateway/call.js"; +import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; + +function normalizeKey(value?: string) { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveMainSessionAlias(cfg: OpenClawConfig) { + const mainKey = normalizeMainKey(cfg.session?.mainKey); + const scope = cfg.session?.scope ?? "per-sender"; + const alias = scope === "global" ? "global" : mainKey; + return { mainKey, alias, scope }; +} + +export function resolveDisplaySessionKey(params: { key: string; alias: string; mainKey: string }) { + if (params.key === params.alias) { + return "main"; + } + if (params.key === params.mainKey) { + return "main"; + } + return params.key; +} + +export function resolveInternalSessionKey(params: { key: string; alias: string; mainKey: string }) { + if (params.key === "main") { + return params.alias; + } + return params.key; +} + +export async function listSpawnedSessionKeys(params: { + requesterSessionKey: string; + limit?: number; +}): Promise> { + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? Math.max(1, Math.floor(params.limit)) + : 500; + try { + const list = await callGateway<{ sessions: Array<{ key?: unknown }> }>({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + limit, + spawnedBy: params.requesterSessionKey, + }, + }); + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + const keys = sessions + .map((entry) => (typeof entry?.key === "string" ? entry.key : "")) + .map((value) => value.trim()) + .filter(Boolean); + return new Set(keys); + } catch { + return new Set(); + } +} + +export async function isRequesterSpawnedSessionVisible(params: { + requesterSessionKey: string; + targetSessionKey: string; + limit?: number; +}): Promise { + if (params.requesterSessionKey === params.targetSessionKey) { + return true; + } + const keys = await listSpawnedSessionKeys({ + requesterSessionKey: params.requesterSessionKey, + limit: params.limit, + }); + return keys.has(params.targetSessionKey); +} + +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function looksLikeSessionId(value: string): boolean { + return SESSION_ID_RE.test(value.trim()); +} + +export function looksLikeSessionKey(value: string): boolean { + const raw = value.trim(); + if (!raw) { + return false; + } + // These are canonical key shapes that should never be treated as sessionIds. + if (raw === "main" || raw === "global" || raw === "unknown") { + return true; + } + if (isAcpSessionKey(raw)) { + return true; + } + if (raw.startsWith("agent:")) { + return true; + } + if (raw.startsWith("cron:") || raw.startsWith("hook:")) { + return true; + } + if (raw.startsWith("node-") || raw.startsWith("node:")) { + return true; + } + if (raw.includes(":group:") || raw.includes(":channel:")) { + return true; + } + return false; +} + +export function shouldResolveSessionIdInput(value: string): boolean { + // Treat anything that doesn't look like a well-formed key as a sessionId candidate. + return looksLikeSessionId(value) || !looksLikeSessionKey(value); +} + +export type SessionReferenceResolution = + | { + ok: true; + key: string; + displayKey: string; + resolvedViaSessionId: boolean; + } + | { ok: false; status: "error" | "forbidden"; error: string }; + +async function resolveSessionKeyFromSessionId(params: { + sessionId: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + try { + // Resolve via gateway so we respect store routing and visibility rules. + const result = await callGateway<{ key?: string }>({ + method: "sessions.resolve", + params: { + sessionId: params.sessionId, + spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, + includeGlobal: !params.restrictToSpawned, + includeUnknown: !params.restrictToSpawned, + }, + }); + const key = typeof result?.key === "string" ? result.key.trim() : ""; + if (!key) { + throw new Error( + `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, + ); + } + return { + ok: true, + key, + displayKey: resolveDisplaySessionKey({ + key, + alias: params.alias, + mainKey: params.mainKey, + }), + resolvedViaSessionId: true, + }; + } catch (err) { + if (params.restrictToSpawned) { + return { + ok: false, + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${params.sessionId}`, + }; + } + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + status: "error", + error: + message || + `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, + }; + } +} + +async function resolveSessionKeyFromKey(params: { + key: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + try { + // Try key-based resolution first so non-standard keys keep working. + const result = await callGateway<{ key?: string }>({ + method: "sessions.resolve", + params: { + key: params.key, + spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined, + }, + }); + const key = typeof result?.key === "string" ? result.key.trim() : ""; + if (!key) { + return null; + } + return { + ok: true, + key, + displayKey: resolveDisplaySessionKey({ + key, + alias: params.alias, + mainKey: params.mainKey, + }), + resolvedViaSessionId: false, + }; + } catch { + return null; + } +} + +export async function resolveSessionReference(params: { + sessionKey: string; + alias: string; + mainKey: string; + requesterInternalKey?: string; + restrictToSpawned: boolean; +}): Promise { + const raw = params.sessionKey.trim(); + if (shouldResolveSessionIdInput(raw)) { + // Prefer key resolution to avoid misclassifying custom keys as sessionIds. + const resolvedByKey = await resolveSessionKeyFromKey({ + key: raw, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + }); + if (resolvedByKey) { + return resolvedByKey; + } + return await resolveSessionKeyFromSessionId({ + sessionId: raw, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + restrictToSpawned: params.restrictToSpawned, + }); + } + + const resolvedKey = resolveInternalSessionKey({ + key: raw, + alias: params.alias, + mainKey: params.mainKey, + }); + const displayKey = resolveDisplaySessionKey({ + key: resolvedKey, + alias: params.alias, + mainKey: params.mainKey, + }); + return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false }; +} + +export function normalizeOptionalKey(value?: string) { + return normalizeKey(value); +} diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index dd8ea3f7620..505201cadb4 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -3,11 +3,7 @@ import crypto from "node:crypto"; import type { AnyAgentTool } from "./common.js"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; -import { - isSubagentSessionKey, - normalizeAgentId, - resolveAgentIdFromSessionKey, -} from "../../routing/session-key.js"; +import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js"; import { type GatewayMessageChannel, @@ -16,13 +12,13 @@ import { import { AGENT_LANE_NESTED } from "../lanes.js"; import { jsonResult, readStringParam } from "./common.js"; import { + createSessionVisibilityGuard, createAgentToAgentPolicy, extractAssistantText, - listSpawnedSessionKeys, + isRequesterSpawnedSessionVisible, resolveEffectiveSessionToolsVisibility, - resolveInternalSessionKey, - resolveMainSessionAlias, resolveSessionReference, + resolveSandboxedSessionToolContext, stripToolMessages, } from "./sessions-helpers.js"; import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./sessions-send-helpers.js"; @@ -51,21 +47,12 @@ export function createSessionsSendTool(opts?: { const params = args as Record; const message = readStringParam(params, "message", { required: true }); const cfg = loadConfig(); - const { mainKey, alias } = resolveMainSessionAlias(cfg); - const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; - const requesterKeyInput = - typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() - ? opts.agentSessionKey - : "main"; - const requesterInternalKey = resolveInternalSessionKey({ - key: requesterKeyInput, - alias, - mainKey, - }); - const restrictToSpawned = - opts?.sandboxed === true && - visibility === "spawned" && - !isSubagentSessionKey(requesterInternalKey); + const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } = + resolveSandboxedSessionToolContext({ + cfg, + agentSessionKey: opts?.agentSessionKey, + sandboxed: opts?.sandboxed, + }); const a2aPolicy = createAgentToAgentPolicy(cfg); const sessionVisibility = resolveEffectiveSessionToolsVisibility({ @@ -84,30 +71,14 @@ export function createSessionsSendTool(opts?: { }); } - const listSessions = async (listParams: Record) => { - const result = await callGateway<{ sessions: Array<{ key: string }> }>({ - method: "sessions.list", - params: listParams, - timeoutMs: 10_000, - }); - return Array.isArray(result?.sessions) ? result.sessions : []; - }; - let sessionKey = sessionKeyParam; if (!sessionKey && labelParam) { - const requesterAgentId = requesterInternalKey - ? resolveAgentIdFromSessionKey(requesterInternalKey) - : undefined; + const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey); const requestedAgentId = labelAgentIdParam ? normalizeAgentId(labelAgentIdParam) : undefined; - if ( - restrictToSpawned && - requestedAgentId && - requesterAgentId && - requestedAgentId !== requesterAgentId - ) { + if (restrictToSpawned && requestedAgentId && requestedAgentId !== requesterAgentId) { return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", @@ -136,7 +107,7 @@ export function createSessionsSendTool(opts?: { const resolveParams: Record = { label: labelParam, ...(requestedAgentId ? { agentId: requestedAgentId } : {}), - ...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}), + ...(restrictToSpawned ? { spawnedBy: effectiveRequesterKey } : {}), }; let resolvedKey = ""; try { @@ -190,7 +161,7 @@ export function createSessionsSendTool(opts?: { sessionKey, alias, mainKey, - requesterInternalKey, + requesterInternalKey: effectiveRequesterKey, restrictToSpawned, }); if (!resolvedSession.ok) { @@ -205,14 +176,11 @@ export function createSessionsSendTool(opts?: { const displayKey = resolvedSession.displayKey; const resolvedViaSessionId = resolvedSession.resolvedViaSessionId; - if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== requesterInternalKey) { - const sessions = await listSessions({ - includeGlobal: false, - includeUnknown: false, - limit: 500, - spawnedBy: requesterInternalKey, + if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) { + const ok = await isRequesterSpawnedSessionVisible({ + requesterSessionKey: effectiveRequesterKey, + targetSessionKey: resolvedKey, }); - const ok = sessions.some((entry) => entry?.key === resolvedKey); if (!ok) { return jsonResult({ runId: crypto.randomUUID(), @@ -230,61 +198,21 @@ export function createSessionsSendTool(opts?: { const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs; const idempotencyKey = crypto.randomUUID(); let runId: string = idempotencyKey; - const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); - const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey); - const isCrossAgent = requesterAgentId !== targetAgentId; - if (isCrossAgent && sessionVisibility !== "all") { + const visibilityGuard = await createSessionVisibilityGuard({ + action: "send", + requesterSessionKey: effectiveRequesterKey, + visibility: sessionVisibility, + a2aPolicy, + }); + const access = visibilityGuard.check(resolvedKey); + if (!access.allowed) { return jsonResult({ runId: crypto.randomUUID(), - status: "forbidden", - error: - "Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.", + status: access.status, + error: access.error, sessionKey: displayKey, }); } - if (isCrossAgent) { - if (!a2aPolicy.enabled) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: - "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", - sessionKey: displayKey, - }); - } - if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: "Agent-to-agent messaging denied by tools.agentToAgent.allow.", - sessionKey: displayKey, - }); - } - } else { - if (sessionVisibility === "self" && resolvedKey !== requesterInternalKey) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: - "Session send visibility is restricted to the current session (tools.sessions.visibility=self).", - sessionKey: displayKey, - }); - } - if (sessionVisibility === "tree" && resolvedKey !== requesterInternalKey) { - const spawned = await listSpawnedSessionKeys({ - requesterSessionKey: requesterInternalKey, - }); - if (!spawned.has(resolvedKey)) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: - "Session send visibility is restricted to the current session tree (tools.sessions.visibility=tree).", - sessionKey: displayKey, - }); - } - } - } const agentMessageContext = buildAgentToAgentMessageContext({ requesterSessionKey: opts?.agentSessionKey, diff --git a/src/telegram/bot.create-telegram-bot.test-harness.ts b/src/telegram/bot.create-telegram-bot.test-harness.ts index b95341a77e0..2bb54660538 100644 --- a/src/telegram/bot.create-telegram-bot.test-harness.ts +++ b/src/telegram/bot.create-telegram-bot.test-harness.ts @@ -88,7 +88,7 @@ vi.mock("../auto-reply/skill-commands.js", () => ({ const systemEventsHoisted = vi.hoisted(() => ({ enqueueSystemEventSpy: vi.fn(), })); -export const enqueueSystemEventSpy = systemEventsHoisted.enqueueSystemEventSpy; +export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy; vi.mock("../infra/system-events.js", () => ({ enqueueSystemEvent: enqueueSystemEventSpy,