diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 6a19fbdd3a0d..790a681a5eef 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -7,7 +7,6 @@ import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configu import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; -import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; import { clearAuthProfileMigrationDiagnostics, @@ -54,7 +53,6 @@ import type { import { resolveLegacyInheritedAuthDir } from "../agents/legacy-inherited-auth-dir.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { resolveStateDir } from "../config/paths.js"; import type { AuthProfileConfig } from "../config/types.auth.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { coerceSecretRef } from "../config/types.secrets.js"; @@ -62,9 +60,11 @@ import { loadJsonFileThroughSymlink } from "../infra/json-file.js"; import { readLegacyMigrationReceipt } from "../infra/state-migrations.receipts.js"; import { shortenHomePath } from "../utils.js"; import { + listAuthProfileRepairCandidates, resolveLegacyAuthProfilesPath as resolveAuthStorePath, resolveLegacyAuthStatePath as resolveAuthStatePath, resolveLegacyFlatAuthPath as resolveLegacyAuthStorePath, + type AuthProfileRepairCandidate, } from "./doctor-auth-legacy-paths.js"; import { acquireAuthProfileMigrationSourceLocks, @@ -78,11 +78,6 @@ import { } from "./doctor-auth-migration-receipts.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; -type AuthProfileRepairCandidate = { - agentDir?: string; - authPath: string; -}; - type AuthProfileSqliteMigrationCandidate = AuthProfileRepairCandidate & { statePath: string; legacyPath: string; @@ -293,59 +288,6 @@ function coerceLegacyFlatAuthProfileStore(raw: unknown): AuthProfileStore | null return Object.keys(store.profiles).length > 0 ? store : null; } -function addCandidate( - candidates: Map, - agentDir: string | undefined, -): void { - const authPath = resolveAuthStorePath(agentDir); - const key = path.resolve(authPath); - const existing = candidates.get(key); - if (!existing || agentDir === undefined) { - candidates.set(key, { agentDir, authPath }); - } -} - -function listExistingAgentDirsFromState(env: NodeJS.ProcessEnv): string[] { - const root = path.join(resolveStateDir(env), "agents"); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(root, { withFileTypes: true }); - } catch { - return []; - } - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(root, entry.name, "agent")) - .filter((agentDir) => { - try { - return fs.statSync(agentDir).isDirectory(); - } catch { - return false; - } - }); -} - -function listAuthProfileRepairCandidates( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv, -): AuthProfileRepairCandidate[] { - const candidates = new Map(); - addCandidate(candidates, undefined); - addCandidate(candidates, resolveLegacyInheritedAuthDir(cfg, env)); - const envAgentDir = - readNonEmptyString(env.OPENCLAW_AGENT_DIR) ?? readNonEmptyString(env.PI_CODING_AGENT_DIR); - if (envAgentDir) { - addCandidate(candidates, envAgentDir); - } - for (const agentId of listAgentIds(cfg)) { - addCandidate(candidates, resolveAgentDir(cfg, agentId, env)); - } - for (const agentDir of listExistingAgentDirsFromState(env)) { - addCandidate(candidates, agentDir); - } - return [...candidates.values()]; -} - function listAuthProfileSqliteMigrationCandidates( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, diff --git a/src/commands/doctor-auth-legacy-paths.ts b/src/commands/doctor-auth-legacy-paths.ts index 14a42bfc02d4..cd3f6358dace 100644 --- a/src/commands/doctor-auth-legacy-paths.ts +++ b/src/commands/doctor-auth-legacy-paths.ts @@ -1,11 +1,87 @@ +import fs from "node:fs"; import path from "node:path"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; import { resolveSharedMainAuthAgentDir } from "../agents/auth-profiles/shared-main-dir.js"; +import { resolveLegacyInheritedAuthDir } from "../agents/legacy-inherited-auth-dir.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; function resolveLegacyAuthAgentDir(agentDir?: string): string { return agentDir ? resolveUserPath(agentDir) : resolveSharedMainAuthAgentDir(); } +export type AuthProfileRepairCandidate = { + agentDir?: string; + authPath: string; +}; + +function addCandidate( + candidates: Map, + agentDir: string | undefined, +): void { + const authPath = resolveLegacyAuthProfilesPath(agentDir); + const key = path.resolve(authPath); + const existing = candidates.get(key); + // The shared-main store (undefined agentDir) owns its path: an agent-scoped + // alias resolving to the same file must not demote it to a per-agent import. + if (!existing || agentDir === undefined) { + candidates.set(key, { agentDir, authPath }); + } +} + +function listExistingAgentDirsFromState(env: NodeJS.ProcessEnv): string[] { + const root = path.join(resolveStateDir(env), "agents"); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return []; + } + return ( + entries + // Symlinked state agent dirs must repair like real ones; statSync follows. + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(root, entry.name, "agent")) + .filter((agentDir) => { + try { + return fs.statSync(agentDir).isDirectory(); + } catch { + return false; + } + }) + ); +} + +/** + * One canonical enumeration of legacy auth-store repair candidates. Sidecar + * inline-recovery and flat-store SQLite migration must see the same dirs, or + * decryptable sidecar secrets get imported as credential-less profiles. + */ +export function listAuthProfileRepairCandidates( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): AuthProfileRepairCandidate[] { + const candidates = new Map(); + // The shared-main default store (undefined agentDir) must stay first so the + // canonical location wins the per-path dedupe over agent-scoped aliases. + addCandidate(candidates, undefined); + addCandidate(candidates, resolveLegacyInheritedAuthDir(cfg, env)); + const envAgentDir = + readNonBlankString(env.OPENCLAW_AGENT_DIR) ?? readNonBlankString(env.PI_CODING_AGENT_DIR); + if (envAgentDir) { + addCandidate(candidates, envAgentDir); + } + for (const agentId of listAgentIds(cfg)) { + addCandidate(candidates, resolveAgentDir(cfg, agentId, env)); + } + for (const agentDir of listExistingAgentDirsFromState(env)) { + addCandidate(candidates, agentDir); + } + return [...candidates.values()]; +} + export function resolveLegacyAuthProfilesPath(agentDir?: string): string { return path.join(resolveLegacyAuthAgentDir(agentDir), "auth-profiles.json"); } diff --git a/src/commands/doctor-auth-oauth-sidecar.test.ts b/src/commands/doctor-auth-oauth-sidecar.test.ts index 6b2c79b3a101..8c951c79805b 100644 --- a/src/commands/doctor-auth-oauth-sidecar.test.ts +++ b/src/commands/doctor-auth-oauth-sidecar.test.ts @@ -505,6 +505,64 @@ describe("maybeRepairLegacyOAuthSidecarProfiles", () => { } }); + it("scans PI_CODING_AGENT_DIR like the flat-store migration so sidecar secrets inline first", async () => { + const state = await makeTestState(); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + const agentDir = state.path("pi-agent"); + const authPath = path.join(agentDir, "auth-profiles.json"); + const profileId = "openai-codex:pi"; + const ref = { + source: "openclaw-credentials" as const, + provider: "openai-codex" as const, + id: "ffffffffffffffffffffffffffffffff", + }; + try { + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync( + authPath, + `${JSON.stringify( + { + version: 1, + profiles: { + [profileId]: { type: "oauth", provider: "openai-codex", oauthRef: ref }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + process.env.PI_CODING_AGENT_DIR = agentDir; + const sidecarPath = await state.writeJson( + path.join("credentials", "auth-profiles", `${ref.id}.json`), + { + version: 1, + profileId, + provider: "openai-codex", + access: "pi-access-token", + refresh: "pi-refresh-token", + }, + ); + + const result = await maybeRepairLegacyOAuthSidecarProfiles({ + cfg: {}, + prompter: makePrompter(true), + now: () => 987, + }); + + expect(result.detected).toEqual([authPath]); + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toHaveLength(1); + expect(fs.existsSync(sidecarPath)).toBe(false); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + it("migrates every store before removing a shared legacy sidecar", async () => { const seed = "shared-sidecar-seed"; const state = await makeTestState(seed); diff --git a/src/commands/doctor-auth-oauth-sidecar.ts b/src/commands/doctor-auth-oauth-sidecar.ts index c879e4372496..c95b8636002a 100644 --- a/src/commands/doctor-auth-oauth-sidecar.ts +++ b/src/commands/doctor-auth-oauth-sidecar.ts @@ -4,16 +4,17 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; -import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/runtime-snapshots.js"; -import { resolveLegacyInheritedAuthDir } from "../agents/legacy-inherited-auth-dir.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; +import { resolveOAuthDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadJsonFileThroughSymlink, writeJsonTarget } from "../infra/json-file.js"; import { shortenHomePath } from "../utils.js"; -import { resolveLegacyAuthProfilesPath as resolveAuthStorePath } from "./doctor-auth-legacy-paths.js"; +import { + listAuthProfileRepairCandidates, + type AuthProfileRepairCandidate, +} from "./doctor-auth-legacy-paths.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; import { isLegacyOAuthRef, @@ -26,11 +27,6 @@ import { const LEGACY_OAUTH_SECRET_DIRNAME = "auth-profiles"; -type AuthProfileRepairCandidate = { - agentDir?: string; - authPath: string; -}; - type LegacyOAuthSidecarProfile = { profileId: string; provider: string; @@ -52,53 +48,6 @@ type LegacyOAuthSidecarRepairResult = { warnings: string[]; }; -function addCandidate( - candidates: Map, - agentDir: string | undefined, -): void { - const authPath = resolveAuthStorePath(agentDir); - candidates.set(path.resolve(authPath), { agentDir, authPath }); -} - -function listExistingAgentDirsFromState(env: NodeJS.ProcessEnv): string[] { - const root = path.join(resolveStateDir(env), "agents"); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(root, { withFileTypes: true }); - } catch { - return []; - } - return entries - .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) - .map((entry) => path.join(root, entry.name, "agent")) - .filter((agentDir) => { - try { - return fs.statSync(agentDir).isDirectory(); - } catch { - return false; - } - }); -} - -function listAuthProfileRepairCandidates( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv, -): AuthProfileRepairCandidate[] { - const candidates = new Map(); - addCandidate(candidates, resolveLegacyInheritedAuthDir(cfg, env)); - const envAgentDir = readNonEmptyString(env.OPENCLAW_AGENT_DIR); - if (envAgentDir) { - addCandidate(candidates, envAgentDir); - } - for (const agentId of listAgentIds(cfg)) { - addCandidate(candidates, resolveAgentDir(cfg, agentId, env)); - } - for (const agentDir of listExistingAgentDirsFromState(env)) { - addCandidate(candidates, agentDir); - } - return [...candidates.values()]; -} - function resolveLegacyOAuthSidecarStore( candidate: AuthProfileRepairCandidate, ): LegacyOAuthSidecarStore | null {