mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
15
src/cli/program/program-context.ts
Normal file
15
src/cli/program/program-context.ts
Normal 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
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -37,6 +37,7 @@ export type AuthChoice =
|
||||
| "minimax-cloud"
|
||||
| "minimax"
|
||||
| "minimax-api"
|
||||
| "minimax-api-key-cn"
|
||||
| "minimax-api-lightning"
|
||||
| "minimax-portal"
|
||||
| "opencode-zen"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
21
src/gateway/node-invoke-sanitize.ts
Normal file
21
src/gateway/node-invoke-sanitize.ts
Normal 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 };
|
||||
}
|
||||
237
src/gateway/node-invoke-system-run-approval.ts
Normal file
237
src/gateway/node-invoke-system-run-approval.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -565,6 +565,7 @@ export async function startGatewayServer(
|
||||
deps,
|
||||
cron,
|
||||
cronStorePath,
|
||||
execApprovalManager,
|
||||
loadGatewayModelCatalog,
|
||||
getHealthCache,
|
||||
refreshHealthSnapshot: refreshGatewayHealthSnapshot,
|
||||
|
||||
281
src/gateway/server.node-invoke-approval-bypass.e2e.test.ts
Normal file
281
src/gateway/server.node-invoke-approval-bypass.e2e.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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" })),
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
37
src/security/dangerous-tools.ts
Normal file
37
src/security/dangerous-tools.ts
Normal 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);
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
59
src/test-utils/imessage-test-plugin.ts
Normal file
59
src/test-utils/imessage-test-plugin.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
@@ -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", () => ({
|
||||
|
||||
Reference in New Issue
Block a user