diff --git a/docs/cli/index.md b/docs/cli/index.md index 780e6418a490..28ba5a547648 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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. diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 3a15d18866f6..862904295e86 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { OPENCLAW_HOME: "/srv/openclaw-home", diff --git a/src/cli/profile.ts b/src/cli/profile.ts index b354badcf4f3..309613c7cc3a 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -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"); } diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index 652c15c10f7f..ccda1cde616e 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -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) => {