fix(qqbot): isolate credential backups by state root

QQBot credential backups now resolve under the active OpenClaw state directory instead of the old home-global QQBot data path. This keeps isolated gateway profiles from restoring each other's QQBot appId/clientSecret backups while preserving per-state-root recovery.

Proof: focused QQBot path/storage-laziness Vitest suite passed on Node 24.15.0, focused oxlint passed, source-runtime two-root backup proof passed, exact-head CI run 26814565282 passed, and ClawSweeper re-review run 26815054980 marked proof sufficient.

Closes #84313.

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
This commit is contained in:
Coy Geek
2026-06-02 04:11:01 -07:00
committed by GitHub
parent 2c48dd2277
commit a14be505ff
3 changed files with 83 additions and 3 deletions

View File

@@ -0,0 +1,74 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "./data-paths.js";
const createdStateDirs: string[] = [];
function createTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
createdStateDirs.push(dir);
return dir;
}
describe("qqbot credential backup paths", () => {
afterEach(() => {
vi.unstubAllEnvs();
for (const stateDir of createdStateDirs.splice(0)) {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("scopes credential backups to the active OPENCLAW_STATE_DIR", () => {
const stateDir = createTempDir("qqbot-state-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
expect(getCredentialBackupFile("default")).toBe(
path.join(stateDir, "qqbot", "data", "credential-backup-default.json"),
);
expect(getLegacyCredentialBackupFile()).toBe(
path.join(stateDir, "qqbot", "data", "credential-backup.json"),
);
});
it("keeps same account IDs isolated across different state directories", () => {
const stateDirA = createTempDir("qqbot-state-a-");
const stateDirB = createTempDir("qqbot-state-b-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDirA);
const gatewayAPath = getCredentialBackupFile("default");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDirB);
const gatewayBPath = getCredentialBackupFile("default");
expect(gatewayAPath).toBe(
path.join(stateDirA, "qqbot", "data", "credential-backup-default.json"),
);
expect(gatewayBPath).toBe(
path.join(stateDirB, "qqbot", "data", "credential-backup-default.json"),
);
expect(gatewayBPath).not.toBe(gatewayAPath);
});
it("uses OPENCLAW_HOME for default credential backup state", () => {
const homeDir = createTempDir("qqbot-openclaw-home-");
vi.stubEnv("OPENCLAW_STATE_DIR", "");
vi.stubEnv("OPENCLAW_HOME", homeDir);
expect(getCredentialBackupFile("default")).toBe(
path.join(homeDir, ".openclaw", "qqbot", "data", "credential-backup-default.json"),
);
});
it("expands tilde state-dir overrides through the canonical state resolver", () => {
const homeDir = createTempDir("qqbot-home-");
vi.stubEnv("HOME", homeDir);
vi.stubEnv("OPENCLAW_HOME", "");
vi.stubEnv("OPENCLAW_STATE_DIR", "~/gateway-a");
expect(getCredentialBackupFile("default")).toBe(
path.join(homeDir, "gateway-a", "qqbot", "data", "credential-backup-default.json"),
);
});
});

View File

@@ -11,7 +11,7 @@
*/
import path from "node:path";
import { getQQBotDataPath } from "./platform.js";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
/**
* Normalise an identifier so it is safe to embed in a filename.
@@ -21,6 +21,10 @@ function safeName(id: string): string {
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
}
function getCredentialBackupRoot(): string {
return path.join(resolveStateDir(process.env), "qqbot", "data");
}
// ---- credential backup ----
/**
@@ -29,10 +33,10 @@ function safeName(id: string): string {
* missing from the live config.
*/
export function getCredentialBackupFile(accountId: string): string {
return path.join(getQQBotDataPath("data"), `credential-backup-${safeName(accountId)}.json`);
return path.join(getCredentialBackupRoot(), `credential-backup-${safeName(accountId)}.json`);
}
/** Legacy single-file credential backup (pre-multi-account-isolation). */
export function getLegacyCredentialBackupFile(): string {
return path.join(getQQBotDataPath("data"), "credential-backup.json");
return path.join(getCredentialBackupRoot(), "credential-backup.json");
}

View File

@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const createdHomes: string[] = [];
async function useMockHome(homeDir: string): Promise<void> {
vi.stubEnv("HOME", homeDir);
vi.resetModules();
vi.doMock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
@@ -26,6 +27,7 @@ function makeHome(): string {
describe("qqbot storage laziness", () => {
afterEach(() => {
vi.doUnmock("node:os");
vi.unstubAllEnvs();
vi.resetModules();
for (const home of createdHomes.splice(0)) {
fs.rmSync(home, { recursive: true, force: true });