Skip to content

Commit 63bb721

Browse files
TurboTheTurtlevincentkoc
authored andcommitted
fix: preserve runtime external auth snapshots
1 parent 5dccba7 commit 63bb721

8 files changed

Lines changed: 1316 additions & 51 deletions

File tree

src/agents/auth-profiles.store.save.test.ts

Lines changed: 699 additions & 3 deletions
Large diffs are not rendered by default.

src/agents/auth-profiles/external-auth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ function hasPersistableExternalCliSyncCandidate(
121121
return false;
122122
}
123123

124+
function hasScopedExternalCliOverlay(params?: ExternalCliOverlayOptions): boolean {
125+
return Boolean(params?.externalCliProviderIds || params?.externalCliProfileIds);
126+
}
127+
124128
export function overlayExternalAuthProfiles(
125129
store: AuthProfileStore,
126130
params?: { agentDir?: string; env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions,
@@ -131,7 +135,9 @@ export function overlayExternalAuthProfiles(
131135
env: params?.env,
132136
externalCli: params,
133137
});
134-
return overlayRuntimeExternalOAuthProfiles(store, profiles);
138+
return overlayRuntimeExternalOAuthProfiles(store, profiles, {
139+
runtimeExternalProfileIdsAuthoritative: !hasScopedExternalCliOverlay(params),
140+
});
135141
}
136142

137143
export function shouldPersistExternalAuthProfile(params: {

src/agents/auth-profiles/oauth-shared.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,69 @@ describe("overlayRuntimeExternalOAuthProfiles", () => {
5151
structuredCloneSpy.mockRestore();
5252
}
5353
});
54+
55+
it("preserves existing runtime-only provenance for non-authoritative overlays", () => {
56+
const store: AuthProfileStore = {
57+
version: 1,
58+
runtimeExternalProfileIds: ["minimax:minimax-cli"],
59+
profiles: {
60+
"anthropic:claude-cli": {
61+
type: "oauth",
62+
provider: "anthropic",
63+
access: "old-access",
64+
refresh: "old-refresh",
65+
expires: 1,
66+
},
67+
"minimax:minimax-cli": {
68+
type: "oauth",
69+
provider: "minimax-portal",
70+
access: "minimax-access",
71+
refresh: "minimax-refresh",
72+
expires: 1,
73+
},
74+
},
75+
};
76+
77+
const overlaid = overlayRuntimeExternalOAuthProfiles(store, [
78+
{
79+
profileId: "anthropic:claude-cli",
80+
credential: {
81+
type: "oauth",
82+
provider: "anthropic",
83+
access: "new-access",
84+
refresh: "new-refresh",
85+
expires: 2,
86+
},
87+
},
88+
]);
89+
90+
expect(overlaid.runtimeExternalProfileIds).toEqual([
91+
"anthropic:claude-cli",
92+
"minimax:minimax-cli",
93+
]);
94+
});
95+
96+
it("preserves existing runtime-only provenance for authoritative overlays", () => {
97+
const store: AuthProfileStore = {
98+
version: 1,
99+
runtimeExternalProfileIds: ["minimax:minimax-cli"],
100+
runtimeExternalProfileIdsAuthoritative: true,
101+
profiles: {
102+
"minimax:minimax-cli": {
103+
type: "oauth",
104+
provider: "minimax-portal",
105+
access: "minimax-access",
106+
refresh: "minimax-refresh",
107+
expires: 1,
108+
},
109+
},
110+
};
111+
112+
const overlaid = overlayRuntimeExternalOAuthProfiles(store, [], {
113+
runtimeExternalProfileIdsAuthoritative: true,
114+
});
115+
116+
expect(overlaid.runtimeExternalProfileIds).toEqual(["minimax:minimax-cli"]);
117+
expect(overlaid.runtimeExternalProfileIdsAuthoritative).toBe(true);
118+
});
54119
});

