Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
012a83cdb2 | ||
|
|
e85abaca2b | ||
|
|
07bc85b7fb |
No files matched your search
+1
-1
@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
|
||||
- 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`.
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
|
||||
- Agents: add diagnostics-configured Anthropic payload logging. (#1501) Thanks @parubets.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
@@ -19,7 +20,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)
|
||||
|
||||
@@ -95,6 +95,29 @@ Console logs are **TTY-aware** and formatted for readability:
|
||||
|
||||
Console formatting is controlled by `logging.consoleStyle`.
|
||||
|
||||
## Anthropic payload log (debugging)
|
||||
|
||||
For Anthropic-only runs, you can enable a dedicated JSONL log that captures the
|
||||
exact request payload (as sent) plus per-run usage stats. This log includes full
|
||||
prompt/message data; treat it as sensitive.
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostics": {
|
||||
"anthropicPayloadLog": {
|
||||
"enabled": true,
|
||||
"filePath": "/path/to/anthropic-payload.jsonl"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Defaults + overrides:
|
||||
|
||||
- Default path: `$CLAWDBOT_STATE_DIR/logs/anthropic-payload.jsonl`
|
||||
- Env enable: `CLAWDBOT_ANTHROPIC_PAYLOAD_LOG=1`
|
||||
- Env path: `CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE=/path/to/anthropic-payload.jsonl`
|
||||
|
||||
## Configuring logging
|
||||
|
||||
All logging configuration lives under `logging` in `~/.clawdbot/clawdbot.json`.
|
||||
|
||||
@@ -62,7 +62,6 @@ Plugins can register:
|
||||
- Background services
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
@@ -495,65 +494,6 @@ export default function (api) {
|
||||
}
|
||||
```
|
||||
|
||||
### Register auto-reply commands
|
||||
|
||||
Plugins can register custom slash commands that execute **without invoking the
|
||||
AI agent**. This is useful for toggle commands, status checks, or quick actions
|
||||
that don't need LLM processing.
|
||||
|
||||
```ts
|
||||
export default function (api) {
|
||||
api.registerCommand({
|
||||
name: "mystatus",
|
||||
description: "Show plugin status",
|
||||
handler: (ctx) => ({
|
||||
text: `Plugin is running! Channel: ${ctx.channel}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Command handler context:
|
||||
|
||||
- `senderId`: The sender's ID (if available)
|
||||
- `channel`: The channel where the command was sent
|
||||
- `isAuthorizedSender`: Whether the sender is an authorized user
|
||||
- `args`: Arguments passed after the command (if `acceptsArgs: true`)
|
||||
- `commandBody`: The full command text
|
||||
- `config`: The current Clawdbot config
|
||||
|
||||
Command options:
|
||||
|
||||
- `name`: Command name (without the leading `/`)
|
||||
- `description`: Help text shown in command lists
|
||||
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
|
||||
- `requireAuth`: Whether to require authorized sender (default: true)
|
||||
- `handler`: Function that returns `{ text: string }` (can be async)
|
||||
|
||||
Example with authorization and arguments:
|
||||
|
||||
```ts
|
||||
api.registerCommand({
|
||||
name: "setmode",
|
||||
description: "Set plugin mode",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx) => {
|
||||
const mode = ctx.args?.trim() || "default";
|
||||
await saveMode(mode);
|
||||
return { text: `Mode set to: ${mode}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Plugin commands are processed **before** built-in commands and the AI agent
|
||||
- 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
|
||||
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
|
||||
- Duplicate command registration across plugins will fail with a diagnostic error
|
||||
|
||||
### Register background services
|
||||
|
||||
```ts
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createAnthropicPayloadLogger } from "./anthropic-payload-log.js";
|
||||
|
||||
describe("createAnthropicPayloadLogger", () => {
|
||||
it("returns null when diagnostics payload logging is disabled", () => {
|
||||
const logger = createAnthropicPayloadLogger({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
env: {},
|
||||
modelApi: "anthropic-messages",
|
||||
});
|
||||
|
||||
expect(logger).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when model api is not anthropic", () => {
|
||||
const logger = createAnthropicPayloadLogger({
|
||||
cfg: {
|
||||
diagnostics: {
|
||||
anthropicPayloadLog: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
modelApi: "openai",
|
||||
writer: {
|
||||
filePath: "memory",
|
||||
write: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(logger).toBeNull();
|
||||
});
|
||||
|
||||
it("honors diagnostics config and expands file paths", () => {
|
||||
const lines: string[] = [];
|
||||
const logger = createAnthropicPayloadLogger({
|
||||
cfg: {
|
||||
diagnostics: {
|
||||
anthropicPayloadLog: {
|
||||
enabled: true,
|
||||
filePath: "~/.clawdbot/logs/anthropic-payload.jsonl",
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
modelApi: "anthropic-messages",
|
||||
writer: {
|
||||
filePath: "memory",
|
||||
write: (line) => lines.push(line),
|
||||
},
|
||||
});
|
||||
|
||||
expect(logger).not.toBeNull();
|
||||
expect(logger?.filePath).toBe(resolveUserPath("~/.clawdbot/logs/anthropic-payload.jsonl"));
|
||||
|
||||
logger?.recordUsage([
|
||||
{
|
||||
role: "assistant",
|
||||
usage: {
|
||||
input: 12,
|
||||
},
|
||||
} as unknown as {
|
||||
role: string;
|
||||
usage: { input: number };
|
||||
},
|
||||
]);
|
||||
|
||||
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
|
||||
expect(event.stage).toBe("usage");
|
||||
expect(event.usage).toEqual({ input: 12 });
|
||||
});
|
||||
|
||||
it("skips usage when no new assistant message was added", () => {
|
||||
const lines: string[] = [];
|
||||
const logger = createAnthropicPayloadLogger({
|
||||
cfg: {
|
||||
diagnostics: {
|
||||
anthropicPayloadLog: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
modelApi: "anthropic-messages",
|
||||
writer: {
|
||||
filePath: "memory",
|
||||
write: (line) => lines.push(line),
|
||||
},
|
||||
});
|
||||
|
||||
logger?.recordUsage(
|
||||
[
|
||||
{
|
||||
role: "assistant",
|
||||
usage: {
|
||||
input: 1,
|
||||
},
|
||||
} as unknown as {
|
||||
role: string;
|
||||
usage: { input: number };
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(lines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("records request payloads and forwards onPayload", async () => {
|
||||
const lines: string[] = [];
|
||||
let forwarded: unknown;
|
||||
const logger = createAnthropicPayloadLogger({
|
||||
cfg: {
|
||||
diagnostics: {
|
||||
anthropicPayloadLog: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
modelApi: "anthropic-messages",
|
||||
writer: {
|
||||
filePath: "memory",
|
||||
write: (line) => lines.push(line),
|
||||
},
|
||||
});
|
||||
|
||||
const streamFn = ((_, __, options) => {
|
||||
options?.onPayload?.({ hello: "world" });
|
||||
return Promise.resolve(undefined);
|
||||
}) as StreamFn;
|
||||
|
||||
const wrapped = logger?.wrapStreamFn(streamFn);
|
||||
await wrapped?.(
|
||||
{ api: "anthropic-messages" } as unknown as { api: string },
|
||||
{},
|
||||
{
|
||||
onPayload: (payload) => {
|
||||
forwarded = payload;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
|
||||
expect(event.stage).toBe("request");
|
||||
expect(event.payload).toEqual({ hello: "world" });
|
||||
expect(event.payloadDigest).toBeTruthy();
|
||||
expect(forwarded).toEqual({ hello: "world" });
|
||||
});
|
||||
|
||||
it("records errors when usage is missing", () => {
|
||||
const lines: string[] = [];
|
||||
const logger = createAnthropicPayloadLogger({
|
||||
cfg: {
|
||||
diagnostics: {
|
||||
anthropicPayloadLog: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
modelApi: "anthropic-messages",
|
||||
writer: {
|
||||
filePath: "memory",
|
||||
write: (line) => lines.push(line),
|
||||
},
|
||||
});
|
||||
|
||||
logger?.recordUsage([], new Error("boom"));
|
||||
|
||||
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
|
||||
expect(event.stage).toBe("usage");
|
||||
expect(event.error).toContain("boom");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@ import path from "node:path";
|
||||
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
@@ -33,6 +35,11 @@ type PayloadLogConfig = {
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
type PayloadLogInit = {
|
||||
cfg?: ClawdbotConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type PayloadLogWriter = {
|
||||
filePath: string;
|
||||
write: (line: string) => void;
|
||||
@@ -41,9 +48,12 @@ type PayloadLogWriter = {
|
||||
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();
|
||||
function resolvePayloadLogConfig(params: PayloadLogInit): PayloadLogConfig {
|
||||
const env = params.env ?? process.env;
|
||||
const config = params.cfg?.diagnostics?.anthropicPayloadLog;
|
||||
const envEnabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG);
|
||||
const enabled = envEnabled ?? config?.enabled ?? false;
|
||||
const fileOverride = config?.filePath?.trim() || env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
|
||||
const filePath = fileOverride
|
||||
? resolveUserPath(fileOverride)
|
||||
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
|
||||
@@ -90,18 +100,6 @@ function safeJsonStringify(value: unknown): string | 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;
|
||||
@@ -112,8 +110,12 @@ function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
|
||||
return (model as { api?: unknown })?.api === "anthropic-messages";
|
||||
}
|
||||
|
||||
function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknown> | null {
|
||||
function findLastAssistantUsage(
|
||||
messages: AgentMessage[],
|
||||
minIndex = 0,
|
||||
): Record<string, unknown> | null {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (i < minIndex) break;
|
||||
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>;
|
||||
@@ -124,11 +126,13 @@ function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknow
|
||||
|
||||
export type AnthropicPayloadLogger = {
|
||||
enabled: true;
|
||||
filePath: string;
|
||||
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
|
||||
recordUsage: (messages: AgentMessage[], error?: unknown) => void;
|
||||
recordUsage: (messages: AgentMessage[], error?: unknown, baselineMessageCount?: number) => void;
|
||||
};
|
||||
|
||||
export function createAnthropicPayloadLogger(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
@@ -137,12 +141,14 @@ export function createAnthropicPayloadLogger(params: {
|
||||
modelId?: string;
|
||||
modelApi?: string | null;
|
||||
workspaceDir?: string;
|
||||
writer?: PayloadLogWriter;
|
||||
}): AnthropicPayloadLogger | null {
|
||||
const env = params.env ?? process.env;
|
||||
const cfg = resolvePayloadLogConfig(env);
|
||||
const cfg = resolvePayloadLogConfig({ env, cfg: params.cfg });
|
||||
if (!cfg.enabled) return null;
|
||||
if (params.modelApi !== "anthropic-messages") return null;
|
||||
|
||||
const writer = getWriter(cfg.filePath);
|
||||
const writer = params.writer ?? getWriter(cfg.filePath);
|
||||
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
@@ -182,16 +188,19 @@ export function createAnthropicPayloadLogger(params: {
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
|
||||
const usage = findLastAssistantUsage(messages);
|
||||
const errorMessage = formatError(error);
|
||||
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (
|
||||
messages,
|
||||
error,
|
||||
baselineMessageCount,
|
||||
) => {
|
||||
const usage = findLastAssistantUsage(messages, baselineMessageCount ?? 0);
|
||||
if (!usage) {
|
||||
if (errorMessage) {
|
||||
if (error) {
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "usage",
|
||||
error: errorMessage,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -201,7 +210,7 @@ export function createAnthropicPayloadLogger(params: {
|
||||
ts: new Date().toISOString(),
|
||||
stage: "usage",
|
||||
usage,
|
||||
error: errorMessage,
|
||||
error: error ? formatErrorMessage(error) : undefined,
|
||||
});
|
||||
log.info("anthropic usage", {
|
||||
runId: params.runId,
|
||||
@@ -210,6 +219,6 @@ export function createAnthropicPayloadLogger(params: {
|
||||
});
|
||||
};
|
||||
|
||||
log.info("anthropic payload logger enabled", { filePath: writer.filePath });
|
||||
return { enabled: true, wrapStreamFn, recordUsage };
|
||||
log.info("anthropic payload logger enabled", { filePath: cfg.filePath });
|
||||
return { enabled: true, filePath: cfg.filePath, wrapStreamFn, recordUsage };
|
||||
}
|
||||
@@ -460,6 +460,7 @@ export async function runEmbeddedAttempt(
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const anthropicPayloadLogger = createAnthropicPayloadLogger({
|
||||
cfg: params.config,
|
||||
env: process.env,
|
||||
runId: params.runId,
|
||||
sessionId: activeSession.sessionId,
|
||||
@@ -642,6 +643,7 @@ export async function runEmbeddedAttempt(
|
||||
|
||||
let messagesSnapshot: AgentMessage[] = [];
|
||||
let sessionIdUsed = activeSession.sessionId;
|
||||
let promptStartMessageCount = activeSession.messages.length;
|
||||
const onAbort = () => {
|
||||
const reason = params.abortSignal ? getAbortReason(params.abortSignal) : undefined;
|
||||
const timeout = reason ? isTimeoutError(reason) : false;
|
||||
@@ -713,6 +715,8 @@ export async function runEmbeddedAttempt(
|
||||
);
|
||||
}
|
||||
|
||||
promptStartMessageCount = activeSession.messages.length;
|
||||
|
||||
try {
|
||||
// Detect and load images referenced in the prompt for vision-capable models.
|
||||
// This eliminates the need for an explicit "view" tool call by injecting
|
||||
@@ -788,7 +792,7 @@ export async function runEmbeddedAttempt(
|
||||
messages: messagesSnapshot,
|
||||
note: promptError ? "prompt error" : undefined,
|
||||
});
|
||||
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError);
|
||||
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError, promptStartMessageCount);
|
||||
|
||||
// Run agent_end hooks to allow plugins to analyze the conversation
|
||||
// This is fire-and-forget, so we don't await
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
handleStopCommand,
|
||||
handleUsageCommand,
|
||||
} from "./commands-session.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
import type {
|
||||
CommandHandler,
|
||||
CommandHandlerResult,
|
||||
@@ -32,8 +31,6 @@ import type {
|
||||
} from "./commands-types.js";
|
||||
|
||||
const HANDLERS: CommandHandler[] = [
|
||||
// Plugin commands are processed first, before built-in commands
|
||||
handlePluginCommand,
|
||||
handleBashCommand,
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Plugin Command Handler
|
||||
*
|
||||
* Handles commands registered by plugins, bypassing the LLM agent.
|
||||
* This handler is called before built-in command handlers.
|
||||
*/
|
||||
|
||||
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
|
||||
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
|
||||
|
||||
/**
|
||||
* Handle plugin-registered commands.
|
||||
* Returns a result if a plugin command was matched and executed,
|
||||
* or null to continue to the next handler.
|
||||
*/
|
||||
export const handlePluginCommand: CommandHandler = async (
|
||||
params,
|
||||
_allowTextCommands,
|
||||
): Promise<CommandHandlerResult | null> => {
|
||||
const { command, cfg } = params;
|
||||
|
||||
// Try to match a plugin command
|
||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||
if (!match) return null;
|
||||
|
||||
// Execute the plugin command (always returns a result)
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
args: match.args,
|
||||
senderId: command.senderId,
|
||||
channel: command.channel,
|
||||
isAuthorizedSender: command.isAuthorizedSender,
|
||||
commandBody: command.commandBodyNormalized,
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: result.text },
|
||||
};
|
||||
};
|
||||
@@ -121,6 +121,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
|
||||
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
|
||||
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
||||
"diagnostics.anthropicPayloadLog.enabled": "Anthropic Payload Log Enabled",
|
||||
"diagnostics.anthropicPayloadLog.filePath": "Anthropic Payload Log File Path",
|
||||
"agents.list.*.identity.avatar": "Identity Avatar",
|
||||
"gateway.remote.url": "Remote Gateway URL",
|
||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||
@@ -390,6 +392,10 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"Include full message payloads in trace output (default: true).",
|
||||
"diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).",
|
||||
"diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).",
|
||||
"diagnostics.anthropicPayloadLog.enabled":
|
||||
"Log Anthropic request payloads + usage for embedded runs (default: false).",
|
||||
"diagnostics.anthropicPayloadLog.filePath":
|
||||
"JSONL output path for Anthropic payload logs (default: $CLAWDBOT_STATE_DIR/logs/anthropic-payload.jsonl).",
|
||||
"tools.exec.applyPatch.enabled":
|
||||
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
|
||||
"tools.exec.applyPatch.allowModels":
|
||||
|
||||
@@ -133,10 +133,16 @@ export type DiagnosticsCacheTraceConfig = {
|
||||
includeSystem?: boolean;
|
||||
};
|
||||
|
||||
export type DiagnosticsAnthropicPayloadLogConfig = {
|
||||
enabled?: boolean;
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
export type DiagnosticsConfig = {
|
||||
enabled?: boolean;
|
||||
otel?: DiagnosticsOtelConfig;
|
||||
cacheTrace?: DiagnosticsCacheTraceConfig;
|
||||
anthropicPayloadLog?: DiagnosticsAnthropicPayloadLogConfig;
|
||||
};
|
||||
|
||||
export type WebReconnectConfig = {
|
||||
|
||||
@@ -86,6 +86,13 @@ export const ClawdbotSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
anthropicPayloadLog: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
filePath: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -12,7 +12,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
const merged = { ...base, ...overrides };
|
||||
|
||||
@@ -140,7 +140,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* Plugin Command Registry
|
||||
*
|
||||
* Manages commands registered by plugins that bypass the LLM agent.
|
||||
* These commands are processed before built-in commands and before agent invocation.
|
||||
*/
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
|
||||
type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
// Registry of plugin commands
|
||||
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
|
||||
|
||||
// Lock to prevent modifications during command execution
|
||||
let registryLocked = false;
|
||||
|
||||
// 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",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate a command name.
|
||||
* Returns an error message if invalid, or null if valid.
|
||||
*/
|
||||
export function validateCommandName(name: string): string | null {
|
||||
const trimmed = name.trim().toLowerCase();
|
||||
|
||||
if (!trimmed) {
|
||||
return "Command name cannot be empty";
|
||||
}
|
||||
|
||||
// Must start with a letter, contain only letters, numbers, hyphens, underscores
|
||||
// Note: trimmed is already lowercased, so no need for /i flag
|
||||
if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) {
|
||||
return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores";
|
||||
}
|
||||
|
||||
// Check reserved commands
|
||||
if (RESERVED_COMMANDS.has(trimmed)) {
|
||||
return `Command name "${trimmed}" is reserved by a built-in command`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type CommandRegistrationResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a plugin command.
|
||||
* Returns an error if the command name is invalid or reserved.
|
||||
*/
|
||||
export function registerPluginCommand(
|
||||
pluginId: string,
|
||||
command: ClawdbotPluginCommandDefinition,
|
||||
): CommandRegistrationResult {
|
||||
// Prevent registration while commands are being processed
|
||||
if (registryLocked) {
|
||||
return { ok: false, error: "Cannot register commands while processing is in progress" };
|
||||
}
|
||||
|
||||
// Validate handler is a function
|
||||
if (typeof command.handler !== "function") {
|
||||
return { ok: false, error: "Command handler must be a function" };
|
||||
}
|
||||
|
||||
const validationError = validateCommandName(command.name);
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError };
|
||||
}
|
||||
|
||||
const key = `/${command.name.toLowerCase()}`;
|
||||
|
||||
// Check for duplicate registration
|
||||
if (pluginCommands.has(key)) {
|
||||
const existing = pluginCommands.get(key)!;
|
||||
return {
|
||||
ok: false,
|
||||
error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
pluginCommands.set(key, { ...command, pluginId });
|
||||
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered plugin commands.
|
||||
* Called during plugin reload.
|
||||
*/
|
||||
export function clearPluginCommands(): void {
|
||||
pluginCommands.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear plugin commands for a specific plugin.
|
||||
*/
|
||||
export function clearPluginCommandsForPlugin(pluginId: string): void {
|
||||
for (const [key, cmd] of pluginCommands.entries()) {
|
||||
if (cmd.pluginId === pluginId) {
|
||||
pluginCommands.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command body matches a registered plugin command.
|
||||
* Returns the command definition and parsed args if matched.
|
||||
*
|
||||
* Note: If a command has `acceptsArgs: false` and the user provides arguments,
|
||||
* the command will not match. This allows the message to fall through to
|
||||
* built-in handlers or the agent. Document this behavior to plugin authors.
|
||||
*/
|
||||
export function matchPluginCommand(
|
||||
commandBody: string,
|
||||
): { command: RegisteredPluginCommand; args?: string } | null {
|
||||
const trimmed = commandBody.trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
|
||||
// Extract command name and args
|
||||
const spaceIndex = trimmed.indexOf(" ");
|
||||
const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
|
||||
const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim();
|
||||
|
||||
const key = commandName.toLowerCase();
|
||||
const command = pluginCommands.get(key);
|
||||
|
||||
if (!command) return null;
|
||||
|
||||
// If command doesn't accept args but args were provided, don't match
|
||||
if (args && !command.acceptsArgs) return null;
|
||||
|
||||
return { command, args: args || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize command arguments to prevent injection attacks.
|
||||
* Removes control characters and enforces length limits.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
// Remove control characters (except newlines and tabs which may be intentional)
|
||||
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;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a plugin command handler.
|
||||
*
|
||||
* Note: Plugin authors should still validate and sanitize ctx.args for their
|
||||
* specific use case. This function provides basic defense-in-depth sanitization.
|
||||
*/
|
||||
export async function executePluginCommand(params: {
|
||||
command: RegisteredPluginCommand;
|
||||
args?: string;
|
||||
senderId?: string;
|
||||
channel: string;
|
||||
isAuthorizedSender: boolean;
|
||||
commandBody: string;
|
||||
config: ClawdbotConfig;
|
||||
}): Promise<{ text: string }> {
|
||||
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
|
||||
|
||||
// Check authorization
|
||||
const requireAuth = command.requireAuth !== false; // Default to true
|
||||
if (requireAuth && !isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Plugin command /${command.name} blocked: unauthorized sender ${senderId || "<unknown>"}`,
|
||||
);
|
||||
return { text: "⚠️ This command requires authorization." };
|
||||
}
|
||||
|
||||
// Sanitize args before passing to handler
|
||||
const sanitizedArgs = sanitizeArgs(args);
|
||||
|
||||
const ctx: PluginCommandContext = {
|
||||
senderId,
|
||||
channel,
|
||||
isAuthorizedSender,
|
||||
args: sanitizedArgs,
|
||||
commandBody,
|
||||
config,
|
||||
};
|
||||
|
||||
// Lock registry during execution to prevent concurrent modifications
|
||||
registryLocked = true;
|
||||
try {
|
||||
const result = await command.handler(ctx);
|
||||
logVerbose(
|
||||
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
|
||||
);
|
||||
return { text: result.text };
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logVerbose(`Plugin command /${command.name} error: ${error.message}`);
|
||||
// Don't leak internal error details - return a safe generic message
|
||||
return { text: "⚠️ Command failed. Please try again later." };
|
||||
} finally {
|
||||
registryLocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered plugin commands.
|
||||
* Used for /help and /commands output.
|
||||
*/
|
||||
export function listPluginCommands(): Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
pluginId: string;
|
||||
}> {
|
||||
return Array.from(pluginCommands.values()).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
pluginId: cmd.pluginId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin command specs for native command registration (e.g., Telegram).
|
||||
*/
|
||||
export function getPluginCommandSpecs(): Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
}> {
|
||||
return Array.from(pluginCommands.values()).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type NormalizedPluginsConfig,
|
||||
} from "./config-state.js";
|
||||
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
||||
import { clearPluginCommands } from "./commands.js";
|
||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||
import { createPluginRuntime } from "./runtime/index.js";
|
||||
import { setActivePluginRegistry } from "./runtime.js";
|
||||
@@ -148,7 +147,6 @@ function createPluginRecord(params: {
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpHandlers: 0,
|
||||
hookCount: 0,
|
||||
configSchema: params.configSchema,
|
||||
@@ -179,9 +177,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
}
|
||||
|
||||
// Clear previously registered plugin commands before reloading
|
||||
clearPluginCommands();
|
||||
|
||||
const runtime = createPluginRuntime();
|
||||
const { registry, createApi } = createPluginRegistry({
|
||||
logger,
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
ClawdbotPluginApi,
|
||||
ClawdbotPluginChannelRegistration,
|
||||
ClawdbotPluginCliRegistrar,
|
||||
ClawdbotPluginCommandDefinition,
|
||||
ClawdbotPluginHttpHandler,
|
||||
ClawdbotPluginHookOptions,
|
||||
ProviderPlugin,
|
||||
@@ -27,7 +26,6 @@ import type {
|
||||
PluginHookHandlerMap,
|
||||
PluginHookRegistration as TypedPluginHookRegistration,
|
||||
} from "./types.js";
|
||||
import { registerPluginCommand } from "./commands.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import path from "node:path";
|
||||
@@ -79,12 +77,6 @@ export type PluginServiceRegistration = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginCommandRegistration = {
|
||||
pluginId: string;
|
||||
command: ClawdbotPluginCommandDefinition;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -104,7 +96,6 @@ export type PluginRecord = {
|
||||
gatewayMethods: string[];
|
||||
cliCommands: string[];
|
||||
services: string[];
|
||||
commands: string[];
|
||||
httpHandlers: number;
|
||||
hookCount: number;
|
||||
configSchema: boolean;
|
||||
@@ -123,7 +114,6 @@ export type PluginRegistry = {
|
||||
httpHandlers: PluginHttpRegistration[];
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
commands: PluginCommandRegistration[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
@@ -145,7 +135,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
|
||||
@@ -363,38 +352,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
};
|
||||
|
||||
const registerCommand = (record: PluginRecord, command: ClawdbotPluginCommandDefinition) => {
|
||||
const name = command.name.trim();
|
||||
if (!name) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "command registration missing name",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Register with the plugin command system (validates name and checks for duplicates)
|
||||
const result = registerPluginCommand(record.id, command);
|
||||
if (!result.ok) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `command registration failed: ${result.error}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
record.commands.push(name);
|
||||
registry.commands.push({
|
||||
pluginId: record.id,
|
||||
command,
|
||||
source: record.source,
|
||||
});
|
||||
};
|
||||
|
||||
const registerTypedHook = <K extends PluginHookName>(
|
||||
record: PluginRecord,
|
||||
hookName: K,
|
||||
@@ -444,7 +401,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerService: (service) => registerService(record, service),
|
||||
registerCommand: (command) => registerCommand(record, command),
|
||||
resolvePath: (input: string) => resolveUserPath(input),
|
||||
on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts),
|
||||
};
|
||||
@@ -460,7 +416,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerGatewayMethod,
|
||||
registerCli,
|
||||
registerService,
|
||||
registerCommand,
|
||||
registerHook,
|
||||
registerTypedHook,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ const createEmptyRegistry = (): PluginRegistry => ({
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -129,59 +129,6 @@ export type ClawdbotPluginGatewayMethod = {
|
||||
handler: GatewayRequestHandler;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Commands
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Context passed to plugin command handlers.
|
||||
*/
|
||||
export type PluginCommandContext = {
|
||||
/** The sender's identifier (e.g., Telegram user ID) */
|
||||
senderId?: string;
|
||||
/** The channel/surface (e.g., "telegram", "discord") */
|
||||
channel: string;
|
||||
/** Whether the sender is on the allowlist */
|
||||
isAuthorizedSender: boolean;
|
||||
/** Raw command arguments after the command name */
|
||||
args?: string;
|
||||
/** The full normalized command body */
|
||||
commandBody: string;
|
||||
/** Current clawdbot configuration */
|
||||
config: ClawdbotConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result returned by a plugin command handler.
|
||||
*/
|
||||
export type PluginCommandResult = {
|
||||
/** Text response to send back to the user */
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler function for plugin commands.
|
||||
*/
|
||||
export type PluginCommandHandler = (
|
||||
ctx: PluginCommandContext,
|
||||
) => PluginCommandResult | Promise<PluginCommandResult>;
|
||||
|
||||
/**
|
||||
* Definition for a plugin-registered command.
|
||||
*/
|
||||
export type ClawdbotPluginCommandDefinition = {
|
||||
/** Command name without leading slash (e.g., "tts_on") */
|
||||
name: string;
|
||||
/** Description shown in /help and command menus */
|
||||
description: string;
|
||||
/** Whether this command accepts arguments */
|
||||
acceptsArgs?: boolean;
|
||||
/** Whether only authorized senders can use this command (default: true) */
|
||||
requireAuth?: boolean;
|
||||
/** The handler function */
|
||||
handler: PluginCommandHandler;
|
||||
};
|
||||
|
||||
export type ClawdbotPluginHttpHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -254,12 +201,6 @@ export type ClawdbotPluginApi = {
|
||||
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||
registerService: (service: ClawdbotPluginService) => void;
|
||||
registerProvider: (provider: ProviderPlugin) => void;
|
||||
/**
|
||||
* Register a custom command that bypasses the LLM agent.
|
||||
* Plugin commands are processed before built-in commands and before agent invocation.
|
||||
* Use this for simple state-toggling or status commands that don't need AI reasoning.
|
||||
*/
|
||||
registerCommand: (command: ClawdbotPluginCommandDefinition) => void;
|
||||
resolvePath: (input: string) => string;
|
||||
/** Register a lifecycle hook handler */
|
||||
on: <K extends PluginHookName>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user