feat: add sessions tools and send policy
This commit is contained in:
@@ -620,11 +620,21 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
idleMinutes: 60,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.clawdis/sessions/sessions.json",
|
||||
mainKey: "main"
|
||||
mainKey: "main",
|
||||
sendPolicy: {
|
||||
rules: [
|
||||
{ action: "deny", match: { surface: "discord", chatType: "group" } }
|
||||
],
|
||||
default: "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||
- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||
|
||||
### `skills` (skills config)
|
||||
|
||||
Controls bundled allowlist, install preferences, extra skill folders, and per-skill
|
||||
|
||||
109
docs/session-tool.md
Normal file
109
docs/session-tool.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
summary: "Agent session tools for listing sessions, fetching history, and sending cross-session messages"
|
||||
read_when:
|
||||
- Adding or modifying session tools
|
||||
---
|
||||
|
||||
# Session Tools
|
||||
|
||||
Goal: small, hard-to-misuse tool surface so agents can list sessions, fetch history, and send to another session.
|
||||
|
||||
## Tool Names
|
||||
- `sessions_list`
|
||||
- `sessions_history`
|
||||
- `sessions_send`
|
||||
|
||||
## Key Model
|
||||
- Main direct chat bucket is always the literal key `"main"`.
|
||||
- Group chats use `surface:group:<id>` or `surface:channel:<id>`.
|
||||
- Cron jobs use `cron:<job.id>`.
|
||||
- Hooks use `hook:<uuid>` unless explicitly set.
|
||||
- Node bridge uses `node-<nodeId>` unless explicitly set.
|
||||
|
||||
`global` and `unknown` are internal-only and never listed. If `session.scope = "global"`, we alias it to `main` for all tools so callers never see `global`.
|
||||
|
||||
## sessions_list
|
||||
List sessions as an array of rows.
|
||||
|
||||
Parameters:
|
||||
- `kinds?: string[]` filter: any of `"main" | "group" | "cron" | "hook" | "node" | "other"`
|
||||
- `limit?: number` max rows (default: server default, clamp e.g. 200)
|
||||
- `activeMinutes?: number` only sessions updated within N minutes
|
||||
- `messageLimit?: number` 0 = no messages (default 0); >0 = include last N messages
|
||||
|
||||
Behavior:
|
||||
- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages.
|
||||
- Tool results are filtered out in list output; use `sessions_history` for tool messages.
|
||||
|
||||
Row shape (JSON):
|
||||
- `key`: session key (string)
|
||||
- `kind`: `main | group | cron | hook | node | other`
|
||||
- `provider`: `whatsapp | telegram | discord | signal | imessage | webchat | internal | unknown`
|
||||
- `displayName` (group display label if available)
|
||||
- `updatedAt` (ms)
|
||||
- `sessionId`
|
||||
- `model`, `contextTokens`, `totalTokens`
|
||||
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
|
||||
- `sendPolicy` (session override if set)
|
||||
- `lastChannel`, `lastTo`
|
||||
- `transcriptPath` (best-effort path derived from store dir + sessionId)
|
||||
- `messages?` (only when `messageLimit > 0`)
|
||||
|
||||
## sessions_history
|
||||
Fetch transcript for one session.
|
||||
|
||||
Parameters:
|
||||
- `sessionKey` (required)
|
||||
- `limit?: number` max messages (server clamps)
|
||||
- `includeTools?: boolean` (default false)
|
||||
|
||||
Behavior:
|
||||
- `includeTools=false` filters `role: "toolResult"` messages.
|
||||
- Returns messages array in the raw transcript format.
|
||||
|
||||
## sessions_send
|
||||
Send a message into another session.
|
||||
|
||||
Parameters:
|
||||
- `sessionKey` (required)
|
||||
- `message` (required)
|
||||
- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget)
|
||||
|
||||
Behavior:
|
||||
- `timeoutSeconds = 0`: enqueue and return `{ runId, status: "accepted" }`.
|
||||
- `timeoutSeconds > 0`: wait up to N seconds for completion, then return `{ runId, status: "ok", reply }`.
|
||||
- If wait times out: `{ runId, status: "timeout", error }`. Run continues; call `sessions_history` later.
|
||||
- If the run fails: `{ runId, status: "error", error }`.
|
||||
|
||||
## Provider Field
|
||||
- For groups, `provider` is the `surface` recorded on the session entry.
|
||||
- For direct chats, `provider` maps from `lastChannel`.
|
||||
- For cron/hook/node, `provider` is `internal`.
|
||||
- If missing, `provider` is `unknown`.
|
||||
|
||||
## Security / Send Policy
|
||||
Policy-based blocking by surface/chat type (not per session id).
|
||||
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"sendPolicy": {
|
||||
"rules": [
|
||||
{
|
||||
"match": { "surface": "discord", "chatType": "group" },
|
||||
"action": "deny"
|
||||
}
|
||||
],
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Runtime override (per session entry):
|
||||
- `sendPolicy: "allow" | "deny"` (unset = inherit config)
|
||||
- Settable via `sessions.patch` or owner-only `/send on|off|inherit`.
|
||||
|
||||
Enforcement points:
|
||||
- `chat.send` / `agent` (gateway)
|
||||
- auto-reply delivery logic
|
||||
@@ -26,12 +26,38 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
|
||||
- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
|
||||
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.)
|
||||
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
|
||||
- Other sources:
|
||||
- Cron jobs: `cron:<job.id>`
|
||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||
- Node bridge runs: `node-<nodeId>`
|
||||
|
||||
## Lifecyle
|
||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdis runs a short “hello” greeting turn to confirm the reset.
|
||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||
|
||||
## Send policy (optional)
|
||||
Block delivery for specific session types without listing individual ids.
|
||||
|
||||
```json5
|
||||
{
|
||||
session: {
|
||||
sendPolicy: {
|
||||
rules: [
|
||||
{ action: "deny", match: { surface: "discord", chatType: "group" } },
|
||||
{ action: "deny", match: { keyPrefix: "cron:" } }
|
||||
],
|
||||
default: "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Runtime override (owner only):
|
||||
- `/send on` → allow for this session
|
||||
- `/send off` → deny for this session
|
||||
- `/send inherit` → clear override and use config rules
|
||||
|
||||
## Configuration (optional rename example)
|
||||
```json5
|
||||
// ~/.clawdis/clawdis.json
|
||||
|
||||
@@ -105,6 +105,19 @@ Core actions:
|
||||
Notes:
|
||||
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
||||
|
||||
### `sessions_list` / `sessions_history` / `sessions_send`
|
||||
List sessions, inspect transcript history, or send to another session.
|
||||
|
||||
Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
|
||||
Notes:
|
||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||
|
||||
### `discord`
|
||||
Send Discord reactions, stickers, or polls.
|
||||
|
||||
|
||||
159
src/agents/clawdis-tools.sessions.test.ts
Normal file
159
src/agents/clawdis-tools.sessions.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
}),
|
||||
resolveGatewayPort: () => 18789,
|
||||
}));
|
||||
|
||||
import { createClawdisTools } from "./clawdis-tools.js";
|
||||
|
||||
describe("sessions tools", () => {
|
||||
it("sessions_list filters kinds and includes messages", async () => {
|
||||
callGatewayMock.mockImplementation(async (opts: any) => {
|
||||
if (opts.method === "sessions.list") {
|
||||
return {
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
sessionId: "s-main",
|
||||
updatedAt: 10,
|
||||
lastChannel: "whatsapp",
|
||||
},
|
||||
{
|
||||
key: "discord:group:dev",
|
||||
kind: "group",
|
||||
sessionId: "s-group",
|
||||
updatedAt: 11,
|
||||
surface: "discord",
|
||||
displayName: "discord:g-dev",
|
||||
},
|
||||
{
|
||||
key: "cron:job-1",
|
||||
kind: "direct",
|
||||
sessionId: "s-cron",
|
||||
updatedAt: 9,
|
||||
},
|
||||
{ key: "global", kind: "global" },
|
||||
{ key: "unknown", kind: "unknown" },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opts.method === "chat.history") {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "toolResult", content: [] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdisTools().find(
|
||||
(candidate) => candidate.name === "sessions_list",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_list tool");
|
||||
|
||||
const result = await tool.execute("call1", { messageLimit: 1 });
|
||||
const details = result.details as { sessions?: any[] };
|
||||
expect(details.sessions).toHaveLength(3);
|
||||
const main = details.sessions?.find((s) => s.key === "main");
|
||||
expect(main?.provider).toBe("whatsapp");
|
||||
expect(main?.messages?.length).toBe(1);
|
||||
expect(main?.messages?.[0]?.role).toBe("assistant");
|
||||
|
||||
const cronOnly = await tool.execute("call2", { kinds: ["cron"] });
|
||||
const cronDetails = cronOnly.details as { sessions?: any[] };
|
||||
expect(cronDetails.sessions).toHaveLength(1);
|
||||
expect(cronDetails.sessions?.[0]?.kind).toBe("cron");
|
||||
});
|
||||
|
||||
it("sessions_history filters tool messages by default", async () => {
|
||||
callGatewayMock.mockImplementation(async (opts: any) => {
|
||||
if (opts.method === "chat.history") {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "toolResult", content: [] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "ok" }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdisTools().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: "main" });
|
||||
const details = result.details as { messages?: any[] };
|
||||
expect(details.messages).toHaveLength(1);
|
||||
expect(details.messages?.[0]?.role).toBe("assistant");
|
||||
|
||||
const withTools = await tool.execute("call4", {
|
||||
sessionKey: "main",
|
||||
includeTools: true,
|
||||
});
|
||||
const withToolsDetails = withTools.details as { messages?: any[] };
|
||||
expect(withToolsDetails.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sessions_send supports fire-and-forget and wait", async () => {
|
||||
callGatewayMock.mockImplementation(async (opts: any) => {
|
||||
if (opts.method === "agent") {
|
||||
return opts.expectFinal
|
||||
? { runId: "run-1", status: "ok" }
|
||||
: { runId: "run-1", status: "accepted" };
|
||||
}
|
||||
if (opts.method === "chat.history") {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "assistant", content: [{ type: "text", text: "done" }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdisTools().find(
|
||||
(candidate) => candidate.name === "sessions_send",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
const fire = await tool.execute("call5", {
|
||||
sessionKey: "main",
|
||||
message: "ping",
|
||||
timeoutSeconds: 0,
|
||||
});
|
||||
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
|
||||
|
||||
const waitPromise = tool.execute("call6", {
|
||||
sessionKey: "main",
|
||||
message: "wait",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
const waited = await waitPromise;
|
||||
expect(waited.details).toMatchObject({
|
||||
status: "ok",
|
||||
runId: "run-1",
|
||||
reply: "done",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
@@ -40,7 +41,11 @@ import {
|
||||
writeScreenRecordToFile,
|
||||
} from "../cli/nodes-screen.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { type DiscordActionConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
type ClawdisConfig,
|
||||
type DiscordActionConfig,
|
||||
loadConfig,
|
||||
} from "../config/config.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
@@ -208,6 +213,126 @@ function jsonResult(payload: unknown): AgentToolResult<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
|
||||
type SessionListRow = {
|
||||
key: string;
|
||||
kind: SessionKind;
|
||||
provider: string;
|
||||
displayName?: string;
|
||||
updatedAt?: number | null;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
contextTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
sendPolicy?: string;
|
||||
lastChannel?: string;
|
||||
lastTo?: string;
|
||||
transcriptPath?: string;
|
||||
messages?: unknown[];
|
||||
};
|
||||
|
||||
function normalizeKey(value?: string) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function resolveMainSessionAlias(cfg: ClawdisConfig) {
|
||||
const mainKey = normalizeKey(cfg.session?.mainKey) ?? "main";
|
||||
const scope = cfg.session?.scope ?? "per-sender";
|
||||
const alias = scope === "global" ? "global" : mainKey;
|
||||
return { mainKey, alias, scope };
|
||||
}
|
||||
|
||||
function resolveDisplaySessionKey(params: {
|
||||
key: string;
|
||||
alias: string;
|
||||
mainKey: string;
|
||||
}) {
|
||||
if (params.key === params.alias) return "main";
|
||||
if (params.key === params.mainKey) return "main";
|
||||
return params.key;
|
||||
}
|
||||
|
||||
function resolveInternalSessionKey(params: {
|
||||
key: string;
|
||||
alias: string;
|
||||
mainKey: string;
|
||||
}) {
|
||||
if (params.key === "main") return params.alias;
|
||||
return params.key;
|
||||
}
|
||||
|
||||
function classifySessionKind(params: {
|
||||
key: string;
|
||||
gatewayKind?: string | null;
|
||||
alias: string;
|
||||
mainKey: string;
|
||||
}): SessionKind {
|
||||
const key = params.key;
|
||||
if (key === params.alias || key === params.mainKey) return "main";
|
||||
if (key.startsWith("cron:")) return "cron";
|
||||
if (key.startsWith("hook:")) return "hook";
|
||||
if (key.startsWith("node-") || key.startsWith("node:")) return "node";
|
||||
if (params.gatewayKind === "group") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function deriveProvider(params: {
|
||||
key: string;
|
||||
kind: SessionKind;
|
||||
surface?: string | null;
|
||||
lastChannel?: string | null;
|
||||
}): string {
|
||||
if (params.kind === "cron" || params.kind === "hook" || params.kind === "node")
|
||||
return "internal";
|
||||
const surface = normalizeKey(params.surface ?? undefined);
|
||||
if (surface) return surface;
|
||||
const lastChannel = normalizeKey(params.lastChannel ?? undefined);
|
||||
if (lastChannel) return lastChannel;
|
||||
const parts = params.key.split(":").filter(Boolean);
|
||||
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
|
||||
return parts[0];
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function stripToolMessages(messages: unknown[]): unknown[] {
|
||||
return messages.filter((msg) => {
|
||||
if (!msg || typeof msg !== "object") return true;
|
||||
const role = (msg as { role?: unknown }).role;
|
||||
return role !== "toolResult";
|
||||
});
|
||||
}
|
||||
|
||||
function extractAssistantText(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== "object") return undefined;
|
||||
if ((message as { role?: unknown }).role !== "assistant") return undefined;
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const chunks: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if ((block as { type?: unknown }).type !== "text") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
chunks.push(text);
|
||||
}
|
||||
}
|
||||
const joined = chunks.join("").trim();
|
||||
return joined ? joined : undefined;
|
||||
}
|
||||
|
||||
async function imageResult(params: {
|
||||
label: string;
|
||||
path: string;
|
||||
@@ -2308,6 +2433,328 @@ function createGatewayTool(): AnyAgentTool {
|
||||
};
|
||||
}
|
||||
|
||||
const SessionsListToolSchema = Type.Object({
|
||||
kinds: Type.Optional(Type.Array(Type.String())),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
messageLimit: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
});
|
||||
|
||||
const SessionsHistoryToolSchema = Type.Object({
|
||||
sessionKey: Type.String(),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
includeTools: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const SessionsSendToolSchema = Type.Object({
|
||||
sessionKey: Type.String(),
|
||||
message: Type.String(),
|
||||
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
});
|
||||
|
||||
function createSessionsListTool(): AnyAgentTool {
|
||||
return {
|
||||
label: "Sessions",
|
||||
name: "sessions_list",
|
||||
description: "List sessions with optional filters and last messages.",
|
||||
parameters: SessionsListToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const cfg = loadConfig();
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
|
||||
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
|
||||
value.trim().toLowerCase(),
|
||||
);
|
||||
const allowedKindsList = (kindsRaw ?? []).filter((value) =>
|
||||
["main", "group", "cron", "hook", "node", "other"].includes(value),
|
||||
);
|
||||
const allowedKinds = allowedKindsList.length
|
||||
? new Set(allowedKindsList)
|
||||
: undefined;
|
||||
|
||||
const limit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? Math.max(1, Math.floor(params.limit))
|
||||
: undefined;
|
||||
const activeMinutes =
|
||||
typeof params.activeMinutes === "number" &&
|
||||
Number.isFinite(params.activeMinutes)
|
||||
? Math.max(1, Math.floor(params.activeMinutes))
|
||||
: undefined;
|
||||
const messageLimitRaw =
|
||||
typeof params.messageLimit === "number" &&
|
||||
Number.isFinite(params.messageLimit)
|
||||
? Math.max(0, Math.floor(params.messageLimit))
|
||||
: 0;
|
||||
const messageLimit = Math.min(messageLimitRaw, 20);
|
||||
|
||||
const list = (await callGateway({
|
||||
method: "sessions.list",
|
||||
params: {
|
||||
limit,
|
||||
activeMinutes,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
},
|
||||
})) as {
|
||||
path?: string;
|
||||
sessions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||
const storePath =
|
||||
typeof list?.path === "string" ? list.path : undefined;
|
||||
const rows: SessionListRow[] = [];
|
||||
|
||||
for (const entry of sessions) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const key = typeof entry.key === "string" ? entry.key : "";
|
||||
if (!key) continue;
|
||||
if (key === "unknown") continue;
|
||||
if (key === "global" && alias !== "global") continue;
|
||||
|
||||
const gatewayKind =
|
||||
typeof entry.kind === "string" ? entry.kind : undefined;
|
||||
const kind = classifySessionKind({ key, gatewayKind, alias, mainKey });
|
||||
if (allowedKinds && !allowedKinds.has(kind)) continue;
|
||||
|
||||
const displayKey = resolveDisplaySessionKey({
|
||||
key,
|
||||
alias,
|
||||
mainKey,
|
||||
});
|
||||
|
||||
const surface =
|
||||
typeof entry.surface === "string" ? entry.surface : undefined;
|
||||
const lastChannel =
|
||||
typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
|
||||
const provider = deriveProvider({
|
||||
key,
|
||||
kind,
|
||||
surface,
|
||||
lastChannel,
|
||||
});
|
||||
|
||||
const sessionId =
|
||||
typeof entry.sessionId === "string" ? entry.sessionId : undefined;
|
||||
const transcriptPath =
|
||||
sessionId && storePath
|
||||
? path.join(path.dirname(storePath), `${sessionId}.jsonl`)
|
||||
: undefined;
|
||||
|
||||
const row: SessionListRow = {
|
||||
key: displayKey,
|
||||
kind,
|
||||
provider,
|
||||
displayName:
|
||||
typeof entry.displayName === "string"
|
||||
? entry.displayName
|
||||
: undefined,
|
||||
updatedAt:
|
||||
typeof entry.updatedAt === "number" ? entry.updatedAt : undefined,
|
||||
sessionId,
|
||||
model: typeof entry.model === "string" ? entry.model : undefined,
|
||||
contextTokens:
|
||||
typeof entry.contextTokens === "number"
|
||||
? entry.contextTokens
|
||||
: undefined,
|
||||
totalTokens:
|
||||
typeof entry.totalTokens === "number"
|
||||
? entry.totalTokens
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
typeof entry.thinkingLevel === "string"
|
||||
? entry.thinkingLevel
|
||||
: undefined,
|
||||
verboseLevel:
|
||||
typeof entry.verboseLevel === "string"
|
||||
? entry.verboseLevel
|
||||
: undefined,
|
||||
systemSent:
|
||||
typeof entry.systemSent === "boolean" ? entry.systemSent : undefined,
|
||||
abortedLastRun:
|
||||
typeof entry.abortedLastRun === "boolean"
|
||||
? entry.abortedLastRun
|
||||
: undefined,
|
||||
sendPolicy:
|
||||
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
|
||||
lastChannel,
|
||||
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
if (messageLimit > 0) {
|
||||
const resolvedKey = resolveInternalSessionKey({
|
||||
key: displayKey,
|
||||
alias,
|
||||
mainKey,
|
||||
});
|
||||
const history = (await callGateway({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: resolvedKey, limit: messageLimit },
|
||||
})) as { messages?: unknown[] };
|
||||
const rawMessages = Array.isArray(history?.messages)
|
||||
? history.messages
|
||||
: [];
|
||||
const filtered = stripToolMessages(rawMessages);
|
||||
row.messages =
|
||||
filtered.length > messageLimit
|
||||
? filtered.slice(-messageLimit)
|
||||
: filtered;
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return jsonResult({
|
||||
count: rows.length,
|
||||
sessions: rows,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionsHistoryTool(): AnyAgentTool {
|
||||
return {
|
||||
label: "Session History",
|
||||
name: "sessions_history",
|
||||
description: "Fetch message history for a session.",
|
||||
parameters: SessionsHistoryToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const sessionKey = readStringParam(params, "sessionKey", {
|
||||
required: true,
|
||||
});
|
||||
const cfg = loadConfig();
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
const resolvedKey = resolveInternalSessionKey({
|
||||
key: sessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
});
|
||||
const limit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? Math.max(1, Math.floor(params.limit))
|
||||
: undefined;
|
||||
const includeTools = Boolean(params.includeTools);
|
||||
const result = (await callGateway({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: resolvedKey, limit },
|
||||
})) as { messages?: unknown[] };
|
||||
const rawMessages = Array.isArray(result?.messages)
|
||||
? result.messages
|
||||
: [];
|
||||
const messages = includeTools
|
||||
? rawMessages
|
||||
: stripToolMessages(rawMessages);
|
||||
return jsonResult({
|
||||
sessionKey: resolveDisplaySessionKey({
|
||||
key: sessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
}),
|
||||
messages,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionsSendTool(): AnyAgentTool {
|
||||
return {
|
||||
label: "Session Send",
|
||||
name: "sessions_send",
|
||||
description: "Send a message into another session.",
|
||||
parameters: SessionsSendToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const sessionKey = readStringParam(params, "sessionKey", {
|
||||
required: true,
|
||||
});
|
||||
const message = readStringParam(params, "message", { required: true });
|
||||
const cfg = loadConfig();
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
const resolvedKey = resolveInternalSessionKey({
|
||||
key: sessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
});
|
||||
const timeoutSeconds =
|
||||
typeof params.timeoutSeconds === "number" &&
|
||||
Number.isFinite(params.timeoutSeconds)
|
||||
? Math.max(0, Math.floor(params.timeoutSeconds))
|
||||
: 30;
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
try {
|
||||
const response = (await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
message,
|
||||
sessionKey: resolvedKey,
|
||||
idempotencyKey,
|
||||
deliver: false,
|
||||
},
|
||||
expectFinal: timeoutSeconds > 0,
|
||||
timeoutMs: timeoutSeconds > 0 ? timeoutSeconds * 1000 : undefined,
|
||||
})) as { runId?: string; status?: string };
|
||||
|
||||
const runId =
|
||||
typeof response?.runId === "string" && response.runId
|
||||
? response.runId
|
||||
: idempotencyKey;
|
||||
|
||||
if (timeoutSeconds === 0) {
|
||||
return jsonResult({
|
||||
runId,
|
||||
status: "accepted",
|
||||
sessionKey: resolveDisplaySessionKey({
|
||||
key: sessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const history = (await callGateway({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: resolvedKey, limit: 50 },
|
||||
})) as { messages?: unknown[] };
|
||||
const filtered = stripToolMessages(
|
||||
Array.isArray(history?.messages) ? history.messages : [],
|
||||
);
|
||||
const last =
|
||||
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
|
||||
const reply = last ? extractAssistantText(last) : undefined;
|
||||
|
||||
return jsonResult({
|
||||
runId,
|
||||
status: "ok",
|
||||
reply,
|
||||
sessionKey: resolveDisplaySessionKey({
|
||||
key: sessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err ?? "error");
|
||||
const isTimeout = message.toLowerCase().includes("timeout");
|
||||
return jsonResult({
|
||||
runId: idempotencyKey,
|
||||
status: isTimeout ? "timeout" : "error",
|
||||
error: message,
|
||||
sessionKey: resolveDisplaySessionKey({
|
||||
key: sessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createClawdisTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
}): AnyAgentTool[] {
|
||||
@@ -2318,5 +2765,8 @@ export function createClawdisTools(options?: {
|
||||
createCronTool(),
|
||||
createDiscordTool(),
|
||||
createGatewayTool(),
|
||||
createSessionsListTool(),
|
||||
createSessionsHistoryTool(),
|
||||
createSessionsSendTool(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -150,6 +150,21 @@
|
||||
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
|
||||
}
|
||||
},
|
||||
"sessions_list": {
|
||||
"emoji": "🗂️",
|
||||
"title": "Sessions",
|
||||
"detailKeys": ["kinds", "limit", "activeMinutes", "messageLimit"]
|
||||
},
|
||||
"sessions_history": {
|
||||
"emoji": "🧾",
|
||||
"title": "Session History",
|
||||
"detailKeys": ["sessionKey", "limit"]
|
||||
},
|
||||
"sessions_send": {
|
||||
"emoji": "📨",
|
||||
"title": "Session Send",
|
||||
"detailKeys": ["sessionKey", "timeoutSeconds"]
|
||||
},
|
||||
"whatsapp_login": {
|
||||
"emoji": "🟢",
|
||||
"title": "WhatsApp Login",
|
||||
|
||||
@@ -107,6 +107,39 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows owner to set send policy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/send off",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Surface: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Send policy set to off");
|
||||
|
||||
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
|
||||
const store = JSON.parse(storeRaw) as Record<string, { sendPolicy?: string }>;
|
||||
expect(store.main?.sendPolicy).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a context overflow fallback when the embedded agent throws", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from "../infra/system-events.js";
|
||||
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
normalizeGroupActivation,
|
||||
parseActivationCommand,
|
||||
} from "./group-activation.js";
|
||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||
import { stripHeartbeatToken } from "./heartbeat.js";
|
||||
import { extractModelDirective } from "./model.js";
|
||||
import { buildStatusMessage } from "./status.js";
|
||||
@@ -986,6 +988,7 @@ export async function getReplyFromConfig(
|
||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
sendPolicy: baseEntry?.sendPolicy,
|
||||
queueMode: baseEntry?.queueMode,
|
||||
queueDebounceMs: baseEntry?.queueDebounceMs,
|
||||
queueCap: baseEntry?.queueCap,
|
||||
@@ -1587,6 +1590,7 @@ export async function getReplyFromConfig(
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg)
|
||||
: rawBodyNormalized;
|
||||
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
||||
const sendPolicyCommand = parseSendPolicyCommand(commandBodyNormalized);
|
||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||
const ownerCandidates = isWhatsAppSurface
|
||||
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
|
||||
@@ -1633,6 +1637,38 @@ export async function getReplyFromConfig(
|
||||
};
|
||||
}
|
||||
|
||||
if (sendPolicyCommand.hasCommand) {
|
||||
if (!isOwnerSender) {
|
||||
logVerbose(
|
||||
`Ignoring /send from non-owner: ${senderE164 || "<unknown>"}`,
|
||||
);
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
if (!sendPolicyCommand.mode) {
|
||||
cleanupTyping();
|
||||
return { text: "⚙️ Usage: /send on|off|inherit" };
|
||||
}
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
if (sendPolicyCommand.mode === "inherit") {
|
||||
delete sessionEntry.sendPolicy;
|
||||
} else {
|
||||
sessionEntry.sendPolicy = sendPolicyCommand.mode;
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
cleanupTyping();
|
||||
const label =
|
||||
sendPolicyCommand.mode === "inherit"
|
||||
? "inherit"
|
||||
: sendPolicyCommand.mode === "allow"
|
||||
? "on"
|
||||
: "off";
|
||||
return { text: `⚙️ Send policy set to ${label}.` };
|
||||
}
|
||||
|
||||
if (
|
||||
commandBodyNormalized === "/restart" ||
|
||||
commandBodyNormalized === "restart" ||
|
||||
@@ -1710,6 +1746,21 @@ export async function getReplyFromConfig(
|
||||
return { text: "⚙️ Agent was aborted." };
|
||||
}
|
||||
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry: sessionEntry,
|
||||
sessionKey,
|
||||
surface: sessionEntry?.surface ?? surface,
|
||||
chatType: sessionEntry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
logVerbose(
|
||||
`Send blocked by policy for session ${sessionKey ?? "unknown"}`,
|
||||
);
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isFirstTurnInSession = isNewSession || !systemSent;
|
||||
const isGroupChat = sessionCtx.ChatType === "group";
|
||||
const wasMentioned = ctx.WasMentioned === true;
|
||||
|
||||
29
src/auto-reply/send-policy.ts
Normal file
29
src/auto-reply/send-policy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type SendPolicyOverride = "allow" | "deny";
|
||||
|
||||
export function normalizeSendPolicyOverride(
|
||||
raw?: string | null,
|
||||
): SendPolicyOverride | undefined {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (!value) return undefined;
|
||||
if (value === "allow" || value === "on") return "allow";
|
||||
if (value === "deny" || value === "off") return "deny";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseSendPolicyCommand(raw?: string): {
|
||||
hasCommand: boolean;
|
||||
mode?: SendPolicyOverride | "inherit";
|
||||
} {
|
||||
if (!raw) return { hasCommand: false };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { hasCommand: false };
|
||||
const match = trimmed.match(/^\/?send\b(?:\s+([a-zA-Z]+))?/i);
|
||||
if (!match) return { hasCommand: false };
|
||||
const token = match[1]?.trim().toLowerCase();
|
||||
if (!token) return { hasCommand: true };
|
||||
if (token === "inherit" || token === "default" || token === "reset") {
|
||||
return { hasCommand: true, mode: "inherit" };
|
||||
}
|
||||
const mode = normalizeSendPolicyOverride(token);
|
||||
return { hasCommand: true, mode };
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
registerAgentRunContext,
|
||||
} from "../infra/agent-events.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
|
||||
@@ -212,6 +213,19 @@ export async function agentCommand(
|
||||
registerAgentRunContext(sessionId, { sessionKey });
|
||||
}
|
||||
|
||||
if (opts.deliver === true) {
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry: sessionEntry,
|
||||
sessionKey,
|
||||
surface: sessionEntry?.surface,
|
||||
chatType: sessionEntry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
throw new Error("send blocked by session policy");
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedThinkLevel =
|
||||
thinkOnce ??
|
||||
thinkOverride ??
|
||||
|
||||
@@ -19,6 +19,20 @@ export const isNixMode = process.env.CLAWDIS_NIX_MODE === "1";
|
||||
export type ReplyMode = "text" | "command";
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
export type ReplyToMode = "off" | "first" | "all";
|
||||
export type SessionSendPolicyAction = "allow" | "deny";
|
||||
export type SessionSendPolicyMatch = {
|
||||
surface?: string;
|
||||
chatType?: "direct" | "group" | "room";
|
||||
keyPrefix?: string;
|
||||
};
|
||||
export type SessionSendPolicyRule = {
|
||||
action: SessionSendPolicyAction;
|
||||
match?: SessionSendPolicyMatch;
|
||||
};
|
||||
export type SessionSendPolicyConfig = {
|
||||
default?: SessionSendPolicyAction;
|
||||
rules?: SessionSendPolicyRule[];
|
||||
};
|
||||
|
||||
export type SessionConfig = {
|
||||
scope?: SessionScope;
|
||||
@@ -28,6 +42,7 @@ export type SessionConfig = {
|
||||
store?: string;
|
||||
typingIntervalSeconds?: number;
|
||||
mainKey?: string;
|
||||
sendPolicy?: SessionSendPolicyConfig;
|
||||
};
|
||||
|
||||
export type LoggingConfig = {
|
||||
@@ -853,6 +868,31 @@ const SessionSchema = z
|
||||
store: z.string().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
mainKey: z.string().optional(),
|
||||
sendPolicy: z
|
||||
.object({
|
||||
default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
|
||||
rules: z
|
||||
.array(
|
||||
z.object({
|
||||
action: z.union([z.literal("allow"), z.literal("deny")]),
|
||||
match: z
|
||||
.object({
|
||||
surface: z.string().optional(),
|
||||
chatType: z
|
||||
.union([
|
||||
z.literal("direct"),
|
||||
z.literal("group"),
|
||||
z.literal("room"),
|
||||
])
|
||||
.optional(),
|
||||
keyPrefix: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export type SessionEntry = {
|
||||
modelOverride?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
sendPolicy?: "allow" | "deny";
|
||||
queueMode?:
|
||||
| "steer"
|
||||
| "followup"
|
||||
@@ -320,6 +321,7 @@ export async function updateLastRoute(params: {
|
||||
verboseLevel: existing?.verboseLevel,
|
||||
providerOverride: existing?.providerOverride,
|
||||
modelOverride: existing?.modelOverride,
|
||||
sendPolicy: existing?.sendPolicy,
|
||||
queueMode: existing?.queueMode,
|
||||
inputTokens: existing?.inputTokens,
|
||||
outputTokens: existing?.outputTokens,
|
||||
|
||||
@@ -150,6 +150,7 @@ function resolveCronSession(params: {
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
|
||||
@@ -298,6 +298,13 @@ export const SessionsPatchParamsSchema = Type.Object(
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
sendPolicy: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("allow"),
|
||||
Type.Literal("deny"),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
groupActivation: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("mention"),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
import {
|
||||
loadVoiceWakeConfig,
|
||||
setVoiceWakeTriggers,
|
||||
@@ -443,6 +444,25 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if ("sendPolicy" in p) {
|
||||
const raw = p.sendPolicy;
|
||||
if (raw === null) {
|
||||
delete next.sendPolicy;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeSendPolicy(String(raw));
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: 'invalid sendPolicy (use "allow"|"deny")',
|
||||
},
|
||||
};
|
||||
}
|
||||
next.sendPolicy = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in p) {
|
||||
const raw = p.groupActivation;
|
||||
if (raw === null) {
|
||||
@@ -507,6 +527,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
displayName: entry?.displayName,
|
||||
chatType: entry?.chatType,
|
||||
surface: entry?.surface,
|
||||
@@ -999,6 +1020,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
@@ -1080,6 +1102,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
|
||||
@@ -75,6 +75,10 @@ import {
|
||||
} from "../infra/voicewake.js";
|
||||
import { webAuthExists } from "../providers/web/index.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
normalizeSendPolicy,
|
||||
resolveSendPolicy,
|
||||
} from "../sessions/send-policy.js";
|
||||
import { sendMessageSignal } from "../signal/index.js";
|
||||
import { probeSignal, type SignalProbe } from "../signal/probe.js";
|
||||
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
||||
@@ -701,7 +705,7 @@ export async function handleGatewayRequest(
|
||||
break;
|
||||
}
|
||||
}
|
||||
const { storePath, store, entry } = loadSessionEntry(p.sessionKey);
|
||||
const { cfg, storePath, store, entry } = loadSessionEntry(p.sessionKey);
|
||||
const now = Date.now();
|
||||
const sessionId = entry?.sessionId ?? randomUUID();
|
||||
const sessionEntry: SessionEntry = {
|
||||
@@ -710,11 +714,31 @@ export async function handleGatewayRequest(
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
const clientRunId = p.idempotencyKey;
|
||||
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry,
|
||||
sessionKey: p.sessionKey,
|
||||
surface: entry?.surface,
|
||||
chatType: entry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"send blocked by session policy",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const cached = dedupe.get(`chat:${clientRunId}`);
|
||||
if (cached) {
|
||||
respond(cached.ok, cached.payload, cached.error, {
|
||||
@@ -1677,6 +1701,27 @@ export async function handleGatewayRequest(
|
||||
}
|
||||
}
|
||||
|
||||
if ("sendPolicy" in p) {
|
||||
const raw = p.sendPolicy;
|
||||
if (raw === null) {
|
||||
delete next.sendPolicy;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeSendPolicy(String(raw));
|
||||
if (!normalized) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
'invalid sendPolicy (use "allow"|"deny")',
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
next.sendPolicy = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in p) {
|
||||
const raw = p.groupActivation;
|
||||
if (raw === null) {
|
||||
@@ -1744,6 +1789,7 @@ export async function handleGatewayRequest(
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
@@ -2739,10 +2785,29 @@ export async function handleGatewayRequest(
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry,
|
||||
sessionKey: requestedSessionKey,
|
||||
surface: entry?.surface,
|
||||
chatType: entry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"send blocked by session policy",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (store) {
|
||||
store[requestedSessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
|
||||
@@ -18,6 +18,96 @@ import {
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server chat", () => {
|
||||
test("chat.send blocked by send policy", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
testState.sessionConfig = {
|
||||
sendPolicy: {
|
||||
default: "allow",
|
||||
rules: [
|
||||
{
|
||||
action: "deny",
|
||||
match: { surface: "discord", chatType: "group" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-discord",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "group",
|
||||
surface: "discord",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "chat.send", {
|
||||
sessionKey: "discord:group:dev",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-1",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(
|
||||
(res.error as { message?: string } | undefined)?.message ?? "",
|
||||
).toMatch(/send blocked/i);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("agent blocked by send policy for sessionKey", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
testState.sessionConfig = {
|
||||
sendPolicy: {
|
||||
default: "allow",
|
||||
rules: [{ action: "deny", match: { keyPrefix: "cron:" } }],
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"cron:job-1": {
|
||||
sessionId: "sess-cron",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const res = await rpcReq(ws, "agent", {
|
||||
sessionKey: "cron:job-1",
|
||||
message: "hi",
|
||||
idempotencyKey: "idem-2",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(
|
||||
(res.error as { message?: string } | undefined)?.message ?? "",
|
||||
).toMatch(/send blocked/i);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
@@ -126,17 +126,26 @@ describe("gateway server sessions", () => {
|
||||
expect(patched.payload?.ok).toBe(true);
|
||||
expect(patched.payload?.key).toBe("main");
|
||||
|
||||
const sendPolicyPatched = await rpcReq<{
|
||||
ok: true;
|
||||
entry: { sendPolicy?: string };
|
||||
}>(ws, "sessions.patch", { key: "main", sendPolicy: "deny" });
|
||||
expect(sendPolicyPatched.ok).toBe(true);
|
||||
expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny");
|
||||
|
||||
const list2 = await rpcReq<{
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
sendPolicy?: string;
|
||||
}>;
|
||||
}>(ws, "sessions.list", {});
|
||||
expect(list2.ok).toBe(true);
|
||||
const main2 = list2.payload?.sessions.find((s) => s.key === "main");
|
||||
expect(main2?.thinkingLevel).toBe("medium");
|
||||
expect(main2?.verboseLevel).toBeUndefined();
|
||||
expect(main2?.sendPolicy).toBe("deny");
|
||||
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||
|
||||
@@ -29,17 +29,21 @@ export type GatewaySessionRow = {
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
chatType?: "direct" | "group" | "room";
|
||||
updatedAt: number | null;
|
||||
sessionId?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
sendPolicy?: "allow" | "deny";
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
lastChannel?: SessionEntry["lastChannel"];
|
||||
lastTo?: string;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
@@ -265,17 +269,21 @@ export function listSessionsFromStore(params: {
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
chatType: entry?.chatType,
|
||||
updatedAt,
|
||||
sessionId: entry?.sessionId,
|
||||
systemSent: entry?.systemSent,
|
||||
abortedLastRun: entry?.abortedLastRun,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
inputTokens: entry?.inputTokens,
|
||||
outputTokens: entry?.outputTokens,
|
||||
totalTokens: total,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
} satisfies GatewaySessionRow;
|
||||
})
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
@@ -79,6 +79,7 @@ export const agentCommand = hoisted.agentCommand;
|
||||
|
||||
export const testState = {
|
||||
sessionStorePath: undefined as string | undefined,
|
||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||
allowFrom: undefined as string[] | undefined,
|
||||
cronStorePath: undefined as string | undefined,
|
||||
cronEnabled: false as boolean | undefined,
|
||||
@@ -239,7 +240,11 @@ vi.mock("../config/config.js", async () => {
|
||||
whatsapp: {
|
||||
allowFrom: testState.allowFrom,
|
||||
},
|
||||
session: { mainKey: "main", store: testState.sessionStorePath },
|
||||
session: {
|
||||
mainKey: "main",
|
||||
store: testState.sessionStorePath,
|
||||
...(testState.sessionConfig ?? {}),
|
||||
},
|
||||
gateway: (() => {
|
||||
const gateway: Record<string, unknown> = {};
|
||||
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
|
||||
@@ -318,6 +323,7 @@ export function installGatewayTestHooks() {
|
||||
testState.migrationChanges = [];
|
||||
testState.cronEnabled = false;
|
||||
testState.cronStorePath = undefined;
|
||||
testState.sessionConfig = undefined;
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.allowFrom = undefined;
|
||||
testIsNixMode.value = false;
|
||||
|
||||
53
src/sessions/send-policy.test.ts
Normal file
53
src/sessions/send-policy.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { resolveSendPolicy } from "./send-policy.js";
|
||||
|
||||
describe("resolveSendPolicy", () => {
|
||||
it("defaults to allow", () => {
|
||||
const cfg = {} as ClawdisConfig;
|
||||
expect(resolveSendPolicy({ cfg })).toBe("allow");
|
||||
});
|
||||
|
||||
it("entry override wins", () => {
|
||||
const cfg = {
|
||||
session: { sendPolicy: { default: "allow" } },
|
||||
} as ClawdisConfig;
|
||||
const entry: SessionEntry = { sessionId: "s", updatedAt: 0, sendPolicy: "deny" };
|
||||
expect(resolveSendPolicy({ cfg, entry })).toBe("deny");
|
||||
});
|
||||
|
||||
it("rule match by surface + chatType", () => {
|
||||
const cfg = {
|
||||
session: {
|
||||
sendPolicy: {
|
||||
default: "allow",
|
||||
rules: [
|
||||
{ action: "deny", match: { surface: "discord", chatType: "group" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as ClawdisConfig;
|
||||
const entry: SessionEntry = {
|
||||
sessionId: "s",
|
||||
updatedAt: 0,
|
||||
surface: "discord",
|
||||
chatType: "group",
|
||||
};
|
||||
expect(resolveSendPolicy({ cfg, entry, sessionKey: "discord:group:dev" })).toBe(
|
||||
"deny",
|
||||
);
|
||||
});
|
||||
|
||||
it("rule match by keyPrefix", () => {
|
||||
const cfg = {
|
||||
session: {
|
||||
sendPolicy: {
|
||||
default: "allow",
|
||||
rules: [{ action: "deny", match: { keyPrefix: "cron:" } }],
|
||||
},
|
||||
},
|
||||
} as ClawdisConfig;
|
||||
expect(resolveSendPolicy({ cfg, sessionKey: "cron:job-1" })).toBe("deny");
|
||||
});
|
||||
});
|
||||
79
src/sessions/send-policy.ts
Normal file
79
src/sessions/send-policy.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import type { SessionEntry, SessionChatType } from "../config/sessions.js";
|
||||
|
||||
export type SessionSendPolicyDecision = "allow" | "deny";
|
||||
|
||||
export function normalizeSendPolicy(
|
||||
raw?: string | null,
|
||||
): SessionSendPolicyDecision | undefined {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (value === "allow") return "allow";
|
||||
if (value === "deny") return "deny";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMatchValue(raw?: string | null) {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function deriveSurfaceFromKey(key?: string) {
|
||||
if (!key) return undefined;
|
||||
const parts = key.split(":").filter(Boolean);
|
||||
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
|
||||
return normalizeMatchValue(parts[0]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function deriveChatTypeFromKey(key?: string): SessionChatType | undefined {
|
||||
if (!key) return undefined;
|
||||
if (key.startsWith("group:") || key.includes(":group:")) return "group";
|
||||
if (key.includes(":channel:")) return "room";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSendPolicy(params: {
|
||||
cfg: ClawdisConfig;
|
||||
entry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
surface?: string;
|
||||
chatType?: SessionChatType;
|
||||
}): SessionSendPolicyDecision {
|
||||
const override = normalizeSendPolicy(params.entry?.sendPolicy);
|
||||
if (override) return override;
|
||||
|
||||
const policy = params.cfg.session?.sendPolicy;
|
||||
if (!policy) return "allow";
|
||||
|
||||
const surface =
|
||||
normalizeMatchValue(params.surface) ??
|
||||
normalizeMatchValue(params.entry?.surface) ??
|
||||
normalizeMatchValue(params.entry?.lastChannel) ??
|
||||
deriveSurfaceFromKey(params.sessionKey);
|
||||
const chatType =
|
||||
normalizeMatchValue(params.chatType ?? params.entry?.chatType) ??
|
||||
normalizeMatchValue(deriveChatTypeFromKey(params.sessionKey));
|
||||
const sessionKey = params.sessionKey ?? "";
|
||||
|
||||
let allowedMatch = false;
|
||||
for (const rule of policy.rules ?? []) {
|
||||
if (!rule) continue;
|
||||
const action = normalizeSendPolicy(rule.action) ?? "allow";
|
||||
const match = rule.match ?? {};
|
||||
const matchSurface = normalizeMatchValue(match.surface);
|
||||
const matchChatType = normalizeMatchValue(match.chatType);
|
||||
const matchPrefix = normalizeMatchValue(match.keyPrefix);
|
||||
|
||||
if (matchSurface && matchSurface !== surface) continue;
|
||||
if (matchChatType && matchChatType !== chatType) continue;
|
||||
if (matchPrefix && !sessionKey.startsWith(matchPrefix)) continue;
|
||||
if (action === "deny") return "deny";
|
||||
allowedMatch = true;
|
||||
}
|
||||
|
||||
if (allowedMatch) return "allow";
|
||||
|
||||
const fallback = normalizeSendPolicy(policy.default);
|
||||
return fallback ?? "allow";
|
||||
}
|
||||
@@ -40,10 +40,18 @@ export type GatewaySessionList = {
|
||||
updatedAt?: number | null;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
sendPolicy?: string;
|
||||
model?: string;
|
||||
contextTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
subject?: string;
|
||||
chatType?: string;
|
||||
lastChannel?: string;
|
||||
lastTo?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user