mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05: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
|
||||
|
||||
- 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: 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.
|
||||
|
||||
@@ -176,12 +176,24 @@ Behavior:
|
||||
|
||||
## 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:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
sessions: {
|
||||
// "self" | "tree" | "agent" | "all"
|
||||
// default: "tree"
|
||||
visibility: "tree",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
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`
|
||||
|
||||
```json5
|
||||
|
||||
@@ -710,7 +710,11 @@ Common use cases:
|
||||
scope: "agent",
|
||||
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: {
|
||||
sessions: { visibility: "tree" }, // self | tree | agent | all
|
||||
allow: [
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
|
||||
@@ -442,12 +442,14 @@ Notes:
|
||||
|
||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||
- `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`.
|
||||
- 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` 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).
|
||||
- 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`
|
||||
|
||||
|
||||
@@ -324,6 +324,7 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sessions": { "visibility": "tree" },
|
||||
"allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
|
||||
"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",
|
||||
agentToAgent: { maxPingPongTurns: 2 },
|
||||
},
|
||||
tools: {
|
||||
// Keep sessions tools permissive in this suite; dedicated visibility tests cover defaults.
|
||||
sessions: { visibility: "all" },
|
||||
},
|
||||
}),
|
||||
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[];
|
||||
};
|
||||
|
||||
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) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { jsonResult, readStringParam } from "./common.js";
|
||||
import {
|
||||
createAgentToAgentPolicy,
|
||||
listSpawnedSessionKeys,
|
||||
resolveEffectiveSessionToolsVisibility,
|
||||
resolveSessionReference,
|
||||
SessionListRow,
|
||||
resolveSandboxedSessionToolContext,
|
||||
@@ -167,7 +169,6 @@ async function isSpawnedSessionAllowed(params: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionsHistoryTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
sandboxed?: boolean;
|
||||
@@ -189,11 +190,12 @@ export function createSessionsHistoryTool(opts?: {
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
sandboxed: opts?.sandboxed,
|
||||
});
|
||||
const effectiveRequesterKey = requesterInternalKey ?? alias;
|
||||
const resolvedSession = await resolveSessionReference({
|
||||
sessionKey: sessionKeyParam,
|
||||
alias,
|
||||
mainKey,
|
||||
requesterInternalKey,
|
||||
requesterInternalKey: effectiveRequesterKey,
|
||||
restrictToSpawned,
|
||||
});
|
||||
if (!resolvedSession.ok) {
|
||||
@@ -203,9 +205,9 @@ export function createSessionsHistoryTool(opts?: {
|
||||
const resolvedKey = resolvedSession.key;
|
||||
const displayKey = resolvedSession.displayKey;
|
||||
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
||||
if (restrictToSpawned && requesterInternalKey && !resolvedViaSessionId) {
|
||||
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== effectiveRequesterKey) {
|
||||
const ok = await isSpawnedSessionAllowed({
|
||||
requesterSessionKey: requesterInternalKey,
|
||||
requesterSessionKey: effectiveRequesterKey,
|
||||
targetSessionKey: resolvedKey,
|
||||
});
|
||||
if (!ok) {
|
||||
@@ -215,11 +217,22 @@ export function createSessionsHistoryTool(opts?: {
|
||||
});
|
||||
}
|
||||
}
|
||||
const visibility = resolveEffectiveSessionToolsVisibility({
|
||||
cfg,
|
||||
sandboxed: opts?.sandboxed === true,
|
||||
});
|
||||
|
||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
|
||||
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
|
||||
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
|
||||
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 (!a2aPolicy.enabled) {
|
||||
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 =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? Math.max(1, Math.floor(params.limit))
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
createAgentToAgentPolicy,
|
||||
classifySessionKind,
|
||||
deriveChannel,
|
||||
listSpawnedSessionKeys,
|
||||
resolveDisplaySessionKey,
|
||||
resolveEffectiveSessionToolsVisibility,
|
||||
resolveInternalSessionKey,
|
||||
resolveSandboxedSessionToolContext,
|
||||
type SessionListRow,
|
||||
@@ -42,6 +44,11 @@ export function createSessionsListTool(opts?: {
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
sandboxed: opts?.sandboxed,
|
||||
});
|
||||
const effectiveRequesterKey = requesterInternalKey ?? alias;
|
||||
const visibility = resolveEffectiveSessionToolsVisibility({
|
||||
cfg,
|
||||
sandboxed: opts?.sandboxed === true,
|
||||
});
|
||||
|
||||
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
|
||||
value.trim().toLowerCase(),
|
||||
@@ -72,15 +79,19 @@ export function createSessionsListTool(opts?: {
|
||||
activeMinutes,
|
||||
includeGlobal: !restrictToSpawned,
|
||||
includeUnknown: !restrictToSpawned,
|
||||
spawnedBy: restrictToSpawned ? requesterInternalKey : undefined,
|
||||
spawnedBy: restrictToSpawned ? effectiveRequesterKey : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||
const storePath = typeof list?.path === "string" ? list.path : undefined;
|
||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
|
||||
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
|
||||
const rows: SessionListRow[] = [];
|
||||
const spawnedKeys =
|
||||
visibility === "tree"
|
||||
? await listSpawnedSessionKeys({ requesterSessionKey: effectiveRequesterKey })
|
||||
: null;
|
||||
|
||||
for (const entry of sessions) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
@@ -93,8 +104,20 @@ export function createSessionsListTool(opts?: {
|
||||
|
||||
const entryAgentId = resolveAgentIdFromSessionKey(key);
|
||||
const crossAgent = entryAgentId !== requesterAgentId;
|
||||
if (crossAgent && !a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) {
|
||||
continue;
|
||||
if (crossAgent) {
|
||||
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") {
|
||||
|
||||
@@ -18,6 +18,8 @@ import { jsonResult, readStringParam } from "./common.js";
|
||||
import {
|
||||
createAgentToAgentPolicy,
|
||||
extractAssistantText,
|
||||
listSpawnedSessionKeys,
|
||||
resolveEffectiveSessionToolsVisibility,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
resolveSessionReference,
|
||||
@@ -51,21 +53,25 @@ export function createSessionsSendTool(opts?: {
|
||||
const cfg = loadConfig();
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
const visibility = cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
|
||||
const requesterInternalKey =
|
||||
const requesterKeyInput =
|
||||
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
|
||||
? resolveInternalSessionKey({
|
||||
key: opts.agentSessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
})
|
||||
: undefined;
|
||||
? opts.agentSessionKey
|
||||
: "main";
|
||||
const requesterInternalKey = resolveInternalSessionKey({
|
||||
key: requesterKeyInput,
|
||||
alias,
|
||||
mainKey,
|
||||
});
|
||||
const restrictToSpawned =
|
||||
opts?.sandboxed === true &&
|
||||
visibility === "spawned" &&
|
||||
!!requesterInternalKey &&
|
||||
!isSubagentSessionKey(requesterInternalKey);
|
||||
|
||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||
const sessionVisibility = resolveEffectiveSessionToolsVisibility({
|
||||
cfg,
|
||||
sandboxed: opts?.sandboxed === true,
|
||||
});
|
||||
|
||||
const sessionKeyParam = readStringParam(params, "sessionKey");
|
||||
const labelParam = readStringParam(params, "label")?.trim() || undefined;
|
||||
@@ -199,7 +205,7 @@ export function createSessionsSendTool(opts?: {
|
||||
const displayKey = resolvedSession.displayKey;
|
||||
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
|
||||
|
||||
if (restrictToSpawned && !resolvedViaSessionId) {
|
||||
if (restrictToSpawned && !resolvedViaSessionId && resolvedKey !== requesterInternalKey) {
|
||||
const sessions = await listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
@@ -227,6 +233,15 @@ export function createSessionsSendTool(opts?: {
|
||||
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
|
||||
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
|
||||
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 (!a2aPolicy.enabled) {
|
||||
return jsonResult({
|
||||
@@ -245,6 +260,30 @@ export function createSessionsSendTool(opts?: {
|
||||
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({
|
||||
|
||||
@@ -71,6 +71,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||
"tools.fs.workspaceOnly":
|
||||
"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":
|
||||
"Legacy override: allow cross-context sends across all providers.",
|
||||
"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.allowModels": "apply_patch Model Allowlist",
|
||||
"tools.fs.workspaceOnly": "Workspace-only FS tools",
|
||||
"tools.sessions.visibility": "Session Tools Visibility",
|
||||
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
||||
"tools.exec.notifyOnExitEmptySuccess": "Exec Notify On Empty Success",
|
||||
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
||||
|
||||
@@ -258,7 +258,7 @@ export type AgentDefaultsConfig = {
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
sessionToolsVisibility?: "spawned" | "all";
|
||||
|
||||
@@ -138,6 +138,8 @@ export type MediaToolsConfig = {
|
||||
|
||||
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
|
||||
|
||||
export type SessionsToolsVisibility = "self" | "tree" | "agent" | "all";
|
||||
|
||||
export type ToolPolicyConfig = {
|
||||
allow?: string[];
|
||||
/**
|
||||
@@ -453,6 +455,21 @@ export type ToolsConfig = {
|
||||
/** Allowlist of agent ids or patterns (implementation-defined). */
|
||||
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?: {
|
||||
/** Enable or disable elevated mode (default: true). */
|
||||
|
||||
@@ -551,6 +551,12 @@ export const ToolsSchema = z
|
||||
web: ToolsWebSchema,
|
||||
media: ToolsMediaSchema,
|
||||
links: ToolsLinksSchema,
|
||||
sessions: z
|
||||
.object({
|
||||
visibility: z.enum(["self", "tree", "agent", "all"]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
message: z
|
||||
.object({
|
||||
allowCrossContextSend: z.boolean().optional(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createOpenClawTools } from "../agents/openclaw-tools.js";
|
||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const { createOpenClawTools } = await import("../agents/openclaw-tools.js");
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
@@ -111,6 +112,18 @@ describe("sessions_send gateway loopback", () => {
|
||||
|
||||
describe("sessions_send label lookup", () => {
|
||||
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);
|
||||
spy.mockImplementation(async (opts) => {
|
||||
const params = opts as {
|
||||
|
||||
@@ -219,4 +219,27 @@ describe("monitorTelegramProvider (grammY)", () => {
|
||||
);
|
||||
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,
|
||||
path: opts.webhookPath,
|
||||
port: opts.webhookPort,
|
||||
secret: opts.webhookSecret,
|
||||
secret: opts.webhookSecret ?? account.config.webhookSecret,
|
||||
host: opts.webhookHost ?? account.config.webhookHost,
|
||||
runtime: opts.runtime as RuntimeEnv,
|
||||
fetch: proxyFetch,
|
||||
|
||||
Reference in New Issue
Block a user