Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-host-headers-first-party-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Stop forwarding the host identity headers (including X-Msh-Device-Id) to kimi-typed providers whose endpoint is not the first-party Moonshot host, so a Kimi-compatible proxy or gateway no longer receives the device identity set.
14 changes: 10 additions & 4 deletions packages/agent-core-v2/src/kosong/model/catalogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@
* referenced provider vendor's declared `baseProtocol`; endpoint and
* credential env fallbacks resolve through `resolveProviderEndpoint` against
* the config env bag; host-header forwarding follows the vendor definition's
* `hostHeaders`; capability detection is `resolveCapability(protocol, name,
* providerType)`.
* `hostHeaders`, scoped by `isFirstPartyBaseUrl` — a vendor's
* `hostHeaders: 'full'` contract covers its own endpoint only, so a provider
* that speaks the same protocol but points elsewhere receives just the
* `User-Agent`, never the device identity set; capability detection is
* `resolveCapability(protocol, name, providerType)`.
*
* Caching (load-bearing): assembled entries are invalidated ONLY by the
* model/provider config-change events. Tests that mutate config
Expand Down Expand Up @@ -90,7 +93,7 @@ import {
toProtocolProvider,
} from './catalog';
import { ModelCatalogErrors } from './errors';
import { IHostRequestHeaders } from './hostRequestHeaders';
import { IHostRequestHeaders, isFirstPartyBaseUrl } from './hostRequestHeaders';
import {
assembleModelInspection,
attributeEffectiveFields,
Expand Down Expand Up @@ -397,6 +400,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
providerConfig?.type,
providerConfig?.customHeaders,
this.hostRequestHeaders.headers,
resolvedBaseUrl,
),
capabilities,
maxContextSize: model.maxContextSize,
Expand Down Expand Up @@ -559,10 +563,12 @@ export function resolveOutboundHeaders(
providerType: string | undefined,
customHeaders: Readonly<Record<string, string>> | undefined,
hostHeaders: Readonly<Record<string, string>>,
baseUrl: string | undefined,
): Readonly<Record<string, string>> {
const forwardsAll =
providerType !== undefined &&
getProviderDefinition(providerType)?.hostHeaders === 'full';
getProviderDefinition(providerType)?.hostHeaders === 'full' &&
isFirstPartyBaseUrl(baseUrl);
Comment on lines 568 to +571

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 Keep inspection header provenance aligned with filtering

Pass the endpoint decision through to attributeHeaders as well. For a type: 'kimi' provider on a foreign base URL, this new condition emits only User-Agent, but inspection.ts:479-495 still computes forwardsAll solely from the provider definition and labels that surviving header as coming from the full host-header set. Consequently catalog.inspect() reports provenance inconsistent with the exact resolution pass used by get().

AGENTS.md reference: AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

const hostLayer = forwardsAll ? hostHeaders : userAgentOnly(hostHeaders);
return { ...parseKimiCodeCustomHeaders(), ...hostLayer, ...customHeaders };
}
Expand Down
21 changes: 21 additions & 0 deletions packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,24 @@ export interface IHostRequestHeaders {
}

export const IHostRequestHeaders = createDecorator<IHostRequestHeaders>('hostRequestHeaders');

const FIRST_PARTY_HOSTS = new Set(['api.moonshot.ai', 'api.moonshot.cn']);

/**
* True when a base URL points at the vendor's own endpoint, the only place
* the full host identity set (device id included) may be forwarded to.
* HTTPS is required: the same hostname over plain HTTP must not receive
* those headers in the clear. An unset base URL means the vendor's default
* endpoint, which is first-party by definition.
*/
export function isFirstPartyBaseUrl(baseUrl: string | undefined): boolean {
if (baseUrl === undefined) {
return true;
}
try {
const url = new URL(baseUrl);
return url.protocol === 'https:' && FIRST_PARTY_HOSTS.has(url.hostname);
} catch {
return false;
}
}
6 changes: 5 additions & 1 deletion packages/agent-core-v2/src/kosong/model/inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { getProviderDefinition } from '../provider/providerDefinition';

import type { ModelRecord } from './model';
import type { ResolvedModelAuthMaterial } from './model.types';
import { isFirstPartyBaseUrl } from './hostRequestHeaders';


export interface InspectedAuth {
Expand Down Expand Up @@ -476,9 +477,12 @@ function attributeHeaders(
): void {
const envLayer = parseKimiCodeCustomHeaders();
const rawHost = trace.captured<Readonly<Record<string, string>>>(TRACE.hostHeaders) ?? {};
// Keep the attribution aligned with resolveOutboundHeaders: full host
// identity only counts as forwarded on a first-party endpoint.
const forwardsAll =
providerConfig?.type !== undefined &&
getProviderDefinition(providerConfig.type)?.hostHeaders === 'full';
getProviderDefinition(providerConfig.type)?.hostHeaders === 'full' &&
isFirstPartyBaseUrl(providerConfig.baseUrl);
const hostLayer: Readonly<Record<string, string>> = forwardsAll
? rawHost
: rawHost['User-Agent'] === undefined
Expand Down
32 changes: 32 additions & 0 deletions packages/agent-core-v2/test/kosong/model/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,35 @@ describe('Model assembly (pure data)', () => {
}
});

it('keeps full host headers when a kimi provider explicitly targets the first-party host', () => {
const { host, catalog } = createHost({
providers: { kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'https://api.moonshot.ai/v1' } },
models: { k2: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 200000 } },
});
try {
const model = catalog.get('k2');
expect(model.headers).toMatchObject({
'User-Agent': 'kimi-test/1.0',
'X-Msh-Device-Id': 'device-1',
});
} finally {
host.dispose();
}
});

it('withholds the identity set from a first-party hostname over plain http', () => {
const { host, catalog } = createHost({
providers: { kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'http://api.moonshot.ai/v1' } },
models: { k2: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 200000 } },
});
try {
const model = catalog.get('k2');
expect(model.headers).toEqual({ 'User-Agent': 'kimi-test/1.0' });
} finally {
host.dispose();
}
});

it('forwards only the User-Agent to vendors without a full hostHeaders declaration', () => {
const { host, catalog } = createHost({
providers: {
Expand Down Expand Up @@ -199,6 +228,9 @@ describe('Model assembly (pure data)', () => {
expect(model.baseUrl).toBe('https://api.example.test');
// Kimi thinking is trait-driven: no Anthropic effort profile is inferred.
expect(model.supportEfforts).toBeUndefined();
// A kimi-typed provider pointed at a third-party host gets only the
// User-Agent, never the host identity set.
expect(model.headers).toEqual({ 'User-Agent': 'kimi-test/1.0' });
} finally {
host.dispose();
}
Expand Down
Loading