refactor: move legacy auth profile imports to doctor

This commit is contained in:
Peter Steinberger
2026-05-09 22:09:01 +01:00
parent 7f903c2fdd
commit 1cdddab014
14 changed files with 300 additions and 218 deletions

View File

@@ -83,6 +83,10 @@ proceed with these assumptions:
- `openclaw doctor --fix` owns the legacy file-to-database migration step.
Runtime startup and `openclaw migrate` should not carry legacy OpenClaw
database-upgrade paths.
- Credential compatibility follows the same rule even though credentials stay
file-backed: runtime reads canonical `auth-profiles.json` only. Retired
per-agent `auth.json` and shared `credentials/oauth.json` files are doctor
migration inputs, then removed after import.
- Runtime must not migrate, normalize, or bridge transcript locators. Active
transcript identity is `{agentId, sessionId}` in SQLite. File paths are
legacy doctor inputs only, and `sqlite-transcript://...` must disappear from
@@ -627,6 +631,10 @@ sessionId}` and session key context.
- Config health fingerprints now use shared SQLite KV instead of
`logs/config-health.json`, keeping the normal config file as the only
non-credential configuration document.
- Auth profile runtime no longer imports retired credential JSON files. The
canonical credential file remains `auth-profiles.json`; per-agent `auth.json`
and shared `credentials/oauth.json` are doctor migration inputs that are
removed after import.
- Voice Wake trigger and routing settings now use shared SQLite KV instead of
`settings/voicewake.json` and `settings/voicewake-routing.json`; doctor imports
the legacy JSON files and removes them after a successful migration.

View File

@@ -136,7 +136,9 @@ This plan has started landing in slices:
- Auth profile runtime routing state now uses the shared SQLite `kv` store as
the primary record path. Older per-agent `auth-state.json` files are imported
and removed by `openclaw doctor --fix`; `auth-profiles.json` still owns
credentials and stays file-backed.
credentials and stays file-backed. Retired per-agent `auth.json` and shared
`credentials/oauth.json` credential files are doctor migration inputs only;
runtime no longer imports them.
- Device identity, local device auth tokens, bootstrap tokens, device/node
pairing ledgers, channel pairing requests/allowlists, inferred commitment
records, subagent run records, TUI restore pointers, auth routing state,
@@ -564,8 +566,9 @@ Add tests before each migration step:
imported and removed only by doctor.
- TUI last-session restore pointers read from SQLite without JSON exports,
import legacy JSON only through doctor, and clear stale pointers from SQLite.
- Auth profile runtime state reads from SQLite, imports legacy JSON only through
doctor, and deletes SQLite state when runtime state is empty.
- Auth profile runtime state reads from SQLite, imports legacy state/credential
JSON only through doctor, and deletes SQLite state when runtime state is
empty.
## Rollout Plan

View File

@@ -152,7 +152,7 @@ describe("ensureAuthProfileStore", () => {
return profile;
}
it("migrates legacy auth.json and deletes it (PR #368)", () => {
it("does not import legacy auth.json at runtime", () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profiles-"));
try {
const legacyPath = path.join(agentDir, "auth.json");
@@ -175,19 +175,9 @@ describe("ensureAuthProfileStore", () => {
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toMatchObject({
type: "oauth",
provider: "anthropic",
});
const migratedPath = path.join(agentDir, "auth-profiles.json");
expect(fs.existsSync(migratedPath)).toBe(true);
expect(fs.existsSync(legacyPath)).toBe(false);
// idempotent
const store2 = ensureAuthProfileStore(agentDir);
expect(store2.profiles).toHaveProperty("anthropic:default");
expect(fs.existsSync(legacyPath)).toBe(false);
expect(store.profiles["anthropic:default"]).toBeUndefined();
expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false);
expect(fs.existsSync(legacyPath)).toBe(true);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
@@ -714,10 +704,11 @@ describe("ensureAuthProfileStore", () => {
},
);
it("normalizes mode/apiKey aliases while migrating legacy auth.json", () => {
it("does not normalize legacy auth.json aliases at runtime", () => {
withTempAgentDir("openclaw-auth-legacy-alias-", (agentDir) => {
const legacyPath = path.join(agentDir, "auth.json");
fs.writeFileSync(
path.join(agentDir, "auth.json"),
legacyPath,
`${JSON.stringify(
{
anthropic: {
@@ -733,11 +724,8 @@ describe("ensureAuthProfileStore", () => {
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toMatchObject({
type: "api_key",
provider: "anthropic",
key: "sk-ant-legacy",
});
expect(store.profiles["anthropic:default"]).toBeUndefined();
expect(fs.existsSync(legacyPath)).toBe(true);
});
});
@@ -762,7 +750,7 @@ describe("ensureAuthProfileStore", () => {
}
});
it("merges legacy oauth.json into auth-profiles.json", () => {
it("does not import legacy oauth.json at runtime", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-migrate-"));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
@@ -795,24 +783,9 @@ describe("ensureAuthProfileStore", () => {
clearRuntimeAuthProfileStoreSnapshots();
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
});
const persisted = JSON.parse(
fs.readFileSync(path.join(agentDir, "auth-profiles.json"), "utf8"),
) as {
profiles: Record<string, unknown>;
};
expect(persisted.profiles["openai-codex:default"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
});
expect(store.profiles["openai-codex:default"]).toBeUndefined();
expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false);
expect(fs.existsSync(path.join(oauthDir, "oauth.json"))).toBe(true);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
restoreEnvValue("OPENCLAW_STATE_DIR", previousStateDir);

View File

@@ -1,9 +1,5 @@
import { createSubsystemLogger } from "../../logging/subsystem.js";
export {
AUTH_PROFILE_FILENAME,
AUTH_STATE_FILENAME,
LEGACY_AUTH_FILENAME,
} from "./path-constants.js";
export { AUTH_PROFILE_FILENAME, AUTH_STATE_FILENAME } from "./path-constants.js";
export const AUTH_STORE_VERSION = 1;

View File

@@ -1,3 +1,2 @@
export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
export const AUTH_STATE_FILENAME = "auth-state.json";
export const LEGACY_AUTH_FILENAME = "auth.json";

View File

@@ -3,22 +3,13 @@ import path from "node:path";
import { resolveStateDir } from "../../config/paths.js";
import { resolveUserPath } from "../../utils.js";
import { resolveDefaultAgentDir } from "../agent-scope-config.js";
import {
AUTH_PROFILE_FILENAME,
AUTH_STATE_FILENAME,
LEGACY_AUTH_FILENAME,
} from "./path-constants.js";
import { AUTH_PROFILE_FILENAME, AUTH_STATE_FILENAME } from "./path-constants.js";
export function resolveAuthStorePath(agentDir?: string): string {
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
return path.join(resolved, AUTH_PROFILE_FILENAME);
}
export function resolveLegacyAuthStorePath(agentDir?: string): string {
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
return path.join(resolved, LEGACY_AUTH_FILENAME);
}
export function resolveAuthStatePath(agentDir?: string): string {
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
return path.join(resolved, AUTH_STATE_FILENAME);

View File

@@ -7,7 +7,6 @@ export {
resolveAuthStatePathForDisplay,
resolveAuthStorePath,
resolveAuthStorePathForDisplay,
resolveLegacyAuthStorePath,
resolveOAuthRefreshLockPath,
} from "./path-resolve.js";

View File

@@ -1,4 +1,3 @@
import { resolveOAuthPath } from "../../config/paths.js";
import { coerceSecretRef } from "../../config/types.secrets.js";
import { loadJsonFile } from "../../infra/json-file.js";
import { normalizeProviderId } from "../provider-id.js";
@@ -10,7 +9,7 @@ import {
normalizeAuthEmailToken,
normalizeAuthIdentityToken,
} from "./oauth-shared.js";
import { resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import { resolveAuthStorePath } from "./paths.js";
import {
coerceAuthProfileState,
loadPersistedAuthProfileState,
@@ -22,12 +21,9 @@ import type {
AuthProfileSecretsStore,
AuthProfileStore,
OAuthCredential,
OAuthCredentials,
ProfileUsageStats,
} from "./types.js";
export type LegacyAuthStore = Record<string, AuthProfileCredential>;
type CredentialRejectReason = "non_object" | "invalid_type" | "missing_provider";
type RejectedCredentialEntry = { key: string; reason: CredentialRejectReason };
@@ -105,28 +101,6 @@ function warnRejectedCredentialEntries(source: string, rejected: RejectedCredent
});
}
function coerceLegacyAuthStore(raw: unknown): LegacyAuthStore | null {
if (!raw || typeof raw !== "object") {
return null;
}
const record = raw as Record<string, unknown>;
if ("profiles" in record) {
return null;
}
const entries: LegacyAuthStore = {};
const rejected: RejectedCredentialEntry[] = [];
for (const [key, value] of Object.entries(record)) {
const parsed = parseCredentialEntry(value, key);
if (!parsed.ok) {
rejected.push({ key, reason: parsed.reason });
continue;
}
entries[key] = parsed.credential;
}
warnRejectedCredentialEntries("auth.json", rejected);
return Object.keys(entries).length > 0 ? entries : null;
}
export function coercePersistedAuthProfileStore(raw: unknown): AuthProfileStore | null {
if (!raw || typeof raw !== "object") {
return null;
@@ -526,69 +500,6 @@ export function buildPersistedAuthProfileSecretsStore(
};
}
export function applyLegacyAuthStore(store: AuthProfileStore, legacy: LegacyAuthStore): void {
for (const [provider, cred] of Object.entries(legacy)) {
const profileId = `${provider}:default`;
const credentialProvider = cred.provider ?? provider;
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: credentialProvider,
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
continue;
}
if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: credentialProvider,
token: cred.token,
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
...(cred.email ? { email: cred.email } : {}),
};
continue;
}
store.profiles[profileId] = {
type: "oauth",
provider: credentialProvider,
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
...(cred.projectId ? { projectId: cred.projectId } : {}),
...(cred.accountId ? { accountId: cred.accountId } : {}),
...(cred.email ? { email: cred.email } : {}),
};
}
}
export function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
const oauthPath = resolveOAuthPath();
const oauthRaw = loadJsonFile(oauthPath);
if (!oauthRaw || typeof oauthRaw !== "object") {
return false;
}
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
let mutated = false;
for (const [provider, creds] of Object.entries(oauthEntries)) {
if (!creds || typeof creds !== "object") {
continue;
}
const profileId = `${provider}:default`;
if (store.profiles[profileId]) {
continue;
}
store.profiles[profileId] = {
type: "oauth",
provider,
...creds,
};
mutated = true;
}
return mutated;
}
export function loadPersistedAuthProfileStore(agentDir?: string): AuthProfileStore | null {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
@@ -601,7 +512,3 @@ export function loadPersistedAuthProfileStore(agentDir?: string): AuthProfileSto
...mergeAuthProfileState(coerceAuthProfileState(raw), loadPersistedAuthProfileState(agentDir)),
};
}
export function loadLegacyAuthProfileStore(agentDir?: string): LegacyAuthStore | null {
return coerceLegacyAuthStore(loadJsonFile(resolveLegacyAuthStorePath(agentDir)));
}

