diff --git a/.changeset/fix-oauth-login-invalidation.md b/.changeset/fix-oauth-login-invalidation.md new file mode 100644 index 0000000000..3beded7ad6 --- /dev/null +++ b/.changeset/fix-oauth-login-invalidation.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix OAuth login hanging after browser authorization when the provider configuration changes during sign-in. diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 60776414b4..c3aba02d60 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -423,13 +423,10 @@ 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); + if (state.status !== 'pending') continue; + state.controller.abort(); + 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..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,7 +492,43 @@ describe('OAuthService', () => { providerChangedEmitter.fire({ added: [], removed: [OAUTH_PROVIDER], changed: [] }); - expect(svc.getFlow(OAUTH_PROVIDER)).toBeUndefined(); + 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 () => { + 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 }); + 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', () => {