feat: add sessions tools and send policy

This commit is contained in:
Peter Steinberger
2026-01-03 23:44:38 +01:00
parent 919d5d1dbb
commit e7c9b9a749
24 changed files with 1304 additions and 4 deletions

View File

@@ -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"),

View File

@@ -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,
};

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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" }];

View File

@@ -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));

View File

@@ -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;