src/agents/auth-profiles/oauth-shared.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,15 +169,29 @@ export function shouldBootstrapFromExternalCliCredential(params: {
169169
export function overlayRuntimeExternalOAuthProfiles(
170170
store: AuthProfileStore,
171171
profiles: Iterable<RuntimeExternalOAuthProfile>,
172+
options?: { runtimeExternalProfileIdsAuthoritative?: boolean },
172173
): AuthProfileStore {
173174
const externalProfiles = Array.from(profiles);
174-
if (externalProfiles.length === 0) {
175-
return store;
176-
}
177175
const next = cloneAuthProfileStore(store);
178176
for (const profile of externalProfiles) {
179177
next.profiles[profile.profileId] = profile.credential;
180178
}
179+
const runtimeOnlyProfileIds = new Set(
180+
externalProfiles
181+
.filter((profile) => profile.persistence !== "persisted")
182+
.map((profile) => profile.profileId),
183+
);
184+
for (const profileId of store.runtimeExternalProfileIds ?? []) {
185+
if (next.profiles[profileId]) {
186+
runtimeOnlyProfileIds.add(profileId);
187+
}
188+
}
189+
next.runtimeExternalProfileIds =
190+
runtimeOnlyProfileIds.size > 0 || options?.runtimeExternalProfileIdsAuthoritative === true
191+
? [...runtimeOnlyProfileIds].toSorted()
192+
: undefined;
193+
next.runtimeExternalProfileIdsAuthoritative =
194+
options?.runtimeExternalProfileIdsAuthoritative === true ? true : undefined;
181195
return next;
182196
}
183197

src/agents/auth-profiles/persisted-boundary.test.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { resolveOAuthDir } from "../../config/paths.js";
66
import { AUTH_STORE_VERSION } from "./constants.js";
77
import { legacyOAuthSidecarTestUtils } from "./legacy-oauth-sidecar.js";
88
import { resolveAuthStorePath } from "./paths.js";
9-
import { coercePersistedAuthProfileStore, loadPersistedAuthProfileStore } from "./persisted.js";
9+
import {
10+
coercePersistedAuthProfileStore,
11+
loadPersistedAuthProfileStore,
12+
mergeAuthProfileStores,
13+
} from "./persisted.js";
1014

1115
function withEnvValue(key: string, value: string | undefined): () => void {
1216
const previous = process.env[key];
@@ -212,4 +216,139 @@ describe("persisted auth profile boundary", () => {
212216
fs.rmSync(stateDir, { recursive: true, force: true });
213217
}
214218
});
219+
220+
it("lets authoritative runtime external metadata remove stale base profiles", () => {
221+
const merged = mergeAuthProfileStores(
222+
{
223+
version: AUTH_STORE_VERSION,
224+
runtimeExternalProfileIds: ["anthropic:claude-cli"],
225+
runtimeExternalProfileIdsAuthoritative: true,
226+
profiles: {
227+
"anthropic:claude-cli": {
228+
type: "oauth",
229+
provider: "anthropic",
230+
access: "stale-access",
231+
refresh: "stale-refresh",
232+
expires: 1,
233+
},
234+
},
235+
order: {
236+
anthropic: ["anthropic:claude-cli"],
237+
},
238+
lastGood: {
239+
anthropic: "anthropic:claude-cli",
240+
},
241+
},
242+
{
243+
version: AUTH_STORE_VERSION,
244+
runtimeExternalProfileIds: [],
245+
runtimeExternalProfileIdsAuthoritative: true,
246+
profiles: {},
247+
},
248+
);
249+
250+
expect(merged.runtimeExternalProfileIds).toEqual([]);
251+
expect(merged.runtimeExternalProfileIdsAuthoritative).toBe(true);
252+
expect(merged.profiles["anthropic:claude-cli"]).toBeUndefined();
253+
expect(merged.order?.anthropic).toBeUndefined();
254+
expect(merged.lastGood?.anthropic).toBeUndefined();
255+
});
256+
257+
it("keeps override profiles when authoritative metadata removes base runtime external state", () => {
258+
const profileId = "anthropic:claude-cli";
259+
const merged = mergeAuthProfileStores(
260+
{
261+
version: AUTH_STORE_VERSION,
262+
runtimeExternalProfileIds: [profileId],
263+
runtimeExternalProfileIdsAuthoritative: true,
264+
profiles: {
265+
[profileId]: {
266+
type: "oauth",
267+
provider: "anthropic",
268+
access: "stale-access",
269+
refresh: "stale-refresh",
270+
expires: 1,
271+
},
272+
},
273+
order: {
274+
anthropic: [profileId],
275+
},
276+
lastGood: {
277+
anthropic: profileId,
278+
},
279+
},
280+
{
281+
version: AUTH_STORE_VERSION,
282+
runtimeExternalProfileIds: [],
283+
runtimeExternalProfileIdsAuthoritative: true,
284+
profiles: {
285+
[profileId]: {
286+
type: "api_key",
287+
provider: "anthropic",
288+
key: "sk-local",
289+
},
290+
},
291+
order: {
292+
anthropic: [profileId],
293+
},
294+
lastGood: {
295+
anthropic: profileId,
296+
},
297+
},
298+
);
299+
300+
expect(merged.runtimeExternalProfileIds).toEqual([]);
301+
expect(merged.runtimeExternalProfileIdsAuthoritative).toBe(true);
302+
expect(merged.profiles[profileId]).toMatchObject({
303+
type: "api_key",
304+
provider: "anthropic",
305+
key: "sk-local",
306+
});
307+
expect(merged.order?.anthropic).toEqual([profileId]);
308+
expect(merged.lastGood?.anthropic).toBe(profileId);
309+
});
310+
311+
it("preserves inherited base runtime external profiles during agent-store merges", () => {
312+
const profileId = "anthropic:claude-cli";
313+
const merged = mergeAuthProfileStores(
314+
{
315+
version: AUTH_STORE_VERSION,
316+
runtimeExternalProfileIds: [profileId],
317+
runtimeExternalProfileIdsAuthoritative: true,
318+
profiles: {
319+
[profileId]: {
320+
type: "oauth",
321+
provider: "anthropic",
322+
access: "main-access",
323+
refresh: "main-refresh",
324+
expires: 1,
325+
},
326+
},
327+
order: {
328+
anthropic: [profileId],
329+
},
330+
lastGood: {
331+
anthropic: profileId,
332+
},
333+
},
334+
{
335+
version: AUTH_STORE_VERSION,
336+
runtimeExternalProfileIds: [],
337+
runtimeExternalProfileIdsAuthoritative: true,
338+
profiles: {},
339+
},
340+
{ preserveBaseRuntimeExternalProfiles: true },
341+
);
342+
343+
expect(merged.runtimeExternalProfileIds).toEqual([profileId]);
344+
expect(merged.runtimeExternalProfileIdsAuthoritative).toBe(true);
345+
expect(merged.profiles[profileId]).toMatchObject({
346+
type: "oauth",
347+
provider: "anthropic",
348+
access: "main-access",
349+
refresh: "main-refresh",
350+
});
351+
expect(merged.order?.anthropic).toEqual([profileId]);
352+
expect(merged.lastGood?.anthropic).toBe(profileId);
353+
});
215354
});

src/agents/auth-profiles/persisted.ts

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -715,23 +715,96 @@ function reconcileMainStoreOAuthProfileDrift(params: {
715715
export function mergeAuthProfileStores(
716716
base: AuthProfileStore,
717717
override: AuthProfileStore,
718+
options?: { preserveBaseRuntimeExternalProfiles?: boolean },
718719
): AuthProfileStore {
719720
if (
720721
Object.keys(override.profiles).length === 0 &&
721722
!override.order &&
722723
!override.lastGood &&
723-
!override.usageStats
724+
!override.usageStats &&
725+
override.runtimeExternalProfileIds === undefined &&
726+
override.runtimeExternalProfileIdsAuthoritative !== true
724727
) {
725728
return base;
726729
}
730+
const overrideProfileIds = new Set(Object.keys(override.profiles));
731+
const overrideRuntimeExternalProfileIds = new Set(override.runtimeExternalProfileIds ?? []);
732+
const removedRuntimeExternalProfileIds = new Set(
733+
override.runtimeExternalProfileIdsAuthoritative === true &&
734+
options?.preserveBaseRuntimeExternalProfiles !== true
735+
? (base.runtimeExternalProfileIds ?? []).filter(
736+
(profileId) =>
737+
!overrideRuntimeExternalProfileIds.has(profileId) && !overrideProfileIds.has(profileId),
738+
)
739+
: [],
740+
);
741+
const profiles = { ...base.profiles, ...override.profiles };
742+
for (const profileId of removedRuntimeExternalProfileIds) {
743+
delete profiles[profileId];
744+
}
745+
const mergedOrder = mergeRecord(base.order, override.order);
746+
const order = mergedOrder
747+
? Object.fromEntries(
748+
Object.entries(mergedOrder)
749+
.map(([provider, profileIds]) => [
750+
provider,
751+
profileIds.filter((profileId) => profiles[profileId]),
752+
])
753+
.filter(([, profileIds]) => profileIds.length > 0),
754+
)
755+
: undefined;
756+
const mergedLastGood = mergeRecord(base.lastGood, override.lastGood);
757+
const lastGood = mergedLastGood
758+
? Object.fromEntries(
759+
Object.entries(mergedLastGood).filter(([, profileId]) => profiles[profileId]),
760+
)
761+
: undefined;
762+
const mergedUsageStats = mergeRecord(base.usageStats, override.usageStats);
763+
const usageStats = mergedUsageStats
764+
? Object.fromEntries(
765+
Object.entries(mergedUsageStats).filter(([profileId]) => profiles[profileId]),
766+
)
767+
: undefined;
727768
const merged = {
728769
version: Math.max(base.version, override.version ?? base.version),
729-
profiles: { ...base.profiles, ...override.profiles },
730-
order: mergeRecord(base.order, override.order),
731-
lastGood: mergeRecord(base.lastGood, override.lastGood),
732-
usageStats: mergeRecord(base.usageStats, override.usageStats),
770+
profiles,
771+
order,
772+
lastGood,
773+
usageStats,
733774
};
734-
return reconcileMainStoreOAuthProfileDrift({ base, override, merged });
775+
const baseRuntimeExternalProfileIds =
776+
override.runtimeExternalProfileIdsAuthoritative === true &&
777+
options?.preserveBaseRuntimeExternalProfiles !== true
778+
? []
779+
: (base.runtimeExternalProfileIds ?? []).filter(
780+
(profileId) => !overrideProfileIds.has(profileId),
781+
);
782+
const runtimeExternalProfileIds = [
783+
...baseRuntimeExternalProfileIds,
784+
...(override.runtimeExternalProfileIds ?? []),
785+
]
786+
.filter((profileId) => merged.profiles[profileId])
787+
.toSorted();
788+
const runtimeExternalProfileIdsAuthoritative =
789+
base.runtimeExternalProfileIdsAuthoritative === true ||
790+
override.runtimeExternalProfileIdsAuthoritative === true;
791+
const runtimeExternalProfileMetadata =
792+
runtimeExternalProfileIds.length > 0 || runtimeExternalProfileIdsAuthoritative
793+
? {
794+
runtimeExternalProfileIds: [...new Set(runtimeExternalProfileIds)],
795+
...(runtimeExternalProfileIdsAuthoritative
796+
? { runtimeExternalProfileIdsAuthoritative: true }
797+
: {}),
798+
}
799+
: {};
800+
return reconcileMainStoreOAuthProfileDrift({
801+
base,
802+
override,
803+
merged: {
804+
...merged,
805+
...runtimeExternalProfileMetadata,
806+
},
807+
});
735808
}
736809

737810
export function buildPersistedAuthProfileSecretsStore(

0 commit comments

Comments
 (0)