Compare commits
3 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050c22668a | ||
|
|
6628db8d61 | ||
|
|
924406472a |
@ -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
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user