mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:28:38 +00:00
fix(security): scope session tools and webhook secret fallback
This commit is contained in:
@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent.
|
||||||
- CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
|
- CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
|
||||||
- CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
|
- CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
|
||||||
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
|
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
|
||||||
|
|||||||
@@ -176,12 +176,24 @@ Behavior:
|
|||||||
|
|
||||||
## Sandbox Session Visibility
|
## Sandbox Session Visibility
|
||||||
|
|
||||||
Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`.
|
Session tools can be scoped to reduce cross-session access.
|
||||||
|
|
||||||
|
Default behavior:
|
||||||
|
|
||||||
|
- `tools.sessions.visibility` defaults to `tree` (current session + spawned subagent sessions).
|
||||||
|
- For sandboxed sessions, `agents.defaults.sandbox.sessionToolsVisibility` can hard-clamp visibility.
|
||||||
|
|
||||||
Config:
|
Config:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
tools: {
|
||||||
|
sessions: {
|
||||||
|
// "self" | "tree" | "agent" | "all"
|
||||||
|
// default: "tree"
|
||||||
|
visibility: "tree",
|
||||||
|
},
|
||||||
|
},
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
@@ -192,3 +204,11 @@ Config:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `self`: only the current session key.
|
||||||
|
- `tree`: current session + sessions spawned by the current session.
|
||||||
|
- `agent`: any session belonging to the current agent id.
|
||||||
|
- `all`: any session (cross-agent access still requires `tools.agentToAgent`).
|
||||||
|
- When a session is sandboxed and `sessionToolsVisibility="spawned"`, OpenClaw clamps visibility to `tree` even if you set `tools.sessions.visibility="all"`.
|
||||||
|
|||||||
@@ -1508,6 +1508,31 @@ Provider auth follows standard order: auth profiles → env vars → `models.pro
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `tools.sessions`
|
||||||
|
|
||||||
|
Controls which sessions can be targeted by the session tools (`sessions_list`, `sessions_history`, `sessions_send`).
|
||||||
|
|
||||||
|
Default: `tree` (current session + sessions spawned by it, such as subagents).
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tools: {
|
||||||
|
sessions: {
|
||||||
|
// "self" | "tree" | "agent" | "all"
|
||||||
|
visibility: "tree",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `self`: only the current session key.
|
||||||
|
- `tree`: current session + sessions spawned by the current session (subagents).
|
||||||
|
- `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under the same agent id).
|
||||||
|
- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
|
||||||
|
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
|
||||||
|
|
||||||
### `tools.subagents`
|
### `tools.subagents`
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
|
|||||||
@@ -710,7 +710,11 @@ Common use cases:
|
|||||||
scope: "agent",
|
scope: "agent",
|
||||||
workspaceAccess: "none",
|
workspaceAccess: "none",
|
||||||
},
|
},
|
||||||
|
// Session tools can reveal sensitive data from transcripts. By default OpenClaw limits these tools
|
||||||
|
// to the current session + spawned subagent sessions, but you can clamp further if needed.
|
||||||
|
// See `tools.sessions.visibility` in the configuration reference.
|
||||||
tools: {
|
tools: {
|
||||||
|
sessions: { visibility: "tree" }, // self | tree | agent | all
|
||||||
allow: [
|
allow: [
|
||||||
"sessions_list",
|
"sessions_list",
|
||||||
"sessions_history",
|
"sessions_history",
|
||||||
|
|||||||
@@ -442,12 +442,14 @@ Notes:
|
|||||||
|
|
||||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||||
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
||||||
|
- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing.
|
||||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||||
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
|
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
|
||||||
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
||||||
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
|
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
|
||||||
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
||||||
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
||||||
|
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`.
|
||||||
|
|
||||||
### `agents_list`
|
### `agents_list`
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
|
"sessions": { "visibility": "tree" },
|
||||||
"allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
|
"allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
|
||||||
"deny": ["exec", "write", "edit", "apply_patch", "read", "browser"]
|
"deny": ["exec", "write", "edit", "apply_patch", "read", "browser"]
|
||||||
}
|
}
|
||||||
|
|||||||
125
src/agents/openclaw-tools.sessions-visibility.e2e.test.ts
Normal file
125
src/agents/openclaw-tools.sessions-visibility.e2e.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const callGatewayMock = vi.fn();
|
||||||
|
vi.mock("../gateway/call.js", () => ({
|
||||||
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockConfig: Record<string, unknown> = {
|
||||||
|
session: { mainKey: "main", scope: "per-sender" },
|
||||||
|
};
|
||||||
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadConfig: () => mockConfig,
|
||||||
|
resolveGatewayPort: () => 18789,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import "./test-helpers/fast-core-tools.js";
|
||||||
|
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||||
|
|
||||||
|
describe("sessions tools visibility", () => {
|
||||||
|
it("defaults to tree visibility (self + spawned) for sessions_history", async () => {
|
||||||
|
mockConfig = {
|
||||||
|
session: { mainKey: "main", scope: "per-sender" },
|
||||||
|
tools: { agentToAgent: { enabled: false } },
|
||||||
|
};
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const req = opts as { method?: string; params?: Record<string, unknown> };
|
||||||
|
if (req.method === "sessions.list" && req.params?.spawnedBy === "main") {
|
||||||
|
return { sessions: [{ key: "subagent:child-1" }] };
|
||||||
|
}
|
||||||
|
if (req.method === "sessions.resolve") {
|
||||||
|
const key = typeof req.params?.key === "string" ? String(req.params?.key) : "";
|
||||||
|
return { key };
|
||||||
|
}
|
||||||
|
if (req.method === "chat.history") {
|
||||||
|
return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
|
||||||
|
(candidate) => candidate.name === "sessions_history",
|
||||||
|
);
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("missing sessions_history tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
const denied = await tool.execute("call1", {
|
||||||
|
sessionKey: "agent:main:discord:direct:someone-else",
|
||||||
|
});
|
||||||
|
expect(denied.details).toMatchObject({ status: "forbidden" });
|
||||||
|
|
||||||
|
const allowed = await tool.execute("call2", { sessionKey: "subagent:child-1" });
|
||||||
|
expect(allowed.details).toMatchObject({
|
||||||
|
sessionKey: "subagent:child-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows broader access when tools.sessions.visibility=all", async () => {
|
||||||
|
mockConfig = {
|
||||||
|
session: { mainKey: "main", scope: "per-sender" },
|
||||||
|
tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: false } },
|
||||||
|
};
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const req = opts as { method?: string; params?: Record<string, unknown> };
|
||||||
|
if (req.method === "chat.history") {
|
||||||
|
return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
|
||||||
|
(candidate) => candidate.name === "sessions_history",
|
||||||
|
);
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("missing sessions_history tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tool.execute("call3", {
|
||||||
|
sessionKey: "agent:main:discord:direct:someone-else",
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
sessionKey: "agent:main:discord:direct:someone-else",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps sandboxed sessions to tree when agents.defaults.sandbox.sessionToolsVisibility=spawned", async () => {
|
||||||
|
mockConfig = {
|
||||||
|
session: { mainKey: "main", scope: "per-sender" },
|
||||||
|
tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: true, allow: ["*"] } },
|
||||||
|
agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } },
|
||||||
|
};
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const req = opts as { method?: string; params?: Record<string, unknown> };
|
||||||
|
if (req.method === "sessions.list" && req.params?.spawnedBy === "main") {
|
||||||
|
return { sessions: [] };
|
||||||
|
}
|
||||||
|
if (req.method === "chat.history") {
|
||||||
|
return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createOpenClawTools({ agentSessionKey: "main", sandboxed: true }).find(
|
||||||
|
(candidate) => candidate.name === "sessions_history",
|
||||||
|
);
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("missing sessions_history tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
const denied = await tool.execute("call4", {
|
||||||
|
sessionKey: "agent:other:main",
|
||||||
|
});
|
||||||
|
expect(denied.details).toMatchObject({ status: "forbidden" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,10 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
agentToAgent: { maxPingPongTurns: 2 },
|
agentToAgent: { maxPingPongTurns: 2 },
|
||||||
},
|
},
|
||||||
|
tools: {
|
||||||
|
// Keep sessions tools permissive in this suite; dedicated visibility tests cover defaults.
|
||||||
|
sessions: { visibility: "all" },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
resolveGatewayPort: () => 18789,
|
resolveGatewayPort: () => 18789,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
|
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||||
|
import "./test-helpers/fast-core-tools.js";
|
||||||
|
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||||
|
|
||||||
|
type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const callGatewayMock = vi.fn();
|
||||||
|
const defaultConfigOverride = {
|
||||||
|
session: {
|
||||||
|
mainKey: "main",
|
||||||
|
scope: "per-sender",
|
||||||
|
},
|
||||||
|
} as SessionsSpawnTestConfig;
|
||||||
|
const state = { configOverride: defaultConfigOverride };
|
||||||
|
return { callGatewayMock, defaultConfigOverride, state };
|
||||||
|
});
|
||||||
|
|
||||||
|
const callGatewayMock = hoisted.callGatewayMock;
|
||||||
|
|
||||||
|
function resetConfigOverride() {
|
||||||
|
hoisted.state.configOverride = hoisted.defaultConfigOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigOverride(next: SessionsSpawnTestConfig) {
|
||||||
|
hoisted.state.configOverride = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../gateway/call.js", () => ({
|
||||||
|
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
|
||||||
|
}));
|
||||||
|
// Some tools import callGateway via "../../gateway/call.js" (from nested folders). Mock that too.
|
||||||
|
vi.mock("../../gateway/call.js", () => ({
|
||||||
|
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadConfig: () => hoisted.state.configOverride,
|
||||||
|
resolveGatewayPort: () => 18789,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same module, different specifier (used by tools under src/agents/tools/*).
|
||||||
|
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadConfig: () => hoisted.state.configOverride,
|
||||||
|
resolveGatewayPort: () => 18789,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openclaw-tools: subagents", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetConfigOverride();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sessions_spawn normalizes allowlisted agent ids", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
setConfigOverride({
|
||||||
|
session: {
|
||||||
|
mainKey: "main",
|
||||||
|
scope: "per-sender",
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
subagents: {
|
||||||
|
allowAgents: ["Research"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let childSessionKey: string | undefined;
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const request = opts as { method?: string; params?: unknown };
|
||||||
|
if (request.method === "agent") {
|
||||||
|
const params = request.params as { sessionKey?: string } | undefined;
|
||||||
|
childSessionKey = params?.sessionKey;
|
||||||
|
return { runId: "run-1", status: "accepted", acceptedAt: 5200 };
|
||||||
|
}
|
||||||
|
if (request.method === "agent.wait") {
|
||||||
|
return { status: "timeout" };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createOpenClawTools({
|
||||||
|
agentSessionKey: "main",
|
||||||
|
agentChannel: "whatsapp",
|
||||||
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("missing sessions_spawn tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tool.execute("call10", {
|
||||||
|
task: "do thing",
|
||||||
|
agentId: "research",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
expect(childSessionKey?.startsWith("agent:research:subagent:")).toBe(true);
|
||||||
|
});
|
||||||
|
it("sessions_spawn forbids cross-agent spawning when not allowed", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
setConfigOverride({
|
||||||
|
session: {
|
||||||
|
mainKey: "main",
|
||||||
|
scope: "per-sender",
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
subagents: {
|
||||||
|
allowAgents: ["alpha"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createOpenClawTools({
|
||||||
|
agentSessionKey: "main",
|
||||||
|
agentChannel: "whatsapp",
|
||||||
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("missing sessions_spawn tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tool.execute("call9", {
|
||||||
|
task: "do thing",
|
||||||
|
agentId: "beta",
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "forbidden",
|
||||||
|
});
|
||||||
|
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sessions_spawn runs cleanup via lifecycle events", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
|
let agentCallCount = 0;
|
||||||
|
let deletedKey: string | undefined;
|
||||||
|
let childRunId: string | undefined;
|
||||||
|
let childSessionKey: string | undefined;
|
||||||
|
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
||||||
|
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const request = opts as { method?: string; params?: unknown };
|
||||||
|
calls.push(request);
|
||||||
|
if (request.method === "agent") {
|
||||||
|
agentCallCount += 1;
|
||||||
|
const runId = `run-${agentCallCount}`;
|
||||||
|
const params = request.params as {
|
||||||
|
message?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
channel?: string;
|
||||||
|
timeout?: number;
|
||||||
|
lane?: string;
|
||||||
|
};
|
||||||
|
if (params?.lane === "subagent") {
|
||||||
|
childRunId = runId;
|
||||||
|
childSessionKey = params?.sessionKey ?? "";
|
||||||
|
expect(params?.channel).toBe("discord");
|
||||||
|
expect(params?.timeout).toBe(1);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
status: "accepted",
|
||||||
|
acceptedAt: 1000 + agentCallCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.method === "agent.wait") {
|
||||||
|
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||||
|
waitCalls.push(params ?? {});
|
||||||
|
return {
|
||||||
|
runId: params?.runId ?? "run-1",
|
||||||
|
status: "ok",
|
||||||
|
startedAt: 1000,
|
||||||
|
endedAt: 2000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.method === "sessions.delete") {
|
||||||
|
const params = request.params as { key?: string } | undefined;
|
||||||
|
deletedKey = params?.key;
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createOpenClawTools({
|
||||||
|
agentSessionKey: "discord:group:req",
|
||||||
|
agentChannel: "discord",
|
||||||
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("missing sessions_spawn tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tool.execute("call1", {
|
||||||
|
task: "do thing",
|
||||||
|
runTimeoutSeconds: 1,
|
||||||
|
cleanup: "delete",
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childRunId) {
|
||||||
|
throw new Error("missing child runId");
|
||||||
|
}
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: childRunId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "end",
|
||||||
|
startedAt: 1234,
|
||||||
|
endedAt: 2345,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
||||||
|
expect(childWait?.timeoutMs).toBe(1000);
|
||||||
|
|
||||||
|
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||||
|
const spawnCalls = agentCalls.filter((call) => {
|
||||||
|
const params = call.params as { lane?: string } | undefined;
|
||||||
|
return params?.lane === "subagent";
|
||||||
|
});
|
||||||
|
expect(spawnCalls).toHaveLength(1);
|
||||||
|
const announceCalls = agentCalls.filter((call) => {
|
||||||
|
const params = call.params as { sessionKey?: string; deliver?: boolean } | undefined;
|
||||||
|
return params?.deliver === true && params?.sessionKey === "discord:group:req";
|
||||||
|
});
|
||||||
|
expect(announceCalls).toHaveLength(1);
|
||||||
|
|
||||||
|
const first = spawnCalls[0]?.params as
|
||||||
|
| {
|
||||||
|
lane?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
sessionKey?: string;
|
||||||
|
channel?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
expect(first?.lane).toBe("subagent");
|
||||||
|
expect(first?.deliver).toBe(false);
|
||||||
|
expect(first?.channel).toBe("discord");
|
||||||
|
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
|
||||||
|
const second = announceCalls[0]?.params as
|
||||||
|
| {
|
||||||
|
sessionKey?: string;
|
||||||
|
message?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
expect(second?.sessionKey).toBe("discord:group:req");
|
||||||
|
expect(second?.deliver).toBe(true);
|
||||||
|
expect(second?.message).toContain("subagent task");
|
||||||
|
|
||||||
|
const sendCalls = calls.filter((c) => c.method === "send");
|
||||||
|
expect(sendCalls.length).toBe(0);
|
||||||
|
|
||||||
|
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sessions_spawn announces with requester accountId", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
|
let agentCallCount = 0;
|
||||||
|
let childRunId: string | undefined;
|
||||||
|
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const request = opts as { method?: string; params?: unknown };
|
||||||
|
calls.push(request);
|
||||||
|
if (request.method === "agent") {
|
||||||
|
agentCallCount += 1;
|
||||||
|
const runId = `run-${agentCallCount}`;
|
||||||
|
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
|
||||||
|
if (params?.lane === "subagent") {
|
||||||
|
childRunId = runId;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
status: "accepted",
|
||||||
|
acceptedAt: 4000 + agentCallCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.method === "agent.wait") {
|
||||||
|
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||||
|
return {
|
||||||
|
runId: params?.runId ?? "run-1",
|
||||||
|
status: "ok",
|
||||||
|
startedAt: 1000,
|
||||||
|
endedAt: 2000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createOpenClawTools({
|
||||||
|
agentSessionKey: "main",
|
||||||
|
agentChannel: "whatsapp",
|
||||||
|
agentAccountId: "kev",
|
||||||
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("missing sessions_spawn tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tool.execute("call2", {
|
||||||
|
task: "do thing",
|
||||||
|
runTimeoutSeconds: 1,
|
||||||
|
cleanup: "keep",
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childRunId) {
|
||||||
|
throw new Error("missing child runId");
|
||||||
|
}
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: childRunId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "end",
|
||||||
|
startedAt: 1000,
|
||||||
|
endedAt: 2000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||||
|
expect(agentCalls).toHaveLength(2);
|
||||||
|
const announceParams = agentCalls[1]?.params as
|
||||||
|
| { accountId?: string; channel?: string; deliver?: boolean }
|
||||||
|
| undefined;
|
||||||
|
expect(announceParams?.deliver).toBe(true);
|
||||||
|
expect(announceParams?.channel).toBe("whatsapp");
|
||||||
|
expect(announceParams?.accountId).toBe("kev");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,62 @@ export type SessionListRow = {
|
|||||||
messages?: unknown[];
|
messages?: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SessionToolsVisibility = "self" | "tree" | "agent" | "all";
|
||||||
|
|
||||||
|
export function resolveSessionToolsVisibility(cfg: OpenClawConfig): SessionToolsVisibility {
|
||||||
|
const raw = (cfg.tools as { sessions?: { visibility?: unknown } } | undefined)?.sessions
|
||||||
|
?.visibility;
|
||||||
|
const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||||
|
if (value === "self" || value === "tree" || value === "agent" || value === "all") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "tree";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveSessionToolsVisibility(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sandboxed: boolean;
|
||||||
|
}): SessionToolsVisibility {
|
||||||
|
const visibility = resolveSessionToolsVisibility(params.cfg);
|
||||||
|
if (!params.sandboxed) {
|
||||||
|
return visibility;
|
||||||
|
}
|
||||||
|
const sandboxClamp = params.cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
|
||||||
|
if (sandboxClamp === "spawned" && visibility !== "tree") {
|
||||||
|
return "tree";
|
||||||
|
}
|
||||||
|
return visibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSpawnedSessionKeys(params: {
|
||||||
|
requesterSessionKey: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<Set<string>> {
|
||||||
|
const limit =
|
||||||
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
|
? Math.max(1, Math.floor(params.limit))
|
||||||
|
: 500;
|
||||||
|
try {
|
||||||
|
const list = await callGateway<{ sessions: Array<{ key?: unknown }> }>({
|
||||||
|
method: "sessions.list",
|
||||||
|
params: {
|
||||||
|
includeGlobal: false,
|
||||||
|
includeUnknown: false,
|
||||||
|
limit,
|
||||||
|
spawnedBy: params.requesterSessionKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||||
|
const keys = sessions
|
||||||
|
.map((entry) => (typeof entry?.key === "string" ? entry.key : ""))
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return new Set(keys);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeKey(value?: string) {
|
function normalizeKey(value?: string) {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { truncateUtf16Safe } from "../../utils.js";
|
|||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
import {
|
import {
|
||||||
createAgentToAgentPolicy,
|
createAgentToAgentPolicy,
|
||||||
|
listSpawnedSessionKeys,
|
||||||
|
resolveEffectiveSessionToolsVisibility,
|
||||||
resolveSessionReference,
|
resolveSessionReference,
|
||||||
SessionListRow,
|
SessionListRow,
|
||||||
resolveSandboxedSessionToolContext,
|
resolveSandboxedSessionToolContext,
|
||||||
@@ -167,7 +169,6 @@ async function isSpawnedSessionAllowed(params: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionsHistoryTool(opts?: {
|
export function createSessionsHistoryTool(opts?: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
@@ -189,11 +190,12 @@ export function createSessionsHistoryTool(opts?: {
|
|||||||
agentSessionKey: opts?.agentSessionKey,
|
agentSessionKey: opts?.agentSessionKey,
|
||||||
sandboxed: opts?.sandboxed,
|
sandboxed: opts?.sandboxed,
|
||||||
});
|
});
|
||||||
|
const effectiveRequesterKey = requesterInternalKey ?? alias;
|
||||||
const resolvedSession = await resolveSessionReference({
|
const resolvedSession = await resolveSessionReference({
|
||||||
sessionKey: sessionKeyParam,
|
sessionKey: sessionKeyParam,
|
||||||
alias,
|
alias,
|
||||||
mainKey,
|
mainKey,
|
||||||
requesterInternalKey,
|
requesterInternalKey: effectiveRequesterKey,
|
||||||
restrictToSpawned,
|
restrictToSpawned,
|
||||||
});
|
});
|
||||||
if (!resolvedSession.ok) {
|
if (!resolvedSession.ok) {
|
||||||
@@ -203,9 +205,9 @@ export function createSessionsHistoryTool(opts?: {
|
|||||||
const resolvedKey = resolvedSession.key;
|
const resolvedKey = resolvedSession.key;
|
||||||
const displayKey = resolvedSession.displayKey;
|
const displayKey = resolvedSession.displayKey;
|
||||||
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
||||||
if (restrictToSpawned && requesterInternalKey && !resolvedViaSessionId) {
|
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) {
|
||||||
const ok = await isSpawnedSessionAllowed({
|
const ok = await isSpawnedSessionAllowed({
|
||||||
requesterSessionKey: requesterInternalKey,
|
requesterSessionKey: effectiveRequesterKey,
|
||||||
targetSessionKey: resolvedKey,
|
targetSessionKey: resolvedKey,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@@ -215,11 +217,22 @@ export function createSessionsHistoryTool(opts?: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const visibility = resolveEffectiveSessionToolsVisibility({
|
||||||
|
cfg,
|
||||||
|
sandboxed: opts?.sandboxed === true,
|
||||||
|
});
|
||||||
|
|
||||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||||
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
|
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
|
||||||
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
|
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
|
||||||
const isCrossAgent = requesterAgentId !== targetAgentId;
|
const isCrossAgent = requesterAgentId !== targetAgentId;
|
||||||
|
if (isCrossAgent && visibility !== "all") {
|
||||||
|
return jsonResult({
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Session history visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isCrossAgent) {
|
if (isCrossAgent) {
|
||||||
if (!a2aPolicy.enabled) {
|
if (!a2aPolicy.enabled) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
@@ -236,6 +249,28 @@ export function createSessionsHistoryTool(opts?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isCrossAgent) {
|
||||||
|
if (visibility === "self" && resolvedKey !== effectiveRequesterKey) {
|
||||||
|
return jsonResult({
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Session history visibility is restricted to the current session (tools.sessions.visibility=self).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (visibility === "tree" && resolvedKey !== effectiveRequesterKey) {
|
||||||
|
const spawned = await listSpawnedSessionKeys({
|
||||||
|
requesterSessionKey: effectiveRequesterKey,
|
||||||
|
});
|
||||||
|
if (!spawned.has(resolvedKey)) {
|
||||||
|
return jsonResult({
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Session history visibility is restricted to the current session tree (tools.sessions.visibility=tree).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const limit =
|
const limit =
|
||||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
? Math.max(1, Math.floor(params.limit))
|
? Math.max(1, Math.floor(params.limit))
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
createAgentToAgentPolicy,
|
createAgentToAgentPolicy,
|
||||||
classifySessionKind,
|
classifySessionKind,
|
||||||
deriveChannel,
|
deriveChannel,
|
||||||
|
listSpawnedSessionKeys,
|
||||||
resolveDisplaySessionKey,
|
resolveDisplaySessionKey,
|
||||||
|
resolveEffectiveSessionToolsVisibility,
|
||||||
resolveInternalSessionKey,
|
resolveInternalSessionKey,
|
||||||
resolveSandboxedSessionToolContext,
|
resolveSandboxedSessionToolContext,
|
||||||
type SessionListRow,
|
type SessionListRow,
|
||||||
@@ -42,6 +44,11 @@ export function createSessionsListTool(opts?: {
|
|||||||
agentSessionKey: opts?.agentSessionKey,
|
agentSessionKey: opts?.agentSessionKey,
|
||||||
sandboxed: opts?.sandboxed,
|
sandboxed: opts?.sandboxed,
|
||||||
});
|
});
|
||||||
|
const effectiveRequesterKey = requesterInternalKey ?? alias;
|
||||||
|
const visibility = resolveEffectiveSessionToolsVisibility({
|
||||||
|
cfg,
|
||||||
|
sandboxed: opts?.sandboxed === true,
|
||||||
|
});
|
||||||
|
|
||||||
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
|
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
|
||||||
value.trim().toLowerCase(),
|
value.trim().toLowerCase(),
|
||||||
@@ -72,15 +79,19 @@ export function createSessionsListTool(opts?: {
|
|||||||
activeMinutes,
|
activeMinutes,
|
||||||
includeGlobal: !restrictToSpawned,
|
includeGlobal: !restrictToSpawned,
|
||||||
includeUnknown: !restrictToSpawned,
|
includeUnknown: !restrictToSpawned,
|
||||||
spawnedBy: restrictToSpawned ? requesterInternalKey : undefined,
|
spawnedBy: restrictToSpawned ? effectiveRequesterKey : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||||
const storePath = typeof list?.path === "string" ? list.path : undefined;
|
const storePath = typeof list?.path === "string" ? list.path : undefined;
|
||||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||||
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
|
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
|
||||||
const rows: SessionListRow[] = [];
|
const rows: SessionListRow[] = [];
|
||||||
|
const spawnedKeys =
|
||||||
|
visibility === "tree"
|
||||||
|
? await listSpawnedSessionKeys({ requesterSessionKey: effectiveRequesterKey })
|
||||||
|
: null;
|
||||||
|
|
||||||
for (const entry of sessions) {
|
for (const entry of sessions) {
|
||||||
if (!entry || typeof entry !== "object") {
|
if (!entry || typeof entry !== "object") {
|
||||||
@@ -93,8 +104,20 @@ export function createSessionsListTool(opts?: {
|
|||||||
|
|
||||||
const entryAgentId = resolveAgentIdFromSessionKey(key);
|
const entryAgentId = resolveAgentIdFromSessionKey(key);
|
||||||
const crossAgent = entryAgentId !== requesterAgentId;
|
const crossAgent = entryAgentId !== requesterAgentId;
|
||||||
if (crossAgent && !a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) {
|
if (crossAgent) {
|
||||||
continue;
|
if (visibility !== "all") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (visibility === "self" && key !== effectiveRequesterKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (visibility === "tree" && key !== effectiveRequesterKey && !spawnedKeys?.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "unknown") {
|
if (key === "unknown") {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { jsonResult, readStringParam } from "./common.js";
|
|||||||
import {
|
import {
|
||||||
createAgentToAgentPolicy,
|
createAgentToAgentPolicy,
|
||||||
extractAssistantText,
|
extractAssistantText,
|
||||||
|
listSpawnedSessionKeys,
|
||||||
|
resolveEffectiveSessionToolsVisibility,
|
||||||
resolveInternalSessionKey,
|
resolveInternalSessionKey,
|
||||||
resolveMainSessionAlias,
|
resolveMainSessionAlias,
|
||||||
resolveSessionReference,
|
resolveSessionReference,
|
||||||
@@ -51,21 +53,25 @@ export function createSessionsSendTool(opts?: {
|
|||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
|
const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
|
||||||
const requesterInternalKey =
|
const requesterKeyInput =
|
||||||
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
|
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
|
||||||
? resolveInternalSessionKey({
|
? opts.agentSessionKey
|
||||||
key: opts.agentSessionKey,
|
: "main";
|
||||||
alias,
|
const requesterInternalKey = resolveInternalSessionKey({
|
||||||
mainKey,
|
key: requesterKeyInput,
|
||||||
})
|
alias,
|
||||||
: undefined;
|
mainKey,
|
||||||
|
});
|
||||||
const restrictToSpawned =
|
const restrictToSpawned =
|
||||||
opts?.sandboxed === true &&
|
opts?.sandboxed === true &&
|
||||||
visibility === "spawned" &&
|
visibility === "spawned" &&
|
||||||
!!requesterInternalKey &&
|
|
||||||
!isSubagentSessionKey(requesterInternalKey);
|
!isSubagentSessionKey(requesterInternalKey);
|
||||||
|
|
||||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||||
|
const sessionVisibility = resolveEffectiveSessionToolsVisibility({
|
||||||
|
cfg,
|
||||||
|
sandboxed: opts?.sandboxed === true,
|
||||||
|
});
|
||||||
|
|
||||||
const sessionKeyParam = readStringParam(params, "sessionKey");
|
const sessionKeyParam = readStringParam(params, "sessionKey");
|
||||||
const labelParam = readStringParam(params, "label")?.trim() || undefined;
|
const labelParam = readStringParam(params, "label")?.trim() || undefined;
|
||||||
@@ -199,7 +205,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
const displayKey = resolvedSession.displayKey;
|
const displayKey = resolvedSession.displayKey;
|
||||||
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
||||||
|
|
||||||
if (restrictToSpawned && !resolvedViaSessionId) {
|
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== requesterInternalKey) {
|
||||||
const sessions = await listSessions({
|
const sessions = await listSessions({
|
||||||
includeGlobal: false,
|
includeGlobal: false,
|
||||||
includeUnknown: false,
|
includeUnknown: false,
|
||||||
@@ -227,6 +233,15 @@ export function createSessionsSendTool(opts?: {
|
|||||||
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
|
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
|
||||||
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
|
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
|
||||||
const isCrossAgent = requesterAgentId !== targetAgentId;
|
const isCrossAgent = requesterAgentId !== targetAgentId;
|
||||||
|
if (isCrossAgent && sessionVisibility !== "all") {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Session send visibility is restricted. Set tools.sessions.visibility=all to allow cross-agent access.",
|
||||||
|
sessionKey: displayKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isCrossAgent) {
|
if (isCrossAgent) {
|
||||||
if (!a2aPolicy.enabled) {
|
if (!a2aPolicy.enabled) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
@@ -245,6 +260,30 @@ export function createSessionsSendTool(opts?: {
|
|||||||
sessionKey: displayKey,
|
sessionKey: displayKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (sessionVisibility === "self" && resolvedKey !== requesterInternalKey) {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Session send visibility is restricted to the current session (tools.sessions.visibility=self).",
|
||||||
|
sessionKey: displayKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (sessionVisibility === "tree" && resolvedKey !== requesterInternalKey) {
|
||||||
|
const spawned = await listSpawnedSessionKeys({
|
||||||
|
requesterSessionKey: requesterInternalKey,
|
||||||
|
});
|
||||||
|
if (!spawned.has(resolvedKey)) {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Session send visibility is restricted to the current session tree (tools.sessions.visibility=tree).",
|
||||||
|
sessionKey: displayKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentMessageContext = buildAgentToAgentMessageContext({
|
const agentMessageContext = buildAgentToAgentMessageContext({
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||||
"tools.fs.workspaceOnly":
|
"tools.fs.workspaceOnly":
|
||||||
"Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).",
|
"Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).",
|
||||||
|
"tools.sessions.visibility":
|
||||||
|
'Controls which sessions can be targeted by sessions_list/sessions_history/sessions_send. ("tree" default = current session + spawned subagent sessions; "self" = only current; "agent" = any session in the current agent id; "all" = any session; cross-agent still requires tools.agentToAgent).',
|
||||||
"tools.message.allowCrossContextSend":
|
"tools.message.allowCrossContextSend":
|
||||||
"Legacy override: allow cross-context sends across all providers.",
|
"Legacy override: allow cross-context sends across all providers.",
|
||||||
"tools.message.crossContext.allowWithinProvider":
|
"tools.message.crossContext.allowWithinProvider":
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.exec.applyPatch.workspaceOnly": "apply_patch Workspace-Only",
|
"tools.exec.applyPatch.workspaceOnly": "apply_patch Workspace-Only",
|
||||||
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
||||||
"tools.fs.workspaceOnly": "Workspace-only FS tools",
|
"tools.fs.workspaceOnly": "Workspace-only FS tools",
|
||||||
|
"tools.sessions.visibility": "Session Tools Visibility",
|
||||||
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
||||||
"tools.exec.notifyOnExitEmptySuccess": "Exec Notify On Empty Success",
|
"tools.exec.notifyOnExitEmptySuccess": "Exec Notify On Empty Success",
|
||||||
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export type AgentDefaultsConfig = {
|
|||||||
workspaceAccess?: "none" | "ro" | "rw";
|
workspaceAccess?: "none" | "ro" | "rw";
|
||||||
/**
|
/**
|
||||||
* Session tools visibility for sandboxed sessions.
|
* Session tools visibility for sandboxed sessions.
|
||||||
* - "spawned": only allow session tools to target sessions spawned from this session (default)
|
* - "spawned": only allow session tools to target the current session and sessions spawned from it (default)
|
||||||
* - "all": allow session tools to target any session
|
* - "all": allow session tools to target any session
|
||||||
*/
|
*/
|
||||||
sessionToolsVisibility?: "spawned" | "all";
|
sessionToolsVisibility?: "spawned" | "all";
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ export type MediaToolsConfig = {
|
|||||||
|
|
||||||
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
|
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
|
||||||
|
|
||||||
|
export type SessionsToolsVisibility = "self" | "tree" | "agent" | "all";
|
||||||
|
|
||||||
export type ToolPolicyConfig = {
|
export type ToolPolicyConfig = {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
/**
|
/**
|
||||||
@@ -453,6 +455,21 @@ export type ToolsConfig = {
|
|||||||
/** Allowlist of agent ids or patterns (implementation-defined). */
|
/** Allowlist of agent ids or patterns (implementation-defined). */
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Session tool visibility controls which sessions can be targeted by session tools
|
||||||
|
* (sessions_list, sessions_history, sessions_send).
|
||||||
|
*
|
||||||
|
* Default: "tree" (current session + spawned subagent sessions).
|
||||||
|
*/
|
||||||
|
sessions?: {
|
||||||
|
/**
|
||||||
|
* - "self": only the current session
|
||||||
|
* - "tree": current session + sessions spawned by this session (default)
|
||||||
|
* - "agent": any session belonging to the current agent id (can include other users)
|
||||||
|
* - "all": any session (cross-agent still requires tools.agentToAgent)
|
||||||
|
*/
|
||||||
|
visibility?: SessionsToolsVisibility;
|
||||||
|
};
|
||||||
/** Elevated exec permissions for the host machine. */
|
/** Elevated exec permissions for the host machine. */
|
||||||
elevated?: {
|
elevated?: {
|
||||||
/** Enable or disable elevated mode (default: true). */
|
/** Enable or disable elevated mode (default: true). */
|
||||||
|
|||||||
@@ -551,6 +551,12 @@ export const ToolsSchema = z
|
|||||||
web: ToolsWebSchema,
|
web: ToolsWebSchema,
|
||||||
media: ToolsMediaSchema,
|
media: ToolsMediaSchema,
|
||||||
links: ToolsLinksSchema,
|
links: ToolsLinksSchema,
|
||||||
|
sessions: z
|
||||||
|
.object({
|
||||||
|
visibility: z.enum(["self", "tree", "agent", "all"]).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
message: z
|
message: z
|
||||||
.object({
|
.object({
|
||||||
allowCrossContextSend: z.boolean().optional(),
|
allowCrossContextSend: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { createOpenClawTools } from "../agents/openclaw-tools.js";
|
|
||||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { captureEnv } from "../test-utils/env.js";
|
import { captureEnv } from "../test-utils/env.js";
|
||||||
@@ -13,6 +12,8 @@ import {
|
|||||||
testState,
|
testState,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
|
const { createOpenClawTools } = await import("../agents/openclaw-tools.js");
|
||||||
|
|
||||||
installGatewayTestHooks({ scope: "suite" });
|
installGatewayTestHooks({ scope: "suite" });
|
||||||
|
|
||||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||||
@@ -111,6 +112,18 @@ describe("sessions_send gateway loopback", () => {
|
|||||||
|
|
||||||
describe("sessions_send label lookup", () => {
|
describe("sessions_send label lookup", () => {
|
||||||
it("finds session by label and sends message", { timeout: 60_000 }, async () => {
|
it("finds session by label and sends message", { timeout: 60_000 }, async () => {
|
||||||
|
// This is an operator feature; enable broader session tool targeting for this test.
|
||||||
|
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||||
|
if (!configPath) {
|
||||||
|
throw new Error("OPENCLAW_CONFIG_PATH missing in gateway test environment");
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ tools: { sessions: { visibility: "all" } } }, null, 2) + "\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
const spy = vi.mocked(agentCommand);
|
const spy = vi.mocked(agentCommand);
|
||||||
spy.mockImplementation(async (opts) => {
|
spy.mockImplementation(async (opts) => {
|
||||||
const params = opts as {
|
const params = opts as {
|
||||||
|
|||||||
@@ -219,4 +219,27 @@ describe("monitorTelegramProvider (grammY)", () => {
|
|||||||
);
|
);
|
||||||
expect(runSpy).not.toHaveBeenCalled();
|
expect(runSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to configured webhookSecret when not passed explicitly", async () => {
|
||||||
|
await monitorTelegramProvider({
|
||||||
|
token: "tok",
|
||||||
|
useWebhook: true,
|
||||||
|
webhookUrl: "https://example.test/telegram",
|
||||||
|
config: {
|
||||||
|
agents: { defaults: { maxConcurrent: 2 } },
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
webhookSecret: "secret-from-config",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(startTelegramWebhookSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
secret: "secret-from-config",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(runSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
path: opts.webhookPath,
|
path: opts.webhookPath,
|
||||||
port: opts.webhookPort,
|
port: opts.webhookPort,
|
||||||
secret: opts.webhookSecret,
|
secret: opts.webhookSecret ?? account.config.webhookSecret,
|
||||||
host: opts.webhookHost ?? account.config.webhookHost,
|
host: opts.webhookHost ?? account.config.webhookHost,
|
||||||
runtime: opts.runtime as RuntimeEnv,
|
runtime: opts.runtime as RuntimeEnv,
|
||||||
fetch: proxyFetch,
|
fetch: proxyFetch,
|
||||||
|
|||||||
Reference in New Issue
Block a user