perf(test): split reply command coverage

This commit is contained in:
Peter Steinberger
2026-04-06 06:01:52 +01:00
parent c6611639ab
commit 104df3360e
15 changed files with 1505 additions and 437 deletions

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { handleAbortTrigger } from "./commands-session-abort.js";
import type { HandleCommandsParams } from "./commands-types.js";
const abortEmbeddedPiRunMock = vi.hoisted(() => vi.fn());
const persistAbortTargetEntryMock = vi.hoisted(() => vi.fn());
const setAbortMemoryMock = vi.hoisted(() => vi.fn());
vi.mock("../../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: abortEmbeddedPiRunMock,
}));
vi.mock("../../globals.js", () => ({
logVerbose: vi.fn(),
}));
vi.mock("../../hooks/internal-hooks.js", () => ({
createInternalHookEvent: vi.fn(),
triggerInternalHook: vi.fn(),
}));
vi.mock("./abort-cutoff.js", () => ({
resolveAbortCutoffFromContext: vi.fn(() => undefined),
shouldPersistAbortCutoff: vi.fn(() => false),
}));
vi.mock("./abort.js", () => ({
formatAbortReplyText: vi.fn(() => "⚙️ Agent was aborted."),
isAbortTrigger: vi.fn((raw: string) => raw === "stop"),
resolveSessionEntryForKey: vi.fn(() => ({ entry: undefined, key: "agent:main:main" })),
setAbortMemory: setAbortMemoryMock,
stopSubagentsForRequester: vi.fn(() => ({ stopped: 0 })),
}));
vi.mock("./commands-session-store.js", () => ({
persistAbortTargetEntry: persistAbortTargetEntryMock,
}));
vi.mock("./queue.js", () => ({
clearSessionQueues: vi.fn(() => ({ followupCleared: 0, laneCleared: 0, keys: [] })),
}));
vi.mock("./reply-run-registry.js", () => ({
replyRunRegistry: {
abort: vi.fn(),
resolveSessionId: vi.fn(() => undefined),
},
}));
function buildAbortParams(): HandleCommandsParams {
return {
cfg: {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig,
ctx: {
Provider: "whatsapp",
Surface: "whatsapp",
CommandSource: "text",
},
command: {
commandBodyNormalized: "stop",
rawBodyNormalized: "stop",
isAuthorizedSender: false,
senderIsOwner: false,
senderId: "unauthorized",
channel: "whatsapp",
channelId: "whatsapp",
surface: "whatsapp",
ownerList: [],
from: "unauthorized",
to: "bot",
},
sessionKey: "agent:main:main",
sessionEntry: {
sessionId: "session-1",
updatedAt: Date.now(),
abortedLastRun: false,
},
sessionStore: {
"agent:main:main": {
sessionId: "session-1",
updatedAt: Date.now(),
abortedLastRun: false,
},
},
} as unknown as HandleCommandsParams;
}
describe("handleAbortTrigger", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects unauthorized natural-language abort triggers", async () => {
const result = await handleAbortTrigger(buildAbortParams(), true);
expect(result).toEqual({ shouldContinue: false });
expect(abortEmbeddedPiRunMock).not.toHaveBeenCalled();
expect(persistAbortTargetEntryMock).not.toHaveBeenCalled();
expect(setAbortMemoryMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { handleBashCommand } from "./commands-bash.js";
import type { HandleCommandsParams } from "./commands-types.js";
const handleBashChatCommandMock = vi.hoisted(() =>
vi.fn(async () => ({ text: "No active bash job" })),
);
vi.mock("./bash-command.js", () => ({
handleBashChatCommand: handleBashChatCommandMock,
}));
function buildBashParams(commandBodyNormalized: string): HandleCommandsParams {
return {
cfg: {
commands: { bash: true, text: true },
whatsapp: { allowFrom: ["*"] },
} as OpenClawConfig,
ctx: {
Provider: "whatsapp",
Surface: "whatsapp",
CommandSource: "text",
CommandBody: commandBodyNormalized,
},
command: {
commandBodyNormalized,
isAuthorizedSender: true,
senderIsOwner: true,
senderId: "owner",
channel: "whatsapp",
channelId: "whatsapp",
surface: "whatsapp",
ownerList: [],
from: "test-user",
to: "test-bot",
},
sessionKey: "agent:main:whatsapp:direct:test-user",
elevated: { enabled: true, allowed: true, failures: [] },
isGroup: false,
} as unknown as HandleCommandsParams;
}
describe("handleBashCommand alias routing", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("routes !poll and !stop through the bash chat handler", async () => {
for (const aliasCommand of ["!poll", "!stop"]) {
const result = await handleBashCommand(buildBashParams(aliasCommand), true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("No active bash job");
}
expect(handleBashChatCommandMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,20 @@
import { logVerbose } from "../../globals.js";
import { buildContextReply } from "./commands-context-report.js";
import type { CommandHandler } from "./commands-types.js";
export const handleContextCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
if (normalized !== "/context" && !normalized.startsWith("/context ")) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /context from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: await buildContextReply(params) };
};

View File

@@ -1 +1 @@
export { emitResetCommandHooks } from "./commands-core.js";
export { emitResetCommandHooks } from "./commands-reset-hooks.js";

View File

@@ -34,7 +34,7 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
}) as unknown as HookRunner,
}));
const { emitResetCommandHooks } = await import("./commands-core.js");
const { emitResetCommandHooks } = await import("./commands-reset-hooks.js");
describe("emitResetCommandHooks", () => {
async function runBeforeResetContext(sessionKey?: string) {

View File

@@ -1,28 +1,17 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { isAcpSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
import { emitResetCommandHooks } from "./commands-reset-hooks.js";
import { maybeHandleResetCommand } from "./commands-reset.js";
import type {
CommandHandler,
CommandHandlerResult,
HandleCommandsParams,
} from "./commands-types.js";
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
export { emitResetCommandHooks } from "./commands-reset-hooks.js";
let commandHandlersRuntimePromise: Promise<typeof import("./commands-handlers.runtime.js")> | null =
null;
function loadRouteReplyRuntime() {
routeReplyRuntimePromise ??= import("./route-reply.runtime.js");
return routeReplyRuntimePromise;
}
function loadCommandHandlersRuntime() {
commandHandlersRuntimePromise ??= import("./commands-handlers.runtime.js");
return commandHandlersRuntimePromise;
@@ -30,290 +19,13 @@ function loadCommandHandlersRuntime() {
let HANDLERS: CommandHandler[] | null = null;
export type ResetCommandAction = "new" | "reset";
// Reset hooks only need the transcript message payloads, not session headers or metadata rows.
function parseTranscriptMessages(content: string): unknown[] {
const messages: unknown[] = [];
for (const line of content.split("\n")) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line);
if (entry.type === "message" && entry.message) {
messages.push(entry.message);
}
} catch {
// Skip malformed lines from partially-written transcripts.
}
}
return messages;
}
// Once /reset rotates a transcript, the newest archived sibling is the best fallback source.
async function findLatestArchivedTranscript(sessionFile: string): Promise<string | undefined> {
try {
const dir = path.dirname(sessionFile);
const base = path.basename(sessionFile);
const resetPrefix = `${base}.reset.`;
const archived = (await fs.readdir(dir))
.filter((name) => name.startsWith(resetPrefix))
.toSorted();
const latest = archived[archived.length - 1];
return latest ? path.join(dir, latest) : undefined;
} catch {
return undefined;
}
}
// Prefer the live transcript path, but fall back to the archived reset transcript when rotation won the race.
async function loadBeforeResetTranscript(params: {
sessionFile?: string;
}): Promise<{ sessionFile?: string; messages: unknown[] }> {
const sessionFile = params.sessionFile;
if (!sessionFile) {
logVerbose("before_reset: no session file available, firing hook with empty messages");
return { sessionFile, messages: [] };
}
try {
return {
sessionFile,
messages: parseTranscriptMessages(await fs.readFile(sessionFile, "utf-8")),
};
} catch (err: unknown) {
if ((err as { code?: unknown })?.code !== "ENOENT") {
logVerbose(
`before_reset: failed to read session file ${sessionFile}; firing hook with empty messages (${String(err)})`,
);
return { sessionFile, messages: [] };
}
}
const archivedSessionFile = await findLatestArchivedTranscript(sessionFile);
if (!archivedSessionFile) {
logVerbose(
`before_reset: failed to find archived transcript for ${sessionFile}; firing hook with empty messages`,
);
return { sessionFile, messages: [] };
}
try {
return {
sessionFile: archivedSessionFile,
messages: parseTranscriptMessages(await fs.readFile(archivedSessionFile, "utf-8")),
};
} catch (err: unknown) {
logVerbose(
`before_reset: failed to read archived session file ${archivedSessionFile}; firing hook with empty messages (${String(err)})`,
);
return { sessionFile: archivedSessionFile, messages: [] };
}
}
export async function emitResetCommandHooks(params: {
action: ResetCommandAction;
ctx: HandleCommandsParams["ctx"];
cfg: HandleCommandsParams["cfg"];
command: Pick<
HandleCommandsParams["command"],
"surface" | "senderId" | "channel" | "from" | "to" | "resetHookTriggered"
>;
sessionKey?: string;
sessionEntry?: HandleCommandsParams["sessionEntry"];
previousSessionEntry?: HandleCommandsParams["previousSessionEntry"];
workspaceDir: string;
}): Promise<void> {
const hookEvent = createInternalHookEvent("command", params.action, params.sessionKey ?? "", {
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
commandSource: params.command.surface,
senderId: params.command.senderId,
workspaceDir: params.workspaceDir,
cfg: params.cfg, // Pass config for LLM slug generation
});
await triggerInternalHook(hookEvent);
params.command.resetHookTriggered = true;
// Send hook messages immediately if present
if (hookEvent.messages.length > 0) {
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
// oxlint-disable-next-line typescript/no-explicit-any
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
if (channel && to) {
const { routeReply } = await loadRouteReplyRuntime();
const hookReply = { text: hookEvent.messages.join("\n\n") };
await routeReply({
payload: hookReply,
channel: channel,
to: to,
sessionKey: params.sessionKey,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
cfg: params.cfg,
});
}
}
// Fire before_reset plugin hook — extract memories before session history is lost
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_reset")) {
const prevEntry = params.previousSessionEntry;
// Fire-and-forget: read old session messages and run hook
void (async () => {
const { sessionFile, messages } = await loadBeforeResetTranscript({
sessionFile: prevEntry?.sessionFile,
});
try {
await hookRunner.runBeforeReset(
{ sessionFile, messages, reason: params.action },
{
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
sessionKey: params.sessionKey,
sessionId: prevEntry?.sessionId,
workspaceDir: params.workspaceDir,
},
);
} catch (err: unknown) {
logVerbose(`before_reset hook failed: ${String(err)}`);
}
})();
}
}
function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: string): void {
const mutableCtx = ctx as Record<string, unknown>;
mutableCtx.Body = resetTail;
mutableCtx.RawBody = resetTail;
mutableCtx.CommandBody = resetTail;
mutableCtx.BodyForCommands = resetTail;
mutableCtx.BodyForAgent = resetTail;
mutableCtx.BodyStripped = resetTail;
mutableCtx.AcpDispatchTailAfterReset = true;
}
function resolveSessionEntryForHookSessionKey(
sessionStore: HandleCommandsParams["sessionStore"] | undefined,
sessionKey: string,
): HandleCommandsParams["sessionEntry"] | undefined {
if (!sessionStore) {
return undefined;
}
const directEntry = sessionStore[sessionKey];
if (directEntry) {
return directEntry;
}
const normalizedTarget = sessionKey.trim().toLowerCase();
if (!normalizedTarget) {
return undefined;
}
for (const [candidateKey, candidateEntry] of Object.entries(sessionStore)) {
if (candidateKey.trim().toLowerCase() === normalizedTarget) {
return candidateEntry;
}
}
return undefined;
}
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
if (HANDLERS === null) {
HANDLERS = (await loadCommandHandlersRuntime()).loadCommandHandlers();
}
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
const resetRequested = Boolean(resetMatch);
if (resetRequested && !params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
// Trigger internal hook for reset/new commands
if (resetRequested && params.command.isAuthorizedSender) {
const commandAction: ResetCommandAction = resetMatch?.[1] === "reset" ? "reset" : "new";
const resetTail =
resetMatch != null
? params.command.commandBodyNormalized.slice(resetMatch[0].length).trimStart()
: "";
const boundAcpSessionKey = resolveBoundAcpThreadSessionKey(params);
const boundAcpKey =
boundAcpSessionKey && isAcpSessionKey(boundAcpSessionKey)
? boundAcpSessionKey.trim()
: undefined;
if (boundAcpKey) {
const resetResult = await resetConfiguredBindingTargetInPlace({
cfg: params.cfg,
sessionKey: boundAcpKey,
reason: commandAction,
});
if (!resetResult.ok && !resetResult.skipped) {
logVerbose(
`acp reset-in-place failed for ${boundAcpKey}: ${resetResult.error ?? "unknown error"}`,
);
}
if (resetResult.ok) {
const hookSessionEntry =
boundAcpKey === params.sessionKey
? params.sessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
const hookPreviousSessionEntry =
boundAcpKey === params.sessionKey
? params.previousSessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: boundAcpKey,
sessionEntry: hookSessionEntry,
previousSessionEntry: hookPreviousSessionEntry,
workspaceDir: params.workspaceDir,
});
if (resetTail) {
applyAcpResetTailContext(params.ctx, resetTail);
if (params.rootCtx && params.rootCtx !== params.ctx) {
applyAcpResetTailContext(params.rootCtx, resetTail);
}
return {
shouldContinue: false,
};
}
return {
shouldContinue: false,
reply: { text: "✅ ACP session reset in place." },
};
}
if (resetResult.skipped) {
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset unavailable for this bound conversation. Rebind with /acp bind or /acp spawn.",
},
};
}
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset failed. Check /acp status and try again.",
},
};
}
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
workspaceDir: params.workspaceDir,
});
const resetResult = await maybeHandleResetCommand(params);
if (resetResult) {
return resetResult;
}
const allowTextCommands = shouldHandleTextCommands({

View File

@@ -0,0 +1,507 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isCommandFlagEnabled } from "../../config/commands.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { handleBashChatCommand } from "./bash-command.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
import type { HandleCommandsParams } from "./commands-types.js";
const readConfigFileSnapshotMock = vi.hoisted(() =>
vi.fn(async () => ({ valid: true, parsed: {} })),
);
const validateConfigObjectWithPluginsMock = vi.hoisted(() =>
vi.fn(() => ({
ok: true,
config: {},
issues: [],
})),
);
const writeConfigFileMock = vi.hoisted(() => vi.fn(async () => undefined));
const getConfigOverridesMock = vi.hoisted(() => vi.fn(() => ({})));
const getConfigValueAtPathMock = vi.hoisted(() => vi.fn());
const parseConfigPathMock = vi.hoisted(() => vi.fn());
const setConfigValueAtPathMock = vi.hoisted(() => vi.fn());
const resolveConfigWriteDeniedTextMock = vi.hoisted(() => vi.fn(() => undefined));
const isInternalMessageChannelMock = vi.hoisted(() => vi.fn(() => false));
vi.mock("../../agents/agent-scope.js", () => ({
resolveSessionAgentId: vi.fn(() => "agent:main"),
}));
vi.mock("../../agents/bash-process-registry.js", () => ({
getFinishedSession: vi.fn(() => undefined),
getSession: vi.fn(() => undefined),
}));
vi.mock("../../agents/bash-tools.js", () => ({
createExecTool: vi.fn(),
}));
vi.mock("../../agents/sandbox.js", () => ({
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })),
}));
vi.mock("../../globals.js", () => ({
logVerbose: vi.fn(),
}));
vi.mock("../../utils.js", async () => {
const actual = await vi.importActual<typeof import("../../utils.js")>("../../utils.js");
return {
...actual,
clampInt: vi.fn((value: number) => value),
};
});
vi.mock("./mentions.js", () => ({
stripMentions: vi.fn((value: string) => value),
stripStructuralPrefixes: vi.fn((value: string) => value),
}));
vi.mock("../../channels/plugins/config-writes.js", () => ({
resolveConfigWriteTargetFromPath: vi.fn(() => "config"),
}));
vi.mock("../../channels/registry.js", () => ({
normalizeChannelId: vi.fn((value?: string) => value),
}));
vi.mock("../../config/config-paths.js", () => ({
getConfigValueAtPath: getConfigValueAtPathMock,
parseConfigPath: parseConfigPathMock,
setConfigValueAtPath: setConfigValueAtPathMock,
unsetConfigValueAtPath: vi.fn(() => true),
}));
vi.mock("../../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../../config/runtime-overrides.js", () => ({
getConfigOverrides: getConfigOverridesMock,
resetConfigOverrides: vi.fn(),
setConfigOverride: vi.fn(() => ({ ok: true })),
unsetConfigOverride: vi.fn(() => ({ ok: true, removed: true })),
}));
vi.mock("../../utils/message-channel.js", () => ({
isInternalMessageChannel: isInternalMessageChannelMock,
}));
vi.mock("./channel-context.js", () => ({
resolveChannelAccountId: vi.fn(() => undefined),
}));
vi.mock("./config-commands.js", () => ({
parseConfigCommand: vi.fn((raw: string) => {
if (!raw.startsWith("/config")) {
return null;
}
const parts = raw.trim().split(/\s+/);
const action = parts[1];
if (action === "show" || action === "get") {
return { action: "show", path: parts.slice(2).join(" ") || undefined };
}
if (action === "set") {
const assignment = raw.slice(raw.indexOf(" set ") + 5).trim();
const equalsIndex = assignment.indexOf("=");
return {
action: "set",
path: assignment.slice(0, equalsIndex),
value: JSON.parse(assignment.slice(equalsIndex + 1)),
};
}
if (action === "unset") {
return { action: "unset", path: parts.slice(2).join(" ") };
}
return { action: "error", message: "Invalid /config syntax." };
}),
}));
vi.mock("./config-write-authorization.js", () => ({
resolveConfigWriteDeniedText: resolveConfigWriteDeniedTextMock,
}));
vi.mock("./debug-commands.js", () => ({
parseDebugCommand: vi.fn((raw: string) => {
if (!raw.startsWith("/debug")) {
return null;
}
return { action: "show" };
}),
}));
function buildParams(commandBody: string, cfg: OpenClawConfig): HandleCommandsParams {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
SessionKey: "agent:main:main",
} as MsgContext;
return {
ctx,
cfg,
command: {
surface: "whatsapp",
channel: "whatsapp",
channelId: "whatsapp",
ownerList: [],
senderIsOwner: false,
isAuthorizedSender: true,
senderId: "user-1",
rawBodyNormalized: commandBody.trim(),
commandBodyNormalized: commandBody.trim(),
from: "user-1",
to: "bot-1",
},
directives: {},
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("command gating", () => {
beforeEach(() => {
vi.clearAllMocks();
readConfigFileSnapshotMock.mockResolvedValue({ valid: true, parsed: {} });
validateConfigObjectWithPluginsMock.mockReturnValue({
ok: true,
config: {},
issues: [],
});
getConfigOverridesMock.mockReturnValue({});
getConfigValueAtPathMock.mockImplementation((target: unknown, path: string[]) => {
let current = target as Record<string, unknown> | undefined;
for (const segment of path) {
if (!current || typeof current !== "object") {
return undefined;
}
current = current[segment] as Record<string, unknown> | undefined;
}
return current;
});
parseConfigPathMock.mockImplementation((raw: string) => ({
ok: true,
path: raw.split("."),
}));
setConfigValueAtPathMock.mockImplementation(
(target: Record<string, unknown>, path: string[], value: unknown) => {
let cursor: Record<string, unknown> = target;
for (const segment of path.slice(0, -1)) {
const next = cursor[segment];
if (!next || typeof next !== "object") {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
const leaf = path[path.length - 1];
if (leaf) {
cursor[leaf] = value;
}
},
);
});
it("blocks disabled bash", async () => {
const result = await handleBashChatCommand({
ctx: {
Body: "/bash echo hi",
CommandBody: "/bash echo hi",
SessionKey: "agent:main:main",
} as MsgContext,
cfg: { commands: { bash: false } } as OpenClawConfig,
sessionKey: "agent:main:main",
isGroup: false,
elevated: { enabled: true, allowed: true, failures: [] },
});
expect(result.text).toContain("bash is disabled");
});
it("blocks bash when elevated access is unavailable", async () => {
const result = await handleBashChatCommand({
ctx: {
Body: "/bash echo hi",
CommandBody: "/bash echo hi",
SessionKey: "agent:main:main",
} as MsgContext,
cfg: { commands: { bash: true } } as OpenClawConfig,
sessionKey: "agent:main:main",
isGroup: false,
elevated: {
enabled: true,
allowed: false,
failures: [{ gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" }],
},
});
expect(result.text).toContain("elevated is not available");
});
it("blocks disabled config", async () => {
const params = buildParams("/config show", {
commands: { config: false, debug: false, text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
params.command.senderIsOwner = true;
const result = await handleConfigCommand(params, true);
expect(result?.reply?.text).toContain("/config is disabled");
});
it("blocks disabled debug", async () => {
const params = buildParams("/debug show", {
commands: { config: false, debug: false, text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
params.command.senderIsOwner = true;
const result = await handleDebugCommand(params, true);
expect(result?.reply?.text).toContain("/debug is disabled");
});
it("blocks authorized non-owners from /config show and /debug show", async () => {
const configParams = buildParams("/config show", {
commands: { config: true, text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
const configResult = await handleConfigCommand(configParams, true);
expect(configResult).toEqual({ shouldContinue: false });
const debugParams = buildParams("/debug show", {
commands: { debug: true, text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
const debugResult = await handleDebugCommand(debugParams, true);
expect(debugResult).toEqual({ shouldContinue: false });
});
it("keeps /config show and /debug show available for owners", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: { messages: { ackReaction: ":)" } },
});
const configParams = buildParams("/config show messages.ackReaction", {
commands: { config: true, text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
configParams.command.senderIsOwner = true;
const configResult = await handleConfigCommand(configParams, true);
expect(configResult?.reply?.text).toContain("⚙️ Config");
expect(configResult?.reply?.text).toContain('":)"');
const debugParams = buildParams("/debug show", {
commands: { debug: true, text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
debugParams.command.senderIsOwner = true;
const debugResult = await handleDebugCommand(debugParams, true);
expect(debugResult?.reply?.text).toContain("Debug overrides");
});
it("returns explicit unauthorized replies for native privileged commands", async () => {
const configParams = buildParams("/config show", {
commands: { config: true, text: true },
channels: { telegram: { allowFrom: ["*"] } },
} as OpenClawConfig);
configParams.ctx.CommandSource = "native";
configParams.command.channel = "telegram";
configParams.command.channelId = "telegram";
configParams.command.surface = "telegram";
const configResult = await handleConfigCommand(configParams, true);
expect(configResult).toEqual({
shouldContinue: false,
reply: { text: "You are not authorized to use this command." },
});
const debugParams = buildParams("/debug show", {
commands: { debug: true, text: true },
channels: { telegram: { allowFrom: ["*"] } },
} as OpenClawConfig);
debugParams.ctx.CommandSource = "native";
debugParams.command.channel = "telegram";
debugParams.command.channelId = "telegram";
debugParams.command.surface = "telegram";
const debugResult = await handleDebugCommand(debugParams, true);
expect(debugResult).toEqual({
shouldContinue: false,
reply: { text: "You are not authorized to use this command." },
});
});
it("ignores inherited command flags", () => {
const inheritedCommands = Object.create({
bash: true,
config: true,
debug: true,
}) as Record<string, unknown>;
const cfg = { commands: inheritedCommands as never } as OpenClawConfig;
expect(isCommandFlagEnabled(cfg, "bash")).toBe(false);
expect(isCommandFlagEnabled(cfg, "config")).toBe(false);
expect(isCommandFlagEnabled(cfg, "debug")).toBe(false);
});
it("blocks disallowed /config set writes", async () => {
resolveConfigWriteDeniedTextMock
.mockReturnValueOnce("Config writes are disabled")
.mockReturnValueOnce("channels.telegram.accounts.work.configWrites=true")
.mockReturnValueOnce("cannot replace channels, channel roots, or accounts collections");
const cases = [
{
name: "channel config writes disabled",
params: (() => {
const params = buildParams('/config set messages.ackReaction=":)"', {
commands: { config: true, text: true },
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
} as OpenClawConfig);
params.command.senderIsOwner = true;
return params;
})(),
expectedText: "Config writes are disabled",
},
{
name: "target account disables writes",
params: (() => {
const params = buildParams("/config set channels.telegram.accounts.work.enabled=false", {
commands: { config: true, text: true },
channels: {
telegram: {
configWrites: true,
accounts: {
work: { configWrites: false, enabled: true },
},
},
},
} as OpenClawConfig);
params.ctx.Provider = "telegram";
params.ctx.Surface = "telegram";
params.command.channel = "telegram";
params.command.channelId = "telegram";
params.command.surface = "telegram";
params.command.senderIsOwner = true;
return params;
})(),
expectedText: "channels.telegram.accounts.work.configWrites=true",
},
{
name: "ambiguous channel-root write",
params: (() => {
const params = buildParams('/config set channels.telegram={"enabled":false}', {
commands: { config: true, text: true },
channels: { telegram: { configWrites: true } },
} as OpenClawConfig);
params.ctx.Provider = "telegram";
params.ctx.Surface = "telegram";
params.command.channel = "telegram";
params.command.channelId = "telegram";
params.command.surface = "telegram";
params.command.senderIsOwner = true;
return params;
})(),
expectedText: "cannot replace channels, channel roots, or accounts collections",
},
] as const;
for (const testCase of cases) {
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const result = await handleConfigCommand(testCase.params, true);
expect(result?.shouldContinue, testCase.name).toBe(false);
expect(result?.reply?.text, testCase.name).toContain(testCase.expectedText);
expect(writeConfigFileMock.mock.calls.length, testCase.name).toBe(previousWriteCount);
}
});
it("honors the configured default account when gating omitted-account /config writes", async () => {
resolveConfigWriteDeniedTextMock.mockReturnValueOnce(
"channels.telegram.accounts.work.configWrites=true",
);
const params = buildParams('/config set messages.ackReaction=":)"', {
commands: { config: true, text: true },
channels: {
telegram: {
defaultAccount: "work",
configWrites: true,
accounts: {
work: { configWrites: false, enabled: true },
},
},
},
} as OpenClawConfig);
params.ctx.Provider = "telegram";
params.ctx.Surface = "telegram";
params.command.channel = "telegram";
params.command.channelId = "telegram";
params.command.surface = "telegram";
params.command.senderIsOwner = true;
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const result = await handleConfigCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
});
it("enforces gateway client permissions for /config commands", async () => {
const baseCfg = { commands: { config: true, text: true } } as OpenClawConfig;
const blockedParams = buildParams('/config set messages.ackReaction=":)"', baseCfg);
blockedParams.ctx.Provider = "webchat";
blockedParams.ctx.Surface = "webchat";
blockedParams.ctx.GatewayClientScopes = ["operator.write"];
blockedParams.command.channel = "webchat";
blockedParams.command.channelId = "webchat";
blockedParams.command.surface = "webchat";
blockedParams.command.senderIsOwner = true;
isInternalMessageChannelMock.mockReturnValueOnce(true);
const blockedResult = await handleConfigCommand(blockedParams, true);
expect(blockedResult?.shouldContinue).toBe(false);
expect(blockedResult?.reply?.text).toContain("requires operator.admin");
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: { messages: { ackReaction: ":)" } },
});
const showParams = buildParams("/config show messages.ackReaction", baseCfg);
showParams.ctx.Provider = "webchat";
showParams.ctx.Surface = "webchat";
showParams.ctx.GatewayClientScopes = ["operator.write"];
showParams.command.channel = "webchat";
showParams.command.channelId = "webchat";
showParams.command.surface = "webchat";
isInternalMessageChannelMock.mockReturnValueOnce(true);
const showResult = await handleConfigCommand(showParams, true);
expect(showResult?.shouldContinue).toBe(false);
expect(showResult?.reply?.text).toContain("Config messages.ackReaction");
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: { messages: { ackReaction: ":)" } },
});
const setParams = buildParams('/config set messages.ackReaction=":D"', baseCfg);
setParams.ctx.Provider = "webchat";
setParams.ctx.Surface = "webchat";
setParams.ctx.GatewayClientScopes = ["operator.write", "operator.admin"];
setParams.command.channel = "webchat";
setParams.command.channelId = "webchat";
setParams.command.surface = "webchat";
setParams.command.senderIsOwner = true;
isInternalMessageChannelMock.mockReturnValueOnce(true);
const setResult = await handleConfigCommand(setParams, true);
expect(setResult?.shouldContinue).toBe(false);
expect(setResult?.reply?.text).toContain("Config updated");
expect(writeConfigFileMock).toHaveBeenCalled();
});
});

View File

@@ -5,14 +5,13 @@ import { handleBashCommand } from "./commands-bash.js";
import { handleBtwCommand } from "./commands-btw.js";
import { handleCompactCommand } from "./commands-compact.js";
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
import { handleContextCommand } from "./commands-context-command.js";
import {
handleCommandsListCommand,
handleContextCommand,
handleExportSessionCommand,
handleHelpCommand,
handleStatusCommand,
handleToolsCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleMcpCommand } from "./commands-mcp.js";
import { handleModelsCommand } from "./commands-models.js";
@@ -32,6 +31,7 @@ import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleTasksCommand } from "./commands-tasks.js";
import { handleTtsCommands } from "./commands-tts.js";
import type { CommandHandler } from "./commands-types.js";
import { handleWhoamiCommand } from "./commands-whoami.js";
export function loadCommandHandlers(): CommandHandler[] {
return [

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { handleContextCommand } from "./commands-context-command.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { handleWhoamiCommand } from "./commands-whoami.js";
const buildContextReplyMock = vi.hoisted(() => vi.fn());
vi.mock("./commands-context-report.js", () => ({
buildContextReply: buildContextReplyMock,
}));
function buildInfoParams(
commandBodyNormalized: string,
cfg: OpenClawConfig,
ctxOverrides?: Partial<MsgContext>,
): HandleCommandsParams {
return {
cfg,
ctx: {
Provider: "whatsapp",
Surface: "whatsapp",
CommandSource: "text",
...ctxOverrides,
},
command: {
commandBodyNormalized,
isAuthorizedSender: true,
senderIsOwner: true,
senderId: "12345",
channel: "whatsapp",
channelId: "whatsapp",
surface: "whatsapp",
ownerList: [],
from: "12345",
to: "bot",
},
sessionKey: "agent:main:whatsapp:direct:12345",
workspaceDir: "/tmp",
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
directives: {},
elevated: { enabled: true, allowed: true, failures: [] },
} as unknown as HandleCommandsParams;
}
describe("info command handlers", () => {
beforeEach(() => {
vi.clearAllMocks();
buildContextReplyMock.mockImplementation(async (params: HandleCommandsParams) => {
const normalized = params.command.commandBodyNormalized;
if (normalized === "/context list") {
return { text: "Injected workspace files:\n- AGENTS.md" };
}
if (normalized === "/context detail") {
return { text: "Context breakdown (detailed)\nTop tools (schema size):" };
}
return { text: "/context\n- /context list\nInline shortcut" };
});
});
it("returns sender details for /whoami", async () => {
const result = await handleWhoamiCommand(
buildInfoParams(
"/whoami",
{
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig,
{
SenderId: "12345",
SenderUsername: "TestUser",
ChatType: "direct",
},
),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Channel: whatsapp");
expect(result?.reply?.text).toContain("User id: 12345");
expect(result?.reply?.text).toContain("Username: @TestUser");
expect(result?.reply?.text).toContain("AllowFrom: 12345");
});
it("returns expected details for /context commands", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig;
const cases = [
{ commandBody: "/context", expectedText: ["/context list", "Inline shortcut"] },
{ commandBody: "/context list", expectedText: ["Injected workspace files:", "AGENTS.md"] },
{
commandBody: "/context detail",
expectedText: ["Context breakdown (detailed)", "Top tools (schema size):"],
},
] as const;
for (const testCase of cases) {
const result = await handleContextCommand(buildInfoParams(testCase.commandBody, cfg), true);
expect(result?.shouldContinue).toBe(false);
for (const expectedText of testCase.expectedText) {
expect(result?.reply?.text).toContain(expectedText);
}
}
});
});

View File

@@ -11,12 +11,13 @@ import {
} from "../status.js";
import { buildThreadingToolContext } from "./agent-runner-utils.js";
import { resolveChannelAccountId } from "./channel-context.js";
import { buildContextReply } from "./commands-context-report.js";
import { buildExportSessionReply } from "./commands-export-session.js";
import { buildStatusReply } from "./commands-status.js";
import type { CommandHandler } from "./commands-types.js";
import { extractExplicitGroupId } from "./group-id.js";
import { resolveReplyToMode } from "./reply-threading.js";
export { handleContextCommand } from "./commands-context-command.js";
export { handleWhoamiCommand } from "./commands-whoami.js";
export const handleHelpCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
@@ -205,23 +206,6 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma
return { shouldContinue: false, reply };
};
export const handleContextCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
if (normalized !== "/context" && !normalized.startsWith("/context ")) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /context from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: await buildContextReply(params) };
};
export const handleExportSessionCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
@@ -243,38 +227,3 @@ export const handleExportSessionCommand: CommandHandler = async (params, allowTe
}
return { shouldContinue: false, reply: await buildExportSessionReply(params) };
};
export const handleWhoamiCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
if (params.command.commandBodyNormalized !== "/whoami") {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /whoami from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const senderId = params.ctx.SenderId ?? "";
const senderUsername = params.ctx.SenderUsername ?? "";
const lines = ["🧭 Identity", `Channel: ${params.command.channel}`];
if (senderId) {
lines.push(`User id: ${senderId}`);
}
if (senderUsername) {
const handle = senderUsername.startsWith("@") ? senderUsername : `@${senderUsername}`;
lines.push(`Username: ${handle}`);
}
if (params.ctx.ChatType === "group" && params.ctx.From) {
lines.push(`Chat: ${params.ctx.From}`);
}
if (params.ctx.MessageThreadId != null) {
lines.push(`Thread: ${params.ctx.MessageThreadId}`);
}
if (senderId) {
lines.push(`AllowFrom: ${senderId}`);
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
};

View File

@@ -1,108 +1,238 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { withTempHome } from "../../config/home-env.test-harness.js";
import { handleCommands } from "./commands-core.js";
import { createCommandWorkspaceHarness } from "./commands-filesystem.test-support.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
import { handlePluginsCommand } from "./commands-plugins.js";
import type { HandleCommandsParams } from "./commands-types.js";
const workspaceHarness = createCommandWorkspaceHarness("openclaw-command-plugins-");
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn(async () => undefined));
const buildPluginSnapshotReportMock = vi.hoisted(() => vi.fn());
const buildPluginDiagnosticsReportMock = vi.hoisted(() => vi.fn());
const buildPluginInspectReportMock = vi.hoisted(() => vi.fn());
const buildAllPluginInspectReportsMock = vi.hoisted(() => vi.fn());
const formatPluginCompatibilityNoticeMock = vi.hoisted(() => vi.fn(() => "ok"));
async function createClaudeBundlePlugin(params: { workspaceDir: string; pluginId: string }) {
const pluginDir = path.join(params.workspaceDir, ".openclaw", "extensions", params.pluginId);
await fs.mkdir(path.join(pluginDir, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginDir, "commands"), { recursive: true });
await fs.writeFile(
path.join(pluginDir, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: params.pluginId }, null, 2),
"utf-8",
);
await fs.writeFile(path.join(pluginDir, "commands", "review.md"), "# Review\n", "utf-8");
}
vi.mock("../../cli/npm-resolution.js", () => ({
buildNpmInstallRecordFields: vi.fn(),
}));
vi.mock("../../cli/plugins-command-helpers.js", () => ({
buildPreferredClawHubSpec: vi.fn(() => null),
createPluginInstallLogger: vi.fn(() => ({})),
decidePreferredClawHubFallback: vi.fn(() => "fallback_to_npm"),
resolveFileNpmSpecToLocalPath: vi.fn(() => null),
}));
vi.mock("../../cli/plugins-install-persist.js", () => ({
persistPluginInstall: vi.fn(async () => undefined),
}));
vi.mock("../../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../../infra/archive.js", () => ({
resolveArchiveKind: vi.fn(() => null),
}));
vi.mock("../../infra/clawhub.js", () => ({
parseClawHubPluginSpec: vi.fn(() => null),
}));
vi.mock("../../plugins/clawhub.js", () => ({
installPluginFromClawHub: vi.fn(),
}));
vi.mock("../../plugins/install.js", () => ({
installPluginFromNpmSpec: vi.fn(),
installPluginFromPath: vi.fn(),
}));
vi.mock("../../plugins/manifest-registry.js", () => ({
clearPluginManifestRegistryCache: vi.fn(),
}));
vi.mock("../../plugins/status.js", () => ({
buildAllPluginInspectReports: buildAllPluginInspectReportsMock,
buildPluginDiagnosticsReport: buildPluginDiagnosticsReportMock,
buildPluginInspectReport: buildPluginInspectReportMock,
buildPluginSnapshotReport: buildPluginSnapshotReportMock,
formatPluginCompatibilityNotice: formatPluginCompatibilityNoticeMock,
}));
vi.mock("../../plugins/toggle-config.js", () => ({
setPluginEnabledInConfig: vi.fn((config: OpenClawConfig, id: string, enabled: boolean) => ({
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[id]: { enabled },
},
},
})),
}));
vi.mock("../../utils.js", async () => {
const actual = await vi.importActual<typeof import("../../utils.js")>("../../utils.js");
return {
...actual,
resolveUserPath: vi.fn((value: string) => value),
};
});
function buildCfg(): OpenClawConfig {
return {
plugins: {
enabled: true,
},
commands: {
text: true,
plugins: true,
},
plugins: { enabled: true },
commands: { text: true, plugins: true },
};
}
describe("handleCommands /plugins", () => {
afterEach(async () => {
await workspaceHarness.cleanupWorkspaces();
function buildPluginsParams(
commandBodyNormalized: string,
cfg: OpenClawConfig,
): HandleCommandsParams {
return {
cfg,
ctx: {
Provider: "whatsapp",
Surface: "whatsapp",
CommandSource: "text",
GatewayClientScopes: ["operator.write", "operator.pairing"],
AccountId: undefined,
},
command: {
commandBodyNormalized,
rawBodyNormalized: commandBodyNormalized,
isAuthorizedSender: true,
senderIsOwner: true,
senderId: "owner",
channel: "whatsapp",
channelId: "whatsapp",
surface: "whatsapp",
ownerList: [],
from: "test-user",
to: "test-bot",
},
sessionKey: "agent:main:whatsapp:direct:test-user",
sessionEntry: {
sessionId: "session-plugin-command",
updatedAt: Date.now(),
},
workspaceDir: "/tmp/plugins-workspace",
} as unknown as HandleCommandsParams;
}
describe("handlePluginsCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
readConfigFileSnapshotMock.mockResolvedValue({
valid: true,
path: "/tmp/openclaw.json",
resolved: buildCfg(),
});
validateConfigObjectWithPluginsMock.mockReturnValue({
ok: true,
config: buildCfg(),
issues: [],
});
buildPluginSnapshotReportMock.mockReturnValue({
workspaceDir: "/tmp/plugins-workspace",
plugins: [
{
id: "superpowers",
name: "superpowers",
status: "disabled",
format: "openclaw",
bundleFormat: "claude",
},
],
});
buildPluginDiagnosticsReportMock.mockReturnValue({
workspaceDir: "/tmp/plugins-workspace",
plugins: [
{
id: "superpowers",
name: "superpowers",
status: "disabled",
format: "openclaw",
bundleFormat: "claude",
},
],
});
buildPluginInspectReportMock.mockReturnValue({
plugin: {
id: "superpowers",
},
compatibility: [],
bundleFormat: "claude",
shape: { commands: ["review"] },
});
buildAllPluginInspectReportsMock.mockReturnValue([
{
plugin: { id: "superpowers" },
compatibility: [],
},
]);
});
it("lists discovered plugins and inspects plugin details", async () => {
await withTempHome("openclaw-command-plugins-home-", async () => {
const workspaceDir = await workspaceHarness.createWorkspace();
await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" });
const listResult = await handlePluginsCommand(
buildPluginsParams("/plugins list", buildCfg()),
true,
);
expect(listResult?.reply?.text).toContain("Plugins");
expect(listResult?.reply?.text).toContain("superpowers");
expect(listResult?.reply?.text).toContain("[disabled]");
const listParams = buildCommandTestParams("/plugins list", buildCfg(), undefined, {
workspaceDir,
});
listParams.command.senderIsOwner = true;
const listResult = await handleCommands(listParams);
expect(listResult.reply?.text).toContain("Plugins");
expect(listResult.reply?.text).toContain("superpowers");
expect(listResult.reply?.text).toContain("[disabled]");
const showResult = await handlePluginsCommand(
buildPluginsParams("/plugins inspect superpowers", buildCfg()),
true,
);
expect(showResult?.reply?.text).toContain('"id": "superpowers"');
expect(showResult?.reply?.text).toContain('"bundleFormat": "claude"');
expect(showResult?.reply?.text).toContain('"shape"');
expect(showResult?.reply?.text).toContain('"compatibilityWarnings": []');
const showParams = buildCommandTestParams(
"/plugins inspect superpowers",
buildCfg(),
undefined,
{
workspaceDir,
},
);
showParams.command.senderIsOwner = true;
const showResult = await handleCommands(showParams);
expect(showResult.reply?.text).toContain('"id": "superpowers"');
expect(showResult.reply?.text).toContain('"bundleFormat": "claude"');
expect(showResult.reply?.text).toContain('"shape":');
expect(showResult.reply?.text).toContain('"compatibilityWarnings": []');
const inspectAllParams = buildCommandTestParams(
"/plugins inspect all",
buildCfg(),
undefined,
{
workspaceDir,
},
);
inspectAllParams.command.senderIsOwner = true;
const inspectAllResult = await handleCommands(inspectAllParams);
expect(inspectAllResult.reply?.text).toContain("```json");
expect(inspectAllResult.reply?.text).toContain('"plugin"');
expect(inspectAllResult.reply?.text).toContain('"compatibilityWarnings"');
expect(inspectAllResult.reply?.text).toContain('"superpowers"');
});
const inspectAllResult = await handlePluginsCommand(
buildPluginsParams("/plugins inspect all", buildCfg()),
true,
);
expect(inspectAllResult?.reply?.text).toContain("```json");
expect(inspectAllResult?.reply?.text).toContain('"plugin"');
expect(inspectAllResult?.reply?.text).toContain('"compatibilityWarnings"');
expect(inspectAllResult?.reply?.text).toContain('"superpowers"');
});
it("rejects internal writes without operator.admin", async () => {
await withTempHome("openclaw-command-plugins-home-", async () => {
const workspaceDir = await workspaceHarness.createWorkspace();
await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" });
const params = buildPluginsParams("/plugins enable superpowers", buildCfg());
params.command.channel = "webchat";
params.command.channelId = "webchat";
params.command.surface = "webchat";
params.ctx.Provider = "webchat";
params.ctx.Surface = "webchat";
params.ctx.GatewayClientScopes = ["operator.write"];
const params = buildCommandTestParams(
"/plugins enable superpowers",
buildCfg(),
{
Provider: "webchat",
Surface: "webchat",
GatewayClientScopes: ["operator.write"],
},
{ workspaceDir },
);
params.command.senderIsOwner = true;
const result = await handlePluginsCommand(params, true);
expect(result?.reply?.text).toContain("requires operator.admin");
});
const result = await handleCommands(params);
expect(result.reply?.text).toContain("requires operator.admin");
it("returns an explicit unauthorized reply for native /plugins list", async () => {
const params = buildPluginsParams("/plugins list", buildCfg());
params.command.senderIsOwner = false;
params.ctx.Provider = "telegram";
params.ctx.Surface = "telegram";
params.ctx.CommandSource = "native";
params.command.channel = "telegram";
params.command.channelId = "telegram";
params.command.surface = "telegram";
const result = await handlePluginsCommand(params, true);
expect(result).toEqual({
shouldContinue: false,
reply: { text: "You are not authorized to use this command." },
});
});
});

View File

@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { maybeHandleResetCommand } from "./commands-reset.js";
import type { HandleCommandsParams } from "./commands-types.js";
const triggerInternalHookMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("../../channels/plugins/binding-targets.js", () => ({
resetConfiguredBindingTargetInPlace: vi.fn().mockResolvedValue({ ok: false, skipped: true }),
}));
vi.mock("../../hooks/internal-hooks.js", () => ({
createInternalHookEvent: (
type: string,
action: string,
sessionKey: string,
context: Record<string, unknown>,
) => ({
type,
action,
sessionKey,
context,
timestamp: new Date(0),
messages: [],
}),
triggerInternalHook: triggerInternalHookMock,
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => null),
}));
vi.mock("../commands-registry.js", () => ({
normalizeCommandBody: (raw: string) => raw.trim(),
shouldHandleTextCommands: () => true,
}));
vi.mock("./commands-acp/targets.js", () => ({
resolveBoundAcpThreadSessionKey: vi.fn(() => undefined),
}));
vi.mock("./commands-handlers.runtime.js", () => ({
loadCommandHandlers: () => [],
}));
function buildResetParams(
commandBody: string,
cfg: OpenClawConfig,
ctxOverrides?: Partial<MsgContext>,
): HandleCommandsParams {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
SessionKey: "agent:main:main",
...ctxOverrides,
} as MsgContext;
return {
ctx,
cfg,
command: {
commandBodyNormalized: commandBody.trim(),
isAuthorizedSender: true,
senderIsOwner: true,
senderId: ctx.SenderId ?? "123",
channel: String(ctx.Surface ?? "whatsapp"),
channelId: String(ctx.Surface ?? "whatsapp"),
surface: String(ctx.Surface ?? "whatsapp"),
ownerList: [],
from: ctx.From ?? "sender",
to: ctx.To ?? "bot",
resetHookTriggered: false,
},
directives: {},
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp/openclaw-commands",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands reset hooks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("triggers hooks for /new commands", async () => {
const cases = [
{
name: "text command with arguments",
params: buildResetParams("/new take notes", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
expectedCall: expect.objectContaining({ type: "command", action: "new" }),
},
{
name: "native command routed to target session",
params: (() => {
const params = buildResetParams(
"/new",
{
commands: { text: true },
channels: { telegram: { allowFrom: ["*"] } },
} as OpenClawConfig,
{
Provider: "telegram",
Surface: "telegram",
CommandSource: "native",
CommandTargetSessionKey: "agent:main:telegram:direct:123",
SessionKey: "telegram:slash:123",
SenderId: "123",
From: "telegram:123",
To: "slash:123",
CommandAuthorized: true,
},
);
params.sessionKey = "agent:main:telegram:direct:123";
return params;
})(),
expectedCall: expect.objectContaining({
type: "command",
action: "new",
sessionKey: "agent:main:telegram:direct:123",
context: expect.objectContaining({
workspaceDir: "/tmp/openclaw-commands",
}),
}),
},
] as const;
for (const testCase of cases) {
await maybeHandleResetCommand(testCase.params);
expect(triggerInternalHookMock, testCase.name).toHaveBeenCalledWith(testCase.expectedCall);
triggerInternalHookMock.mockClear();
}
});
});

View File

@@ -0,0 +1,159 @@
import fs from "node:fs/promises";
import path from "node:path";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import type { HandleCommandsParams } from "./commands-types.js";
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
function loadRouteReplyRuntime() {
routeReplyRuntimePromise ??= import("./route-reply.runtime.js");
return routeReplyRuntimePromise;
}
export type ResetCommandAction = "new" | "reset";
function parseTranscriptMessages(content: string): unknown[] {
const messages: unknown[] = [];
for (const line of content.split("\n")) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line);
if (entry.type === "message" && entry.message) {
messages.push(entry.message);
}
} catch {
// Skip malformed lines from partially-written transcripts.
}
}
return messages;
}
async function findLatestArchivedTranscript(sessionFile: string): Promise<string | undefined> {
try {
const dir = path.dirname(sessionFile);
const base = path.basename(sessionFile);
const resetPrefix = `${base}.reset.`;
const archived = (await fs.readdir(dir))
.filter((name) => name.startsWith(resetPrefix))
.toSorted();
const latest = archived[archived.length - 1];
return latest ? path.join(dir, latest) : undefined;
} catch {
return undefined;
}
}
async function loadBeforeResetTranscript(params: {
sessionFile?: string;
}): Promise<{ sessionFile?: string; messages: unknown[] }> {
const sessionFile = params.sessionFile;
if (!sessionFile) {
logVerbose("before_reset: no session file available, firing hook with empty messages");
return { sessionFile, messages: [] };
}
try {
return {
sessionFile,
messages: parseTranscriptMessages(await fs.readFile(sessionFile, "utf-8")),
};
} catch (err: unknown) {
if ((err as { code?: unknown })?.code !== "ENOENT") {
logVerbose(
`before_reset: failed to read session file ${sessionFile}; firing hook with empty messages (${String(err)})`,
);
return { sessionFile, messages: [] };
}
}
const archivedSessionFile = await findLatestArchivedTranscript(sessionFile);
if (!archivedSessionFile) {
logVerbose(
`before_reset: failed to find archived transcript for ${sessionFile}; firing hook with empty messages`,
);
return { sessionFile, messages: [] };
}
try {
return {
sessionFile: archivedSessionFile,
messages: parseTranscriptMessages(await fs.readFile(archivedSessionFile, "utf-8")),
};
} catch (err: unknown) {
logVerbose(
`before_reset: failed to read archived session file ${archivedSessionFile}; firing hook with empty messages (${String(err)})`,
);
return { sessionFile: archivedSessionFile, messages: [] };
}
}
export async function emitResetCommandHooks(params: {
action: ResetCommandAction;
ctx: HandleCommandsParams["ctx"];
cfg: HandleCommandsParams["cfg"];
command: Pick<
HandleCommandsParams["command"],
"surface" | "senderId" | "channel" | "from" | "to" | "resetHookTriggered"
>;
sessionKey?: string;
sessionEntry?: HandleCommandsParams["sessionEntry"];
previousSessionEntry?: HandleCommandsParams["previousSessionEntry"];
workspaceDir: string;
}): Promise<void> {
const hookEvent = createInternalHookEvent("command", params.action, params.sessionKey ?? "", {
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
commandSource: params.command.surface,
senderId: params.command.senderId,
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
await triggerInternalHook(hookEvent);
params.command.resetHookTriggered = true;
if (hookEvent.messages.length > 0) {
const channel = params.ctx.OriginatingChannel || params.command.channel;
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
if (channel && to) {
const { routeReply } = await loadRouteReplyRuntime();
await routeReply({
payload: { text: hookEvent.messages.join("\n\n") },
channel,
to,
sessionKey: params.sessionKey,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
cfg: params.cfg,
});
}
}
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_reset")) {
const prevEntry = params.previousSessionEntry;
void (async () => {
const { sessionFile, messages } = await loadBeforeResetTranscript({
sessionFile: prevEntry?.sessionFile,
});
try {
await hookRunner.runBeforeReset(
{ sessionFile, messages, reason: params.action },
{
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
sessionKey: params.sessionKey,
sessionId: prevEntry?.sessionId,
workspaceDir: params.workspaceDir,
},
);
} catch (err: unknown) {
logVerbose(`before_reset hook failed: ${String(err)}`);
}
})();
}
}

View File

@@ -0,0 +1,130 @@
import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js";
import { logVerbose } from "../../globals.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js";
import { emitResetCommandHooks, type ResetCommandAction } from "./commands-reset-hooks.js";
import type { CommandHandlerResult, HandleCommandsParams } from "./commands-types.js";
function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: string): void {
const mutableCtx = ctx as Record<string, unknown>;
mutableCtx.Body = resetTail;
mutableCtx.RawBody = resetTail;
mutableCtx.CommandBody = resetTail;
mutableCtx.BodyForCommands = resetTail;
mutableCtx.BodyForAgent = resetTail;
mutableCtx.BodyStripped = resetTail;
mutableCtx.AcpDispatchTailAfterReset = true;
}
function resolveSessionEntryForHookSessionKey(
sessionStore: HandleCommandsParams["sessionStore"] | undefined,
sessionKey: string,
): HandleCommandsParams["sessionEntry"] | undefined {
if (!sessionStore) {
return undefined;
}
const directEntry = sessionStore[sessionKey];
if (directEntry) {
return directEntry;
}
const normalizedTarget = sessionKey.trim().toLowerCase();
if (!normalizedTarget) {
return undefined;
}
for (const [candidateKey, candidateEntry] of Object.entries(sessionStore)) {
if (candidateKey.trim().toLowerCase() === normalizedTarget) {
return candidateEntry;
}
}
return undefined;
}
export async function maybeHandleResetCommand(
params: HandleCommandsParams,
): Promise<CommandHandlerResult | null> {
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
if (!resetMatch) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const commandAction: ResetCommandAction = resetMatch[1] === "reset" ? "reset" : "new";
const resetTail = params.command.commandBodyNormalized.slice(resetMatch[0].length).trimStart();
const boundAcpSessionKey = resolveBoundAcpThreadSessionKey(params);
const boundAcpKey =
boundAcpSessionKey && isAcpSessionKey(boundAcpSessionKey)
? boundAcpSessionKey.trim()
: undefined;
if (boundAcpKey) {
const resetResult = await resetConfiguredBindingTargetInPlace({
cfg: params.cfg,
sessionKey: boundAcpKey,
reason: commandAction,
});
if (!resetResult.ok && !resetResult.skipped) {
logVerbose(
`acp reset-in-place failed for ${boundAcpKey}: ${resetResult.error ?? "unknown error"}`,
);
}
if (resetResult.ok) {
const hookSessionEntry =
boundAcpKey === params.sessionKey
? params.sessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
const hookPreviousSessionEntry =
boundAcpKey === params.sessionKey
? params.previousSessionEntry
: resolveSessionEntryForHookSessionKey(params.sessionStore, boundAcpKey);
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: boundAcpKey,
sessionEntry: hookSessionEntry,
previousSessionEntry: hookPreviousSessionEntry,
workspaceDir: params.workspaceDir,
});
if (resetTail) {
applyAcpResetTailContext(params.ctx, resetTail);
if (params.rootCtx && params.rootCtx !== params.ctx) {
applyAcpResetTailContext(params.rootCtx, resetTail);
}
return { shouldContinue: false };
}
return {
shouldContinue: false,
reply: { text: "✅ ACP session reset in place." },
};
}
if (resetResult.skipped) {
return {
shouldContinue: false,
reply: {
text: "⚠️ ACP session reset unavailable for this bound conversation. Rebind with /acp bind or /acp spawn.",
},
};
}
return {
shouldContinue: false,
reply: { text: "⚠️ ACP session reset failed. Check /acp status and try again." },
};
}
await emitResetCommandHooks({
action: commandAction,
ctx: params.ctx,
cfg: params.cfg,
command: params.command,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
previousSessionEntry: params.previousSessionEntry,
workspaceDir: params.workspaceDir,
});
return null;
}

View File

@@ -0,0 +1,37 @@
import { logVerbose } from "../../globals.js";
import type { CommandHandler } from "./commands-types.js";
export const handleWhoamiCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
if (params.command.commandBodyNormalized !== "/whoami") {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /whoami from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const senderId = params.ctx.SenderId ?? "";
const senderUsername = params.ctx.SenderUsername ?? "";
const lines = ["🧭 Identity", `Channel: ${params.command.channel}`];
if (senderId) {
lines.push(`User id: ${senderId}`);
}
if (senderUsername) {
const handle = senderUsername.startsWith("@") ? senderUsername : `@${senderUsername}`;
lines.push(`Username: ${handle}`);
}
if (params.ctx.ChatType === "group" && params.ctx.From) {
lines.push(`Chat: ${params.ctx.From}`);
}
if (params.ctx.MessageThreadId != null) {
lines.push(`Thread: ${params.ctx.MessageThreadId}`);
}
if (senderId) {
lines.push(`AllowFrom: ${senderId}`);
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
};