fix: serialize managed credential refresh with reads and secret edits

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-10-03 21:30:51 -07:00
1 parent feba1f926f
commit 7cf8df553d
7 files changed
+109 -17

No files matched your search

@@ -84,6 +84,37 @@ describe("managed AI connections", () => {
} finally { fetchSpy.mockRestore(); }
});
it("serializes quota exchange with a concurrent secret edit and runtime credential read", async () => {
const owner = "quota-race-owner";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
const original = JSON.stringify({ tokens: { access_token: "old-access", refresh_token: "single-use", id_token: "identity", account_id: "race-account" } });
const account = await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: "Racing refresh", loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
const [row] = await service.quotaAccounts(companyId, owner);
const secretId = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!.secretId;
const exchangeStarted = deferredSignal();
const releaseExchange = deferredSignal();
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
exchangeStarted.resolve(); await releaseExchange.promise;
return Response.json({ access_token: "new-access", refresh_token: "new-refresh" });
});
let runtime: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
const refreshing = service.refreshQuotaCredential(row, original, new AbortController().signal);
try {
await exchangeStarted.promise;
const edit = secretService(db).update(secretId, { description: "Changed during refresh" });
const preparing = prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local", binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
// Keep the provider exchange pending while both contenders reach their
// lock/read paths. Neither may hold a conflicting row lock or vend old auth.
await new Promise(resolve => setTimeout(resolve, 50));
releaseExchange.resolve();
const results = await Promise.all([refreshing, edit, preparing]);
runtime = results[2];
expect(JSON.parse(await readFile(path.join(String(runtime.config.env.CODEX_HOME), "auth.json"), "utf8"))).toMatchObject({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } });
expect(JSON.parse(await service.credential(row))).toMatchObject({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } });
expect((await secretService(db).getById(secretId))?.description).toBe("Changed during refresh");
} finally { releaseExchange.resolve(); await runtime?.cleanup(); request.mockRestore(); }
}, 10_000);
it("serializes quota credential refresh, persists rotation, and defers while the provider is active", async () => {
const owner = "quota-refresh-owner";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
@@ -1069,3 +1100,9 @@ describe("AI connection recovery delivery", () => {
}, 30000,
);
});
function deferredSignal() {
let resolve!: () => void;
const promise = new Promise<void>(done => { resolve = done; });
return { promise, resolve };
}
+3 -3
View File
@@ -11,7 +11,7 @@ import {
} from "@paperclipai/shared";
import { aiConnectionService } from "./ai-connections.js";
import { secretService } from "./secrets.js";
import { decideCodexAuthMerge } from "@paperclipai/adapter-codex-local/server";
import { decideCodexAuthMerge, withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import { decideGrokAuthMerge } from "@paperclipai/adapter-grok-local/server";
@@ -314,7 +314,7 @@ export async function prepareManagedAiRuntime(
if (subscriptionFile) {
const refreshed = await readFile(authFile, "utf8");
if (refreshed !== value)
await db.transaction(async (tx) => {
await withAccountHomeSecretMutationLock(undefined, input.companyId, () => db.transaction(async (tx) => {
const [grant] = await tx
.select()
.from(connectionGrants)
@@ -374,7 +374,7 @@ export async function prepareManagedAiRuntime(
.update(connectionGrants)
.set({ updatedAt: new Date() })
.where(eq(connectionGrants.id, grant.id));
});
}));
}
} finally {
if (home) await rm(home, { recursive: true, force: true });
+10 -6
View File
@@ -1,3 +1,4 @@
import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
import { createHash, randomUUID } from "node:crypto";
import { and, eq, inArray, or, sql } from "drizzle-orm";
@@ -402,6 +403,9 @@ export function aiConnectionService(db: Db) {
};
}
async function credential(row: Pick<Awaited<ReturnType<typeof select>>, "connection" | "grant">) {
return withAccountHomeSecretMutationLock(undefined, row.connection.companyId, () => credentialUnlocked(row));
}
async function credentialUnlocked(row: Pick<Awaited<ReturnType<typeof select>>, "connection" | "grant">) {
const ref = row.grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
);
@@ -498,7 +502,7 @@ export function aiConnectionService(db: Db) {
);
const id = reconnect?.connection.id ?? randomUUID();
const grantId = reconnect?.grant.id ?? randomUUID();
return db.transaction(async (tx) => {
return withAccountHomeSecretMutationLock(undefined, companyId, () => db.transaction(async (tx) => {
const secrets = secretService(tx);
if (sessionId) {
const [session] = await tx
@@ -803,7 +807,7 @@ export function aiConnectionService(db: Db) {
details: { provider: input.provider, method: input.method, grantId },
});
return { connectionId: id, grantId };
});
}));
}
/** A late failure must never invalidate credentials that were refreshed or reconnected meanwhile. */
async function markAuthenticationFailed(input: {
@@ -813,7 +817,7 @@ export function aiConnectionService(db: Db) {
runStartedAt: Date;
attribution: AiConnectionAttribution & { identity: string };
}) {
return db.transaction(async (tx) => {
return withAccountHomeSecretMutationLock(undefined, input.companyId, () => db.transaction(async (tx) => {
const { attribution } = input;
const [grant] = await tx.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, input.companyId),
@@ -847,11 +851,11 @@ export function aiConnectionService(db: Db) {
entityType: "tool_connection", entityId: connection.id,
details: { provider: attribution.provider, grantId: grant.id },
});
});
}));
}
/** Refresh only the selected vaulted identity, serializing rotation and reconnect. */
async function refreshQuotaCredential(row: Parameters<typeof credential>[0], failedValue: string, signal: AbortSignal) {
return db.transaction(async (tx) => {
return withAccountHomeSecretMutationLock(undefined, row.connection.companyId, () => db.transaction(async (tx) => {
const companyId = row.connection.companyId;
const [grant] = await tx.select().from(connectionGrants).where(and(
eq(connectionGrants.id, row.grant.id), eq(connectionGrants.companyId, companyId),
@@ -904,7 +908,7 @@ export function aiConnectionService(db: Db) {
action: "ai_connection.credential_refreshed", entityType: "tool_connection", entityId: row.connection.id,
details: { provider: "openai", grantId: grant.id } });
return refreshed;
});
}));
}
// A quota read uses the same credential audience as execution. Operator status
// alone never grants access to another member's personal subscription.