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

@@ -107,6 +107,39 @@ describe("trigger handling", () => {
});
});
it("allows owner to set send policy", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
whatsapp: {
allowFrom: ["+1000"],
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/send off",
From: "+1000",
To: "+2000",
Surface: "whatsapp",
SenderE164: "+1000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Send policy set to off");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { sendPolicy?: string }>;
expect(store.main?.sendPolicy).toBe("deny");
});
});
it("returns a context overflow fallback when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(

View File

@@ -55,6 +55,7 @@ import {
} from "../infra/system-events.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { normalizeE164 } from "../utils.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
@@ -63,6 +64,7 @@ import {
normalizeGroupActivation,
parseActivationCommand,
} from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js";
import { stripHeartbeatToken } from "./heartbeat.js";
import { extractModelDirective } from "./model.js";
import { buildStatusMessage } from "./status.js";
@@ -986,6 +988,7 @@ export async function getReplyFromConfig(
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
sendPolicy: baseEntry?.sendPolicy,
queueMode: baseEntry?.queueMode,
queueDebounceMs: baseEntry?.queueDebounceMs,
queueCap: baseEntry?.queueCap,
@@ -1587,6 +1590,7 @@ export async function getReplyFromConfig(
? stripMentions(rawBodyNormalized, ctx, cfg)
: rawBodyNormalized;
const activationCommand = parseActivationCommand(commandBodyNormalized);
const sendPolicyCommand = parseSendPolicyCommand(commandBodyNormalized);
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
const ownerCandidates = isWhatsAppSurface
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
@@ -1633,6 +1637,38 @@ export async function getReplyFromConfig(
};
}
if (sendPolicyCommand.hasCommand) {
if (!isOwnerSender) {
logVerbose(
`Ignoring /send from non-owner: ${senderE164 || "<unknown>"}`,
);
cleanupTyping();
return undefined;
}
if (!sendPolicyCommand.mode) {
cleanupTyping();
return { text: "⚙️ Usage: /send on|off|inherit" };
}
if (sessionEntry && sessionStore && sessionKey) {
if (sendPolicyCommand.mode === "inherit") {
delete sessionEntry.sendPolicy;
} else {
sessionEntry.sendPolicy = sendPolicyCommand.mode;
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
cleanupTyping();
const label =
sendPolicyCommand.mode === "inherit"
? "inherit"
: sendPolicyCommand.mode === "allow"
? "on"
: "off";
return { text: `⚙️ Send policy set to ${label}.` };
}
if (
commandBodyNormalized === "/restart" ||
commandBodyNormalized === "restart" ||
@@ -1710,6 +1746,21 @@ export async function getReplyFromConfig(
return { text: "⚙️ Agent was aborted." };
}
const sendPolicy = resolveSendPolicy({
cfg,
entry: sessionEntry,
sessionKey,
surface: sessionEntry?.surface ?? surface,
chatType: sessionEntry?.chatType,
});
if (sendPolicy === "deny") {
logVerbose(
`Send blocked by policy for session ${sessionKey ?? "unknown"}`,
);
cleanupTyping();
return undefined;
}
const isFirstTurnInSession = isNewSession || !systemSent;
const isGroupChat = sessionCtx.ChatType === "group";
const wasMentioned = ctx.WasMentioned === true;

View 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 };
}