Preserve runtime external auth snapshots (#85558)

Summary:
- The PR adds runtime-only external OAuth provenance to auth-profile stores, updates save/merge/read paths to  ... e profiles in active snapshots while filtering disk persistence, and expands auth-profile regression tests.
- PR surface: Source +381, Tests +974. Total +1355 across 8 files.
- Reproducibility: yes. from source: current main writes the disk-filtered localStore into an existing runtime ... tches the reported credential drop path. I did not run a failing current-main repro in this read-only pass.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Preserve runtime external auth snapshots

Validation:
- ClawSweeper review passed for head a73074ed45.
- Required merge gates passed before the squash merge.

Prepared head SHA: a73074ed45
Review: https://github.com/openclaw/openclaw/pull/85558#issuecomment-4523577269

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
Andy Ye
2026-05-25 21:41:59 -07:00
committed by GitHub
parent 7db4b3db41
commit 711e963723
8 changed files with 1413 additions and 58 deletions

View File

@@ -1,27 +1,42 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveOAuthDir } from "../config/paths.js";
import { legacyOAuthSidecarTestUtils } from "./auth-profiles/legacy-oauth-sidecar.js";
import { resolveAuthStatePath, resolveAuthStorePath } from "./auth-profiles/paths.js";
import { getRuntimeAuthProfileStoreSnapshot } from "./auth-profiles/runtime-snapshots.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStoreForLocalUpdate,
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
replaceRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
} from "./auth-profiles/store.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
const listRuntimeExternalAuthProfilesMock = vi.hoisted(() =>
vi.fn<(_params: unknown) => unknown[]>(() => []),
);
const externalAuthMocks = vi.hoisted(() => ({
listRuntimeExternalAuthProfiles: vi.fn((params?: { store?: unknown }) => {
const store = params?.store as { profiles?: Record<string, unknown> } | undefined;
return Object.entries(store?.profiles ?? {})
.filter(([, credential]) => (credential as { type?: string }).type === "oauth")
.map(([profileId, credential]) => ({
profileId,
credential,
persistence: externalAuthMocks.shouldPersistExternalAuthProfile({ profileId })
? "persisted"
: "runtime-only",
}));
}),
overlayExternalAuthProfiles: vi.fn((store: unknown) => store),
shouldPersistExternalAuthProfile: vi.fn((_params?: { profileId?: string }) => true),
}));
vi.mock("./auth-profiles/external-auth.js", () => ({
listRuntimeExternalAuthProfiles: listRuntimeExternalAuthProfilesMock,
overlayExternalAuthProfiles: <T>(store: T) => store,
shouldPersistExternalAuthProfile: () => true,
listRuntimeExternalAuthProfiles: externalAuthMocks.listRuntimeExternalAuthProfiles,
overlayExternalAuthProfiles: externalAuthMocks.overlayExternalAuthProfiles,
shouldPersistExternalAuthProfile: externalAuthMocks.shouldPersistExternalAuthProfile,
syncPersistedExternalCliAuthProfiles: <T>(store: T) => store,
}));
@@ -68,12 +83,12 @@ describe("saveAuthProfileStore", () => {
};
try {
listRuntimeExternalAuthProfilesMock.mockClear();
externalAuthMocks.listRuntimeExternalAuthProfiles.mockClear();
saveAuthProfileStore(store, agentDir);
expect(listRuntimeExternalAuthProfilesMock).toHaveBeenCalledTimes(1);
expect(listRuntimeExternalAuthProfilesMock.mock.calls[0]?.[0]).toMatchObject({
expect(externalAuthMocks.listRuntimeExternalAuthProfiles).toHaveBeenCalledTimes(1);
expect(externalAuthMocks.listRuntimeExternalAuthProfiles.mock.calls[0]?.[0]).toMatchObject({
store,
agentDir,
});
@@ -82,6 +97,12 @@ describe("saveAuthProfileStore", () => {
}
});
beforeEach(() => {
externalAuthMocks.listRuntimeExternalAuthProfiles.mockClear();
externalAuthMocks.overlayExternalAuthProfiles.mockImplementation((store) => store);
externalAuthMocks.shouldPersistExternalAuthProfile.mockReturnValue(true);
});
it("strips plaintext when keyRef/tokenRef are present", async () => {
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-"));
@@ -445,6 +466,643 @@ describe("saveAuthProfileStore", () => {
}
});
it("keeps runtime-only external cli oauth profiles in active runtime snapshots", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-external-"));
const externalProfileId = "anthropic:claude-cli";
const localAnthropicProfileId = "anthropic:local";
const localProfileId = "openai:default";
externalAuthMocks.shouldPersistExternalAuthProfile.mockImplementation(
(params?: { profileId?: string }) => params?.profileId !== externalProfileId,
);
try {
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
profiles: {
[externalProfileId]: {
type: "oauth",
provider: "anthropic",
access: "stale-external-access",
refresh: "stale-external-refresh",
expires: 1,
},
},
},
},
]);
const runtimeStore: AuthProfileStore = {
version: 1,
runtimeExternalProfileIds: [externalProfileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[externalProfileId]: {
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
expires: 2,
},
[localProfileId]: {
type: "api_key",
provider: "openai",
key: "sk-local",
},
[localAnthropicProfileId]: {
type: "api_key",
provider: "anthropic",
key: "sk-anthropic-local",
},
},
order: {
anthropic: [externalProfileId],
openai: [localProfileId],
},
lastGood: {
anthropic: externalProfileId,
openai: localProfileId,
},
usageStats: {
[externalProfileId]: {
lastUsed: 123,
},
[localProfileId]: {
lastUsed: 456,
},
},
};
externalAuthMocks.overlayExternalAuthProfiles.mockImplementation((store) => {
const base = store as AuthProfileStore;
const externalUsage = base.usageStats?.[externalProfileId] ?? { lastUsed: 123 };
return {
...base,
profiles: {
...base.profiles,
[externalProfileId]: runtimeStore.profiles[externalProfileId],
},
order: {
...base.order,
anthropic: [externalProfileId],
},
lastGood: {
...base.lastGood,
anthropic: externalProfileId,
},
usageStats: {
...base.usageStats,
[externalProfileId]: externalUsage,
},
runtimeExternalProfileIds: [externalProfileId],
runtimeExternalProfileIdsAuthoritative: true,
};
});
saveAuthProfileStore(runtimeStore, agentDir);
const persisted = JSON.parse(await fs.readFile(resolveAuthStorePath(agentDir), "utf8")) as {
profiles: Record<string, unknown>;
};
expect(persisted.profiles[externalProfileId]).toBeUndefined();
expectProfileFields(persisted.profiles[localProfileId], {
type: "api_key",
provider: "openai",
key: "sk-local",
});
const persistedState = JSON.parse(
await fs.readFile(resolveAuthStatePath(agentDir), "utf8"),
) as {
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
usageStats?: Record<string, unknown>;
};
expect(persistedState.order?.anthropic).toBeUndefined();
expect(persistedState.lastGood?.anthropic).toBeUndefined();
expect(persistedState.usageStats?.[externalProfileId]).toBeUndefined();
expect(persistedState.order?.openai).toEqual([localProfileId]);
const runtime = ensureAuthProfileStore(agentDir);
expectProfileFields(runtime.profiles[externalProfileId], {
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
});
expect(runtime.order?.anthropic).toEqual([externalProfileId]);
expect(runtime.lastGood?.anthropic).toBe(externalProfileId);
expect(runtime.usageStats?.[externalProfileId]?.lastUsed).toBe(123);
const runtimeWithoutExternal = ensureAuthProfileStoreWithoutExternalProfiles(agentDir);
expect(runtimeWithoutExternal.profiles[externalProfileId]).toBeUndefined();
expect(runtimeWithoutExternal.order?.anthropic).toBeUndefined();
expect(runtimeWithoutExternal.lastGood?.anthropic).toBeUndefined();
expect(runtimeWithoutExternal.usageStats?.[externalProfileId]).toBeUndefined();
saveAuthProfileStore(
{
...runtimeStore,
profiles: {
...runtimeStore.profiles,
[externalProfileId]: {
type: "oauth",
provider: "anthropic",
access: "refreshed-external-access",
refresh: "refreshed-external-refresh",
expires: 3,
},
},
usageStats: {
...runtimeStore.usageStats,
[externalProfileId]: {
lastUsed: 789,
},
},
},
agentDir,
);
const snapshotAfterRuntimeBackedSave = getRuntimeAuthProfileStoreSnapshot(agentDir);
expectProfileFields(snapshotAfterRuntimeBackedSave?.profiles[externalProfileId], {
type: "oauth",
provider: "anthropic",
access: "refreshed-external-access",
refresh: "refreshed-external-refresh",
});
expect(snapshotAfterRuntimeBackedSave?.usageStats?.[externalProfileId]?.lastUsed).toBe(789);
saveAuthProfileStore(runtimeWithoutExternal, agentDir);
const persistedAfterDiskBackedSave = JSON.parse(
await fs.readFile(resolveAuthStorePath(agentDir), "utf8"),
) as {
profiles: Record<string, unknown>;
};
expect(persistedAfterDiskBackedSave.profiles[externalProfileId]).toBeUndefined();
const snapshotAfterDiskBackedSave = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(snapshotAfterDiskBackedSave?.runtimeExternalProfileIds).toEqual([externalProfileId]);
expect(snapshotAfterDiskBackedSave?.runtimeExternalProfileIdsAuthoritative).toBe(true);
expectProfileFields(snapshotAfterDiskBackedSave?.profiles[externalProfileId], {
type: "oauth",
provider: "anthropic",
access: "refreshed-external-access",
refresh: "refreshed-external-refresh",
});
expectProfileFields(snapshotAfterDiskBackedSave?.profiles[localProfileId], {
type: "api_key",
provider: "openai",
key: "sk-local",
});
expect(snapshotAfterDiskBackedSave?.order?.anthropic).toEqual([externalProfileId]);
expect(snapshotAfterDiskBackedSave?.lastGood?.anthropic).toBe(externalProfileId);
expect(snapshotAfterDiskBackedSave?.usageStats?.[externalProfileId]?.lastUsed).toBe(789);
const ensuredRuntime = ensureAuthProfileStore(agentDir);
expectProfileFields(ensuredRuntime.profiles[localProfileId], {
type: "api_key",
provider: "openai",
key: "sk-local",
});
expect(ensuredRuntime.order?.anthropic).toEqual([externalProfileId]);
expect(ensuredRuntime.lastGood?.anthropic).toBe(externalProfileId);
expect(ensuredRuntime.usageStats?.[externalProfileId]?.lastUsed).toBe(789);
saveAuthProfileStore(
{
...runtimeWithoutExternal,
order: {
...runtimeWithoutExternal.order,
anthropic: [localAnthropicProfileId],
},
lastGood: {
...runtimeWithoutExternal.lastGood,
anthropic: localAnthropicProfileId,
},
},
agentDir,
);
const snapshotAfterExplicitOrderSave = getRuntimeAuthProfileStoreSnapshot(agentDir);
expectProfileFields(snapshotAfterExplicitOrderSave?.profiles[externalProfileId], {
type: "oauth",
provider: "anthropic",
access: "refreshed-external-access",
refresh: "refreshed-external-refresh",
});
expect(snapshotAfterExplicitOrderSave?.order?.anthropic).toEqual([localAnthropicProfileId]);
expect(snapshotAfterExplicitOrderSave?.lastGood?.anthropic).toBe(localAnthropicProfileId);
saveAuthProfileStore(
{
...runtimeWithoutExternal,
runtimeExternalProfileIds: [],
runtimeExternalProfileIdsAuthoritative: true,
},
agentDir,
);
const snapshotAfterAuthoritativeRemoval = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(snapshotAfterAuthoritativeRemoval?.runtimeExternalProfileIds).toEqual([]);
expect(snapshotAfterAuthoritativeRemoval?.runtimeExternalProfileIdsAuthoritative).toBe(true);
expect(snapshotAfterAuthoritativeRemoval?.profiles[externalProfileId]).toBeUndefined();
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("preserves unrelated runtime-only external profiles after scoped runtime saves", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-scoped-"));
const scopedProfileId = "anthropic:claude-cli";
const unrelatedProfileId = "minimax:minimax-cli";
externalAuthMocks.shouldPersistExternalAuthProfile.mockReturnValue(false);
try {
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
runtimeExternalProfileIds: [scopedProfileId, unrelatedProfileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[scopedProfileId]: {
type: "oauth",
provider: "anthropic",
access: "old-scoped-access",
refresh: "old-scoped-refresh",
expires: 1,
},
[unrelatedProfileId]: {
type: "oauth",
provider: "minimax-portal",
access: "unrelated-access",
refresh: "unrelated-refresh",
expires: 2,
},
},
order: {
anthropic: [scopedProfileId],
"minimax-portal": [unrelatedProfileId],
},
lastGood: {
anthropic: scopedProfileId,
"minimax-portal": unrelatedProfileId,
},
usageStats: {
[scopedProfileId]: { lastUsed: 10 },
[unrelatedProfileId]: { lastUsed: 20 },
},
},
},
]);
saveAuthProfileStore(
{
version: 1,
runtimeExternalProfileIds: [scopedProfileId],
profiles: {
[scopedProfileId]: {
type: "oauth",
provider: "anthropic",
access: "new-scoped-access",
refresh: "new-scoped-refresh",
expires: 3,
},
},
order: {
anthropic: [scopedProfileId],
},
usageStats: {
[scopedProfileId]: { lastUsed: 30 },
},
},
agentDir,
);
const snapshot = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(snapshot?.runtimeExternalProfileIds).toEqual([scopedProfileId, unrelatedProfileId]);
expect(snapshot?.runtimeExternalProfileIdsAuthoritative).toBe(true);
expectProfileFields(snapshot?.profiles[scopedProfileId], {
type: "oauth",
provider: "anthropic",
access: "new-scoped-access",
refresh: "new-scoped-refresh",
});
expectProfileFields(snapshot?.profiles[unrelatedProfileId], {
type: "oauth",
provider: "minimax-portal",
access: "unrelated-access",
refresh: "unrelated-refresh",
});
expect(snapshot?.usageStats?.[scopedProfileId]?.lastUsed).toBe(30);
expect(snapshot?.usageStats?.[unrelatedProfileId]?.lastUsed).toBe(20);
expect(snapshot?.order?.anthropic).toEqual([scopedProfileId]);
expect(snapshot?.order?.["minimax-portal"]).toEqual([unrelatedProfileId]);
const scopedRead = ensureAuthProfileStore(agentDir, {
externalCliProviderIds: ["anthropic"],
});
expect(scopedRead.profiles[unrelatedProfileId]).toBeUndefined();
saveAuthProfileStore(
{
version: 1,
runtimeExternalProfileIds: [scopedProfileId],
profiles: {
[scopedProfileId]: {
type: "oauth",
provider: "anthropic",
access: "newer-scoped-access",
refresh: "newer-scoped-refresh",
expires: 4,
},
[unrelatedProfileId]: {
type: "oauth",
provider: "minimax-portal",
access: "unrelated-access",
refresh: "unrelated-refresh",
expires: 2,
},
},
order: {
anthropic: [scopedProfileId],
"minimax-portal": [unrelatedProfileId],
},
},
agentDir,
);
const snapshotAfterProfileCarryingScopedSave = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(snapshotAfterProfileCarryingScopedSave?.runtimeExternalProfileIds).toEqual([
scopedProfileId,
unrelatedProfileId,
]);
expect(snapshotAfterProfileCarryingScopedSave?.runtimeExternalProfileIdsAuthoritative).toBe(
true,
);
const runtimeWithoutExternal = ensureAuthProfileStoreWithoutExternalProfiles(agentDir);
expect(runtimeWithoutExternal.profiles[unrelatedProfileId]).toBeUndefined();
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("does not persist profiles already marked runtime-only external", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-runtime-only-"));
const profileId = "anthropic:claude-cli";
try {
const store: AuthProfileStore = {
version: 1,
runtimeExternalProfileIds: [profileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
expires: 1,
},
},
order: {
anthropic: [profileId],
},
lastGood: {
anthropic: profileId,
},
usageStats: {
[profileId]: { lastUsed: 10 },
},
};
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store }]);
saveAuthProfileStore(store, agentDir);
const authProfiles = JSON.parse(
await fs.readFile(resolveAuthStorePath(agentDir), "utf8"),
) as {
profiles: Record<string, unknown>;
};
expect(authProfiles.profiles[profileId]).toBeUndefined();
const snapshot = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(snapshot?.runtimeExternalProfileIds).toEqual([profileId]);
expect(snapshot?.profiles[profileId]).toMatchObject({
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
});
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("does not persist runtime-only external profiles without an installed snapshot", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-unsnapshotted-"));
const profileId = "openai-codex:default";
try {
saveAuthProfileStore(
{
version: 1,
runtimeExternalProfileIds: [profileId],
profiles: {
[profileId]: {
type: "oauth",
provider: "openai-codex",
access: "runtime-access",
refresh: "runtime-refresh",
expires: 1,
},
},
},
agentDir,
);
const authProfiles = JSON.parse(
await fs.readFile(resolveAuthStorePath(agentDir), "utf8"),
) as {
profiles: Record<string, unknown>;
};
expect(authProfiles.profiles[profileId]).toBeUndefined();
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("returns active runtime-only external profiles on unscoped reads", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-read-runtime-only-"));
const profileId = "openai-codex:default";
try {
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
runtimeExternalProfileIds: [profileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "oauth",
provider: "openai-codex",
access: "runtime-access",
refresh: "runtime-refresh",
expires: 1,
},
},
usageStats: {
[profileId]: { lastUsed: 10 },
},
},
},
]);
const store = ensureAuthProfileStore(agentDir);
expect(store.runtimeExternalProfileIds).toEqual([profileId]);
expectProfileFields(store.profiles[profileId], {
type: "oauth",
provider: "openai-codex",
access: "runtime-access",
refresh: "runtime-refresh",
});
expect(store.usageStats?.[profileId]?.lastUsed).toBe(10);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("does not resurrect runtime-only profiles after authoritative empty overlays", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-read-removed-"));
const profileId = "anthropic:claude-cli";
externalAuthMocks.overlayExternalAuthProfiles.mockImplementation((store) => ({
...(store as AuthProfileStore),
runtimeExternalProfileIds: [],
runtimeExternalProfileIdsAuthoritative: true,
}));
try {
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
runtimeExternalProfileIds: [profileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "runtime-access",
refresh: "runtime-refresh",
expires: 1,
},
},
},
},
]);
const store = ensureAuthProfileStore(agentDir);
expect(store.runtimeExternalProfileIds).toEqual([]);
expect(store.runtimeExternalProfileIdsAuthoritative).toBe(true);
expect(store.profiles[profileId]).toBeUndefined();
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("persists refreshed runtime-only external OAuth credentials", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-refreshed-"));
const profileId = "anthropic:claude-cli";
try {
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
runtimeExternalProfileIds: [profileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
expires: 1,
},
},
},
},
]);
saveAuthProfileStore(
{
version: 1,
runtimeExternalProfileIds: [profileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "refreshed-access",
refresh: "refreshed-refresh",
expires: 2,
},
},
},
agentDir,
);
const authProfiles = JSON.parse(
await fs.readFile(resolveAuthStorePath(agentDir), "utf8"),
) as {
profiles: Record<string, unknown>;
};
expectProfileFields(authProfiles.profiles[profileId], {
type: "oauth",
provider: "anthropic",
access: "refreshed-access",
refresh: "refreshed-refresh",
});
const activeRuntime = getRuntimeAuthProfileStoreSnapshot(agentDir);
if (!activeRuntime) {
throw new Error("expected active runtime auth snapshot");
}
saveAuthProfileStore(
{
...activeRuntime,
usageStats: {
[profileId]: { lastUsed: 20 },
},
},
agentDir,
);
const authProfilesAfterUsageSave = JSON.parse(
await fs.readFile(resolveAuthStorePath(agentDir), "utf8"),
) as {
profiles: Record<string, unknown>;
};
expectProfileFields(authProfilesAfterUsageSave.profiles[profileId], {
type: "oauth",
provider: "anthropic",
access: "refreshed-access",
refresh: "refreshed-refresh",
});
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("writes runtime scheduling state to auth-state.json only", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-state-"));
try {
@@ -723,4 +1381,116 @@ describe("saveAuthProfileStore", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps local replacements for old runtime-only profile ids visible", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-replace-"));
const profileId = "anthropic:claude-cli";
try {
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
runtimeExternalProfileIds: [profileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
expires: 1,
},
},
},
},
]);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "api_key",
provider: "anthropic",
key: "sk-local",
},
},
},
agentDir,
);
const snapshot = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(snapshot?.runtimeExternalProfileIds).toEqual([]);
expect(snapshot?.runtimeExternalProfileIdsAuthoritative).toBe(true);
expect(snapshot?.profiles[profileId]).toMatchObject({
type: "api_key",
provider: "anthropic",
key: "sk-local",
});
const runtimeWithoutExternal = ensureAuthProfileStoreWithoutExternalProfiles(agentDir);
expect(runtimeWithoutExternal.profiles[profileId]).toMatchObject({
type: "api_key",
provider: "anthropic",
key: "sk-local",
});
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("clears non-authoritative runtime-only metadata after local replacements", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-replace-scoped-"));
const profileId = "anthropic:claude-cli";
try {
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
runtimeExternalProfileIds: [profileId],
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
expires: 1,
},
},
},
},
]);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "api_key",
provider: "anthropic",
key: "sk-local",
},
},
},
agentDir,
);
const snapshot = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(snapshot?.runtimeExternalProfileIds).toBeUndefined();
expect(snapshot?.runtimeExternalProfileIdsAuthoritative).toBeUndefined();
expect(snapshot?.profiles[profileId]).toMatchObject({
type: "api_key",
provider: "anthropic",
key: "sk-local",
});
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
});

