mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
d4ee874fea
commit
3eed7596a9
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user