Skip to content

Commit e375f37

Browse files
committed
fix: harden qqbot doctor state migration
1 parent 66b1ee1 commit e375f37

10 files changed

Lines changed: 419 additions & 202 deletions

docs/refactor/database-first.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,8 +524,8 @@ The branch already has a real shared SQLite base:
524524
shape into SQLite before normal runtime use.
525525
- QQBot credential recovery snapshots now live in SQLite plugin state under
526526
`qqbot/credential-backups`. Runtime no longer writes
527-
`qqbot/data/credential-backup*.json`; doctor imports and removes those
528-
legacy backup files with the other QQBot state inputs.
527+
`qqbot/data/credential-backup*.json`; the QQBot doctor contract imports and
528+
archives those legacy backup files from the active state directory.
529529
- Gateway reload planning compares SQLite installed-plugin index snapshots under
530530
an internal `installedPluginIndex.installRecords.*` diff namespace. Runtime
531531
reload decisions no longer wrap those rows in fake `plugins.installs` config
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
2+
export { stateMigrations } from "./src/state-migrations.js";

extensions/qqbot/legacy-state-migrations-api.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

extensions/qqbot/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@
3333
"./index.ts"
3434
],
3535
"setupEntry": "./setup-entry.ts",
36-
"setupFeatures": {
37-
"legacyStateMigrations": true
38-
},
3936
"channel": {
4037
"id": "qqbot",
4138
"label": "QQ Bot",

extensions/qqbot/setup-entry.test.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

extensions/qqbot/setup-entry.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,10 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
33

44
export default defineBundledChannelSetupEntry({
55
importMetaUrl: import.meta.url,
6-
features: {
7-
legacyStateMigrations: true,
8-
},
96
plugin: {
107
specifier: "./setup-plugin-api.js",
118
exportName: "qqbotSetupPlugin",
129
},
13-
legacyStateMigrations: {
14-
specifier: "./legacy-state-migrations-api.js",
15-
exportName: "detectQQBotLegacyStateMigrations",
16-
},
1710
secrets: {
1811
specifier: "./secret-contract-api.js",
1912
exportName: "channelSecrets",
Lines changed: 202 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,255 @@
1-
import fs from "node:fs";
1+
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4-
import { afterEach, describe, expect, it } from "vitest";
5-
import { detectQQBotLegacyStateMigrations } from "./state-migrations.js";
4+
import {
5+
createPluginStateKeyedStoreForTests,
6+
resetPluginStateStoreForTests,
7+
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
8+
import type {
9+
OpenKeyedStoreOptions,
10+
PluginDoctorStateMigrationContext,
11+
PluginStateKeyedStore,
12+
} from "openclaw/plugin-sdk/runtime-doctor";
13+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
14+
import { stateMigrations } from "../doctor-contract-api.js";
15+
import { buildQQBotStateKey } from "./engine/utils/state-keys.js";
16+
17+
type CredentialBackup = {
18+
accountId: string;
19+
appId: string;
20+
clientSecret: string;
21+
savedAt: string;
22+
};
623

724
const createdDirs: string[] = [];
825

9-
function createTempDir(prefix: string): string {
10-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
26+
async function createTempDir(prefix: string): Promise<string> {
27+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
1128
createdDirs.push(dir);
1229
return dir;
1330
}
1431

15-
function writeJson(filePath: string, value: unknown): void {
16-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
17-
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
32+
async function writeJson(filePath: string, value: unknown): Promise<void> {
33+
await fs.mkdir(path.dirname(filePath), { recursive: true });
34+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
35+
}
36+
37+
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
38+
return {
39+
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
40+
return createPluginStateKeyedStoreForTests<T>("qqbot", {
41+
...options,
42+
env: options.env ?? env,
43+
});
44+
},
45+
};
46+
}
47+
48+
function createEvictingDoctorContext(params: {
49+
values: Map<string, CredentialBackup>;
50+
evictedKey: string;
51+
}): PluginDoctorStateMigrationContext {
52+
let shouldEvict = true;
53+
const store: PluginStateKeyedStore<CredentialBackup> = {
54+
async register(key, value) {
55+
params.values.set(key, value);
56+
if (shouldEvict) {
57+
shouldEvict = false;
58+
params.values.delete(params.evictedKey);
59+
}
60+
},
61+
async registerIfAbsent(key, value) {
62+
if (params.values.has(key)) {
63+
return false;
64+
}
65+
await store.register(key, value);
66+
return true;
67+
},
68+
async lookup(key) {
69+
return params.values.get(key);
70+
},
71+
async consume(key) {
72+
const value = params.values.get(key);
73+
params.values.delete(key);
74+
return value;
75+
},
76+
async delete(key) {
77+
return params.values.delete(key);
78+
},
79+
async entries() {
80+
return [...params.values].map(([key, value]) => ({ key, value, createdAt: 0 }));
81+
},
82+
async clear() {
83+
params.values.clear();
84+
},
85+
};
86+
return {
87+
openPluginStateKeyedStore<T>() {
88+
return store as unknown as PluginStateKeyedStore<T>;
89+
},
90+
};
1891
}
1992

20-
describe("qqbot state migrations", () => {
21-
afterEach(() => {
93+
describe("qqbot doctor state migration", () => {
94+
let stateDir = "";
95+
let env: NodeJS.ProcessEnv;
96+
97+
beforeEach(async () => {
98+
resetPluginStateStoreForTests();
99+
stateDir = await createTempDir("qqbot-state-");
100+
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
101+
});
102+
103+
afterEach(async () => {
104+
resetPluginStateStoreForTests();
22105
for (const dir of createdDirs.splice(0)) {
23-
fs.rmSync(dir, { recursive: true, force: true });
106+
await fs.rm(dir, { recursive: true, force: true });
24107
}
25108
});
26109

27-
it("detects credential backups as doctor-owned plugin-state imports", async () => {
28-
const stateDir = createTempDir("qqbot-state-");
110+
function migrationParams() {
111+
return {
112+
config: {},
113+
env,
114+
stateDir,
115+
oauthDir: path.join(stateDir, "oauth"),
116+
context: createDoctorContext(env),
117+
};
118+
}
119+
120+
it("imports an active-state credential backup and archives the source", async () => {
29121
const sourcePath = path.join(stateDir, "qqbot", "data", "credential-backup-default.json");
30-
writeJson(sourcePath, {
122+
const backup: CredentialBackup = {
31123
accountId: "default",
32124
appId: "app-1",
33125
clientSecret: "secret-1",
34126
savedAt: "2026-06-02T00:00:00.000Z",
35-
});
127+
};
128+
await writeJson(sourcePath, backup);
36129

37-
const plans = detectQQBotLegacyStateMigrations({ env: {}, stateDir });
38-
39-
expect(plans).toHaveLength(1);
40-
const [plan] = plans;
41-
expect(plan).toMatchObject({
42-
kind: "plugin-state-import",
43-
label: "QQBot credential backup",
44-
sourcePath,
45-
pluginId: "qqbot",
46-
namespace: "credential-backups",
47-
cleanupSource: "rename",
130+
const migration = stateMigrations[0];
131+
await expect(migration.detectLegacyState(migrationParams())).resolves.toMatchObject({
132+
preview: [expect.stringContaining("QQBot credential backups: 1 file")],
48133
});
49-
if (plan?.kind !== "plugin-state-import") {
50-
throw new Error("expected plugin-state-import plan");
51-
}
52-
const [entry] = await plan.readEntries();
53-
expect(entry).toMatchObject({
54-
key: expect.stringMatching(/^[a-f0-9]{64}$/),
55-
value: {
56-
accountId: "default",
57-
appId: "app-1",
58-
clientSecret: "secret-1",
59-
savedAt: "2026-06-02T00:00:00.000Z",
60-
},
134+
await expect(migration.migrateLegacyState(migrationParams())).resolves.toEqual({
135+
changes: [
136+
"Migrated 1 QQBot credential backup -> plugin state",
137+
expect.stringContaining("Archived QQBot credential backup legacy source"),
138+
],
139+
warnings: [],
61140
});
141+
142+
await expect(fs.access(sourcePath)).rejects.toThrow();
143+
await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined();
144+
if (process.platform !== "win32") {
145+
expect((await fs.stat(`${sourcePath}.migrated`)).mode & 0o777).toBe(0o600);
146+
}
147+
await expect(
148+
createDoctorContext(env)
149+
.openPluginStateKeyedStore<CredentialBackup>({
150+
namespace: "credential-backups",
151+
maxEntries: 1000,
152+
})
153+
.lookup(buildQQBotStateKey("credential-backup", "default")),
154+
).resolves.toEqual(backup);
62155
});
63156

64-
it("keeps per-account credential backups ahead of legacy single-file backups", async () => {
65-
const stateDir = createTempDir("qqbot-state-");
157+
it("prefers per-account backups over the legacy singleton", async () => {
66158
const dataDir = path.join(stateDir, "qqbot", "data");
67-
writeJson(path.join(dataDir, "credential-backup.json"), {
159+
const singlePath = path.join(dataDir, "credential-backup.json");
160+
const accountPath = path.join(dataDir, "credential-backup-default.json");
161+
await writeJson(singlePath, {
68162
accountId: "default",
69163
appId: "stale-app",
70164
clientSecret: "stale-secret",
71165
savedAt: "2026-06-01T00:00:00.000Z",
72166
});
73-
writeJson(path.join(dataDir, "credential-backup-default.json"), {
167+
await writeJson(accountPath, {
74168
accountId: "default",
75169
appId: "current-app",
76170
clientSecret: "current-secret",
77171
savedAt: "2026-06-02T00:00:00.000Z",
78172
});
79173

80-
const plans = detectQQBotLegacyStateMigrations({ env: {}, stateDir });
174+
const result = await stateMigrations[0].migrateLegacyState(migrationParams());
81175

82-
expect(plans).toHaveLength(2);
83-
const [firstPlan] = plans;
84-
if (firstPlan?.kind !== "plugin-state-import") {
85-
throw new Error("expected plugin-state-import plan");
86-
}
87-
const [entry] = await firstPlan.readEntries();
88-
expect(entry?.value).toMatchObject({
89-
accountId: "default",
90-
appId: "current-app",
91-
clientSecret: "current-secret",
92-
});
176+
expect(result.warnings).toEqual([]);
177+
await expect(
178+
createDoctorContext(env)
179+
.openPluginStateKeyedStore<CredentialBackup>({
180+
namespace: "credential-backups",
181+
maxEntries: 1000,
182+
})
183+
.lookup(buildQQBotStateKey("credential-backup", "default")),
184+
).resolves.toMatchObject({ appId: "current-app", clientSecret: "current-secret" });
185+
await expect(fs.access(`${singlePath}.migrated`)).resolves.toBeUndefined();
186+
await expect(fs.access(`${accountPath}.migrated`)).resolves.toBeUndefined();
93187
});
94188

95-
it("does not trust mismatched per-account backup filenames", () => {
96-
const stateDir = createTempDir("qqbot-state-");
97-
const dataDir = path.join(stateDir, "qqbot", "data");
98-
writeJson(path.join(dataDir, "credential-backup-other.json"), {
189+
it("ignores mismatched per-account backup filenames", async () => {
190+
await writeJson(path.join(stateDir, "qqbot", "data", "credential-backup-other.json"), {
99191
accountId: "default",
100192
appId: "wrong-app",
101193
clientSecret: "wrong-secret",
102194
savedAt: "2026-06-02T00:00:00.000Z",
103195
});
104196

105-
expect(detectQQBotLegacyStateMigrations({ env: {}, stateDir })).toEqual([]);
197+
await expect(stateMigrations[0].detectLegacyState(migrationParams())).resolves.toBeNull();
198+
});
199+
200+
it("does not scan credential backups outside the active state directory", async () => {
201+
const homeDir = await createTempDir("qqbot-home-");
202+
env.HOME = homeDir;
203+
await writeJson(
204+
path.join(homeDir, ".openclaw", "qqbot", "data", "credential-backup-default.json"),
205+
{
206+
accountId: "default",
207+
appId: "other-state-app",
208+
clientSecret: "other-state-secret",
209+
savedAt: "2026-06-02T00:00:00.000Z",
210+
},
211+
);
212+
213+
await expect(stateMigrations[0].detectLegacyState(migrationParams())).resolves.toBeNull();
214+
});
215+
216+
it("restores credential state and preserves sources when plugin capacity evicts a row", async () => {
217+
const sourcePath = path.join(stateDir, "qqbot", "data", "credential-backup-new.json");
218+
await writeJson(sourcePath, {
219+
accountId: "new",
220+
appId: "new-app",
221+
clientSecret: "new-secret",
222+
savedAt: "2026-06-02T00:00:00.000Z",
223+
});
224+
const existingKey = buildQQBotStateKey("credential-backup", "existing");
225+
const incomingKey = buildQQBotStateKey("credential-backup", "new");
226+
const existingBackup: CredentialBackup = {
227+
accountId: "existing",
228+
appId: "existing-app",
229+
clientSecret: "existing-secret",
230+
savedAt: "2026-06-01T00:00:00.000Z",
231+
};
232+
const values = new Map([[existingKey, existingBackup]]);
233+
const params = migrationParams();
234+
params.context = createEvictingDoctorContext({ values, evictedKey: existingKey });
235+
236+
const result = await stateMigrations[0].migrateLegacyState(params);
237+
238+
expect(result.changes).toEqual([]);
239+
expect(result.warnings).toEqual([expect.stringContaining("plugin state capacity evicted")]);
240+
expect(values).toEqual(new Map([[existingKey, existingBackup]]));
241+
expect(values.has(incomingKey)).toBe(false);
242+
await expect(fs.access(sourcePath)).resolves.toBeUndefined();
243+
await expect(fs.access(`${sourcePath}.migrated`)).rejects.toThrow();
106244
});
107245

108-
it("does not migrate QQBot runtime caches", () => {
109-
const stateDir = createTempDir("qqbot-state-");
110-
const homeDir = createTempDir("qqbot-home-");
111-
writeJson(path.join(homeDir, ".openclaw", "qqbot", "sessions", "session-default.json"), {
246+
it("does not migrate QQBot runtime caches", async () => {
247+
await writeJson(path.join(stateDir, "qqbot", "sessions", "session-default.json"), {
112248
sessionId: "session-1",
113249
});
114-
writeJson(path.join(homeDir, ".openclaw", "qqbot", "data", "known-users.json"), []);
115-
fs.mkdirSync(path.join(homeDir, ".openclaw", "qqbot", "data"), { recursive: true });
116-
fs.writeFileSync(
117-
path.join(homeDir, ".openclaw", "qqbot", "data", "ref-index.jsonl"),
118-
`${JSON.stringify({ k: "ref-1", v: {}, t: Date.now() })}\n`,
119-
);
250+
await writeJson(path.join(stateDir, "qqbot", "data", "known-users.json"), []);
251+
await fs.writeFile(path.join(stateDir, "qqbot", "data", "ref-index.jsonl"), "{}\n");
120252

121-
expect(detectQQBotLegacyStateMigrations({ env: { HOME: homeDir }, stateDir })).toEqual([]);
253+
await expect(stateMigrations[0].detectLegacyState(migrationParams())).resolves.toBeNull();
122254
});
123255
});

0 commit comments

Comments
 (0)