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,
|
idleMinutes: 60,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdis/sessions/sessions.json",
|
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)
|
### `skills` (skills config)
|
||||||
|
|
||||||
Controls bundled allowlist, install preferences, extra skill folders, and per-skill
|
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.
|
- 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>`.)
|
- 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.
|
- 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
|
## Lifecyle
|
||||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
- 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.
|
- 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.
|
- 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)
|
## Configuration (optional rename example)
|
||||||
```json5
|
```json5
|
||||||
// ~/.clawdis/clawdis.json
|
// ~/.clawdis/clawdis.json
|
||||||
|
|||||||
@@ -105,6 +105,19 @@ Core actions:
|
|||||||
Notes:
|
Notes:
|
||||||
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
- 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`
|
### `discord`
|
||||||
Send Discord reactions, stickers, or polls.
|
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 crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
@@ -40,7 +41,11 @@ import {
|
|||||||
writeScreenRecordToFile,
|
writeScreenRecordToFile,
|
||||||
} from "../cli/nodes-screen.js";
|
} from "../cli/nodes-screen.js";
|
||||||
import { parseDurationMs } from "../cli/parse-duration.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 {
|
import {
|
||||||
addRoleDiscord,
|
addRoleDiscord,
|
||||||
banMemberDiscord,
|
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: {
|
async function imageResult(params: {
|
||||||
label: string;
|
label: string;
|
||||||
path: 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?: {
|
export function createClawdisTools(options?: {
|
||||||
browserControlUrl?: string;
|
browserControlUrl?: string;
|
||||||
}): AnyAgentTool[] {
|
}): AnyAgentTool[] {
|
||||||
@@ -2318,5 +2765,8 @@ export function createClawdisTools(options?: {
|
|||||||
createCronTool(),
|
createCronTool(),
|
||||||
createDiscordTool(),
|
createDiscordTool(),
|
||||||
createGatewayTool(),
|
createGatewayTool(),
|
||||||
|
createSessionsListTool(),
|
||||||
|
createSessionsHistoryTool(),
|
||||||
|
createSessionsSendTool(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,21 @@
|
|||||||
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
|
"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": {
|
"whatsapp_login": {
|
||||||
"emoji": "🟢",
|
"emoji": "🟢",
|
||||||
"title": "WhatsApp Login",
|
"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 () => {
|
it("returns a context overflow fallback when the embedded agent throws", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
|
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
} from "../infra/system-events.js";
|
} from "../infra/system-events.js";
|
||||||
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||||
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
normalizeGroupActivation,
|
normalizeGroupActivation,
|
||||||
parseActivationCommand,
|
parseActivationCommand,
|
||||||
} from "./group-activation.js";
|
} from "./group-activation.js";
|
||||||
|
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||||
import { stripHeartbeatToken } from "./heartbeat.js";
|
import { stripHeartbeatToken } from "./heartbeat.js";
|
||||||
import { extractModelDirective } from "./model.js";
|
import { extractModelDirective } from "./model.js";
|
||||||
import { buildStatusMessage } from "./status.js";
|
import { buildStatusMessage } from "./status.js";
|
||||||
@@ -986,6 +988,7 @@ export async function getReplyFromConfig(
|
|||||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||||
|
sendPolicy: baseEntry?.sendPolicy,
|
||||||
queueMode: baseEntry?.queueMode,
|
queueMode: baseEntry?.queueMode,
|
||||||
queueDebounceMs: baseEntry?.queueDebounceMs,
|
queueDebounceMs: baseEntry?.queueDebounceMs,
|
||||||
queueCap: baseEntry?.queueCap,
|
queueCap: baseEntry?.queueCap,
|
||||||
@@ -1587,6 +1590,7 @@ export async function getReplyFromConfig(
|
|||||||
? stripMentions(rawBodyNormalized, ctx, cfg)
|
? stripMentions(rawBodyNormalized, ctx, cfg)
|
||||||
: rawBodyNormalized;
|
: rawBodyNormalized;
|
||||||
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
||||||
|
const sendPolicyCommand = parseSendPolicyCommand(commandBodyNormalized);
|
||||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||||
const ownerCandidates = isWhatsAppSurface
|
const ownerCandidates = isWhatsAppSurface
|
||||||
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
|
? (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 (
|
if (
|
||||||
commandBodyNormalized === "/restart" ||
|
commandBodyNormalized === "/restart" ||
|
||||||
commandBodyNormalized === "restart" ||
|
commandBodyNormalized === "restart" ||
|
||||||
@@ -1710,6 +1746,21 @@ export async function getReplyFromConfig(
|
|||||||
return { text: "⚙️ Agent was aborted." };
|
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 isFirstTurnInSession = isNewSession || !systemSent;
|
||||||
const isGroupChat = sessionCtx.ChatType === "group";
|
const isGroupChat = sessionCtx.ChatType === "group";
|
||||||
const wasMentioned = ctx.WasMentioned === true;
|
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,
|
registerAgentRunContext,
|
||||||
} from "../infra/agent-events.js";
|
} from "../infra/agent-events.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||||
import { resolveTelegramToken } from "../telegram/token.js";
|
import { resolveTelegramToken } from "../telegram/token.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
|
|
||||||
@@ -212,6 +213,19 @@ export async function agentCommand(
|
|||||||
registerAgentRunContext(sessionId, { sessionKey });
|
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 =
|
let resolvedThinkLevel =
|
||||||
thinkOnce ??
|
thinkOnce ??
|
||||||
thinkOverride ??
|
thinkOverride ??
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ export const isNixMode = process.env.CLAWDIS_NIX_MODE === "1";
|
|||||||
export type ReplyMode = "text" | "command";
|
export type ReplyMode = "text" | "command";
|
||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
export type ReplyToMode = "off" | "first" | "all";
|
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 = {
|
export type SessionConfig = {
|
||||||
scope?: SessionScope;
|
scope?: SessionScope;
|
||||||
@@ -28,6 +42,7 @@ export type SessionConfig = {
|
|||||||
store?: string;
|
store?: string;
|
||||||
typingIntervalSeconds?: number;
|
typingIntervalSeconds?: number;
|
||||||
mainKey?: string;
|
mainKey?: string;
|
||||||
|
sendPolicy?: SessionSendPolicyConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoggingConfig = {
|
export type LoggingConfig = {
|
||||||
@@ -853,6 +868,31 @@ const SessionSchema = z
|
|||||||
store: z.string().optional(),
|
store: z.string().optional(),
|
||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
mainKey: z.string().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();
|
.optional();
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type SessionEntry = {
|
|||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
groupActivation?: "mention" | "always";
|
groupActivation?: "mention" | "always";
|
||||||
groupActivationNeedsSystemIntro?: boolean;
|
groupActivationNeedsSystemIntro?: boolean;
|
||||||
|
sendPolicy?: "allow" | "deny";
|
||||||
queueMode?:
|
queueMode?:
|
||||||
| "steer"
|
| "steer"
|
||||||
| "followup"
|
| "followup"
|
||||||
@@ -320,6 +321,7 @@ export async function updateLastRoute(params: {
|
|||||||
verboseLevel: existing?.verboseLevel,
|
verboseLevel: existing?.verboseLevel,
|
||||||
providerOverride: existing?.providerOverride,
|
providerOverride: existing?.providerOverride,
|
||||||
modelOverride: existing?.modelOverride,
|
modelOverride: existing?.modelOverride,
|
||||||
|
sendPolicy: existing?.sendPolicy,
|
||||||
queueMode: existing?.queueMode,
|
queueMode: existing?.queueMode,
|
||||||
inputTokens: existing?.inputTokens,
|
inputTokens: existing?.inputTokens,
|
||||||
outputTokens: existing?.outputTokens,
|
outputTokens: existing?.outputTokens,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ function resolveCronSession(params: {
|
|||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -298,6 +298,13 @@ export const SessionsPatchParamsSchema = Type.Object(
|
|||||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
model: 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(
|
groupActivation: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("mention"),
|
Type.Literal("mention"),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||||
import {
|
import {
|
||||||
loadVoiceWakeConfig,
|
loadVoiceWakeConfig,
|
||||||
setVoiceWakeTriggers,
|
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) {
|
if ("groupActivation" in p) {
|
||||||
const raw = p.groupActivation;
|
const raw = p.groupActivation;
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
@@ -507,6 +527,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
displayName: entry?.displayName,
|
displayName: entry?.displayName,
|
||||||
chatType: entry?.chatType,
|
chatType: entry?.chatType,
|
||||||
surface: entry?.surface,
|
surface: entry?.surface,
|
||||||
@@ -999,6 +1020,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
@@ -1080,6 +1102,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ import {
|
|||||||
} from "../infra/voicewake.js";
|
} from "../infra/voicewake.js";
|
||||||
import { webAuthExists } from "../providers/web/index.js";
|
import { webAuthExists } from "../providers/web/index.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import {
|
||||||
|
normalizeSendPolicy,
|
||||||
|
resolveSendPolicy,
|
||||||
|
} from "../sessions/send-policy.js";
|
||||||
import { sendMessageSignal } from "../signal/index.js";
|
import { sendMessageSignal } from "../signal/index.js";
|
||||||
import { probeSignal, type SignalProbe } from "../signal/probe.js";
|
import { probeSignal, type SignalProbe } from "../signal/probe.js";
|
||||||
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
||||||
@@ -701,7 +705,7 @@ export async function handleGatewayRequest(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { storePath, store, entry } = loadSessionEntry(p.sessionKey);
|
const { cfg, storePath, store, entry } = loadSessionEntry(p.sessionKey);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
const sessionEntry: SessionEntry = {
|
const sessionEntry: SessionEntry = {
|
||||||
@@ -710,11 +714,31 @@ export async function handleGatewayRequest(
|
|||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
const clientRunId = p.idempotencyKey;
|
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}`);
|
const cached = dedupe.get(`chat:${clientRunId}`);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
respond(cached.ok, cached.payload, cached.error, {
|
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) {
|
if ("groupActivation" in p) {
|
||||||
const raw = p.groupActivation;
|
const raw = p.groupActivation;
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
@@ -1744,6 +1789,7 @@ export async function handleGatewayRequest(
|
|||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
skillsSnapshot: entry?.skillsSnapshot,
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
@@ -2739,10 +2785,29 @@ export async function handleGatewayRequest(
|
|||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
skillsSnapshot: entry?.skillsSnapshot,
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
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) {
|
if (store) {
|
||||||
store[requestedSessionKey] = sessionEntry;
|
store[requestedSessionKey] = sessionEntry;
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
|
|||||||
@@ -18,6 +18,96 @@ import {
|
|||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
describe("gateway server chat", () => {
|
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 () => {
|
test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|||||||
@@ -126,17 +126,26 @@ describe("gateway server sessions", () => {
|
|||||||
expect(patched.payload?.ok).toBe(true);
|
expect(patched.payload?.ok).toBe(true);
|
||||||
expect(patched.payload?.key).toBe("main");
|
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<{
|
const list2 = await rpcReq<{
|
||||||
sessions: Array<{
|
sessions: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
|
sendPolicy?: string;
|
||||||
}>;
|
}>;
|
||||||
}>(ws, "sessions.list", {});
|
}>(ws, "sessions.list", {});
|
||||||
expect(list2.ok).toBe(true);
|
expect(list2.ok).toBe(true);
|
||||||
const main2 = list2.payload?.sessions.find((s) => s.key === "main");
|
const main2 = list2.payload?.sessions.find((s) => s.key === "main");
|
||||||
expect(main2?.thinkingLevel).toBe("medium");
|
expect(main2?.thinkingLevel).toBe("medium");
|
||||||
expect(main2?.verboseLevel).toBeUndefined();
|
expect(main2?.verboseLevel).toBeUndefined();
|
||||||
|
expect(main2?.sendPolicy).toBe("deny");
|
||||||
|
|
||||||
piSdkMock.enabled = true;
|
piSdkMock.enabled = true;
|
||||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||||
|
|||||||
@@ -29,17 +29,21 @@ export type GatewaySessionRow = {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
room?: string;
|
room?: string;
|
||||||
space?: string;
|
space?: string;
|
||||||
|
chatType?: "direct" | "group" | "room";
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
abortedLastRun?: boolean;
|
abortedLastRun?: boolean;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
|
sendPolicy?: "allow" | "deny";
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
|
lastChannel?: SessionEntry["lastChannel"];
|
||||||
|
lastTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionsListResult = {
|
export type SessionsListResult = {
|
||||||
@@ -265,17 +269,21 @@ export function listSessionsFromStore(params: {
|
|||||||
subject,
|
subject,
|
||||||
room,
|
room,
|
||||||
space,
|
space,
|
||||||
|
chatType: entry?.chatType,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
sessionId: entry?.sessionId,
|
sessionId: entry?.sessionId,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
abortedLastRun: entry?.abortedLastRun,
|
abortedLastRun: entry?.abortedLastRun,
|
||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
|
sendPolicy: entry?.sendPolicy,
|
||||||
inputTokens: entry?.inputTokens,
|
inputTokens: entry?.inputTokens,
|
||||||
outputTokens: entry?.outputTokens,
|
outputTokens: entry?.outputTokens,
|
||||||
totalTokens: total,
|
totalTokens: total,
|
||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
|
lastChannel: entry?.lastChannel,
|
||||||
|
lastTo: entry?.lastTo,
|
||||||
} satisfies GatewaySessionRow;
|
} satisfies GatewaySessionRow;
|
||||||
})
|
})
|
||||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const agentCommand = hoisted.agentCommand;
|
|||||||
|
|
||||||
export const testState = {
|
export const testState = {
|
||||||
sessionStorePath: undefined as string | undefined,
|
sessionStorePath: undefined as string | undefined,
|
||||||
|
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||||
allowFrom: undefined as string[] | undefined,
|
allowFrom: undefined as string[] | undefined,
|
||||||
cronStorePath: undefined as string | undefined,
|
cronStorePath: undefined as string | undefined,
|
||||||
cronEnabled: false as boolean | undefined,
|
cronEnabled: false as boolean | undefined,
|
||||||
@@ -239,7 +240,11 @@ vi.mock("../config/config.js", async () => {
|
|||||||
whatsapp: {
|
whatsapp: {
|
||||||
allowFrom: testState.allowFrom,
|
allowFrom: testState.allowFrom,
|
||||||
},
|
},
|
||||||
session: { mainKey: "main", store: testState.sessionStorePath },
|
session: {
|
||||||
|
mainKey: "main",
|
||||||
|
store: testState.sessionStorePath,
|
||||||
|
...(testState.sessionConfig ?? {}),
|
||||||
|
},
|
||||||
gateway: (() => {
|
gateway: (() => {
|
||||||
const gateway: Record<string, unknown> = {};
|
const gateway: Record<string, unknown> = {};
|
||||||
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
|
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
|
||||||
@@ -318,6 +323,7 @@ export function installGatewayTestHooks() {
|
|||||||
testState.migrationChanges = [];
|
testState.migrationChanges = [];
|
||||||
testState.cronEnabled = false;
|
testState.cronEnabled = false;
|
||||||
testState.cronStorePath = undefined;
|
testState.cronStorePath = undefined;
|
||||||
|
testState.sessionConfig = undefined;
|
||||||
testState.sessionStorePath = undefined;
|
testState.sessionStorePath = undefined;
|
||||||
testState.allowFrom = undefined;
|
testState.allowFrom = undefined;
|
||||||
testIsNixMode.value = false;
|
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;
|
updatedAt?: number | null;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
|
sendPolicy?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number | null;
|
contextTokens?: number | null;
|
||||||
totalTokens?: number | null;
|
totalTokens?: number | null;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
surface?: string;
|
||||||
|
room?: string;
|
||||||
|
space?: string;
|
||||||
|
subject?: string;
|
||||||
|
chatType?: string;
|
||||||
|
lastChannel?: string;
|
||||||
|
lastTo?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user