diff --git a/CHANGELOG.md b/CHANGELOG.md index fb04c6b100c..dc4c42437d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/models: lazy-load model runtime helpers for command actions so `openclaw models --help` renders without importing the model runtime path. - Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang. - Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom. - Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd. diff --git a/src/cli/models-cli.lazy.test.ts b/src/cli/models-cli.lazy.test.ts new file mode 100644 index 00000000000..e6bac999d01 --- /dev/null +++ b/src/cli/models-cli.lazy.test.ts @@ -0,0 +1,77 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("models cli lazy runtime boundary", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock("./models-cli.runtime.js"); + vi.doUnmock("../commands/models/list.status-command.js"); + vi.resetModules(); + }); + + it("renders help without importing the models runtime", async () => { + const runtimeLoaded = vi.fn(); + vi.doMock("./models-cli.runtime.js", () => { + runtimeLoaded(); + return { + defaultRuntime: {}, + rejectAgentScopedModelWrite: vi.fn(), + resolveModelAgentOption: vi.fn(), + runModelsCommand: vi.fn(), + }; + }); + + const { registerModelsCli } = await import("./models-cli.js"); + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeErr: () => {}, + writeOut: () => {}, + }); + registerModelsCli(program); + + await expect(program.parseAsync(["models", "--help"], { from: "user" })).rejects.toMatchObject({ + exitCode: 0, + }); + expect(runtimeLoaded).not.toHaveBeenCalled(); + }); + + it("loads the models runtime for command actions", async () => { + const defaultRuntime = {}; + const modelsStatusCommand = vi.fn().mockResolvedValue(undefined); + const runModelsCommand = vi.fn(async (action: () => Promise) => { + await action(); + }); + const resolveModelAgentOption = vi.fn(() => "poe"); + const runtimeLoaded = vi.fn(); + + vi.doMock("./models-cli.runtime.js", () => { + runtimeLoaded(); + return { + defaultRuntime, + rejectAgentScopedModelWrite: vi.fn(), + resolveModelAgentOption, + runModelsCommand, + }; + }); + vi.doMock("../commands/models/list.status-command.js", () => ({ + modelsStatusCommand, + })); + + const { registerModelsCli } = await import("./models-cli.js"); + const program = new Command(); + registerModelsCli(program); + + await program.parseAsync(["models", "status", "--json"], { from: "user" }); + + expect(runtimeLoaded).toHaveBeenCalledTimes(1); + expect(runModelsCommand).toHaveBeenCalledTimes(1); + expect(modelsStatusCommand).toHaveBeenCalledWith( + expect.objectContaining({ agent: "poe", json: true }), + defaultRuntime, + ); + }); +}); diff --git a/src/cli/models-cli.runtime.ts b/src/cli/models-cli.runtime.ts new file mode 100644 index 00000000000..f3f245066a6 --- /dev/null +++ b/src/cli/models-cli.runtime.ts @@ -0,0 +1,33 @@ +import type { Command } from "commander"; +import { defaultRuntime } from "../runtime.js"; +import { resolveOptionFromCommand, runCommandWithRuntime } from "./cli-utils.js"; +import { formatCliCommand } from "./command-format.js"; + +export { defaultRuntime }; + +export function runModelsCommand(action: () => Promise) { + return runCommandWithRuntime(defaultRuntime, action); +} + +export function resolveModelAgentOption( + command: Command | undefined, + opts?: { agent?: unknown }, +): string | undefined { + return ( + resolveOptionFromCommand(command, "agent") ?? + (typeof opts?.agent === "string" ? opts.agent : undefined) + ); +} + +export function rejectAgentScopedModelWrite( + command: Command, + commandName: "set" | "set-image", +): void { + const agent = resolveOptionFromCommand(command, "agent"); + if (!agent) { + return; + } + throw new Error( + `openclaw models ${commandName} does not support --agent; it only updates global model defaults. Remove --agent, or run ${formatCliCommand("openclaw agents list")} and set the per-agent model in agent config.`, + ); +} diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 89601119b78..8c14362f8b7 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -1,22 +1,14 @@ import type { Command } from "commander"; -import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; -import { resolveOptionFromCommand, runCommandWithRuntime } from "./cli-utils.js"; -import { formatCliCommand } from "./command-format.js"; -function runModelsCommand(action: () => Promise) { - return runCommandWithRuntime(defaultRuntime, action); -} +type ModelsCliRuntime = typeof import("./models-cli.runtime.js"); -function rejectAgentScopedModelWrite(command: Command, commandName: "set" | "set-image"): void { - const agent = resolveOptionFromCommand(command, "agent"); - if (!agent) { - return; - } - throw new Error( - `openclaw models ${commandName} does not support --agent; it only updates global model defaults. Remove --agent, or run ${formatCliCommand("openclaw agents list")} and set the per-agent model in agent config.`, - ); +async function withModelsRuntime( + action: (runtime: ModelsCliRuntime) => Promise, +): Promise { + const runtime = await import("./models-cli.runtime.js"); + return runtime.runModelsCommand(() => action(runtime)); } export function registerModelsCli(program: Command) { @@ -44,7 +36,7 @@ export function registerModelsCli(program: Command) { .option("--json", "Output JSON", false) .option("--plain", "Plain line output", false) .action(async (opts) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsListCommand } = await import("../commands/models/list.list-command.js"); await modelsListCommand(opts, defaultRuntime); }); @@ -79,9 +71,8 @@ export function registerModelsCli(program: Command) { "Agent id to inspect (overrides OPENCLAW_AGENT_DIR/PI_CODING_AGENT_DIR)", ) .action(async (opts, command) => { - const agent = - resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command, opts); const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { @@ -106,10 +97,11 @@ export function registerModelsCli(program: Command) { .description("Set the default model") .argument("", "Model id or alias") .action(async (model: string, _opts: unknown, command: Command) => { - rejectAgentScopedModelWrite(command, "set"); - await runModelsCommand(async () => { + const runtime = await import("./models-cli.runtime.js"); + runtime.rejectAgentScopedModelWrite(command, "set"); + await runtime.runModelsCommand(async () => { const { modelsSetCommand } = await import("../commands/models/set.js"); - await modelsSetCommand(model, defaultRuntime); + await modelsSetCommand(model, runtime.defaultRuntime); }); }); @@ -118,10 +110,11 @@ export function registerModelsCli(program: Command) { .description("Set the image model") .argument("", "Model id or alias") .action(async (model: string, _opts: unknown, command: Command) => { - rejectAgentScopedModelWrite(command, "set-image"); - await runModelsCommand(async () => { + const runtime = await import("./models-cli.runtime.js"); + runtime.rejectAgentScopedModelWrite(command, "set-image"); + await runtime.runModelsCommand(async () => { const { modelsSetImageCommand } = await import("../commands/models/set-image.js"); - await modelsSetImageCommand(model, defaultRuntime); + await modelsSetImageCommand(model, runtime.defaultRuntime); }); }); @@ -133,7 +126,7 @@ export function registerModelsCli(program: Command) { .option("--json", "Output JSON", false) .option("--plain", "Plain output", false) .action(async (opts) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsAliasesListCommand } = await import("../commands/models/aliases.js"); await modelsAliasesListCommand(opts, defaultRuntime); }); @@ -145,7 +138,7 @@ export function registerModelsCli(program: Command) { .argument("", "Alias name") .argument("", "Model id or alias") .action(async (alias: string, model: string) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsAliasesAddCommand } = await import("../commands/models/aliases.js"); await modelsAliasesAddCommand(alias, model, defaultRuntime); }); @@ -156,7 +149,7 @@ export function registerModelsCli(program: Command) { .description("Remove a model alias") .argument("", "Alias name") .action(async (alias: string) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsAliasesRemoveCommand } = await import("../commands/models/aliases.js"); await modelsAliasesRemoveCommand(alias, defaultRuntime); }); @@ -170,7 +163,7 @@ export function registerModelsCli(program: Command) { .option("--json", "Output JSON", false) .option("--plain", "Plain output", false) .action(async (opts) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsFallbacksListCommand } = await import("../commands/models/fallbacks.js"); await modelsFallbacksListCommand(opts, defaultRuntime); }); @@ -181,7 +174,7 @@ export function registerModelsCli(program: Command) { .description("Add a fallback model") .argument("", "Model id or alias") .action(async (model: string) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsFallbacksAddCommand } = await import("../commands/models/fallbacks.js"); await modelsFallbacksAddCommand(model, defaultRuntime); }); @@ -192,7 +185,7 @@ export function registerModelsCli(program: Command) { .description("Remove a fallback model") .argument("", "Model id or alias") .action(async (model: string) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsFallbacksRemoveCommand } = await import("../commands/models/fallbacks.js"); await modelsFallbacksRemoveCommand(model, defaultRuntime); }); @@ -202,7 +195,7 @@ export function registerModelsCli(program: Command) { .command("clear") .description("Clear all fallback models") .action(async () => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsFallbacksClearCommand } = await import("../commands/models/fallbacks.js"); await modelsFallbacksClearCommand(defaultRuntime); }); @@ -218,7 +211,7 @@ export function registerModelsCli(program: Command) { .option("--json", "Output JSON", false) .option("--plain", "Plain output", false) .action(async (opts) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsImageFallbacksListCommand } = await import("../commands/models/image-fallbacks.js"); await modelsImageFallbacksListCommand(opts, defaultRuntime); @@ -230,7 +223,7 @@ export function registerModelsCli(program: Command) { .description("Add an image fallback model") .argument("", "Model id or alias") .action(async (model: string) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsImageFallbacksAddCommand } = await import("../commands/models/image-fallbacks.js"); await modelsImageFallbacksAddCommand(model, defaultRuntime); @@ -242,7 +235,7 @@ export function registerModelsCli(program: Command) { .description("Remove an image fallback model") .argument("", "Model id or alias") .action(async (model: string) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsImageFallbacksRemoveCommand } = await import("../commands/models/image-fallbacks.js"); await modelsImageFallbacksRemoveCommand(model, defaultRuntime); @@ -253,7 +246,7 @@ export function registerModelsCli(program: Command) { .command("clear") .description("Clear all image fallback models") .action(async () => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsImageFallbacksClearCommand } = await import("../commands/models/image-fallbacks.js"); await modelsImageFallbacksClearCommand(defaultRuntime); @@ -276,14 +269,14 @@ export function registerModelsCli(program: Command) { .option("--set-image", "Set agents.defaults.imageModel to the first image selection", false) .option("--json", "Output JSON", false) .action(async (opts) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsScanCommand } = await import("../commands/models/scan.js"); await modelsScanCommand(opts, defaultRuntime); }); }); models.action(async (opts) => { - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { @@ -309,9 +302,8 @@ export function registerModelsCli(program: Command) { .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Output JSON", false) .action(async (opts, command) => { - const agent = - resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command, opts); const { modelsAuthListCommand } = await import("../commands/models/auth-list.js"); await modelsAuthListCommand( { @@ -328,10 +320,8 @@ export function registerModelsCli(program: Command) { .command("add") .description("Interactive auth helper (provider auth or paste token)") .action(async (command) => { - const agent = - resolveOptionFromCommand(command, "agent") ?? - resolveOptionFromCommand(auth, "agent"); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command) ?? resolveModelAgentOption(auth); const { modelsAuthAddCommand } = await import("../commands/models/auth.js"); await modelsAuthAddCommand({ agent }, defaultRuntime); }); @@ -344,8 +334,8 @@ export function registerModelsCli(program: Command) { .option("--method ", "Provider auth method id") .option("--set-default", "Apply the provider's default model recommendation", false) .action(async (opts, command) => { - const agent = resolveOptionFromCommand(command, "agent"); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command); const { modelsAuthLoginCommand } = await import("../commands/models/auth.js"); await modelsAuthLoginCommand( { @@ -365,8 +355,8 @@ export function registerModelsCli(program: Command) { .option("--provider ", "Provider id") .option("--yes", "Skip confirmation", false) .action(async (opts, command) => { - const agent = resolveOptionFromCommand(command, "agent"); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command); const { modelsAuthSetupTokenCommand } = await import("../commands/models/auth.js"); await modelsAuthSetupTokenCommand( { @@ -389,8 +379,8 @@ export function registerModelsCli(program: Command) { "Optional expiry duration (e.g. 365d, 12h). Stored as absolute expiresAt.", ) .action(async (opts, command) => { - const agent = resolveOptionFromCommand(command, "agent"); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command); const { modelsAuthPasteTokenCommand } = await import("../commands/models/auth.js"); await modelsAuthPasteTokenCommand( { @@ -409,8 +399,8 @@ export function registerModelsCli(program: Command) { .description("Login to GitHub Copilot via GitHub device flow (TTY required)") .option("--yes", "Overwrite existing profile without prompting", false) .action(async (opts, command) => { - const agent = resolveOptionFromCommand(command, "agent"); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command); const { modelsAuthLoginCommand } = await import("../commands/models/auth.js"); await modelsAuthLoginCommand( { @@ -433,9 +423,8 @@ export function registerModelsCli(program: Command) { .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Output JSON", false) .action(async (opts, command) => { - const agent = - resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command, opts); const { modelsAuthOrderGetCommand } = await import("../commands/models/auth-order.js"); await modelsAuthOrderGetCommand( { @@ -455,9 +444,8 @@ export function registerModelsCli(program: Command) { .option("--agent ", "Agent id (default: configured default agent)") .argument("", "Auth profile ids (e.g. anthropic:default)") .action(async (profileIds: string[], opts, command) => { - const agent = - resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command, opts); const { modelsAuthOrderSetCommand } = await import("../commands/models/auth-order.js"); await modelsAuthOrderSetCommand( { @@ -476,9 +464,8 @@ export function registerModelsCli(program: Command) { .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .action(async (opts, command) => { - const agent = - resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); - await runModelsCommand(async () => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command, opts); const { modelsAuthOrderClearCommand } = await import("../commands/models/auth-order.js"); await modelsAuthOrderClearCommand( {