fix(doctor): gate cross-state-dir legacy imports behind explicit doctor opt-in (#103247)

* fix(doctor): gate cross-state-dir legacy imports behind explicit doctor opt-in

A gateway or CLI command started with OPENCLAW_STATE_DIR pointing at a
non-default directory used to import legacy files FROM the default
~/.openclaw state dir (exec-approvals.json, plugin-binding-approvals.json)
and archive the originals with a .migrated suffix. Any isolated, test, or
staging run on a host with production state silently captured and archived
the production files.

Cross-state-dir imports now run only with the new crossStateDirImports
opt-in: openclaw doctor --fix (or an interactive doctor confirm) performs
the import; the implicit CLI/gateway preflight leaves the default dir
untouched and emits a notice pointing at doctor instead.

* test(doctor): include notices in legacy-state detector mocks
This commit is contained in:
Peter Steinberger
2026-07-10 03:56:02 +01:00
committed by GitHub
parent 0fa49ad4dd
commit 2b3dc3042f
11 changed files with 243 additions and 43 deletions

View File

@@ -314,7 +314,9 @@ describe("ensureConfigReady", () => {
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("runs doctor flow before agent commands when default exec approvals must move to a custom state dir", async () => {
it("does not run doctor flow for default-state-dir exec approvals when a custom state dir is set", async () => {
// Cross-state-dir imports are doctor-owned; the implicit preflight must not
// trigger (and must never archive) files that belong to the default dir.
const root = useTempOpenClawHome();
const stateDir = path.join(root, "custom-state");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
@@ -322,12 +324,7 @@ describe("ensureConfigReady", () => {
await runEnsureConfigReady(["agent"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
});
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
});
it.each([

View File

@@ -98,20 +98,6 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir:
return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile);
}
function hasLegacyExecApprovalsMigrationInput(stateDir: string): boolean {
if (!process.env.OPENCLAW_STATE_DIR?.trim()) {
return false;
}
const homeDir = resolveRequiredHomeDir(process.env, os.homedir);
const sourcePath = path.join(homeDir, ".openclaw", "exec-approvals.json");
const targetPath = path.join(stateDir, "exec-approvals.json");
return (
path.resolve(sourcePath) !== path.resolve(targetPath) &&
fileOrDirExists(sourcePath) &&
!fileOrDirExists(targetPath)
);
}
function hasPendingSqliteSidecarArchive(sourcePath: string): boolean {
return (
fileOrDirExists(`${sourcePath}.migrated`) &&
@@ -147,8 +133,7 @@ function hasLegacyStateMigrationInputs(): boolean {
sqliteSidecarPaths.some(
(sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath),
) ||
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) ||
hasLegacyExecApprovalsMigrationInput(stateDir)
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir)
);
}