fix(auth): report the database that actually holds an agent's profiles (#126918)

`openclaw models auth list` printed `Auth state store:
<state>/agents/main/agent/openclaw-agent.sqlite` on every install created since
a8a9f284fb, and that file does not exist. Credentials now persist in the shared
state database, so an operator debugging auth was sent to the wrong file while
the listed profiles resolved correctly from somewhere else.

`resolveAuthStorePathForDisplay` and `resolveAuthStatePathForDisplay` named the
agent-local file whenever an agent dir was supplied. That matched storage before
shared-auth ownership moved and stopped matching afterwards. The same helpers
feed `models auth order`, `models list --status`, the auth overview, two
auto-reply directive surfaces, and the `path` field of doctor's auth
HealthFindings, so structured diagnostics pointed at the wrong file too.

Display now mirrors the loader's own selection: an agent with a local auth store
shows its own database, otherwise the shared owner. Both helpers move to the
`paths.ts` barrel so they can consult `hasLocalAuthProfileStoreSource` without a
cycle back through `path-resolve`. Nothing about storage or loading changes.

`model-auth-provider` no longer derives the agent dir from the store path -- that
would have reported the state directory once the shared owner is selected -- and
uses the caller's agent dir instead.

Production -4 LOC.
This commit is contained in:
Peter Steinberger
2026-08-20 18:05:50 -07:00
committed by GitHub
parent a042125170
commit 7e87d77261
10 changed files with 150 additions and 67 deletions
+2 -1
View File
@@ -658,6 +658,7 @@ describe("Hermes migration secret items", () => {
reportDir,
});
const plan = await provider.plan(ctx);
const plannedTarget = authProfileTarget(agentDir, "openai:hermes-import");
writeAuthProfileStore(agentDir, {
version: 1,
profiles: {
@@ -677,7 +678,7 @@ describe("Hermes migration secret items", () => {
kind: "secret",
action: "create",
source: path.join(source, ".env"),
target: authProfileTarget(agentDir, "openai:hermes-import"),
target: plannedTarget,
status: "conflict",
sensitive: true,
reason: HERMES_REASON_AUTH_PROFILE_EXISTS,
@@ -1,9 +1,13 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { writeConfigMachineState } from "../../state/config-machine-state.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import { withEnv } from "../../test-utils/env.js";
import { resolveAuthStatePathForDisplay, resolveAuthStorePathForDisplay } from "./paths.js";
import { writePersistedAuthProfileStoreRaw } from "./sqlite.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@@ -38,6 +42,14 @@ describe("shared auth store path resolution", () => {
expect(resolveSharedAuthStorePath(aliasEnv)).toBe(
path.join(legacyDir, "openclaw-agent.sqlite"),
);
withEnv({ OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR, OPENCLAW_AGENT_DIR: undefined }, () => {
writePersistedAuthProfileStoreRaw({ version: 1, profiles: {} }, legacyDir);
const expectedPath = path.join(legacyDir, "openclaw-agent.sqlite");
expect(resolveAuthStorePathForDisplay(legacyDir)).toBe(expectedPath);
expect(resolveAuthStatePathForDisplay(legacyDir)).toBe(expectedPath);
expect(existsSync(expectedPath)).toBe(true);
});
});
it("resolves the relocated store to the canonical shared state database", async () => {
@@ -48,6 +60,29 @@ describe("shared auth store path resolution", () => {
expect(resolveSharedAuthStoreOwnership(env)).toEqual({ location: "state-db" });
expect(resolveSharedAuthStorePath(env)).toBe(resolveOpenClawStateSqlitePath(env));
withEnv({ OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR, OPENCLAW_AGENT_DIR: undefined }, () => {
writePersistedAuthProfileStoreRaw({ version: 1, profiles: {} });
const agentDir = path.join(env.OPENCLAW_STATE_DIR ?? "", "agents", "helper", "agent");
const expectedPath = resolveOpenClawStateSqlitePath(env);
expect(resolveAuthStorePathForDisplay(agentDir)).toBe(expectedPath);
expect(resolveAuthStatePathForDisplay(agentDir)).toBe(expectedPath);
expect(existsSync(expectedPath)).toBe(true);
});
});
it("keeps an agent-local store local under shared-state ownership", async () => {
const env = makeStateEnv();
writeConfigMachineState("auth.sharedStore", { location: "state-db" }, { env });
const agentDir = path.join(env.OPENCLAW_STATE_DIR ?? "", "agents", "helper", "agent");
withEnv({ OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR, OPENCLAW_AGENT_DIR: undefined }, () => {
writePersistedAuthProfileStoreRaw({ version: 1, profiles: {} }, agentDir);
const expectedPath = path.join(agentDir, "openclaw-agent.sqlite");
expect(resolveAuthStorePathForDisplay(agentDir)).toBe(expectedPath);
expect(resolveAuthStatePathForDisplay(agentDir)).toBe(expectedPath);
expect(existsSync(expectedPath)).toBe(true);
});
});
it("caches ownership independently for each canonical state root", async () => {
+1 -18
View File
@@ -1,13 +1,12 @@
/**
* Auth profile path resolution.
* Centralizes canonical SQLite display paths and cross-agent OAuth refresh lock paths.
* Centralizes canonical shared SQLite and cross-agent OAuth refresh lock paths.
*/
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveStateDir } from "../../config/paths.js";
import { readConfigMachineState } from "../../state/config-machine-state.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import { resolveUserPath } from "../../utils.js";
import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js";
export const SHARED_AUTH_STORE_STATE_KEY = "auth.sharedStore";
@@ -84,22 +83,6 @@ export function resolveSharedAuthStorePath(env: NodeJS.ProcessEnv = process.env)
return path.join(resolveSharedMainAuthAgentDir(env), "openclaw-agent.sqlite");
}
/** Resolve the user-facing auth profile database path. */
export function resolveAuthStorePathForDisplay(agentDir?: string): string {
const pathname = agentDir
? path.join(resolveUserPath(agentDir), "openclaw-agent.sqlite")
: resolveSharedAuthStorePath();
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
}
/** Resolve the user-facing auth state database path. */
export function resolveAuthStatePathForDisplay(agentDir?: string): string {
const pathname = agentDir
? path.join(resolveUserPath(agentDir), "openclaw-agent.sqlite")
: resolveSharedAuthStorePath();
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
}
/**
* Resolve the path of the cross-agent, per-profile OAuth refresh coordination
* lock. The filename digests a JSON tuple of `[provider, profileId]` so it is
@@ -1,29 +1,27 @@
/**
* Direct-import tests for auth profile path helpers.
* Calls path-resolve exports directly so coverage attribution stays honest
* despite the public paths.ts re-export barrel.
* Calls the owning modules directly so coverage attribution stays honest.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import {
resolveLegacyAuthProfilesPath as resolveAuthStorePath,
resolveLegacyAuthStatePath as resolveAuthStatePath,
resolveLegacyFlatAuthPath as resolveLegacyAuthStorePath,
} from "../../commands/doctor-auth-legacy-paths.js";
import { withEnv } from "../../test-utils/env.js";
import { resolveAuthStatePathForDisplay, resolveAuthStorePathForDisplay } from "./path-resolve.js";
import { resolveSharedAuthStorePath } from "./path-resolve.js";
import { resolveAuthStatePathForDisplay, resolveAuthStorePathForDisplay } from "./paths.js";
import { writePersistedAuthProfileStoreRaw } from "./sqlite.js";
describe("path-resolve helpers (direct-import coverage attribution)", () => {
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("auth profile path helpers (direct-import coverage attribution)", () => {
let stateDir = "";
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-direct-"));
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
beforeEach(() => {
stateDir = tempDirs.make("openclaw-path-direct-");
});
it("resolveAuthStorePath joins agentDir with the auth-profiles filename", () => {
@@ -80,22 +78,23 @@ describe("path-resolve helpers (direct-import coverage attribution)", () => {
});
});
it("resolveAuthStorePathForDisplay returns the resolved path for a non-tilde input", () => {
it("uses one database path for an agent-local auth store and its runtime state", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveAuthStorePathForDisplay(agentDir);
expect(resolved.startsWith(stateDir)).toBe(true);
expect(path.basename(resolved)).toBe("openclaw-agent.sqlite");
withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => {
writePersistedAuthProfileStoreRaw({ version: 1, profiles: {} }, agentDir);
const expectedPath = path.join(agentDir, "openclaw-agent.sqlite");
expect(resolveAuthStorePathForDisplay(agentDir)).toBe(expectedPath);
expect(resolveAuthStatePathForDisplay(agentDir)).toBe(expectedPath);
});
});
it("resolveAuthStorePathForDisplay expands a tilde-rooted agent dir to the sqlite store", () => {
const tildeAgentDir = "~fake-openclaw-no-expand";
const resolved = resolveAuthStorePathForDisplay(tildeAgentDir);
expect(resolved).toBe(path.resolve(tildeAgentDir, "openclaw-agent.sqlite"));
});
it("resolveAuthStatePathForDisplay returns the sqlite auth state store", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveAuthStatePathForDisplay(agentDir);
expect(resolved).toBe(path.join(agentDir, "openclaw-agent.sqlite"));
it("falls back to the shared owner for an agent dir that has no local store", () => {
withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => {
// A tilde-rooted dir resolveUserPath cannot expand still must not be reported as the owner:
// without a local store the loader reads the shared database, so display must name that.
const resolved = resolveAuthStorePathForDisplay("~fake-openclaw-no-expand");
expect(resolved).toBe(resolveSharedAuthStorePath());
expect(resolved.startsWith("~")).toBe(false);
});
});
});
+20 -5
View File
@@ -2,8 +2,23 @@
* Public path barrel for auth-profile stores.
* Import through this file for canonical SQLite display and lock paths.
*/
export {
resolveAuthStatePathForDisplay,
resolveAuthStorePathForDisplay,
resolveOAuthRefreshLockPath,
} from "./path-resolve.js";
import path from "node:path";
import { resolveUserPath } from "../../utils.js";
import { resolveOAuthRefreshLockPath, resolveSharedAuthStorePath } from "./path-resolve.js";
import { hasLocalAuthProfileStoreSource } from "./source-check.js";
export { resolveOAuthRefreshLockPath };
/** Resolve the user-facing path for the database selected by the auth store loader. */
export function resolveAuthStorePathForDisplay(agentDir?: string): string {
const pathname =
agentDir && hasLocalAuthProfileStoreSource(agentDir)
? path.join(resolveUserPath(agentDir), "openclaw-agent.sqlite")
: resolveSharedAuthStorePath();
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
}
/** Retained name for callers that present auth runtime state from the same selected store. */
export function resolveAuthStatePathForDisplay(agentDir?: string): string {
return resolveAuthStorePathForDisplay(agentDir);
}
+3 -3
View File
@@ -1,7 +1,6 @@
/**
* Ordered credential resolution for one provider request.
*/
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -12,6 +11,7 @@ import {
} from "../plugins/provider-runtime.js";
import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js";
import { SecretSurfaceUnavailableError } from "../secrets/runtime-degraded-state.js";
import { resolveUserPath } from "../utils.js";
import { resolveDefaultAgentDir } from "./agent-scope-config.js";
import {
type AuthProfileStore,
@@ -603,13 +603,13 @@ export async function resolveApiKeyForProviderCore(params: {
}
const authStorePath = resolveAuthStorePathForDisplay(agentDir);
const resolvedAgentDir = path.dirname(authStorePath);
const agentDirContext = agentDir ? ` (agentDir: ${resolveUserPath(agentDir)})` : "";
throw new ProviderAuthError(
"missing-provider-auth",
provider,
[
`No API key found for provider "${provider}".`,
`Auth store: ${authStorePath} (agentDir: ${resolvedAgentDir}).`,
`Auth store: ${authStorePath}${agentDirContext}.`,
`Configure auth for this agent (${formatCliCommand("openclaw agents add <id>")}) or copy only portable static auth profiles from the main agentDir.`,
].join(" "),
);
+13 -3
View File
@@ -5,6 +5,8 @@ import path from "node:path";
import type { Model } from "openclaw/plugin-sdk/llm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { writeConfigMachineState } from "../state/config-machine-state.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { withEnvAsync } from "../test-utils/env.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { clearRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js";
@@ -730,12 +732,20 @@ describe("getApiKeyForModelCore", () => {
OPENAI_API_KEY: undefined,
},
},
async () => {
await expect(resolveApiKeyForProviderCore({ provider: "openai" })).rejects.toMatchObject({
async (state) => {
writeConfigMachineState("auth.sharedStore", { location: "state-db" }, { env: state.env });
const error = await resolveApiKeyForProviderCore({
provider: "openai",
agentDir: state.agentDir(),
}).catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: "missing-provider-auth",
message: expect.stringContaining('No API key found for provider "openai".'),
provider: "openai",
});
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
`Auth store: ${resolveOpenClawStateSqlitePath(state.env)} (agentDir: ${state.agentDir()}).`,
);
},
);
@@ -1,10 +1,14 @@
// Doctor auth profile-health tests cover stale profile detection, repair notes, and store health.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { writePersistedAuthProfileStoreRaw } from "../agents/auth-profiles/sqlite.js";
import type { AuthProfileFailureReason, AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { writeConfigMachineState } from "../state/config-machine-state.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const authProfileMocks = vi.hoisted(() => ({
@@ -40,12 +44,14 @@ import { note } from "../../packages/terminal-core/src/note.js";
import { collectAuthProfileHealthFindings, noteAuthProfileHealth } from "./doctor-auth.js";
const noteMock = vi.mocked(note);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("noteAuthProfileHealth", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-auth-"));
tempDir = tempDirs.make("openclaw-doctor-auth-");
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
authProfileMocks.ensureAuthProfileStore.mockReset();
authProfileMocks.hasAnyAuthProfileStoreSource.mockReset();
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(false);
@@ -57,13 +63,14 @@ describe("noteAuthProfileHealth", () => {
});
afterEach(() => {
closeOpenClawStateDatabaseForTest();
vi.unstubAllEnvs();
vi.restoreAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
});
function writeAuthStore(agentDir: string): void {
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, "auth-profiles.json"), "{}\n", "utf8");
writePersistedAuthProfileStoreRaw({ version: 1, profiles: {} }, agentDir);
}
function expectedAuthStorePath(agentDir: string): string {
@@ -89,6 +96,7 @@ describe("noteAuthProfileHealth", () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const mainDir = path.join(tempDir, "main-agent");
writeAuthStore(mainDir);
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
authProfileMocks.ensureAuthProfileStore.mockReturnValue(
expiredStore("openai:default", now - 60_000),
@@ -114,6 +122,30 @@ describe("noteAuthProfileHealth", () => {
]);
});
it("points shared-store findings at the existing shared state database", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const mainDir = path.join(tempDir, "main-agent");
writeConfigMachineState("auth.sharedStore", { location: "state-db" });
writePersistedAuthProfileStoreRaw({ version: 1, profiles: {} });
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
authProfileMocks.ensureAuthProfileStore.mockReturnValue(
expiredStore("openai:default", now - 60_000),
);
const findings = await collectAuthProfileHealthFindings({
cfg: {
agents: {
list: [{ id: "main", default: true, agentDir: mainDir }],
},
} as OpenClawConfig,
});
const sharedPath = resolveOpenClawStateSqlitePath();
expect(findings).toEqual([expect.objectContaining({ path: sharedPath })]);
expect(fs.existsSync(sharedPath)).toBe(true);
});
it("does not warn while Claude CLI owns refresh of an expiring access token", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
@@ -213,6 +245,7 @@ describe("noteAuthProfileHealth", () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const mainDir = path.join(tempDir, "main-agent");
writeAuthStore(mainDir);
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
authProfileMocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(now + 5 * 60_000);
authProfileMocks.ensureAuthProfileStore.mockReturnValue({
@@ -427,6 +460,7 @@ describe("noteAuthProfileHealth", () => {
it("maps malformed API-key auth profiles to structured findings", async () => {
const mainDir = path.join(tempDir, "main-agent");
writeAuthStore(mainDir);
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
authProfileMocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
+2 -4
View File
@@ -30,10 +30,8 @@ import {
formatOAuthRefreshFailureLoginCommandMarkdown,
type OAuthRefreshFailureReason,
} from "../agents/auth-profiles/oauth-refresh-failure.js";
import {
resolveAuthStorePathForDisplay,
resolveSharedAuthStoreOwnership,
} from "../agents/auth-profiles/path-resolve.js";
import { resolveSharedAuthStoreOwnership } from "../agents/auth-profiles/path-resolve.js";
import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/paths.js";
import { inspectPersistedSharedAuthProfileStoreRaw } from "../agents/auth-profiles/sqlite.js";
import { buildProviderAuthRecoveryHint } from "../agents/provider-auth-recovery-hint.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+11 -3
View File
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
externalCliDiscoveryForProviderAuth: vi.fn(() => ({ kind: "none" })),
loadModelsConfig: vi.fn(),
resolveAuthProfileDisplayLabel: vi.fn(({ profileId }: { profileId: string }) => profileId),
resolveAuthStatePathForDisplay: vi.fn((agentDir: string) => `${agentDir}/openclaw-agent.sqlite`),
resolveModelsTargetAgent: vi.fn((_cfg: OpenClawConfig, rawAgentId?: string) => {
const agentId = rawAgentId ?? "main";
return { agentDir: `/tmp/openclaw/agents/${agentId}`, agentId };
@@ -25,7 +26,7 @@ vi.mock("../../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
externalCliDiscoveryForProviderAuth: mocks.externalCliDiscoveryForProviderAuth,
resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel,
resolveAuthStatePathForDisplay: (agentDir: string) => `${agentDir}/openclaw-agent.sqlite`,
resolveAuthStatePathForDisplay: mocks.resolveAuthStatePathForDisplay,
}));
vi.mock("./load-config.js", () => ({
@@ -62,6 +63,9 @@ describe("modelsAuthListCommand", () => {
mocks.ensureAuthProfileStore.mockReset();
mocks.externalCliDiscoveryForProviderAuth.mockClear();
mocks.resolveAuthProfileDisplayLabel.mockClear();
mocks.resolveAuthStatePathForDisplay
.mockReset()
.mockImplementation((agentDir: string) => `${agentDir}/openclaw-agent.sqlite`);
mocks.resolveModelsTargetAgent.mockClear();
});
@@ -296,15 +300,19 @@ describe("modelsAuthListCommand", () => {
expect(JSON.stringify(runtime.jsonPayloads[0])).not.toContain("secret");
});
it("prints an empty profile list without failing", async () => {
it.each([
["agent-local", "/tmp/openclaw/agents/main/openclaw-agent.sqlite"],
["shared", "/tmp/openclaw/state/openclaw.sqlite"],
])("prints an empty profile list with the %s auth path", async (_shape, authStatePath) => {
mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
mocks.resolveAuthStatePathForDisplay.mockReturnValue(authStatePath);
const runtime = createRuntime();
await modelsAuthListCommand({}, runtime);
expect(runtime.logs).toEqual([
"Agent: main",
"Auth state store: /tmp/openclaw/agents/main/openclaw-agent.sqlite",
`Auth state store: ${authStatePath}`,
"Profiles: (none)",
]);
});