mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cli): isolate explicit profiles from inherited service state (#114446)
This commit is contained in:
committed by
GitHub
parent
6b48140a53
commit
914bd1946a
@@ -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.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user