Gateway: harden dev reset safety checks

This commit is contained in:
Tristan Manchester 2026-03-02 20:31:12 +01:00
parent 0a30503239
commit 6fc6fd0add
2 changed files with 101 additions and 7 deletions

View File

@ -1,3 +1,5 @@
import fs from "node:fs";
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";
@ -362,4 +364,55 @@ describe("gateway run option collisions", () => {
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");
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/test/.openclaw-work");
vi.stubEnv("OPENCLAW_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("treats symlinked default paths as default reset targets", async () => {
const home = "/Users/test";
const defaultStateDir = path.join(home, ".openclaw");
const defaultConfigPath = path.join(defaultStateDir, "openclaw.json");
const aliasStateDir = path.join(home, ".openclaw-alias");
const aliasConfigPath = path.join(aliasStateDir, "openclaw.json");
const realpathSpy = vi.spyOn(fs, "realpathSync").mockImplementation((candidate) => {
const resolved = path.resolve(String(candidate));
if (resolved === path.resolve(aliasStateDir)) {
return path.resolve(defaultStateDir);
}
if (resolved === path.resolve(aliasConfigPath)) {
return path.resolve(defaultConfigPath);
}
return resolved;
});
vi.stubEnv("HOME", home);
vi.stubEnv("OPENCLAW_STATE_DIR", aliasStateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", aliasConfigPath);
resolveStateDir.mockReturnValue(aliasStateDir);
resolveConfigPath.mockReturnValue(aliasConfigPath);
try {
await expectGatewayExit(["gateway", "run", "--dev", "--reset"]);
} finally {
realpathSpy.mockRestore();
}
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"Refusing to run `gateway --dev --reset` because the reset target is not dev-isolated.",
);
});
});

View File

@ -183,6 +183,31 @@ function resolveDevResetPaths(env: NodeJS.ProcessEnv = process.env): {
};
}
function canonicalizePathForCompare(rawPath: string): string {
const resolvedPath = path.resolve(rawPath);
try {
return fs.realpathSync(resolvedPath);
} catch {
return resolvedPath;
}
}
function resolveProfileDefaultPaths(
profile: string,
env: NodeJS.ProcessEnv = process.env,
): {
stateDir: string;
configPath: string;
} {
const home = resolveRequiredHomeDir(env, os.homedir);
const suffix = profile.toLowerCase() === "default" ? "" : `-${profile}`;
const stateDir = path.join(home, `.openclaw${suffix}`);
return {
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
};
}
async function runGatewayCommand(opts: GatewayRunOpts) {
const envProfile = process.env.OPENCLAW_PROFILE?.trim();
if (envProfile && !isValidProfileName(envProfile)) {
@ -203,16 +228,32 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
if (opts.reset && devMode) {
const paths = resolveDevResetPaths(process.env);
const resolvedStateDir = path.resolve(paths.stateDir);
const resolvedConfigPath = path.resolve(paths.configPath);
const stateIsDefault = resolvedStateDir === path.resolve(paths.defaultStateDir);
const configIsDefault = resolvedConfigPath === path.resolve(paths.defaultConfigPath);
const stateMatchesDev = resolvedStateDir === path.resolve(paths.expectedDevStateDir);
const configMatchesDev = resolvedConfigPath === path.resolve(paths.expectedDevConfigPath);
const resolvedStateDir = canonicalizePathForCompare(paths.stateDir);
const resolvedConfigPath = canonicalizePathForCompare(paths.configPath);
const stateIsDefault = resolvedStateDir === canonicalizePathForCompare(paths.defaultStateDir);
const configIsDefault =
resolvedConfigPath === canonicalizePathForCompare(paths.defaultConfigPath);
const stateMatchesDev =
resolvedStateDir === canonicalizePathForCompare(paths.expectedDevStateDir);
const configMatchesDev =
resolvedConfigPath === canonicalizePathForCompare(paths.expectedDevConfigPath);
const profileDefaultPaths = envProfile
? resolveProfileDefaultPaths(envProfile, process.env)
: null;
const stateMatchesProfileDefault = profileDefaultPaths
? resolvedStateDir === canonicalizePathForCompare(profileDefaultPaths.stateDir)
: false;
const configMatchesProfileDefault = profileDefaultPaths
? resolvedConfigPath === canonicalizePathForCompare(profileDefaultPaths.configPath)
: false;
const targetMatchesProfileDefaults = stateMatchesProfileDefault && configMatchesProfileDefault;
const hasStateOverride = Boolean(process.env.OPENCLAW_STATE_DIR?.trim());
const hasConfigOverride = Boolean(process.env.OPENCLAW_CONFIG_PATH?.trim());
const hasExplicitCustomTarget =
(hasStateOverride || hasConfigOverride) && !stateIsDefault && !configIsDefault;
(hasStateOverride || hasConfigOverride) &&
!stateIsDefault &&
!configIsDefault &&
!targetMatchesProfileDefaults;
if (!hasExplicitCustomTarget && (!stateMatchesDev || !configMatchesDev)) {
defaultRuntime.error(