refactor(sessions): split access and resolution helpers

This commit is contained in:
Peter Steinberger
2026-02-16 03:56:39 +01:00
parent 2f621876f1
commit 1a03aad246
7 changed files with 604 additions and 544 deletions

View File

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

View File

@@ -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<Set<string>> {
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<SessionReferenceResolution> {
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<SessionReferenceResolution | null> {
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<SessionReferenceResolution> {
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;

View File

@@ -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<boolean> {
try {
const list = await callGateway<{ sessions: Array<SessionListRow> }>({
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)

View File

@@ -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<unknown> }>({
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<unknown> }>({
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,

View File

@@ -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<Set<string>> {
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<boolean> {
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<SessionReferenceResolution> {
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<SessionReferenceResolution | null> {
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<SessionReferenceResolution> {
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);
}

View File

@@ -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<string, unknown>;
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<string, unknown>) => {
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<string, unknown> = {
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,

View File

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