Compare commits
8 Commits
fix/slack-
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185ffca274 | ||
|
|
c2940adc80 | ||
|
|
29043209c9 | ||
|
|
bfc0fb742e | ||
|
|
051c92651f | ||
|
|
b16576f298 | ||
|
|
691adccf32 | ||
|
|
420af3285f |
@@ -5,9 +5,9 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.23 (Unreleased)
|
||||
|
||||
### Changes
|
||||
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
|
||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||
- Plugins: add LLM-free plugin slash commands and include them in `/commands`. (#1558) Thanks @Glucksberg.
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
@@ -19,7 +19,6 @@ Docs: https://docs.clawd.bot
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||
|
||||
### Fixes
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||
|
||||
@@ -66,12 +66,12 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
|
||||
|
||||
## Time handling
|
||||
|
||||
The system prompt includes a dedicated **Current Date & Time** section when the
|
||||
user timezone is known. To keep the prompt cache-stable, it now only includes
|
||||
the **time zone** (no dynamic clock or time format).
|
||||
The system prompt includes a dedicated **Current Date & Time** section when user
|
||||
time or timezone is known. It is explicit about:
|
||||
|
||||
Use `session_status` when the agent needs the current time; the status card
|
||||
includes a timestamp line.
|
||||
- The user’s **local time** (already converted).
|
||||
- The **time zone** used for the conversion.
|
||||
- The **time format** (12-hour / 24-hour).
|
||||
|
||||
Configure with:
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
Clawdbot defaults to **host-local time for transport timestamps** and **user timezone only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics (current time is available via `session_status`).
|
||||
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics.
|
||||
|
||||
## Message envelopes (local by default)
|
||||
|
||||
@@ -63,16 +63,16 @@ You can override this behavior:
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
If the user timezone is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section with the **time zone only** (no clock/time format)
|
||||
to keep prompt caching stable:
|
||||
If the user timezone or local time is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section:
|
||||
|
||||
```
|
||||
Time zone: America/Chicago
|
||||
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
|
||||
Time format: 12-hour
|
||||
```
|
||||
|
||||
When the agent needs the current time, use the `session_status` tool; the status
|
||||
card includes a timestamp line.
|
||||
If only the timezone is known, we still include the section and instruct the model
|
||||
to assume UTC for unknown time references.
|
||||
|
||||
## System event lines (local by default)
|
||||
|
||||
|
||||
@@ -551,6 +551,7 @@ Notes:
|
||||
- Commands are registered globally and work across all channels
|
||||
- Command names are case-insensitive (`/MyStatus` matches `/mystatus`)
|
||||
- Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
|
||||
- Telegram native commands only allow `a-z0-9_` (max 32 chars). Use underscores (not hyphens) if you want a plugin command to appear in Telegram’s native command list.
|
||||
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
|
||||
- Duplicate command registration across plugins will fail with a diagnostic error
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
|
||||
type PayloadLogStage = "request" | "usage";
|
||||
|
||||
type PayloadLogEvent = {
|
||||
ts: string;
|
||||
stage: PayloadLogStage;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string | null;
|
||||
workspaceDir?: string;
|
||||
payload?: unknown;
|
||||
usage?: Record<string, unknown>;
|
||||
error?: string;
|
||||
payloadDigest?: string;
|
||||
};
|
||||
|
||||
type PayloadLogConfig = {
|
||||
enabled: boolean;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
type PayloadLogWriter = {
|
||||
filePath: string;
|
||||
write: (line: string) => void;
|
||||
};
|
||||
|
||||
const writers = new Map<string, PayloadLogWriter>();
|
||||
const log = createSubsystemLogger("agent/anthropic-payload");
|
||||
|
||||
function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig {
|
||||
const enabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG) ?? false;
|
||||
const fileOverride = env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
|
||||
const filePath = fileOverride
|
||||
? resolveUserPath(fileOverride)
|
||||
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
|
||||
return { enabled, filePath };
|
||||
}
|
||||
|
||||
function getWriter(filePath: string): PayloadLogWriter {
|
||||
const existing = writers.get(filePath);
|
||||
if (existing) return existing;
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const writer: PayloadLogWriter = {
|
||||
filePath,
|
||||
write: (line: string) => {
|
||||
queue = queue
|
||||
.then(() => ready)
|
||||
.then(() => fs.appendFile(filePath, line, "utf8"))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
};
|
||||
|
||||
writers.set(filePath, writer);
|
||||
return writer;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string | null {
|
||||
try {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (typeof val === "bigint") return val.toString();
|
||||
if (typeof val === "function") return "[Function]";
|
||||
if (val instanceof Error) {
|
||||
return { name: val.name, message: val.message, stack: val.stack };
|
||||
}
|
||||
if (val instanceof Uint8Array) {
|
||||
return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
|
||||
}
|
||||
return val;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
|
||||
return String(error);
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
return safeJsonStringify(error) ?? "unknown error";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string | undefined {
|
||||
const serialized = safeJsonStringify(value);
|
||||
if (!serialized) return undefined;
|
||||
return crypto.createHash("sha256").update(serialized).digest("hex");
|
||||
}
|
||||
|
||||
function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
|
||||
return (model as { api?: unknown })?.api === "anthropic-messages";
|
||||
}
|
||||
|
||||
function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknown> | null {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const msg = messages[i] as { role?: unknown; usage?: unknown };
|
||||
if (msg?.role === "assistant" && msg.usage && typeof msg.usage === "object") {
|
||||
return msg.usage as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AnthropicPayloadLogger = {
|
||||
enabled: true;
|
||||
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
|
||||
recordUsage: (messages: AgentMessage[], error?: unknown) => void;
|
||||
};
|
||||
|
||||
export function createAnthropicPayloadLogger(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string | null;
|
||||
workspaceDir?: string;
|
||||
}): AnthropicPayloadLogger | null {
|
||||
const env = params.env ?? process.env;
|
||||
const cfg = resolvePayloadLogConfig(env);
|
||||
if (!cfg.enabled) return null;
|
||||
|
||||
const writer = getWriter(cfg.filePath);
|
||||
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
workspaceDir: params.workspaceDir,
|
||||
};
|
||||
|
||||
const record = (event: PayloadLogEvent) => {
|
||||
const line = safeJsonStringify(event);
|
||||
if (!line) return;
|
||||
writer.write(`${line}\n`);
|
||||
};
|
||||
|
||||
const wrapStreamFn: AnthropicPayloadLogger["wrapStreamFn"] = (streamFn) => {
|
||||
const wrapped: StreamFn = (model, context, options) => {
|
||||
if (!isAnthropicModel(model as Model<Api>)) {
|
||||
return streamFn(model, context, options);
|
||||
}
|
||||
const nextOnPayload = (payload: unknown) => {
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "request",
|
||||
payload,
|
||||
payloadDigest: digest(payload),
|
||||
});
|
||||
options?.onPayload?.(payload);
|
||||
};
|
||||
return streamFn(model, context, {
|
||||
...options,
|
||||
onPayload: nextOnPayload,
|
||||
});
|
||||
};
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
|
||||
const usage = findLastAssistantUsage(messages);
|
||||
const errorMessage = formatError(error);
|
||||
if (!usage) {
|
||||
if (errorMessage) {
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "usage",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "usage",
|
||||
usage,
|
||||
error: errorMessage,
|
||||
});
|
||||
log.info("anthropic usage", {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
usage,
|
||||
});
|
||||
};
|
||||
|
||||
log.info("anthropic payload logger enabled", { filePath: writer.filePath });
|
||||
return { enabled: true, wrapStreamFn, recordUsage };
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
import { createCacheTrace } from "../../cache-trace.js";
|
||||
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
|
||||
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||
@@ -459,16 +458,6 @@ export async function runEmbeddedAttempt(
|
||||
modelApi: params.model.api,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const anthropicPayloadLogger = createAnthropicPayloadLogger({
|
||||
env: process.env,
|
||||
runId: params.runId,
|
||||
sessionId: activeSession.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.model.api,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
|
||||
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
|
||||
activeSession.agent.streamFn = streamSimple;
|
||||
@@ -489,11 +478,6 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn);
|
||||
}
|
||||
if (anthropicPayloadLogger) {
|
||||
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
|
||||
activeSession.agent.streamFn,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const prior = await sanitizeSessionHistory({
|
||||
@@ -788,7 +772,6 @@ export async function runEmbeddedAttempt(
|
||||
messages: messagesSnapshot,
|
||||
note: promptError ? "prompt error" : undefined,
|
||||
});
|
||||
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError);
|
||||
|
||||
// Run agent_end hooks to allow plugins to analyze the conversation
|
||||
// This is fire-and-forget, so we don't await
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
|
||||
});
|
||||
|
||||
it("includes user timezone when provided (12-hour)", () => {
|
||||
it("includes user time when provided (12-hour)", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
@@ -133,10 +133,11 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain("Time zone: America/Chicago");
|
||||
expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)");
|
||||
expect(prompt).toContain("Time format: 12-hour");
|
||||
});
|
||||
|
||||
it("includes user timezone when provided (24-hour)", () => {
|
||||
it("includes user time when provided (24-hour)", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
@@ -145,10 +146,11 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain("Time zone: America/Chicago");
|
||||
expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)");
|
||||
expect(prompt).toContain("Time format: 24-hour");
|
||||
});
|
||||
|
||||
it("shows timezone when only timezone is provided", () => {
|
||||
it("shows UTC fallback when only timezone is provided", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
@@ -156,7 +158,9 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain("Time zone: America/Chicago");
|
||||
expect(prompt).toContain(
|
||||
"Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes model alias guidance when aliases are provided", () => {
|
||||
|
||||
@@ -49,9 +49,22 @@ function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: bool
|
||||
return ["## User Identity", ownerLine, ""];
|
||||
}
|
||||
|
||||
function buildTimeSection(params: { userTimezone?: string }) {
|
||||
if (!params.userTimezone) return [];
|
||||
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
|
||||
function buildTimeSection(params: {
|
||||
userTimezone?: string;
|
||||
userTime?: string;
|
||||
userTimeFormat?: ResolvedTimeFormat;
|
||||
}) {
|
||||
if (!params.userTimezone && !params.userTime) return [];
|
||||
return [
|
||||
"## Current Date & Time",
|
||||
params.userTime
|
||||
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
|
||||
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
|
||||
params.userTimeFormat
|
||||
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
|
||||
: "",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function buildReplyTagsSection(isMinimal: boolean) {
|
||||
@@ -199,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
sessions_send: "Send a message to another session/sub-agent",
|
||||
sessions_spawn: "Spawn a sub-agent session",
|
||||
session_status:
|
||||
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
|
||||
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
|
||||
image: "Analyze an image with the configured image model",
|
||||
};
|
||||
|
||||
@@ -289,6 +302,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
: undefined;
|
||||
const reasoningLevel = params.reasoningLevel ?? "off";
|
||||
const userTimezone = params.userTimezone?.trim();
|
||||
const userTime = params.userTime?.trim();
|
||||
const skillsPrompt = params.skillsPrompt?.trim();
|
||||
const heartbeatPrompt = params.heartbeatPrompt?.trim();
|
||||
const heartbeatPromptLine = heartbeatPrompt
|
||||
@@ -451,6 +465,8 @@ export function buildAgentSystemPrompt(params: {
|
||||
...buildUserIdentitySection(ownerLine, isMinimal),
|
||||
...buildTimeSection({
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat: params.userTimeFormat,
|
||||
}),
|
||||
"## Workspace Files (injected)",
|
||||
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
resolveDefaultModelForAgent,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
|
||||
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
|
||||
import { buildStatusMessage } from "../../auto-reply/status.js";
|
||||
@@ -216,7 +215,7 @@ export function createSessionStatusTool(opts?: {
|
||||
label: "Session Status",
|
||||
name: "session_status",
|
||||
description:
|
||||
"Show a /status-equivalent session status card (usage + time + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
|
||||
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
|
||||
parameters: SessionStatusToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
@@ -325,13 +324,6 @@ export function createSessionStatusTool(opts?: {
|
||||
resolved.entry.queueDebounceMs ?? resolved.entry.queueCap ?? resolved.entry.queueDrop,
|
||||
);
|
||||
|
||||
const userTimezone = resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(cfg.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const timeLine = userTime
|
||||
? `🕒 Time: ${userTime} (${userTimezone})`
|
||||
: `🕒 Time zone: ${userTimezone}`;
|
||||
|
||||
const agentDefaults = cfg.agents?.defaults ?? {};
|
||||
const defaultLabel = `${configured.provider}/${configured.model}`;
|
||||
const agentModel =
|
||||
@@ -354,7 +346,6 @@ export function createSessionStatusTool(opts?: {
|
||||
agentDir,
|
||||
}),
|
||||
usageLine,
|
||||
timeLine,
|
||||
queue: {
|
||||
mode: queueSettings.mode,
|
||||
depth: queueDepth,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
listChatCommandsForConfig,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
normalizeNativeCommandSpecsForSurface,
|
||||
normalizeCommandBody,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
@@ -15,15 +16,18 @@ import {
|
||||
shouldHandleTextCommands,
|
||||
} from "./commands-registry.js";
|
||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
describe("commands registry", () => {
|
||||
@@ -42,6 +46,20 @@ describe("commands registry", () => {
|
||||
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("normalizes telegram native command specs", () => {
|
||||
const specs = [
|
||||
{ name: "OK", description: "Ok", acceptsArgs: false },
|
||||
{ name: "bad-name", description: "Bad", acceptsArgs: false },
|
||||
{ name: "fine_name", description: "Fine", acceptsArgs: false },
|
||||
{ name: "ok", description: "Dup", acceptsArgs: false },
|
||||
];
|
||||
const normalized = normalizeNativeCommandSpecsForSurface({
|
||||
surface: "telegram",
|
||||
specs,
|
||||
});
|
||||
expect(normalized.map((spec) => spec.name)).toEqual(["ok", "fine_name"]);
|
||||
});
|
||||
|
||||
it("filters commands based on config flags", () => {
|
||||
const disabled = listChatCommandsForConfig({
|
||||
commands: { config: false, debug: false },
|
||||
@@ -85,6 +103,19 @@ describe("commands registry", () => {
|
||||
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes plugin commands in native specs", () => {
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "plugstatus",
|
||||
description: "Plugin status",
|
||||
handler: () => ({ text: "ok" }),
|
||||
});
|
||||
const native = listNativeCommandSpecsForConfig(
|
||||
{ commands: { config: false, debug: false, native: true } },
|
||||
{ skillCommands: [] },
|
||||
);
|
||||
expect(native.find((spec) => spec.name === "plugstatus")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("detects known text commands", () => {
|
||||
const detection = getCommandDetection();
|
||||
expect(detection.exact.has("/commands")).toBe(true);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||
import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js";
|
||||
import { getPluginCommandSpecs } from "../plugins/commands.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
normalizeTelegramCommandName,
|
||||
TELEGRAM_COMMAND_NAME_PATTERN,
|
||||
} from "../config/telegram-custom-commands.js";
|
||||
import type {
|
||||
ChatCommandDefinition,
|
||||
CommandArgChoiceContext,
|
||||
@@ -108,7 +113,7 @@ export function listChatCommandsForConfig(
|
||||
export function listNativeCommandSpecs(params?: {
|
||||
skillCommands?: SkillCommandSpec[];
|
||||
}): NativeCommandSpec[] {
|
||||
return listChatCommands({ skillCommands: params?.skillCommands })
|
||||
const base = listChatCommands({ skillCommands: params?.skillCommands })
|
||||
.filter((command) => command.scope !== "text" && command.nativeName)
|
||||
.map((command) => ({
|
||||
name: command.nativeName ?? command.key,
|
||||
@@ -116,13 +121,18 @@ export function listNativeCommandSpecs(params?: {
|
||||
acceptsArgs: Boolean(command.acceptsArgs),
|
||||
args: command.args,
|
||||
}));
|
||||
const pluginSpecs = getPluginCommandSpecs();
|
||||
if (pluginSpecs.length === 0) return base;
|
||||
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
|
||||
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
|
||||
return extras.length > 0 ? [...base, ...extras] : base;
|
||||
}
|
||||
|
||||
export function listNativeCommandSpecsForConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params?: { skillCommands?: SkillCommandSpec[] },
|
||||
): NativeCommandSpec[] {
|
||||
return listChatCommandsForConfig(cfg, params)
|
||||
const base = listChatCommandsForConfig(cfg, params)
|
||||
.filter((command) => command.scope !== "text" && command.nativeName)
|
||||
.map((command) => ({
|
||||
name: command.nativeName ?? command.key,
|
||||
@@ -130,6 +140,42 @@ export function listNativeCommandSpecsForConfig(
|
||||
acceptsArgs: Boolean(command.acceptsArgs),
|
||||
args: command.args,
|
||||
}));
|
||||
const pluginSpecs = getPluginCommandSpecs();
|
||||
if (pluginSpecs.length === 0) return base;
|
||||
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
|
||||
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
|
||||
return extras.length > 0 ? [...base, ...extras] : base;
|
||||
}
|
||||
|
||||
function normalizeNativeCommandNameForSurface(name: string, surface: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if (surface === "telegram") {
|
||||
const normalized = normalizeTelegramCommandName(trimmed);
|
||||
if (!normalized) return null;
|
||||
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function normalizeNativeCommandSpecsForSurface(params: {
|
||||
surface: string;
|
||||
specs: NativeCommandSpec[];
|
||||
}): NativeCommandSpec[] {
|
||||
const surface = params.surface.toLowerCase();
|
||||
if (!surface) return params.specs;
|
||||
const normalized: NativeCommandSpec[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const spec of params.specs) {
|
||||
const normalizedName = normalizeNativeCommandNameForSurface(spec.name, surface);
|
||||
if (!normalizedName) continue;
|
||||
const key = normalizedName.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(normalizedName === spec.name ? spec : { ...spec, name: normalizedName });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {
|
||||
|
||||
53
src/auto-reply/reply/commands-plugin.test.ts
Normal file
53
src/auto-reply/reply/commands-plugin.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
|
||||
describe("handlePluginCommand", () => {
|
||||
beforeEach(() => {
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
it("skips plugin commands when text commands are disabled", async () => {
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "ping",
|
||||
description: "Ping",
|
||||
handler: () => ({ text: "pong" }),
|
||||
});
|
||||
|
||||
const params = {
|
||||
command: {
|
||||
commandBodyNormalized: "/ping",
|
||||
senderId: "user-1",
|
||||
channel: "test",
|
||||
isAuthorizedSender: true,
|
||||
},
|
||||
cfg: {} as ClawdbotConfig,
|
||||
} as HandleCommandsParams;
|
||||
|
||||
const result = await handlePluginCommand(params, false);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("executes plugin commands when text commands are enabled", async () => {
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "ping",
|
||||
description: "Ping",
|
||||
handler: () => ({ text: "pong" }),
|
||||
});
|
||||
|
||||
const params = {
|
||||
command: {
|
||||
commandBodyNormalized: "/ping",
|
||||
senderId: "user-1",
|
||||
channel: "test",
|
||||
isAuthorizedSender: true,
|
||||
},
|
||||
cfg: {} as ClawdbotConfig,
|
||||
} as HandleCommandsParams;
|
||||
|
||||
const result = await handlePluginCommand(params, true);
|
||||
expect(result?.reply?.text).toBe("pong");
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,9 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
|
||||
*/
|
||||
export const handlePluginCommand: CommandHandler = async (
|
||||
params,
|
||||
_allowTextCommands,
|
||||
allowTextCommands,
|
||||
): Promise<CommandHandlerResult | null> => {
|
||||
if (!allowTextCommands) return null;
|
||||
const { command, cfg } = params;
|
||||
|
||||
// Try to match a plugin command
|
||||
|
||||
@@ -4,9 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
|
||||
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginCommands();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -423,6 +425,19 @@ describe("buildCommandsMessage", () => {
|
||||
);
|
||||
expect(text).toContain("/demo_skill - Demo skill");
|
||||
});
|
||||
|
||||
it("includes plugin commands when registered", () => {
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "plugstatus",
|
||||
description: "Plugin status",
|
||||
handler: () => ({ text: "ok" }),
|
||||
});
|
||||
const text = buildCommandsMessage({
|
||||
commands: { config: false, debug: false },
|
||||
} as ClawdbotConfig);
|
||||
expect(text).toContain("🔌 Plugin commands");
|
||||
expect(text).toContain("/plugstatus - Plugin status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHelpMessage", () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "../utils/usage-format.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
||||
import { listPluginCommands } from "../plugins/commands.js";
|
||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
||||
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
||||
@@ -52,7 +53,6 @@ type StatusArgs = {
|
||||
resolvedElevated?: ElevatedLevel;
|
||||
modelAuth?: string;
|
||||
usageLine?: string;
|
||||
timeLine?: string;
|
||||
queue?: QueueStatus;
|
||||
mediaDecisions?: MediaUnderstandingDecision[];
|
||||
subagentsLine?: string;
|
||||
@@ -382,7 +382,6 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
|
||||
return [
|
||||
versionLine,
|
||||
args.timeLine,
|
||||
modelLine,
|
||||
usageCostLine,
|
||||
`📚 ${contextLine}`,
|
||||
@@ -444,5 +443,12 @@ export function buildCommandsMessage(
|
||||
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
||||
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
||||
}
|
||||
const pluginCommands = listPluginCommands();
|
||||
if (pluginCommands.length > 0) {
|
||||
lines.push("🔌 Plugin commands");
|
||||
for (const command of pluginCommands) {
|
||||
lines.push(`/${command.name} - ${command.description}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
63
src/plugins/commands.test.ts
Normal file
63
src/plugins/commands.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
clearPluginCommands,
|
||||
executePluginCommand,
|
||||
matchPluginCommand,
|
||||
registerPluginCommand,
|
||||
validateCommandName,
|
||||
} from "./commands.js";
|
||||
|
||||
describe("validateCommandName", () => {
|
||||
it("rejects reserved aliases from built-in commands", () => {
|
||||
const error = validateCommandName("id");
|
||||
expect(error).toContain("reserved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin command registry", () => {
|
||||
beforeEach(() => {
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
it("normalizes command names for registration and matching", () => {
|
||||
const result = registerPluginCommand("plugin-core", {
|
||||
name: " ping ",
|
||||
description: "Ping",
|
||||
handler: () => ({ text: "pong" }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const match = matchPluginCommand("/ping");
|
||||
expect(match?.command.name).toBe("ping");
|
||||
});
|
||||
|
||||
it("blocks registration while a command is executing", async () => {
|
||||
let nestedResult: { ok: boolean; error?: string } | undefined;
|
||||
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "outer",
|
||||
description: "Outer",
|
||||
handler: () => {
|
||||
nestedResult = registerPluginCommand("plugin-inner", {
|
||||
name: "inner",
|
||||
description: "Inner",
|
||||
handler: () => ({ text: "ok" }),
|
||||
});
|
||||
return { text: "done" };
|
||||
},
|
||||
});
|
||||
|
||||
await executePluginCommand({
|
||||
command: matchPluginCommand("/outer")!.command,
|
||||
senderId: "user-1",
|
||||
channel: "test",
|
||||
isAuthorizedSender: true,
|
||||
commandBody: "/outer",
|
||||
config: {} as ClawdbotConfig,
|
||||
});
|
||||
|
||||
expect(nestedResult?.ok).toBe(false);
|
||||
expect(nestedResult?.error).toContain("processing is in progress");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { listChatCommands } from "../auto-reply/commands-registry.js";
|
||||
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
|
||||
@@ -16,53 +17,29 @@ type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
|
||||
// Registry of plugin commands
|
||||
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
|
||||
|
||||
// Lock to prevent modifications during command execution
|
||||
let registryLocked = false;
|
||||
// Lock counter to prevent modifications during command execution
|
||||
let registryLockCount = 0;
|
||||
|
||||
// Maximum allowed length for command arguments (defense in depth)
|
||||
const MAX_ARGS_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* Reserved command names that plugins cannot override.
|
||||
* These are built-in commands from commands-registry.data.ts.
|
||||
*/
|
||||
const RESERVED_COMMANDS = new Set([
|
||||
// Core commands
|
||||
"help",
|
||||
"commands",
|
||||
"status",
|
||||
"whoami",
|
||||
"context",
|
||||
// Session management
|
||||
"stop",
|
||||
"restart",
|
||||
"reset",
|
||||
"new",
|
||||
"compact",
|
||||
// Configuration
|
||||
"config",
|
||||
"debug",
|
||||
"allowlist",
|
||||
"activation",
|
||||
// Agent control
|
||||
"skill",
|
||||
"subagents",
|
||||
"model",
|
||||
"models",
|
||||
"queue",
|
||||
// Messaging
|
||||
"send",
|
||||
// Execution
|
||||
"bash",
|
||||
"exec",
|
||||
// Mode toggles
|
||||
"think",
|
||||
"verbose",
|
||||
"reasoning",
|
||||
"elevated",
|
||||
// Billing
|
||||
"usage",
|
||||
]);
|
||||
function getReservedCommands(): Set<string> {
|
||||
const reserved = new Set<string>();
|
||||
for (const command of listChatCommands()) {
|
||||
if (command.nativeName) {
|
||||
const normalized = command.nativeName.trim().toLowerCase();
|
||||
if (normalized) reserved.add(normalized);
|
||||
}
|
||||
for (const alias of command.textAliases ?? []) {
|
||||
const trimmed = alias.trim();
|
||||
if (!trimmed) continue;
|
||||
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
|
||||
const normalized = withoutSlash.trim().toLowerCase();
|
||||
if (normalized) reserved.add(normalized);
|
||||
}
|
||||
}
|
||||
return reserved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a command name.
|
||||
@@ -82,7 +59,7 @@ export function validateCommandName(name: string): string | null {
|
||||
}
|
||||
|
||||
// Check reserved commands
|
||||
if (RESERVED_COMMANDS.has(trimmed)) {
|
||||
if (getReservedCommands().has(trimmed)) {
|
||||
return `Command name "${trimmed}" is reserved by a built-in command`;
|
||||
}
|
||||
|
||||
@@ -103,7 +80,7 @@ export function registerPluginCommand(
|
||||
command: ClawdbotPluginCommandDefinition,
|
||||
): CommandRegistrationResult {
|
||||
// Prevent registration while commands are being processed
|
||||
if (registryLocked) {
|
||||
if (registryLockCount > 0) {
|
||||
return { ok: false, error: "Cannot register commands while processing is in progress" };
|
||||
}
|
||||
|
||||
@@ -117,7 +94,8 @@ export function registerPluginCommand(
|
||||
return { ok: false, error: validationError };
|
||||
}
|
||||
|
||||
const key = `/${command.name.toLowerCase()}`;
|
||||
const normalizedName = command.name.trim();
|
||||
const key = `/${normalizedName.toLowerCase()}`;
|
||||
|
||||
// Check for duplicate registration
|
||||
if (pluginCommands.has(key)) {
|
||||
@@ -128,7 +106,7 @@ export function registerPluginCommand(
|
||||
};
|
||||
}
|
||||
|
||||
pluginCommands.set(key, { ...command, pluginId });
|
||||
pluginCommands.set(key, { ...command, name: normalizedName, pluginId });
|
||||
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -139,6 +117,7 @@ export function registerPluginCommand(
|
||||
*/
|
||||
export function clearPluginCommands(): void {
|
||||
pluginCommands.clear();
|
||||
registryLockCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,16 +169,26 @@ function sanitizeArgs(args: string | undefined): string | undefined {
|
||||
if (!args) return undefined;
|
||||
|
||||
// Enforce length limit
|
||||
if (args.length > MAX_ARGS_LENGTH) {
|
||||
return args.slice(0, MAX_ARGS_LENGTH);
|
||||
}
|
||||
const trimmed = args.length > MAX_ARGS_LENGTH ? args.slice(0, MAX_ARGS_LENGTH) : args;
|
||||
|
||||
// Remove control characters (except newlines and tabs which may be intentional)
|
||||
let needsSanitize = false;
|
||||
for (let i = 0; i < trimmed.length; i += 1) {
|
||||
const code = trimmed.charCodeAt(i);
|
||||
if (code === 0x09 || code === 0x0a) continue;
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
needsSanitize = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!needsSanitize) return trimmed;
|
||||
|
||||
let sanitized = "";
|
||||
for (const char of args) {
|
||||
const code = char.charCodeAt(0);
|
||||
const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f;
|
||||
if (!isControl) sanitized += char;
|
||||
for (let i = 0; i < trimmed.length; i += 1) {
|
||||
const code = trimmed.charCodeAt(i);
|
||||
if (code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f)) {
|
||||
sanitized += trimmed[i];
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
@@ -243,7 +232,7 @@ export async function executePluginCommand(params: {
|
||||
};
|
||||
|
||||
// Lock registry during execution to prevent concurrent modifications
|
||||
registryLocked = true;
|
||||
registryLockCount += 1;
|
||||
try {
|
||||
const result = await command.handler(ctx);
|
||||
logVerbose(
|
||||
@@ -256,7 +245,7 @@ export async function executePluginCommand(params: {
|
||||
// Don't leak internal error details - return a safe generic message
|
||||
return { text: "⚠️ Command failed. Please try again later." };
|
||||
} finally {
|
||||
registryLocked = false;
|
||||
registryLockCount = Math.max(0, registryLockCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,9 +271,11 @@ export function listPluginCommands(): Array<{
|
||||
export function getPluginCommandSpecs(): Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs: boolean;
|
||||
}> {
|
||||
return Array.from(pluginCommands.values()).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
acceptsArgs: Boolean(cmd.acceptsArgs),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -376,7 +376,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
}
|
||||
|
||||
// Register with the plugin command system (validates name and checks for duplicates)
|
||||
const result = registerPluginCommand(record.id, command);
|
||||
const normalizedCommand = { ...command, name };
|
||||
const result = registerPluginCommand(record.id, normalizedCommand);
|
||||
if (!result.ok) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
@@ -390,7 +391,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
record.commands.push(name);
|
||||
registry.commands.push({
|
||||
pluginId: record.id,
|
||||
command,
|
||||
command: normalizedCommand,
|
||||
source: record.source,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -60,61 +60,3 @@ describe("resolveSlackSystemEventSessionKey", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isChannelAllowed with groupPolicy and channelsConfig", () => {
|
||||
it("allows unlisted channels when groupPolicy is open even with channelsConfig entries", () => {
|
||||
// Bug fix: when groupPolicy="open" and channels has some entries,
|
||||
// unlisted channels should still be allowed (not blocked)
|
||||
const ctx = createSlackMonitorContext({
|
||||
...baseParams(),
|
||||
groupPolicy: "open",
|
||||
channelsConfig: {
|
||||
C_LISTED: { requireMention: true },
|
||||
},
|
||||
});
|
||||
// Listed channel should be allowed
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true);
|
||||
// Unlisted channel should ALSO be allowed when policy is "open"
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks unlisted channels when groupPolicy is allowlist", () => {
|
||||
const ctx = createSlackMonitorContext({
|
||||
...baseParams(),
|
||||
groupPolicy: "allowlist",
|
||||
channelsConfig: {
|
||||
C_LISTED: { requireMention: true },
|
||||
},
|
||||
});
|
||||
// Listed channel should be allowed
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true);
|
||||
// Unlisted channel should be blocked when policy is "allowlist"
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks explicitly denied channels even when groupPolicy is open", () => {
|
||||
const ctx = createSlackMonitorContext({
|
||||
...baseParams(),
|
||||
groupPolicy: "open",
|
||||
channelsConfig: {
|
||||
C_ALLOWED: { allow: true },
|
||||
C_DENIED: { allow: false },
|
||||
},
|
||||
});
|
||||
// Explicitly allowed channel
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_ALLOWED", channelType: "channel" })).toBe(true);
|
||||
// Explicitly denied channel should be blocked even with open policy
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_DENIED", channelType: "channel" })).toBe(false);
|
||||
// Unlisted channel should be allowed with open policy
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true);
|
||||
});
|
||||
|
||||
it("allows all channels when groupPolicy is open and channelsConfig is empty", () => {
|
||||
const ctx = createSlackMonitorContext({
|
||||
...baseParams(),
|
||||
groupPolicy: "open",
|
||||
channelsConfig: undefined,
|
||||
});
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_ANY", channelType: "channel" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,11 +327,7 @@ export function createSlackMonitorContext(params: {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
|
||||
// (i.e., have a matching config entry with allow:false). Channels not in the
|
||||
// config (matchSource undefined) should be allowed under open policy.
|
||||
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
|
||||
if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) {
|
||||
if (!channelAllowed) {
|
||||
logVerbose(`slack: drop channel ${p.channelId} (${channelMatchMeta})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { registerSlackMonitorSlashCommands } from "./slash.js";
|
||||
|
||||
const dispatchMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const upsertPairingRequestMock = vi.fn();
|
||||
const resolveAgentRouteMock = vi.fn();
|
||||
|
||||
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../routing/resolve-route.js", () => ({
|
||||
resolveAgentRoute: (...args: unknown[]) => resolveAgentRouteMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/identity.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/identity.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveEffectiveMessagesConfig: () => ({ responsePrefix: "" }),
|
||||
};
|
||||
});
|
||||
|
||||
function createHarness(overrides?: {
|
||||
groupPolicy?: "open" | "allowlist";
|
||||
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
}) {
|
||||
const commands = new Map<unknown, (args: unknown) => Promise<void>>();
|
||||
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
|
||||
const app = {
|
||||
client: { chat: { postEphemeral } },
|
||||
command: (name: unknown, handler: (args: unknown) => Promise<void>) => {
|
||||
commands.set(name, handler);
|
||||
},
|
||||
};
|
||||
|
||||
const channelId = overrides?.channelId ?? "C_UNLISTED";
|
||||
const channelName = overrides?.channelName ?? "unlisted";
|
||||
|
||||
const ctx = {
|
||||
cfg: { commands: { native: false } },
|
||||
runtime: {},
|
||||
botToken: "bot-token",
|
||||
botUserId: "bot",
|
||||
teamId: "T1",
|
||||
allowFrom: ["*"],
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
groupDmEnabled: false,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
groupPolicy: overrides?.groupPolicy ?? "open",
|
||||
useAccessGroups: true,
|
||||
channelsConfig: overrides?.channelsConfig,
|
||||
slashCommand: { enabled: true, name: "clawd", ephemeral: true, sessionPrefix: "slack:slash" },
|
||||
textLimit: 4000,
|
||||
app,
|
||||
isChannelAllowed: () => true,
|
||||
resolveChannelName: async () => ({ name: channelName, type: "channel" }),
|
||||
resolveUserName: async () => ({ name: "Ada" }),
|
||||
} as unknown;
|
||||
|
||||
const account = { accountId: "acct", config: { commands: { native: false } } } as unknown;
|
||||
|
||||
return { commands, ctx, account, postEphemeral, channelId, channelName };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dispatchMock.mockReset().mockResolvedValue({ counts: { final: 1, tool: 0, block: 0 } });
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
resolveAgentRouteMock.mockReset().mockReturnValue({
|
||||
agentId: "main",
|
||||
sessionKey: "session:1",
|
||||
accountId: "acct",
|
||||
});
|
||||
});
|
||||
|
||||
describe("slack slash commands channel policy", () => {
|
||||
it("allows unlisted channels when groupPolicy is open", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||
groupPolicy: "open",
|
||||
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||
channelId: "C_UNLISTED",
|
||||
channelName: "unlisted",
|
||||
});
|
||||
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||
|
||||
const handler = [...commands.values()][0];
|
||||
if (!handler) throw new Error("Missing slash handler");
|
||||
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler({
|
||||
command: {
|
||||
user_id: "U1",
|
||||
user_name: "Ada",
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
text: "hello",
|
||||
trigger_id: "t1",
|
||||
},
|
||||
ack: vi.fn().mockResolvedValue(undefined),
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
expect(respond).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "This channel is not allowed." }),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks explicitly denied channels when groupPolicy is open", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||
groupPolicy: "open",
|
||||
channelsConfig: { C_DENIED: { allow: false } },
|
||||
channelId: "C_DENIED",
|
||||
channelName: "denied",
|
||||
});
|
||||
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||
|
||||
const handler = [...commands.values()][0];
|
||||
if (!handler) throw new Error("Missing slash handler");
|
||||
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler({
|
||||
command: {
|
||||
user_id: "U1",
|
||||
user_name: "Ada",
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
text: "hello",
|
||||
trigger_id: "t1",
|
||||
},
|
||||
ack: vi.fn().mockResolvedValue(undefined),
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||
groupPolicy: "allowlist",
|
||||
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||
channelId: "C_UNLISTED",
|
||||
channelName: "unlisted",
|
||||
});
|
||||
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||
|
||||
const handler = [...commands.values()][0];
|
||||
if (!handler) throw new Error("Missing slash handler");
|
||||
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler({
|
||||
command: {
|
||||
user_id: "U1",
|
||||
user_name: "Ada",
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
text: "hello",
|
||||
trigger_id: "t1",
|
||||
},
|
||||
ack: vi.fn().mockResolvedValue(undefined),
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -262,7 +262,8 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
groupPolicy: ctx.groupPolicy,
|
||||
channelAllowlistConfigured,
|
||||
channelAllowed,
|
||||
})
|
||||
}) ||
|
||||
!channelAllowed
|
||||
) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
@@ -270,17 +271,13 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
|
||||
// (i.e., have a matching config entry with allow:false). Channels not in the
|
||||
// config (matchSource undefined) should be allowed under open policy.
|
||||
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
|
||||
if (!channelAllowed && (ctx.groupPolicy !== "open" || hasExplicitConfig)) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ctx.useAccessGroups && channelConfig?.allowed === false) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findCommandByNativeName,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
normalizeNativeCommandSpecsForSurface,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
} from "../auto-reply/commands-registry.js";
|
||||
@@ -84,13 +85,28 @@ export const registerTelegramNativeCommands = ({
|
||||
}: RegisterTelegramNativeCommandsParams) => {
|
||||
const skillCommands =
|
||||
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
|
||||
const nativeCommands = nativeEnabled
|
||||
const rawNativeCommands = nativeEnabled
|
||||
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
|
||||
: [];
|
||||
const nativeCommands = normalizeNativeCommandSpecsForSurface({
|
||||
surface: "telegram",
|
||||
specs: rawNativeCommands,
|
||||
});
|
||||
const reservedCommands = new Set(
|
||||
listNativeCommandSpecs().map((command) => command.name.toLowerCase()),
|
||||
normalizeNativeCommandSpecsForSurface({
|
||||
surface: "telegram",
|
||||
specs: listNativeCommandSpecs(),
|
||||
}).map((command) => command.name.toLowerCase()),
|
||||
);
|
||||
for (const command of skillCommands) {
|
||||
const reservedSkillSpecs = normalizeNativeCommandSpecsForSurface({
|
||||
surface: "telegram",
|
||||
specs: skillCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
acceptsArgs: true,
|
||||
})),
|
||||
});
|
||||
for (const command of reservedSkillSpecs) {
|
||||
reservedCommands.add(command.name.toLowerCase());
|
||||
}
|
||||
const customResolution = resolveTelegramCustomCommands({
|
||||
|
||||
Reference in New Issue
Block a user