From 639347b5e6139c558b261c57d841be2b714d9c01 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 11:37:40 +0800 Subject: [PATCH 1/3] fix(agent-core-v2): keep invalidated OAuth flows observable during login invalidateFlows() deleted flows outright when the provider configuration changed, including terminal ones. That voided the TERMINAL_RETENTION_MS window and, worse, left the status guards in handleSuccess / finalizeAuthentication blind when the invalidation landed in the microtask gap after toolkit.login resolved: setTerminal then wrote 'authenticated' onto an orphaned state the client could never observe, so the login hung after a successful browser authorization. Transition affected pending flows to a visible 'cancelled' terminal status instead, mirroring abortExisting(). Also normalize the KIMI_CODE_BASE_URL env override at the source in kimiCodeBaseUrl(): provision persists it verbatim while the model refresh rewrites it normalized, and the deep-equal diff between the two shapes fired a spurious providers-changed event mid-login. --- .changeset/fix-oauth-login-invalidation.md | 5 ++ .../agent-core-v2/src/app/auth/authService.ts | 21 ++++++--- .../agent-core-v2/test/app/auth/auth.test.ts | 47 ++++++++++++++++++- packages/oauth/src/managed-usage.ts | 8 +++- packages/oauth/test/managed-usage.test.ts | 15 ++++++ 5 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-oauth-login-invalidation.md diff --git a/.changeset/fix-oauth-login-invalidation.md b/.changeset/fix-oauth-login-invalidation.md new file mode 100644 index 0000000000..b091d785db --- /dev/null +++ b/.changeset/fix-oauth-login-invalidation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix OAuth login hanging after successful browser authorization when the provider configuration changed during sign-in, and strip trailing slashes from the KIMI_CODE_BASE_URL override. diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 60776414b4..16f0fc271a 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -423,13 +423,20 @@ export class OAuthService extends Disposable implements IOAuthService { if (affected.size === 0) return; for (const state of this.flows.values()) { if (!affected.has(state.provider)) continue; - if (state.status === 'pending') { - state.controller.abort(); - } - if (state.gcTimer !== undefined) { - clearTimeout(state.gcTimer); - } - this.flows.delete(state.provider); + // Terminal flows keep their retention — deleting them here would void + // the TERMINAL_RETENTION_MS window clients rely on to observe outcomes. + if (state.status !== 'pending') continue; + state.controller.abort(); + // Transition the status instead of deleting from the map: the + // `state.status !== 'pending'` guards in handleSuccess / + // finalizeAuthentication then observe the invalidation even when it + // lands in the microtask gap after toolkit.login resolved (an abort + // is inert once the final poll returned). A bare delete left those + // guards blind and let setTerminal write 'authenticated' onto an + // orphaned state the client could never see. handleFailure does not + // run for this flow, so record the reason here. + state.errorMessage = 'Provider configuration changed during login.'; + this.setTerminal(state, 'cancelled'); } } diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index 28ee757927..8be88e8dd5 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -492,7 +492,52 @@ describe('OAuthService', () => { providerChangedEmitter.fire({ added: [], removed: [OAUTH_PROVIDER], changed: [] }); - expect(svc.getFlow(OAUTH_PROVIDER)).toBeUndefined(); + // The flow transitions to a visible terminal status and stays observable + // for the retention window instead of vanishing from the map. + const flow = svc.getFlow(OAUTH_PROVIDER); + expect(flow?.status).toBe('cancelled'); + expect(flow?.error_message).toBe('Provider configuration changed during login.'); + }); + + it('marks an in-flight OAuth flow cancelled (not vanished) when its provider config changes', async () => { + toolkit.login.mockImplementation((_provider, options) => { + options.onDeviceCode(deviceAuth); + return new Promise(() => { }); + }); + const svc = createService(); + await svc.startLogin(OAUTH_PROVIDER); + expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('pending'); + + providerChangedEmitter.fire({ added: [], removed: [], changed: [OAUTH_PROVIDER] }); + + const flow = svc.getFlow(OAUTH_PROVIDER); + expect(flow?.status).toBe('cancelled'); + expect(flow?.error_message).toBe('Provider configuration changed during login.'); + }); + + it('does not finalize a login whose provider changed after toolkit.login resolved', async () => { + // The microtask gap: the login promise has resolved but the .then + // handlers (handleSuccess) have not run yet. An invalidation landing in + // this window must still be observed — a bare map delete here is + // invisible to the finalization guards and would strand the flow: + // setTerminal would write 'authenticated' onto a state getFlow can + // never return. + let resolveLogin!: (value: { providerName: string; ok: true }) => void; + toolkit.login.mockImplementation((_provider, options) => { + options.onDeviceCode(deviceAuth); + return new Promise((resolve) => { + resolveLogin = resolve; + }); + }); + const svc = createService(); + await svc.startLogin(OAUTH_PROVIDER); + expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('pending'); + + resolveLogin({ providerName: OAUTH_PROVIDER, ok: true }); + // Synchronous window: loginPromise settled, .then microtasks pending. + providerChangedEmitter.fire({ added: [], removed: [], changed: [OAUTH_PROVIDER] }); + + await vi.waitFor(() => expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('cancelled')); }); it('cancelLogin aborts a pending flow and marks it cancelled', async () => { diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 928d9008c2..907d7ae685 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -31,11 +31,15 @@ export function isManagedKimiCode(providerKey?: string | null): boolean { } export function kimiCodeBaseUrl(): string { - return process.env['KIMI_CODE_BASE_URL'] ?? DEFAULT_KIMI_CODE_BASE_URL; + // Single source of truth for the canonical base-url shape: normalize the + // env override here instead of letting a trailing slash leak into the + // persisted provider entry, where a later normalized rewrite would diff + // against it and emit a spurious providers-changed event during login. + return (process.env['KIMI_CODE_BASE_URL'] ?? DEFAULT_KIMI_CODE_BASE_URL).replace(/\/+$/, ''); } export function kimiCodeUsageUrl(): string { - return `${kimiCodeBaseUrl().replace(/\/+$/, '')}/usages`; + return `${kimiCodeBaseUrl()}/usages`; } export interface UsageRow { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index f390653bc5..8580b4751c 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -5,11 +5,26 @@ import { formatDuration, formatResetTime, isManagedKimiCode, + kimiCodeBaseUrl, + kimiCodeUsageUrl, parseManagedUsagePayload, } from '../src/managed-usage'; afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('kimiCodeBaseUrl', () => { + it('strips trailing slashes from the KIMI_CODE_BASE_URL override', () => { + // The env value must be normalized at the source: provision persists it + // verbatim while the model refresh rewrites it normalized, and the + // deep-equal diff between the two shapes would fire a spurious + // providers-changed event mid-login. + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://gw.example.com/'); + expect(kimiCodeBaseUrl()).toBe('https://gw.example.com'); + expect(kimiCodeUsageUrl()).toBe('https://gw.example.com/usages'); + }); }); describe('isManagedKimiCode', () => { From e54374012bf1033f08d1da546efe21739a60f994 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 12:06:48 +0800 Subject: [PATCH 2/3] style(agent-core-v2): drop non-header comments from the oauth login fix --- packages/agent-core-v2/src/app/auth/authService.ts | 10 ---------- packages/agent-core-v2/test/app/auth/auth.test.ts | 9 --------- 2 files changed, 19 deletions(-) diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 16f0fc271a..c3aba02d60 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -423,18 +423,8 @@ export class OAuthService extends Disposable implements IOAuthService { if (affected.size === 0) return; for (const state of this.flows.values()) { if (!affected.has(state.provider)) continue; - // Terminal flows keep their retention — deleting them here would void - // the TERMINAL_RETENTION_MS window clients rely on to observe outcomes. if (state.status !== 'pending') continue; state.controller.abort(); - // Transition the status instead of deleting from the map: the - // `state.status !== 'pending'` guards in handleSuccess / - // finalizeAuthentication then observe the invalidation even when it - // lands in the microtask gap after toolkit.login resolved (an abort - // is inert once the final poll returned). A bare delete left those - // guards blind and let setTerminal write 'authenticated' onto an - // orphaned state the client could never see. handleFailure does not - // run for this flow, so record the reason here. state.errorMessage = 'Provider configuration changed during login.'; this.setTerminal(state, 'cancelled'); } diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index 8be88e8dd5..6081ef9243 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -492,8 +492,6 @@ describe('OAuthService', () => { providerChangedEmitter.fire({ added: [], removed: [OAUTH_PROVIDER], changed: [] }); - // The flow transitions to a visible terminal status and stays observable - // for the retention window instead of vanishing from the map. const flow = svc.getFlow(OAUTH_PROVIDER); expect(flow?.status).toBe('cancelled'); expect(flow?.error_message).toBe('Provider configuration changed during login.'); @@ -516,12 +514,6 @@ describe('OAuthService', () => { }); it('does not finalize a login whose provider changed after toolkit.login resolved', async () => { - // The microtask gap: the login promise has resolved but the .then - // handlers (handleSuccess) have not run yet. An invalidation landing in - // this window must still be observed — a bare map delete here is - // invisible to the finalization guards and would strand the flow: - // setTerminal would write 'authenticated' onto a state getFlow can - // never return. let resolveLogin!: (value: { providerName: string; ok: true }) => void; toolkit.login.mockImplementation((_provider, options) => { options.onDeviceCode(deviceAuth); @@ -534,7 +526,6 @@ describe('OAuthService', () => { expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('pending'); resolveLogin({ providerName: OAUTH_PROVIDER, ok: true }); - // Synchronous window: loginPromise settled, .then microtasks pending. providerChangedEmitter.fire({ added: [], removed: [], changed: [OAUTH_PROVIDER] }); await vi.waitFor(() => expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('cancelled')); From 6dc8bb73472c1fe13c0b8b4bf65e4633dba34488 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 12:09:21 +0800 Subject: [PATCH 3/3] chore: cover agent-core-v2 in the oauth login fix changeset --- .changeset/fix-oauth-login-invalidation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-oauth-login-invalidation.md b/.changeset/fix-oauth-login-invalidation.md index b091d785db..3beded7ad6 100644 --- a/.changeset/fix-oauth-login-invalidation.md +++ b/.changeset/fix-oauth-login-invalidation.md @@ -1,5 +1,6 @@ --- +"@moonshot-ai/agent-core-v2": patch "@moonshot-ai/kimi-code": patch --- -Fix OAuth login hanging after successful browser authorization when the provider configuration changed during sign-in, and strip trailing slashes from the KIMI_CODE_BASE_URL override. +Fix OAuth login hanging after browser authorization when the provider configuration changes during sign-in.