From 7cf8df553d8324efdd7d4a28ce0bf22a0d78f8ed Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Sat, 3 Oct 2026 21:30:51 -0700
Subject: [PATCH] fix: serialize managed credential refresh with reads and
secret edits
Co-Authored-By: Paperclip
---
.../src/server/codex-auth-cache.test.ts | 36 +++++++++++++++++-
.../src/server/codex-auth-cache.ts | 19 +++++++++-
server/src/__tests__/ai-connections.test.ts | 37 +++++++++++++++++++
server/src/services/ai-connection-runtime.ts | 6 +--
server/src/services/ai-connections.ts | 16 +++++---
ui/src/components/AccountQuotaPanels.test.tsx | 3 +-
ui/src/components/AccountQuotaPanels.tsx | 9 +++--
7 files changed, 109 insertions(+), 17 deletions(-)
diff --git a/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts b/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts
index 0ca1bee28ca..2cd6ba7c173 100644
--- a/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts
+++ b/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts
@@ -463,13 +463,15 @@ describe("codex auth cache store", () => {
releaseFirstCaller = resolve;
});
+ const entered = deferredSignal();
const firstCall = withAccountHomeSecretMutationLock(env, "company-shared", async () => {
events.push("first-enter");
+ entered.resolve();
await firstCallerGate;
events.push("first-exit");
return "first";
});
- await new Promise((resolve) => setTimeout(resolve, 20));
+ await entered.promise;
const secondCall = withAccountHomeSecretMutationLock(env, "company-shared", async () => {
events.push("second-enter");
events.push("second-exit");
@@ -497,12 +499,14 @@ describe("codex auth cache store", () => {
releaseCompanyA = resolve;
});
+ const entered = deferredSignal();
const companyACall = withAccountHomeSecretMutationLock(env, "company-a", async () => {
events.push("a-enter");
+ entered.resolve();
await companyAGate;
events.push("a-exit");
});
- await new Promise((resolve) => setTimeout(resolve, 20));
+ await entered.promise;
const companyBCall = withAccountHomeSecretMutationLock(env, "company-b", async () => {
events.push("b-enter");
events.push("b-exit");
@@ -536,6 +540,28 @@ describe("codex auth cache store", () => {
expect(events).toEqual(["promotion-enter", "mutation-enter", "mutation-exit", "promotion-exit"]);
});
+ it("allows awaited nested writes but expires inherited ownership after release", async () => {
+ const env = envFor(await makeInstanceRoot());
+ const detachedGate = deferredSignal();
+ let detached!: Promise;
+ let enteredDetached = false;
+ await withAccountHomeSecretMutationLock(env, "nested", async () => {
+ await expect(withAccountHomeSecretMutationLock(env, "nested", async () => "nested write")).resolves.toBe("nested write");
+ detached = detachedGate.promise.then(() => withAccountHomeSecretMutationLock(env, "nested", async () => { enteredDetached = true; }));
+ });
+ const holding = deferredSignal();
+ const release = deferredSignal();
+ const other = withAccountHomeSecretMutationLock(env, "nested", async () => { holding.resolve(); await release.promise; });
+ await holding.promise;
+ detachedGate.resolve();
+ try {
+ await new Promise(resolve => setTimeout(resolve, 30));
+ expect(enteredDetached).toBe(false);
+ } finally { release.resolve(); }
+ await Promise.all([other, detached]);
+ expect(enteredDetached).toBe(true);
+ });
+
});
describe("Phase 8: account-home directory validity before a secret write commits", () => {
@@ -581,3 +607,9 @@ describe("codex auth cache store", () => {
});
});
});
+
+function deferredSignal() {
+ let resolve!: () => void;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
diff --git a/packages/adapters/codex-local/src/server/codex-auth-cache.ts b/packages/adapters/codex-local/src/server/codex-auth-cache.ts
index c51921fff6b..d6dd757313a 100644
--- a/packages/adapters/codex-local/src/server/codex-auth-cache.ts
+++ b/packages/adapters/codex-local/src/server/codex-auth-cache.ts
@@ -1,4 +1,5 @@
-import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
+import { AsyncLocalStorage } from "node:async_hooks";
+import { lstat, mkdir, open, readFile, realpath, rename, rm } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
@@ -334,6 +335,11 @@ export async function withCodexAccountHomePromotionLock(
* create) can still call into a secrets-service write that takes this lock
* without deadlocking on itself.
*/
+// An outer credential transaction acquires this lock before database row locks.
+// Nested secret-service writes must share that ownership, not acquire it again.
+// Expire ownership on return so detached work cannot inherit a released lock.
+const secretMutationOwnership = new AsyncLocalStorage
)}
{account.windows.map((window, index) => window.usedPercent == null ? (
-
-
{window.label}
-
{window.valueLabel ?? "Usage not reported"}
+
+
+ {window.label}
+ {window.valueLabel ?? "Usage not reported"}
+
+ {window.resetsAt &&
Resets {new Date(window.resetsAt).toLocaleString()}
}
) : (