Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-oauth-login-invalidation.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 4 additions & 7 deletions packages/agent-core-v2/src/app/auth/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}

Expand Down
38 changes: 37 additions & 1 deletion packages/agent-core-v2/test/app/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/oauth/src/managed-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions packages/oauth/test/managed-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading