mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 12:57:40 +00:00
feat: add sessions tools and send policy
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user