fix(agents): enforce subagent envelope inheritance on ACP child sessions [AI-assisted] (#69383)

* fix: address issue

* fix: address review feedback

* fix: finalize issue changes

* fix: address PR review feedback

* address build faiure

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback
This commit is contained in:
Pavan Kumar Gondhi
2026-04-21 17:25:25 +05:30
committed by GitHub
parent 89b6d02481
commit 31160dc069
18 changed files with 997 additions and 30 deletions

View File

@@ -23,6 +23,14 @@ function createDefaultSpawnConfig(): OpenClawConfig {
backend: "acpx",
allowedAgents: ["codex"],
},
agents: {
defaults: {
subagents: {
allowAgents: ["codex"],
maxSpawnDepth: 2,
},
},
},
session: {
mainKey: "main",
scope: "per-sender",
@@ -61,6 +69,9 @@ const hoisted = vi.hoisted(() => {
});
const cleanupFailedAcpSpawnMock = vi.fn();
const createRunningTaskRunMock = vi.fn();
const countActiveRunsForSessionMock = vi.fn();
const getSubagentRunByChildSessionKeyMock = vi.fn();
const listTasksForOwnerKeyMock = vi.fn();
const state = {
cfg: createDefaultSpawnConfig(),
};
@@ -84,6 +95,9 @@ const hoisted = vi.hoisted(() => {
normalizeChannelIdMock,
cleanupFailedAcpSpawnMock,
createRunningTaskRunMock,
countActiveRunsForSessionMock,
getSubagentRunByChildSessionKeyMock,
listTasksForOwnerKeyMock,
state,
};
});
@@ -110,6 +124,11 @@ vi.mock("../config/sessions/store.js", () => ({
loadSessionStore: hoisted.loadSessionStoreMock,
}));
vi.mock("../config/sessions.js", () => ({
loadSessionStore: hoisted.loadSessionStoreMock,
resolveStorePath: hoisted.resolveStorePathMock,
}));
vi.mock("../config/sessions/transcript.js", () => ({
resolveSessionTranscriptFile: hoisted.resolveSessionTranscriptFileMock,
}));
@@ -131,6 +150,15 @@ vi.mock("./acp-spawn-parent-stream.js", () => ({
startAcpSpawnParentStreamRelay: hoisted.startAcpSpawnParentStreamRelayMock,
}));
vi.mock("./subagent-registry.js", () => ({
countActiveRunsForSession: hoisted.countActiveRunsForSessionMock,
getSubagentRunByChildSessionKey: hoisted.getSubagentRunByChildSessionKeyMock,
}));
vi.mock("../tasks/runtime-internal.js", () => ({
listTasksForOwnerKey: hoisted.listTasksForOwnerKeyMock,
}));
const { isSpawnAcpAcceptedResult, spawnAcpDirect } = await import("./acp-spawn.js");
type SpawnRequest = Parameters<typeof spawnAcpDirect>[0];
type SpawnContext = Parameters<typeof spawnAcpDirect>[1];
@@ -490,6 +518,9 @@ describe("spawnAcpDirect", () => {
hoisted.getLoadedChannelPluginMock.mockReset().mockReturnValue(undefined);
hoisted.cleanupFailedAcpSpawnMock.mockReset().mockResolvedValue(undefined);
hoisted.createRunningTaskRunMock.mockReset().mockReturnValue(undefined);
hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0);
hoisted.getSubagentRunByChildSessionKeyMock.mockReset().mockReturnValue(null);
hoisted.listTasksForOwnerKeyMock.mockReset().mockReturnValue([]);
hoisted.callGatewayMock.mockReset();
hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => {
@@ -687,6 +718,244 @@ describe("spawnAcpDirect", () => {
expect(transcriptCalls[1]?.threadId).toBe("child-thread");
});
it("inherits subagent envelope fields onto ACP children", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
subagents: {
...hoisted.state.cfg.agents?.defaults?.subagents,
maxSpawnDepth: 2,
},
},
},
});
const result = await spawnAcpDirect(createSpawnRequest(), {
...createRequesterContext(),
agentSessionKey: "agent:main:subagent:parent",
});
const accepted = expectAcceptedSpawn(result);
const patchCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "sessions.patch");
expect(patchCall?.params).toMatchObject({
key: accepted.childSessionKey,
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
subagentControlScope: "none",
});
});
it("rejects ACP spawns that exceed subagent max depth", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
subagents: {
...hoisted.state.cfg.agents?.defaults?.subagents,
maxSpawnDepth: 2,
},
},
},
});
const result = await spawnAcpDirect(createSpawnRequest(), {
...createRequesterContext(),
agentSessionKey: "agent:main:subagent:parent:subagent:leaf",
});
const failed = expectFailedSpawn(result, "forbidden");
expect(failed.errorCode).toBe("subagent_policy");
expect(failed.error).toContain("current depth: 2, max: 2");
});
it("rejects ACP spawns that exceed subagent child caps", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
subagents: {
...hoisted.state.cfg.agents?.defaults?.subagents,
maxChildrenPerAgent: 1,
},
},
},
});
hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(1);
const result = await spawnAcpDirect(createSpawnRequest(), {
...createRequesterContext(),
agentSessionKey: "agent:main:subagent:parent",
});
const failed = expectFailedSpawn(result, "forbidden");
expect(failed.errorCode).toBe("subagent_policy");
expect(failed.error).toContain("max active children");
});
it('counts streamTo="parent" ACP runs toward subagent child caps', async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
subagents: {
...hoisted.state.cfg.agents?.defaults?.subagents,
maxChildrenPerAgent: 1,
},
},
},
});
hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([
{
runtime: "acp",
status: "running",
childSessionKey: "agent:codex:acp:existing-parent-stream",
},
]);
const result = await spawnAcpDirect(
createSpawnRequest({
streamTo: "parent",
}),
{
...createRequesterContext(),
agentSessionKey: "agent:main:subagent:parent",
},
);
const failed = expectFailedSpawn(result, "forbidden");
expect(failed.errorCode).toBe("subagent_policy");
expect(failed.error).toContain("max active children");
});
it("does not double-count duplicate ACP task rows for the same child session", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
subagents: {
...hoisted.state.cfg.agents?.defaults?.subagents,
maxChildrenPerAgent: 2,
},
},
},
});
hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([
{
runtime: "acp",
status: "running",
childSessionKey: "agent:codex:acp:existing-parent-stream",
},
{
runtime: "acp",
status: "queued",
childSessionKey: "agent:codex:acp:existing-parent-stream",
},
]);
const result = await spawnAcpDirect(
createSpawnRequest({
streamTo: "parent",
}),
{
...createRequesterContext(),
agentSessionKey: "agent:main:subagent:parent",
},
);
expectAcceptedSpawn(result);
});
it("does not double-count ACP task rows for active registry-tracked ACP children", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
subagents: {
...hoisted.state.cfg.agents?.defaults?.subagents,
maxChildrenPerAgent: 2,
},
},
},
});
hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(1);
hoisted.getSubagentRunByChildSessionKeyMock.mockImplementationOnce((childSessionKey: string) =>
childSessionKey === "agent:codex:acp:existing-parent-stream"
? {
childSessionKey,
createdAt: Date.now(),
}
: null,
);
hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([
{
runtime: "acp",
status: "running",
childSessionKey: "agent:codex:acp:existing-parent-stream",
},
]);
const result = await spawnAcpDirect(
createSpawnRequest({
streamTo: "parent",
}),
{
...createRequesterContext(),
agentSessionKey: "agent:main:subagent:parent",
},
);
expectAcceptedSpawn(result);
});
it("rejects ACP spawns to agents outside the subagent allowlist", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
acp: {
...hoisted.state.cfg.acp,
allowedAgents: ["codex", "writer"],
},
agents: {
...hoisted.state.cfg.agents,
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "writer",
},
],
},
});
const result = await spawnAcpDirect(
createSpawnRequest({
agentId: "writer",
}),
{
...createRequesterContext(),
agentSessionKey: "agent:main:subagent:parent",
},
);
const failed = expectFailedSpawn(result, "forbidden");
expect(failed.errorCode).toBe("subagent_policy");
expect(failed.error).toContain("agentId is not allowed");
});
it("spawns Matrix thread-bound ACP sessions from top-level room targets", async () => {
enableMatrixAcpThreadBindings();
hoisted.sessionBindingBindMock.mockImplementationOnce(
@@ -1522,6 +1791,7 @@ describe("spawnAcpDirect", () => {
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
sandbox: { mode: "all" },
},
},
@@ -1623,6 +1893,7 @@ describe("spawnAcpDirect", () => {
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
heartbeat: {
every: "30m",
target: "last",
@@ -1701,11 +1972,81 @@ describe("spawnAcpDirect", () => {
expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1);
});
it("does not implicitly stream for ACP requester sessions inside a subagent envelope", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
heartbeat: {
every: "30m",
target: "last",
},
},
},
});
hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => {
const store: Record<
string,
{
sessionId: string;
updatedAt: number;
deliveryContext?: unknown;
spawnedBy?: string;
spawnDepth?: number;
subagentRole?: string;
subagentControlScope?: string;
}
> = {
"agent:main:acp:child": {
sessionId: "parent-sess-1",
updatedAt: Date.now(),
deliveryContext: {
channel: "discord",
to: "channel:parent-channel",
accountId: "default",
},
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
subagentControlScope: "none",
},
};
return new Proxy(store, {
get(target, prop) {
if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) {
return { sessionId: "sess-123", updatedAt: Date.now() };
}
return target[prop as keyof typeof target];
},
});
});
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
},
{
agentSessionKey: "agent:main:acp:child",
agentChannel: "discord",
agentAccountId: "default",
agentTo: "channel:parent-channel",
},
);
const accepted = expectAcceptedSpawn(result);
expect(accepted.mode).toBe("run");
expect(accepted.streamLogPath).toBeUndefined();
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
});
it("does not implicitly stream when heartbeat target is not session-local", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
heartbeat: {
every: "30m",
target: "discord",
@@ -1740,6 +2081,7 @@ describe("spawnAcpDirect", () => {
},
agents: {
defaults: {
...hoisted.state.cfg.agents?.defaults,
heartbeat: {
every: "30m",
target: "last",
@@ -1768,6 +2110,7 @@ describe("spawnAcpDirect", () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
...hoisted.state.cfg.agents,
list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }],
},
});
@@ -1792,6 +2135,7 @@ describe("spawnAcpDirect", () => {
replaceSpawnConfig({
...hoisted.state.cfg,
agents: {
...hoisted.state.cfg.agents,
list: [
{
id: "research",

View File

@@ -33,6 +33,10 @@ import {
resolveThreadBindingSpawnPolicy,
} from "../channels/thread-bindings-policy.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import {
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
} from "../config/agent-limits.js";
import { loadConfig } from "../config/config.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { loadSessionStore } from "../config/sessions/store.js";
@@ -60,6 +64,7 @@ import {
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { createRunningTaskRun } from "../tasks/detached-task-runtime.js";
import { listTasksForOwnerKey } from "../tasks/runtime-internal.js";
import {
deliveryContextFromSession,
formatConversationTarget,
@@ -75,6 +80,14 @@ import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js";
import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js";
import {
isSubagentEnvelopeSession,
resolveSubagentCapabilities,
resolveSubagentCapabilityStore,
type SessionCapabilityStore,
} from "./subagent-capabilities.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { countActiveRunsForSession, getSubagentRunByChildSessionKey } from "./subagent-registry.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
const log = createSubsystemLogger("agents/acp-spawn");
@@ -117,6 +130,7 @@ export const ACP_SPAWN_ERROR_CODES = [
"acp_disabled",
"requester_session_required",
"runtime_policy",
"subagent_policy",
"thread_required",
"target_agent_required",
"agent_forbidden",
@@ -216,6 +230,52 @@ type AcpSpawnStreamPlan = {
effectiveStreamToParent: boolean;
};
type AcpSubagentEnvelopeState = {
childSessionPatch?: {
spawnDepth: number;
subagentRole: "orchestrator" | "leaf" | null;
subagentControlScope: "children" | "none";
};
error?: string;
};
function isActiveTaskStatus(status: string | undefined): boolean {
return status === "queued" || status === "running";
}
function countUntrackedActiveAcpRunsForOwner(ownerKey: string | undefined): number {
const normalizedOwnerKey = normalizeOptionalString(ownerKey);
if (!normalizedOwnerKey) {
return 0;
}
const tasks = listTasksForOwnerKey(normalizedOwnerKey);
const trackedChildSessionKeys = new Set(
tasks
.filter(
(task) =>
task.runtime === "subagent" &&
isActiveTaskStatus(task.status) &&
normalizeOptionalString(task.childSessionKey),
)
.map((task) => normalizeOptionalString(task.childSessionKey) as string),
);
const activeAcpChildSessionKeys = new Set(
tasks.flatMap((task) => {
const childSessionKey = normalizeOptionalString(task.childSessionKey);
const trackedRun = childSessionKey ? getSubagentRunByChildSessionKey(childSessionKey) : null;
const hasActiveRegistryRun = Boolean(trackedRun && typeof trackedRun.endedAt !== "number");
return task.runtime === "acp" &&
isActiveTaskStatus(task.status) &&
childSessionKey !== undefined &&
!hasActiveRegistryRun &&
!trackedChildSessionKeys.has(childSessionKey)
? [childSessionKey]
: [];
}),
);
return activeAcpChildSessionKeys.size;
}
type AcpSpawnBootstrapDeliveryPlan = {
useInlineDelivery: boolean;
channel?: string;
@@ -658,6 +718,7 @@ function resolveAcpSpawnRequesterState(params: {
parentSessionKey?: string;
targetAgentId: string;
ctx: SpawnAcpContext;
subagentStore?: SessionCapabilityStore;
}): AcpSpawnRequesterState {
const bindingService = getSessionBindingService();
const requesterParsedSession = parseAgentSessionKey(params.parentSessionKey);
@@ -706,6 +767,94 @@ function resolveAcpSpawnRequesterState(params: {
};
}
function resolveAcpSubagentEnvelopeState(params: {
cfg: OpenClawConfig;
requesterSessionKey?: string;
targetAgentId: string;
requestedAgentId?: string;
subagentStore?: SessionCapabilityStore;
}): AcpSubagentEnvelopeState {
const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey);
if (!requesterSessionKey) {
return {};
}
if (
!isSubagentEnvelopeSession(requesterSessionKey, {
cfg: params.cfg,
store: params.subagentStore,
})
) {
return {};
}
const callerDepth = getSubagentDepthFromSessionStore(requesterSessionKey, {
cfg: params.cfg,
});
const maxSpawnDepth =
params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
if (callerDepth >= maxSpawnDepth) {
return {
error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxSpawnDepth})`,
};
}
const maxChildren =
params.cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ??
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT;
const activeChildren =
countActiveRunsForSession(requesterSessionKey) +
countUntrackedActiveAcpRunsForOwner(requesterSessionKey);
if (activeChildren >= maxChildren) {
return {
error: `sessions_spawn has reached max active children for this session (${activeChildren}/${maxChildren})`,
};
}
const requesterAgentId = normalizeAgentId(parseAgentSessionKey(requesterSessionKey)?.agentId);
const requireAgentId =
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.requireAgentId ??
params.cfg.agents?.defaults?.subagents?.requireAgentId ??
false;
if (requireAgentId && !params.requestedAgentId?.trim()) {
return {
error:
"sessions_spawn requires explicit agentId when requireAgentId is configured. Use agents_list to see allowed agent ids.",
};
}
if (params.targetAgentId !== requesterAgentId) {
const allowAgents =
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ??
params.cfg.agents?.defaults?.subagents?.allowAgents ??
[];
const allowAny = allowAgents.some((value) => value.trim() === "*");
const normalizedTargetId = normalizeOptionalLowercaseString(params.targetAgentId) ?? "";
const allowSet = new Set(
allowAgents
.filter((value) => value.trim() && value.trim() !== "*")
.map((value) => normalizeOptionalLowercaseString(normalizeAgentId(value)) ?? ""),
);
if (!allowAny && !allowSet.has(normalizedTargetId)) {
const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none";
return {
error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`,
};
}
}
const childCapabilities = resolveSubagentCapabilities({
depth: callerDepth + 1,
maxSpawnDepth,
});
return {
childSessionPatch: {
spawnDepth: childCapabilities.depth,
subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role,
subagentControlScope: childCapabilities.controlScope,
},
};
}
function resolveAcpSpawnStreamPlan(params: {
spawnMode: SpawnAcpMode;
requestThreadBinding: boolean;
@@ -1006,12 +1155,30 @@ export async function spawnAcpDirect(
error: agentPolicyError.message,
});
}
const subagentStore = resolveSubagentCapabilityStore(parentSessionKey, {
cfg,
});
const requesterState = resolveAcpSpawnRequesterState({
cfg,
parentSessionKey,
targetAgentId,
ctx,
subagentStore,
});
const subagentEnvelopeState = resolveAcpSubagentEnvelopeState({
cfg,
requesterSessionKey: requesterInternalKey,
targetAgentId,
requestedAgentId: params.agentId,
subagentStore,
});
if (subagentEnvelopeState.error) {
return createAcpSpawnFailure({
status: "forbidden",
errorCode: "subagent_policy",
error: subagentEnvelopeState.error,
});
}
const { effectiveStreamToParent } = resolveAcpSpawnStreamPlan({
spawnMode,
requestThreadBinding,
@@ -1070,6 +1237,7 @@ export async function spawnAcpDirect(
params: {
key: sessionKey,
spawnedBy: requesterInternalKey,
...subagentEnvelopeState.childSessionPatch,
...(params.label ? { label: params.label } : {}),
},
timeoutMs: 10_000,

View File

@@ -1,12 +1,15 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getPluginToolMeta } from "../../plugins/tools.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import {
resolveEffectiveToolPolicy,
resolveGroupContextFromSessionKey,
resolveGroupToolPolicy,
resolveSubagentToolPolicyForSession,
} from "../pi-tools.policy.js";
import {
isSubagentEnvelopeSession,
resolveSubagentCapabilityStore,
} from "../subagent-capabilities.js";
import {
applyToolPolicyPipeline,
buildDefaultToolPolicyPipelineSteps,
@@ -133,9 +136,18 @@ export function applyFinalEffectiveToolPolicy(
providerProfilePolicy,
providerProfileAlsoAllow,
);
const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, {
cfg: params.config,
});
const subagentPolicy =
isSubagentSessionKey(params.sessionKey) && params.sessionKey
? resolveSubagentToolPolicyForSession(params.config, params.sessionKey)
params.sessionKey &&
isSubagentEnvelopeSession(params.sessionKey, {
cfg: params.config,
store: subagentStore,
})
? resolveSubagentToolPolicyForSession(params.config, params.sessionKey, {
store: subagentStore,
})
: undefined;
const ownerFiltered = applyOwnerOnlyToolPolicy(
params.bundledTools,

View File

@@ -238,6 +238,183 @@ describe("createOpenClawCodingTools", () => {
}
});
it("applies subagent tool policy to ACP children spawned under a subagent envelope", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-subagent-policy-"));
try {
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const mainStorePath = storeTemplate.replaceAll("{agentId}", "main");
const writerStorePath = storeTemplate.replaceAll("{agentId}", "writer");
await fs.writeFile(
mainStorePath,
JSON.stringify(
{
"agent:main:acp:child": {
sessionId: "session-acp-child",
updatedAt: Date.now(),
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
subagentControlScope: "none",
},
"agent:main:acp:plain": {
sessionId: "session-acp-plain",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
"agent:main:acp:parent": {
sessionId: "session-acp-parent",
updatedAt: Date.now(),
spawnedBy: "agent:main:subagent:parent",
},
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
writerStorePath,
JSON.stringify(
{
"agent:writer:acp:child": {
sessionId: "session-acp-cross-agent-child",
updatedAt: Date.now(),
spawnedBy: "agent:main:acp:parent",
},
},
null,
2,
),
"utf-8",
);
const persistedEnvelopeTools = createOpenClawCodingTools({
sessionKey: "agent:main:acp:child",
config: {
session: {
store: storeTemplate,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
},
});
const persistedEnvelopeNames = new Set(persistedEnvelopeTools.map((tool) => tool.name));
expect(persistedEnvelopeNames.has("sessions_spawn")).toBe(false);
expect(persistedEnvelopeNames.has("sessions_list")).toBe(false);
expect(persistedEnvelopeNames.has("sessions_history")).toBe(false);
expect(persistedEnvelopeNames.has("subagents")).toBe(false);
const restrictedTools = createOpenClawCodingTools({
sessionKey: "agent:main:acp:plain",
config: {
session: {
store: storeTemplate,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
},
});
const restrictedNames = new Set(restrictedTools.map((tool) => tool.name));
expect(restrictedNames.has("sessions_spawn")).toBe(true);
expect(restrictedNames.has("subagents")).toBe(true);
const ancestryTools = createOpenClawCodingTools({
sessionKey: "agent:writer:acp:child",
config: {
session: {
store: storeTemplate,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
},
});
const ancestryNames = new Set(ancestryTools.map((tool) => tool.name));
expect(ancestryNames.has("sessions_spawn")).toBe(false);
expect(ancestryNames.has("sessions_list")).toBe(false);
expect(ancestryNames.has("sessions_history")).toBe(false);
expect(ancestryNames.has("subagents")).toBe(false);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("applies leaf tool policy for cross-agent subagent sessions when spawnDepth is missing", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cross-agent-subagent-"));
try {
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const mainStorePath = storeTemplate.replaceAll("{agentId}", "main");
const writerStorePath = storeTemplate.replaceAll("{agentId}", "writer");
await fs.writeFile(
mainStorePath,
JSON.stringify(
{
"agent:main:subagent:parent": {
sessionId: "session-main-parent",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
writerStorePath,
JSON.stringify(
{
"agent:writer:subagent:child": {
sessionId: "session-writer-child",
updatedAt: Date.now(),
spawnedBy: "agent:main:subagent:parent",
},
},
null,
2,
),
"utf-8",
);
const tools = createOpenClawCodingTools({
sessionKey: "agent:writer:subagent:child",
config: {
session: {
store: storeTemplate,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("sessions_list")).toBe(false);
expect(names.has("sessions_history")).toBe(false);
expect(names.has("subagents")).toBe(false);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("supports allow-only sub-agent tool policy", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:test",

View File

@@ -19,7 +19,9 @@ import type { AnyAgentTool } from "./pi-tools.types.js";
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
import type { SandboxToolPolicy } from "./sandbox.js";
import {
resolveSubagentCapabilityStore,
resolveStoredSubagentCapabilities,
type SessionCapabilityStore,
type SubagentSessionRole,
} from "./subagent-capabilities.js";
import { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js";
@@ -100,9 +102,19 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number):
export function resolveSubagentToolPolicyForSession(
cfg: OpenClawConfig | undefined,
sessionKey: string,
opts?: {
store?: SessionCapabilityStore;
},
): SandboxToolPolicy {
const configured = cfg?.tools?.subagents?.tools;
const capabilities = resolveStoredSubagentCapabilities(sessionKey, { cfg });
const store = resolveSubagentCapabilityStore(sessionKey, {
cfg,
store: opts?.store,
});
const capabilities = resolveStoredSubagentCapabilities(sessionKey, {
cfg,
store,
});
const allow = Array.isArray(configured?.allow) ? configured.allow : undefined;
const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined;
const explicitAllow = new Set(

View File

@@ -5,7 +5,6 @@ import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
@@ -49,6 +48,10 @@ import {
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxContext } from "./sandbox.js";
import {
isSubagentEnvelopeSession,
resolveSubagentCapabilityStore,
} from "./subagent-capabilities.js";
import {
EXEC_TOOL_DISPLAY_SUMMARY,
PROCESS_TOOL_DISPLAY_SUMMARY,
@@ -395,9 +398,18 @@ export function createOpenClawCodingTools(options?: {
// Fallback to agentId if no sessionKey is available (e.g. legacy or global contexts).
const scopeKey =
options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentStore = resolveSubagentCapabilityStore(options?.sessionKey, {
cfg: options?.config,
});
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
? resolveSubagentToolPolicyForSession(options.config, options.sessionKey)
options?.sessionKey &&
isSubagentEnvelopeSession(options.sessionKey, {
cfg: options.config,
store: subagentStore,
})
? resolveSubagentToolPolicyForSession(options.config, options.sessionKey, {
store: subagentStore,
})
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicyWithAlsoAllow,

View File

@@ -1,7 +1,11 @@
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isSubagentSessionKey, parseAgentSessionKey } from "../routing/session-key.js";
import {
isAcpSessionKey,
isSubagentSessionKey,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { normalizeSubagentSessionKey } from "./subagent-session-key.js";
@@ -12,13 +16,16 @@ export type SubagentSessionRole = (typeof SUBAGENT_SESSION_ROLES)[number];
export const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const;
export type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number];
type SessionCapabilityEntry = {
export type SessionCapabilityEntry = {
sessionId?: unknown;
spawnDepth?: unknown;
subagentRole?: unknown;
subagentControlScope?: unknown;
spawnedBy?: unknown;
};
export type SessionCapabilityStore = Record<string, SessionCapabilityEntry>;
function normalizeSubagentRole(value: unknown): SubagentSessionRole | undefined {
const trimmed = normalizeOptionalLowercaseString(value);
return SUBAGENT_SESSION_ROLES.find((entry) => entry === trimmed);
@@ -29,6 +36,20 @@ function normalizeSubagentControlScope(value: unknown): SubagentControlScope | u
return SUBAGENT_CONTROL_SCOPES.find((entry) => entry === trimmed);
}
function shouldInspectStoredSubagentEnvelope(sessionKey: string): boolean {
return isSubagentSessionKey(sessionKey) || isAcpSessionKey(sessionKey);
}
function isSameAgentSessionStore(leftSessionKey: string, rightSessionKey: string): boolean {
const leftAgentId = normalizeOptionalLowercaseString(
parseAgentSessionKey(leftSessionKey)?.agentId,
);
const rightAgentId = normalizeOptionalLowercaseString(
parseAgentSessionKey(rightSessionKey)?.agentId,
);
return Boolean(leftAgentId) && leftAgentId === rightAgentId;
}
function readSessionStore(storePath: string): Record<string, SessionCapabilityEntry> {
try {
return loadSessionStore(storePath);
@@ -38,7 +59,7 @@ function readSessionStore(storePath: string): Record<string, SessionCapabilityEn
}
function findEntryBySessionId(
store: Record<string, SessionCapabilityEntry>,
store: SessionCapabilityStore,
sessionId: string,
): SessionCapabilityEntry | undefined {
const normalizedSessionId = normalizeSubagentSessionKey(sessionId);
@@ -57,7 +78,7 @@ function findEntryBySessionId(
function resolveSessionCapabilityEntry(params: {
sessionKey: string;
cfg?: OpenClawConfig;
store?: Record<string, SessionCapabilityEntry>;
store?: SessionCapabilityStore;
}): SessionCapabilityEntry | undefined {
if (params.store) {
return params.store[params.sessionKey] ?? findEntryBySessionId(params.store, params.sessionKey);
@@ -74,6 +95,31 @@ function resolveSessionCapabilityEntry(params: {
return store[params.sessionKey] ?? findEntryBySessionId(store, params.sessionKey);
}
export function resolveSubagentCapabilityStore(
sessionKey: string | undefined | null,
opts?: {
cfg?: OpenClawConfig;
store?: SessionCapabilityStore;
},
): SessionCapabilityStore | undefined {
const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey);
if (!normalizedSessionKey) {
return opts?.store;
}
if (opts?.store) {
return opts.store;
}
if (!opts?.cfg || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
return undefined;
}
const parsed = parseAgentSessionKey(normalizedSessionKey);
if (!parsed?.agentId) {
return undefined;
}
const storePath = resolveStorePath(opts.cfg.session?.store, { agentId: parsed.agentId });
return readSessionStore(storePath);
}
export function resolveSubagentRoleForDepth(params: {
depth: number;
maxSpawnDepth?: number;
@@ -107,28 +153,122 @@ export function resolveSubagentCapabilities(params: { depth: number; maxSpawnDep
};
}
function isStoredSubagentEnvelopeSession(
params: {
sessionKey: string;
cfg?: OpenClawConfig;
store?: SessionCapabilityStore;
entry?: SessionCapabilityEntry;
},
visited = new Set<string>(),
): boolean {
const normalizedSessionKey = normalizeSubagentSessionKey(params.sessionKey);
if (!normalizedSessionKey || visited.has(normalizedSessionKey)) {
return false;
}
visited.add(normalizedSessionKey);
if (isSubagentSessionKey(normalizedSessionKey)) {
return true;
}
if (!isAcpSessionKey(normalizedSessionKey)) {
return false;
}
const entry =
params.entry ??
resolveSessionCapabilityEntry({
sessionKey: normalizedSessionKey,
cfg: params.cfg,
store: params.store,
});
if (
normalizeSubagentRole(entry?.subagentRole) ||
normalizeSubagentControlScope(entry?.subagentControlScope)
) {
return true;
}
const spawnedBy = normalizeSubagentSessionKey(entry?.spawnedBy);
if (!spawnedBy) {
return false;
}
const parentStore = isSameAgentSessionStore(normalizedSessionKey, spawnedBy)
? params.store
: undefined;
return isStoredSubagentEnvelopeSession(
{
sessionKey: spawnedBy,
cfg: params.cfg,
store: parentStore,
},
visited,
);
}
export function isSubagentEnvelopeSession(
sessionKey: string | undefined | null,
opts?: {
cfg?: OpenClawConfig;
store?: SessionCapabilityStore;
entry?: SessionCapabilityEntry;
},
): boolean {
const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey);
if (!normalizedSessionKey) {
return false;
}
if (isSubagentSessionKey(normalizedSessionKey)) {
return true;
}
if (!isAcpSessionKey(normalizedSessionKey)) {
return false;
}
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
return isStoredSubagentEnvelopeSession({
sessionKey: normalizedSessionKey,
cfg: opts?.cfg,
store,
entry: opts?.entry,
});
}
export function resolveStoredSubagentCapabilities(
sessionKey: string | undefined | null,
opts?: {
cfg?: OpenClawConfig;
store?: Record<string, SessionCapabilityEntry>;
store?: SessionCapabilityStore;
},
) {
const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey);
const maxSpawnDepth =
opts?.cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, {
cfg: opts?.cfg,
store: opts?.store,
});
if (!normalizedSessionKey || !isSubagentSessionKey(normalizedSessionKey)) {
if (!normalizedSessionKey) {
return resolveSubagentCapabilities({ depth: 0, maxSpawnDepth });
}
if (!shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, {
cfg: opts?.cfg,
store: opts?.store,
});
return resolveSubagentCapabilities({ depth, maxSpawnDepth });
}
const entry = resolveSessionCapabilityEntry({
sessionKey: normalizedSessionKey,
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
const entry = normalizedSessionKey
? resolveSessionCapabilityEntry({
sessionKey: normalizedSessionKey,
cfg: opts?.cfg,
store,
})
: undefined;
const depthStore = opts?.cfg && typeof entry?.spawnDepth !== "number" ? undefined : store;
const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, {
cfg: opts?.cfg,
store: opts?.store,
store: depthStore,
});
if (!isSubagentEnvelopeSession(normalizedSessionKey, { ...opts, store, entry })) {
return resolveSubagentCapabilities({ depth, maxSpawnDepth });
}
const storedRole = normalizeSubagentRole(entry?.subagentRole);
const storedControlScope = normalizeSubagentControlScope(entry?.subagentControlScope);
const fallback = resolveSubagentCapabilities({ depth, maxSpawnDepth });

View File

@@ -1,5 +1,8 @@
export { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js";
export { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
export {
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
} from "../config/agent-limits.js";
export { loadConfig } from "../config/config.js";
export { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
export { callGateway } from "../gateway/call.js";

View File

@@ -159,6 +159,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
params.emitSessionLifecycleEventMock?.(...args),
formatThinkingLevels: (levels: string[]) => levels.join(", "),
normalizeThinkLevel: (level: unknown) => normalizeOptionalString(level),
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT: 5,
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH: 3,
ADMIN_SCOPE: "operator.admin",
AGENT_LANE_SUBAGENT: "subagent",

View File

@@ -36,6 +36,7 @@ import {
import {
ADMIN_SCOPE,
AGENT_LANE_SUBAGENT,
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
buildSubagentSystemPrompt,
callGateway,
@@ -436,7 +437,8 @@ export async function spawnSubagentDirect(
};
}
const maxChildren = cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? 5;
const maxChildren =
cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT;
const activeChildren = countActiveRunsForSession(requesterInternalKey);
if (activeChildren >= maxChildren) {
return {

View File

@@ -29,7 +29,15 @@ describe("plugins cli list", () => {
await runPluginsCommand(["plugins", "list", "--json"]);
expect(buildPluginSnapshotReport).toHaveBeenCalledWith();
expect(buildPluginSnapshotReport).toHaveBeenCalledWith(
expect.objectContaining({
logger: expect.objectContaining({
info: expect.any(Function),
warn: expect.any(Function),
error: expect.any(Function),
}),
}),
);
expect(JSON.parse(runtimeLogs[0] ?? "null")).toEqual({
workspaceDir: "/workspace",

View File

@@ -16,6 +16,7 @@ import {
buildPluginSnapshotReport,
formatPluginCompatibilityNotice,
} from "../plugins/status.js";
import type { PluginLogger } from "../plugins/types.js";
import {
resolveUninstallChannelConfigKeys,
resolveUninstallDirectoryTarget,
@@ -66,6 +67,13 @@ export type PluginUninstallOptions = {
dryRun?: boolean;
};
const quietPluginJsonLogger: PluginLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function formatInspectSection(title: string, lines: string[]): string[] {
if (lines.length === 0) {
return [];
@@ -144,7 +152,9 @@ export function registerPluginsCli(program: Command) {
.option("--enabled", "Only show enabled plugins", false)
.option("--verbose", "Show detailed entries", false)
.action((opts: PluginsListOptions) => {
const report = buildPluginSnapshotReport();
const report = buildPluginSnapshotReport(
opts.json ? { logger: quietPluginJsonLogger } : undefined,
);
const list = opts.enabled
? report.plugins.filter((p) => p.status === "loaded")
: report.plugins;
@@ -246,7 +256,10 @@ export function registerPluginsCli(program: Command) {
.option("--json", "Print JSON")
.action((id: string | undefined, opts: PluginInspectOptions) => {
const cfg = loadConfig();
const report = buildPluginDiagnosticsReport({ config: cfg });
const report = buildPluginDiagnosticsReport({
config: cfg,
...(opts.json ? { logger: quietPluginJsonLogger } : {}),
});
if (opts.all) {
if (id) {
defaultRuntime.error("Pass either a plugin id or --all, not both.");
@@ -254,6 +267,7 @@ export function registerPluginsCli(program: Command) {
}
const inspectAll = buildAllPluginInspectReports({
config: cfg,
...(opts.json ? { logger: quietPluginJsonLogger } : {}),
report,
});
const inspectAllWithInstall = inspectAll.map((inspect) => ({
@@ -322,6 +336,7 @@ export function registerPluginsCli(program: Command) {
const inspect = buildPluginInspectReport({
id,
config: cfg,
...(opts.json ? { logger: quietPluginJsonLogger } : {}),
report,
});
if (!inspect) {

View File

@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "./types.js";
export const DEFAULT_AGENT_MAX_CONCURRENT = 4;
export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8;
export const DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT = 5;
// Keep depth-1 subagents as leaves unless config explicitly opts into nesting.
export const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 1;

View File

@@ -3,8 +3,12 @@ import { createOpenClawTools } from "../agents/openclaw-tools.js";
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicy,
resolveSubagentToolPolicyForSession,
} from "../agents/pi-tools.policy.js";
import {
isSubagentEnvelopeSession,
resolveSubagentCapabilityStore,
} from "../agents/subagent-capabilities.js";
import {
applyToolPolicyPipeline,
buildDefaultToolPolicyPipelineSteps,
@@ -18,7 +22,6 @@ import type { AnyAgentTool } from "../agents/tools/common.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
export type GatewayScopedToolSurface = "http" | "loopback";
@@ -61,8 +64,16 @@ export function resolveGatewayScopedTools(params: {
messageProvider: params.messageProvider,
accountId: params.accountId ?? null,
});
const subagentPolicy = isSubagentSessionKey(params.sessionKey)
? resolveSubagentToolPolicy(params.cfg)
const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, {
cfg: params.cfg,
});
const subagentPolicy = isSubagentEnvelopeSession(params.sessionKey, {
cfg: params.cfg,
store: subagentStore,
})
? resolveSubagentToolPolicyForSession(params.cfg, params.sessionKey, {
store: subagentStore,
})
: undefined;
const workspaceDir = resolveAgentWorkspaceDir(
params.cfg,

View File

@@ -79,6 +79,29 @@ describe("loadPluginMetadataRegistrySnapshot", () => {
);
});
it("forwards an explicit logger through metadata snapshots", () => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
loadPluginMetadataRegistrySnapshot({
config: { plugins: {} },
logger,
workspaceDir: "/workspace",
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: { plugins: {} },
logger,
workspaceDir: "/workspace",
mode: "validate",
}),
);
});
it("preserves explicit empty plugin scopes on metadata snapshots", () => {
loadPluginMetadataRegistrySnapshot({
config: { plugins: {} },

View File

@@ -2,12 +2,14 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadOpenClawPlugins } from "../loader.js";
import { hasExplicitPluginIdScope } from "../plugin-scope.js";
import type { PluginRegistry } from "../registry.js";
import type { PluginLogger } from "../types.js";
import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext } from "./load-context.js";
export function loadPluginMetadataRegistrySnapshot(options?: {
config?: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
logger?: PluginLogger;
workspaceDir?: string;
onlyPluginIds?: string[];
loadModules?: boolean;

View File

@@ -111,6 +111,7 @@ function expectPluginLoaderCall(params: {
autoEnabledReasons?: Record<string, string[]>;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: unknown;
loadModules?: boolean;
}) {
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
@@ -124,6 +125,7 @@ function expectPluginLoaderCall(params: {
: {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
...(params.env ? { env: params.env } : {}),
...(params.logger !== undefined ? { logger: params.logger } : {}),
...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}),
}),
);
@@ -134,6 +136,7 @@ function expectMetadataSnapshotLoaderCall(params: {
activationSourceConfig?: unknown;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: unknown;
loadModules?: boolean;
}) {
expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith(
@@ -144,6 +147,7 @@ function expectMetadataSnapshotLoaderCall(params: {
: {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
...(params.env ? { env: params.env } : {}),
...(params.logger !== undefined ? { logger: params.logger } : {}),
...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}),
}),
);
@@ -367,6 +371,27 @@ describe("plugin status reports", () => {
});
});
it("forwards an explicit logger to plugin loading", () => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
buildPluginSnapshotReport({
config: {},
logger,
workspaceDir: "/workspace",
});
expectMetadataSnapshotLoaderCall({
config: {},
logger,
workspaceDir: "/workspace",
loadModules: false,
});
});
it("uses a metadata snapshot load for snapshot reports", () => {
buildPluginSnapshotReport({ config: {}, workspaceDir: "/workspace" });

View File

@@ -26,7 +26,7 @@ import {
resolvePluginRuntimeLoadContext,
} from "./runtime/load-context.js";
import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js";
import type { PluginHookName } from "./types.js";
import type { PluginHookName, PluginLogger } from "./types.js";
export type PluginStatusReport = PluginRegistry & {
workspaceDir?: string;
@@ -134,6 +134,7 @@ type PluginReportParams = {
workspaceDir?: string;
/** Use an explicit env when plugin roots should resolve independently from process.env. */
env?: NodeJS.ProcessEnv;
logger?: PluginLogger;
};
function buildPluginReport(
@@ -143,6 +144,7 @@ function buildPluginReport(
const baseContext = resolvePluginRuntimeLoadContext({
config: params?.config ?? loadConfig(),
env: params?.env,
logger: params?.logger,
workspaceDir: params?.workspaceDir,
});
const workspaceDir = baseContext.workspaceDir ?? resolveDefaultAgentWorkspaceDir();
@@ -193,6 +195,7 @@ function buildPluginReport(
activationSourceConfig: rawConfig,
workspaceDir,
env: params?.env,
logger: params?.logger,
loadModules: false,
});
const importedPluginIds = new Set([
@@ -230,18 +233,21 @@ export function buildPluginInspectReport(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: PluginLogger;
report?: PluginStatusReport;
}): PluginInspectReport | null {
const rawConfig = params.config ?? loadConfig();
const config = resolvePluginRuntimeLoadContext({
config: rawConfig,
env: params.env,
logger: params.logger,
workspaceDir: params.workspaceDir,
}).config;
const report =
params.report ??
buildPluginDiagnosticsReport({
config: rawConfig,
logger: params.logger,
workspaceDir: params.workspaceDir,
env: params.env,
});
@@ -355,6 +361,7 @@ export function buildAllPluginInspectReports(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: PluginLogger;
report?: PluginStatusReport;
}): PluginInspectReport[] {
const rawConfig = params?.config ?? loadConfig();
@@ -362,6 +369,7 @@ export function buildAllPluginInspectReports(params?: {
params?.report ??
buildPluginDiagnosticsReport({
config: rawConfig,
logger: params?.logger,
workspaceDir: params?.workspaceDir,
env: params?.env,
});
@@ -371,6 +379,7 @@ export function buildAllPluginInspectReports(params?: {
buildPluginInspectReport({
id: plugin.id,
config: rawConfig,
logger: params?.logger,
report,
}),
)
@@ -381,6 +390,7 @@ export function buildPluginCompatibilityWarnings(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: PluginLogger;
report?: PluginStatusReport;
}): string[] {
return buildPluginCompatibilityNotices(params).map(formatPluginCompatibilityNotice);
@@ -390,6 +400,7 @@ export function buildPluginCompatibilityNotices(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: PluginLogger;
report?: PluginStatusReport;
}): PluginCompatibilityNotice[] {
return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility);