fix(doctor): sidecar OAuth recovery misses PI_CODING_AGENT_DIR and strands recoverable secrets (#123187)

* fix(doctor): unify legacy auth repair candidate enumeration

The sidecar inline-recovery and flat-store SQLite migration each carried
a near-duplicate listAuthProfileRepairCandidates that diverged: the
sidecar copy ignored PI_CODING_AGENT_DIR (a supported env contract used
by dotenv, gateway env selection, secrets scan, and stale-auth-order)
and the flat copy ignored symlinked state agent dirs. Because repair
sequencing runs sidecar recovery first, any store only visible to the
flat migration had its decryptable sidecar secrets imported as
credential-less 'configured-unavailable' profiles and the user was told
to re-authenticate — while the secrets sat on disk, recoverable.

Move one canonical enumeration (env superset + symlink-tolerant dirent
filter) into doctor-auth-legacy-paths.ts and delete both copies.

* fix(doctor): preserve shared-main precedence in extracted candidate dedupe

The extraction dropped main's undefined-agentDir-wins rule (added with the
state-DB shared store work); an agent-scoped alias resolving to the same
path demoted the shared-main store to a per-agent import, breaking SQLite
migration and archive repair.
This commit is contained in:
Peter Steinberger
2026-08-14 00:17:37 -07:00
committed by GitHub
parent d4ee874fea
commit 3eed7596a9
4 changed files with 141 additions and 116 deletions
+2 -60
View File
@@ -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<string, AuthProfileRepairCandidate>,
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<string, AuthProfileRepairCandidate>();
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,
+76
View File
@@ -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<string, AuthProfileRepairCandidate>,
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<string, AuthProfileRepairCandidate>();
// 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");
}
@@ -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);
+5 -56
View File
@@ -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<string, AuthProfileRepairCandidate>,
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<string, AuthProfileRepairCandidate>();
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 {