View File

@@ -121,6 +121,10 @@ function hasPersistableExternalCliSyncCandidate(
return false;
}
function hasScopedExternalCliOverlay(params?: ExternalCliOverlayOptions): boolean {
return Boolean(params?.externalCliProviderIds || params?.externalCliProfileIds);
}
export function overlayExternalAuthProfiles(
store: AuthProfileStore,
params?: { agentDir?: string; env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions,
@@ -131,7 +135,9 @@ export function overlayExternalAuthProfiles(
env: params?.env,
externalCli: params,
});
return overlayRuntimeExternalOAuthProfiles(store, profiles);
return overlayRuntimeExternalOAuthProfiles(store, profiles, {
runtimeExternalProfileIdsAuthoritative: !hasScopedExternalCliOverlay(params),
});
}
export function shouldPersistExternalAuthProfile(params: {

View File

@@ -51,4 +51,69 @@ describe("overlayRuntimeExternalOAuthProfiles", () => {
structuredCloneSpy.mockRestore();
}
});
it("preserves existing runtime-only provenance for non-authoritative overlays", () => {
const store: AuthProfileStore = {
version: 1,
runtimeExternalProfileIds: ["minimax:minimax-cli"],
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "old-access",
refresh: "old-refresh",
expires: 1,
},
"minimax:minimax-cli": {
type: "oauth",
provider: "minimax-portal",
access: "minimax-access",
refresh: "minimax-refresh",
expires: 1,
},
},
};
const overlaid = overlayRuntimeExternalOAuthProfiles(store, [
{
profileId: "anthropic:claude-cli",
credential: {
type: "oauth",
provider: "anthropic",
access: "new-access",
refresh: "new-refresh",
expires: 2,
},
},
]);
expect(overlaid.runtimeExternalProfileIds).toEqual([
"anthropic:claude-cli",
"minimax:minimax-cli",
]);
});
it("preserves existing runtime-only provenance for authoritative overlays", () => {
const store: AuthProfileStore = {
version: 1,
runtimeExternalProfileIds: ["minimax:minimax-cli"],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
"minimax:minimax-cli": {
type: "oauth",
provider: "minimax-portal",
access: "minimax-access",
refresh: "minimax-refresh",
expires: 1,
},
},
};
const overlaid = overlayRuntimeExternalOAuthProfiles(store, [], {
runtimeExternalProfileIdsAuthoritative: true,
});
expect(overlaid.runtimeExternalProfileIds).toEqual(["minimax:minimax-cli"]);
expect(overlaid.runtimeExternalProfileIdsAuthoritative).toBe(true);
});
});

