mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-22 06:32:00 +00:00
perf(test): split reply command coverage
This commit is contained in:
103
src/auto-reply/reply/commands-abort-trigger.test.ts
Normal file
103
src/auto-reply/reply/commands-abort-trigger.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
57
src/auto-reply/reply/commands-bash-alias.test.ts
Normal file
57
src/auto-reply/reply/commands-bash-alias.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
src/auto-reply/reply/commands-context-command.ts
Normal file
20
src/auto-reply/reply/commands-context-command.ts
Normal 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) };
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
export { emitResetCommandHooks } from "./commands-core.js";
|
||||
export { emitResetCommandHooks } from "./commands-reset-hooks.js";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
507
src/auto-reply/reply/commands-gating.test.ts
Normal file
507
src/auto-reply/reply/commands-gating.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 [
|
||||
|
||||
114
src/auto-reply/reply/commands-info.test.ts
Normal file
114
src/auto-reply/reply/commands-info.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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") } };
|
||||
};
|
||||
|
||||
@@ -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." },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
150
src/auto-reply/reply/commands-reset-hooks.test.ts
Normal file
150
src/auto-reply/reply/commands-reset-hooks.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
159
src/auto-reply/reply/commands-reset-hooks.ts
Normal file
159
src/auto-reply/reply/commands-reset-hooks.ts
Normal 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)}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
130
src/auto-reply/reply/commands-reset.ts
Normal file
130
src/auto-reply/reply/commands-reset.ts
Normal 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;
|
||||
}
|
||||
37
src/auto-reply/reply/commands-whoami.ts
Normal file
37
src/auto-reply/reply/commands-whoami.ts
Normal 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") } };
|
||||
};
|
||||
Reference in New Issue
Block a user