fix(coding-agent): surface auth storage save failures

closes #6223
This commit is contained in:
Vegard Stikbakke
2026-07-01 21:24:26 +02:00
parent f58c115626
commit f8bec25f34
3 changed files with 50 additions and 8 deletions
+1
View File
@@ -9,6 +9,7 @@
### Fixed ### Fixed
- Fixed `/login` to report auth storage persistence failures instead of claiming credentials were saved when `auth.json` is locked ([#6223](https://github.com/earendil-works/pi/issues/6223)).
- Fixed split-turn compaction to serialize summary requests so single-concurrency local providers do not fail with 429 errors ([#5536](https://github.com/earendil-works/pi/issues/5536)). - Fixed split-turn compaction to serialize summary requests so single-concurrency local providers do not fail with 429 errors ([#5536](https://github.com/earendil-works/pi/issues/5536)).
- Fixed custom session entries appended during assistant streaming to render before the live assistant message, matching persisted session order. - Fixed custom session entries appended during assistant streaming to render before the live assistant message, matching persisted session order.
- Fixed oversized bash tool timeouts to fail with a clear validation error instead of being clamped to an immediate timeout ([#6181](https://github.com/earendil-works/pi/issues/6181)). - Fixed oversized bash tool timeouts to fail with a clear validation error instead of being clamped to an immediate timeout ([#6181](https://github.com/earendil-works/pi/issues/6181)).
+17 -6
View File
@@ -271,12 +271,21 @@ export class AuthStorage {
} }
} }
private persistProviderChange(provider: string, credential: AuthCredential | undefined): void { private persistProviderChange(provider: string, credential: AuthCredential | undefined): AuthStorageData {
if (this.loadError) { if (this.loadError) {
return; this.reload();
}
if (this.loadError) {
const error = new Error(
`Cannot update auth storage because it could not be loaded: ${this.loadError.message}`,
);
this.recordError(error);
throw error;
} }
try { try {
let persistedData: AuthStorageData = {};
this.storage.withLock((current) => { this.storage.withLock((current) => {
const currentData = this.parseStorageData(current); const currentData = this.parseStorageData(current);
const merged: AuthStorageData = { ...currentData }; const merged: AuthStorageData = { ...currentData };
@@ -285,10 +294,14 @@ export class AuthStorage {
} else { } else {
delete merged[provider]; delete merged[provider];
} }
persistedData = merged;
return { result: undefined, next: JSON.stringify(merged, null, 2) }; return { result: undefined, next: JSON.stringify(merged, null, 2) };
}); });
this.loadError = null;
return persistedData;
} catch (error) { } catch (error) {
this.recordError(error); this.recordError(error);
throw error;
} }
} }
@@ -311,16 +324,14 @@ export class AuthStorage {
* Set credential for a provider. * Set credential for a provider.
*/ */
set(provider: string, credential: AuthCredential): void { set(provider: string, credential: AuthCredential): void {
this.data[provider] = credential; this.data = this.persistProviderChange(provider, credential);
this.persistProviderChange(provider, credential);
} }
/** /**
* Remove credential for a provider. * Remove credential for a provider.
*/ */
remove(provider: string): void { remove(provider: string): void {
delete this.data[provider]; this.data = this.persistProviderChange(provider, undefined);
this.persistProviderChange(provider, undefined);
} }
/** /**
@@ -581,7 +581,7 @@ describe("AuthStorage", () => {
expect(updated.google.key).toBe("google-key"); expect(updated.google.key).toBe("google-key");
}); });
test("does not overwrite malformed auth file after load error", () => { test("throws and does not overwrite malformed auth file after load error", () => {
writeAuthJson({ writeAuthJson({
anthropic: { type: "api_key", key: "anthropic-key" }, anthropic: { type: "api_key", key: "anthropic-key" },
}); });
@@ -590,10 +590,40 @@ describe("AuthStorage", () => {
writeFileSync(authJsonPath, "{invalid-json", "utf-8"); writeFileSync(authJsonPath, "{invalid-json", "utf-8");
authStorage.reload(); authStorage.reload();
authStorage.set("openai", { type: "api_key", key: "openai-key" }); expect(() => authStorage.set("openai", { type: "api_key", key: "openai-key" })).toThrow(
"Cannot update auth storage because it could not be loaded",
);
const raw = readFileSync(authJsonPath, "utf-8"); const raw = readFileSync(authJsonPath, "utf-8");
expect(raw).toBe("{invalid-json"); expect(raw).toBe("{invalid-json");
expect(authStorage.has("openai")).toBe(false);
});
test("throws when a stale auth lock prevents persistence", () => {
writeAuthJson({});
writeFileSync(`${authJsonPath}.lock`, "", "utf-8");
authStorage = AuthStorage.create(authJsonPath);
expect(() => authStorage.set("github-copilot", { type: "api_key", key: "copilot-key" })).toThrow(
"Cannot update auth storage because it could not be loaded",
);
expect(readFileSync(authJsonPath, "utf-8")).toBe("{}");
expect(authStorage.has("github-copilot")).toBe(false);
});
test("recovers from an earlier load error before persisting", () => {
writeAuthJson({});
const lockPath = `${authJsonPath}.lock`;
writeFileSync(lockPath, "", "utf-8");
authStorage = AuthStorage.create(authJsonPath);
rmSync(lockPath);
authStorage.set("github-copilot", { type: "api_key", key: "copilot-key" });
const updated = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record<string, { key: string }>;
expect(updated["github-copilot"].key).toBe("copilot-key");
expect(authStorage.has("github-copilot")).toBe(true);
}); });
test("reload records parse errors and drainErrors clears buffer", () => { test("reload records parse errors and drainErrors clears buffer", () => {