Merge remote-tracking branch 'origin/main'

This commit is contained in:
Peter Steinberger
2026-02-14 13:30:22 +01:00
80 changed files with 2208 additions and 556 deletions

View File

@@ -48,6 +48,55 @@ describe("resolvePermissionRequest", () => {
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
});
it("prompts for non-read/search tools (write)", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: { toolCallId: "tool-w", title: "write: /tmp/pwn", status: "pending" },
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledWith("write", "write: /tmp/pwn");
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
});
it("auto-approves search without prompting", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: { toolCallId: "tool-s", title: "search: foo", status: "pending" },
}),
{ prompt, log: () => {} },
);
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
expect(prompt).not.toHaveBeenCalled();
});
it("prompts for fetch even when tool name is known", async () => {
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: { toolCallId: "tool-f", title: "fetch: https://example.com", status: "pending" },
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledTimes(1);
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
});
it("prompts when tool name contains read/search substrings but isn't a safe kind", async () => {
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: { toolCallId: "tool-t", title: "thread: reply", status: "pending" },
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledTimes(1);
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
});
it("uses allow_always and reject_always when once options are absent", async () => {
const options: RequestPermissionRequest["options"] = [
{ kind: "allow_always", name: "Always allow", optionId: "allow-always" },

View File

@@ -10,24 +10,9 @@ import { spawn, type ChildProcess } from "node:child_process";
import * as readline from "node:readline";
import { Readable, Writable } from "node:stream";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js";
/**
* Tools that require explicit user approval in ACP sessions.
* These tools can execute arbitrary code, modify the filesystem,
* or access sensitive resources.
*/
const DANGEROUS_ACP_TOOLS = new Set([
"exec",
"spawn",
"shell",
"sessions_spawn",
"sessions_send",
"gateway",
"fs_write",
"fs_delete",
"fs_move",
"apply_patch",
]);
const SAFE_AUTO_APPROVE_KINDS = new Set(["read", "search"]);
type PermissionOption = RequestPermissionRequest["options"][number];
@@ -77,6 +62,54 @@ function parseToolNameFromTitle(title: string | undefined | null): string | unde
return normalizeToolName(head);
}
function resolveToolKindForPermission(
params: RequestPermissionRequest,
toolName: string | undefined,
): string | undefined {
const toolCall = params.toolCall as unknown as { kind?: unknown; title?: unknown } | undefined;
const kindRaw = typeof toolCall?.kind === "string" ? toolCall.kind.trim().toLowerCase() : "";
if (kindRaw) {
return kindRaw;
}
const name =
toolName ??
parseToolNameFromTitle(typeof toolCall?.title === "string" ? toolCall.title : undefined);
if (!name) {
return undefined;
}
const normalized = name.toLowerCase();
const hasToken = (token: string) => {
// Tool names tend to be snake_case. Avoid substring heuristics (ex: "thread" contains "read").
const re = new RegExp(`(?:^|[._-])${token}(?:$|[._-])`);
return re.test(normalized);
};
// Prefer a conservative classifier: only classify safe kinds when confident.
if (normalized === "read" || hasToken("read")) {
return "read";
}
if (normalized === "search" || hasToken("search") || hasToken("find")) {
return "search";
}
if (normalized.includes("fetch") || normalized.includes("http")) {
return "fetch";
}
if (normalized.includes("write") || normalized.includes("edit") || normalized.includes("patch")) {
return "edit";
}
if (normalized.includes("delete") || normalized.includes("remove")) {
return "delete";
}
if (normalized.includes("move") || normalized.includes("rename")) {
return "move";
}
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
return "execute";
}
return "other";
}
function resolveToolNameForPermission(params: RequestPermissionRequest): string | undefined {
const toolCall = params.toolCall;
const toolMeta = asRecord(toolCall?._meta);
@@ -158,6 +191,7 @@ export async function resolvePermissionRequest(
const options = params.options ?? [];
const toolTitle = params.toolCall?.title ?? "tool";
const toolName = resolveToolNameForPermission(params);
const toolKind = resolveToolKindForPermission(params, toolName);
if (options.length === 0) {
log(`[permission cancelled] ${toolName ?? "unknown"}: no options available`);
@@ -166,7 +200,8 @@ export async function resolvePermissionRequest(
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
const promptRequired = !toolName || DANGEROUS_ACP_TOOLS.has(toolName);
const isSafeKind = Boolean(toolKind && SAFE_AUTO_APPROVE_KINDS.has(toolKind));
const promptRequired = !toolName || !isSafeKind || DANGEROUS_ACP_TOOLS.has(toolName);
if (!promptRequired) {
const option = allowOption ?? options[0];
@@ -174,11 +209,13 @@ export async function resolvePermissionRequest(
log(`[permission cancelled] ${toolName}: no selectable options`);
return cancelledPermission();
}
log(`[permission auto-approved] ${toolName}`);
log(`[permission auto-approved] ${toolName} (${toolKind ?? "unknown"})`);
return selectedPermission(option.optionId);
}
log(`\n[permission requested] ${toolTitle}${toolName ? ` (${toolName})` : ""}`);
log(
`\n[permission requested] ${toolTitle}${toolName ? ` (${toolName})` : ""}${toolKind ? ` [${toolKind}]` : ""}`,
);
const approved = await prompt(toolName, toolTitle);
if (approved && allowOption) {

View File

@@ -135,6 +135,7 @@ describe("nodes run", () => {
it("requests approval and retries with allow-once decision", async () => {
let invokeCalls = 0;
let approvalId: string | null = null;
callGateway.mockImplementation(async ({ method, params }) => {
if (method === "node.list") {
return { nodes: [{ nodeId: "mac-1", commands: ["system.run"] }] };
@@ -149,6 +150,7 @@ describe("nodes run", () => {
command: "system.run",
params: {
command: ["echo", "hi"],
runId: approvalId,
approved: true,
approvalDecision: "allow-once",
},
@@ -157,10 +159,15 @@ describe("nodes run", () => {
}
if (method === "exec.approval.request") {
expect(params).toMatchObject({
id: expect.any(String),
command: "echo hi",
host: "node",
timeoutMs: 120_000,
});
approvalId =
typeof (params as { id?: unknown } | undefined)?.id === "string"
? ((params as { id: string }).id ?? null)
: null;
return { decision: "allow-once" };
}
throw new Error(`unexpected method: ${String(method)}`);

View File

@@ -467,10 +467,12 @@ export function createNodesTool(options?: {
// the gateway and wait for the user to approve/deny via the UI.
const APPROVAL_TIMEOUT_MS = 120_000;
const cmdText = command.join(" ");
const approvalId = crypto.randomUUID();
const approvalResult = await callGatewayTool(
"exec.approval.request",
{ ...gatewayOpts, timeoutMs: APPROVAL_TIMEOUT_MS + 5_000 },
{
id: approvalId,
command: cmdText,
cwd,
host: "node",
@@ -502,6 +504,7 @@ export function createNodesTool(options?: {
command: "system.run",
params: {
...runParams,
runId: approvalId,
approved: true,
approvalDecision,
},

View File

@@ -72,4 +72,17 @@ describe("applyReplyThreading auto-threading", () => {
expect(result[0].replyToId).toBe("42");
expect(result[0].replyToTag).toBe(true);
});
it("keeps explicit tags for Telegram when off mode is enabled", () => {
const result = applyReplyThreading({
payloads: [{ text: "[[reply_to_current]]A" }],
replyToMode: "off",
replyToChannel: "telegram",
currentMessageId: "42",
});
expect(result).toHaveLength(1);
expect(result[0].replyToId).toBe("42");
expect(result[0].replyToTag).toBe(true);
});
});

View File

@@ -54,9 +54,11 @@ export function createReplyToModeFilterForChannel(
channel?: OriginatingChannelType,
) {
const provider = normalizeChannelId(channel);
// Always honour explicit [[reply_to_*]] tags even when replyToMode is "off".
// Per-channel opt-out is possible but the safe default is to allow them.
const allowTagsWhenOff = provider
? Boolean(getChannelDock(provider)?.threading?.allowTagsWhenOff)
: false;
? (getChannelDock(provider)?.threading?.allowTagsWhenOff ?? true)
: true;
return createReplyToModeFilter(mode, {
allowTagsWhenOff,
});

View File

@@ -9,11 +9,8 @@ import { slackOutbound } from "../../channels/plugins/outbound/slack.js";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createIMessageTestPlugin,
createOutboundTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
const mocks = vi.hoisted(() => ({

View File

@@ -5,6 +5,8 @@ import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { routeLogsToStderr } from "../logging/console.js";
import { pathExists } from "../utils.js";
import { getCoreCliCommandNames, registerCoreCliByName } from "./program/command-registry.js";
import { getProgramContext } from "./program/program-context.js";
import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js";
const COMPLETION_SHELLS = ["zsh", "bash", "powershell", "fish"] as const;
@@ -240,6 +242,16 @@ export function registerCompletionCli(program: Command) {
// the completion script written to stdout.
routeLogsToStderr();
const shell = options.shell ?? "zsh";
// Completion needs the full Commander command tree (including nested subcommands).
// Our CLI defaults to lazy registration for perf; force-register core commands here.
const ctx = getProgramContext(program);
if (ctx) {
for (const name of getCoreCliCommandNames()) {
await registerCoreCliByName(program, ctx, name);
}
}
// Eagerly register all subcommands to build the full tree
const entries = getSubCliEntries();
for (const entry of entries) {

View File

@@ -16,7 +16,7 @@ const report: HookStatusReport = {
handlerPath: "/tmp/hooks/session-memory/handler.js",
hookKey: "session-memory",
emoji: "💾",
homepage: "https://docs.openclaw.ai/hooks#session-memory",
homepage: "https://docs.openclaw.ai/automation/hooks#session-memory",
events: ["command:new"],
always: false,
disabled: false,

View File

@@ -128,6 +128,7 @@ describe("nodes-cli coverage", () => {
agentId: "main",
approved: true,
approvalDecision: "allow-once",
runId: expect.any(String),
});
expect(invoke?.params?.timeoutMs).toBe(5000);
});
@@ -153,6 +154,7 @@ describe("nodes-cli coverage", () => {
agentId: "main",
approved: true,
approvalDecision: "allow-once",
runId: expect.any(String),
});
});

View File

@@ -269,8 +269,11 @@ export function registerNodesInvokeCommands(nodes: Command) {
}
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss";
let approvalId: string | null = null;
if (requiresAsk) {
approvalId = crypto.randomUUID();
const decisionResult = (await callGatewayCli("exec.approval.request", opts, {
id: approvalId,
command: rawCommand ?? argv.join(" "),
cwd: opts.cwd,
host: "node",
@@ -330,6 +333,9 @@ export function registerNodesInvokeCommands(nodes: Command) {
if (approvalDecision) {
(invokeParams.params as Record<string, unknown>).approvalDecision = approvalDecision;
}
if (approvedByAsk && approvalId) {
(invokeParams.params as Record<string, unknown>).runId = approvalId;
}
if (invokeTimeout !== undefined) {
invokeParams.timeoutMs = invokeTimeout;
}

View File

@@ -3,12 +3,14 @@ import { registerProgramCommands } from "./command-registry.js";
import { createProgramContext } from "./context.js";
import { configureProgramHelp } from "./help.js";
import { registerPreActionHooks } from "./preaction.js";
import { setProgramContext } from "./program-context.js";
export function buildProgram() {
const program = new Command();
const ctx = createProgramContext();
const argv = process.argv;
setProgramContext(program, ctx);
configureProgramHelp(program, ctx);
registerPreActionHooks(program, ctx.programVersion);

View File

@@ -1,15 +1,7 @@
import type { Command } from "commander";
import type { ProgramContext } from "./context.js";
import { registerBrowserCli } from "../browser-cli.js";
import { registerConfigCli } from "../config-cli.js";
import { registerMemoryCli } from "../memory-cli.js";
import { registerAgentCommands } from "./register.agent.js";
import { registerConfigureCommand } from "./register.configure.js";
import { registerMaintenanceCommands } from "./register.maintenance.js";
import { registerMessageCommands } from "./register.message.js";
import { registerOnboardCommand } from "./register.onboard.js";
import { registerSetupCommand } from "./register.setup.js";
import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js";
import { buildParseArgv, getPrimaryCommand, hasHelpOrVersion } from "../argv.js";
import { resolveActionArgs } from "./helpers.js";
import { registerSubCliCommands } from "./register.subclis.js";
type CommandRegisterParams = {
@@ -23,60 +15,198 @@ export type CommandRegistration = {
register: (params: CommandRegisterParams) => void;
};
export const commandRegistry: CommandRegistration[] = [
type CoreCliEntry = {
commands: Array<{ name: string; description: string }>;
register: (params: CommandRegisterParams) => Promise<void> | void;
};
const shouldRegisterCorePrimaryOnly = (argv: string[]) => {
if (hasHelpOrVersion(argv)) {
return false;
}
return true;
};
const coreEntries: CoreCliEntry[] = [
{
id: "setup",
register: ({ program }) => registerSetupCommand(program),
commands: [{ name: "setup", description: "Setup helpers" }],
register: async ({ program }) => {
const mod = await import("./register.setup.js");
mod.registerSetupCommand(program);
},
},
{
id: "onboard",
register: ({ program }) => registerOnboardCommand(program),
commands: [{ name: "onboard", description: "Onboarding helpers" }],
register: async ({ program }) => {
const mod = await import("./register.onboard.js");
mod.registerOnboardCommand(program);
},
},
{
id: "configure",
register: ({ program }) => registerConfigureCommand(program),
commands: [{ name: "configure", description: "Configure wizard" }],
register: async ({ program }) => {
const mod = await import("./register.configure.js");
mod.registerConfigureCommand(program);
},
},
{
id: "config",
register: ({ program }) => registerConfigCli(program),
commands: [{ name: "config", description: "Config helpers" }],
register: async ({ program }) => {
const mod = await import("../config-cli.js");
mod.registerConfigCli(program);
},
},
{
id: "maintenance",
register: ({ program }) => registerMaintenanceCommands(program),
commands: [{ name: "maintenance", description: "Maintenance commands" }],
register: async ({ program }) => {
const mod = await import("./register.maintenance.js");
mod.registerMaintenanceCommands(program);
},
},
{
id: "message",
register: ({ program, ctx }) => registerMessageCommands(program, ctx),
commands: [{ name: "message", description: "Send, read, and manage messages" }],
register: async ({ program, ctx }) => {
const mod = await import("./register.message.js");
mod.registerMessageCommands(program, ctx);
},
},
{
id: "memory",
register: ({ program }) => registerMemoryCli(program),
commands: [{ name: "memory", description: "Memory commands" }],
register: async ({ program }) => {
const mod = await import("../memory-cli.js");
mod.registerMemoryCli(program);
},
},
{
id: "agent",
register: ({ program, ctx }) =>
registerAgentCommands(program, { agentChannelOptions: ctx.agentChannelOptions }),
commands: [{ name: "agent", description: "Agent commands" }],
register: async ({ program, ctx }) => {
const mod = await import("./register.agent.js");
mod.registerAgentCommands(program, { agentChannelOptions: ctx.agentChannelOptions });
},
},
{
id: "subclis",
register: ({ program, argv }) => registerSubCliCommands(program, argv),
commands: [
{ name: "status", description: "Gateway status" },
{ name: "health", description: "Gateway health" },
{ name: "sessions", description: "Session management" },
],
register: async ({ program }) => {
const mod = await import("./register.status-health-sessions.js");
mod.registerStatusHealthSessionsCommands(program);
},
},
{
id: "status-health-sessions",
register: ({ program }) => registerStatusHealthSessionsCommands(program),
},
{
id: "browser",
register: ({ program }) => registerBrowserCli(program),
commands: [{ name: "browser", description: "Browser tools" }],
register: async ({ program }) => {
const mod = await import("../browser-cli.js");
mod.registerBrowserCli(program);
},
},
];
export function getCoreCliCommandNames(): string[] {
const seen = new Set<string>();
const names: string[] = [];
for (const entry of coreEntries) {
for (const cmd of entry.commands) {
if (seen.has(cmd.name)) {
continue;
}
seen.add(cmd.name);
names.push(cmd.name);
}
}
return names;
}
function removeCommand(program: Command, command: Command) {
const commands = program.commands as Command[];
const index = commands.indexOf(command);
if (index >= 0) {
commands.splice(index, 1);
}
}
function registerLazyCoreCommand(
program: Command,
ctx: ProgramContext,
entry: CoreCliEntry,
command: { name: string; description: string },
) {
const placeholder = program.command(command.name).description(command.description);
placeholder.allowUnknownOption(true);
placeholder.allowExcessArguments(true);
placeholder.action(async (...actionArgs) => {
removeCommand(program, placeholder);
await entry.register({ program, ctx, argv: process.argv });
const actionCommand = actionArgs.at(-1) as Command | undefined;
const root = actionCommand?.parent ?? program;
const rawArgs = (root as Command & { rawArgs?: string[] }).rawArgs;
const actionArgsList = resolveActionArgs(actionCommand);
const fallbackArgv = actionCommand?.name()
? [actionCommand.name(), ...actionArgsList]
: actionArgsList;
const parseArgv = buildParseArgv({
programName: program.name(),
rawArgs,
fallbackArgv,
});
await program.parseAsync(parseArgv);
});
}
export async function registerCoreCliByName(
program: Command,
ctx: ProgramContext,
name: string,
argv: string[] = process.argv,
): Promise<boolean> {
const entry = coreEntries.find((candidate) =>
candidate.commands.some((cmd) => cmd.name === name),
);
if (!entry) {
return false;
}
// Some registrars install multiple top-level commands (e.g. status/health/sessions).
// Remove placeholders/old registrations for all names in the entry before re-registering.
for (const cmd of entry.commands) {
const existing = program.commands.find((c) => c.name() === cmd.name);
if (existing) {
removeCommand(program, existing);
}
}
await entry.register({ program, ctx, argv });
return true;
}
export function registerCoreCliCommands(program: Command, ctx: ProgramContext, argv: string[]) {
const primary = getPrimaryCommand(argv);
if (primary && shouldRegisterCorePrimaryOnly(argv)) {
const entry = coreEntries.find((candidate) =>
candidate.commands.some((cmd) => cmd.name === primary),
);
if (entry) {
const cmd = entry.commands.find((c) => c.name === primary);
if (cmd) {
registerLazyCoreCommand(program, ctx, entry, cmd);
}
return;
}
}
for (const entry of coreEntries) {
for (const cmd of entry.commands) {
registerLazyCoreCommand(program, ctx, entry, cmd);
}
}
}
export function registerProgramCommands(
program: Command,
ctx: ProgramContext,
argv: string[] = process.argv,
) {
for (const entry of commandRegistry) {
entry.register({ program, ctx, argv });
}
registerCoreCliCommands(program, ctx, argv);
registerSubCliCommands(program, argv);
}

View File

@@ -20,11 +20,22 @@ const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
"restart",
]);
let didRunDoctorConfigFlow = false;
let configSnapshotPromise: Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> | null =
null;
function formatConfigIssues(issues: Array<{ path: string; message: string }>): string[] {
return issues.map((issue) => `- ${issue.path || "<root>"}: ${issue.message}`);
}
async function getConfigSnapshot() {
// Tests often mutate config fixtures; caching can make those flaky.
if (process.env.VITEST === "true") {
return readConfigFileSnapshot();
}
configSnapshotPromise ??= readConfigFileSnapshot();
return configSnapshotPromise;
}
export async function ensureConfigReady(params: {
runtime: RuntimeEnv;
commandPath?: string[];
@@ -38,7 +49,7 @@ export async function ensureConfigReady(params: {
});
}
const snapshot = await readConfigFileSnapshot();
const snapshot = await getConfigSnapshot();
const commandName = commandPath[0];
const subcommandName = commandPath[1];
const allowInvalid = commandName

View File

@@ -5,8 +5,6 @@ import { defaultRuntime } from "../../runtime.js";
import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
import { emitCliBanner } from "../banner.js";
import { resolveCliName } from "../cli-name.js";
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
import { ensureConfigReady } from "./config-guard.js";
function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand;
@@ -48,9 +46,11 @@ export function registerPreActionHooks(program: Command, programVersion: string)
if (commandPath[0] === "doctor" || commandPath[0] === "completion") {
return;
}
const { ensureConfigReady } = await import("./config-guard.js");
await ensureConfigReady({ runtime: defaultRuntime, commandPath });
// Load plugins for commands that need channel access
if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
const { ensurePluginRegistryLoaded } = await import("../plugin-registry.js");
ensurePluginRegistryLoaded();
}
});

View File

@@ -0,0 +1,15 @@
import type { Command } from "commander";
import type { ProgramContext } from "./context.js";
const PROGRAM_CONTEXT_SYMBOL: unique symbol = Symbol.for("openclaw.cli.programContext");
export function setProgramContext(program: Command, ctx: ProgramContext): void {
(program as Command & { [PROGRAM_CONTEXT_SYMBOL]?: ProgramContext })[PROGRAM_CONTEXT_SYMBOL] =
ctx;
}
export function getProgramContext(program: Command): ProgramContext | undefined {
return (program as Command & { [PROGRAM_CONTEXT_SYMBOL]?: ProgramContext })[
PROGRAM_CONTEXT_SYMBOL
];
}

View File

@@ -93,9 +93,16 @@ export async function runCli(argv: string[] = process.argv) {
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
// Register the primary subcommand if one exists (for lazy-loading)
// Register the primary command (builtin or subcli) so help and command parsing
// are correct even with lazy command registration.
const primary = getPrimaryCommand(parseArgv);
if (primary && shouldRegisterPrimarySubcommand(parseArgv)) {
if (primary) {
const { getProgramContext } = await import("./program/program-context.js");
const ctx = getProgramContext(program);
if (ctx) {
const { registerCoreCliByName } = await import("./program/command-registry.js");
await registerCoreCliByName(program, ctx, primary, parseArgv);
}
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}

View File

@@ -54,6 +54,7 @@ describe("buildAuthChoiceOptions", () => {
});
expect(options.some((opt) => opt.value === "minimax-api")).toBe(true);
expect(options.some((opt) => opt.value === "minimax-api-key-cn")).toBe(true);
expect(options.some((opt) => opt.value === "minimax-api-lightning")).toBe(true);
});

View File

@@ -50,7 +50,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
value: "minimax",
label: "MiniMax",
hint: "M2.5 (recommended)",
choices: ["minimax-portal", "minimax-api", "minimax-api-lightning"],
choices: ["minimax-portal", "minimax-api", "minimax-api-key-cn", "minimax-api-lightning"],
},
{
value: "moonshot",
@@ -286,6 +286,11 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
hint: "Claude, GPT, Gemini via opencode.ai/zen",
},
{ value: "minimax-api", label: "MiniMax M2.5" },
{
value: "minimax-api-key-cn",
label: "MiniMax M2.5 (CN)",
hint: "China endpoint (api.minimaxi.com)",
},
{
value: "minimax-api-lightning",
label: "MiniMax M2.5 Lightning",

View File

@@ -10,7 +10,9 @@ import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import {
applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxApiConfigCn,
applyMinimaxApiProviderConfig,
applyMinimaxApiProviderConfigCn,
applyMinimaxConfig,
applyMinimaxProviderConfig,
setMinimaxApiKey,
@@ -97,6 +99,49 @@ export async function applyAuthChoiceMiniMax(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "minimax-api-key-cn") {
const modelId = "MiniMax-M2.5";
let hasCredential = false;
const envKey = resolveEnvApiKey("minimax");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing MINIMAX_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await setMinimaxApiKey(envKey.apiKey, params.agentDir);
hasCredential = true;
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter MiniMax China API key",
validate: validateApiKeyInput,
});
await setMinimaxApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
{
const modelRef = `minimax/${modelId}`;
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: modelRef,
applyDefaultConfig: applyMinimaxApiConfigCn,
applyProviderConfig: applyMinimaxApiProviderConfigCn,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "minimax") {
const applied = await applyDefaultModelChoice({
config: nextConfig,

View File

@@ -6,7 +6,11 @@ import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { AuthChoice } from "./onboard-types.js";
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL } from "./onboard-auth.js";
import {
MINIMAX_CN_API_BASE_URL,
ZAI_CODING_CN_BASE_URL,
ZAI_CODING_GLOBAL_BASE_URL,
} from "./onboard-auth.js";
vi.mock("../providers/github-copilot-auth.js", () => ({
githubCopilotLoginCommand: vi.fn(async () => {}),
@@ -209,6 +213,60 @@ describe("applyAuthChoice", () => {
expect(parsed.profiles?.["minimax:default"]?.key).toBe("sk-minimax-test");
});
it("prompts and writes MiniMax API key when selecting minimax-api-key-cn", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
const text = vi.fn().mockResolvedValue("sk-minimax-test");
const select: WizardPrompter["select"] = vi.fn(
async (params) => params.options[0]?.value as never,
);
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select,
multiselect,
text,
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
const result = await applyAuthChoice({
authChoice: "minimax-api-key-cn",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(text).toHaveBeenCalledWith(
expect.objectContaining({ message: "Enter MiniMax China API key" }),
);
expect(result.config.auth?.profiles?.["minimax:default"]).toMatchObject({
provider: "minimax",
mode: "api_key",
});
expect(result.config.models?.providers?.minimax?.baseUrl).toBe(MINIMAX_CN_API_BASE_URL);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
};
expect(parsed.profiles?.["minimax:default"]?.key).toBe("sk-minimax-test");
});
it("prompts and writes Synthetic API key when selecting synthetic-api-key", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;

View File

@@ -34,6 +34,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"copilot-proxy": "copilot-proxy",
"minimax-cloud": "minimax",
"minimax-api": "minimax",
"minimax-api-key-cn": "minimax",
"minimax-api-lightning": "minimax",
minimax: "lmstudio",
"opencode-zen": "opencode",

View File

@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { signalPlugin } from "../../extensions/signal/src/channel.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createIMessageTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
const configMocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_MINIMAX_CONTEXT_WINDOW,
DEFAULT_MINIMAX_MAX_TOKENS,
MINIMAX_API_BASE_URL,
MINIMAX_CN_API_BASE_URL,
MINIMAX_HOSTED_COST,
MINIMAX_HOSTED_MODEL_ID,
MINIMAX_HOSTED_MODEL_REF,
@@ -148,7 +149,37 @@ export function applyMinimaxHostedConfig(
// MiniMax Anthropic-compatible API (platform.minimax.io/anthropic)
export function applyMinimaxApiProviderConfig(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.1",
modelId: string = "MiniMax-M2.5",
): OpenClawConfig {
return applyMinimaxApiProviderConfigWithBaseUrl(cfg, modelId, MINIMAX_API_BASE_URL);
}
export function applyMinimaxApiConfig(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
): OpenClawConfig {
return applyMinimaxApiConfigWithBaseUrl(cfg, modelId, MINIMAX_API_BASE_URL);
}
// MiniMax China API (api.minimaxi.com)
export function applyMinimaxApiProviderConfigCn(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
): OpenClawConfig {
return applyMinimaxApiProviderConfigWithBaseUrl(cfg, modelId, MINIMAX_CN_API_BASE_URL);
}
export function applyMinimaxApiConfigCn(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
): OpenClawConfig {
return applyMinimaxApiConfigWithBaseUrl(cfg, modelId, MINIMAX_CN_API_BASE_URL);
}
function applyMinimaxApiProviderConfigWithBaseUrl(
cfg: OpenClawConfig,
modelId: string,
baseUrl: string,
): OpenClawConfig {
const providers = { ...cfg.models?.providers };
const existingProvider = providers.minimax;
@@ -164,7 +195,7 @@ export function applyMinimaxApiProviderConfig(
const normalizedApiKey = resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey;
providers.minimax = {
...existingProviderRest,
baseUrl: MINIMAX_API_BASE_URL,
baseUrl,
api: "anthropic-messages",
...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [apiModel],
@@ -189,11 +220,12 @@ export function applyMinimaxApiProviderConfig(
};
}
export function applyMinimaxApiConfig(
function applyMinimaxApiConfigWithBaseUrl(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.1",
modelId: string,
baseUrl: string,
): OpenClawConfig {
const next = applyMinimaxApiProviderConfig(cfg, modelId);
const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, modelId, baseUrl);
return {
...next,
agents: {

View File

@@ -3,6 +3,7 @@ import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID } from "../agents/models-con
export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic";
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1";
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;

View File

@@ -38,7 +38,9 @@ export {
} from "./onboard-auth.config-core.js";
export {
applyMinimaxApiConfig,
applyMinimaxApiConfigCn,
applyMinimaxApiProviderConfig,
applyMinimaxApiProviderConfigCn,
applyMinimaxConfig,
applyMinimaxHostedConfig,
applyMinimaxHostedProviderConfig,
@@ -92,6 +94,7 @@ export {
KIMI_CODING_MODEL_ID,
KIMI_CODING_MODEL_REF,
MINIMAX_API_BASE_URL,
MINIMAX_CN_API_BASE_URL,
MINIMAX_HOSTED_MODEL_ID,
MINIMAX_HOSTED_MODEL_REF,
MOONSHOT_BASE_URL,

View File

@@ -15,7 +15,7 @@ export async function setupInternalHooks(
"Hooks let you automate actions when agent commands are issued.",
"Example: Save session context to memory when you issue /new.",
"",
"Learn more: https://docs.openclaw.ai/hooks",
"Learn more: https://docs.openclaw.ai/automation/hooks",
].join("\n"),
"Hooks",
);

View File

@@ -155,6 +155,72 @@ async function expectApiKeyProfile(params: {
}
describe("onboard (non-interactive): provider auth", () => {
it("stores MiniMax API key and uses global baseUrl by default", async () => {
await withOnboardEnv("openclaw-onboard-minimax-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "minimax-api",
minimaxApiKey: "sk-minimax-test",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
agents?: { defaults?: { model?: { primary?: string } } };
models?: { providers?: Record<string, { baseUrl?: string }> };
}>(configPath);
expect(cfg.auth?.profiles?.["minimax:default"]?.provider).toBe("minimax");
expect(cfg.auth?.profiles?.["minimax:default"]?.mode).toBe("api_key");
expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5");
await expectApiKeyProfile({
profileId: "minimax:default",
provider: "minimax",
key: "sk-minimax-test",
});
});
}, 60_000);
it("supports MiniMax CN API endpoint auth choice", async () => {
await withOnboardEnv("openclaw-onboard-minimax-cn-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "minimax-api-key-cn",
minimaxApiKey: "sk-minimax-test",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
agents?: { defaults?: { model?: { primary?: string } } };
models?: { providers?: Record<string, { baseUrl?: string }> };
}>(configPath);
expect(cfg.auth?.profiles?.["minimax:default"]?.provider).toBe("minimax");
expect(cfg.auth?.profiles?.["minimax:default"]?.mode).toBe("api_key");
expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimaxi.com/anthropic");
expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5");
await expectApiKeyProfile({
profileId: "minimax:default",
provider: "minimax",
key: "sk-minimax-test",
});
});
}, 60_000);
it("stores Z.AI API key and uses global baseUrl by default", async () => {
await withOnboardEnv("openclaw-onboard-zai-", async ({ configPath, runtime }) => {
await runNonInteractive(

View File

@@ -15,6 +15,7 @@ import {
applyQianfanConfig,
applyKimiCodeConfig,
applyMinimaxApiConfig,
applyMinimaxApiConfigCn,
applyMinimaxConfig,
applyMoonshotConfig,
applyMoonshotConfigCn,
@@ -570,6 +571,7 @@ export async function applyNonInteractiveAuthChoice(params: {
if (
authChoice === "minimax-cloud" ||
authChoice === "minimax-api" ||
authChoice === "minimax-api-key-cn" ||
authChoice === "minimax-api-lightning"
) {
const resolved = await resolveNonInteractiveApiKey({
@@ -592,8 +594,10 @@ export async function applyNonInteractiveAuthChoice(params: {
mode: "api_key",
});
const modelId =
authChoice === "minimax-api-lightning" ? "MiniMax-M2.1-lightning" : "MiniMax-M2.1";
return applyMinimaxApiConfig(nextConfig, modelId);
authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-Lightning" : "MiniMax-M2.5";
return authChoice === "minimax-api-key-cn"
? applyMinimaxApiConfigCn(nextConfig, modelId)
: applyMinimaxApiConfig(nextConfig, modelId);
}
if (authChoice === "minimax") {

View File

@@ -37,6 +37,7 @@ export type AuthChoice =
| "minimax-cloud"
| "minimax"
| "minimax-api"
| "minimax-api-key-cn"
| "minimax-api-lightning"
| "minimax-portal"
| "opencode-zen"

View File

@@ -20,6 +20,10 @@ export type ExecApprovalRecord = {
request: ExecApprovalRequestPayload;
createdAtMs: number;
expiresAtMs: number;
// Caller metadata (best-effort). Used to prevent other clients from replaying an approval id.
requestedByConnId?: string | null;
requestedByDeviceId?: string | null;
requestedByClientId?: string | null;
resolvedAtMs?: number;
decision?: ExecApprovalDecision;
resolvedBy?: string | null;

View File

@@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createIMessageTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
import {
extractHookToken,
isHookAgentAllowed,

View File

@@ -0,0 +1,21 @@
import type { ExecApprovalManager } from "./exec-approval-manager.js";
import type { GatewayClient } from "./server-methods/types.js";
import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js";
export function sanitizeNodeInvokeParamsForForwarding(opts: {
command: string;
rawParams: unknown;
client: GatewayClient | null;
execApprovalManager?: ExecApprovalManager;
}):
| { ok: true; params: unknown }
| { ok: false; message: string; details?: Record<string, unknown> } {
if (opts.command === "system.run") {
return sanitizeSystemRunParamsForForwarding({
rawParams: opts.rawParams,
client: opts.client,
execApprovalManager: opts.execApprovalManager,
});
}
return { ok: true, params: opts.rawParams };
}

View File

@@ -0,0 +1,237 @@
import type { ExecApprovalManager, ExecApprovalRecord } from "./exec-approval-manager.js";
import type { GatewayClient } from "./server-methods/types.js";
type SystemRunParamsLike = {
command?: unknown;
rawCommand?: unknown;
cwd?: unknown;
env?: unknown;
timeoutMs?: unknown;
needsScreenRecording?: unknown;
agentId?: unknown;
sessionKey?: unknown;
approved?: unknown;
approvalDecision?: unknown;
runId?: unknown;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function normalizeApprovalDecision(value: unknown): "allow-once" | "allow-always" | null {
const s = normalizeString(value);
return s === "allow-once" || s === "allow-always" ? s : null;
}
function clientHasApprovals(client: GatewayClient | null): boolean {
const scopes = Array.isArray(client?.connect?.scopes) ? client?.connect?.scopes : [];
return scopes.includes("operator.admin") || scopes.includes("operator.approvals");
}
function getCmdText(params: SystemRunParamsLike): string {
const raw = normalizeString(params.rawCommand);
if (raw) {
return raw;
}
if (Array.isArray(params.command)) {
const parts = params.command.map((v) => String(v));
if (parts.length > 0) {
return parts.join(" ");
}
}
return "";
}
function approvalMatchesRequest(params: SystemRunParamsLike, record: ExecApprovalRecord): boolean {
if (record.request.host !== "node") {
return false;
}
const cmdText = getCmdText(params);
if (!cmdText || record.request.command !== cmdText) {
return false;
}
const reqCwd = record.request.cwd ?? null;
const runCwd = normalizeString(params.cwd) ?? null;
if (reqCwd !== runCwd) {
return false;
}
const reqAgentId = record.request.agentId ?? null;
const runAgentId = normalizeString(params.agentId) ?? null;
if (reqAgentId !== runAgentId) {
return false;
}
const reqSessionKey = record.request.sessionKey ?? null;
const runSessionKey = normalizeString(params.sessionKey) ?? null;
if (reqSessionKey !== runSessionKey) {
return false;
}
return true;
}
function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unknown> {
// Defensive allowlist: only forward fields that the node-host `system.run` handler understands.
// This prevents future internal control fields from being smuggled through the gateway.
const next: Record<string, unknown> = {};
for (const key of [
"command",
"rawCommand",
"cwd",
"env",
"timeoutMs",
"needsScreenRecording",
"agentId",
"sessionKey",
"runId",
]) {
if (key in raw) {
next[key] = raw[key];
}
}
return next;
}
/**
* Gate `system.run` approval flags (`approved`, `approvalDecision`) behind a real
* `exec.approval.*` record. This prevents users with only `operator.write` from
* bypassing node-host approvals by injecting control fields into `node.invoke`.
*/
export function sanitizeSystemRunParamsForForwarding(opts: {
rawParams: unknown;
client: GatewayClient | null;
execApprovalManager?: ExecApprovalManager;
nowMs?: number;
}):
| { ok: true; params: unknown }
| { ok: false; message: string; details?: Record<string, unknown> } {
const obj = asRecord(opts.rawParams);
if (!obj) {
return { ok: true, params: opts.rawParams };
}
const p = obj as SystemRunParamsLike;
const approved = p.approved === true;
const requestedDecision = normalizeApprovalDecision(p.approvalDecision);
const wantsApprovalOverride = approved || requestedDecision !== null;
// Always strip control fields from user input. If the override is allowed,
// we re-add trusted fields based on the gateway approval record.
const next: Record<string, unknown> = pickSystemRunParams(obj);
if (!wantsApprovalOverride) {
return { ok: true, params: next };
}
const runId = normalizeString(p.runId);
if (!runId) {
return {
ok: false,
message: "approval override requires params.runId",
details: { code: "MISSING_RUN_ID" },
};
}
const manager = opts.execApprovalManager;
if (!manager) {
return {
ok: false,
message: "exec approvals unavailable",
details: { code: "APPROVALS_UNAVAILABLE" },
};
}
const snapshot = manager.getSnapshot(runId);
if (!snapshot) {
return {
ok: false,
message: "unknown or expired approval id",
details: { code: "UNKNOWN_APPROVAL_ID", runId },
};
}
const nowMs = typeof opts.nowMs === "number" ? opts.nowMs : Date.now();
if (nowMs > snapshot.expiresAtMs) {
return {
ok: false,
message: "approval expired",
details: { code: "APPROVAL_EXPIRED", runId },
};
}
// Prefer binding by device identity (stable across reconnects / per-call clients like callGateway()).
// Fallback to connId only when device identity is not available.
const snapshotDeviceId = snapshot.requestedByDeviceId ?? null;
const clientDeviceId = opts.client?.connect?.device?.id ?? null;
if (snapshotDeviceId) {
if (snapshotDeviceId !== clientDeviceId) {
return {
ok: false,
message: "approval id not valid for this device",
details: { code: "APPROVAL_DEVICE_MISMATCH", runId },
};
}
} else if (
snapshot.requestedByConnId &&
snapshot.requestedByConnId !== (opts.client?.connId ?? null)
) {
return {
ok: false,
message: "approval id not valid for this client",
details: { code: "APPROVAL_CLIENT_MISMATCH", runId },
};
}
if (!approvalMatchesRequest(p, snapshot)) {
return {
ok: false,
message: "approval id does not match request",
details: { code: "APPROVAL_REQUEST_MISMATCH", runId },
};
}
// Normal path: enforce the decision recorded by the gateway.
if (snapshot.decision === "allow-once" || snapshot.decision === "allow-always") {
next.approved = true;
next.approvalDecision = snapshot.decision;
return { ok: true, params: next };
}
// If the approval request timed out (decision=null), allow askFallback-driven
// "allow-once" ONLY for clients that are allowed to use exec approvals.
const timedOut =
snapshot.resolvedAtMs !== undefined &&
snapshot.decision === undefined &&
snapshot.resolvedBy === null;
if (
timedOut &&
approved &&
requestedDecision === "allow-once" &&
clientHasApprovals(opts.client)
) {
next.approved = true;
next.approvalDecision = "allow-once";
return { ok: true, params: next };
}
return {
ok: false,
message: "approval required",
details: { code: "APPROVAL_REQUIRED", runId },
};
}

View File

@@ -1,6 +1,6 @@
import type { GatewayWsClient } from "./server/ws-types.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import { logWs, summarizeAgentEventForWsLog } from "./ws-log.js";
import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js";
const ADMIN_SCOPE = "operator.admin";
const APPROVALS_SCOPE = "operator.approvals";
@@ -43,6 +43,9 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
},
targetConnIds?: ReadonlySet<string>,
) => {
if (params.clients.size === 0) {
return;
}
const isTargeted = Boolean(targetConnIds);
const eventSeq = isTargeted ? undefined : ++seq;
const frame = JSON.stringify({
@@ -52,19 +55,21 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
seq: eventSeq,
stateVersion: opts?.stateVersion,
});
const logMeta: Record<string, unknown> = {
event,
seq: eventSeq ?? "targeted",
clients: params.clients.size,
targets: targetConnIds ? targetConnIds.size : undefined,
dropIfSlow: opts?.dropIfSlow,
presenceVersion: opts?.stateVersion?.presence,
healthVersion: opts?.stateVersion?.health,
};
if (event === "agent") {
Object.assign(logMeta, summarizeAgentEventForWsLog(payload));
if (shouldLogWs()) {
const logMeta: Record<string, unknown> = {
event,
seq: eventSeq ?? "targeted",
clients: params.clients.size,
targets: targetConnIds ? targetConnIds.size : undefined,
dropIfSlow: opts?.dropIfSlow,
presenceVersion: opts?.stateVersion?.presence,
healthVersion: opts?.stateVersion?.health,
};
if (event === "agent") {
Object.assign(logMeta, summarizeAgentEventForWsLog(payload));
}
logWs("out", "event", logMeta);
}
logWs("out", "event", logMeta);
for (const c of params.clients) {
if (targetConnIds && !targetConnIds.has(c.connId)) {
continue;

View File

@@ -15,7 +15,7 @@ export function createExecApprovalHandlers(
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
return {
"exec.approval.request": async ({ params, respond, context }) => {
"exec.approval.request": async ({ params, respond, context, client }) => {
if (!validateExecApprovalRequestParams(params)) {
respond(
false,
@@ -64,6 +64,9 @@ export function createExecApprovalHandlers(
sessionKey: p.sessionKey ?? null,
};
const record = manager.create(request, timeoutMs, explicitId);
record.requestedByConnId = client?.connId ?? null;
record.requestedByDeviceId = client?.connect?.device?.id ?? null;
record.requestedByClientId = client?.connect?.client?.id ?? null;
// Use register() to synchronously add to pending map before sending any response.
// This ensures the approval ID is valid immediately after the "accepted" response.
let decisionPromise: Promise<

View File

@@ -10,6 +10,7 @@ import {
verifyNodeToken,
} from "../../infra/node-pairing.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js";
import {
ErrorCodes,
errorShape,
@@ -361,7 +362,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
);
});
},
"node.invoke": async ({ params, respond, context }) => {
"node.invoke": async ({ params, respond, context, client }) => {
if (!validateNodeInvokeParams(params)) {
respondInvalidParams({
respond,
@@ -417,10 +418,26 @@ export const nodeHandlers: GatewayRequestHandlers = {
);
return;
}
const forwardedParams = sanitizeNodeInvokeParamsForForwarding({
command,
rawParams: p.params,
client,
execApprovalManager: context.execApprovalManager,
});
if (!forwardedParams.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, forwardedParams.message, {
details: forwardedParams.details ?? null,
}),
);
return;
}
const res = await context.nodeRegistry.invoke({
nodeId,
command,
params: p.params,
params: forwardedParams.params,
timeoutMs: p.timeoutMs,
idempotencyKey: p.idempotencyKey,
});

View File

@@ -5,6 +5,7 @@ import type { CronService } from "../../cron/service.js";
import type { createSubsystemLogger } from "../../logging/subsystem.js";
import type { WizardSession } from "../../wizard/session.js";
import type { ChatAbortControllerEntry } from "../chat-abort.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import type { NodeRegistry } from "../node-registry.js";
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
@@ -28,6 +29,7 @@ export type GatewayRequestContext = {
deps: ReturnType<typeof createDefaultDeps>;
cron: CronService;
cronStorePath: string;
execApprovalManager?: ExecApprovalManager;
loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
getHealthCache: () => HealthSummary | null;
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;

View File

@@ -565,6 +565,7 @@ export async function startGatewayServer(
deps,
cron,
cronStorePath,
execApprovalManager,
loadGatewayModelCatalog,
getHealthCache,
refreshHealthSnapshot: refreshGatewayHealthSnapshot,

View File

@@ -0,0 +1,281 @@
import crypto from "node:crypto";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
deriveDeviceIdFromPublicKey,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { sleep } from "../utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import {
connectReq,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
describe("node.invoke approval bypass", () => {
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let port: number;
beforeAll(async () => {
const started = await startServerWithClient("secret", { controlUiEnabled: true });
server = started.server;
port = started.port;
});
afterAll(async () => {
await server.close();
});
const connectOperator = async (scopes: string[]) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, { token: "secret", scopes });
expect(res.ok).toBe(true);
return ws;
};
const connectOperatorWithNewDevice = async (scopes: string[]) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
expect(deviceId).toBeTruthy();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: deviceId!,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes,
signedAtMs,
token: "secret",
});
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, {
token: "secret",
scopes,
device: {
id: deviceId!,
publicKey: publicKeyRaw,
signature: signDevicePayload(privateKeyPem, payload),
signedAt: signedAtMs,
},
});
expect(res.ok).toBe(true);
return ws;
};
const connectLinuxNode = async (onInvoke: (payload: unknown) => void) => {
let readyResolve: (() => void) | null = null;
const ready = new Promise<void>((resolve) => {
readyResolve = resolve;
});
const client = new GatewayClient({
url: `ws://127.0.0.1:${port}`,
connectDelayMs: 0,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientVersion: "1.0.0",
platform: "linux",
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: ["system.run"],
onHelloOk: () => readyResolve?.(),
onEvent: (evt) => {
if (evt.event !== "node.invoke.request") {
return;
}
onInvoke(evt.payload);
const payload = evt.payload as {
id?: string;
nodeId?: string;
};
const id = typeof payload?.id === "string" ? payload.id : "";
const nodeId = typeof payload?.nodeId === "string" ? payload.nodeId : "";
if (!id || !nodeId) {
return;
}
void client.request("node.invoke.result", {
id,
nodeId,
ok: true,
payloadJSON: JSON.stringify({ ok: true }),
});
},
});
client.start();
await Promise.race([
ready,
sleep(10_000).then(() => {
throw new Error("timeout waiting for node to connect");
}),
]);
return client;
};
test("rejects injecting approved/approvalDecision without approval id", async () => {
let sawInvoke = false;
const node = await connectLinuxNode(() => {
sawInvoke = true;
});
const ws = await connectOperator(["operator.write"]);
const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>(
ws,
"node.list",
{},
);
expect(nodes.ok).toBe(true);
const nodeId = nodes.payload?.nodes?.find((n) => n.connected)?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const res = await rpcReq(ws, "node.invoke", {
nodeId,
command: "system.run",
params: {
command: ["echo", "hi"],
rawCommand: "echo hi",
approved: true,
approvalDecision: "allow-once",
},
idempotencyKey: crypto.randomUUID(),
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("params.runId");
// Ensure the node didn't receive the invoke (gateway should fail early).
await sleep(50);
expect(sawInvoke).toBe(false);
ws.close();
node.stop();
});
test("binds system.run approval flags to exec.approval decision (ignores caller escalation)", async () => {
let lastInvokeParams: Record<string, unknown> | null = null;
const node = await connectLinuxNode((payload) => {
const obj = payload as { paramsJSON?: unknown };
const raw = typeof obj?.paramsJSON === "string" ? obj.paramsJSON : "";
if (!raw) {
lastInvokeParams = null;
return;
}
lastInvokeParams = JSON.parse(raw) as Record<string, unknown>;
});
const ws = await connectOperator(["operator.write", "operator.approvals"]);
const ws2 = await connectOperator(["operator.write"]);
const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>(
ws,
"node.list",
{},
);
expect(nodes.ok).toBe(true);
const nodeId = nodes.payload?.nodes?.find((n) => n.connected)?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const approvalId = crypto.randomUUID();
const requestP = rpcReq(ws, "exec.approval.request", {
id: approvalId,
command: "echo hi",
cwd: null,
host: "node",
timeoutMs: 30_000,
});
await rpcReq(ws, "exec.approval.resolve", { id: approvalId, decision: "allow-once" });
const requested = await requestP;
expect(requested.ok).toBe(true);
// Use a second WebSocket connection to simulate per-call clients (callGatewayTool/callGatewayCli).
// Approval binding should be based on device identity, not the ephemeral connId.
const invoke = await rpcReq(ws2, "node.invoke", {
nodeId,
command: "system.run",
params: {
command: ["echo", "hi"],
rawCommand: "echo hi",
runId: approvalId,
approved: true,
// Try to escalate to allow-always; gateway should clamp to allow-once from record.
approvalDecision: "allow-always",
injected: "nope",
},
idempotencyKey: crypto.randomUUID(),
});
expect(invoke.ok).toBe(true);
expect(lastInvokeParams).toBeTruthy();
expect(lastInvokeParams?.approved).toBe(true);
expect(lastInvokeParams?.approvalDecision).toBe("allow-once");
expect(lastInvokeParams?.injected).toBeUndefined();
ws.close();
ws2.close();
node.stop();
});
test("rejects replaying approval id from another device", async () => {
let sawInvoke = false;
const node = await connectLinuxNode(() => {
sawInvoke = true;
});
const ws = await connectOperator(["operator.write", "operator.approvals"]);
const wsOtherDevice = await connectOperatorWithNewDevice(["operator.write"]);
const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>(
ws,
"node.list",
{},
);
expect(nodes.ok).toBe(true);
const nodeId = nodes.payload?.nodes?.find((n) => n.connected)?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const approvalId = crypto.randomUUID();
const requestP = rpcReq(ws, "exec.approval.request", {
id: approvalId,
command: "echo hi",
cwd: null,
host: "node",
timeoutMs: 30_000,
});
await rpcReq(ws, "exec.approval.resolve", { id: approvalId, decision: "allow-once" });
const requested = await requestP;
expect(requested.ok).toBe(true);
const invoke = await rpcReq(wsOtherDevice, "node.invoke", {
nodeId,
command: "system.run",
params: {
command: ["echo", "hi"],
rawCommand: "echo hi",
runId: approvalId,
approved: true,
approvalDecision: "allow-once",
},
idempotencyKey: crypto.randomUUID(),
});
expect(invoke.ok).toBe(false);
expect(invoke.error?.message ?? "").toContain("not valid for this device");
await sleep(50);
expect(sawInvoke).toBe(false);
ws.close();
wsOtherDevice.close();
node.stop();
});
});

View File

@@ -175,6 +175,110 @@ type TranscriptMessage = {
provenance?: unknown;
};
export function readSessionTitleFieldsFromTranscript(
sessionId: string,
storePath: string | undefined,
sessionFile?: string,
agentId?: string,
opts?: { includeInterSession?: boolean },
): { firstUserMessage: string | null; lastMessagePreview: string | null } {
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
const filePath = candidates.find((p) => fs.existsSync(p));
if (!filePath) {
return { firstUserMessage: null, lastMessagePreview: null };
}
let fd: number | null = null;
try {
fd = fs.openSync(filePath, "r");
const stat = fs.fstatSync(fd);
const size = stat.size;
if (size === 0) {
return { firstUserMessage: null, lastMessagePreview: null };
}
// Head (first user message)
let firstUserMessage: string | null = null;
try {
const buf = Buffer.alloc(8192);
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
if (bytesRead > 0) {
const chunk = buf.toString("utf-8", 0, bytesRead);
const lines = chunk.split(/\r?\n/).slice(0, MAX_LINES_TO_SCAN);
for (const line of lines) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line);
const msg = parsed?.message as TranscriptMessage | undefined;
if (msg?.role !== "user") {
continue;
}
if (opts?.includeInterSession !== true && hasInterSessionUserProvenance(msg)) {
continue;
}
const text = extractTextFromContent(msg.content);
if (text) {
firstUserMessage = text;
break;
}
} catch {
// skip malformed lines
}
}
}
} catch {
// ignore head read errors
}
// Tail (last message preview)
let lastMessagePreview: string | null = null;
try {
const readStart = Math.max(0, size - LAST_MSG_MAX_BYTES);
const readLen = Math.min(size, LAST_MSG_MAX_BYTES);
const buf = Buffer.alloc(readLen);
fs.readSync(fd, buf, 0, readLen, readStart);
const chunk = buf.toString("utf-8");
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
const tailLines = lines.slice(-LAST_MSG_MAX_LINES);
for (let i = tailLines.length - 1; i >= 0; i--) {
const line = tailLines[i];
try {
const parsed = JSON.parse(line);
const msg = parsed?.message as TranscriptMessage | undefined;
if (msg?.role !== "user" && msg?.role !== "assistant") {
continue;
}
const text = extractTextFromContent(msg.content);
if (text) {
lastMessagePreview = text;
break;
}
} catch {
// skip malformed
}
}
} catch {
// ignore tail read errors
}
return { firstUserMessage, lastMessagePreview };
} catch {
return { firstUserMessage: null, lastMessagePreview: null };
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
}
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
if (typeof content === "string") {
return content.trim() || null;

View File

@@ -33,10 +33,7 @@ import {
} from "../routing/session-key.js";
import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
import {
readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript,
} from "./session-utils.fs.js";
import { readSessionTitleFieldsFromTranscript } from "./session-utils.fs.js";
export {
archiveFileOnDisk,
@@ -44,6 +41,7 @@ export {
capArrayByJsonBytes,
readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript,
readSessionTitleFieldsFromTranscript,
readSessionPreviewItemsFromTranscript,
readSessionMessages,
resolveSessionTranscriptCandidates,
@@ -817,22 +815,21 @@ export function listSessionsFromStore(params: {
let derivedTitle: string | undefined;
let lastMessagePreview: string | undefined;
if (entry?.sessionId) {
if (includeDerivedTitles) {
const firstUserMsg = readFirstUserMessageFromTranscript(
if (includeDerivedTitles || includeLastMessage) {
const parsed = parseAgentSessionKey(s.key);
const agentId =
parsed && parsed.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(cfg);
const fields = readSessionTitleFieldsFromTranscript(
entry.sessionId,
storePath,
entry.sessionFile,
agentId,
);
derivedTitle = deriveSessionTitle(entry, firstUserMsg);
}
if (includeLastMessage) {
const lastMsg = readLastMessagePreviewFromTranscript(
entry.sessionId,
storePath,
entry.sessionFile,
);
if (lastMsg) {
lastMessagePreview = lastMsg;
if (includeDerivedTitles) {
derivedTitle = deriveSessionTitle(entry, fields.firstUserMessage);
}
if (includeLastMessage && fields.lastMessagePreview) {
lastMessagePreview = fields.lastMessagePreview;
}
}
}

View File

@@ -279,6 +279,68 @@ describe("POST /tools/invoke", () => {
expect(res.status).toBe(404);
});
it("allows gateway tool via HTTP when explicitly enabled in gateway.tools.allow", async () => {
testState.agentsConfig = {
list: [{ id: "main", tools: { allow: ["gateway"] } }],
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: { tools: { allow: ["gateway"] } },
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
const token = resolveGatewayToken();
try {
const res = await invokeTool({
port: sharedPort,
tool: "gateway",
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
// Ensure we didn't hit the HTTP deny list (404). Invalid args should map to 400.
expect(res.status).toBe(400);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error?.type).toBe("tool_error");
} finally {
await writeConfigFile({
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
}
});
it("treats gateway.tools.deny as higher priority than gateway.tools.allow", async () => {
testState.agentsConfig = {
list: [{ id: "main", tools: { allow: ["gateway"] } }],
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: { tools: { allow: ["gateway"], deny: ["gateway"] } },
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
const token = resolveGatewayToken();
try {
const res = await invokeTool({
port: sharedPort,
tool: "gateway",
headers: { authorization: `Bearer ${token}` },
sessionKey: "main",
});
expect(res.status).toBe(404);
} finally {
await writeConfigFile({
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
}
});
it("uses the configured main session key when sessionKey is missing or main", async () => {
testState.agentsConfig = {
list: [

View File

@@ -22,6 +22,7 @@ import { logWarn } from "../logger.js";
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
import {
@@ -36,22 +37,6 @@ import { getBearerToken, getHeader } from "./http-utils.js";
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
/**
* Tools denied via HTTP /tools/invoke regardless of session policy.
* Prevents RCE and privilege escalation from HTTP API surface.
* Configurable via gateway.tools.{deny,allow} in openclaw.json.
*/
const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [
// Session orchestration — spawning agents remotely is RCE
"sessions_spawn",
// Cross-session injection — message injection across sessions
"sessions_send",
// Gateway control plane — prevents gateway reconfiguration via HTTP
"gateway",
// Interactive setup — requires terminal QR scan, hangs on HTTP
"whatsapp_login",
];
type ToolsInvokeBody = {
tool?: unknown;
action?: unknown;
@@ -345,9 +330,12 @@ export async function handleToolsInvokeHttpRequest(
// Gateway HTTP-specific deny list — applies to ALL sessions via HTTP.
const gatewayToolsCfg = cfg.gateway?.tools;
const gatewayDenyNames = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter(
const defaultGatewayDeny: string[] = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter(
(name) => !gatewayToolsCfg?.allow?.includes(name),
).concat(Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : []);
);
const gatewayDenyNames = defaultGatewayDeny.concat(
Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : [],
);
const gatewayDenySet = new Set(gatewayDenyNames);
const gatewayFiltered = subagentFiltered.filter((t) => !gatewayDenySet.has(t.name));

View File

@@ -25,6 +25,10 @@ const wsInflightOptimized = new Map<string, number>();
const wsInflightSince = new Map<string, number>();
const wsLog = createSubsystemLogger("gateway/ws");
export function shouldLogWs(): boolean {
return shouldLogSubsystemToConsole("gateway/ws");
}
export function shortId(value: string): string {
const s = value.trim();
if (UUID_RE.test(s)) {

View File

@@ -81,7 +81,7 @@ session-memory/
---
name: my-hook
description: "Short description"
homepage: https://docs.openclaw.ai/hooks#my-hook
homepage: https://docs.openclaw.ai/automation/hooks#my-hook
metadata:
{ "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
---
@@ -220,4 +220,4 @@ Test your hooks by:
## Documentation
Full documentation: https://docs.openclaw.ai/hooks
Full documentation: https://docs.openclaw.ai/automation/hooks

View File

@@ -1,7 +1,7 @@
---
name: boot-md
description: "Run BOOT.md on gateway startup"
homepage: https://docs.openclaw.ai/hooks#boot-md
homepage: https://docs.openclaw.ai/automation/hooks#boot-md
metadata:
{
"openclaw":

View File

@@ -1,7 +1,7 @@
---
name: command-logger
description: "Log all command events to a centralized audit file"
homepage: https://docs.openclaw.ai/hooks#command-logger
homepage: https://docs.openclaw.ai/automation/hooks#command-logger
metadata:
{
"openclaw":

View File

@@ -1,7 +1,7 @@
---
name: session-memory
description: "Save session context to memory when /new command is issued"
homepage: https://docs.openclaw.ai/hooks#session-memory
homepage: https://docs.openclaw.ai/automation/hooks#session-memory
metadata:
{
"openclaw":

View File

@@ -233,7 +233,7 @@ describe("resolveOpenClawMetadata", () => {
const content = `---
name: session-memory
description: "Save session context to memory when /new command is issued"
homepage: https://docs.openclaw.ai/hooks#session-memory
homepage: https://docs.openclaw.ai/automation/hooks#session-memory
metadata:
{
"openclaw":

View File

@@ -5,11 +5,8 @@ import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { markdownToSignalTextChunks } from "../../signal/format.js";
import {
createIMessageTestPlugin,
createOutboundTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
const mocks = vi.hoisted(() => ({
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),

View File

@@ -9,11 +9,8 @@ import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
import { jsonResult } from "../../agents/tools/common.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createIMessageTestPlugin,
createOutboundTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { loadWebMedia } from "../../web/media.js";
import { runMessageAction } from "./message-action-runner.js";

View File

@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createIMessageTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { sendMessage, sendPoll } from "./message.js";
const setRegistry = (registry: ReturnType<typeof createTestRegistry>) => {

View File

@@ -68,7 +68,13 @@ vi.mock("../logging/subsystem.js", () => ({
},
}));
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
vi.mock(import("node:child_process"), async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
spawn: vi.fn(),
};
});
import { spawn as mockedSpawn } from "node:child_process";
import type { OpenClawConfig } from "../config/config.js";

View File

@@ -160,6 +160,57 @@ type BindingScope = {
memberRoleIds: Set<string>;
};
type EvaluatedBindingsCache = {
bindingsRef: OpenClawConfig["bindings"];
byChannelAccount: Map<string, EvaluatedBinding[]>;
};
const evaluatedBindingsCacheByCfg = new WeakMap<OpenClawConfig, EvaluatedBindingsCache>();
const MAX_EVALUATED_BINDINGS_CACHE_KEYS = 2000;
function getEvaluatedBindingsForChannelAccount(
cfg: OpenClawConfig,
channel: string,
accountId: string,
): EvaluatedBinding[] {
const bindingsRef = cfg.bindings;
const existing = evaluatedBindingsCacheByCfg.get(cfg);
const cache =
existing && existing.bindingsRef === bindingsRef
? existing
: { bindingsRef, byChannelAccount: new Map<string, EvaluatedBinding[]>() };
if (cache !== existing) {
evaluatedBindingsCacheByCfg.set(cfg, cache);
}
const cacheKey = `${channel}\t${accountId}`;
const hit = cache.byChannelAccount.get(cacheKey);
if (hit) {
return hit;
}
const evaluated: EvaluatedBinding[] = listBindings(cfg).flatMap((binding) => {
if (!binding || typeof binding !== "object") {
return [];
}
if (!matchesChannel(binding.match, channel)) {
return [];
}
if (!matchesAccountId(binding.match?.accountId, accountId)) {
return [];
}
return [{ binding, match: normalizeBindingMatch(binding.match) }];
});
cache.byChannelAccount.set(cacheKey, evaluated);
if (cache.byChannelAccount.size > MAX_EVALUATED_BINDINGS_CACHE_KEYS) {
cache.byChannelAccount.clear();
cache.byChannelAccount.set(cacheKey, evaluated);
}
return evaluated;
}
function normalizePeerConstraint(
peer: { kind?: string; id?: string } | undefined,
): NormalizedPeerConstraint {
@@ -242,18 +293,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
const memberRoleIds = input.memberRoleIds ?? [];
const memberRoleIdSet = new Set(memberRoleIds);
const bindings: EvaluatedBinding[] = listBindings(input.cfg).flatMap((binding) => {
if (!binding || typeof binding !== "object") {
return [];
}
if (!matchesChannel(binding.match, channel)) {
return [];
}
if (!matchesAccountId(binding.match?.accountId, accountId)) {
return [];
}
return [{ binding, match: normalizeBindingMatch(binding.match) }];
});
const bindings = getEvaluatedBindingsForChannelAccount(input.cfg, channel, accountId);
const dmScope = input.cfg.session?.dmScope ?? "main";
const identityLinks = input.cfg.session?.identityLinks;

View File

@@ -153,6 +153,53 @@ describe("security audit", () => {
).toBe(true);
});
it("warns when gateway.tools.allow re-enables dangerous HTTP /tools/invoke tools (loopback)", async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "loopback",
auth: { token: "secret" },
tools: { allow: ["sessions_spawn"] },
},
};
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(
res.findings.some(
(f) => f.checkId === "gateway.tools_invoke_http.dangerous_allow" && f.severity === "warn",
),
).toBe(true);
});
it("flags dangerous gateway.tools.allow over HTTP as critical when gateway binds beyond loopback", async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
auth: { token: "secret" },
tools: { allow: ["sessions_spawn", "gateway"] },
},
};
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(
res.findings.some(
(f) =>
f.checkId === "gateway.tools_invoke_http.dangerous_allow" && f.severity === "critical",
),
).toBe(true);
});
it("does not warn for auth rate limiting when configured", async () => {
const cfg: OpenClawConfig = {
gateway: {

View File

@@ -33,6 +33,7 @@ import {
formatPermissionRemediation,
inspectPathPermissions,
} from "./audit-fs.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js";
export type SecurityAuditSeverity = "info" | "warn" | "critical";
@@ -258,6 +259,34 @@ function collectGatewayConfigFindings(
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve";
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
// HTTP /tools/invoke is intended for narrow automation, not session orchestration/admin operations.
// If operators opt-in to re-enabling these tools over HTTP, warn loudly so the choice is explicit.
const gatewayToolsAllowRaw = Array.isArray(cfg.gateway?.tools?.allow)
? cfg.gateway?.tools?.allow
: [];
const gatewayToolsAllow = new Set(
gatewayToolsAllowRaw
.map((v) => (typeof v === "string" ? v.trim().toLowerCase() : ""))
.filter(Boolean),
);
const reenabledOverHttp = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) =>
gatewayToolsAllow.has(name),
);
if (reenabledOverHttp.length > 0) {
const extraRisk = bind !== "loopback" || tailscaleMode === "funnel";
findings.push({
checkId: "gateway.tools_invoke_http.dangerous_allow",
severity: extraRisk ? "critical" : "warn",
title: "Gateway HTTP /tools/invoke re-enables dangerous tools",
detail:
`gateway.tools.allow includes ${reenabledOverHttp.join(", ")} which removes them from the default HTTP deny list. ` +
"This can allow remote session spawning / control-plane actions via HTTP and increases RCE blast radius if the gateway is reachable.",
remediation:
"Remove these entries from gateway.tools.allow (recommended). " +
"If you keep them enabled, keep gateway.bind loopback-only (or tailnet-only), restrict network exposure, and treat the gateway token/password as full-admin.",
});
}
if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") {
findings.push({
checkId: "gateway.bind_no_auth",

View File

@@ -0,0 +1,37 @@
// Shared tool-risk constants.
// Keep these centralized so gateway HTTP restrictions, security audits, and ACP prompts don't drift.
/**
* Tools denied via Gateway HTTP `POST /tools/invoke` by default.
* These are high-risk because they enable session orchestration, control-plane actions,
* or interactive flows that don't make sense over a non-interactive HTTP surface.
*/
export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [
// Session orchestration — spawning agents remotely is RCE
"sessions_spawn",
// Cross-session injection — message injection across sessions
"sessions_send",
// Gateway control plane — prevents gateway reconfiguration via HTTP
"gateway",
// Interactive setup — requires terminal QR scan, hangs on HTTP
"whatsapp_login",
] as const;
/**
* ACP tools that should always require explicit user approval.
* ACP is an automation surface; we never want "silent yes" for mutating/execution tools.
*/
export const DANGEROUS_ACP_TOOL_NAMES = [
"exec",
"spawn",
"shell",
"sessions_spawn",
"sessions_send",
"gateway",
"fs_write",
"fs_delete",
"fs_move",
"apply_patch",
] as const;
export const DANGEROUS_ACP_TOOLS = new Set<string>(DANGEROUS_ACP_TOOL_NAMES);

View File

@@ -10,9 +10,13 @@ import {
} from "./sticker-cache.js";
// Mock the state directory to use a temp location
vi.mock("../config/paths.js", () => ({
STATE_DIR: "/tmp/openclaw-test-sticker-cache",
}));
vi.mock("../config/paths.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/paths.js")>();
return {
...actual,
STATE_DIR: "/tmp/openclaw-test-sticker-cache",
};
});
const TEST_CACHE_DIR = "/tmp/openclaw-test-sticker-cache/telegram";
const TEST_CACHE_FILE = path.join(TEST_CACHE_DIR, "sticker-cache.json");

View File

@@ -5,8 +5,6 @@ import type {
ChannelPlugin,
} from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { imessageOutbound } from "../channels/plugins/outbound/imessage.js";
import { normalizeIMessageHandle } from "../imessage/targets.js";
export const createTestRegistry = (channels: PluginRegistry["channels"] = []): PluginRegistry => ({
plugins: [],
@@ -24,62 +22,6 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
diagnostics: [],
});
export const createIMessageTestPlugin = (params?: {
outbound?: ChannelOutboundAdapter;
}): ChannelPlugin => ({
id: "imessage",
meta: {
id: "imessage",
label: "iMessage",
selectionLabel: "iMessage (imsg)",
docsPath: "/channels/imessage",
blurb: "iMessage test stub.",
aliases: ["imsg"],
},
capabilities: { chatTypes: ["direct", "group"], media: true },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
status: {
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) {
return [];
}
return [
{
channel: "imessage",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
},
outbound: params?.outbound ?? imessageOutbound,
messaging: {
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return true;
}
if (trimmed.includes("@")) {
return true;
}
return /^\+?\d{3,}$/.test(trimmed);
},
hint: "<handle|chat_id:ID>",
},
normalizeTarget: (raw) => normalizeIMessageHandle(raw),
},
});
export const createOutboundTestPlugin = (params: {
id: ChannelId;
outbound: ChannelOutboundAdapter;

View File

@@ -0,0 +1,59 @@
import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js";
import { imessageOutbound } from "../channels/plugins/outbound/imessage.js";
import { normalizeIMessageHandle } from "../imessage/targets.js";
export const createIMessageTestPlugin = (params?: {
outbound?: ChannelOutboundAdapter;
}): ChannelPlugin => ({
id: "imessage",
meta: {
id: "imessage",
label: "iMessage",
selectionLabel: "iMessage (imsg)",
docsPath: "/channels/imessage",
blurb: "iMessage test stub.",
aliases: ["imsg"],
},
capabilities: { chatTypes: ["direct", "group"], media: true },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
status: {
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) {
return [];
}
return [
{
channel: "imessage",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
},
outbound: params?.outbound ?? imessageOutbound,
messaging: {
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return true;
}
if (trimmed.includes("@")) {
return true;
}
return /^\+?\d{3,}$/.test(trimmed);
},
hint: "<handle|chat_id:ID>",
},
normalizeTarget: (raw) => normalizeIMessageHandle(raw),
},
});

View File

@@ -6,6 +6,9 @@ import * as tts from "./tts.js";
vi.mock("@mariozechner/pi-ai", () => ({
completeSimple: vi.fn(),
// Some auth helpers import oauth provider metadata at module load time.
getOAuthProviders: () => [],
getOAuthApiKey: vi.fn(async () => null),
}));
vi.mock("../agents/pi-embedded-runner/model.js", () => ({