View File

@@ -169,15 +169,29 @@ export function shouldBootstrapFromExternalCliCredential(params: {
export function overlayRuntimeExternalOAuthProfiles(
store: AuthProfileStore,
profiles: Iterable<RuntimeExternalOAuthProfile>,
options?: { runtimeExternalProfileIdsAuthoritative?: boolean },
): AuthProfileStore {
const externalProfiles = Array.from(profiles);
if (externalProfiles.length === 0) {
return store;
}
const next = cloneAuthProfileStore(store);
for (const profile of externalProfiles) {
next.profiles[profile.profileId] = profile.credential;
}
const runtimeOnlyProfileIds = new Set(
externalProfiles
.filter((profile) => profile.persistence !== "persisted")
.map((profile) => profile.profileId),
);
for (const profileId of store.runtimeExternalProfileIds ?? []) {
if (next.profiles[profileId]) {
runtimeOnlyProfileIds.add(profileId);
}
}
next.runtimeExternalProfileIds =
runtimeOnlyProfileIds.size > 0 || options?.runtimeExternalProfileIdsAuthoritative === true
? [...runtimeOnlyProfileIds].toSorted()
: undefined;
next.runtimeExternalProfileIdsAuthoritative =
options?.runtimeExternalProfileIdsAuthoritative === true ? true : undefined;
return next;
}

View File

@@ -6,7 +6,11 @@ import { resolveOAuthDir } from "../../config/paths.js";
import { AUTH_STORE_VERSION } from "./constants.js";
import { legacyOAuthSidecarTestUtils } from "./legacy-oauth-sidecar.js";
import { resolveAuthStorePath } from "./paths.js";
import { coercePersistedAuthProfileStore, loadPersistedAuthProfileStore } from "./persisted.js";
import {
coercePersistedAuthProfileStore,
loadPersistedAuthProfileStore,
mergeAuthProfileStores,
} from "./persisted.js";
function withEnvValue(key: string, value: string | undefined): () => void {
const previous = process.env[key];
@@ -212,4 +216,139 @@ describe("persisted auth profile boundary", () => {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("lets authoritative runtime external metadata remove stale base profiles", () => {
const merged = mergeAuthProfileStores(
{
version: AUTH_STORE_VERSION,
runtimeExternalProfileIds: ["anthropic:claude-cli"],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "stale-access",
refresh: "stale-refresh",
expires: 1,
},
},
order: {
anthropic: ["anthropic:claude-cli"],
},
lastGood: {
anthropic: "anthropic:claude-cli",
},
},
{
version: AUTH_STORE_VERSION,
runtimeExternalProfileIds: [],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {},
},
);
expect(merged.runtimeExternalProfileIds).toEqual([]);
expect(merged.runtimeExternalProfileIdsAuthoritative).toBe(true);
expect(merged.profiles["anthropic:claude-cli"]).toBeUndefined();
expect(merged.order?.anthropic).toBeUndefined();
expect(merged.lastGood?.anthropic).toBeUndefined();
});
it("keeps override profiles when authoritative metadata removes base runtime external state", () => {
const profileId = "anthropic:claude-cli";
const merged = mergeAuthProfileStores(
{
version: AUTH_STORE_VERSION,
runtimeExternalProfileIds: [profileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "stale-access",
refresh: "stale-refresh",
expires: 1,
},
},
order: {
anthropic: [profileId],
},
lastGood: {
anthropic: profileId,
},
},
{
version: AUTH_STORE_VERSION,
runtimeExternalProfileIds: [],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "api_key",
provider: "anthropic",
key: "sk-local",
},
},
order: {
anthropic: [profileId],
},
lastGood: {
anthropic: profileId,
},
},
);
expect(merged.runtimeExternalProfileIds).toEqual([]);
expect(merged.runtimeExternalProfileIdsAuthoritative).toBe(true);
expect(merged.profiles[profileId]).toMatchObject({
type: "api_key",
provider: "anthropic",
key: "sk-local",
});
expect(merged.order?.anthropic).toEqual([profileId]);
expect(merged.lastGood?.anthropic).toBe(profileId);
});
it("preserves inherited base runtime external profiles during agent-store merges", () => {
const profileId = "anthropic:claude-cli";
const merged = mergeAuthProfileStores(
{
version: AUTH_STORE_VERSION,
runtimeExternalProfileIds: [profileId],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "main-access",
refresh: "main-refresh",
expires: 1,
},
},
order: {
anthropic: [profileId],
},
lastGood: {
anthropic: profileId,
},
},
{
version: AUTH_STORE_VERSION,
runtimeExternalProfileIds: [],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {},
},
{ preserveBaseRuntimeExternalProfiles: true },
);
expect(merged.runtimeExternalProfileIds).toEqual([profileId]);
expect(merged.runtimeExternalProfileIdsAuthoritative).toBe(true);
expect(merged.profiles[profileId]).toMatchObject({
type: "oauth",
provider: "anthropic",
access: "main-access",
refresh: "main-refresh",
});
expect(merged.order?.anthropic).toEqual([profileId]);
expect(merged.lastGood?.anthropic).toBe(profileId);
});
});

