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
8 changes: 8 additions & 0 deletions .changeset/scope-managed-oauth-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code-oauth": patch
"@moonshot-ai/kimi-code-sdk": patch
"@moonshot-ai/kimi-code": patch
---

Keep managed OAuth credentials scoped to their configured authentication and API endpoints.
16 changes: 10 additions & 6 deletions apps/kimi-code/src/tui/utils/refresh-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isOpenPlatformId,
removeCustomRegistryProvider,
removeOpenPlatformConfig,
resolveKimiCodeRuntimeAuth,
type CustomRegistrySource,
type ManagedKimiConfigShape,
} from '@moonshot-ai/kimi-code-oauth';
Expand Down Expand Up @@ -120,13 +121,14 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
managedProvider.oauth !== undefined
) {
try {
const accessToken = await host.resolveOAuthToken(
KIMI_CODE_PROVIDER_NAME,
managedProvider.oauth,
);
const auth = resolveKimiCodeRuntimeAuth({
configuredBaseUrl: managedProvider.baseUrl,
configuredOAuthRef: managedProvider.oauth,
});
const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef);
const models = await fetchManagedKimiCodeModels({
accessToken,
baseUrl: managedProvider.baseUrl,
baseUrl: auth.baseUrl,
});
if (models.length > 0) {
const beforeIds = collectModelIdsForProvider(config, KIMI_CODE_PROVIDER_NAME);
Expand All @@ -140,7 +142,9 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi
clearManagedKimiCodeConfig(asManaged(config));
applyManagedKimiCodeConfig(asManaged(config), {
models,
baseUrl: managedProvider.baseUrl,
baseUrl: auth.baseUrl,
oauthKey: auth.oauthRef.key,
oauthHost: auth.oauthRef.oauthHost,
preserveDefaultModel: true,
});
await host.setConfig({
Expand Down
96 changes: 96 additions & 0 deletions apps/kimi-code/test/tui/utils/refresh-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
KIMI_CODE_PROVIDER_NAME,
resolveKimiCodeOAuthKey,
resolveKimiCodeOAuthRef,
} from '@moonshot-ai/kimi-code-oauth';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { refreshAllProviderModels } from '../../../src/tui/utils/refresh-providers';
import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk';

type FetchMock = (
input: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1],
) => Promise<Response>;

function fetchInputUrl(input: Parameters<typeof fetch>[0]): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.href;
return input.url;
}

describe('refreshAllProviderModels', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it('refreshes managed Kimi Code against environment endpoints over persisted config', async () => {
const configuredBaseUrl = 'https://api.configured.example.test/coding/v1';
const envBaseUrl = 'https://api.env.example.test/coding/v1';
const envOauthHost = 'https://auth.env.example.test';
const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl });
const envOauthRef = resolveKimiCodeOAuthRef({
oauthHost: envOauthHost,
baseUrl: envBaseUrl,
});
const config: KimiConfig = {
providers: {
[KIMI_CODE_PROVIDER_NAME]: {
type: 'kimi',
baseUrl: configuredBaseUrl,
apiKey: '',
oauth: {
storage: 'file',
key: configuredOauthKey,
oauthHost: 'https://auth.kimi.com',
},
},
},
models: {
'kimi-code/kimi-for-coding': {
provider: KIMI_CODE_PROVIDER_NAME,
model: 'kimi-for-coding',
maxContextSize: 262144,
},
},
defaultModel: 'kimi-code/kimi-for-coding',
telemetry: true,
};
vi.stubEnv('KIMI_CODE_BASE_URL', envBaseUrl);
vi.stubEnv('KIMI_CODE_OAUTH_HOST', envOauthHost);
const resolveOAuthToken = vi.fn(async (_providerName, oauthRef) => {
expect(oauthRef).toEqual(envOauthRef);
return 'env-access-token';
});
const fetchMock = vi.fn<FetchMock>(async (input, init) => {
expect(fetchInputUrl(input)).toBe(`${envBaseUrl}/models`);
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer env-access-token');
return new Response(
JSON.stringify({
data: [
{
id: 'kimi-for-coding',
context_length: 262144,
supports_reasoning: true,
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);

const result = await refreshAllProviderModels({
getConfig: async () => config,
removeProvider: vi.fn(),
setConfig: vi.fn(),
resolveOAuthToken,
});

expect(result.failed).toEqual([]);
expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef);
});
});
1 change: 1 addition & 0 deletions packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type ProviderType = z.infer<typeof ProviderTypeSchema>;
export const OAuthRefSchema = z.object({
storage: z.enum(['file', 'keyring']),
key: z.string().min(1),
oauthHost: z.string().min(1).optional(),
});

export type OAuthRef = z.infer<typeof OAuthRefSchema>;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/config/toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ function hookToToml(hook: HookDefConfig): Record<string, unknown> {
function oauthToToml(oauth: OAuthRef): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(oauth)) {
out[camelToSnake(key)] = value;
setDefined(out, camelToSnake(key), value);
}
return out;
}
Expand Down
32 changes: 32 additions & 0 deletions packages/agent-core/test/config/configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,38 @@ source = { kind = "apiJson", url = "https://registry.example/api.json", apiKey =
});
});

