mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 14:45:46 +00:00
Subagents: restrict follow-up messaging scope (#46801)
* Subagents: restrict follow-up messaging scope * Subagents: cover foreign-session follow-up sends * Update CHANGELOG.md
This commit is contained in:
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
|
||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
|
||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||
|
||||
38
src/agents/subagent-control.test.ts
Normal file
38
src/agents/subagent-control.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { sendControlledSubagentMessage } from "./subagent-control.js";
|
||||
|
||||
describe("sendControlledSubagentMessage", () => {
|
||||
it("rejects runs controlled by another session", async () => {
|
||||
const result = await sendControlledSubagentMessage({
|
||||
cfg: {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig,
|
||||
controller: {
|
||||
controllerSessionKey: "agent:main:subagent:leaf",
|
||||
callerSessionKey: "agent:main:subagent:leaf",
|
||||
callerIsSubagent: true,
|
||||
controlScope: "children",
|
||||
},
|
||||
entry: {
|
||||
runId: "run-foreign",
|
||||
childSessionKey: "agent:main:subagent:other",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
controllerSessionKey: "agent:main:subagent:other-parent",
|
||||
task: "foreign run",
|
||||
cleanup: "keep",
|
||||
createdAt: Date.now() - 5_000,
|
||||
startedAt: Date.now() - 4_000,
|
||||
endedAt: Date.now() - 1_000,
|
||||
outcome: { status: "ok" },
|
||||
},
|
||||
message: "continue",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "forbidden",
|
||||
error: "Subagents can only control runs spawned from their own session.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -686,9 +686,24 @@ export async function steerControlledSubagentRun(params: {
|
||||
|
||||
export async function sendControlledSubagentMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
controller: ResolvedSubagentController;
|
||||
entry: SubagentRunRecord;
|
||||
message: string;
|
||||
}) {
|
||||
const ownershipError = ensureControllerOwnsRun({
|
||||
controller: params.controller,
|
||||
entry: params.entry,
|
||||
});
|
||||
if (ownershipError) {
|
||||
return { status: "forbidden" as const, error: ownershipError };
|
||||
}
|
||||
if (params.controller.controlScope !== "children") {
|
||||
return {
|
||||
status: "forbidden" as const,
|
||||
error: "Leaf subagents cannot control other sessions.",
|
||||
};
|
||||
}
|
||||
|
||||
const targetSessionKey = params.entry.childSessionKey;
|
||||
const parsed = parseAgentSessionKey(targetSessionKey);
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
|
||||
|
||||
@@ -37,8 +37,9 @@ export async function handleSubagentsSendAction(
|
||||
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
|
||||
}
|
||||
|
||||
const controller = resolveCommandSubagentController(params, ctx.requesterKey);
|
||||
|
||||
if (steerRequested) {
|
||||
const controller = resolveCommandSubagentController(params, ctx.requesterKey);
|
||||
const result = await steerControlledSubagentRun({
|
||||
cfg: params.cfg,
|
||||
controller,
|
||||
@@ -61,6 +62,7 @@ export async function handleSubagentsSendAction(
|
||||
|
||||
const result = await sendControlledSubagentMessage({
|
||||
cfg: params.cfg,
|
||||
controller,
|
||||
entry: targetResolution.entry,
|
||||
message,
|
||||
});
|
||||
@@ -70,6 +72,9 @@ export async function handleSubagentsSendAction(
|
||||
if (result.status === "error") {
|
||||
return stopWithText(`⚠️ Subagent error: ${result.error} (run ${result.runId.slice(0, 8)}).`);
|
||||
}
|
||||
if (result.status === "forbidden") {
|
||||
return stopWithText(`⚠️ ${result.error ?? "send failed"}`);
|
||||
}
|
||||
return stopWithText(
|
||||
result.replyText ??
|
||||
`✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`,
|
||||
|
||||
@@ -1887,6 +1887,53 @@ describe("handleCommands subagents", () => {
|
||||
expect(waitCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("blocks leaf subagents from sending to explicitly-owned child sessions", async () => {
|
||||
const leafKey = "agent:main:subagent:leaf";
|
||||
const childKey = `${leafKey}:subagent:child`;
|
||||
const storePath = path.join(testWorkspaceDir, "sessions-subagents-send-scope.json");
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[leafKey] = {
|
||||
sessionId: "leaf-session",
|
||||
updatedAt: Date.now(),
|
||||
spawnedBy: "agent:main:main",
|
||||
subagentRole: "leaf",
|
||||
subagentControlScope: "none",
|
||||
};
|
||||
store[childKey] = {
|
||||
sessionId: "child-session",
|
||||
updatedAt: Date.now(),
|
||||
spawnedBy: leafKey,
|
||||
subagentRole: "leaf",
|
||||
subagentControlScope: "none",
|
||||
};
|
||||
});
|
||||
addSubagentRunForTests({
|
||||
runId: "run-child-send",
|
||||
childSessionKey: childKey,
|
||||
requesterSessionKey: leafKey,
|
||||
requesterDisplayKey: leafKey,
|
||||
task: "child follow-up target",
|
||||
cleanup: "keep",
|
||||
createdAt: Date.now() - 20_000,
|
||||
startedAt: Date.now() - 20_000,
|
||||
endedAt: Date.now() - 1_000,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/subagents send 1 continue with follow-up details", cfg);
|
||||
params.sessionKey = leafKey;
|
||||
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Leaf subagents cannot control other sessions.");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("steers subagents via /steer alias", async () => {
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
|
||||
Reference in New Issue
Block a user