fix(profiles): isolate approval state migrations

This commit is contained in:
Peter Steinberger 2026-07-09 10:11:32 -04:00
parent 01918c84ad
commit 239d92627e
9 changed files with 141 additions and 16 deletions

View file

@ -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

View file

@ -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

View file

@ -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"],

View file

@ -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);

View file

@ -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);
}

View file

@ -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 });

View file

@ -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();

View file

@ -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");

View file

@ -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),
};