it('round-trips OAuth refs with scoped OAuth hosts', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'oauth-ref.toml');
const toml = `
[providers."managed:kimi-code"]
type = "kimi"
base_url = "https://coding.deva.msh.team/coding/v1"
api_key = ""
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.kimi.team" }

[services.moonshot_search]
base_url = "https://coding.deva.msh.team/coding/v1/search"
api_key = ""
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.kimi.team" }
`;
const config = parseConfigString(toml, configPath);
expect(config.providers['managed:kimi-code']?.oauth).toEqual({
storage: 'file',
key: 'oauth/kimi-code-env-1234',
oauthHost: 'https://auth.dev.kimi.team',
});
expect(config.services?.moonshotSearch?.oauth?.oauthHost).toBe('https://auth.dev.kimi.team');

await writeConfigFile(configPath, config);
const text = await readFile(configPath, 'utf-8');
expect(text).toContain('oauth_host = "https://auth.dev.kimi.team"');
const roundTripped = parseConfigString(text, configPath);
expect(roundTripped.providers['managed:kimi-code']?.oauth?.oauthHost).toBe(
'https://auth.dev.kimi.team',
);
});

it('loads defaults for absent files and writes typed fields without dropping raw sections', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'config.toml');
Expand Down
83 changes: 76 additions & 7 deletions packages/node-sdk/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
applyManagedKimiCodeLogoutConfig,
KIMI_CODE_PROVIDER_NAME,
KimiOAuthToolkit,
resolveKimiCodeLoginAuth,
resolveKimiCodeRuntimeAuth,
type AuthManagedUsageResult,
type AuthStatus,
type BearerTokenProvider,
Expand Down Expand Up @@ -68,14 +70,27 @@ export class KimiAuthFacade {
}

async status(providerName?: string | undefined): Promise<AuthStatus> {
return this.toolkit.status(providerName);
return this.toolkit.status(providerName, this.resolveRuntimeManagedAuth(providerName).oauthRef);
}

