Skip to content

Commit 25f16f8

Browse files
committed
fix: preserve cli oauth session continuity
1 parent 597dcb1 commit 25f16f8

5 files changed

Lines changed: 132 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Docs: https://docs.openclaw.ai
132132

133133
### Fixes
134134

135+
- CLI backends: keep versioned OAuth identity matches reusable when auth profile ids rotate, so Claude CLI sessions do not reset and lose continuity during same-account OAuth refresh/profile alias changes. Fixes #78541.
135136
- Anthropic: reject uppercase provider-prefixed forward-compat model ids locally instead of sending malformed dynamic ids upstream. Fixes #73715.
136137
- OpenAI/embeddings: pass configured output dimensionality through single and batched embedding requests so memory embedding indexes can request smaller vectors. Fixes #55126.
137138
- CLI/infer: normalize HEIC/HEIF image files to JPEG before model-run requests, avoiding providers that reject Apple image container formats. Fixes #50081.

src/agents/cli-auth-epoch.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,85 @@ describe("resolveCliAuthEpoch", () => {
184184
expect(second).toBe(first);
185185
});
186186

187+
it("keeps oauth auth-profile epochs stable across profile id aliases for the same account", async () => {
188+
const store: AuthProfileStore = {
189+
version: 1,
190+
profiles: {
191+
"anthropic:work": {
192+
type: "oauth",
193+
provider: "anthropic",
194+
access: "access-a",
195+
refresh: "refresh-a",
196+
expires: 1,
197+
198+
},
199+
"anthropic:work-alias": {
200+
type: "oauth",
201+
provider: "anthropic",
202+
access: "access-b",
203+
refresh: "refresh-b",
204+
expires: 2,
205+
206+
},
207+
},
208+
};
209+
setCliAuthEpochTestDeps({
210+
readGeminiCliCredentialsCached: () => null,
211+
loadAuthProfileStoreForRuntime: () => store,
212+
});
213+
214+
const first = await resolveCliAuthEpoch({
215+
provider: "google-gemini-cli",
216+
authProfileId: "anthropic:work",
217+
});
218+
const second = await resolveCliAuthEpoch({
219+
provider: "google-gemini-cli",
220+
authProfileId: "anthropic:work-alias",
221+
});
222+
223+
expect(first).toBeDefined();
224+
expect(second).toBe(first);
225+
});
226+
227+
it("keeps identity-less oauth auth-profile epochs scoped to the profile id", async () => {
228+
const store: AuthProfileStore = {
229+
version: 1,
230+
profiles: {
231+
"anthropic:work": {
232+
type: "oauth",
233+
provider: "anthropic",
234+
access: "access-a",
235+
refresh: "refresh-a",
236+
expires: 1,
237+
},
238+
"anthropic:personal": {
239+
type: "oauth",
240+
provider: "anthropic",
241+
access: "access-b",
242+
refresh: "refresh-b",
243+
expires: 2,
244+
},
245+
},
246+
};
247+
setCliAuthEpochTestDeps({
248+
readGeminiCliCredentialsCached: () => null,
249+
loadAuthProfileStoreForRuntime: () => store,
250+
});
251+
252+
const first = await resolveCliAuthEpoch({
253+
provider: "google-gemini-cli",
254+
authProfileId: "anthropic:work",
255+
});
256+
const second = await resolveCliAuthEpoch({
257+
provider: "google-gemini-cli",
258+
authProfileId: "anthropic:personal",
259+
});
260+
261+
expect(first).toBeDefined();
262+
expect(second).toBeDefined();
263+
expect(second).not.toBe(first);
264+
});
265+
187266
it("changes oauth auth-profile epochs when the account identity changes", async () => {
188267
let store: AuthProfileStore = {
189268
version: 1,

src/agents/cli-auth-epoch.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,25 @@ function encodeAuthProfileCredential(credential: AuthProfileCredential): string
114114
throw new Error("Unsupported auth profile credential type");
115115
}
116116

117+
function hasOAuthAccountIdentity(credential: AuthProfileCredential): boolean {
118+
return (
119+
credential.type === "oauth" &&
120+
(normalizeOptionalString(credential.accountId) !== undefined ||
121+
normalizeOptionalString(credential.email) !== undefined)
122+
);
123+
}
124+
125+
function encodeAuthProfileEpochPart(
126+
authProfileId: string,
127+
credential: AuthProfileCredential,
128+
): string {
129+
const credentialHash = hashCliAuthEpochPart(encodeAuthProfileCredential(credential));
130+
if (hasOAuthAccountIdentity(credential)) {
131+
return `profile:oauth-identity:${credentialHash}`;
132+
}
133+
return `profile:${authProfileId}:${credentialHash}`;
134+
}
135+
117136
function getLocalCliCredentialFingerprint(provider: string): string | undefined {
118137
switch (provider) {
119138
case "claude-cli": {
@@ -174,9 +193,7 @@ export async function resolveCliAuthEpoch(params: {
174193
});
175194
const credential = getAuthProfileCredential(store, authProfileId);
176195
if (credential) {
177-
parts.push(
178-
`profile:${authProfileId}:${hashCliAuthEpochPart(encodeAuthProfileCredential(credential))}`,
179-
);
196+
parts.push(encodeAuthProfileEpochPart(authProfileId, credential));
180197
}
181198
}
182199

src/agents/cli-session.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ describe("cli-session helpers", () => {
128128
resolveCliSessionReuse({
129129
binding,
130130
authProfileId: "anthropic:personal",
131-
authEpoch: "auth-epoch-a",
131+
authEpoch: "auth-epoch-b",
132132
authEpochVersion: 2,
133133
extraSystemPromptHash: "prompt-a",
134134
mcpConfigHash: "mcp-a",
@@ -166,6 +166,28 @@ describe("cli-session helpers", () => {
166166
).toEqual({ invalidatedReason: "mcp" });
167167
});
168168

169+
it("reuses when auth profile ids rotate but the versioned auth epoch is stable", () => {
170+
const binding = {
171+
sessionId: "cli-session-1",
172+
authProfileId: "anthropic:work",
173+
authEpoch: "auth-epoch-a",
174+
authEpochVersion: 2,
175+
extraSystemPromptHash: "prompt-a",
176+
mcpConfigHash: "mcp-a",
177+
};
178+
179+
expect(
180+
resolveCliSessionReuse({
181+
binding,
182+
authProfileId: "anthropic:work-alias",
183+
authEpoch: "auth-epoch-a",
184+
authEpochVersion: 2,
185+
extraSystemPromptHash: "prompt-a",
186+
mcpConfigHash: "mcp-a",
187+
}),
188+
).toEqual({ sessionId: "cli-session-1" });
189+
});
190+
169191
it("accepts unversioned auth epochs for binding upgrades", () => {
170192
const binding = {
171193
sessionId: "cli-session-1",

src/agents/cli-session.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,17 @@ export function resolveCliSessionReuse(params: {
150150
const currentMcpConfigHash = normalizeOptionalString(params.mcpConfigHash);
151151
const currentMcpResumeHash = normalizeOptionalString(params.mcpResumeHash);
152152
const storedAuthProfileId = normalizeOptionalString(binding?.authProfileId);
153+
const storedAuthEpoch = normalizeOptionalString(binding?.authEpoch);
154+
const hasMatchingVersionedAuthEpoch =
155+
binding?.authEpochVersion === params.authEpochVersion &&
156+
storedAuthEpoch !== undefined &&
157+
currentAuthEpoch !== undefined &&
158+
storedAuthEpoch === currentAuthEpoch;
153159
if (storedAuthProfileId !== currentAuthProfileId) {
154-
return { invalidatedReason: "auth-profile" };
160+
if (!hasMatchingVersionedAuthEpoch) {
161+
return { invalidatedReason: "auth-profile" };
162+
}
155163
}
156-
const storedAuthEpoch = normalizeOptionalString(binding?.authEpoch);
157164
if (
158165
binding?.authEpochVersion === params.authEpochVersion &&
159166
storedAuthEpoch !== currentAuthEpoch

0 commit comments

Comments
 (0)