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.
|
- 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
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user