async login(
providerName: string | undefined = KIMI_CODE_PROVIDER_NAME,
options: KimiAuthLoginOptions = {},
): Promise<KimiAuthLoginResult> {
const result = await this.toolkit.login(providerName, { ...options, provisionConfig: true });
const auth = this.resolveManagedAuth(providerName);
const loginAuth = resolveKimiCodeLoginAuth({
configuredBaseUrl: auth.baseUrl,
configuredOAuthRef: auth.oauthRef,
requestedBaseUrl: options.baseUrl,
requestedOAuthHost: options.oauthHost,
});
const result = await this.toolkit.login(providerName, {
...options,
baseUrl: loginAuth.baseUrl,
oauthHost: loginAuth.oauthHost,
oauthRef: options.oauthRef ?? loginAuth.oauthRef,
provisionConfig: true,
});
if (result.provision === undefined) {
throw new Error('Kimi auth login did not provision model config.');
}
Expand All @@ -91,7 +106,10 @@ export class KimiAuthFacade {
}

async logout(providerName?: string | undefined): Promise<KimiAuthLogoutResult> {
const result = await this.toolkit.logout(providerName);
const result = await this.toolkit.logout(
providerName,
this.resolveRuntimeManagedAuth(providerName).oauthRef,
);
Comment on lines +109 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve config when logging out an env override

When KIMI_CODE_BASE_URL/KIMI_CODE_OAUTH_HOST are set temporarily over an existing managed config, resolveRuntimeManagedAuth() returns the env-scoped OAuth ref, so this logs out the override token, but KimiOAuthToolkit.logout() still unconditionally runs the config adapter's remove() for managed:kimi-code. A user who only meant to clear the temporary environment credential therefore loses the persisted provider/models for the configured environment while that configured token remains untouched; use the persisted config ref for config removal, or skip config removal when the runtime ref was produced by env overrides.

Useful? React with 👍 / 👎.

Comment on lines +109 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delete stale legacy tokens during scoped logout

For an existing pre-scoping config whose base_url is non-default but whose persisted oauth.key is still oauth/kimi-code, this call first normalizes the ref to the new scoped slot before logout. In that migration case the actual legacy token file is left behind while the config is removed, so /logout appears to succeed but the old production credential can still be reused later; when repairing a stale ref for logout, also delete the original configured slot or pass it through before removing config.

Useful? React with 👍 / 👎.

const updated = readConfigFile(this.options.configPath);
this.options.onConfigUpdated?.(updated);
return {
Expand All @@ -101,13 +119,18 @@ export class KimiAuthFacade {
}

async getManagedUsage(providerName?: string | undefined): Promise<AuthManagedUsageResult> {
return this.toolkit.getManagedUsage(providerName);
const auth = this.resolveRuntimeManagedAuth(providerName);
return this.toolkit.getManagedUsage(providerName, {
oauthRef: auth.oauthRef,
baseUrl: auth.baseUrl,
Comment on lines +123 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor env endpoint overrides for managed usage

When KIMI_CODE_BASE_URL is set to temporarily target another managed environment while the config still contains a persisted base_url, login() deliberately ignores the configured base URL, but usage and feedback now always pass auth.baseUrl from the config. In that environment override scenario these calls still hit the old configured /usages and /feedback endpoints with the wrong scoped token, so they can report the wrong account or fail authorization; apply the same env-precedence rule used for login before passing baseUrl.

Useful? React with 👍 / 👎.

});
}

async submitFeedback(
input: KimiAuthSubmitFeedbackInput,
providerName?: string | undefined,
): Promise<FetchSubmitFeedbackResult> {
const auth = this.resolveRuntimeManagedAuth(providerName);
return this.toolkit.submitFeedback(
{
session_id: input.sessionId,
Expand All @@ -117,17 +140,63 @@ export class KimiAuthFacade {
model: input.model,
},
providerName,
{
oauthRef: auth.oauthRef,
baseUrl: auth.baseUrl,
},
);
}

async getCachedAccessToken(providerName?: string): Promise<string | undefined> {
return this.toolkit.getCachedAccessToken(providerName);
async getCachedAccessToken(
providerName?: string,
oauthRef?: OAuthRef | undefined,
): Promise<string | undefined> {
return this.toolkit.getCachedAccessToken(
providerName,
this.runtimeOAuthRef(providerName, oauthRef),
);
}

readonly resolveOAuthTokenProvider = (
providerName: string,
oauthRef?: OAuthRef | undefined,
): BearerTokenProvider => {
return this.toolkit.tokenProvider(providerName, oauthRef);
return this.toolkit.tokenProvider(providerName, this.runtimeOAuthRef(providerName, oauthRef));
};

private resolveManagedAuth(providerName?: string | undefined): {
readonly oauthRef?: OAuthRef | undefined;
readonly baseUrl?: string | undefined;
} {
const name = providerName ?? KIMI_CODE_PROVIDER_NAME;
const config = readConfigFile(this.options.configPath);
const provider = config.providers[name];
return {
oauthRef: provider?.oauth,
baseUrl: provider?.baseUrl,
};
}

private resolveRuntimeManagedAuth(providerName?: string | undefined): {
readonly oauthRef: OAuthRef;
readonly baseUrl?: string | undefined;
} {
const auth = this.resolveManagedAuth(providerName);
return resolveKimiCodeRuntimeAuth({
configuredBaseUrl: auth.baseUrl,
configuredOAuthRef: auth.oauthRef,
});
}

private runtimeOAuthRef(
providerName: string | undefined,
oauthRef?: OAuthRef | undefined,
): OAuthRef | undefined {
if ((providerName ?? KIMI_CODE_PROVIDER_NAME) !== KIMI_CODE_PROVIDER_NAME) return oauthRef;
const auth = this.resolveManagedAuth(providerName);
return resolveKimiCodeRuntimeAuth({
configuredBaseUrl: auth.baseUrl,
configuredOAuthRef: oauthRef ?? auth.oauthRef,
}).oauthRef;
Comment on lines +197 to +200

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit service OAuth refs

When a moonshot_search/moonshot_fetch service is explicitly configured with its own scoped oauth ref, core-impl.ts passes that service ref to this resolver, but this normalization recomputes it against the managed model provider's baseUrl rather than the service's endpoint. If the service points at a different managed environment than the model provider, the service ref is discarded and the tool calls that service with the provider's token; leave explicit refs unchanged unless they are the provider ref, or normalize them with the matching service base URL.

Useful? React with 👍 / 👎.

}
}
13 changes: 12 additions & 1 deletion packages/node-sdk/src/kimi-code-model-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import {
} from '@moonshot-ai/agent-core';
import {
createKimiDefaultHeaders,
KIMI_CODE_FLOW_CONFIG,
KIMI_CODE_PROVIDER_NAME,
KimiOAuthToolkit,
kimiCodeBaseUrl,
resolveKimiCodeOAuthRef,
type KimiHostIdentity,
type ManagedKimiOAuthRef,
} from '@moonshot-ai/kimi-code-oauth';
import type {
ProviderConfig as KosongProviderConfig,
Expand All @@ -35,6 +38,7 @@ export class KimiForCodingProvider implements ModelProvider {
private readonly toolkit: KimiOAuthToolkit;
private readonly homeDir: string;
private readonly identity: KimiHostIdentity;
private readonly oauthRef: ManagedKimiOAuthRef;

constructor(options: KimiForCodingProviderOptions) {
this.model = options.model ?? 'kimi-for-coding';
Expand All @@ -47,6 +51,10 @@ export class KimiForCodingProvider implements ModelProvider {
version: options.version,
userAgentSuffix: options.userAgentSuffix,
};
this.oauthRef = resolveKimiCodeOAuthRef({
oauthHost: KIMI_CODE_FLOW_CONFIG.oauthHost,
baseUrl: this.baseUrl,
});
this.toolkit = new KimiOAuthToolkit({
homeDir: this.homeDir,
identity: this.identity,
Expand Down Expand Up @@ -111,7 +119,10 @@ export class KimiForCodingProvider implements ModelProvider {
}

private async buildAuth(force: boolean): Promise<ProviderRequestAuth> {
const apiKey = await this.toolkit.ensureFresh(KIMI_CODE_PROVIDER_NAME, { force });
const apiKey = await this.toolkit.ensureFresh(KIMI_CODE_PROVIDER_NAME, {
force,
oauthRef: this.oauthRef,
});
return { apiKey };
}
}
Loading
Loading