From 221a80c3bce2bf8f8546ecc9f7eacfe0d5bbdfb5 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 14 Oct 2026 11:06:53 -0400 Subject: [PATCH 1/4] fix: Prevent conflicting queued logins from replacing active credentials --- .changeset/clear-pumas-retire.md | 5 ++ js/src/logger-caching.test.ts | 113 +++++++++++++++++++++++++------ js/src/logger.ts | 17 +++++ 3 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 .changeset/clear-pumas-retire.md diff --git a/.changeset/clear-pumas-retire.md b/.changeset/clear-pumas-retire.md new file mode 100644 index 000000000..f3ce9027a --- /dev/null +++ b/.changeset/clear-pumas-retire.md @@ -0,0 +1,5 @@ +--- +"braintrust": patch +--- + +fix: Prevent conflicting queued logins from replacing active credentials diff --git a/js/src/logger-caching.test.ts b/js/src/logger-caching.test.ts index 5af2481a2..ec87e7744 100644 --- a/js/src/logger-caching.test.ts +++ b/js/src/logger-caching.test.ts @@ -691,29 +691,104 @@ describe("login deduplication", () => { expect(state.loggedIn).toBe(true); }); - test.each(["apiKey", "appUrl", "orgName", "fetch"] as const)( - "does not combine differing %s options", - async (field) => { + test.each([ + ["apiKey", { apiKey: "different-credential" }], + ["appUrl", { appUrl: "https://other.test" }], + ["orgName", { orgName: "different-org-id" }], + ["fetch", { fetch: true }], + ] as const)( + "rejects a queued conflicting %s login without replacing the active login", + async (_field, conflictingOption) => { const { state, fetch } = createState(); - await Promise.all([ - state.login({}), - state.login( - field === "fetch" - ? { fetch: (...args) => fetch(...args) } - : { - [field]: - field === "orgName" - ? "org-id" - : field === "appUrl" - ? "https://other.test" - : "different", - }, - ), - ]); - expect(fetch).toHaveBeenCalledTimes(2); + let resolveFirst!: (value: Response) => void; + fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + + const first = state.login({ + apiKey: "first-credential", + orgName: "first-org-id", + }); + const conflicting = state.login( + conflictingOption.fetch + ? { fetch: (...args) => fetch(...args) } + : conflictingOption, + ); + expect(fetch).toHaveBeenCalledTimes(1); + + resolveFirst(loginResponse("first-org-id")); + await first; + await expect(conflicting).rejects.toThrow("forceLogin: true"); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(state.loginToken).toBe("first-credential"); + expect(state.orgId).toBe("first-org-id"); + expect(state.activeLoginOrgNameSelector).toBe("first-org-id"); }, ); + test("reuses a matching login queued behind the active login", async () => { + const { state, fetch } = createState(); + let resolveFirst!: (value: Response) => void; + fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const options = { + apiKey: " matching-credential ", + orgName: "matching-org-id", + }; + const first = state.login(options); + const matching = state.login({ + ...options, + apiKey: "matching-credential", + }); + resolveFirst(loginResponse("matching-org-id")); + await Promise.all([first, matching]); + expect(fetch).toHaveBeenCalledTimes(1); + expect(state.loginToken).toBe("matching-credential"); + expect(state.orgId).toBe("matching-org-id"); + }); + + test("allows a queued login to proceed when the preceding login fails", async () => { + const { state, fetch } = createState(); + fetch.mockRejectedValueOnce(new Error("first login failed")); + const first = state.login({ apiKey: "first-credential" }); + const next = state.login({ apiKey: "second-credential" }); + await expect(first).rejects.toThrow("first login failed"); + await expect(next).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(state.loginToken).toBe("second-credential"); + }); + + test("forced login still runs after a queued conflict", async () => { + const { state, fetch } = createState(); + let resolveFirst!: (value: Response) => void; + fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const first = state.login({ apiKey: "first-credential" }); + const conflicting = state.login({ apiKey: "second-credential" }); + const forced = state.login({ + apiKey: "second-credential", + forceLogin: true, + }); + resolveFirst(loginResponse("first-org-id")); + await first; + await expect(conflicting).rejects.toThrow("forceLogin: true"); + await forced; + expect(fetch).toHaveBeenCalledTimes(2); + expect(state.loginToken).toBe("second-credential"); + }); + test.each([false, true])( "concurrent forced loggers remain usable (credential discovery: %s)", async (discoverCredential) => { diff --git a/js/src/logger.ts b/js/src/logger.ts index e38591676..490ebd95f 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -1278,6 +1278,23 @@ export class BraintrustState { if (generation !== this.loginGeneration) { throw new Error("Login cancelled by a reset."); } + // A non-forced login may have been queued while another login was in + // flight. Recheck the active credentials after that login completes so + // a queued caller cannot silently replace the shared state's identity. + if (this.apiUrl && !loginParams.forceLogin) { + if ( + this.appUrl !== appUrl || + this.loginToken !== + (apiKey && HTTPConnection.sanitize_token(apiKey)) || + this.activeLoginOrgNameSelector !== orgName || + this.fetch !== fetch + ) { + throw new Error( + "Another login completed with different options. To force re-login, pass `forceLogin: true`.", + ); + } + return; + } const newState = await loginToState({ ...options, appUrl, From 0aa23e8d8ece12c3052238d170a4f92c2d18e174 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 14 Oct 2026 11:35:53 -0400 Subject: [PATCH 2/4] fix: Reapply queued login debug level --- js/src/logger-caching.test.ts | 50 ++++++++++++++++++++++++++--------- js/src/logger.ts | 3 +++ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/js/src/logger-caching.test.ts b/js/src/logger-caching.test.ts index ec87e7744..4208877ad 100644 --- a/js/src/logger-caching.test.ts +++ b/js/src/logger-caching.test.ts @@ -691,14 +691,9 @@ describe("login deduplication", () => { expect(state.loggedIn).toBe(true); }); - test.each([ - ["apiKey", { apiKey: "different-credential" }], - ["appUrl", { appUrl: "https://other.test" }], - ["orgName", { orgName: "different-org-id" }], - ["fetch", { fetch: true }], - ] as const)( + test.each(["apiKey", "appUrl", "orgName", "fetch"] as const)( "rejects a queued conflicting %s login without replacing the active login", - async (_field, conflictingOption) => { + async (field) => { const { state, fetch } = createState(); let resolveFirst!: (value: Response) => void; fetch.mockImplementationOnce( @@ -712,11 +707,17 @@ describe("login deduplication", () => { apiKey: "first-credential", orgName: "first-org-id", }); - const conflicting = state.login( - conflictingOption.fetch - ? { fetch: (...args) => fetch(...args) } - : conflictingOption, - ); + const conflictingOptions = + field === "apiKey" + ? { apiKey: "different-credential" } + : field === "appUrl" + ? { appUrl: "https://other.test" } + : field === "orgName" + ? { orgName: "different-org-id" } + : { + fetch: (...args: Parameters) => fetch(...args), + }; + const conflicting = state.login(conflictingOptions); expect(fetch).toHaveBeenCalledTimes(1); resolveFirst(loginResponse("first-org-id")); @@ -726,7 +727,7 @@ describe("login deduplication", () => { expect(fetch).toHaveBeenCalledTimes(1); expect(state.loginToken).toBe("first-credential"); expect(state.orgId).toBe("first-org-id"); - expect(state.activeLoginOrgNameSelector).toBe("first-org-id"); + expect(state.orgName).toBe("first-org-id"); }, ); @@ -755,6 +756,29 @@ describe("login deduplication", () => { expect(state.orgId).toBe("matching-org-id"); }); + test("applies a queued matching login's debug level after the earlier login", async () => { + const { state, fetch } = createState(); + let resolveFirst!: (value: Response) => void; + fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const first = state.login({ + apiKey: "matching-credential", + debugLogLevel: "warn", + }); + const queued = state.login({ + apiKey: "matching-credential", + debugLogLevel: "debug", + }); + resolveFirst(loginResponse()); + await Promise.all([first, queued]); + expect(fetch).toHaveBeenCalledTimes(1); + expect(state.getDebugLogLevel()).toBe("debug"); + }); + test("allows a queued login to proceed when the preceding login fails", async () => { const { state, fetch } = createState(); fetch.mockRejectedValueOnce(new Error("first login failed")); diff --git a/js/src/logger.ts b/js/src/logger.ts index 490ebd95f..25204272a 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -1293,6 +1293,9 @@ export class BraintrustState { "Another login completed with different options. To force re-login, pass `forceLogin: true`.", ); } + // copyLoginInfo() may have replaced this queued caller's debug level + // while its login was waiting. + this.setDebugLogLevel(loginParams.debugLogLevel); return; } const newState = await loginToState({ From 49bd09667c36835ca2ff7d92dc59f3a12cd62378 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 14 Oct 2026 12:34:09 -0400 Subject: [PATCH 3/4] fix: Preserve resolved queued login options --- js/src/logger-caching.test.ts | 32 ++++++++++++++++++++++++++------ js/src/logger.ts | 5 +++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/js/src/logger-caching.test.ts b/js/src/logger-caching.test.ts index 4208877ad..dcd5e41a7 100644 --- a/js/src/logger-caching.test.ts +++ b/js/src/logger-caching.test.ts @@ -740,14 +740,10 @@ describe("login deduplication", () => { resolveFirst = resolve; }), ); - const options = { - apiKey: " matching-credential ", - orgName: "matching-org-id", - }; - const first = state.login(options); + const first = state.login({ apiKey: " matching-credential " }); const matching = state.login({ - ...options, apiKey: "matching-credential", + orgName: "matching-org-id", }); resolveFirst(loginResponse("matching-org-id")); await Promise.all([first, matching]); @@ -779,6 +775,30 @@ describe("login deduplication", () => { expect(state.getDebugLogLevel()).toBe("debug"); }); + test("restores the constructor debug level for a queued login without an override", async () => { + const { fetch } = createState(); + const state = new BraintrustState({ + apiKey: "matching-credential", + appUrl: "https://app.test", + fetch, + debugLogLevel: "warn", + noExitFlush: true, + }); + let resolveFirst!: (value: Response) => void; + fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const first = state.login({ debugLogLevel: "debug" }); + const queued = state.login({}); + resolveFirst(loginResponse()); + await Promise.all([first, queued]); + expect(fetch).toHaveBeenCalledTimes(1); + expect(state.getDebugLogLevel()).toBe("warn"); + }); + test("allows a queued login to proceed when the preceding login fails", async () => { const { state, fetch } = createState(); fetch.mockRejectedValueOnce(new Error("first login failed")); diff --git a/js/src/logger.ts b/js/src/logger.ts index 25204272a..e64236800 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -1286,7 +1286,8 @@ export class BraintrustState { this.appUrl !== appUrl || this.loginToken !== (apiKey && HTTPConnection.sanitize_token(apiKey)) || - this.activeLoginOrgNameSelector !== orgName || + (this.activeLoginOrgNameSelector !== orgName && + (this.orgName === undefined || this.orgName !== orgName)) || this.fetch !== fetch ) { throw new Error( @@ -1295,7 +1296,7 @@ export class BraintrustState { } // copyLoginInfo() may have replaced this queued caller's debug level // while its login was waiting. - this.setDebugLogLevel(loginParams.debugLogLevel); + this.setDebugLogLevel(options.debugLogLevel); return; } const newState = await loginToState({ From feb1d71461e52a9f0a2e5f13455315605fb948ca Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 14 Oct 2026 12:43:51 -0400 Subject: [PATCH 4/4] fix: Restore environment debug level on login reuse --- js/src/logger-caching.test.ts | 34 ++++++++++++++++++++++++++++++++++ js/src/logger.ts | 12 +++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/js/src/logger-caching.test.ts b/js/src/logger-caching.test.ts index dcd5e41a7..31cab579a 100644 --- a/js/src/logger-caching.test.ts +++ b/js/src/logger-caching.test.ts @@ -799,6 +799,40 @@ describe("login deduplication", () => { expect(state.getDebugLogLevel()).toBe("warn"); }); + test("restores the environment debug level for a queued login without an override", async () => { + const originalDebugLevel = process.env.BRAINTRUST_DEBUG_LOG_LEVEL; + process.env.BRAINTRUST_DEBUG_LOG_LEVEL = "warn"; + try { + const { fetch } = createState(); + const state = new BraintrustState({ + apiKey: "matching-credential", + appUrl: "https://app.test", + fetch, + noExitFlush: true, + }); + let resolveFirst!: (value: Response) => void; + fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const first = state.login({ debugLogLevel: "debug" }); + const queued = state.login({}); + resolveFirst(loginResponse()); + await Promise.all([first, queued]); + expect(fetch).toHaveBeenCalledTimes(1); + expect(state.getDebugLogLevel()).toBe("warn"); + expect(state.hasDebugLogLevelOverride()).toBe(false); + } finally { + if (originalDebugLevel === undefined) { + delete process.env.BRAINTRUST_DEBUG_LOG_LEVEL; + } else { + process.env.BRAINTRUST_DEBUG_LOG_LEVEL = originalDebugLevel; + } + } + }); + test("allows a queued login to proceed when the preceding login fails", async () => { const { state, fetch } = createState(); fetch.mockRejectedValueOnce(new Error("first login failed")); diff --git a/js/src/logger.ts b/js/src/logger.ts index e64236800..c70661482 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -1294,9 +1294,15 @@ export class BraintrustState { "Another login completed with different options. To force re-login, pass `forceLogin: true`.", ); } - // copyLoginInfo() may have replaced this queued caller's debug level - // while its login was waiting. - this.setDebugLogLevel(options.debugLogLevel); + // copyLoginInfo() may have replaced this queued caller's effective + // debug level while its login was waiting. + if (options.debugLogLevel === undefined) { + this.debugLogLevelConfigured = false; + this.debugLogLevel = getEnvDebugLogLevel(); + setGlobalDebugLogLevel(undefined); + } else { + this.setDebugLogLevel(options.debugLogLevel); + } return; } const newState = await loginToState({