refactor(agents): dedupe workspace and session tool flows

This commit is contained in:
Peter Steinberger
2026-02-22 21:18:02 +00:00
parent 2f8c68ae4d
commit 06bdd53658
9 changed files with 227 additions and 128 deletions

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { writeSkill } from "./skills.e2e-test-helpers.js"; import { writeSkill } from "./skills.e2e-test-helpers.js";
import { buildWorkspaceSkillSnapshot } from "./skills.js"; import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./skills.js";
const tempDirs = createTrackedTempDirs(); const tempDirs = createTrackedTempDirs();
@@ -51,6 +51,40 @@ describe("buildWorkspaceSkillSnapshot", () => {
]); ]);
}); });
it("keeps prompt output aligned with buildWorkspaceSkillsPrompt", async () => {
const workspaceDir = await tempDirs.make("openclaw-");
await writeSkill({
dir: path.join(workspaceDir, "skills", "visible"),
name: "visible",
description: "Visible",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "hidden"),
name: "hidden",
description: "Hidden",
frontmatterExtra: "disable-model-invocation: true",
});
const config = {
skills: {
limits: {
maxSkillsInPrompt: 1,
maxSkillsPromptChars: 200,
},
},
} as const;
const opts = {
config,
managedSkillsDir: path.join(workspaceDir, ".managed"),
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
eligibility: { remote: { note: "Remote note" } },
} as const;
const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, opts);
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, opts);
expect(snapshot.prompt).toBe(prompt);
});
it("truncates the skills prompt when it exceeds the configured char budget", async () => { it("truncates the skills prompt when it exceeds the configured char budget", async () => {
const workspaceDir = await tempDirs.make("openclaw-"); const workspaceDir = await tempDirs.make("openclaw-");

View File

@@ -445,45 +445,9 @@ function applySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawCon
export function buildWorkspaceSkillSnapshot( export function buildWorkspaceSkillSnapshot(
workspaceDir: string, workspaceDir: string,
opts?: { opts?: WorkspaceSkillBuildOptions & { snapshotVersion?: number },
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
/** If provided, only include skills with these names */
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
snapshotVersion?: number;
},
): SkillSnapshot { ): SkillSnapshot {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); const { eligible, prompt, resolvedSkills } = resolveWorkspaceSkillPromptState(workspaceDir, opts);
const eligible = filterSkillEntries(
skillEntries,
opts?.config,
opts?.skillFilter,
opts?.eligibility,
);
const promptEntries = eligible.filter(
(entry) => entry.invocation?.disableModelInvocation !== true,
);
const resolvedSkills = promptEntries.map((entry) => entry.skill);
const remoteNote = opts?.eligibility?.remote?.note?.trim();
const { skillsForPrompt, truncated } = applySkillsPromptLimits({
skills: resolvedSkills,
config: opts?.config,
});
const truncationNote = truncated
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.`
: "";
const prompt = [
remoteNote,
truncationNote,
formatSkillsForPrompt(compactSkillPaths(skillsForPrompt)),
]
.filter(Boolean)
.join("\n");
const skillFilter = normalizeSkillFilter(opts?.skillFilter); const skillFilter = normalizeSkillFilter(opts?.skillFilter);
return { return {
prompt, prompt,
@@ -500,16 +464,29 @@ export function buildWorkspaceSkillSnapshot(
export function buildWorkspaceSkillsPrompt( export function buildWorkspaceSkillsPrompt(
workspaceDir: string, workspaceDir: string,
opts?: { opts?: WorkspaceSkillBuildOptions,
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
/** If provided, only include skills with these names */
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
},
): string { ): string {
return resolveWorkspaceSkillPromptState(workspaceDir, opts).prompt;
}
type WorkspaceSkillBuildOptions = {
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
/** If provided, only include skills with these names */
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
};
function resolveWorkspaceSkillPromptState(
workspaceDir: string,
opts?: WorkspaceSkillBuildOptions,
): {
eligible: SkillEntry[];
prompt: string;
resolvedSkills: Skill[];
} {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries( const eligible = filterSkillEntries(
skillEntries, skillEntries,
@@ -529,9 +506,14 @@ export function buildWorkspaceSkillsPrompt(
const truncationNote = truncated const truncationNote = truncated
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.` ? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.`
: ""; : "";
return [remoteNote, truncationNote, formatSkillsForPrompt(compactSkillPaths(skillsForPrompt))] const prompt = [
remoteNote,
truncationNote,
formatSkillsForPrompt(compactSkillPaths(skillsForPrompt)),
]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
return { eligible, prompt, resolvedSkills };
} }
export function resolveSkillsPromptForRun(params: { export function resolveSkillsPromptForRun(params: {

View File

@@ -96,6 +96,18 @@ vi.mock("./common.js", async () => {
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { createBrowserTool } from "./browser-tool.js"; import { createBrowserTool } from "./browser-tool.js";
function mockSingleBrowserProxyNode() {
nodesUtilsMocks.listNodes.mockResolvedValue([
{
nodeId: "node-1",
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
},
]);
}
describe("browser tool snapshot maxChars", () => { describe("browser tool snapshot maxChars", () => {
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -210,15 +222,7 @@ describe("browser tool snapshot maxChars", () => {
}); });
it("routes to node proxy when target=node", async () => { it("routes to node proxy when target=node", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([ mockSingleBrowserProxyNode();
{
nodeId: "node-1",
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
},
]);
const tool = createBrowserTool(); const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", target: "node" }); await tool.execute?.("call-1", { action: "status", target: "node" });
@@ -234,15 +238,7 @@ describe("browser tool snapshot maxChars", () => {
}); });
it("keeps sandbox bridge url when node proxy is available", async () => { it("keeps sandbox bridge url when node proxy is available", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([ mockSingleBrowserProxyNode();
{
nodeId: "node-1",
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
},
]);
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.("call-1", { action: "status" }); await tool.execute?.("call-1", { action: "status" });
@@ -254,15 +250,7 @@ describe("browser tool snapshot maxChars", () => {
}); });
it("keeps chrome profile on host when node proxy is available", async () => { it("keeps chrome profile on host when node proxy is available", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([ mockSingleBrowserProxyNode();
{
nodeId: "node-1",
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
},
]);
const tool = createBrowserTool(); const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", profile: "chrome" }); await tool.execute?.("call-1", { action: "status", profile: "chrome" });

View File

@@ -54,6 +54,27 @@ function wrapBrowserExternalJson(params: {
}; };
} }
function formatTabsToolResult(tabs: unknown[]) {
const wrapped = wrapBrowserExternalJson({
kind: "tabs",
payload: { tabs },
includeWarning: false,
});
return {
content: [{ type: "text", text: wrapped.wrappedText }],
details: { ...wrapped.safeDetails, tabCount: tabs.length },
};
}
function readOptionalTargetAndTimeout(params: Record<string, unknown>) {
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const timeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
return { targetId, timeoutMs };
}
type BrowserProxyFile = { type BrowserProxyFile = {
path: string; path: string;
base64: string; base64: string;
@@ -359,27 +380,11 @@ export function createBrowserTool(opts?: {
profile, profile,
}); });
const tabs = (result as { tabs?: unknown[] }).tabs ?? []; const tabs = (result as { tabs?: unknown[] }).tabs ?? [];
const wrapped = wrapBrowserExternalJson({ return formatTabsToolResult(tabs);
kind: "tabs",
payload: { tabs },
includeWarning: false,
});
return {
content: [{ type: "text", text: wrapped.wrappedText }],
details: { ...wrapped.safeDetails, tabCount: tabs.length },
};
} }
{ {
const tabs = await browserTabs(baseUrl, { profile }); const tabs = await browserTabs(baseUrl, { profile });
const wrapped = wrapBrowserExternalJson({ return formatTabsToolResult(tabs);
kind: "tabs",
payload: { tabs },
includeWarning: false,
});
return {
content: [{ type: "text", text: wrapped.wrappedText }],
details: { ...wrapped.safeDetails, tabCount: tabs.length },
};
} }
case "open": { case "open": {
const targetUrl = readStringParam(params, "targetUrl", { const targetUrl = readStringParam(params, "targetUrl", {
@@ -712,11 +717,7 @@ export function createBrowserTool(opts?: {
const ref = readStringParam(params, "ref"); const ref = readStringParam(params, "ref");
const inputRef = readStringParam(params, "inputRef"); const inputRef = readStringParam(params, "inputRef");
const element = readStringParam(params, "element"); const element = readStringParam(params, "element");
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined; const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
const timeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
if (proxyRequest) { if (proxyRequest) {
const result = await proxyRequest({ const result = await proxyRequest({
method: "POST", method: "POST",
@@ -748,11 +749,7 @@ export function createBrowserTool(opts?: {
case "dialog": { case "dialog": {
const accept = Boolean(params.accept); const accept = Boolean(params.accept);
const promptText = typeof params.promptText === "string" ? params.promptText : undefined; const promptText = typeof params.promptText === "string" ? params.promptText : undefined;
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined; const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
const timeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
if (proxyRequest) { if (proxyRequest) {
const result = await proxyRequest({ const result = await proxyRequest({
method: "POST", method: "POST",

View File

@@ -15,6 +15,7 @@ export {
export type { SessionReferenceResolution } from "./sessions-resolution.js"; export type { SessionReferenceResolution } from "./sessions-resolution.js";
export { export {
isRequesterSpawnedSessionVisible, isRequesterSpawnedSessionVisible,
isResolvedSessionVisibleToRequester,
listSpawnedSessionKeys, listSpawnedSessionKeys,
looksLikeSessionId, looksLikeSessionId,
looksLikeSessionKey, looksLikeSessionKey,
@@ -23,6 +24,7 @@ export {
resolveMainSessionAlias, resolveMainSessionAlias,
resolveSessionReference, resolveSessionReference,
shouldResolveSessionIdInput, shouldResolveSessionIdInput,
shouldVerifyRequesterSpawnedSessionVisibility,
} from "./sessions-resolution.js"; } from "./sessions-resolution.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { extractTextFromChatContent } from "../../shared/chat-content.js";
import { sanitizeUserFacingText } from "../pi-embedded-helpers.js"; import { sanitizeUserFacingText } from "../pi-embedded-helpers.js";

View File

@@ -8,7 +8,7 @@ import { jsonResult, readStringParam } from "./common.js";
import { import {
createSessionVisibilityGuard, createSessionVisibilityGuard,
createAgentToAgentPolicy, createAgentToAgentPolicy,
isRequesterSpawnedSessionVisible, isResolvedSessionVisibleToRequester,
resolveEffectiveSessionToolsVisibility, resolveEffectiveSessionToolsVisibility,
resolveSessionReference, resolveSessionReference,
resolveSandboxedSessionToolContext, resolveSandboxedSessionToolContext,
@@ -183,17 +183,18 @@ export function createSessionsHistoryTool(opts?: {
const resolvedKey = resolvedSession.key; const resolvedKey = resolvedSession.key;
const displayKey = resolvedSession.displayKey; const displayKey = resolvedSession.displayKey;
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId; const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) {
const ok = await isRequesterSpawnedSessionVisible({ const visible = await isResolvedSessionVisibleToRequester({
requesterSessionKey: effectiveRequesterKey, requesterSessionKey: effectiveRequesterKey,
targetSessionKey: resolvedKey, targetSessionKey: resolvedKey,
restrictToSpawned,
resolvedViaSessionId,
});
if (!visible) {
return jsonResult({
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKeyParam}`,
}); });
if (!ok) {
return jsonResult({
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKeyParam}`,
});
}
} }
const a2aPolicy = createAgentToAgentPolicy(cfg); const a2aPolicy = createAgentToAgentPolicy(cfg);

View File

@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { import {
isResolvedSessionVisibleToRequester,
looksLikeSessionId, looksLikeSessionId,
looksLikeSessionKey, looksLikeSessionKey,
resolveDisplaySessionKey, resolveDisplaySessionKey,
resolveInternalSessionKey, resolveInternalSessionKey,
resolveMainSessionAlias, resolveMainSessionAlias,
shouldVerifyRequesterSpawnedSessionVisibility,
shouldResolveSessionIdInput, shouldResolveSessionIdInput,
} from "./sessions-resolution.js"; } from "./sessions-resolution.js";
@@ -75,3 +77,59 @@ describe("session reference shape detection", () => {
expect(shouldResolveSessionIdInput("random-slug")).toBe(true); expect(shouldResolveSessionIdInput("random-slug")).toBe(true);
}); });
}); });
describe("resolved session visibility checks", () => {
it("requires spawned-session verification only for sandboxed key-based cross-session access", () => {
expect(
shouldVerifyRequesterSpawnedSessionVisibility({
requesterSessionKey: "agent:main:main",
targetSessionKey: "agent:main:worker",
restrictToSpawned: true,
resolvedViaSessionId: false,
}),
).toBe(true);
expect(
shouldVerifyRequesterSpawnedSessionVisibility({
requesterSessionKey: "agent:main:main",
targetSessionKey: "agent:main:worker",
restrictToSpawned: false,
resolvedViaSessionId: false,
}),
).toBe(false);
expect(
shouldVerifyRequesterSpawnedSessionVisibility({
requesterSessionKey: "agent:main:main",
targetSessionKey: "agent:main:worker",
restrictToSpawned: true,
resolvedViaSessionId: true,
}),
).toBe(false);
expect(
shouldVerifyRequesterSpawnedSessionVisibility({
requesterSessionKey: "agent:main:main",
targetSessionKey: "agent:main:main",
restrictToSpawned: true,
resolvedViaSessionId: false,
}),
).toBe(false);
});
it("returns true immediately when spawned-session verification is not required", async () => {
await expect(
isResolvedSessionVisibleToRequester({
requesterSessionKey: "agent:main:main",
targetSessionKey: "agent:main:main",
restrictToSpawned: true,
resolvedViaSessionId: false,
}),
).resolves.toBe(true);
await expect(
isResolvedSessionVisibleToRequester({
requesterSessionKey: "agent:main:main",
targetSessionKey: "agent:main:other",
restrictToSpawned: false,
resolvedViaSessionId: false,
}),
).resolves.toBe(true);
});
});

View File

@@ -75,6 +75,43 @@ export async function isRequesterSpawnedSessionVisible(params: {
return keys.has(params.targetSessionKey); return keys.has(params.targetSessionKey);
} }
export function shouldVerifyRequesterSpawnedSessionVisibility(params: {
requesterSessionKey: string;
targetSessionKey: string;
restrictToSpawned: boolean;
resolvedViaSessionId: boolean;
}): boolean {
return (
params.restrictToSpawned &&
!params.resolvedViaSessionId &&
params.requesterSessionKey !== params.targetSessionKey
);
}
export async function isResolvedSessionVisibleToRequester(params: {
requesterSessionKey: string;
targetSessionKey: string;
restrictToSpawned: boolean;
resolvedViaSessionId: boolean;
limit?: number;
}): Promise<boolean> {
if (
!shouldVerifyRequesterSpawnedSessionVisibility({
requesterSessionKey: params.requesterSessionKey,
targetSessionKey: params.targetSessionKey,
restrictToSpawned: params.restrictToSpawned,
resolvedViaSessionId: params.resolvedViaSessionId,
})
) {
return true;
}
return await isRequesterSpawnedSessionVisible({
requesterSessionKey: params.requesterSessionKey,
targetSessionKey: params.targetSessionKey,
limit: params.limit,
});
}
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; 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 { export function looksLikeSessionId(value: string): boolean {

View File

@@ -15,7 +15,7 @@ import {
createSessionVisibilityGuard, createSessionVisibilityGuard,
createAgentToAgentPolicy, createAgentToAgentPolicy,
extractAssistantText, extractAssistantText,
isRequesterSpawnedSessionVisible, isResolvedSessionVisibleToRequester,
resolveEffectiveSessionToolsVisibility, resolveEffectiveSessionToolsVisibility,
resolveSessionReference, resolveSessionReference,
resolveSandboxedSessionToolContext, resolveSandboxedSessionToolContext,
@@ -176,19 +176,19 @@ export function createSessionsSendTool(opts?: {
const displayKey = resolvedSession.displayKey; const displayKey = resolvedSession.displayKey;
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId; const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) { const visible = await isResolvedSessionVisibleToRequester({
const ok = await isRequesterSpawnedSessionVisible({ requesterSessionKey: effectiveRequesterKey,
requesterSessionKey: effectiveRequesterKey, targetSessionKey: resolvedKey,
targetSessionKey: resolvedKey, restrictToSpawned,
resolvedViaSessionId,
});
if (!visible) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: displayKey,
}); });
if (!ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: displayKey,
});
}
} }
const timeoutSeconds = const timeoutSeconds =
typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds)