diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e9f31e7..dbf058742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,8 +64,10 @@ Docs: https://docs.openclaw.ai - Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman. - Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr. - Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic. +- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution. - CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale. - CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle. +- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead). - Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent. - Security/ACP: prompt for non-read/search permission requests in ACP clients (reduces silent tool approval risk). Thanks @aether-ai-agent. - Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo. diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 6874611f8..5659f9596 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -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) { diff --git a/src/cli/program/build-program.ts b/src/cli/program/build-program.ts index 4feff385f..72cc798e6 100644 --- a/src/cli/program/build-program.ts +++ b/src/cli/program/build-program.ts @@ -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); diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 1e03dd702..84114f01f 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -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; +}; + +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(); + 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 { + 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); } diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 0c15b8f12..da8d3da73 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -20,11 +20,22 @@ const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([ "restart", ]); let didRunDoctorConfigFlow = false; +let configSnapshotPromise: Promise>> | null = + null; function formatConfigIssues(issues: Array<{ path: string; message: string }>): string[] { return issues.map((issue) => `- ${issue.path || ""}: ${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 diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 8fb1b7b53..9c2259690 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -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(); } }); diff --git a/src/cli/program/program-context.ts b/src/cli/program/program-context.ts new file mode 100644 index 000000000..83ff80f6d --- /dev/null +++ b/src/cli/program/program-context.ts @@ -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 + ]; +} diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index d90eda95b..e9eb5d824 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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); }