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. - 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) - 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`. - 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 ### Fixes

View File

@ -4,6 +4,23 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser
function createDefaultSpawnConfig(): OpenClawConfig { function createDefaultSpawnConfig(): OpenClawConfig {
return { return {
agents: {
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
},
acp: { acp: {
enabled: true, enabled: true,
backend: "acpx", backend: "acpx",
@ -82,35 +99,25 @@ function buildSessionBindingServiceMock() {
}; };
} }
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../config/config.js", () => ({
const actual = await importOriginal<typeof import("../config/config.js")>(); loadConfig: () => hoisted.state.cfg,
return { }));
...actual,
loadConfig: () => hoisted.state.cfg,
};
});
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
})); }));
vi.mock("../config/sessions.js", async (importOriginal) => { vi.mock("../config/sessions.js", () => ({
const actual = await importOriginal<typeof import("../config/sessions.js")>(); loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
return { resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts),
...actual, resolveAgentMainSessionKey: ({ agentId }: { agentId: string }) => `agent:${agentId}:main`,
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath), canonicalizeMainSessionAlias: ({ sessionKey }: { sessionKey: string }) => sessionKey,
resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts), }));
};
});
vi.mock("../config/sessions/transcript.js", async (importOriginal) => { vi.mock("../config/sessions/transcript.js", () => ({
const actual = await importOriginal<typeof import("../config/sessions/transcript.js")>(); resolveSessionTranscriptFile: (params: unknown) =>
return { hoisted.resolveSessionTranscriptFileMock(params),
...actual, }));
resolveSessionTranscriptFile: (params: unknown) =>
hoisted.resolveSessionTranscriptFileMock(params),
};
});
vi.mock("../acp/control-plane/manager.js", () => { vi.mock("../acp/control-plane/manager.js", () => {
return { return {
@ -650,6 +657,21 @@ describe("spawnAcpDirect", () => {
hoisted.state.cfg = { hoisted.state.cfg = {
...hoisted.state.cfg, ...hoisted.state.cfg,
agents: { agents: {
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
defaults: { defaults: {
heartbeat: { heartbeat: {
every: "30m", every: "30m",
@ -728,6 +750,21 @@ describe("spawnAcpDirect", () => {
hoisted.state.cfg = { hoisted.state.cfg = {
...hoisted.state.cfg, ...hoisted.state.cfg,
agents: { agents: {
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
defaults: { defaults: {
heartbeat: { heartbeat: {
every: "30m", every: "30m",
@ -762,6 +799,21 @@ describe("spawnAcpDirect", () => {
scope: "global", scope: "global",
}, },
agents: { agents: {
list: [
{
id: "main",
default: true,
subagents: {
allowAgents: ["codex"],
},
},
{
id: "research",
subagents: {
allowAgents: ["codex"],
},
},
],
defaults: { defaults: {
heartbeat: { heartbeat: {
every: "30m", every: "30m",
@ -791,7 +843,21 @@ describe("spawnAcpDirect", () => {
hoisted.state.cfg = { hoisted.state.cfg = {
...hoisted.state.cfg, ...hoisted.state.cfg,
agents: { 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", id: "research",
heartbeat: { every: "0m" }, heartbeat: { every: "0m" },
subagents: {
allowAgents: ["codex"],
},
}, },
], ],
}, },
@ -1041,4 +1110,45 @@ describe("spawnAcpDirect", () => {
expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
expect(hoisted.startAcpSpawnParentStreamRelayMock).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); 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 { function summarizeError(err: unknown): string {
if (err instanceof Error) { if (err instanceof Error) {
return err.message; return err.message;
@ -506,6 +532,17 @@ export async function spawnAcpDirect(
}; };
} }
const targetAgentId = targetAgentResult.agentId; const targetAgentId = targetAgentResult.agentId;
const crossAgentAllowlistError = resolveCrossAgentAllowlistError({
cfg,
requesterAgentId,
targetAgentId,
});
if (crossAgentAllowlistError) {
return {
status: "forbidden",
error: crossAgentAllowlistError,
};
}
const agentPolicyError = resolveAcpAgentPolicyError(cfg, targetAgentId); const agentPolicyError = resolveAcpAgentPolicyError(cfg, targetAgentId);
if (agentPolicyError) { if (agentPolicyError) {
return { return {