diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index f439d2bf897..4eee632693c 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -252,18 +252,12 @@ enum ExecApprovalsStore { private static func legacyStateDirURLs() -> [URL] { if let home = OpenClawEnv.path("OPENCLAW_HOME") { - var urls = [ + // OPENCLAW_HOME replaces the OS home; crossing back would mutate + // the real default profile during an isolated run. + return [ URL(fileURLWithPath: home, isDirectory: true) .appendingPathComponent(".openclaw", isDirectory: true), ] - let osHomeURL = FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(".openclaw", isDirectory: true) - if !urls.contains(where: { - $0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path - }) { - urls.append(osHomeURL) - } - return urls } return [ FileManager().homeDirectoryForCurrentUser @@ -273,6 +267,13 @@ enum ExecApprovalsStore { private static func legacyFileURLIfPending() -> URL? { guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil } + // Named profiles are isolated. Bare state-dir relocation keeps the + // shipped migration for users who moved their primary state root. + if let profile = OpenClawEnv.path("OPENCLAW_PROFILE"), + profile.lowercased() != "default" + { + return nil + } let targetURL = self.fileURL() for stateDirURL in self.legacyStateDirURLs() { let legacyURL = stateDirURL diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index ed899273e23..f5d90730954 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -60,6 +60,7 @@ struct ExecApprovalsStoreRefactorTests { try await self.withLockedEnv([ "OPENCLAW_HOME": home.path, + "OPENCLAW_PROFILE": nil, "OPENCLAW_STATE_DIR": stateDir.path, ]) { try await body(stateDir) @@ -67,6 +68,7 @@ struct ExecApprovalsStoreRefactorTests { } private func withTempHomeAndStateDir( + profile: String? = nil, _ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws { let root = self.realTemporaryDirectory @@ -77,6 +79,7 @@ struct ExecApprovalsStoreRefactorTests { try await self.withLockedEnv([ "OPENCLAW_HOME": home.path, + "OPENCLAW_PROFILE": profile, "OPENCLAW_STATE_DIR": stateDir.path, ]) { try await body(home, stateDir) @@ -141,6 +144,33 @@ struct ExecApprovalsStoreRefactorTests { } } + @Test + func `ensure file keeps named profile isolated from default approvals`() async throws { + try await self.withTempHomeAndStateDir(profile: "work") { home, stateDir in + let defaultDir = home.appendingPathComponent(".openclaw", isDirectory: true) + try FileManager().createDirectory(at: defaultDir, withIntermediateDirectories: true) + let defaultFile = defaultDir.appendingPathComponent("exec-approvals.json") + let defaultJson = """ + { + "version": 1, + "socket": { "token": "default-profile-token" }, + "defaults": { "security": "full", "ask": "off" }, + "agents": {} + } + """ + try Data(defaultJson.utf8).write(to: defaultFile) + let defaultBefore = try Data(contentsOf: defaultFile) + + let file = ExecApprovalsStore.ensureFile() + + #expect(file.socket?.token != "default-profile-token") + #expect(FileManager().fileExists( + atPath: stateDir.appendingPathComponent("exec-approvals.json").path)) + #expect(try Data(contentsOf: defaultFile) == defaultBefore) + #expect(!FileManager().fileExists(atPath: "\(defaultFile.path).migrated")) + } + } + @Test func `update allowlist accepts basename pattern`() async throws { try await self.withTempStateDir { _ in diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 17878a31ea5..5c3fe5cd4cc 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -101,6 +101,7 @@ describe("ensureConfigReady", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-guard-")); tempRoots.push(root); setTestEnvValue("OPENCLAW_HOME", root); + deleteTestEnvValue("OPENCLAW_PROFILE"); deleteTestEnvValue("OPENCLAW_STATE_DIR"); return root; } @@ -125,7 +126,7 @@ describe("ensureConfigReady", () => { } beforeEach(() => { - envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); + envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_PROFILE", "OPENCLAW_STATE_DIR"]); vi.clearAllMocks(); resetConfigGuardStateForTests(); for (const root of tempRoots.splice(0)) { @@ -266,6 +267,17 @@ describe("ensureConfigReady", () => { }); }); + it("does not route named profiles through default-profile approvals migration", async () => { + const root = useTempOpenClawHome(); + setTestEnvValue("OPENCLAW_PROFILE", "work"); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(root, ".openclaw-work")); + writeStateMarker(root, "exec-approvals.json"); + + await runEnsureConfigReady(["agent"]); + + expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled(); + }); + it.each([ ["Discord model picker preferences", "discord/model-picker-preferences.json"], ["Discord thread bindings", "discord/thread-bindings.json"], diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 8d332d27b3f..21a38f60887 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -4,7 +4,12 @@ import os from "node:os"; import path from "node:path"; import { withSuppressedNotes } from "../../../packages/terminal-core/src/note.js"; import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; -import { resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir } from "../../config/paths.js"; +import { + isNamedProfile, + resolveLegacyStateDirs, + resolveOAuthDir, + resolveStateDir, +} from "../../config/paths.js"; import type { ConfigFileSnapshot } from "../../config/types.js"; import { resolveRequiredHomeDir } from "../../infra/home-dir.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -99,7 +104,7 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir: } function hasLegacyExecApprovalsMigrationInput(stateDir: string): boolean { - if (!process.env.OPENCLAW_STATE_DIR?.trim()) { + if (!process.env.OPENCLAW_STATE_DIR?.trim() || isNamedProfile()) { return false; } const homeDir = resolveRequiredHomeDir(process.env, os.homedir); diff --git a/src/config/paths.ts b/src/config/paths.ts index c9f5d257874..1638976ae6c 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -25,6 +25,12 @@ const NEW_STATE_DIRNAME = ".openclaw"; const CONFIG_FILENAME = "openclaw.json"; const LEGACY_CONFIG_FILENAMES = ["clawdbot.json"] as const; +/** True when the root CLI selected a non-default isolated profile. */ +export function isNamedProfile(env: NodeJS.ProcessEnv = process.env): boolean { + const profile = env.OPENCLAW_PROFILE?.trim(); + return Boolean(profile && profile.toLowerCase() !== "default"); +} + function resolveDefaultHomeDir(): string { return resolveRequiredHomeDir(process.env, os.homedir); } diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 24c02719b95..75bb92562d6 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -34,7 +34,7 @@ let resolveExecApprovalsTranscriptPath: ExecApprovalsModule["resolveExecApproval let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"]; const tempDirs: string[] = []; -const testEnvSnapshot = captureEnv(["OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); +const testEnvSnapshot = captureEnv(["OPENCLAW_HOME", "OPENCLAW_PROFILE", "OPENCLAW_STATE_DIR"]); beforeAll(async () => { ({ @@ -74,6 +74,7 @@ function createHomeDir(): string { const dir = makeTempDir(); tempDirs.push(dir); setTestEnvValue("OPENCLAW_HOME", dir); + deleteTestEnvValue("OPENCLAW_PROFILE"); deleteTestEnvValue("OPENCLAW_STATE_DIR"); return dir; } @@ -197,6 +198,32 @@ describe("exec approvals store helpers", () => { expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false); }); + it("keeps named-profile approvals isolated from the default profile", () => { + const dir = createHomeDir(); + const stateDir = path.join(dir, ".openclaw-work"); + const defaultPath = approvalsFilePath(dir); + fs.mkdirSync(path.dirname(defaultPath), { recursive: true }); + fs.writeFileSync( + defaultPath, + `${JSON.stringify({ + version: 1, + socket: { token: "default-profile-token" }, + defaults: { security: "full", ask: "off" }, + agents: {}, + })}\n`, + "utf8", + ); + const defaultBefore = fs.readFileSync(defaultPath, "utf8"); + setTestEnvValue("OPENCLAW_PROFILE", "work"); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + + const ensured = ensureExecApprovals(); + + expect(ensured.socket?.token).not.toBe("default-profile-token"); + expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(true); + expect(fs.readFileSync(defaultPath, "utf8")).toBe(defaultBefore); + }); + it("keeps the default approvals path when only legacy state exists", () => { const dir = createHomeDir(); fs.mkdirSync(path.join(dir, ".clawdbot"), { recursive: true }); diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 0e4d5bb7b16..c792db7eb02 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -8,6 +8,7 @@ import { normalizeOptionalString, readStringValue, } from "@openclaw/normalization-core/string-coerce"; +import { isNamedProfile } from "../config/paths.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import type { CommandExplanationSummary } from "./command-analysis/explain.js"; import { sha256Hex, sha256HexPrefix } from "./crypto-digest.js"; @@ -350,7 +351,7 @@ function resolveLegacyExecApprovalsPath(): string { } function hasUnmigratedLegacyExecApprovals(filePath: string): boolean { - if (!process.env.OPENCLAW_STATE_DIR?.trim()) { + if (!process.env.OPENCLAW_STATE_DIR?.trim() || isNamedProfile()) { return false; } const legacyPath = resolveLegacyExecApprovalsPath(); diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index e587a1a6086..97b6a82a172 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -2360,6 +2360,46 @@ describe("state migrations", () => { await expectMissingPath(sourcePath); }); + it("does not import default-profile approvals into a named profile", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw-work"); + const env = { ...createEnv(stateDir), OPENCLAW_PROFILE: "work" }; + const cfg = createConfig(); + const defaultStateDir = path.join(root, ".openclaw"); + const execApprovalsPath = path.join(defaultStateDir, "exec-approvals.json"); + const pluginApprovalsPath = path.join(defaultStateDir, "plugin-binding-approvals.json"); + await fs.mkdir(defaultStateDir, { recursive: true }); + await fs.writeFile(execApprovalsPath, '{"version":1,"agents":{}}\n', "utf8"); + await fs.writeFile( + pluginApprovalsPath, + JSON.stringify({ + version: 1, + approvals: [ + { + pluginRoot: "/plugins/codex-a", + pluginId: "codex", + channel: "telegram", + accountId: "default", + approvedAt: 2345, + }, + ], + }), + "utf8", + ); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + + expect(detected.execApprovals.hasLegacy).toBe(false); + expect(detected.pluginBindingApprovals.hasLegacy).toBe(false); + const result = await runLegacyStateMigrations({ detected, config: cfg, env }); + expect(result.changes.some((change) => change.includes("exec approvals"))).toBe(false); + expect(result.changes.some((change) => change.includes("plugin binding approval"))).toBe(false); + await expect(fs.access(execApprovalsPath)).resolves.toBeUndefined(); + await expect(fs.access(pluginApprovalsPath)).resolves.toBeUndefined(); + await expectMissingPath(path.join(stateDir, "exec-approvals.json")); + expect(readPluginBindingApprovalRows(env)).toEqual([]); + }); + it("imports non-conflicting legacy current-conversation bindings when SQLite has a conflict", async () => { const root = await createTempDir(); const stateDir = path.join(root, ".openclaw"); diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index b8ffc76b6a1..fec7e5ba61b 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -12,6 +12,7 @@ import { } from "../channels/plugins/bundled.js"; import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import { + isNamedProfile, resolveLegacyStateDirs, resolveNewStateDir, resolveOAuthDir, @@ -349,6 +350,7 @@ function detectLegacyExecApprovalsMigration(params: { targetPath, hasLegacy: Boolean(params.env.OPENCLAW_STATE_DIR?.trim()) && + !isNamedProfile(params.env) && path.resolve(sourcePath) !== path.resolve(targetPath) && fileExists(sourcePath) && !fileExists(targetPath), @@ -2404,7 +2406,7 @@ function migrateLegacyPluginBindingApprovals(params: { }): { changes: string[]; warnings: string[] } { const changes: string[] = []; const warnings: string[] = []; - if (!fileExists(params.detected.sourcePath)) { + if (!params.detected.hasLegacy || !fileExists(params.detected.sourcePath)) { return { changes, warnings }; } let approvals: LegacyPluginBindingApprovalEntry[]; @@ -4305,7 +4307,8 @@ export async function detectLegacyStateMigrations(params: { const pluginBindingApprovals = { sourcePath: resolveLegacyPluginBindingApprovalsPath(env, homedir), }; - const hasPluginBindingApprovals = fileExists(pluginBindingApprovals.sourcePath); + const hasPluginBindingApprovals = + !isNamedProfile(env) && fileExists(pluginBindingApprovals.sourcePath); const currentConversationBindings = { sourcePath: resolveLegacyCurrentConversationBindingsPath(stateDir), };