mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(profiles): isolate approval state migrations
This commit is contained in:
parent
01918c84ad
commit
239d92627e
9 changed files with 141 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue