From 1cdddab014e36ecf7b3e96a0984b7dfdcab179c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 9 May 2026 22:09:01 +0100 Subject: [PATCH] refactor: move legacy auth profile imports to doctor --- docs/refactor/database-first.md | 8 + docs/refactor/piless.md | 9 +- ...th-profiles.ensureauthprofilestore.test.ts | 53 ++--- src/agents/auth-profiles/constants.ts | 6 +- src/agents/auth-profiles/path-constants.ts | 1 - src/agents/auth-profiles/path-resolve.ts | 11 +- src/agents/auth-profiles/paths.ts | 1 - src/agents/auth-profiles/persisted.ts | 95 +-------- src/agents/auth-profiles/source-check.ts | 10 +- src/agents/auth-profiles/store.ts | 48 +---- .../doctor-auth-flat-profiles.test.ts | 79 ++++++++ src/commands/doctor-auth-flat-profiles.ts | 190 +++++++++++++++++- src/commands/doctor-workspace.ts | 3 +- src/memory/root-memory-files.ts | 4 - 14 files changed, 300 insertions(+), 218 deletions(-) diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 7da3560db0e..30246dd85fb 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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. diff --git a/docs/refactor/piless.md b/docs/refactor/piless.md index b1595762048..4492300e15d 100644 --- a/docs/refactor/piless.md +++ b/docs/refactor/piless.md @@ -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 diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index f6e04d9ed99..94ae7915a7a 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -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; - }; - 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); diff --git a/src/agents/auth-profiles/constants.ts b/src/agents/auth-profiles/constants.ts index 9e0ba5207f8..567500b87c2 100644 --- a/src/agents/auth-profiles/constants.ts +++ b/src/agents/auth-profiles/constants.ts @@ -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; diff --git a/src/agents/auth-profiles/path-constants.ts b/src/agents/auth-profiles/path-constants.ts index d723f34cd0c..9329df974c1 100644 --- a/src/agents/auth-profiles/path-constants.ts +++ b/src/agents/auth-profiles/path-constants.ts @@ -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"; diff --git a/src/agents/auth-profiles/path-resolve.ts b/src/agents/auth-profiles/path-resolve.ts index 5db7d5dfb29..0cc2b6c3839 100644 --- a/src/agents/auth-profiles/path-resolve.ts +++ b/src/agents/auth-profiles/path-resolve.ts @@ -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); diff --git a/src/agents/auth-profiles/paths.ts b/src/agents/auth-profiles/paths.ts index fb05e687c45..542ae34e627 100644 --- a/src/agents/auth-profiles/paths.ts +++ b/src/agents/auth-profiles/paths.ts @@ -7,7 +7,6 @@ export { resolveAuthStatePathForDisplay, resolveAuthStorePath, resolveAuthStorePathForDisplay, - resolveLegacyAuthStorePath, resolveOAuthRefreshLockPath, } from "./path-resolve.js"; diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 1276a5aa725..fb4a33ebf17 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -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; - 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; - 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; - 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))); -} diff --git a/src/agents/auth-profiles/source-check.ts b/src/agents/auth-profiles/source-check.ts index 9e20baec0c4..c169b702a0c 100644 --- a/src/agents/auth-profiles/source-check.ts +++ b/src/agents/auth-profiles/source-check.ts @@ -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)) ); } diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index c7652ccee70..bc48ad7bcb9 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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({ diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index d771f1f1f10..94fe4cca59e 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -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 = { diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 2812ecd3534..e4ed3bb1148 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -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); diff --git a/src/commands/doctor-workspace.ts b/src/commands/doctor-workspace.ts index 9f9744d638b..50aaac43da8 100644 --- a/src/commands/doctor-workspace.ts +++ b/src/commands/doctor-workspace.ts @@ -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 { 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) diff --git a/src/memory/root-memory-files.ts b/src/memory/root-memory-files.ts index 7fc550b55e3..0cc95971c07 100644 --- a/src/memory/root-memory-files.ts +++ b/src/memory/root-memory-files.ts @@ -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"); }