Skip to content

Commit e24c3df

Browse files
fix(auth): verify SQLite auth migration before cleanup
Verify imported auth profiles and auth runtime state can be read back from the canonical SQLite store before doctor removes legacy JSON auth files. The migration now keeps legacy JSON in place with a warning when SQLite verification misses expected imported data, avoiding source cleanup before durable readback succeeds. Verification: - `scripts/pr review-tests 91740 src/commands/doctor-auth-flat-profiles.test.ts` - `./node_modules/.bin/oxfmt --check src/commands/doctor-auth-flat-profiles.ts src/commands/doctor-auth-flat-profiles.test.ts` - `./node_modules/.bin/oxlint src/commands/doctor-auth-flat-profiles.ts src/commands/doctor-auth-flat-profiles.test.ts` - `scripts/pr-prepare gates 91740` passed `pnpm build` and `pnpm check`; broad local `pnpm test` failed in unrelated baseline shards and was explicitly waived by maintainer. - Real behavior proof: https://github.com/openclaw/openclaw/actions/runs/27285927515/job/80593035530 Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: Radek Sienkiewicz <velvet-shark@users.noreply.github.com>
1 parent dbcae5b commit e24c3df

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

src/commands/doctor-auth-flat-profiles.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,39 @@ describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => {
409409
expect(fs.existsSync(authPath)).toBe(false);
410410
expect(fs.existsSync(`${authPath}.sqlite-import.458.bak`)).toBe(true);
411411
});
412+
413+
it("keeps legacy JSON when SQLite verification misses an imported profile", async () => {
414+
const state = await makeTestState();
415+
const authPath = await writeLegacyAuthProfilesJson(state, {
416+
version: 1,
417+
profiles: {
418+
"openrouter:default": {
419+
type: "api_key",
420+
provider: "openrouter",
421+
key: "sk-openrouter",
422+
},
423+
},
424+
});
425+
426+
const result = await maybeMigrateAuthProfileJsonStoresToSqlite({
427+
cfg: {},
428+
prompter: makePrompter(true),
429+
now: () => 464,
430+
deps: {
431+
loadPersistedAuthProfileStore: () => ({
432+
version: 1,
433+
profiles: {},
434+
}),
435+
},
436+
});
437+
438+
expect(result.changes).toStrictEqual([]);
439+
expect(result.warnings).toStrictEqual([
440+
`Left auth profile JSON in place for ${authPath} because SQLite verification did not find imported profile(s): openrouter:default.`,
441+
]);
442+
expect(fs.existsSync(authPath)).toBe(true);
443+
expect(fs.existsSync(`${authPath}.sqlite-import.464.bak`)).toBe(false);
444+
});
412445
});
413446

414447
describe("maybeRepairLegacyFlatAuthProfileStores", () => {

src/commands/doctor-auth-flat-profiles.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,46 @@ function mergeImportedAuthProfileState(params: {
354354
};
355355
}
356356

357+
function formatMissingAuthProfileSqliteVerification(params: {
358+
expected: AuthProfileStore;
359+
importedProfileIds: ReadonlySet<string>;
360+
loaded: AuthProfileStore | null;
361+
}): string | null {
362+
const missingProfileIds = [...params.importedProfileIds].filter(
363+
(profileId) => !params.loaded?.profiles[profileId],
364+
);
365+
const missingStateFields: string[] = [];
366+
for (const [provider, profileIds] of Object.entries(params.expected.order ?? {})) {
367+
const loadedProfileIds = params.loaded?.order?.[provider];
368+
if (
369+
!loadedProfileIds ||
370+
loadedProfileIds.length !== profileIds.length ||
371+
loadedProfileIds.some((profileId, index) => profileId !== profileIds[index])
372+
) {
373+
missingStateFields.push(`order.${provider}`);
374+
}
375+
}
376+
for (const [provider, profileId] of Object.entries(params.expected.lastGood ?? {})) {
377+
if (params.loaded?.lastGood?.[provider] !== profileId) {
378+
missingStateFields.push(`lastGood.${provider}`);
379+
}
380+
}
381+
for (const profileId of Object.keys(params.expected.usageStats ?? {})) {
382+
if (!params.loaded?.usageStats?.[profileId]) {
383+
missingStateFields.push(`usageStats.${profileId}`);
384+
}
385+
}
386+
387+
const parts: string[] = [];
388+
if (missingProfileIds.length > 0) {
389+
parts.push(`imported profile(s): ${missingProfileIds.toSorted().join(", ")}`);
390+
}
391+
if (missingStateFields.length > 0) {
392+
parts.push(`auth state field(s): ${missingStateFields.toSorted().join(", ")}`);
393+
}
394+
return parts.length > 0 ? parts.join("; ") : null;
395+
}
396+
357397
function filterRawAuthProfileState(
358398
raw: Record<string, unknown>,
359399
shouldKeepProfileId: (profileId: string) => boolean,
@@ -483,9 +523,14 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
483523
prompter: Pick<DoctorPrompter, "confirmAutoFix">;
484524
now?: () => number;
485525
env?: NodeJS.ProcessEnv;
526+
deps?: {
527+
loadPersistedAuthProfileStore?: typeof loadPersistedAuthProfileStore;
528+
};
486529
}): Promise<LegacyFlatAuthProfileRepairResult> {
487530
const now = params.now ?? Date.now;
488531
const env = params.env ?? process.env;
532+
const loadMigratedStore =
533+
params.deps?.loadPersistedAuthProfileStore ?? loadPersistedAuthProfileStore;
489534
const candidates = listAuthProfileSqliteMigrationCandidates(params.cfg, env);
490535
const detected = candidates.filter(
491536
(candidate) =>
@@ -582,23 +627,30 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
582627
continue;
583628
}
584629

585-
const existing = loadPersistedAuthProfileStore(candidate.agentDir) ?? {
630+
const existing = loadMigratedStore(candidate.agentDir) ?? {
586631
version: AUTH_STORE_VERSION,
587632
profiles: {},
588633
};
589634
const existingProfileIds = new Set(Object.keys(existing.profiles));
590635
const existingState = coerceAuthProfileState(existing);
591636
let next: AuthProfileStore = { ...existing };
637+
const importedProfileIds = new Set<string>();
592638
if (legacyStore) {
593639
const legacyAsStore: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
594640
applyLegacyAuthStore(legacyAsStore, legacyStore);
641+
for (const profileId of Object.keys(legacyAsStore.profiles)) {
642+
importedProfileIds.add(profileId);
643+
}
595644
next = mergeImportedAuthProfiles({
596645
store: next,
597646
profiles: legacyAsStore.profiles,
598647
existingProfileIds,
599648
});
600649
}
601650
if (canonicalStore) {
651+
for (const profileId of Object.keys(canonicalStore.profiles)) {
652+
importedProfileIds.add(profileId);
653+
}
602654
next = {
603655
...next,
604656
version: Math.max(next.version, canonicalStore.version),
@@ -631,6 +683,17 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
631683
preserveStateProfileIds: stateProfileIds,
632684
syncExternalCli: false,
633685
});
686+
const verificationFailure = formatMissingAuthProfileSqliteVerification({
687+
expected: next,
688+
importedProfileIds,
689+
loaded: loadMigratedStore(candidate.agentDir),
690+
});
691+
if (verificationFailure) {
692+
result.warnings.push(
693+
`Left auth profile JSON in place for ${shortenHomePath(candidate.authPath)} because SQLite verification did not find ${verificationFailure}.`,
694+
);
695+
continue;
696+
}
634697
}
635698

636699
const backups: string[] = [];

0 commit comments

Comments
 (0)