mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 07:51:41 +00:00
refactor(sessions): split access and resolution helpers
This commit is contained in:
240
src/agents/tools/sessions-access.ts
Normal file
240
src/agents/tools/sessions-access.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
import type { OpenClawConfig } from "../../config/config.js";
|
export type {
|
||||||
import { callGateway } from "../../gateway/call.js";
|
AgentToAgentPolicy,
|
||||||
import {
|
SessionAccessAction,
|
||||||
isAcpSessionKey,
|
SessionAccessResult,
|
||||||
isSubagentSessionKey,
|
SessionToolsVisibility,
|
||||||
normalizeMainKey,
|
} from "./sessions-access.js";
|
||||||
} from "../../routing/session-key.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 { sanitizeUserFacingText } from "../pi-embedded-helpers.js";
|
||||||
import {
|
import {
|
||||||
stripDowngradedToolCallText,
|
stripDowngradedToolCallText,
|
||||||
@@ -44,343 +63,11 @@ export type SessionListRow = {
|
|||||||
messages?: unknown[];
|
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) {
|
function normalizeKey(value?: string) {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
return trimmed ? trimmed : undefined;
|
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: {
|
export function classifySessionKind(params: {
|
||||||
key: string;
|
key: string;
|
||||||
gatewayKind?: string | null;
|
gatewayKind?: string | null;
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ import type { AnyAgentTool } from "./common.js";
|
|||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js";
|
import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js";
|
||||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
import {
|
import {
|
||||||
|
createSessionVisibilityGuard,
|
||||||
createAgentToAgentPolicy,
|
createAgentToAgentPolicy,
|
||||||
listSpawnedSessionKeys,
|
isRequesterSpawnedSessionVisible,
|
||||||
resolveEffectiveSessionToolsVisibility,
|
resolveEffectiveSessionToolsVisibility,
|
||||||
resolveSessionReference,
|
resolveSessionReference,
|
||||||
SessionListRow,
|
|
||||||
resolveSandboxedSessionToolContext,
|
resolveSandboxedSessionToolContext,
|
||||||
stripToolMessages,
|
stripToolMessages,
|
||||||
} from "./sessions-helpers.js";
|
} from "./sessions-helpers.js";
|
||||||
@@ -149,26 +148,6 @@ function enforceSessionsHistoryHardCap(params: {
|
|||||||
return { items: placeholder, bytes: jsonUtf8Bytes(placeholder), hardCapped: true };
|
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?: {
|
export function createSessionsHistoryTool(opts?: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
@@ -184,13 +163,12 @@ export function createSessionsHistoryTool(opts?: {
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const { mainKey, alias, requesterInternalKey, restrictToSpawned } =
|
const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } =
|
||||||
resolveSandboxedSessionToolContext({
|
resolveSandboxedSessionToolContext({
|
||||||
cfg,
|
cfg,
|
||||||
agentSessionKey: opts?.agentSessionKey,
|
agentSessionKey: opts?.agentSessionKey,
|
||||||
sandboxed: opts?.sandboxed,
|
sandboxed: opts?.sandboxed,
|
||||||
});
|
});
|
||||||
const effectiveRequesterKey = requesterInternalKey ?? alias;
|
|
||||||
const resolvedSession = await resolveSessionReference({
|
const resolvedSession = await resolveSessionReference({
|
||||||
sessionKey: sessionKeyParam,
|
sessionKey: sessionKeyParam,
|
||||||
alias,
|
alias,
|
||||||
@@ -206,7 +184,7 @@ export function createSessionsHistoryTool(opts?: {
|
|||||||
const displayKey = resolvedSession.displayKey;
|
const displayKey = resolvedSession.displayKey;
|
||||||
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
||||||
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) {
|
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) {
|
||||||
const ok = await isSpawnedSessionAllowed({
|
const ok = await isRequesterSpawnedSessionVisible({
|
||||||
requesterSessionKey: effectiveRequesterKey,
|
requesterSessionKey: effectiveRequesterKey,
|
||||||
targetSessionKey: resolvedKey,
|
targetSessionKey: resolvedKey,
|
||||||
});
|
});
|
||||||
@@ -217,59 +195,25 @@ export function createSessionsHistoryTool(opts?: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||||
const visibility = resolveEffectiveSessionToolsVisibility({
|
const visibility = resolveEffectiveSessionToolsVisibility({
|
||||||
cfg,
|
cfg,
|
||||||
sandboxed: opts?.sandboxed === true,
|
sandboxed: opts?.sandboxed === true,
|
||||||
});
|
});
|
||||||
|
const visibilityGuard = await createSessionVisibilityGuard({
|
||||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
action: "history",
|
||||||
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
|
requesterSessionKey: effectiveRequesterKey,
|
||||||
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
|
visibility,
|
||||||
const isCrossAgent = requesterAgentId !== targetAgentId;
|
a2aPolicy,
|
||||||
if (isCrossAgent && visibility !== "all") {
|
});
|
||||||
|
const access = visibilityGuard.check(resolvedKey);
|
||||||
|
if (!access.allowed) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
status: "forbidden",
|
status: access.status,
|
||||||
error:
|
error: access.error,
|
||||||
"Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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 =
|
const limit =
|
||||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { callGateway } from "../../gateway/call.js";
|
|||||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||||
import { jsonResult, readStringArrayParam } from "./common.js";
|
import { jsonResult, readStringArrayParam } from "./common.js";
|
||||||
import {
|
import {
|
||||||
|
createSessionVisibilityGuard,
|
||||||
createAgentToAgentPolicy,
|
createAgentToAgentPolicy,
|
||||||
classifySessionKind,
|
classifySessionKind,
|
||||||
deriveChannel,
|
deriveChannel,
|
||||||
listSpawnedSessionKeys,
|
|
||||||
resolveDisplaySessionKey,
|
resolveDisplaySessionKey,
|
||||||
resolveEffectiveSessionToolsVisibility,
|
resolveEffectiveSessionToolsVisibility,
|
||||||
resolveInternalSessionKey,
|
resolveInternalSessionKey,
|
||||||
@@ -86,12 +86,14 @@ export function createSessionsListTool(opts?: {
|
|||||||
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||||
const storePath = typeof list?.path === "string" ? list.path : undefined;
|
const storePath = typeof list?.path === "string" ? list.path : undefined;
|
||||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||||
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
|
const visibilityGuard = await createSessionVisibilityGuard({
|
||||||
|
action: "list",
|
||||||
|
requesterSessionKey: effectiveRequesterKey,
|
||||||
|
visibility,
|
||||||
|
a2aPolicy,
|
||||||
|
});
|
||||||
const rows: SessionListRow[] = [];
|
const rows: SessionListRow[] = [];
|
||||||
const spawnedKeys =
|
const historyTargets: Array<{ row: SessionListRow; resolvedKey: string }> = [];
|
||||||
visibility === "tree"
|
|
||||||
? await listSpawnedSessionKeys({ requesterSessionKey: effectiveRequesterKey })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
for (const entry of sessions) {
|
for (const entry of sessions) {
|
||||||
if (!entry || typeof entry !== "object") {
|
if (!entry || typeof entry !== "object") {
|
||||||
@@ -101,23 +103,9 @@ export function createSessionsListTool(opts?: {
|
|||||||
if (!key) {
|
if (!key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const access = visibilityGuard.check(key);
|
||||||
const entryAgentId = resolveAgentIdFromSessionKey(key);
|
if (!access.allowed) {
|
||||||
const crossAgent = entryAgentId !== requesterAgentId;
|
continue;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "unknown") {
|
if (key === "unknown") {
|
||||||
@@ -211,25 +199,41 @@ export function createSessionsListTool(opts?: {
|
|||||||
lastAccountId,
|
lastAccountId,
|
||||||
transcriptPath,
|
transcriptPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (messageLimit > 0) {
|
if (messageLimit > 0) {
|
||||||
const resolvedKey = resolveInternalSessionKey({
|
const resolvedKey = resolveInternalSessionKey({
|
||||||
key: displayKey,
|
key: displayKey,
|
||||||
alias,
|
alias,
|
||||||
mainKey,
|
mainKey,
|
||||||
});
|
});
|
||||||
const history = await callGateway<{ messages: Array<unknown> }>({
|
historyTargets.push({ row, resolvedKey });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push(row);
|
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({
|
return jsonResult({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
sessions: rows,
|
sessions: rows,
|
||||||
|
|||||||
257
src/agents/tools/sessions-resolution.ts
Normal file
257
src/agents/tools/sessions-resolution.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -3,11 +3,7 @@ import crypto from "node:crypto";
|
|||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
import {
|
import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||||
isSubagentSessionKey,
|
|
||||||
normalizeAgentId,
|
|
||||||
resolveAgentIdFromSessionKey,
|
|
||||||
} from "../../routing/session-key.js";
|
|
||||||
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
||||||
import {
|
import {
|
||||||
type GatewayMessageChannel,
|
type GatewayMessageChannel,
|
||||||
@@ -16,13 +12,13 @@ import {
|
|||||||
import { AGENT_LANE_NESTED } from "../lanes.js";
|
import { AGENT_LANE_NESTED } from "../lanes.js";
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
import {
|
import {
|
||||||
|
createSessionVisibilityGuard,
|
||||||
createAgentToAgentPolicy,
|
createAgentToAgentPolicy,
|
||||||
extractAssistantText,
|
extractAssistantText,
|
||||||
listSpawnedSessionKeys,
|
isRequesterSpawnedSessionVisible,
|
||||||
resolveEffectiveSessionToolsVisibility,
|
resolveEffectiveSessionToolsVisibility,
|
||||||
resolveInternalSessionKey,
|
|
||||||
resolveMainSessionAlias,
|
|
||||||
resolveSessionReference,
|
resolveSessionReference,
|
||||||
|
resolveSandboxedSessionToolContext,
|
||||||
stripToolMessages,
|
stripToolMessages,
|
||||||
} from "./sessions-helpers.js";
|
} from "./sessions-helpers.js";
|
||||||
import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./sessions-send-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 params = args as Record<string, unknown>;
|
||||||
const message = readStringParam(params, "message", { required: true });
|
const message = readStringParam(params, "message", { required: true });
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } =
|
||||||
const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
|
resolveSandboxedSessionToolContext({
|
||||||
const requesterKeyInput =
|
cfg,
|
||||||
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
|
agentSessionKey: opts?.agentSessionKey,
|
||||||
? opts.agentSessionKey
|
sandboxed: opts?.sandboxed,
|
||||||
: "main";
|
});
|
||||||
const requesterInternalKey = resolveInternalSessionKey({
|
|
||||||
key: requesterKeyInput,
|
|
||||||
alias,
|
|
||||||
mainKey,
|
|
||||||
});
|
|
||||||
const restrictToSpawned =
|
|
||||||
opts?.sandboxed === true &&
|
|
||||||
visibility === "spawned" &&
|
|
||||||
!isSubagentSessionKey(requesterInternalKey);
|
|
||||||
|
|
||||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||||
const sessionVisibility = resolveEffectiveSessionToolsVisibility({
|
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;
|
let sessionKey = sessionKeyParam;
|
||||||
if (!sessionKey && labelParam) {
|
if (!sessionKey && labelParam) {
|
||||||
const requesterAgentId = requesterInternalKey
|
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
|
||||||
? resolveAgentIdFromSessionKey(requesterInternalKey)
|
|
||||||
: undefined;
|
|
||||||
const requestedAgentId = labelAgentIdParam
|
const requestedAgentId = labelAgentIdParam
|
||||||
? normalizeAgentId(labelAgentIdParam)
|
? normalizeAgentId(labelAgentIdParam)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (
|
if (restrictToSpawned && requestedAgentId && requestedAgentId !== requesterAgentId) {
|
||||||
restrictToSpawned &&
|
|
||||||
requestedAgentId &&
|
|
||||||
requesterAgentId &&
|
|
||||||
requestedAgentId !== requesterAgentId
|
|
||||||
) {
|
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
runId: crypto.randomUUID(),
|
runId: crypto.randomUUID(),
|
||||||
status: "forbidden",
|
status: "forbidden",
|
||||||
@@ -136,7 +107,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
const resolveParams: Record<string, unknown> = {
|
const resolveParams: Record<string, unknown> = {
|
||||||
label: labelParam,
|
label: labelParam,
|
||||||
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
|
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
|
||||||
...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}),
|
...(restrictToSpawned ? { spawnedBy: effectiveRequesterKey } : {}),
|
||||||
};
|
};
|
||||||
let resolvedKey = "";
|
let resolvedKey = "";
|
||||||
try {
|
try {
|
||||||
@@ -190,7 +161,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
alias,
|
alias,
|
||||||
mainKey,
|
mainKey,
|
||||||
requesterInternalKey,
|
requesterInternalKey: effectiveRequesterKey,
|
||||||
restrictToSpawned,
|
restrictToSpawned,
|
||||||
});
|
});
|
||||||
if (!resolvedSession.ok) {
|
if (!resolvedSession.ok) {
|
||||||
@@ -205,14 +176,11 @@ export function createSessionsSendTool(opts?: {
|
|||||||
const displayKey = resolvedSession.displayKey;
|
const displayKey = resolvedSession.displayKey;
|
||||||
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
||||||
|
|
||||||
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== requesterInternalKey) {
|
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) {
|
||||||
const sessions = await listSessions({
|
const ok = await isRequesterSpawnedSessionVisible({
|
||||||
includeGlobal: false,
|
requesterSessionKey: effectiveRequesterKey,
|
||||||
includeUnknown: false,
|
targetSessionKey: resolvedKey,
|
||||||
limit: 500,
|
|
||||||
spawnedBy: requesterInternalKey,
|
|
||||||
});
|
});
|
||||||
const ok = sessions.some((entry) => entry?.key === resolvedKey);
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
runId: crypto.randomUUID(),
|
runId: crypto.randomUUID(),
|
||||||
@@ -230,61 +198,21 @@ export function createSessionsSendTool(opts?: {
|
|||||||
const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs;
|
const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs;
|
||||||
const idempotencyKey = crypto.randomUUID();
|
const idempotencyKey = crypto.randomUUID();
|
||||||
let runId: string = idempotencyKey;
|
let runId: string = idempotencyKey;
|
||||||
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
|
const visibilityGuard = await createSessionVisibilityGuard({
|
||||||
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
|
action: "send",
|
||||||
const isCrossAgent = requesterAgentId !== targetAgentId;
|
requesterSessionKey: effectiveRequesterKey,
|
||||||
if (isCrossAgent && sessionVisibility !== "all") {
|
visibility: sessionVisibility,
|
||||||
|
a2aPolicy,
|
||||||
|
});
|
||||||
|
const access = visibilityGuard.check(resolvedKey);
|
||||||
|
if (!access.allowed) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
runId: crypto.randomUUID(),
|
runId: crypto.randomUUID(),
|
||||||
status: "forbidden",
|
status: access.status,
|
||||||
error:
|
error: access.error,
|
||||||
"Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.",
|
|
||||||
sessionKey: displayKey,
|
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({
|
const agentMessageContext = buildAgentToAgentMessageContext({
|
||||||
requesterSessionKey: opts?.agentSessionKey,
|
requesterSessionKey: opts?.agentSessionKey,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ vi.mock("../auto-reply/skill-commands.js", () => ({
|
|||||||
const systemEventsHoisted = vi.hoisted(() => ({
|
const systemEventsHoisted = vi.hoisted(() => ({
|
||||||
enqueueSystemEventSpy: vi.fn(),
|
enqueueSystemEventSpy: vi.fn(),
|
||||||
}));
|
}));
|
||||||
export const enqueueSystemEventSpy = systemEventsHoisted.enqueueSystemEventSpy;
|
export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy;
|
||||||
|
|
||||||
vi.mock("../infra/system-events.js", () => ({
|
vi.mock("../infra/system-events.js", () => ({
|
||||||
enqueueSystemEvent: enqueueSystemEventSpy,
|
enqueueSystemEvent: enqueueSystemEventSpy,
|
||||||
|
|||||||
Reference in New Issue
Block a user