CLI: harden profile passthrough and reset overrides

This commit is contained in:
Tristan Manchester 2026-03-13 04:46:58 +01:00
parent 8ccf40e36a
commit 1fae5ec32f
6 changed files with 104 additions and 6 deletions

View File

@ -3,7 +3,6 @@ import fsPromises from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
@ -373,6 +372,18 @@ describe("gateway run option collisions", () => {
expect(ensureDevGatewayConfig).toHaveBeenCalledWith({ reset: true });
});
it("allows --dev --reset for explicit non-default legacy state/config paths", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("CLAWDBOT_STATE_DIR", "/tmp/custom-dev");
vi.stubEnv("CLAWDBOT_CONFIG_PATH", "/tmp/custom-dev/openclaw.json");
resolveStateDir.mockReturnValue("/tmp/custom-dev");
resolveConfigPath.mockReturnValue("/tmp/custom-dev/openclaw.json");
await runGatewayCli(["gateway", "run", "--dev", "--reset", "--allow-unconfigured"]);
expect(ensureDevGatewayConfig).toHaveBeenCalledWith({ reset: true });
});
it("hard-stops --dev --reset when state/config match non-dev profile defaults", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("OPENCLAW_PROFILE", "work");
@ -389,6 +400,22 @@ describe("gateway run option collisions", () => {
);
});
it("hard-stops --dev --reset when legacy env resolves to non-dev profile defaults", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("OPENCLAW_PROFILE", "work");
vi.stubEnv("CLAWDBOT_STATE_DIR", "/Users/test/.openclaw-work");
vi.stubEnv("CLAWDBOT_CONFIG_PATH", "/Users/test/.openclaw-work/openclaw.json");
resolveStateDir.mockReturnValue("/Users/test/.openclaw-work");
resolveConfigPath.mockReturnValue("/Users/test/.openclaw-work/openclaw.json");
await expectGatewayExit(["gateway", "run", "--dev", "--reset"]);
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
);
});
it("hard-stops --dev --reset when state is profile-default but config is custom", async () => {
vi.stubEnv("HOME", "/Users/test");
vi.stubEnv("OPENCLAW_PROFILE", "work");

View File

@ -248,8 +248,12 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
: false;
const stateTargetsProfileDefault = stateIsDefault || stateMatchesProfileDefault;
const configTargetsProfileDefault = configIsDefault || configMatchesProfileDefault;
const hasStateOverride = Boolean(process.env.OPENCLAW_STATE_DIR?.trim());
const hasConfigOverride = Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim());
const hasStateOverride = Boolean(
process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim(),
);
const hasConfigOverride = Boolean(
process.env.OPENCLAW_CONFIG_PATH?.trim() || process.env.CLAWDBOT_CONFIG_PATH?.trim(),
);
const hasExplicitCustomTarget =
hasStateOverride &&
!stateTargetsProfileDefault &&

View File

@ -145,6 +145,60 @@ describe("parseCliProfileArgs", () => {
expect(res.argv).toEqual(["node", "openclaw", "docs", "aws", "--profile", "prod"]);
});
it("does not intercept --profile in acp client server args", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"acp",
"client",
"--server-args",
"--profile",
"work",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"openclaw",
"acp",
"client",
"--server-args",
"--profile",
"work",
]);
});
it("preserves acp client server args after root option values", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"--log-level",
"debug",
"acp",
"client",
"--server-args",
"--profile",
"work",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"openclaw",
"--log-level",
"debug",
"acp",
"client",
"--server-args",
"--profile",
"work",
]);
});
it("keeps passthrough --profile when global --profile is set before terminator", () => {
const res = parseCliProfileArgs([
"node",

View File

@ -27,7 +27,7 @@ function takeValue(
return { value: trimmed || null, consumedNext: Boolean(next) };
}
const ARBITRARY_ARG_COMMAND_PATHS = [["nodes", "run"], ["docs"]] as const;
const ARBITRARY_ARG_COMMAND_PATHS = [["nodes", "run"], ["docs"], ["acp", "client"]] as const;
const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]);

View File

@ -187,4 +187,17 @@ describe("resolveResetTargets", () => {
expect(targets.credentialsPath).toBe(path.resolve("/tmp/custom-openclaw/credentials"));
expect(targets.sessionsDir).toBe(path.resolve("/tmp/custom-openclaw/agents/main/sessions"));
});
it("respects legacy CLAWDBOT_CONFIG_PATH override at runtime", () => {
const env = {
OPENCLAW_STATE_DIR: "/tmp/custom-openclaw",
CLAWDBOT_CONFIG_PATH: "/tmp/legacy/openclaw.json",
} as NodeJS.ProcessEnv;
const targets = resolveResetTargets(env);
expect(targets.stateDir).toBe(path.resolve("/tmp/custom-openclaw"));
expect(targets.configPath).toBe(path.resolve("/tmp/legacy/openclaw.json"));
expect(targets.credentialsPath).toBe(path.resolve("/tmp/custom-openclaw/credentials"));
expect(targets.sessionsDir).toBe(path.resolve("/tmp/custom-openclaw/agents/main/sessions"));
});
});

View File

@ -5,7 +5,7 @@ import { inspect } from "node:util";
import { cancel, isCancel } from "@clack/prompts";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/config.js";
import { resolveCanonicalConfigPath, resolveStateDir } from "../config/config.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
@ -335,7 +335,7 @@ export function resolveResetTargets(
const stateDir = resolveStateDir(env);
return {
stateDir,
configPath: resolveConfigPath(env, stateDir),
configPath: resolveCanonicalConfigPath(env, stateDir),
credentialsPath: path.join(stateDir, "credentials"),
sessionsDir: resolveSessionTranscriptsDirForAgent(agentId, env),
};