View File

@@ -1,16 +1,10 @@
import fs from "node:fs";
import {
resolveAuthStatePath,
resolveAuthStorePath,
resolveLegacyAuthStorePath,
} from "./path-resolve.js";
import { resolveAuthStatePath, resolveAuthStorePath } from "./path-resolve.js";
import { hasAnyRuntimeAuthProfileStoreSource } from "./runtime-snapshots.js";
function hasStoredAuthProfileFiles(agentDir?: string): boolean {
return (
fs.existsSync(resolveAuthStorePath(agentDir)) ||
fs.existsSync(resolveAuthStatePath(agentDir)) ||
fs.existsSync(resolveLegacyAuthStorePath(agentDir))
fs.existsSync(resolveAuthStorePath(agentDir)) || fs.existsSync(resolveAuthStatePath(agentDir))
);
}

View File

@@ -8,24 +8,15 @@ import {
AUTH_STORE_LOCK_OPTIONS,
AUTH_STORE_VERSION,
EXTERNAL_CLI_SYNC_TTL_MS,
log,
} from "./constants.js";
import { overlayExternalAuthProfiles, shouldPersistExternalAuthProfile } from "./external-auth.js";
import type { ExternalCliAuthDiscovery } from "./external-cli-discovery.js";
import { isSafeToAdoptMainStoreOAuthIdentity } from "./oauth-shared.js";
import { ensureAuthStoreFile, resolveAuthStatePath, resolveAuthStorePath } from "./paths.js";
import {
ensureAuthStoreFile,
resolveAuthStatePath,
resolveAuthStorePath,
resolveLegacyAuthStorePath,
} from "./paths.js";
import {
applyLegacyAuthStore,
buildPersistedAuthProfileSecretsStore,
loadLegacyAuthProfileStore,
loadPersistedAuthProfileStore,
mergeAuthProfileStores,
mergeOAuthFileIntoStore,
} from "./persisted.js";
import {
clearRuntimeAuthProfileStoreSnapshots as clearRuntimeAuthProfileStoreSnapshotsImpl,
@@ -338,15 +329,6 @@ export function loadAuthProfileStore(): AuthProfileStore {
if (asStore) {
return overlayExternalAuthProfiles(asStore);
}
const legacy = loadLegacyAuthProfileStore();
if (legacy) {
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
applyLegacyAuthStore(store, legacy);
return overlayExternalAuthProfiles(store);
}
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
return overlayExternalAuthProfiles(store);
@@ -384,38 +366,10 @@ function loadAuthProfileStoreForAgent(
return asStore;
}
const legacy = loadLegacyAuthProfileStore(agentDir);
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
if (legacy) {
applyLegacyAuthStore(store, legacy);
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth);
if (shouldWrite) {
saveAuthProfileStore(store, agentDir);
}
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
// overwriting fresh OAuth creds with stale tokens (fixes #363). Delete only
// after we've successfully written auth-profiles.json.
if (shouldWrite && legacy !== null) {
const legacyPath = resolveLegacyAuthStorePath(agentDir);
try {
fs.unlinkSync(legacyPath);
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
log.warn("failed to delete legacy auth.json after migration", {
err,
legacyPath,
});
}
}
}
if (!readOnly) {
writeCachedAuthProfileStore({

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/store.js";
import {
@@ -81,6 +82,84 @@ describe("maybeRepairLegacyFlatAuthProfileStores", () => {
expect(JSON.parse(fs.readFileSync(`${authPath}.legacy-flat.123.bak`, "utf8"))).toEqual(legacy);
});
it("imports retired auth.json stores into auth-profiles.json and removes the source", async () => {
const state = await makeTestState();
const agentDir = state.agentDir();
fs.mkdirSync(agentDir, { recursive: true });
const legacyPath = `${agentDir}/auth.json`;
const legacy = {
anthropic: {
mode: "api_key",
apiKey: "sk-ant-legacy",
},
};
fs.writeFileSync(legacyPath, `${JSON.stringify(legacy, null, 2)}\n`);
const result = await maybeRepairLegacyFlatAuthProfileStores({
cfg: {},
prompter: makePrompter(true),
now: () => 234,
});
expect(result.detected).toEqual([legacyPath]);
expect(result.warnings).toStrictEqual([]);
expect(fs.existsSync(legacyPath)).toBe(false);
expect(JSON.parse(fs.readFileSync(`${legacyPath}.legacy-auth.234.bak`, "utf8"))).toEqual(
legacy,
);
expect(JSON.parse(fs.readFileSync(`${agentDir}/auth-profiles.json`, "utf8"))).toEqual({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-ant-legacy",
},
},
});
});
it("imports retired oauth.json into auth-profiles.json and removes the source", async () => {
const state = await makeTestState();
const legacyPath = state.statePath("credentials", "oauth.json");
const legacy = {
"openai-codex": {
access: "access-token",
refresh: "refresh-token",
expires: 1_800_000_000_000,
accountId: "acct_123",
},
};
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, `${JSON.stringify(legacy, null, 2)}\n`);
const result = await maybeRepairLegacyFlatAuthProfileStores({
cfg: {},
prompter: makePrompter(true),
now: () => 345,
});
expect(result.detected).toEqual([legacyPath]);
expect(result.warnings).toStrictEqual([]);
expect(fs.existsSync(legacyPath)).toBe(false);
expect(JSON.parse(fs.readFileSync(`${legacyPath}.legacy-oauth.345.bak`, "utf8"))).toEqual(
legacy,
);
expect(JSON.parse(fs.readFileSync(`${state.agentDir()}/auth-profiles.json`, "utf8"))).toEqual({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: 1_800_000_000_000,
accountId: "acct_123",
},
},
});
});
it("reports legacy flat stores without rewriting when repair is declined", async () => {
const state = await makeTestState();
const legacy = {

View File

@@ -3,13 +3,21 @@ import path from "node:path";
import { resolveAgentDir, resolveDefaultAgentDir, listAgentIds } from "../agents/agent-scope.js";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import {
loadPersistedAuthProfileStore,
mergeAuthProfileStores,
} from "../agents/auth-profiles/persisted.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
} from "../agents/auth-profiles/store.js";
import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles/types.js";
import type {
AuthProfileCredential,
AuthProfileStore,
OAuthCredentials,
} from "../agents/auth-profiles/types.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveOAuthPath, resolveStateDir } from "../config/paths.js";
import type { AuthProfileConfig } from "../config/types.auth.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadJsonFile } from "../infra/json-file.js";
@@ -28,6 +36,20 @@ type LegacyFlatAuthProfileStore = {
store: AuthProfileStore;
};
type LegacyAuthJsonStore = {
agentDir?: string;
authPath: string;
legacyPath: string;
store: AuthProfileStore;
};
type LegacyOAuthJsonStore = {
agentDir?: string;
authPath: string;
legacyPath: string;
store: AuthProfileStore;
};
type AwsSdkProfileMarker = {
profileId: string;
provider: string;
@@ -228,18 +250,121 @@ function resolveLegacyFlatStore(
};
}
function resolveLegacyAuthJsonStore(
candidate: AuthProfileRepairCandidate,
): LegacyAuthJsonStore | null {
const legacyPath = path.join(path.dirname(candidate.authPath), "auth.json");
if (!fs.existsSync(legacyPath)) {
return null;
}
const raw = loadJsonFile(legacyPath);
const store = coerceLegacyFlatAuthProfileStore(raw);
if (!store || Object.keys(store.profiles).length === 0) {
return null;
}
return {
...candidate,
legacyPath,
store,
};
}
function coerceLegacyOAuthJsonStore(raw: unknown): AuthProfileStore | null {
if (!isRecord(raw)) {
return null;
}
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
for (const [provider, value] of Object.entries(raw)) {
if (!isRecord(value) || !isSafeLegacyProviderKey(provider)) {
continue;
}
const creds = value as OAuthCredentials;
if (
!readNonEmptyString(creds.access) ||
!readNonEmptyString(creds.refresh) ||
typeof creds.expires !== "number"
) {
continue;
}
store.profiles[`${provider}:default`] = {
type: "oauth",
provider,
access: creds.access,
refresh: creds.refresh,
expires: creds.expires,
...(readNonEmptyString(creds.accountId)
? { accountId: readNonEmptyString(creds.accountId) }
: {}),
...(readNonEmptyString(creds.email) ? { email: readNonEmptyString(creds.email) } : {}),
};
}
return Object.keys(store.profiles).length > 0 ? store : null;
}
function resolveLegacyOAuthJsonStore(cfg: OpenClawConfig): LegacyOAuthJsonStore | null {
const legacyPath = resolveOAuthPath();
if (!fs.existsSync(legacyPath)) {
return null;
}
const store = coerceLegacyOAuthJsonStore(loadJsonFile(legacyPath));
if (!store || Object.keys(store.profiles).length === 0) {
return null;
}
const agentDir = resolveDefaultAgentDir(cfg);
return {
agentDir,
authPath: resolveAuthStorePath(agentDir),
legacyPath,
store,
};
}
function backupAuthProfileStore(authPath: string, now: () => number): string {
const backupPath = `${authPath}.legacy-flat.${now()}.bak`;
fs.copyFileSync(authPath, backupPath);
return backupPath;
}
function backupLegacyAuthJsonStore(legacyPath: string, now: () => number): string {
const backupPath = `${legacyPath}.legacy-auth.${now()}.bak`;
fs.copyFileSync(legacyPath, backupPath);
return backupPath;
}
function backupLegacyOAuthJsonStore(legacyPath: string, now: () => number): string {
const backupPath = `${legacyPath}.legacy-oauth.${now()}.bak`;
fs.copyFileSync(legacyPath, backupPath);
return backupPath;
}
function backupAwsSdkProfileMarkerStore(authPath: string, now: () => number): string {
const backupPath = `${authPath}.aws-sdk-profile.${now()}.bak`;
fs.copyFileSync(authPath, backupPath);
return backupPath;
}
function mergeMissingAuthProfiles(params: {
agentDir?: string;
imported: AuthProfileStore;
}): AuthProfileStore {
const existing = loadPersistedAuthProfileStore(params.agentDir);
if (!existing) {
return params.imported;
}
const missingOnly: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: Object.fromEntries(
Object.entries(params.imported.profiles).filter(
([profileId]) => !existing.profiles[profileId],
),
),
};
return mergeAuthProfileStores(existing, missingOnly);
}
function resolveAwsSdkAuthProfileMarkerStore(
candidate: AuthProfileRepairCandidate,
): AwsSdkAuthProfileMarkerStore | null {
@@ -311,6 +436,10 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
const legacyStores = listAuthProfileRepairCandidates(params.cfg)
.map(resolveLegacyFlatStore)
.filter((entry): entry is LegacyFlatAuthProfileStore => entry !== null);
const legacyAuthJsonStores = listAuthProfileRepairCandidates(params.cfg)
.map(resolveLegacyAuthJsonStore)
.filter((entry): entry is LegacyAuthJsonStore => entry !== null);
const legacyOAuthJsonStore = resolveLegacyOAuthJsonStore(params.cfg);
const awsSdkMarkerStores = listAuthProfileRepairCandidates(params.cfg)
.map(resolveAwsSdkAuthProfileMarkerStore)
.filter((entry): entry is AwsSdkAuthProfileMarkerStore => entry !== null);
@@ -318,12 +447,19 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
const result: LegacyFlatAuthProfileRepairResult = {
detected: [
...legacyStores.map((entry) => entry.authPath),
...legacyAuthJsonStores.map((entry) => entry.legacyPath),
...(legacyOAuthJsonStore ? [legacyOAuthJsonStore.legacyPath] : []),
...awsSdkMarkerStores.map((entry) => entry.authPath),
],
changes: [],
warnings: [],
};
if (legacyStores.length === 0 && awsSdkMarkerStores.length === 0) {
if (
legacyStores.length === 0 &&
legacyAuthJsonStores.length === 0 &&
!legacyOAuthJsonStore &&
awsSdkMarkerStores.length === 0
) {
return result;
}
@@ -331,6 +467,15 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
...legacyStores.map(
(entry) => `- ${shortenHomePath(entry.authPath)} uses the legacy flat auth profile format.`,
),
...legacyAuthJsonStores.map(
(entry) =>
`- ${shortenHomePath(entry.legacyPath)} uses the retired auth.json credential format.`,
),
...(legacyOAuthJsonStore
? [
`- ${shortenHomePath(legacyOAuthJsonStore.legacyPath)} uses the retired shared OAuth credential format.`,
]
: []),
...awsSdkMarkerStores.map(
(entry) =>
`- ${shortenHomePath(entry.authPath)} contains aws-sdk profile markers that belong in openclaw.json auth.profiles.`,
@@ -341,6 +486,11 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
`- The gateway expects the canonical version/profiles store; ${formatCliCommand("openclaw doctor --fix")} rewrites this legacy shape with a backup.`,
);
}
if (legacyAuthJsonStores.length > 0 || legacyOAuthJsonStore) {
noteLines.push(
`- Runtime no longer imports retired credential JSON files; ${formatCliCommand("openclaw doctor --fix")} imports them into auth-profiles.json and removes the source files.`,
);
}
if (awsSdkMarkerStores.length > 0) {
noteLines.push(
`- AWS SDK profile markers are routing metadata, not stored credentials; ${formatCliCommand("openclaw doctor --fix")} moves them to config with a backup.`,
@@ -367,6 +517,40 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: {
result.warnings.push(`Failed to rewrite ${shortenHomePath(entry.authPath)}: ${String(err)}`);
}
}
for (const entry of legacyAuthJsonStores) {
try {
const backupPath = backupLegacyAuthJsonStore(entry.legacyPath, now);
const merged = mergeMissingAuthProfiles({
agentDir: entry.agentDir,
imported: entry.store,
});
saveAuthProfileStore(merged, entry.agentDir, { syncExternalCli: false });
fs.unlinkSync(entry.legacyPath);
result.changes.push(
`Imported ${shortenHomePath(entry.legacyPath)} into ${shortenHomePath(entry.authPath)} (backup: ${shortenHomePath(backupPath)}).`,
);
} catch (err) {
result.warnings.push(`Failed to import ${shortenHomePath(entry.legacyPath)}: ${String(err)}`);
}
}
if (legacyOAuthJsonStore) {
try {
const backupPath = backupLegacyOAuthJsonStore(legacyOAuthJsonStore.legacyPath, now);
const merged = mergeMissingAuthProfiles({
agentDir: legacyOAuthJsonStore.agentDir,
imported: legacyOAuthJsonStore.store,
});
saveAuthProfileStore(merged, legacyOAuthJsonStore.agentDir, { syncExternalCli: false });
fs.unlinkSync(legacyOAuthJsonStore.legacyPath);
result.changes.push(
`Imported ${shortenHomePath(legacyOAuthJsonStore.legacyPath)} into ${shortenHomePath(legacyOAuthJsonStore.authPath)} (backup: ${shortenHomePath(backupPath)}).`,
);
} catch (err) {
result.warnings.push(
`Failed to import ${shortenHomePath(legacyOAuthJsonStore.legacyPath)}: ${String(err)}`,
);
}
}
for (const entry of awsSdkMarkerStores) {
try {
const backupPath = backupAwsSdkProfileMarkerStore(entry.authPath, now);

View File

@@ -8,7 +8,6 @@ import {
CANONICAL_ROOT_MEMORY_FILENAME,
LEGACY_ROOT_MEMORY_FILENAME,
resolveCanonicalRootMemoryPath,
resolveLegacyRootMemoryPath,
resolveRootMemoryRepairDir,
} from "../memory/root-memory-files.js";
import { note } from "../terminal/note.js";
@@ -118,7 +117,7 @@ export async function detectRootMemoryFiles(
): Promise<RootMemoryFilesDetection> {
const resolvedWorkspace = path.resolve(workspaceDir);
const canonicalPath = resolveCanonicalRootMemoryPath(resolvedWorkspace);
const legacyPath = resolveLegacyRootMemoryPath(resolvedWorkspace);
const legacyPath = path.join(resolvedWorkspace, LEGACY_ROOT_MEMORY_FILENAME);
const entries = await listWorkspaceEntries(resolvedWorkspace);
const [canonical, legacy] = await Promise.all([
entries.has(CANONICAL_ROOT_MEMORY_FILENAME)

View File

@@ -9,10 +9,6 @@ export function resolveCanonicalRootMemoryPath(workspaceDir: string): string {
return path.join(workspaceDir, CANONICAL_ROOT_MEMORY_FILENAME);
}
export function resolveLegacyRootMemoryPath(workspaceDir: string): string {
return path.join(workspaceDir, LEGACY_ROOT_MEMORY_FILENAME);
}
export function resolveRootMemoryRepairDir(workspaceDir: string): string {
return path.join(workspaceDir, ".openclaw-repair", "root-memory");
}