Compare commits

...

3 Commits

Author SHA1 Message Date
Vincent Koc
050c22668a ACP: strengthen allowlist regression coverage 2026-03-14 23:19:20 -07:00
Vincent Koc
6628db8d61 Changelog: note ACP allowAgents parity 2026-03-14 20:17:39 -07:00
Vincent Koc
924406472a ACP: honor per-agent allowlists for spawned sessions 2026-03-14 20:12:27 -07:00
3 changed files with 172 additions and 24 deletions

View File

@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
- 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)
- 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`.
- ACP/sessions_spawn: honor per-agent `allowAgents` limits for ACP-backed spawns, matching the existing subagent runtime behavior. Thanks @vincentkoc.
### Fixes

View File

@ -4,6 +4,23 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser
function createDefaultSpawnConfig(): OpenClawConfig {
return {
agents: {
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
},
acp: {
enabled: true,
backend: "acpx",
@ -82,35 +99,25 @@ function buildSessionBindingServiceMock() {
};
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => hoisted.state.cfg,
};
});
vi.mock("../config/config.js", () => ({
loadConfig: () => hoisted.state.cfg,
}));
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
}));
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts),
};
});
vi.mock("../config/sessions.js", () => ({
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts),
resolveAgentMainSessionKey: ({ agentId }: { agentId: string }) => `agent:${agentId}:main`,
canonicalizeMainSessionAlias: ({ sessionKey }: { sessionKey: string }) => sessionKey,
}));
vi.mock("../config/sessions/transcript.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions/transcript.js")>();
return {
...actual,
resolveSessionTranscriptFile: (params: unknown) =>
hoisted.resolveSessionTranscriptFileMock(params),
};
});
vi.mock("../config/sessions/transcript.js", () => ({
resolveSessionTranscriptFile: (params: unknown) =>
hoisted.resolveSessionTranscriptFileMock(params),
}));
vi.mock("../acp/control-plane/manager.js", () => {
return {
@ -650,6 +657,21 @@ describe("spawnAcpDirect", () => {
hoisted.state.cfg = {
...hoisted.state.cfg,
agents: {
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
defaults: {
heartbeat: {
every: "30m",
@ -728,6 +750,21 @@ describe("spawnAcpDirect", () => {
hoisted.state.cfg = {
...hoisted.state.cfg,
agents: {
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
defaults: {
heartbeat: {
every: "30m",
@ -762,6 +799,21 @@ describe("spawnAcpDirect", () => {
scope: "global",
},
agents: {
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
defaults: {
heartbeat: {
every: "30m",
@ -791,7 +843,21 @@ describe("spawnAcpDirect", () => {
hoisted.state.cfg = {
...hoisted.state.cfg,
agents: {
list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }],
list: [
{
id: "main",
heartbeat: { every: "30m" },
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
},
};
@ -819,6 +885,9 @@ describe("spawnAcpDirect", () => {
{
id: "research",
heartbeat: { every: "0m" },
subagents: {
allowAgents: ["codex"],
},
},
],
},
@ -1041,4 +1110,45 @@ describe("spawnAcpDirect", () => {
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
});
it("forbids ACP cross-agent spawning when the requester allowlist does not include the target", async () => {
hoisted.state.cfg = {
...createDefaultSpawnConfig(),
acp: {
enabled: true,
backend: "acpx",
allowedAgents: ["ops"],
},
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
{
id: "ops",
},
],
},
};
const result = await spawnAcpDirect(
{
task: "do thing",
agentId: "ops",
},
{
agentSessionKey: "agent:main:subagent:parent",
},
);
expect(result).toEqual({
status: "forbidden",
error: "agentId is not allowed for sessions_spawn (allowed: research)",
});
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
expect(hoisted.initializeSessionMock).not.toHaveBeenCalled();
});
});

View File

@ -256,6 +256,32 @@ function normalizeOptionalAgentId(value: string | undefined | null): string | un
return normalizeAgentId(trimmed);
}
function resolveCrossAgentAllowlistError(params: {
cfg: OpenClawConfig;
requesterAgentId?: string;
targetAgentId: string;
}): string | undefined {
const requesterAgentId = normalizeOptionalAgentId(params.requesterAgentId);
if (!requesterAgentId || requesterAgentId === params.targetAgentId) {
return undefined;
}
const allowAgents =
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
const allowAny = allowAgents.some((value) => value.trim() === "*");
const allowSet = new Set(
allowAgents
.filter((value) => value.trim() && value.trim() !== "*")
.map((value) => normalizeAgentId(value).toLowerCase()),
);
if (allowAny || allowSet.has(params.targetAgentId.toLowerCase())) {
return undefined;
}
const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none";
return `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`;
}
function summarizeError(err: unknown): string {
if (err instanceof Error) {
return err.message;
@ -506,6 +532,17 @@ export async function spawnAcpDirect(
};
}
const targetAgentId = targetAgentResult.agentId;
const crossAgentAllowlistError = resolveCrossAgentAllowlistError({
cfg,
requesterAgentId,
targetAgentId,
});
if (crossAgentAllowlistError) {
return {
status: "forbidden",
error: crossAgentAllowlistError,
};
}
const agentPolicyError = resolveAcpAgentPolicyError(cfg, targetAgentId);
if (agentPolicyError) {
return {