Files
openclaw/src/plugin-sdk/session-visibility.ts
Peter Steinberger 42ecd5d95e fix(acpx): harden session lifecycle cleanup
Harden ACPX process cleanup with lease-backed ownership verification, startup orphan reaping, reusable cancel semantics, and spawned-session visibility fixes.
2026-05-07 07:30:37 +01:00

322 lines
11 KiB
TypeScript

import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway as defaultCallGateway } from "../gateway/call.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
type GatewayCaller = typeof defaultCallGateway;
let callGatewayForListSpawned: GatewayCaller = defaultCallGateway;
/** Test hook: must stay aligned with `sessions-resolution` `__testing.setDepsForTest`. */
export const sessionVisibilityGatewayTesting = {
setCallGatewayForListSpawned(overrides?: GatewayCaller) {
callGatewayForListSpawned = overrides ?? defaultCallGateway;
},
};
export type SessionToolsVisibility = "self" | "tree" | "agent" | "all";
export type AgentToAgentPolicy = {
enabled: boolean;
matchesAllow: (agentId: string) => boolean;
isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean;
};
export type SessionAccessAction = "history" | "send" | "list" | "status";
export type SessionAccessResult =
| { allowed: true }
| { allowed: false; error: string; status: "forbidden" };
export type SessionVisibilityRow = {
key: string;
agentId?: string;
ownerSessionKey?: string;
spawnedBy?: string;
parentSessionKey?: string;
};
export async function listSpawnedSessionKeys(params: {
requesterSessionKey: string;
limit?: number;
}): Promise<Set<string>> {
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: undefined;
try {
const list = await callGatewayForListSpawned<{ sessions: Array<{ key?: unknown }> }>({
method: "sessions.list",
params: {
includeGlobal: false,
includeUnknown: false,
...(limit !== undefined ? { limit } : {}),
spawnedBy: params.requesterSessionKey,
},
});
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const keys = sessions.map((entry) => normalizeOptionalString(entry?.key) ?? "").filter(Boolean);
return new Set(keys);
} catch {
return new Set();
}
}
export function resolveSessionToolsVisibility(cfg: OpenClawConfig): SessionToolsVisibility {
const raw = (cfg.tools as { sessions?: { visibility?: unknown } } | undefined)?.sessions
?.visibility;
const value = normalizeLowercaseStringOrEmpty(raw);
if (value === "self" || value === "tree" || value === "agent" || value === "all") {
return value;
}
return "tree";
}
export function resolveEffectiveSessionToolsVisibility(params: {
cfg: OpenClawConfig;
sandboxed: boolean;
}): SessionToolsVisibility {
const visibility = resolveSessionToolsVisibility(params.cfg);
if (!params.sandboxed) {
return visibility;
}
const sandboxClamp = params.cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
if (sandboxClamp === "spawned" && visibility !== "tree") {
return "tree";
}
return visibility;
}
export function resolveSandboxSessionToolsVisibility(cfg: OpenClawConfig): "spawned" | "all" {
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolicy {
const routingA2A = cfg.tools?.agentToAgent;
const enabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) {
return true;
}
return allowPatterns.some((pattern) => {
const raw =
normalizeOptionalString(typeof pattern === "string" ? pattern : String(pattern ?? "")) ??
"";
if (!raw) {
return false;
}
if (raw === "*") {
return true;
}
if (!raw.includes("*")) {
return raw === agentId;
}
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const isAllowed = (requesterAgentId: string, targetAgentId: string) => {
if (requesterAgentId === targetAgentId) {
return true;
}
if (!enabled) {
return false;
}
return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId);
};
return { enabled, matchesAllow, isAllowed };
}
function actionPrefix(action: SessionAccessAction): string {
if (action === "history") {
return "Session history";
}
if (action === "send") {
return "Session send";
}
if (action === "status") {
return "Session status";
}
return "Session list";
}
function a2aDisabledMessage(action: SessionAccessAction): string {
if (action === "history") {
return "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.";
}
if (action === "send") {
return "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.";
}
if (action === "status") {
return "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.";
}
return "Agent-to-agent listing is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent visibility.";
}
function a2aDeniedMessage(action: SessionAccessAction): string {
if (action === "history") {
return "Agent-to-agent history denied by tools.agentToAgent.allow.";
}
if (action === "send") {
return "Agent-to-agent messaging denied by tools.agentToAgent.allow.";
}
if (action === "status") {
return "Agent-to-agent status denied by tools.agentToAgent.allow.";
}
return "Agent-to-agent listing denied by tools.agentToAgent.allow.";
}
function crossVisibilityMessage(action: SessionAccessAction): string {
if (action === "history") {
return "Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.";
}
if (action === "send") {
return "Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.";
}
if (action === "status") {
return "Session status visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.";
}
return "Session list visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.";
}
function selfVisibilityMessage(action: SessionAccessAction): string {
return `${actionPrefix(action)} visibility is restricted to the current session (tools.sessions.visibility=self).`;
}
function treeVisibilityMessage(action: SessionAccessAction): string {
return `${actionPrefix(action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`;
}
export function createSessionVisibilityChecker(params: {
action: SessionAccessAction;
requesterSessionKey: string;
visibility: SessionToolsVisibility;
a2aPolicy: AgentToAgentPolicy;
spawnedKeys: Set<string> | null;
}): { check: (targetSessionKey: string) => SessionAccessResult } {
const spawnedKeys = params.spawnedKeys;
const rowChecker = createSessionVisibilityRowChecker({
action: params.action,
requesterSessionKey: params.requesterSessionKey,
visibility: params.visibility,
a2aPolicy: params.a2aPolicy,
});
const check = (targetSessionKey: string): SessionAccessResult => {
const isSpawnedSession = spawnedKeys?.has(targetSessionKey) === true;
return rowChecker.check({
key: targetSessionKey,
spawnedBy: isSpawnedSession ? params.requesterSessionKey : undefined,
});
};
return { check };
}
function rowOwnedByRequester(row: SessionVisibilityRow, requesterSessionKey: string): boolean {
return (
row.ownerSessionKey === requesterSessionKey ||
row.spawnedBy === requesterSessionKey ||
row.parentSessionKey === requesterSessionKey
);
}
export function createSessionVisibilityRowChecker(params: {
action: SessionAccessAction;
requesterSessionKey: string;
visibility: SessionToolsVisibility;
a2aPolicy: AgentToAgentPolicy;
}): { check: (row: SessionVisibilityRow) => SessionAccessResult } {
const requesterAgentId = resolveAgentIdFromSessionKey(params.requesterSessionKey);
const check = (row: SessionVisibilityRow): SessionAccessResult => {
const targetSessionKey = row.key;
const targetAgentId = row.agentId ?? resolveAgentIdFromSessionKey(targetSessionKey);
const isRequesterSession =
targetSessionKey === params.requesterSessionKey || targetSessionKey === "current";
const isRequesterOwned = rowOwnedByRequester(row, params.requesterSessionKey);
// Row ownership is stronger than agent ids: ACP children may use a backend
// agent id while still belonging to the requester that spawned them.
if (
!isRequesterSession &&
isRequesterOwned &&
(params.visibility === "tree" || params.visibility === "all")
) {
return { allowed: true };
}
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" && !isRequesterSession) {
return {
allowed: false,
status: "forbidden",
error: selfVisibilityMessage(params.action),
};
}
if (params.visibility === "tree" && !isRequesterSession && !isRequesterOwned) {
return {
allowed: false,
status: "forbidden",
error: treeVisibilityMessage(params.action),
};
}
return { allowed: true };
};
return { check };
}
export async function createSessionVisibilityGuard(params: {
action: SessionAccessAction;
requesterSessionKey: string;
visibility: SessionToolsVisibility;
a2aPolicy: AgentToAgentPolicy;
}): Promise<{
check: (targetSessionKey: string) => SessionAccessResult;
}> {
// Listing already has row ownership metadata; direct key actions still need
// this lookup until every caller can pass a normalized session row.
const spawnedKeys =
params.action !== "list" && (params.visibility === "tree" || params.visibility === "all")
? await listSpawnedSessionKeys({ requesterSessionKey: params.requesterSessionKey })
: null;
return createSessionVisibilityChecker({
action: params.action,
requesterSessionKey: params.requesterSessionKey,
visibility: params.visibility,
a2aPolicy: params.a2aPolicy,
spawnedKeys,
});
}