From 711e963723b4e17cc5376684c3deeafc2bf4dd25 Mon Sep 17 00:00:00 2001 From: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Date: Mon, 25 May 2026 21:41:59 -0700 Subject: [PATCH] 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 a73074ed45bf4d7663c6cca4b7751c5f3ee65ae4. - Required merge gates passed before the squash merge. Prepared head SHA: a73074ed45bf4d7663c6cca4b7751c5f3ee65ae4 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> --- src/agents/auth-profiles.store.save.test.ts | 790 +++++++++++++++++- src/agents/auth-profiles/external-auth.ts | 8 +- src/agents/auth-profiles/oauth-shared.test.ts | 65 ++ src/agents/auth-profiles/oauth-shared.ts | 20 +- .../auth-profiles/persisted-boundary.test.ts | 141 +++- src/agents/auth-profiles/persisted.ts | 85 +- src/agents/auth-profiles/store.ts | 354 +++++++- src/agents/auth-profiles/types.ts | 8 +- 8 files changed, 1413 insertions(+), 58 deletions(-) diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index 83414d869df..c62f33a93f4 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -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 } | 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: (store: T) => store, - shouldPersistExternalAuthProfile: () => true, + listRuntimeExternalAuthProfiles: externalAuthMocks.listRuntimeExternalAuthProfiles, + overlayExternalAuthProfiles: externalAuthMocks.overlayExternalAuthProfiles, + shouldPersistExternalAuthProfile: externalAuthMocks.shouldPersistExternalAuthProfile, syncPersistedExternalCliAuthProfiles: (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; + }; + 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; + lastGood?: Record; + usageStats?: Record; + }; + 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; + }; + 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; + }; + 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; + }; + 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; + }; + 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; + }; + 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 }); + } + }); }); diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index 6391b08afb4..6080976c394 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -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: { diff --git a/src/agents/auth-profiles/oauth-shared.test.ts b/src/agents/auth-profiles/oauth-shared.test.ts index 2b6601b1038..6dbaa80d7e5 100644 --- a/src/agents/auth-profiles/oauth-shared.test.ts +++ b/src/agents/auth-profiles/oauth-shared.test.ts @@ -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); + }); }); diff --git a/src/agents/auth-profiles/oauth-shared.ts b/src/agents/auth-profiles/oauth-shared.ts index dda697ec958..046e693b315 100644 --- a/src/agents/auth-profiles/oauth-shared.ts +++ b/src/agents/auth-profiles/oauth-shared.ts @@ -169,15 +169,29 @@ export function shouldBootstrapFromExternalCliCredential(params: { export function overlayRuntimeExternalOAuthProfiles( store: AuthProfileStore, profiles: Iterable, + 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; } diff --git a/src/agents/auth-profiles/persisted-boundary.test.ts b/src/agents/auth-profiles/persisted-boundary.test.ts index f87ea710caa..2627701e2b5 100644 --- a/src/agents/auth-profiles/persisted-boundary.test.ts +++ b/src/agents/auth-profiles/persisted-boundary.test.ts @@ -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); + }); }); diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index c2f633a8f8b..ad32ecb7c5b 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -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( diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index bb609f2043a..c638d108cc3 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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, +): 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; +}): 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; + 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(); + 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(); + 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, + ); } } diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 6bd704dfb8c..dfe535f3bf8 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -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;