mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 22:56:49 +00:00
* refactor: share talk event metric extraction * refactor: reuse shared coercion helpers * refactor: reuse shared primitive guards * refactor: reuse shared record guard * refactor: reuse shared primitive helpers * refactor: reuse shared string guards * refactor: reuse shared non-empty string guard * refactor: share plugin primitive coercion helpers * refactor: reuse plugin coercion helpers * refactor: reuse plugin coercion helpers in more plugins * refactor: reuse channel coercion helpers * refactor: reuse monitor coercion helpers * refactor: reuse provider coercion helpers * refactor: reuse core coercion helpers * refactor: reuse runtime coercion helpers * refactor: reuse helper coercion in codex paths * refactor: reuse helper coercion in runtime paths * refactor: reuse codex app-server coercion helpers * refactor: reuse codex record helpers * refactor: reuse migration and qa record helpers * refactor: reuse feishu and core helper guards * refactor: reuse browser and policy coercion helpers * refactor: reuse memory wiki record helper * refactor: share boolean coercion helpers * refactor: reuse finite number coercion * refactor: reuse trimmed string list helpers * refactor: reuse string list normalization * refactor: reuse remaining string list helpers * refactor: reuse string entry normalizer * refactor: share sorted string helpers * refactor: share string list normalization * test: preserve command registry browser imports * refactor: reuse trimmed list helpers * refactor: reuse string dedupe helpers * refactor: reuse local dedupe helpers * refactor: reuse more string dedupe helpers * refactor: reuse command string dedupe helpers * refactor: dedupe memory path lists with helper * refactor: expose string dedupe helpers to plugins * refactor: reuse core string dedupe helpers * refactor: reuse shared unique value helpers * refactor: reuse unique helpers in agent utilities * refactor: reuse unique helpers in config plumbing * refactor: reuse unique helpers in extensions * refactor: reuse unique helpers in core utilities * refactor: reuse unique helpers in qa plugins * refactor: reuse unique helpers in memory plugins * refactor: reuse unique helpers in channel plugins * refactor: reuse unique helpers in core tails * refactor: reuse unique helper in comfy workflow * refactor: reuse unique helpers in test utilities * refactor: expose unique value helper to plugins * refactor: reuse unique helpers for numeric lists * refactor: replace index dedupe filters * refactor: reuse string entry normalization * refactor: reuse string normalization in plugin helpers * refactor: reuse string normalization in extension helpers * refactor: reuse string normalization in channel parsers * refactor: reuse string normalization in memory search * refactor: reuse string normalization in provider parsers * refactor: reuse string normalization in qa helpers * refactor: reuse string normalization in infra parsers * refactor: reuse string normalization in messaging parsers * refactor: reuse string normalization in core parsers * refactor: reuse string normalization in extension parsers * refactor: reuse string normalization in remaining parsers * refactor: reuse string normalization in final parser spots * refactor: reuse string normalization in qa media helpers * refactor: reuse normalization in provider and media lists * refactor: reuse normalization for remaining set filters * refactor: reuse normalization in policy allowlists * refactor: reuse normalization in session and owner lists * refactor: centralize primitive string lists * refactor: reuse lowercase entry helpers * refactor: reuse sorted string helpers * refactor: reuse unique trimmed helpers * refactor: reuse string normalization helpers * refactor: reuse catalog string helpers * refactor: reuse remaining string helpers * refactor: simplify remaining list normalization * refactor: reuse codex auth order normalization * chore: refresh plugin sdk api baseline * fix: make shared string sorting deterministic * chore: refresh plugin sdk api baseline * fix: align host env security ordering
385 lines
12 KiB
TypeScript
385 lines
12 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";
|
|
import { normalizeTrimmedStringList } from "../shared/string-normalization.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 = normalizeTrimmedStringList(sessions.map((entry) => entry?.key));
|
|
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";
|
|
}
|
|
|
|
type CompiledAgentAllowPattern =
|
|
| { kind: "all" }
|
|
| { kind: "deny" }
|
|
| { kind: "exact"; value: string }
|
|
| {
|
|
kind: "wildcard";
|
|
first: string;
|
|
last: string;
|
|
interior: string[];
|
|
};
|
|
|
|
function compileAgentAllowPattern(pattern: string): CompiledAgentAllowPattern {
|
|
const raw = normalizeOptionalString(pattern) ?? "";
|
|
if (!raw) {
|
|
return { kind: "deny" };
|
|
}
|
|
if (raw === "*") {
|
|
return { kind: "all" };
|
|
}
|
|
if (!raw.includes("*")) {
|
|
return { kind: "exact", value: raw };
|
|
}
|
|
const parts = raw.toLowerCase().split("*");
|
|
return {
|
|
kind: "wildcard",
|
|
first: parts[0] ?? "",
|
|
last: parts[parts.length - 1] ?? "",
|
|
interior: parts.slice(1, -1).filter(Boolean),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Linear-time case-insensitive glob matcher for precompiled `*` patterns.
|
|
* Checks prefix, suffix, then ordered interior segments without entering the
|
|
* regex engine, avoiding polynomial backtracking on repeated wildcards.
|
|
*/
|
|
function matchesCompiledWildcard(
|
|
pattern: Extract<CompiledAgentAllowPattern, { kind: "wildcard" }>,
|
|
lower: string,
|
|
): boolean {
|
|
let pos = 0;
|
|
if (pattern.first) {
|
|
if (!lower.startsWith(pattern.first)) {
|
|
return false;
|
|
}
|
|
pos = pattern.first.length;
|
|
}
|
|
|
|
const endBound = pattern.last ? lower.length - pattern.last.length : lower.length;
|
|
if (pattern.last && (!lower.endsWith(pattern.last) || endBound < pos)) {
|
|
return false;
|
|
}
|
|
|
|
for (const part of pattern.interior) {
|
|
const idx = lower.indexOf(part, pos);
|
|
if (idx === -1 || idx + part.length > endBound) {
|
|
return false;
|
|
}
|
|
pos = idx + part.length;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolicy {
|
|
const routingA2A = cfg.tools?.agentToAgent;
|
|
const enabled = routingA2A?.enabled === true;
|
|
const rawAllowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
|
|
const allowPatterns = rawAllowPatterns.map((pattern) => compileAgentAllowPattern(pattern));
|
|
const hasWildcardPatterns = allowPatterns.some((pattern) => pattern.kind === "wildcard");
|
|
const matchesAllow = (agentId: string) => {
|
|
if (allowPatterns.length === 0) {
|
|
return true;
|
|
}
|
|
const lowerAgentId = hasWildcardPatterns ? agentId.toLowerCase() : "";
|
|
return allowPatterns.some((pattern) => {
|
|
if (pattern.kind === "all") {
|
|
return true;
|
|
}
|
|
if (pattern.kind === "deny") {
|
|
return false;
|
|
}
|
|
if (pattern.kind === "exact") {
|
|
return pattern.value === agentId;
|
|
}
|
|
return matchesCompiledWildcard(pattern, lowerAgentId);
|
|
});
|
|
};
|
|
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,
|
|
});
|
|
}
|