View File

@@ -715,23 +715,96 @@ function reconcileMainStoreOAuthProfileDrift(params: {
export function mergeAuthProfileStores(
base: AuthProfileStore,
override: AuthProfileStore,
options?: { preserveBaseRuntimeExternalProfiles?: boolean },
): AuthProfileStore {
if (
Object.keys(override.profiles).length === 0 &&
!override.order &&
!override.lastGood &&
!override.usageStats
!override.usageStats &&
override.runtimeExternalProfileIds === undefined &&
override.runtimeExternalProfileIdsAuthoritative !== true
) {
return base;
}
const overrideProfileIds = new Set(Object.keys(override.profiles));
const overrideRuntimeExternalProfileIds = new Set(override.runtimeExternalProfileIds ?? []);
const removedRuntimeExternalProfileIds = new Set(
override.runtimeExternalProfileIdsAuthoritative === true &&
options?.preserveBaseRuntimeExternalProfiles !== true
? (base.runtimeExternalProfileIds ?? []).filter(
(profileId) =>
!overrideRuntimeExternalProfileIds.has(profileId) && !overrideProfileIds.has(profileId),
)
: [],
);
const profiles = { ...base.profiles, ...override.profiles };
for (const profileId of removedRuntimeExternalProfileIds) {
delete profiles[profileId];
}
const mergedOrder = mergeRecord(base.order, override.order);
const order = mergedOrder
? Object.fromEntries(
Object.entries(mergedOrder)
.map(([provider, profileIds]) => [
provider,
profileIds.filter((profileId) => profiles[profileId]),
])
.filter(([, profileIds]) => profileIds.length > 0),
)
: undefined;
const mergedLastGood = mergeRecord(base.lastGood, override.lastGood);
const lastGood = mergedLastGood
? Object.fromEntries(
Object.entries(mergedLastGood).filter(([, profileId]) => profiles[profileId]),
)
: undefined;
const mergedUsageStats = mergeRecord(base.usageStats, override.usageStats);
const usageStats = mergedUsageStats
? Object.fromEntries(
Object.entries(mergedUsageStats).filter(([profileId]) => profiles[profileId]),
)
: undefined;
const merged = {
version: Math.max(base.version, override.version ?? base.version),
profiles: { ...base.profiles, ...override.profiles },
order: mergeRecord(base.order, override.order),
lastGood: mergeRecord(base.lastGood, override.lastGood),
usageStats: mergeRecord(base.usageStats, override.usageStats),
profiles,
order,
lastGood,
usageStats,
};
return reconcileMainStoreOAuthProfileDrift({ base, override, merged });
const baseRuntimeExternalProfileIds =
override.runtimeExternalProfileIdsAuthoritative === true &&
options?.preserveBaseRuntimeExternalProfiles !== true
? []
: (base.runtimeExternalProfileIds ?? []).filter(
(profileId) => !overrideProfileIds.has(profileId),
);
const runtimeExternalProfileIds = [
...baseRuntimeExternalProfileIds,
...(override.runtimeExternalProfileIds ?? []),
]
.filter((profileId) => merged.profiles[profileId])
.toSorted();
const runtimeExternalProfileIdsAuthoritative =
base.runtimeExternalProfileIdsAuthoritative === true ||
override.runtimeExternalProfileIdsAuthoritative === true;
const runtimeExternalProfileMetadata =
runtimeExternalProfileIds.length > 0 || runtimeExternalProfileIdsAuthoritative
? {
runtimeExternalProfileIds: [...new Set(runtimeExternalProfileIds)],
...(runtimeExternalProfileIdsAuthoritative
? { runtimeExternalProfileIdsAuthoritative: true }
: {}),
}
: {};
return reconcileMainStoreOAuthProfileDrift({
base,
override,
merged: {
...merged,
...runtimeExternalProfileMetadata,
},
});
}
export function buildPersistedAuthProfileSecretsStore(

View File

@@ -161,7 +161,9 @@ function resolveRuntimeAuthProfileStore(
}
if (mainStore && requestedStore) {
return mergeAuthProfileStores(mainStore, requestedStore);
return mergeAuthProfileStores(mainStore, requestedStore, {
preserveBaseRuntimeExternalProfiles: true,
});
}
if (requestedStore) {
const persistedMainStore = loadAuthProfileStoreForAgent(undefined, {
@@ -169,7 +171,9 @@ function resolveRuntimeAuthProfileStore(
syncExternalCli: false,
...resolvePersistedLoadOptions(options),
});
return mergeAuthProfileStores(persistedMainStore, requestedStore);
return mergeAuthProfileStores(persistedMainStore, requestedStore, {
preserveBaseRuntimeExternalProfiles: true,
});
}
if (mainStore) {
return mainStore;
@@ -294,6 +298,12 @@ function resolveExternalCliOverlayOptions(
};
}
function hasScopedExternalCliOverlay(options: ResolvedExternalCliOverlayOptions): boolean {
return (
options.externalCliProviderIds !== undefined || options.externalCliProfileIds !== undefined
);
}
function maybeSyncPersistedExternalCliAuthProfiles(params: {
store: AuthProfileStore;
agentDir?: string;
@@ -385,6 +395,24 @@ function shouldKeepProfileInLocalStore(params: {
if (params.options?.filterExternalAuthProfiles === false) {
return true;
}
if (params.store.runtimeExternalProfileIds?.includes(params.profileId)) {
const persistedCredential = loadPersistedAuthProfileStore(params.agentDir)?.profiles[
params.profileId
];
if (persistedCredential) {
return shouldPersistRuntimeExternalOAuthProfile({
profileId: params.profileId,
credential: params.credential,
profiles: params.externalProfiles(),
});
}
const runtimeCredential = getRuntimeAuthProfileStoreSnapshot(params.agentDir)?.profiles[
params.profileId
];
if (!runtimeCredential || isDeepStrictEqual(runtimeCredential, params.credential)) {
return false;
}
}
return shouldPersistRuntimeExternalOAuthProfile({
profileId: params.profileId,
credential: params.credential,
@@ -392,6 +420,44 @@ function shouldKeepProfileInLocalStore(params: {
});
}
function pruneAuthProfileStoreReferences(
store: AuthProfileStore,
keptProfileIds: Set<string>,
): void {
store.order = store.order
? Object.fromEntries(
Object.entries(store.order)
.map(([provider, profileIds]) => [
provider,
profileIds.filter((profileId) => keptProfileIds.has(profileId)),
])
.filter(([, profileIds]) => profileIds.length > 0),
)
: undefined;
store.lastGood = store.lastGood
? Object.fromEntries(
Object.entries(store.lastGood).filter(([, profileId]) => keptProfileIds.has(profileId)),
)
: undefined;
store.usageStats = store.usageStats
? Object.fromEntries(
Object.entries(store.usageStats).filter(([profileId]) => keptProfileIds.has(profileId)),
)
: undefined;
store.runtimeExternalProfileIds = store.runtimeExternalProfileIds
?.filter((profileId) => keptProfileIds.has(profileId))
.toSorted();
if (
store.runtimeExternalProfileIds?.length === 0 &&
store.runtimeExternalProfileIdsAuthoritative !== true
) {
store.runtimeExternalProfileIds = undefined;
}
if (store.runtimeExternalProfileIdsAuthoritative === true) {
store.runtimeExternalProfileIds ??= [];
}
}
function buildLocalAuthProfileStoreForSave(params: {
store: AuthProfileStore;
agentDir?: string;
@@ -417,33 +483,216 @@ function buildLocalAuthProfileStoreForSave(params: {
),
);
const keptProfileIds = new Set(Object.keys(localStore.profiles));
localStore.order = localStore.order
? Object.fromEntries(
Object.entries(localStore.order)
.map(([provider, profileIds]) => [
provider,
profileIds.filter((profileId) => keptProfileIds.has(profileId)),
])
.filter(([, profileIds]) => profileIds.length > 0),
)
: undefined;
localStore.lastGood = localStore.lastGood
? Object.fromEntries(
Object.entries(localStore.lastGood).filter(([, profileId]) =>
keptProfileIds.has(profileId),
),
)
: undefined;
localStore.usageStats = localStore.usageStats
? Object.fromEntries(
Object.entries(localStore.usageStats).filter(([profileId]) =>
keptProfileIds.has(profileId),
),
)
: undefined;
pruneAuthProfileStoreReferences(localStore, keptProfileIds);
if (params.options?.filterExternalAuthProfiles !== false) {
localStore.runtimeExternalProfileIds = undefined;
localStore.runtimeExternalProfileIdsAuthoritative = undefined;
}
return localStore;
}
function buildAuthProfileStoreWithoutExternalProfiles(params: {
store: AuthProfileStore;
agentDir?: string;
options?: Pick<LoadAuthProfileStoreOptions, "allowKeychainPrompt" | "resolveLegacyOAuthSidecars">;
}): AuthProfileStore {
const runtimeExternalProfileIds = new Set(params.store.runtimeExternalProfileIds ?? []);
const localStore = cloneAuthProfileStore(params.store);
if (runtimeExternalProfileIds.size === 0) {
localStore.runtimeExternalProfileIds = undefined;
localStore.runtimeExternalProfileIdsAuthoritative = undefined;
return localStore;
}
for (const profileId of runtimeExternalProfileIds) {
delete localStore.profiles[profileId];
}
const keptProfileIds = new Set(Object.keys(localStore.profiles));
pruneAuthProfileStoreReferences(localStore, keptProfileIds);
localStore.runtimeExternalProfileIds = undefined;
localStore.runtimeExternalProfileIdsAuthoritative = undefined;
const persistedStore = loadAuthProfileStoreWithoutExternalProfiles(
params.agentDir,
params.options,
);
return mergeAuthProfileStores(persistedStore, localStore);
}
function buildRuntimeAuthProfileStoreForSave(params: {
store: AuthProfileStore;
agentDir?: string;
options?: SaveAuthProfileStoreOptions;
}): AuthProfileStore {
return buildLocalAuthProfileStoreForSave({
...params,
options: {
...params.options,
filterExternalAuthProfiles: false,
},
});
}
function setRuntimeExternalProfileMetadata(params: {
store: AuthProfileStore;
profileIds: ReadonlySet<string>;
authoritative: boolean;
}): void {
const profileIds = [...params.profileIds].toSorted();
params.store.runtimeExternalProfileIds =
profileIds.length > 0 || params.authoritative ? profileIds : undefined;
params.store.runtimeExternalProfileIdsAuthoritative = params.authoritative ? true : undefined;
}
function mergeRuntimeExternalProfileReferences(params: {
next: AuthProfileStore;
existing: AuthProfileStore;
}): AuthProfileStore {
const runtimeExternalProfileIds = new Set(params.existing.runtimeExternalProfileIds ?? []);
if (params.next.runtimeExternalProfileIdsAuthoritative === true) {
return params.next;
}
if (runtimeExternalProfileIds.size === 0) {
return params.next;
}
const merged = cloneAuthProfileStore(params.next);
const mergedRuntimeExternalProfileIds = new Set(merged.runtimeExternalProfileIds ?? []);
const backfilledRuntimeExternalProfileIds = new Set<string>();
for (const profileId of runtimeExternalProfileIds) {
const existingCredential = params.existing.profiles[profileId];
const nextCredential = merged.profiles[profileId];
if (nextCredential) {
if (
mergedRuntimeExternalProfileIds.has(profileId) ||
(existingCredential && isDeepStrictEqual(nextCredential, existingCredential))
) {
mergedRuntimeExternalProfileIds.add(profileId);
}
continue;
}
if (!existingCredential) {
continue;
}
merged.profiles[profileId] = existingCredential;
mergedRuntimeExternalProfileIds.add(profileId);
backfilledRuntimeExternalProfileIds.add(profileId);
if (params.existing.usageStats?.[profileId]) {
merged.usageStats = {
...merged.usageStats,
[profileId]: params.existing.usageStats[profileId],
};
}
}
for (const [provider, profileIds] of Object.entries(params.existing.order ?? {})) {
const externalProfileIds = profileIds.filter((profileId) =>
backfilledRuntimeExternalProfileIds.has(profileId),
);
if (externalProfileIds.length === 0) {
continue;
}
if (merged.order?.[provider]) {
continue;
}
const existingOrder = merged.order?.[provider] ?? [];
merged.order = {
...merged.order,
[provider]: [
...externalProfileIds,
...existingOrder.filter((profileId) => !externalProfileIds.includes(profileId)),
],
};
}
for (const [provider, profileId] of Object.entries(params.existing.lastGood ?? {})) {
if (!backfilledRuntimeExternalProfileIds.has(profileId) || merged.lastGood?.[provider]) {
continue;
}
merged.lastGood = {
...merged.lastGood,
[provider]: profileId,
};
}
setRuntimeExternalProfileMetadata({
store: merged,
profileIds: mergedRuntimeExternalProfileIds,
authoritative: params.existing.runtimeExternalProfileIdsAuthoritative === true,
});
return merged;
}
function mergeRuntimeExternalProfileState(params: {
next: AuthProfileStore;
existing: AuthProfileStore;
}): AuthProfileStore {
const existingRuntimeProfileIds = new Set(params.existing.runtimeExternalProfileIds ?? []);
if (existingRuntimeProfileIds.size === 0) {
return params.next;
}
const merged = cloneAuthProfileStore(params.next);
const mergedRuntimeProfileIds = new Set(merged.runtimeExternalProfileIds ?? []);
const activeRuntimeProfileIds = new Set<string>();
const nextRuntimeProfileIdsAuthoritative =
params.next.runtimeExternalProfileIdsAuthoritative === true;
for (const profileId of existingRuntimeProfileIds) {
if (nextRuntimeProfileIdsAuthoritative && !mergedRuntimeProfileIds.has(profileId)) {
continue;
}
const existingCredential = params.existing.profiles[profileId];
if (!existingCredential) {
continue;
}
const nextCredential = merged.profiles[profileId];
if (nextCredential) {
if (
mergedRuntimeProfileIds.has(profileId) ||
isDeepStrictEqual(nextCredential, existingCredential)
) {
mergedRuntimeProfileIds.add(profileId);
activeRuntimeProfileIds.add(profileId);
}
continue;
}
merged.profiles[profileId] = existingCredential;
mergedRuntimeProfileIds.add(profileId);
activeRuntimeProfileIds.add(profileId);
}
if (activeRuntimeProfileIds.size === 0) {
return params.next;
}
for (const profileId of activeRuntimeProfileIds) {
if (params.existing.usageStats?.[profileId]) {
merged.usageStats = {
...merged.usageStats,
[profileId]: params.existing.usageStats[profileId],
};
}
}
for (const [provider, profileIds] of Object.entries(params.existing.order ?? {})) {
const externalProfileIds = profileIds.filter((profileId) =>
activeRuntimeProfileIds.has(profileId),
);
if (externalProfileIds.length === 0 || merged.order?.[provider]) {
continue;
}
merged.order = {
...merged.order,
[provider]: externalProfileIds,
};
}
for (const [provider, profileId] of Object.entries(params.existing.lastGood ?? {})) {
if (!activeRuntimeProfileIds.has(profileId) || merged.lastGood?.[provider]) {
continue;
}
merged.lastGood = {
...merged.lastGood,
[provider]: profileId,
};
}
setRuntimeExternalProfileMetadata({
store: merged,
profileIds: mergedRuntimeProfileIds,
authoritative: params.existing.runtimeExternalProfileIdsAuthoritative === true,
});
return merged;
}
export async function updateAuthProfileStoreWithLock(params: {
agentDir?: string;
saveOptions?: SaveAuthProfileStoreOptions;
@@ -591,10 +840,15 @@ export function loadAuthProfileStoreForRuntime(
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return overlayExternalAuthProfiles(mergeAuthProfileStores(mainStore, store), {
agentDir,
...externalCli,
});
return overlayExternalAuthProfiles(
mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
}),
{
agentDir,
...externalCli,
},
);
}
export function loadAuthProfileStoreForSecretsRuntime(
@@ -632,7 +886,9 @@ export function loadAuthProfileStoreWithoutExternalProfiles(
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return mergeAuthProfileStores(mainStore, store);
return mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
});
}
export function ensureAuthProfileStore(
@@ -646,13 +902,21 @@ export function ensureAuthProfileStore(
},
): AuthProfileStore {
const externalCli = resolveExternalCliOverlayOptions(options);
return overlayExternalAuthProfiles(
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir, options);
const store = overlayExternalAuthProfiles(
ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options),
{
agentDir,
...externalCli,
},
);
if (!runtimeStore || hasScopedExternalCliOverlay(externalCli)) {
return store;
}
return mergeRuntimeExternalProfileState({
next: store,
existing: runtimeStore,
});
}
export function ensureAuthProfileStoreWithoutExternalProfiles(
@@ -665,7 +929,11 @@ export function ensureAuthProfileStoreWithoutExternalProfiles(
};
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir, effectiveOptions);
if (runtimeStore) {
return runtimeStore;
return buildAuthProfileStoreWithoutExternalProfiles({
store: runtimeStore,
agentDir,
options: effectiveOptions,
});
}
const store = loadAuthProfileStoreForAgent(agentDir, effectiveOptions);
const authPath = resolveAuthStorePath(agentDir);
@@ -675,7 +943,9 @@ export function ensureAuthProfileStoreWithoutExternalProfiles(
}
const mainStore = loadAuthProfileStoreForAgent(undefined, effectiveOptions);
return mergeAuthProfileStores(mainStore, store);
return mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
});
}
export function findPersistedAuthProfileCredential(params: {
@@ -738,7 +1008,9 @@ export function ensureAuthProfileStoreForLocalUpdate(agentDir?: string): AuthPro
readOnly: true,
syncExternalCli: false,
});
return mergeAuthProfileStores(mainStore, store);
return mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
});
}
export { hasAnyAuthProfileStoreSource } from "./source-check.js";
@@ -784,6 +1056,16 @@ export function saveAuthProfileStore(
store: localStore,
});
if (hasRuntimeAuthProfileStoreSnapshot(agentDir)) {
setRuntimeAuthProfileStoreSnapshot(localStore, agentDir);
const existingRuntimeStore = getRuntimeAuthProfileStoreSnapshot(agentDir);
const nextRuntimeStore = buildRuntimeAuthProfileStoreForSave({ store, agentDir, options });
setRuntimeAuthProfileStoreSnapshot(
existingRuntimeStore
? mergeRuntimeExternalProfileReferences({
next: nextRuntimeStore,
existing: existingRuntimeStore,
})
: nextRuntimeStore,
agentDir,
);
}
}

View File

@@ -117,7 +117,13 @@ export type AuthProfileStateStore = {
version: number;
} & AuthProfileState;
export type AuthProfileStore = AuthProfileSecretsStore & AuthProfileState;
export type AuthProfileStore = AuthProfileSecretsStore &
AuthProfileState & {
/** Runtime-only provenance for external OAuth profiles overlaid onto this store. */
runtimeExternalProfileIds?: string[];
/** True when the runtime external profile set was freshly resolved, even if empty. */
runtimeExternalProfileIdsAuthoritative?: boolean;
};
export type AuthProfileIdRepairResult = {
config: OpenClawConfig;