fix(cli): isolate explicit profiles from inherited service state (#114446)

This commit is contained in:
Peter Steinberger
2026-07-27 04:59:20 -04:00
committed by GitHub
parent 6b48140a53
commit 914bd1946a
4 changed files with 206 additions and 6 deletions
+4
View File
@@ -48,6 +48,10 @@ Setup commands by intent:
| `--update` | Shorthand for [`openclaw update`](/cli/update); works for both source checkouts and package installs |
| `-V`, `--version`, `-v` | Print version and exit |
A named `--profile` replaces canonical state and config paths inherited from
another profile, including a running Gateway service. Explicitly customized
state directories and config paths remain unchanged.
## Output modes
- ANSI colors and progress indicators render only in TTY sessions.
+122
View File
@@ -306,6 +306,128 @@ describe("applyCliProfileEnv", () => {
expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join("/custom", "openclaw.json"));
});
it.each([
{
name: "the default profile without a profile marker",
inheritedProfile: undefined,
inheritedStateDir: "/home/peter/.openclaw",
},
{
name: "the explicitly marked default profile",
inheritedProfile: "default",
inheritedStateDir: "/home/peter/.openclaw",
},
{
name: "another named profile",
inheritedProfile: "main",
inheritedStateDir: "/home/peter/.openclaw-main",
},
{
name: "a home-relative default state directory",
inheritedProfile: undefined,
inheritedStateDir: "~/.openclaw",
},
])(
"switches inherited canonical state from $name to the requested profile",
({ inheritedProfile, inheritedStateDir }) => {
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: inheritedProfile,
OPENCLAW_STATE_DIR: inheritedStateDir,
OPENCLAW_CONFIG_PATH: path.join(inheritedStateDir, "openclaw.json"),
};
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
const expectedStateDir = path.join(path.resolve("/home/peter"), ".openclaw-work");
expect(env.OPENCLAW_PROFILE).toBe("work");
expect(env.OPENCLAW_STATE_DIR).toBe(expectedStateDir);
expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join(expectedStateDir, "openclaw.json"));
},
);
it("preserves an explicit config outside inherited canonical profile state", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: "main",
OPENCLAW_STATE_DIR: "/home/peter/.openclaw-main",
OPENCLAW_CONFIG_PATH: "/srv/openclaw/custom.json",
};
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
expect(env.OPENCLAW_STATE_DIR).toBe("/home/peter/.openclaw-work");
expect(env.OPENCLAW_CONFIG_PATH).toBe("/srv/openclaw/custom.json");
});
it.each([
{ inheritedProfile: "Main", selectedProfile: "main" },
{ inheritedProfile: "main", selectedProfile: "Main" },
])(
"keeps case-distinct named profiles isolated ($inheritedProfile to $selectedProfile)",
({ inheritedProfile, selectedProfile }) => {
const inheritedStateDir = `/home/peter/.openclaw-${inheritedProfile}`;
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: inheritedProfile,
OPENCLAW_STATE_DIR: inheritedStateDir,
OPENCLAW_CONFIG_PATH: path.join(inheritedStateDir, "openclaw.json"),
};
applyCliProfileEnv({ profile: selectedProfile, env, homedir: () => "/home/peter" });
const expectedStateDir = `/home/peter/.openclaw-${selectedProfile}`;
expect(env.OPENCLAW_PROFILE).toBe(selectedProfile);
expect(env.OPENCLAW_STATE_DIR).toBe(expectedStateDir);
expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join(expectedStateDir, "openclaw.json"));
},
);
it("treats case variants of the default profile as the same canonical profile", () => {
const stateDir = "/home/peter/.openclaw";
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: "Default",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
};
applyCliProfileEnv({ profile: "default", env, homedir: () => "/home/peter" });
expect(env.OPENCLAW_PROFILE).toBe("default");
expect(env.OPENCLAW_STATE_DIR).toBe(stateDir);
expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join(stateDir, "openclaw.json"));
});
it.each([
{
name: "the default profile",
inheritedProfile: undefined,
inheritedConfigPath: "/home/peter/.openclaw/openclaw.json",
},
{
name: "another named profile",
inheritedProfile: "main",
inheritedConfigPath: "/home/peter/.openclaw-main/openclaw.json",
},
{
name: "a home-relative named profile",
inheritedProfile: "main",
inheritedConfigPath: "~/.openclaw-main/openclaw.json",
},
])(
"switches an inherited $name config when the state directory is absent",
({ inheritedProfile, inheritedConfigPath }) => {
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: inheritedProfile,
OPENCLAW_CONFIG_PATH: inheritedConfigPath,
};
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
const expectedStateDir = "/home/peter/.openclaw-work";
expect(env.OPENCLAW_PROFILE).toBe("work");
expect(env.OPENCLAW_STATE_DIR).toBe(expectedStateDir);
expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join(expectedStateDir, "openclaw.json"));
},
);
it("uses OPENCLAW_HOME when deriving profile state dir", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_HOME: "/srv/openclaw-home",
+31 -6
View File
@@ -5,7 +5,7 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
import { isValidProfileName } from "./profile-utils.js";
import { scanCliRootOptions } from "./root-option-scan.js";
@@ -91,16 +91,41 @@ export function applyCliProfileEnv(params: {
return;
}
// Convenience only: fill defaults, never override explicit env values.
const inheritedProfile = normalizeOptionalString(env.OPENCLAW_PROFILE) ?? "default";
const existingStateDir = normalizeOptionalString(env.OPENCLAW_STATE_DIR);
const existingConfigPath = normalizeOptionalString(env.OPENCLAW_CONFIG_PATH);
const inheritedProfileStateDir = resolveProfileStateDir(inheritedProfile, env, homedir);
const selectedProfileStateDir = resolveProfileStateDir(profile, env, homedir);
const switchesInheritedProfile = inheritedProfileStateDir !== selectedProfileStateDir;
const switchesInheritedProfileState = Boolean(
existingStateDir &&
switchesInheritedProfile &&
resolveHomeRelativePath(existingStateDir, {
env: env as NodeJS.ProcessEnv,
homedir,
}) === inheritedProfileStateDir,
);
const replacesInheritedProfileConfig = Boolean(
switchesInheritedProfile &&
(!existingStateDir || switchesInheritedProfileState) &&
existingConfigPath &&
resolveHomeRelativePath(existingConfigPath, {
env: env as NodeJS.ProcessEnv,
homedir,
}) === path.join(inheritedProfileStateDir, "openclaw.json"),
);
// A service's canonical profile paths are inherited defaults, not custom overrides.
// Switch them together so an explicit profile cannot mutate the service's profile.
env.OPENCLAW_PROFILE = profile;
const existingStateDir = normalizeOptionalString(env.OPENCLAW_STATE_DIR);
const stateDir = existingStateDir || resolveProfileStateDir(profile, env, homedir);
if (!existingStateDir) {
const stateDir =
existingStateDir && !switchesInheritedProfileState ? existingStateDir : selectedProfileStateDir;
if (!existingStateDir || switchesInheritedProfileState) {
env.OPENCLAW_STATE_DIR = stateDir;
}
if (!normalizeOptionalString(env.OPENCLAW_CONFIG_PATH)) {
if (!existingConfigPath || replacesInheritedProfileConfig) {
env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json");
}
+49
View File
@@ -28,6 +28,55 @@ function runSourceCli(tempHome: string, args: string[], envOverrides: NodeJS.Pro
}
describe("cli json stdout contract", () => {
it.each([
{ name: "default service", inheritedProfile: undefined, inheritedStateName: ".openclaw" },
{ name: "named service", inheritedProfile: "main", inheritedStateName: ".openclaw-main" },
])("resolves the requested profile from inherited $name state", async (inherited) => {
await withTempHome(
async (tempHome) => {
const inheritedStateDir = path.join(tempHome, inherited.inheritedStateName);
const result = runSourceCli(tempHome, ["--profile", "work", "config", "file"], {
OPENCLAW_PROFILE: inherited.inheritedProfile,
OPENCLAW_STATE_DIR: inheritedStateDir,
OPENCLAW_CONFIG_PATH: path.join(inheritedStateDir, "openclaw.json"),
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(path.join(tempHome, ".openclaw-work", "openclaw.json"));
},
{ prefix: "openclaw-profile-isolation-e2e-" },
);
});
it("keeps default-profile exec approvals untouched for a scratch-state config query", async () => {
await withTempHome(
async (tempHome) => {
const defaultStateDir = path.join(tempHome, ".openclaw");
const scratchStateDir = path.join(tempHome, "scratch-state");
const approvalsPath = path.join(defaultStateDir, "exec-approvals.json");
const approvals = '{"version":1,"approvals":{"demo":true}}\n';
await fs.mkdir(defaultStateDir, { recursive: true });
await fs.mkdir(scratchStateDir, { recursive: true });
await fs.writeFile(approvalsPath, approvals, "utf8");
const result = runSourceCli(tempHome, ["config", "file"], {
OPENCLAW_STATE_DIR: scratchStateDir,
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe(path.join(scratchStateDir, "openclaw.json"));
await expect(fs.readFile(approvalsPath, "utf8")).resolves.toBe(approvals);
await expect(fs.access(`${approvalsPath}.migrated`)).rejects.toMatchObject({
code: "ENOENT",
});
await expect(
fs.access(path.join(scratchStateDir, "exec-approvals.json")),
).rejects.toMatchObject({ code: "ENOENT" });
},
{ prefix: "openclaw-read-only-state-e2e-" },
);
});
it("keeps `update status --json` stdout parseable even with legacy doctor preflight inputs", async () => {
await withTempHome(
async (tempHome) => {