refactor(cli): lazy-load models runtime

This commit is contained in:
Vincent Koc
2026-05-14 15:47:43 +08:00
parent bfc798bd0b
commit babd25c6b7
4 changed files with 159 additions and 61 deletions

View File

@@ -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.

View File

@@ -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<void>) => {
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,
);
});
});

View File

@@ -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<void>) {
return runCommandWithRuntime(defaultRuntime, action);
}
export function resolveModelAgentOption(
command: Command | undefined,
opts?: { agent?: unknown },
): string | undefined {
return (
resolveOptionFromCommand<string>(command, "agent") ??
(typeof opts?.agent === "string" ? opts.agent : undefined)
);
}
export function rejectAgentScopedModelWrite(
command: Command,
commandName: "set" | "set-image",
): void {
const agent = resolveOptionFromCommand<string>(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.`,
);
}

View File

@@ -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<void>) {
return runCommandWithRuntime(defaultRuntime, action);
}
type ModelsCliRuntime = typeof import("./models-cli.runtime.js");
function rejectAgentScopedModelWrite(command: Command, commandName: "set" | "set-image"): void {
const agent = resolveOptionFromCommand<string>(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<void>,
): Promise<void> {
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<string>(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>", "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>", "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>", "Alias name")
.argument("<model>", "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>", "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>", "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>", "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>", "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>", "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 <id>", "Agent id (default: configured default agent)")
.option("--json", "Output JSON", false)
.action(async (opts, command) => {
const agent =
resolveOptionFromCommand<string>(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<string>(command, "agent") ??
resolveOptionFromCommand<string>(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 <id>", "Provider auth method id")
.option("--set-default", "Apply the provider's default model recommendation", false)
.action(async (opts, command) => {
const agent = resolveOptionFromCommand<string>(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 <name>", "Provider id")
.option("--yes", "Skip confirmation", false)
.action(async (opts, command) => {
const agent = resolveOptionFromCommand<string>(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<string>(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<string>(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 <id>", "Agent id (default: configured default agent)")
.option("--json", "Output JSON", false)
.action(async (opts, command) => {
const agent =
resolveOptionFromCommand<string>(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 <id>", "Agent id (default: configured default agent)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:default)")
.action(async (profileIds: string[], opts, command) => {
const agent =
resolveOptionFromCommand<string>(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 <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.action(async (opts, command) => {
const agent =
resolveOptionFromCommand<string>(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(
{