From 86e653bbb927fe12e33bea20852ac9b40a1762d5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 01:17:03 +0800 Subject: [PATCH 01/15] refactor(oauth): make X-Msh-Platform an explicit host identity field X-Msh-Platform was hardcoded to kimi_code_cli in createKimiDeviceHeaders, so non-CLI hosts could not state their own platform and the desktop had to patch the header after the fact. KimiHostIdentity now carries a required platform (every host declares its own value; the CLI constant stays the fallback only for direct createKimiDeviceHeaders callers), and userAgentProduct is renamed to productName so the transport identity uses one name everywhere. All in-repo identity constructions pass platform explicitly; the wire value for CLI and VS Code hosts is unchanged (kimi_code_cli). --- apps/kimi-code/src/cli/version.ts | 5 +-- apps/kimi-code/test/cli/run-shell.test.ts | 2 +- apps/vscode/docs/node-sdk-migration.md | 2 +- apps/vscode/src/runtime/kimi-runtime.ts | 3 +- .../test/kimi-harness.integration.test.ts | 2 +- .../test/replay-resume.integration.test.ts | 2 +- .../test/services/coreProcessService.test.ts | 4 +-- .../examples/kimi-harness-log-marker.ts | 2 +- .../examples/kimi-harness-logging-smoke.ts | 2 +- .../examples/runtime-smoke-helpers.ts | 3 +- .../examples/t8-race-create-single.ts | 2 +- packages/node-sdk/examples/t8-race-create.ts | 2 +- .../node-sdk/src/kimi-code-model-provider.ts | 3 +- packages/node-sdk/src/kimi-harness.ts | 2 +- packages/node-sdk/test/test-identity.ts | 3 +- packages/oauth/examples/kimi-oauth-smoke.ts | 3 +- packages/oauth/src/identity.ts | 21 +++++++++--- packages/oauth/src/toolkit.ts | 1 + packages/oauth/test/identity.test.ts | 34 ++++++++++++++++--- packages/oauth/test/toolkit.test.ts | 3 +- 20 files changed, 72 insertions(+), 29 deletions(-) diff --git a/apps/kimi-code/src/cli/version.ts b/apps/kimi-code/src/cli/version.ts index d5a4f36cdb..5a283d269d 100644 --- a/apps/kimi-code/src/cli/version.ts +++ b/apps/kimi-code/src/cli/version.ts @@ -7,7 +7,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { createKimiDefaultHeaders, createKimiUserAgent, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; +import { createKimiDefaultHeaders, createKimiUserAgent, KIMI_CODE_PLATFORM, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; import { CLI_USER_AGENT_PRODUCT } from '#/constant/app'; @@ -50,8 +50,9 @@ export function getVersion(): string { export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIdentity { return { - userAgentProduct: CLI_USER_AGENT_PRODUCT, + productName: CLI_USER_AGENT_PRODUCT, version, + platform: KIMI_CODE_PLATFORM, }; } diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 7e62606265..8f0c17d9d7 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -267,7 +267,7 @@ describe('runShell', () => { expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith( expect.objectContaining({ identity: expect.objectContaining({ - userAgentProduct: 'kimi-code-cli', + productName: 'kimi-code-cli', version: '1.2.3-test', }), sessionStartedProperties: { yolo: true, auto: false, plan: true, afk: false }, diff --git a/apps/vscode/docs/node-sdk-migration.md b/apps/vscode/docs/node-sdk-migration.md index 92ba893d09..78a9731657 100644 --- a/apps/vscode/docs/node-sdk-migration.md +++ b/apps/vscode/docs/node-sdk-migration.md @@ -80,7 +80,7 @@ stay in the trusted Extension Host. The runtime constructs the SDK client with: -- `userAgentProduct: "kimi-code-vscode"` +- `productName: "kimi-code-vscode"` - `version` from `apps/vscode/package.json` - `uiMode: "vscode"` diff --git a/apps/vscode/src/runtime/kimi-runtime.ts b/apps/vscode/src/runtime/kimi-runtime.ts index 9c5a9e5444..a2ddd68fe3 100644 --- a/apps/vscode/src/runtime/kimi-runtime.ts +++ b/apps/vscode/src/runtime/kimi-runtime.ts @@ -60,8 +60,9 @@ export class KimiRuntime { createKimiHarness({ ...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }), identity: { - userAgentProduct: "kimi-code-vscode", + productName: "kimi-code-vscode", version: options.version, + platform: "kimi_code_cli", }, uiMode: "vscode", }); diff --git a/apps/vscode/test/kimi-harness.integration.test.ts b/apps/vscode/test/kimi-harness.integration.test.ts index 21c466b84e..ea53fae3d3 100644 --- a/apps/vscode/test/kimi-harness.integration.test.ts +++ b/apps/vscode/test/kimi-harness.integration.test.ts @@ -141,7 +141,7 @@ async function createRuntimeRig(extraAliases: readonly string[] = []): Promise { const harness = createKimiHarness({ homeDir, - identity: { userAgentProduct: "kimi-code-cli", version: "test" }, + identity: { productName: "kimi-code-cli", version: "test", platform: "kimi_code_cli" }, }); cleanups.push(() => harness.close()); return harness; diff --git a/apps/vscode/test/replay-resume.integration.test.ts b/apps/vscode/test/replay-resume.integration.test.ts index 01706ea44a..769511cc1b 100644 --- a/apps/vscode/test/replay-resume.integration.test.ts +++ b/apps/vscode/test/replay-resume.integration.test.ts @@ -46,7 +46,7 @@ async function createReplayRig(): Promise { const provider = await createFakeProviderHarness(); const harness = createKimiHarness({ homeDir, - identity: { userAgentProduct: "kimi-code-vscode", version: "test" }, + identity: { productName: "kimi-code-vscode", version: "test", platform: "kimi_code_cli" }, }); await harness.setConfig({ providers: { diff --git a/packages/agent-core/test/services/coreProcessService.test.ts b/packages/agent-core/test/services/coreProcessService.test.ts index 0c9241e4c8..60dbd7e49c 100644 --- a/packages/agent-core/test/services/coreProcessService.test.ts +++ b/packages/agent-core/test/services/coreProcessService.test.ts @@ -276,7 +276,7 @@ describe('CoreProcessService direct construction', () => { it('default-wires kimiRequestHeaders from identity when caller omits headers', () => { const headers = CoreProcessService._defaultKimiRequestHeaders( tmpHome, - { userAgentProduct: 'kimi-code-cli', version: '9.9.9' }, + { productName: 'kimi-code-cli', version: '9.9.9', platform: 'kimi_code_cli' }, ); expect(headers).toBeDefined(); expect(headers!['User-Agent']).toMatch(/^kimi-code-cli\/9\.9\.9/); @@ -297,7 +297,7 @@ describe('CoreProcessService direct construction', () => { const picked = explicit ?? CoreProcessService._defaultKimiRequestHeaders( tmpHome, - { userAgentProduct: 'kimi-code-cli', version: '9.9.9' }, + { productName: 'kimi-code-cli', version: '9.9.9', platform: 'kimi_code_cli' }, ); expect(picked).toBe(explicit); }); diff --git a/packages/node-sdk/examples/kimi-harness-log-marker.ts b/packages/node-sdk/examples/kimi-harness-log-marker.ts index 8062f5d528..5d1ab73cd5 100644 --- a/packages/node-sdk/examples/kimi-harness-log-marker.ts +++ b/packages/node-sdk/examples/kimi-harness-log-marker.ts @@ -42,7 +42,7 @@ async function main(): Promise { const options = parseCliArgs(); const resolvedHome = resolveKimiHome(options.homeDir); const harness = createKimiHarness({ - identity: { userAgentProduct: 'kimi-code-cli', version: 'log-marker' }, + identity: { productName: 'kimi-code-cli', version: 'log-marker', platform: 'kimi_code_cli' }, homeDir: options.homeDir, }); diff --git a/packages/node-sdk/examples/kimi-harness-logging-smoke.ts b/packages/node-sdk/examples/kimi-harness-logging-smoke.ts index 090cb50c4c..3105c9a03c 100644 --- a/packages/node-sdk/examples/kimi-harness-logging-smoke.ts +++ b/packages/node-sdk/examples/kimi-harness-logging-smoke.ts @@ -73,7 +73,7 @@ async function main(): Promise { const longEntry = `SMOKE_LONG_TRUNCATED_${runId}`; const finalEntry = `SMOKE_FINAL_AFTER_ROTATION_SHOULD_APPEAR_${runId}`; const harness = createKimiHarness({ - identity: { userAgentProduct: 'kimi-code-cli', version: '0.1.1' }, + identity: { productName: 'kimi-code-cli', version: '0.1.1', platform: 'kimi_code_cli' }, homeDir: TEST_HOME, }); diff --git a/packages/node-sdk/examples/runtime-smoke-helpers.ts b/packages/node-sdk/examples/runtime-smoke-helpers.ts index 784fa99230..8db4c4f720 100644 --- a/packages/node-sdk/examples/runtime-smoke-helpers.ts +++ b/packages/node-sdk/examples/runtime-smoke-helpers.ts @@ -7,8 +7,9 @@ export function smokeIdentityFromEnv(): KimiHostIdentity { throw new Error('KIMI_CODE_SMOKE_VERSION is required for Kimi SDK smoke examples.'); } return { - userAgentProduct: 'kimi-code-cli', + productName: 'kimi-code-cli', version, + platform: 'kimi_code_cli', }; } diff --git a/packages/node-sdk/examples/t8-race-create-single.ts b/packages/node-sdk/examples/t8-race-create-single.ts index a6974caee1..2124ba4fc3 100644 --- a/packages/node-sdk/examples/t8-race-create-single.ts +++ b/packages/node-sdk/examples/t8-race-create-single.ts @@ -6,7 +6,7 @@ const homeDir = process.argv[3]!; const sessionId = process.argv[4]!; const label = process.argv[5] ?? 'P'; -const identity: any = { userAgentProduct: 'kimi-code-cli', version: '0.0.1-test' }; +const identity: any = { productName: 'kimi-code-cli', version: '0.0.1-test', platform: 'kimi_code_cli' }; const h = createKimiHarness({ identity, homeDir }); try { diff --git a/packages/node-sdk/examples/t8-race-create.ts b/packages/node-sdk/examples/t8-race-create.ts index 0c6a74d2f4..2839f96619 100644 --- a/packages/node-sdk/examples/t8-race-create.ts +++ b/packages/node-sdk/examples/t8-race-create.ts @@ -5,7 +5,7 @@ const workDir = process.argv[2]!; const homeDir = process.argv[3]!; const sessionId = process.argv[4]!; -const identity: any = { userAgentProduct: 'kimi-code-cli', version: '0.0.1-test' }; +const identity: any = { productName: 'kimi-code-cli', version: '0.0.1-test', platform: 'kimi_code_cli' }; const harnessA = createKimiHarness({ identity, homeDir }); const harnessB = createKimiHarness({ identity, homeDir }); diff --git a/packages/node-sdk/src/kimi-code-model-provider.ts b/packages/node-sdk/src/kimi-code-model-provider.ts index 4e2f6eff5e..39350c13d9 100644 --- a/packages/node-sdk/src/kimi-code-model-provider.ts +++ b/packages/node-sdk/src/kimi-code-model-provider.ts @@ -50,8 +50,9 @@ export class KimiForCodingProvider implements ModelProvider { this.defaultHeaders = options.defaultHeaders; this.homeDir = resolveKimiHome(options.homeDir); this.identity = { - userAgentProduct: options.userAgentProduct, + productName: options.productName, version: options.version, + platform: options.platform, userAgentSuffix: options.userAgentSuffix, }; this.oauthRef = resolveKimiCodeOAuthRef({ diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index a32ee5c103..8197d9bac0 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -353,7 +353,7 @@ export class KimiHarness { // see core-impl.ts). Kept as an explicit key so both producers share the // same session_started schema. client_id: null, - client_name: this.identity?.userAgentProduct ?? null, + client_name: this.identity?.productName ?? null, client_version: this.identity?.version ?? null, ui_mode: this.uiMode, resumed, diff --git a/packages/node-sdk/test/test-identity.ts b/packages/node-sdk/test/test-identity.ts index c068486602..1080bdf869 100644 --- a/packages/node-sdk/test/test-identity.ts +++ b/packages/node-sdk/test/test-identity.ts @@ -1,6 +1,7 @@ import type { KimiHostIdentity } from '#/index'; export const TEST_IDENTITY: KimiHostIdentity = { - userAgentProduct: 'kimi-code-cli', + productName: 'kimi-code-cli', version: '0.0.0-test', + platform: 'kimi_code_cli', }; diff --git a/packages/oauth/examples/kimi-oauth-smoke.ts b/packages/oauth/examples/kimi-oauth-smoke.ts index ebdd2e3c70..00d5278541 100644 --- a/packages/oauth/examples/kimi-oauth-smoke.ts +++ b/packages/oauth/examples/kimi-oauth-smoke.ts @@ -78,8 +78,9 @@ function smokeIdentityFromEnv(): KimiHostIdentity { throw new Error('KIMI_CODE_SMOKE_VERSION is required for Kimi OAuth smoke.'); } return { - userAgentProduct: "kimi-code-cli", + productName: "kimi-code-cli", version, + platform: "kimi_code_cli", }; } diff --git a/packages/oauth/src/identity.ts b/packages/oauth/src/identity.ts index 6200d3531e..8812722f59 100644 --- a/packages/oauth/src/identity.ts +++ b/packages/oauth/src/identity.ts @@ -18,8 +18,15 @@ import type { DeviceHeaders } from './types'; export const KIMI_CODE_PLATFORM = 'kimi_code_cli'; export interface KimiHostIdentity { - readonly userAgentProduct: string; + readonly productName: string; readonly version: string; + /** + * `X-Msh-Platform` value reported to the OAuth host and managed endpoints + * (e.g. `kimi_code_cli`, `kimi_code_desktop`). Every host must state its own + * explicitly — `KIMI_CODE_PLATFORM` is the CLI's value, not a default to + * inherit silently. + */ + readonly platform: string; readonly userAgentSuffix?: string | undefined; } @@ -70,9 +77,12 @@ export function createKimiDeviceId( export function createKimiDeviceHeaders(options: { readonly homeDir: string; readonly version: string; + /** Falls back to `KIMI_CODE_PLATFORM` only for direct callers without a host + identity; hosts carrying a `KimiHostIdentity` always pass theirs. */ + readonly platform?: string | undefined; }): DeviceHeaders { return { - 'X-Msh-Platform': KIMI_CODE_PLATFORM, + 'X-Msh-Platform': options.platform ?? KIMI_CODE_PLATFORM, 'X-Msh-Version': requiredAsciiHeader(options.version, 'Kimi identity version'), 'X-Msh-Device-Name': asciiHeader(hostname()), 'X-Msh-Device-Model': asciiHeader(deviceModel()), @@ -82,11 +92,11 @@ export function createKimiDeviceHeaders(options: { } export function createKimiUserAgent(options: { - readonly userAgentProduct: string; + readonly productName: string; readonly version: string; readonly userAgentSuffix?: string | undefined; }): string { - const product = requiredAsciiHeader(options.userAgentProduct, 'Kimi identity product'); + const product = requiredAsciiHeader(options.productName, 'Kimi identity product'); const version = requiredAsciiHeader(options.version, 'Kimi identity version'); const suffix = options.userAgentSuffix === undefined ? undefined : asciiHeader(options.userAgentSuffix, ''); @@ -101,6 +111,7 @@ export function createKimiDefaultHeaders(options: KimiIdentityOptions): Record { createKimiDeviceHeaders({ homeDir: this.homeDir, version: identity.version, + platform: identity.platform, }), ...this.managerOptions, }); diff --git a/packages/oauth/test/identity.test.ts b/packages/oauth/test/identity.test.ts index e94e04a3cf..6dfb34c87a 100644 --- a/packages/oauth/test/identity.test.ts +++ b/packages/oauth/test/identity.test.ts @@ -78,42 +78,66 @@ describe('Kimi identity factories', () => { it('creates kimi-code-cli User-Agent and appends suffix only to UA', () => { expect( createKimiUserAgent({ - userAgentProduct: 'kimi-code-cli', + productName: 'kimi-code-cli', version: '1.2.3', }), ).toBe('kimi-code-cli/1.2.3'); expect( createKimiUserAgent({ - userAgentProduct: 'kimi-code-cli', + productName: 'kimi-code-cli', version: '1.2.3', userAgentSuffix: 'wire 4.5.6', }), ).toBe('kimi-code-cli/1.2.3 (wire 4.5.6)'); }); + it('honors an explicit X-Msh-Platform value', () => { + const headers = createKimiDeviceHeaders({ + homeDir: tempHome(), + version: '1.2.3-test', + platform: 'kimi_code_desktop', + }); + + expect(headers['X-Msh-Platform']).toBe('kimi_code_desktop'); + }); + it('merges User-Agent and device headers into default headers', () => { const headers = createKimiDefaultHeaders({ homeDir: tempHome(), - userAgentProduct: 'kimi-code-cli', + productName: 'kimi-code-cli', version: '1.2.3', + platform: 'kimi_code_cli', }); expect(headers['User-Agent']).toBe('kimi-code-cli/1.2.3'); + expect(headers['X-Msh-Platform']).toBe('kimi_code_cli'); expect(headers['X-Msh-Version']).toBe('1.2.3'); expect(headers['X-Msh-Device-Id']).toMatch(/^[0-9a-f-]+$/); }); + + it('threads the identity platform into default headers', () => { + const headers = createKimiDefaultHeaders({ + homeDir: tempHome(), + productName: 'kimi-code-desktop', + version: '0.0.13', + platform: 'kimi_code_desktop', + }); + + expect(headers['User-Agent']).toBe('kimi-code-desktop/0.0.13'); + expect(headers['X-Msh-Platform']).toBe('kimi_code_desktop'); + }); }); // HTTP header values must be plain ASCII without leading/trailing whitespace. // The public factories surface the sanitizer used for User-Agent and X-Msh-*. describe('ascii header value sanitization', () => { it('strips a trailing newline from a header value', () => { - const ua = createKimiUserAgent({ userAgentProduct: 'kimi-code-cli', version: '6.8.0-101\n' }); + const ua = createKimiUserAgent({ productName: 'kimi-code-cli', version: '6.8.0-101\n' }); expect(ua).toBe('kimi-code-cli/6.8.0-101'); }); it('drops non-ASCII codepoints while keeping the ASCII remainder', () => { - const ua = createKimiUserAgent({ userAgentProduct: 'kimi-code-cli', version: 'héllo' }); + const ua = createKimiUserAgent({ productName: 'kimi-code-cli', version: 'héllo' }); expect(ua).toBe('kimi-code-cli/hllo'); }); diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 56c86b44eb..110d45ccf3 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -45,8 +45,9 @@ function token(accessToken: string): TokenInfo { } const TEST_IDENTITY = { - userAgentProduct: 'kimi-code-cli', + productName: 'kimi-code-cli', version: '0.0.0-test', + platform: 'kimi_code_cli', } as const; afterEach(() => { From 5e81a392fdeda2be4b8c5757850b76e187f8406c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 01:30:19 +0800 Subject: [PATCH 02/15] feat(agent-core-v2): carry the host identity in the bootstrap snapshot Replace the flat clientVersion field with a required clientIdentity (KimiHostIdentity) so every consumer reads the same host identity object: OAuthToolkitService now passes it to the OAuth toolkit, which means the OAuth device-flow endpoints (device authorization, token polling, refresh) on the kap-server path finally send the full X-Msh-* device headers instead of none, and the telemetry cloud appender reads client_version from the same source. A built-in CLI fallback keeps bare bootstrap() calls in tests working; composition roots must pass their own identity. The session export manifest grows an optional desktopVersion field (payload plumbed through; filled by kap-server in a follow-up). --- apps/kimi-code/src/cli/v2/run-v2-print.ts | 2 +- apps/kimi-code/test/cli/v2-run-print.test.ts | 6 +++- .../agent-core-v2/src/app/auth/authService.ts | 2 +- .../src/app/bootstrap/bootstrap.ts | 23 +++++++++--- .../src/app/bootstrap/bootstrapService.ts | 6 ++-- .../src/app/sessionExport/manifest.ts | 2 ++ .../src/app/sessionExport/sessionExport.ts | 2 ++ .../app/sessionExport/sessionExportService.ts | 1 + .../src/app/telemetry/cloudAppender.ts | 6 ++-- .../app/bootstrap/bootstrapService.test.ts | 35 +++++++++++++++++-- .../agent-core-v2/test/app/bootstrap/stubs.ts | 8 ++++- .../test/app/telemetry/cloudAppender.test.ts | 4 +-- packages/agent-core-v2/test/harness/agent.ts | 3 ++ packages/kap-server/src/start.ts | 35 +++++++++++++------ packages/kap-server/test/boot.test.ts | 6 +++- packages/kap-server/test/telemetry.test.ts | 5 +++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 2 +- 17 files changed, 116 insertions(+), 32 deletions(-) diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 0bf117b038..3f8ccbcf70 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -128,7 +128,7 @@ export async function runV2Print( const identity = createKimiCodeHostIdentity(version); const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity }); - const { app } = bootstrap({ homeDir, clientVersion: version }, [ + const { app } = bootstrap({ homeDir, clientIdentity: identity }, [ ...logSeed(logging), ...hostRequestHeadersSeed(hostHeaders), // `--skillsDir` (v1 print parity): explicit skill dirs replace default diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index e6a5e5ce3e..27c6643898 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -205,7 +205,11 @@ function makeFakeHarness() { { platform: 'linux', arch: 'x64', - clientVersion: '1.2.3-test', + clientIdentity: { + productName: 'test-product', + version: '1.2.3-test', + platform: 'test_platform', + }, osHomeDir: '/home/test', getEnv: () => undefined, }, diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 71b6c71c22..37f4e07bd8 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -864,7 +864,7 @@ function managedModel( class OAuthToolkitService extends KimiOAuthToolkit implements IOAuthToolkit { declare readonly _serviceBrand: undefined; constructor(@IBootstrapService bootstrap: IBootstrapService) { - super({ homeDir: bootstrap.homeDir }); + super({ homeDir: bootstrap.homeDir, identity: bootstrap.clientIdentity }); } } diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 0ea371c39d..a9a7558fd8 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -3,7 +3,7 @@ * * Defines the `IBootstrapService`, the snapshot of the world the process runs * in, resolved once at startup and frozen for the process: observed host facts - * (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientVersion`) and the + * (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `clientIdentity`) and the * app path layout (`homeDir`, `configPath`, …). `resolveBootstrapOptions` is * the single place that reads `process.env` / `os.homedir()` / invocation * input to resolve the snapshot; everything downstream reads from @@ -18,6 +18,8 @@ import { homedir } from 'node:os'; import { join } from 'pathe'; +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; + import { SyncDescriptor } from '#/_base/di/descriptors'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { createAppScope, type Scope, type ScopeSeed } from '#/_base/di/scope'; @@ -36,7 +38,7 @@ export interface IBootstrapOptions { readonly arch: string; readonly cwd: string; readonly env: NodeJS.ProcessEnv; - readonly clientVersion: string; + readonly clientIdentity: KimiHostIdentity; } export const IBootstrapOptions: ServiceIdentifier = @@ -61,7 +63,7 @@ export interface IBootstrapService { readonly osHomeDir: string; readonly homeDir: string; readonly configPath: string; - readonly clientVersion: string; + readonly clientIdentity: KimiHostIdentity; readonly sessionsDir: string; readonly blobsDir: string; readonly storeDir: string; @@ -87,9 +89,20 @@ export interface BootstrapInput { readonly platform?: NodeJS.Platform; readonly arch?: string; readonly cwd?: string; - readonly clientVersion?: string; + readonly clientIdentity?: KimiHostIdentity; } +/** + * Built-in identity used only when the caller omits `clientIdentity`. This is + * a fallback so bare `bootstrap()` calls in unit tests keep working — NOT the + * contract: composition roots must pass the host's own `KimiHostIdentity`. + */ +const FALLBACK_CLIENT_IDENTITY: KimiHostIdentity = { + productName: 'kimi-code-cli', + version: 'unknown', + platform: 'kimi_code_cli', +}; + export function resolveBootstrapOptions(input: BootstrapInput = {}): IBootstrapOptions { const env = input.env ?? process.env; const osHomeDir = input.osHomeDir ?? homedir(); @@ -103,7 +116,7 @@ export function resolveBootstrapOptions(input: BootstrapInput = {}): IBootstrapO arch: input.arch ?? process.arch, cwd: input.cwd ?? process.cwd(), env, - clientVersion: input.clientVersion ?? 'unknown', + clientIdentity: input.clientIdentity ?? FALLBACK_CLIENT_IDENTITY, }; } diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index 32fcfacdfa..afeb61eca5 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -15,6 +15,8 @@ import { basename, join, relative } from 'pathe'; +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; + import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { @@ -32,7 +34,7 @@ export class BootstrapService implements IBootstrapService { readonly osHomeDir: string; readonly homeDir: string; readonly configPath: string; - readonly clientVersion: string; + readonly clientIdentity: KimiHostIdentity; readonly sessionsDir: string; readonly blobsDir: string; readonly storeDir: string; @@ -51,7 +53,7 @@ export class BootstrapService implements IBootstrapService { this.env = options.env; this.homeDir = options.homeDir; this.configPath = options.configPath; - this.clientVersion = options.clientVersion; + this.clientIdentity = options.clientIdentity; this.sessionsDir = join(options.homeDir, 'sessions'); this.blobsDir = join(options.homeDir, 'blobs'); this.storeDir = join(options.homeDir, 'store'); diff --git a/packages/agent-core-v2/src/app/sessionExport/manifest.ts b/packages/agent-core-v2/src/app/sessionExport/manifest.ts index 49b418987c..27f5c80cd9 100644 --- a/packages/agent-core-v2/src/app/sessionExport/manifest.ts +++ b/packages/agent-core-v2/src/app/sessionExport/manifest.ts @@ -29,6 +29,7 @@ export function buildExportManifest(args: { readonly sessionLogPath?: string | undefined; readonly globalLogPath?: string | undefined; readonly webLogPath?: string; + readonly desktopVersion?: string; readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; }): ExportSessionManifest { @@ -52,6 +53,7 @@ export function buildExportManifest(args: { sessionLogPath: args.sessionLogPath, globalLogPath: args.globalLogPath, webLogPath: args.webLogPath, + desktopVersion: args.desktopVersion, installSource: args.installSource, shellEnv: args.shellEnv, }; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts index a634941e3d..df06acd2e7 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts @@ -23,6 +23,7 @@ export interface ExportSessionPayload { readonly includeGlobalLog?: boolean | undefined; readonly includeDesktopLog?: boolean; readonly version: string; + readonly desktopVersion?: string; readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; } @@ -42,6 +43,7 @@ export interface ExportSessionManifest { readonly globalLogPath?: string | undefined; readonly desktopLogPath?: string; readonly webLogPath?: string; + readonly desktopVersion?: string; readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; } diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 17e59b7976..7f97631baa 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -215,6 +215,7 @@ export async function exportSessionDirectory(input: { sessionScan, sessionLogPath: stableSessionLog === undefined ? undefined : SESSION_LOG_REL, webLogPath: bundledWebLog ? WEB_LOG_REL : undefined, + desktopVersion: input.request.desktopVersion, installSource: input.request.installSource, shellEnv: input.request.shellEnv, }); diff --git a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts index fd0cb5655f..fa14f1626c 100644 --- a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts +++ b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts @@ -3,7 +3,7 @@ * batches events, drops non-primitive properties, redacts PII from string * values, enriches events with common context, and posts them to the * telemetry endpoint through `CloudTransport`, which persists failed events - * through the `storage` byte layer. Reads host facts (`clientVersion`, env, + * through the `storage` byte layer. Reads host facts (`clientIdentity`, env, * platform/arch) from `IBootstrapService`; `createCloudAppender` assembles * one from a `ServicesAccessor` so hosts only supply identity facts. * App-scoped; independent of `@moonshot-ai/kimi-telemetry`. @@ -186,8 +186,8 @@ function buildContext(options: CloudAppenderOptions): CloudContext { const { bootstrap } = options; const context: CloudContext = { app_name: options.appName, - client_version: bootstrap.clientVersion, - version: bootstrap.clientVersion, + client_version: bootstrap.clientIdentity.version, + version: bootstrap.clientIdentity.version, core_version: resolveCoreVersion(), runtime: 'node', platform: bootstrap.platform, diff --git a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts index 9231e71e53..dc08aee9f7 100644 --- a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts +++ b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts @@ -12,6 +12,8 @@ import { BootstrapService } from '#/app/bootstrap/bootstrapService'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { stubClientIdentity } from './stubs'; + describe('BootstrapService (scoped)', () => { beforeEach(() => { // Keep the registry minimal so unrelated OnScopeCreated services do not run. @@ -26,7 +28,9 @@ describe('BootstrapService (scoped)', () => { }); it('resolves homeDir/configPath from the seeded context token', () => { - const host = createScopedTestHost(bootstrapSeed({ homeDir: '/tmp/kimi-home' })); + const host = createScopedTestHost( + bootstrapSeed({ homeDir: '/tmp/kimi-home', clientIdentity: stubClientIdentity }), + ); const svc = host.app.accessor.get(IBootstrapService); expect(svc.homeDir).toBe('/tmp/kimi-home'); expect(svc.configPath).toBe('/tmp/kimi-home/config.toml'); @@ -34,8 +38,19 @@ describe('BootstrapService (scoped)', () => { host.dispose(); }); + it('exposes the seeded client identity', () => { + const host = createScopedTestHost( + bootstrapSeed({ homeDir: '/tmp/kimi-home', clientIdentity: stubClientIdentity }), + ); + const svc = host.app.accessor.get(IBootstrapService); + expect(svc.clientIdentity).toEqual(stubClientIdentity); + host.dispose(); + }); + it('getEnv reads from the seeded env bag', () => { - const host = createScopedTestHost(bootstrapSeed({ env: { FOO: 'bar' } })); + const host = createScopedTestHost( + bootstrapSeed({ env: { FOO: 'bar' }, clientIdentity: stubClientIdentity }), + ); const svc = host.app.accessor.get(IBootstrapService); expect(svc.getEnv('FOO')).toBe('bar'); expect(svc.getEnv('MISSING')).toBeUndefined(); @@ -49,11 +64,25 @@ describe('resolveBootstrapOptions', () => { expect(resolveBootstrapOptions({ osHomeDir: '/b', env: { KIMI_CODE_HOME: '/c' } }).homeDir).toBe('/c'); expect(resolveBootstrapOptions({ osHomeDir: '/b', env: {} }).homeDir).toBe('/b/.kimi-code'); }); + + it('passes through an explicit clientIdentity', () => { + expect( + resolveBootstrapOptions({ env: {}, clientIdentity: stubClientIdentity }).clientIdentity, + ).toEqual(stubClientIdentity); + }); + + it('falls back to a built-in CLI identity when clientIdentity is omitted', () => { + expect(resolveBootstrapOptions({ env: {} }).clientIdentity).toEqual({ + productName: 'kimi-code-cli', + version: 'unknown', + platform: 'kimi_code_cli', + }); + }); }); describe('bootstrap() storage seeding', () => { it('seeds IFileSystemStorageService as a FileStorageService instance', () => { - const { app } = bootstrap({ homeDir: '/tmp/kimi-home' }); + const { app } = bootstrap({ homeDir: '/tmp/kimi-home', clientIdentity: stubClientIdentity }); try { const storage = app.accessor.get(IFileSystemStorageService); expect(storage).toBeInstanceOf(FileStorageService); diff --git a/packages/agent-core-v2/test/app/bootstrap/stubs.ts b/packages/agent-core-v2/test/app/bootstrap/stubs.ts index f5420299d8..b5a097969c 100644 --- a/packages/agent-core-v2/test/app/bootstrap/stubs.ts +++ b/packages/agent-core-v2/test/app/bootstrap/stubs.ts @@ -12,6 +12,12 @@ import { type PersistenceScopeName, } from '#/app/bootstrap/bootstrap'; +export const stubClientIdentity = { + productName: 'test-product', + version: '0.0.0-test', + platform: 'test_platform', +} as const; + export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService { const sessionsScope = 'sessions'; const scopes: Record = { @@ -36,7 +42,7 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv homeDir, configPath: `${homeDir}/config.toml`, configKey: 'config.toml', - clientVersion: '0.0.0-test', + clientIdentity: stubClientIdentity, sessionsDir: `${homeDir}/sessions`, blobsDir: `${homeDir}/blobs`, storeDir: `${homeDir}/store`, diff --git a/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts b/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts index 3c3abf3b3b..7d70c3d4f1 100644 --- a/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts @@ -11,7 +11,7 @@ import { import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { CloudAppender, type CloudAppenderOptions } from '#/app/telemetry/cloudAppender'; -import { stubBootstrap } from '../bootstrap/stubs'; +import { stubBootstrap, stubClientIdentity } from '../bootstrap/stubs'; interface CapturedRequest { readonly url: string; @@ -50,7 +50,7 @@ function baseOptions( const { homeDir: dir = '', storage, ...rest } = overrides; return { storage: storage ?? new FileStorageService(dir), - bootstrap: { ...stubBootstrap(), clientVersion: '1.0.0' }, + bootstrap: { ...stubBootstrap(), clientIdentity: { ...stubClientIdentity, version: '1.0.0' } }, deviceId: 'dev', appName: 'test-app', sleep: async () => {}, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 7c9d868ca0..4a4a25174b 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -178,6 +178,7 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog import { ISessionSwarmService } from '#/session/swarm/sessionSwarm'; import type { PathAccessOperation } from '#/session/workspaceContext/workspaceContext'; +import { stubClientIdentity } from '../app/bootstrap/stubs'; import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events'; import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec'; import { createScriptedGenerate } from './scripted-generate'; @@ -537,6 +538,7 @@ export function homeDirServices(homeDir: string | undefined): TestAgentServiceOv homeDir, cwd: process.cwd(), env: process.env, + clientIdentity: stubClientIdentity, })) { reg.defineInstance(id, value); } @@ -1032,6 +1034,7 @@ export class AgentTestContext { cwd: this.cwd, osHomeDir: TEST_HOME_DIR, env: process.env, + clientIdentity: stubClientIdentity, })) { reg.defineInstance(id, value); } diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 79610a3d95..e281b4a797 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -231,17 +231,30 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { // ... and it backs the default product User-Agent. const defaults = server.core.accessor.get(IHostRequestHeaders); expect(defaults.headers['User-Agent']).toBe('kimi-code-cli/9.9.9-host'); - expect(server.core.accessor.get(IBootstrapService).clientVersion).toBe('9.9.9-host'); + expect(server.core.accessor.get(IBootstrapService).clientIdentity).toEqual({ + productName: 'kimi-code-cli', + version: '9.9.9-host', + platform: 'kimi_code_cli', + }); }); it('seeds a default product User-Agent that opts.seeds can override', async () => { diff --git a/packages/kap-server/test/telemetry.test.ts b/packages/kap-server/test/telemetry.test.ts index 5892691dfd..b0f615a432 100644 --- a/packages/kap-server/test/telemetry.test.ts +++ b/packages/kap-server/test/telemetry.test.ts @@ -61,6 +61,11 @@ describe('server telemetry', () => { homeDir: home as string, configPath: resolveConfigPath({ homeDir: home as string }), env: resolvedEnv, + clientIdentity: { + productName: 'test-product', + version: '0.0.0-test', + platform: 'test_platform', + }, }, [ ...logSeed(resolveLoggingConfig({ homeDir: home as string, env: resolvedEnv })), diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 81848610c6..96f4f2e224 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -416,7 +416,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { { homeDir: this.homeDir, configPath: this.configPath, - clientVersion: this.identity?.version, + clientIdentity: this.identity, }, [ ...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env })), From a544ff9fdbf30dc98dfd50dc7e83787f2c518ccd Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 01:38:44 +0800 Subject: [PATCH 03/15] feat(agent-core): thread the host identity into the managed auth facades The v1 managed auth facade constructed its OAuth toolkit without an identity, so token refreshes from inside the core went out without any X-Msh-* device headers. createManagedAuthFacade now takes an optional KimiHostIdentity and every call site supplies one: CoreProcessService._defaultOAuthTokenResolver forwards the core process's options.identity (the same source _defaultKimiRequestHeaders uses), and the DI-held services (oauth / auth summary / model catalog) read it from a new optional identity field on IEnvironmentService. The library-level "no identity, no device headers" contract is unchanged. --- .../src/services/auth/managedAuth.ts | 6 ++- .../authSummary/authSummaryService.ts | 2 +- .../coreProcess/coreProcessService.ts | 12 ++++- .../src/services/environment/environment.ts | 13 +++++- .../modelCatalog/modelCatalogService.ts | 2 +- .../src/services/oauth/oauthService.ts | 2 +- .../test/services/coreProcessService.test.ts | 45 ++++++++++++++++++- 7 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/agent-core/src/services/auth/managedAuth.ts b/packages/agent-core/src/services/auth/managedAuth.ts index 04ddbb1c87..23bd9d096d 100644 --- a/packages/agent-core/src/services/auth/managedAuth.ts +++ b/packages/agent-core/src/services/auth/managedAuth.ts @@ -9,6 +9,7 @@ import { resolveKimiCodeLoginAuth, resolveKimiCodeRuntimeAuth, type BearerTokenProvider, + type KimiHostIdentity, type KimiOAuthLoginOptions, type ManagedKimiConfigShape, } from '@moonshot-ai/kimi-code-oauth'; @@ -50,9 +51,11 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { constructor( private readonly options: Pick, + identity?: KimiHostIdentity, ) { this.toolkit = new KimiOAuthToolkit({ homeDir: options.homeDir, + identity, configAdapter: { configPath: options.configPath, read: () => readConfigFile(options.configPath) as ServicesManagedConfig, @@ -169,6 +172,7 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { export function createManagedAuthFacade( env: Pick, + identity?: KimiHostIdentity, ): ServicesAuthFacade { - return new ServicesManagedAuthFacade(env); + return new ServicesManagedAuthFacade(env, identity); } diff --git a/packages/agent-core/src/services/authSummary/authSummaryService.ts b/packages/agent-core/src/services/authSummary/authSummaryService.ts index e288d2e9ed..b9a3c5af13 100644 --- a/packages/agent-core/src/services/authSummary/authSummaryService.ts +++ b/packages/agent-core/src/services/authSummary/authSummaryService.ts @@ -30,7 +30,7 @@ export class AuthSummaryService @ICoreProcessService private readonly core: ICoreProcessService, ) { super(); - this._authFacade = createManagedAuthFacade(env); + this._authFacade = createManagedAuthFacade(env, env.identity); } async get(): Promise { diff --git a/packages/agent-core/src/services/coreProcess/coreProcessService.ts b/packages/agent-core/src/services/coreProcess/coreProcessService.ts index 970adb5172..e880133258 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcessService.ts +++ b/packages/agent-core/src/services/coreProcess/coreProcessService.ts @@ -92,7 +92,11 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic // tests) can still override via `options.resolveOAuthTokenProvider`. const resolveOAuthTokenProvider: OAuthTokenProviderResolver = options.resolveOAuthTokenProvider ?? - CoreProcessService._defaultOAuthTokenResolver(env.homeDir, env.configPath); + CoreProcessService._defaultOAuthTokenResolver( + env.homeDir, + env.configPath, + options.identity, + ); // Default-wire the Kimi request headers (User-Agent + X-Msh-* device // identity). Without this, KimiCore's outbound fetch carries the @@ -212,14 +216,18 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic * runtimes share OAuth credentials when both run against the same * `~/.kimi-code`. * + * `identity` is forwarded to the managed auth facade so token refreshes + * carry the same `X-Msh-*` device headers as `_defaultKimiRequestHeaders`. + * * Exposed as `static` so tests can assert the wiring without exercising the * full agent-core turn loop. */ static _defaultOAuthTokenResolver( homeDir: string, configPath: string, + identity?: KimiHostIdentity, ): OAuthTokenProviderResolver { - const facade = createManagedAuthFacade({ homeDir, configPath }); + const facade = createManagedAuthFacade({ homeDir, configPath }, identity); return facade.resolveOAuthTokenProvider; } diff --git a/packages/agent-core/src/services/environment/environment.ts b/packages/agent-core/src/services/environment/environment.ts index a8eea68f1d..18c58ae822 100644 --- a/packages/agent-core/src/services/environment/environment.ts +++ b/packages/agent-core/src/services/environment/environment.ts @@ -1,7 +1,7 @@ /** * `IEnvironmentService` — canonical source for resolved filesystem paths - * (home directory, config file, etc.) used by the daemon and in-process - * services. + * (home directory, config file, etc.) and the host identity used by the + * daemon and in-process services. * * VSCode-style: injected via `@IEnvironmentService` rather than passed as * a static options prefix. This eliminates the "options bag as first ctor @@ -9,6 +9,7 @@ */ import { createDecorator } from '../../di'; +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; export interface IEnvironmentService { readonly _serviceBrand: undefined; @@ -16,6 +17,14 @@ export interface IEnvironmentService { readonly homeDir: string; /** Resolved absolute path to `config.toml`. */ readonly configPath: string; + /** + * Host identity handed to the managed OAuth toolkit so device-flow and + * token-refresh requests carry `X-Msh-*` headers. Optional: v1 is a + * library layer and "no identity, no headers" is the oauth package + * contract; real hosts always set it. Rides on this bag because the DI + * registry has no options channel that reaches OAuthService & co. + */ + readonly identity?: KimiHostIdentity; } export const IEnvironmentService = createDecorator( diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index db70fdebf5..2be0f5c14a 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -44,7 +44,7 @@ export class ModelCatalogService @IEventService private readonly eventService: IEventService, ) { super(); - this._authFacade = createManagedAuthFacade(env); + this._authFacade = createManagedAuthFacade(env, env.identity); } static _createForTest( diff --git a/packages/agent-core/src/services/oauth/oauthService.ts b/packages/agent-core/src/services/oauth/oauthService.ts index 055556993f..c098005d1d 100644 --- a/packages/agent-core/src/services/oauth/oauthService.ts +++ b/packages/agent-core/src/services/oauth/oauthService.ts @@ -83,7 +83,7 @@ export class OAuthService extends Disposable implements IOAuthService { constructor(@IEnvironmentService private readonly env: IEnvironmentService) { super(); this._flows = this._register(new DisposableMap()); - this._authFacade = createManagedAuthFacade(env); + this._authFacade = createManagedAuthFacade(env, env.identity); } /** @internal Test-only factory that injects a mock facade. */ diff --git a/packages/agent-core/test/services/coreProcessService.test.ts b/packages/agent-core/test/services/coreProcessService.test.ts index 60dbd7e49c..86e637011e 100644 --- a/packages/agent-core/test/services/coreProcessService.test.ts +++ b/packages/agent-core/test/services/coreProcessService.test.ts @@ -1,8 +1,8 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor, @@ -131,6 +131,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllGlobals(); if (prevHome === undefined) { delete process.env['KIMI_HOME']; } else { @@ -273,6 +274,46 @@ describe('CoreProcessService direct construction', () => { expect(typeof tokenProvider?.getAccessToken).toBe('function'); }); + it('threads identity into the default resolver so refreshes carry X-Msh-Platform', async () => { + const credentialsDir = join(tmpHome, 'credentials'); + mkdirSync(credentialsDir, { recursive: true }); + writeFileSync( + join(credentialsDir, 'kimi-code.json'), + JSON.stringify({ + access_token: 'expired-access', + refresh_token: 'refresh-1', + expires_at: 1, + scope: '', + token_type: 'Bearer', + expires_in: 3600, + }), + ); + const refreshHeaders: Record[] = []; + vi.stubGlobal('fetch', async (_input: unknown, init?: RequestInit) => { + refreshHeaders.push((init?.headers ?? {}) as Record); + return new Response( + JSON.stringify({ + access_token: 'rotated-access', + refresh_token: 'rotated-refresh', + expires_in: 3600, + scope: '', + token_type: 'Bearer', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + + const resolver = CoreProcessService._defaultOAuthTokenResolver( + tmpHome, + join(tmpHome, 'config.toml'), + { productName: 'test', version: '0.0.0-test', platform: 'test_platform' }, + ); + const tokenProvider = resolver('managed:kimi-code'); + await expect(tokenProvider?.getAccessToken()).resolves.toBe('rotated-access'); + expect(refreshHeaders).toHaveLength(1); + expect(refreshHeaders[0]?.['X-Msh-Platform']).toBe('test_platform'); + }); + it('default-wires kimiRequestHeaders from identity when caller omits headers', () => { const headers = CoreProcessService._defaultKimiRequestHeaders( tmpHome, From 1bb734bcf61b8d7830cd8446bad8dba05ca01b90 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 01:54:32 +0800 Subject: [PATCH 04/15] feat(kap-server)!: require the host identity and derive request headers from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerStartOptions.hostIdentity is now a required ServerHostIdentity (KimiHostIdentity + optional prompt display fields), replacing both the old optional HostIdentityOverrides (renamed to PromptIdentityOverrides, its productName field now displayName) and the version option (renamed to serverVersion — it is the engine version reported as server_version, while the host product version travels in hostIdentity.version). The server now feeds bootstrap's clientIdentity from hostIdentity and derives the default outbound headers (User-Agent + X-Msh-*) from it via createKimiDefaultHeaders, so kap-server-hosted OAuth flows and model / WebSearch requests carry the real host identity instead of a hardcoded kimi-code-cli fallback UA. Explicit header seeds still win as an escape hatch. Session export manifests record the host product version: kimiCodeVersion now carries hostIdentity.version (the engine version no longer appears), and desktop exports (desktop: true) are additionally stamped with a desktopVersion field. The instance registry keeps its host_version wire field for compatibility (kimi-inspect reads it); only the in-memory name changed to serverVersion. --- apps/kimi-code/src/cli/sub/web/run.ts | 9 ++- .../src/app/hostIdentity/hostIdentity.ts | 12 ++-- packages/kap-server/src/index.ts | 2 +- packages/kap-server/src/instanceRegistry.ts | 10 +-- .../src/routes/registerApiV1Routes.ts | 8 ++- .../kap-server/src/routes/sessionExport.ts | 11 +++- packages/kap-server/src/start.ts | 62 ++++++++++--------- .../test/apiSurface.snapshot.test.ts | 2 + packages/kap-server/test/approvals.test.ts | 2 + packages/kap-server/test/auth.test.ts | 2 + .../kap-server/test/authMiddleware.test.ts | 11 ++-- .../kap-server/test/authWiring.e2e.test.ts | 3 +- packages/kap-server/test/boot.test.ts | 38 ++++++++---- packages/kap-server/test/config.test.ts | 2 + packages/kap-server/test/connections.test.ts | 3 +- .../test/debugNonloopback.e2e.test.ts | 4 ++ .../kap-server/test/disableAuth.e2e.test.ts | 2 + packages/kap-server/test/files.test.ts | 2 + packages/kap-server/test/fs-watch.e2e.test.ts | 2 + packages/kap-server/test/fs.test.ts | 2 + packages/kap-server/test/guiStore.test.ts | 3 +- .../kap-server/test/helpers/hostIdentity.ts | 12 ++++ .../kap-server/test/hostExposure.e2e.test.ts | 6 +- packages/kap-server/test/hostnames.test.ts | 2 + .../kap-server/test/instanceRegistry.test.ts | 13 ++-- packages/kap-server/test/messages.test.ts | 2 + packages/kap-server/test/modelCatalog.test.ts | 2 + .../test/modelCatalogCatalog.test.ts | 2 + .../test/modelCatalogProviderWrite.test.ts | 2 + packages/kap-server/test/oauthUsage.test.ts | 2 + packages/kap-server/test/openapi.test.ts | 2 + packages/kap-server/test/prompts.test.ts | 3 +- packages/kap-server/test/questions.test.ts | 2 + .../kap-server/test/requestLogging.test.ts | 3 +- packages/kap-server/test/rpc.test.ts | 5 +- .../test/search/searchRoute.test.ts | 2 + .../kap-server/test/securityExposure.test.ts | 14 +++-- packages/kap-server/test/sessions.test.ts | 13 ++++ packages/kap-server/test/skills.test.ts | 4 +- packages/kap-server/test/snapshot.test.ts | 9 +-- packages/kap-server/test/tasks.test.ts | 2 + packages/kap-server/test/terminals.test.ts | 2 + packages/kap-server/test/tools.test.ts | 2 + packages/kap-server/test/transcript.test.ts | 2 + packages/kap-server/test/workspaceFs.test.ts | 4 ++ packages/kap-server/test/workspaces.test.ts | 2 + .../kap-server/test/wsBearerProtocol.test.ts | 3 +- packages/kap-server/test/wsHostOrigin.test.ts | 3 + .../kap-server/test/wsUpgradeAuth.test.ts | 2 + packages/kap-server/test/wsV1Resync.test.ts | 3 +- 50 files changed, 232 insertions(+), 85 deletions(-) create mode 100644 packages/kap-server/test/helpers/hostIdentity.ts diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index 9318b85395..feada90bf4 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -278,7 +278,14 @@ async function runServerInProcess( port: options.port, // Report the CLI's product version as `server_version` (/meta, web UI) // rather than kap-server's private package version. - version, + serverVersion: version, + // Temporary CLI identity wiring: the full host-identity hookup lands in + // step 5 of the host-identity unification. + hostIdentity: { + productName: 'kimi-code-cli', + version, + platform: 'kimi_code_cli', + }, logLevel: options.logLevel, logger, debugEndpoints: options.debugEndpoints, diff --git a/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts b/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts index 8720222a72..ea9c3c3709 100644 --- a/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts +++ b/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts @@ -2,7 +2,7 @@ * `hostIdentity` domain (L3) — runtime identity of the embedding host. * * Holds process-level overrides the host product (CLI, desktop, …) injects at - * the composition root: `productName` fills the `${product_name}` slot in the + * the composition root: `displayName` fills the `${product_name}` slot in the * base system-prompt template, `replyStyleGuide` replaces the * `${reply_style_guide}` block (the CLI default describes Markdown rendering * in a terminal). Composition roots set them through {@link hostIdentitySeed}; @@ -13,8 +13,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { LifecycleScope, registerScopedService, ScopeActivation, type ScopeSeed } from '#/_base/di/scope'; -export interface HostIdentityOverrides { - readonly productName?: string; +export interface PromptIdentityOverrides { + readonly displayName?: string; readonly replyStyleGuide?: string; } @@ -36,13 +36,13 @@ export class HostIdentity implements IHostIdentity { ) {} } -export function hostIdentitySeed(overrides: HostIdentityOverrides | undefined): ScopeSeed { +export function hostIdentitySeed(overrides: PromptIdentityOverrides | undefined): ScopeSeed { if (overrides === undefined) return []; - if (overrides.productName === undefined && overrides.replyStyleGuide === undefined) return []; + if (overrides.displayName === undefined && overrides.replyStyleGuide === undefined) return []; return [ [ IHostIdentity as ServiceIdentifier, - new HostIdentity(overrides.productName, overrides.replyStyleGuide), + new HostIdentity(overrides.displayName, overrides.replyStyleGuide), ], ]; } diff --git a/packages/kap-server/src/index.ts b/packages/kap-server/src/index.ts index 999cb62a56..c8c7aa3655 100644 --- a/packages/kap-server/src/index.ts +++ b/packages/kap-server/src/index.ts @@ -4,7 +4,7 @@ */ export { startServer } from './start'; -export type { ServerStartOptions, RunningServer } from './start'; +export type { ServerHostIdentity, ServerStartOptions, RunningServer } from './start'; export { okEnvelope, errEnvelope } from './envelope'; export type { Envelope } from './envelope'; export { classify } from './security/bindClassify'; diff --git a/packages/kap-server/src/instanceRegistry.ts b/packages/kap-server/src/instanceRegistry.ts index 96c9305faa..a441f95b3c 100644 --- a/packages/kap-server/src/instanceRegistry.ts +++ b/packages/kap-server/src/instanceRegistry.ts @@ -35,7 +35,7 @@ export interface ServerInstanceInfo { readonly port: number; readonly startedAt: number; readonly heartbeatAt: number; - readonly hostVersion?: string; + readonly serverVersion?: string; } /** On-disk JSON shape. snake_case to match operator-facing logs and the legacy lock. */ @@ -46,6 +46,8 @@ interface ServerInstanceDisk { port: number; started_at: number; heartbeat_at: number; + // Wire name kept as `host_version`: external tooling (kimi-inspect's server + // discovery) parses these files independently. host_version?: string; } @@ -105,7 +107,7 @@ function encode(info: ServerInstanceInfo): string { port: info.port, started_at: info.startedAt, heartbeat_at: info.heartbeatAt, - ...(info.hostVersion !== undefined ? { host_version: info.hostVersion } : {}), + ...(info.serverVersion !== undefined ? { host_version: info.serverVersion } : {}), }; return JSON.stringify(disk); } @@ -128,7 +130,7 @@ function decode(raw: string): ServerInstanceInfo | undefined { port: parsed.port, startedAt: parsed.started_at, heartbeatAt: parsed.heartbeat_at, - ...(parsed.host_version !== undefined ? { hostVersion: parsed.host_version } : {}), + ...(parsed.host_version !== undefined ? { serverVersion: parsed.host_version } : {}), }; } return undefined; @@ -264,7 +266,7 @@ export function createInstanceRegistry(options: InstanceRegistryOptions = {}): I port: state.port, startedAt: info.startedAt, heartbeatAt: now(), - ...(info.hostVersion !== undefined ? { hostVersion: info.hostVersion } : {}), + ...(info.serverVersion !== undefined ? { serverVersion: info.serverVersion } : {}), }; await writeFileAtomic(filePath, encode(full)); } finally { diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 307578094c..36adaa3e44 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -10,6 +10,7 @@ */ import type { Scope } from '@moonshot-ai/agent-core-v2'; +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; import { ulid } from 'ulid'; import { okEnvelope } from '../envelope'; @@ -62,6 +63,11 @@ interface ApiV1RouteHost { export interface RegisterApiV1RoutesOptions { readonly serverVersion: string; + /** + * Host product identity from `startServer` — the session export route stamps + * its manifest from `hostIdentity.version`. + */ + readonly hostIdentity: KimiHostIdentity; readonly debugEndpoints?: boolean; readonly enableShutdown?: boolean; readonly enableTerminals?: boolean; @@ -115,7 +121,7 @@ export async function registerApiV1Routes( registerSessionExportRoute( apiV1 as unknown as Parameters[0], core, - { serverVersion: opts.serverVersion }, + { hostIdentity: opts.hostIdentity }, ); registerSkillsRoutes(apiV1 as unknown as Parameters[0], core); registerMessagesRoutes( diff --git a/packages/kap-server/src/routes/sessionExport.ts b/packages/kap-server/src/routes/sessionExport.ts index 2a13dec730..bc47540490 100644 --- a/packages/kap-server/src/routes/sessionExport.ts +++ b/packages/kap-server/src/routes/sessionExport.ts @@ -18,6 +18,7 @@ import { isError2, type Scope, } from '@moonshot-ai/agent-core-v2'; +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; @@ -48,7 +49,7 @@ interface SessionExportReply { export function registerSessionExportRoute( app: SessionExportRouteHost, core: Scope, - options: { readonly serverVersion: string }, + options: { readonly hostIdentity: KimiHostIdentity }, ): void { const log = core.accessor.get(ILogService); const route = defineRoute( @@ -120,9 +121,13 @@ export function registerSessionExportRoute( outputPath, includeGlobalLog: true, // Desktop hosts ask for their own app log via `desktop: true`; - // the file is read server-side (missing files are skipped). + // the file is read server-side (missing files are skipped) and the + // manifest is stamped with the host product version as + // `desktopVersion`. includeDesktopLog: req.body.desktop === true, - version: options.serverVersion, + version: options.hostIdentity.version, + desktopVersion: + req.body.desktop === true ? options.hostIdentity.version : undefined, }, { webLog: req.body.web_log, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index e281b4a797..8fa4d47597 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -19,10 +19,13 @@ import { resolveKimiHome, resolveLoggingConfig, skillCatalogRuntimeOptionsSeed, - type HostIdentityOverrides, type Scope, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; +import { + createKimiDefaultHeaders, + type KimiHostIdentity, +} from '@moonshot-ai/kimi-code-oauth'; import { createAsyncApiDocument } from './protocol/asyncapi'; import Fastify, { type FastifyInstance } from 'fastify'; @@ -85,6 +88,13 @@ import { createTokenStore } from './services/auth/tokenStore'; // evaluation time. import { drainGlobalSearchDisposals, IGlobalSearchService } from './search/searchService'; +export interface ServerHostIdentity extends KimiHostIdentity { + /** Fills the `${product_name}` slot in the base system prompt. Defaults render the CLI text. */ + readonly displayName?: string; + /** Replaces the `${reply_style_guide}` block in the base system prompt. */ + readonly replyStyleGuide?: string; +} + export interface ServerStartOptions { readonly host?: string; readonly port?: number; @@ -118,13 +128,14 @@ export interface ServerStartOptions { /** Extra scope seeds applied at bootstrap (e.g. a host-provided `ISessionModelResolver`). */ readonly seeds?: ScopeSeed; /** - * Host product identity injected into the base system prompt: `productName` - * fills the `${product_name}` slot, `replyStyleGuide` replaces the - * `${reply_style_guide}` block. Applied to every agent the server hosts — for - * embedding hosts (e.g. a desktop app), not per-session use. Defaults render - * the CLI text. + * Identity of the host product embedding the server: feeds the engine's + * `bootstrap()` client identity, the default outbound request headers + * (User-Agent + `X-Msh-*` via `createKimiDefaultHeaders`), and the session + * export manifest. Applied to every agent and request the server hosts — + * required, so every host states its own product name, version, and + * platform explicitly. */ - readonly hostIdentity?: HostIdentityOverrides; + readonly hostIdentity: ServerHostIdentity; /** * Explicit skill directories for this process (v1's SDK `skillDirs`): when * non-empty, default user / project skill discovery is skipped and these @@ -139,12 +150,12 @@ export interface ServerStartOptions { */ readonly webAssetsDir?: string; /** - * Host product version, reported as `server_version` (GET /api/v1/meta), in - * the OpenAPI document, session exports, the lock / instance registry, and - * the default User-Agent. Defaults to kap-server's own package version; - * embedding hosts (the CLI) should pass their own version. + * Engine version, reported as `server_version` (GET /api/v1/meta), in the + * OpenAPI document, and in the lock / instance registry. Defaults to + * kap-server's own package version; the host product version travels in + * `hostIdentity.version` instead. */ - readonly version?: string; + readonly serverVersion?: string; /** * Opt-in cloud telemetry for the engine's `ITelemetryService` events: when * true, a `CloudAppender` is attached at startup (still gated by the config @@ -168,7 +179,7 @@ export interface RunningServer { const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 58627; -export async function startServer(opts: ServerStartOptions = {}): Promise { +export async function startServer(opts: ServerStartOptions): Promise { const host = opts.host ?? DEFAULT_HOST; const port = opts.port ?? DEFAULT_PORT; const homeDir = resolveKimiHome(opts.homeDir); @@ -178,7 +189,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { const { default: swagger } = await import('@fastify/swagger'); await app.register(swagger, { @@ -441,6 +444,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-api-surface-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/approvals.test.ts b/packages/kap-server/test/approvals.test.ts index ccc2039ca6..8622c9bb95 100644 --- a/packages/kap-server/test/approvals.test.ts +++ b/packages/kap-server/test/approvals.test.ts @@ -6,6 +6,7 @@ import { ISessionApprovalService, ISessionLifecycleService } from '@moonshot-ai/ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -45,6 +46,7 @@ describe('server-v2 /api/v1/sessions/{sid}/approvals', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-approvals-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/auth.test.ts b/packages/kap-server/test/auth.test.ts index 86d0b8c7da..47ab094fe2 100644 --- a/packages/kap-server/test/auth.test.ts +++ b/packages/kap-server/test/auth.test.ts @@ -6,6 +6,7 @@ import { authSummarySchema, type AuthSummary } from '@moonshot-ai/agent-core-v2/ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authedFetch } from './helpers/auth'; interface Envelope { @@ -40,6 +41,7 @@ describe('server-v2 GET /api/v1/auth', () => { await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); } server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/authMiddleware.test.ts b/packages/kap-server/test/authMiddleware.test.ts index 3afaea7fce..d5d4f4081d 100644 --- a/packages/kap-server/test/authMiddleware.test.ts +++ b/packages/kap-server/test/authMiddleware.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; describe('server-v2 /api/v1 bearer auth', () => { let server: RunningServer | undefined; @@ -26,13 +27,13 @@ describe('server-v2 /api/v1 bearer auth', () => { }); it('allows healthz without a token', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const res = await server.app.inject({ method: 'GET', url: '/api/v1/healthz' }); expect(res.statusCode).toBe(200); }); it('rejects /api/v1/auth without a token with 40101', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const res = await server.app.inject({ method: 'GET', url: '/api/v1/auth' }); expect(res.statusCode).toBe(401); const body = res.json() as Record; @@ -40,7 +41,7 @@ describe('server-v2 /api/v1 bearer auth', () => { }); it('rejects /api/v1/auth with a wrong token', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const res = await server.app.inject({ method: 'GET', url: '/api/v1/auth', @@ -52,7 +53,7 @@ describe('server-v2 /api/v1 bearer auth', () => { }); it('accepts /api/v1/auth with the persistent token', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const token = server.authTokenService.getToken(); const res = await server.app.inject({ method: 'GET', @@ -65,7 +66,7 @@ describe('server-v2 /api/v1 bearer auth', () => { }); it('requires auth for /openapi.json', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const res = await server.app.inject({ method: 'GET', url: '/openapi.json' }); expect(res.statusCode).toBe(401); }); diff --git a/packages/kap-server/test/authWiring.e2e.test.ts b/packages/kap-server/test/authWiring.e2e.test.ts index cd20f3be33..d0caaee550 100644 --- a/packages/kap-server/test/authWiring.e2e.test.ts +++ b/packages/kap-server/test/authWiring.e2e.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket, type RawData } from 'ws'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; function rawToString(data: RawData): string { if (typeof data === 'string') return data; @@ -69,7 +70,7 @@ describe('production auth wiring', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-auth-wiring-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; }); diff --git a/packages/kap-server/test/boot.test.ts b/packages/kap-server/test/boot.test.ts index 234ffd6da2..325d3627a6 100644 --- a/packages/kap-server/test/boot.test.ts +++ b/packages/kap-server/test/boot.test.ts @@ -26,7 +26,7 @@ import { import { listLiveServerInstances } from '../src/instanceRegistry'; import { listenWithPortRetry, type RunningServer, startServer } from '../src/start'; -import { getServerVersion } from '../src/version'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authedFetch } from './helpers/auth'; describe('server-v2 boot', () => { @@ -47,6 +47,7 @@ describe('server-v2 boot', () => { it('boots agent-core-v2 and serves the basic /api/v1 routes', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -96,14 +97,19 @@ describe('server-v2 boot', () => { expect(oauthBody.data).toBeNull(); }); - it('reports opts.version as server_version instead of the package version', async () => { + it('reports opts.serverVersion as server_version instead of the package version', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-version-')); server = await startServer({ + hostIdentity: { + productName: 'test-host', + version: '9.9.9-host', + platform: 'test_platform', + }, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', - version: '9.9.9-host', + serverVersion: '9.9.9-host', }); const base = `http://127.0.0.1:${server.port}`; @@ -114,37 +120,42 @@ describe('server-v2 boot', () => { }; expect(metaBody.data.server_version).toBe('9.9.9-host'); - // The host version is also what the instance registry advertises to + // The engine version is also what the instance registry advertises to // status/ps clients. const [instance] = await listLiveServerInstances(home); - expect(instance?.hostVersion).toBe('9.9.9-host'); + expect(instance?.serverVersion).toBe('9.9.9-host'); - // ... and it backs the default product User-Agent. + // ... while the default product User-Agent and the engine's client + // identity come from the host identity. const defaults = server.core.accessor.get(IHostRequestHeaders); - expect(defaults.headers['User-Agent']).toBe('kimi-code-cli/9.9.9-host'); + expect(defaults.headers['User-Agent']).toBe('test-host/9.9.9-host'); expect(server.core.accessor.get(IBootstrapService).clientIdentity).toEqual({ - productName: 'kimi-code-cli', + productName: 'test-host', version: '9.9.9-host', - platform: 'kimi_code_cli', + platform: 'test_platform', }); }); - it('seeds a default product User-Agent that opts.seeds can override', async () => { + it('seeds default Kimi identity headers from hostIdentity that opts.seeds can override', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ua-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', }); const defaults = server.core.accessor.get(IHostRequestHeaders); - expect(defaults.headers['User-Agent']).toBe(`kimi-code-cli/${getServerVersion()}`); + expect(defaults.headers['User-Agent']).toBe('test-host/0.0.0-test'); + expect(defaults.headers['X-Msh-Version']).toBe('0.0.0-test'); + expect(defaults.headers['X-Msh-Platform']).toBe('test_platform'); // Restart on the same homeDir with a host-provided seed; it must win over // the default (the CLI passes full Kimi identity headers this way). await server.close(); server = undefined; server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -158,6 +169,7 @@ describe('server-v2 boot', () => { it('seeds explicit skill dirs into the core scope when skillDirs is provided', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-skills-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -172,6 +184,7 @@ describe('server-v2 boot', () => { await server.close(); server = undefined; server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -186,6 +199,7 @@ describe('server-v2 boot', () => { const shutdown = vi.fn(async () => {}); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -215,6 +229,7 @@ describe('server-v2 boot', () => { } as unknown as IOAuthToolkit; server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -386,6 +401,7 @@ describe('server-v2 boot — port retry', () => { const occupant = await listenOnPort('127.0.0.1', port); try { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port, homeDir: home, diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index 42d8e89086..54b601ad53 100644 --- a/packages/kap-server/test/config.test.ts +++ b/packages/kap-server/test/config.test.ts @@ -6,6 +6,7 @@ import { configResponseSchema, type ConfigResponse } from '../src/protocol/rest- import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authedFetch } from './helpers/auth'; interface Envelope { @@ -40,6 +41,7 @@ describe('server-v2 /api/v1/config', () => { await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); } server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/connections.test.ts b/packages/kap-server/test/connections.test.ts index 76a2d76c28..5dd3ef80d6 100644 --- a/packages/kap-server/test/connections.test.ts +++ b/packages/kap-server/test/connections.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -32,7 +33,7 @@ describe('server-v2 GET /api/v1/connections', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-connections-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; }); diff --git a/packages/kap-server/test/debugNonloopback.e2e.test.ts b/packages/kap-server/test/debugNonloopback.e2e.test.ts index 24aea43be1..b913f2b838 100644 --- a/packages/kap-server/test/debugNonloopback.e2e.test.ts +++ b/packages/kap-server/test/debugNonloopback.e2e.test.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; let prevPassword: string | undefined; const createdDirs: string[] = []; @@ -62,6 +63,7 @@ describe('debug endpoints are not exposed on a non-loopback bind', () => { process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; const home = await tmpHome(); const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, @@ -77,6 +79,7 @@ describe('debug endpoints are not exposed on a non-loopback bind', () => { it('is not mounted on loopback by default (without the option)', async () => { const home = await tmpHome(); const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -89,6 +92,7 @@ describe('debug endpoints are not exposed on a non-loopback bind', () => { it('mounts the whitelist-free RPC surface on loopback when requested', async () => { const home = await tmpHome(); const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/disableAuth.e2e.test.ts b/packages/kap-server/test/disableAuth.e2e.test.ts index ad21b325d3..f9b669f29b 100644 --- a/packages/kap-server/test/disableAuth.e2e.test.ts +++ b/packages/kap-server/test/disableAuth.e2e.test.ts @@ -16,6 +16,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { WebSocket, type RawData } from 'ws'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { fixedTokenAuth } from './helpers/fixedAuth'; const TOKEN = 'test-token'; @@ -68,6 +69,7 @@ describe('server-v2 disableAuth (--dangerous-bypass-auth)', () => { async function boot(disableAuth?: boolean): Promise<{ base: string; port: number }> { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-disable-auth-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/files.test.ts b/packages/kap-server/test/files.test.ts index 4a94dacdf0..f83999a31c 100644 --- a/packages/kap-server/test/files.test.ts +++ b/packages/kap-server/test/files.test.ts @@ -15,6 +15,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; let home: string; let server: RunningServer | undefined; @@ -35,6 +36,7 @@ afterEach(async () => { async function boot(): Promise { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/fs-watch.e2e.test.ts b/packages/kap-server/test/fs-watch.e2e.test.ts index e674e625e3..0e693a59a5 100644 --- a/packages/kap-server/test/fs-watch.e2e.test.ts +++ b/packages/kap-server/test/fs-watch.e2e.test.ts @@ -24,6 +24,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WebSocket, type RawData } from 'ws'; import { startServer, type RunningServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; let tmpDir: string; let bridgeHome: string; @@ -53,6 +54,7 @@ afterEach(async () => { async function boot(): Promise { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: bridgeHome, diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index bf1a057cb3..6cc18ecd1f 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -7,6 +7,7 @@ import { ErrorCode } from '../src/protocol/error-codes'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -63,6 +64,7 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { }, }; server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/guiStore.test.ts b/packages/kap-server/test/guiStore.test.ts index 1df8c2156b..97f683e068 100644 --- a/packages/kap-server/test/guiStore.test.ts +++ b/packages/kap-server/test/guiStore.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; interface InjectResponse { statusCode: number; @@ -60,7 +61,7 @@ describe('server-v2 gui store routes', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-gui-store-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); }); afterEach(async () => { diff --git a/packages/kap-server/test/helpers/hostIdentity.ts b/packages/kap-server/test/helpers/hostIdentity.ts new file mode 100644 index 0000000000..3f3de72bed --- /dev/null +++ b/packages/kap-server/test/helpers/hostIdentity.ts @@ -0,0 +1,12 @@ +import type { ServerHostIdentity } from '../../src/start'; + +/** + * Neutral fixture identity for kap-server tests — stands in for the embedding + * host's product identity, which `startServer` requires. Tests that care + * about a specific version or platform build their own literal instead. + */ +export const TEST_HOST_IDENTITY: ServerHostIdentity = { + productName: 'test-host', + version: '0.0.0-test', + platform: 'test_platform', +}; diff --git a/packages/kap-server/test/hostExposure.e2e.test.ts b/packages/kap-server/test/hostExposure.e2e.test.ts index cccdde2928..23419563de 100644 --- a/packages/kap-server/test/hostExposure.e2e.test.ts +++ b/packages/kap-server/test/hostExposure.e2e.test.ts @@ -19,6 +19,7 @@ import { pino, type Logger } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; const createdDirs: string[] = []; const running: RunningServer[] = []; @@ -67,7 +68,7 @@ describe('public-bind gate', () => { it('refuses to bind 0.0.0.0 without --insecure-no-tls', async () => { const home = await tmpHome(); await expect( - startServer({ host: '0.0.0.0', port: 0, homeDir: home, logLevel: 'silent' }), + startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, logLevel: 'silent' }), ).rejects.toThrow(/without TLS/); }); @@ -76,6 +77,7 @@ describe('public-bind gate', () => { const home = await tmpHome(); const { logger, lines } = capturingLogger(); const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, @@ -98,6 +100,7 @@ describe('real password path (verifyPassword)', () => { process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; const home = await tmpHome(); const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, @@ -146,6 +149,7 @@ describe('auth-failure rate limit on a real bind', () => { // Inject a fast token-only auth service: this test exercises the rate // limiter, not bcrypt — 12 sequential cost-12 compares would take seconds. const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, diff --git a/packages/kap-server/test/hostnames.test.ts b/packages/kap-server/test/hostnames.test.ts index d4a7a79879..6539cadbca 100644 --- a/packages/kap-server/test/hostnames.test.ts +++ b/packages/kap-server/test/hostnames.test.ts @@ -14,6 +14,7 @@ import { stripPort, } from '../src/middleware/hostnames'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; describe('stripPort', () => { it('strips the port from a hostname', () => { @@ -187,6 +188,7 @@ describe('startServer allowedHosts — env + option merge', () => { process.env[ENV_KEY] = 'env-only.example.com'; home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-host-merge-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/instanceRegistry.test.ts b/packages/kap-server/test/instanceRegistry.test.ts index 4bd79f9873..0ac00fe2c5 100644 --- a/packages/kap-server/test/instanceRegistry.test.ts +++ b/packages/kap-server/test/instanceRegistry.test.ts @@ -20,6 +20,7 @@ import { type ServerInstanceInfo, } from '../src/instanceRegistry'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; let tmpDir: string; let instancesDir: string; @@ -99,7 +100,7 @@ describe('createInstanceRegistry — register / release', () => { it('records host_version when provided', async () => { const registry = createInstanceRegistry({ instancesDir, now: () => 1 }); - const reg = await registry.register({ ...baseInfo, hostVersion: '1.2.3' }); + const reg = await registry.register({ ...baseInfo, serverVersion: '1.2.3' }); expect(readInstance(reg.serverId).host_version).toBe('1.2.3'); await reg.release(); }); @@ -278,9 +279,9 @@ describe('startServer — instance registry wiring', () => { it('lets two servers share one homeDir, each registering a distinct instance and port', async () => { home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); - const a = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + const a = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); servers.push(a); - const b = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + const b = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); servers.push(b); // Each instance binds its own (ephemeral) port and registers it. @@ -298,9 +299,9 @@ describe('startServer — instance registry wiring', () => { it('removes its instance file on close so peers no longer list it', async () => { home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); - const a = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + const a = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); servers.push(a); - const b = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + const b = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); servers.push(b); expect(await listLiveServerInstances(home)).toHaveLength(2); @@ -315,6 +316,7 @@ describe('startServer — instance registry wiring', () => { it('releases its registration on close so a fresh instance on the same home can start', async () => { home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); const first = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -323,6 +325,7 @@ describe('startServer — instance registry wiring', () => { await first.close(); const restarted = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/messages.test.ts b/packages/kap-server/test/messages.test.ts index b6dff4ef97..0e7102e96e 100644 --- a/packages/kap-server/test/messages.test.ts +++ b/packages/kap-server/test/messages.test.ts @@ -14,6 +14,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -80,6 +81,7 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { async function boot(): Promise { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home as string, diff --git a/packages/kap-server/test/modelCatalog.test.ts b/packages/kap-server/test/modelCatalog.test.ts index c617872ad4..cb423250ae 100644 --- a/packages/kap-server/test/modelCatalog.test.ts +++ b/packages/kap-server/test/modelCatalog.test.ts @@ -16,6 +16,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -88,6 +89,7 @@ describe('server-v2 /api/v1 model/provider catalog', () => { await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); } server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/modelCatalogCatalog.test.ts b/packages/kap-server/test/modelCatalogCatalog.test.ts index dd3acf13d1..c5d34645d6 100644 --- a/packages/kap-server/test/modelCatalogCatalog.test.ts +++ b/packages/kap-server/test/modelCatalogCatalog.test.ts @@ -10,6 +10,7 @@ import { setModelsDevUpstreamForTest, } from '@moonshot-ai/agent-core-v2/app/kosongConfig/modelsDevUpstream'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -155,6 +156,7 @@ describe('server-v2 /api/v1 catalog browse + import endpoints', () => { await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); } server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/modelCatalogProviderWrite.test.ts b/packages/kap-server/test/modelCatalogProviderWrite.test.ts index 9e84d56fc0..3ec70ae6eb 100644 --- a/packages/kap-server/test/modelCatalogProviderWrite.test.ts +++ b/packages/kap-server/test/modelCatalogProviderWrite.test.ts @@ -6,6 +6,7 @@ import { parse as parseToml } from 'smol-toml'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -131,6 +132,7 @@ describe('server-v2 /api/v1 provider write endpoints', () => { await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); } server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/oauthUsage.test.ts b/packages/kap-server/test/oauthUsage.test.ts index 8db52b5165..0a42bf3f65 100644 --- a/packages/kap-server/test/oauthUsage.test.ts +++ b/packages/kap-server/test/oauthUsage.test.ts @@ -14,6 +14,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -66,6 +67,7 @@ describe('server-v2 GET /api/v1/oauth/usage', () => { async function boot(seeds: ScopeSeed): Promise { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/openapi.test.ts b/packages/kap-server/test/openapi.test.ts index 19bb2827a2..f1d1807aa9 100644 --- a/packages/kap-server/test/openapi.test.ts +++ b/packages/kap-server/test/openapi.test.ts @@ -14,6 +14,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; describe('server-v2 OpenAPI', () => { @@ -34,6 +35,7 @@ describe('server-v2 OpenAPI', () => { async function fetchOpenApi(): Promise> { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-openapi-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 0db4a13480..06cae83d48 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -13,6 +13,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -134,7 +135,7 @@ describe('server-v2 /api/v1 prompts', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-prompts-')); await writeFile(join(home, 'config.toml'), PROMPT_TOML, 'utf-8'); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; }); diff --git a/packages/kap-server/test/questions.test.ts b/packages/kap-server/test/questions.test.ts index c9d9bf71b1..3c28e79cd4 100644 --- a/packages/kap-server/test/questions.test.ts +++ b/packages/kap-server/test/questions.test.ts @@ -11,6 +11,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -70,6 +71,7 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-questions-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/requestLogging.test.ts b/packages/kap-server/test/requestLogging.test.ts index 123c01c243..1be8b63c2c 100644 --- a/packages/kap-server/test/requestLogging.test.ts +++ b/packages/kap-server/test/requestLogging.test.ts @@ -8,6 +8,7 @@ import { afterEach, assert, describe, expect, it } from 'vitest'; import { extractEnvelopeCode } from '../src/requestLogging'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; function captureLogger(): { logger: Logger; lines: string[] } { const lines: string[] = []; @@ -50,7 +51,7 @@ describe('requestLogging', () => { it('logs the envelope code instead of the HTTP status code', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-request-log-')); const { logger, lines } = captureLogger(); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logger }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logger }); const res = await fetch(`http://127.0.0.1:${String(server.port)}/api/v1/healthz`); expect(res.status).toBe(200); diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index 65c7f2d02f..aa6a2f8a31 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -20,6 +20,7 @@ import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -76,7 +77,7 @@ describe('server-v2 /api/v1/debug RPC', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', debugEndpoints: true }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', debugEndpoints: true }); base = `http://127.0.0.1:${server.port}`; }); @@ -623,6 +624,7 @@ describe('server-v2 /api/v1/debug RPC auth', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-auth-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -690,6 +692,7 @@ describe('server-v2 /api/v1/debug RPC (dev-only, whitelist-free)', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-debug-rpc-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/search/searchRoute.test.ts b/packages/kap-server/test/search/searchRoute.test.ts index 9c35d50730..6af7361327 100644 --- a/packages/kap-server/test/search/searchRoute.test.ts +++ b/packages/kap-server/test/search/searchRoute.test.ts @@ -6,6 +6,7 @@ import { ISessionIndex, type SessionSummary } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../../src/start'; +import { TEST_HOST_IDENTITY } from '../helpers/hostIdentity'; import { authedFetch } from '../helpers/auth'; interface Envelope { @@ -102,6 +103,7 @@ describe('server-v2 /api/v1/search', () => { }, ]; server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/securityExposure.test.ts b/packages/kap-server/test/securityExposure.test.ts index 5b25995802..927bfa4782 100644 --- a/packages/kap-server/test/securityExposure.test.ts +++ b/packages/kap-server/test/securityExposure.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; describe('server-v2 exposure hardening hooks', () => { let server: RunningServer | undefined; @@ -26,7 +27,7 @@ describe('server-v2 exposure hardening hooks', () => { }); it('rejects a disallowed Host header with 40301', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const res = await server.app.inject({ method: 'GET', url: '/api/v1/healthz', @@ -38,13 +39,13 @@ describe('server-v2 exposure hardening hooks', () => { }); it('allows the default loopback Host header', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const res = await server.app.inject({ method: 'GET', url: '/api/v1/healthz' }); expect(res.statusCode).toBe(200); }); it('echoes CORS headers for a same-origin request', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const res = await server.app.inject({ method: 'GET', url: '/api/v1/healthz', @@ -56,12 +57,13 @@ describe('server-v2 exposure hardening hooks', () => { it('refuses to bind non-loopback hosts without TLS opt-out', async () => { await expect( - startServer({ host: '0.0.0.0', port: 0, homeDir: home, logLevel: 'silent' }), + startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, logLevel: 'silent' }), ).rejects.toThrow(/Refusing to bind 0\.0\.0\.0/); }); it('sets security headers on a non-loopback bind without HSTS', async () => { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, @@ -79,7 +81,7 @@ describe('server-v2 exposure hardening hooks', () => { }); it('does not set security headers on a loopback bind', async () => { - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); const res = await server.app.inject({ method: 'GET', url: '/api/v1/healthz' }); expect(res.statusCode).toBe(200); expect(res.headers['x-content-type-options']).toBeUndefined(); @@ -90,6 +92,7 @@ describe('server-v2 exposure hardening hooks', () => { it('does not register shutdown or terminal routes on non-loopback by default', async () => { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, @@ -114,6 +117,7 @@ describe('server-v2 exposure hardening hooks', () => { it('can explicitly re-enable terminal routes on non-loopback', async () => { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index b6a2db27d9..e7c5a1f7a2 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -30,6 +30,7 @@ import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/se import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -90,6 +91,7 @@ describe('server-v2 /api/v1/sessions', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-sessions-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -177,13 +179,19 @@ describe('server-v2 /api/v1/sessions', () => { const entries = readZipEntries(archive); const manifest = JSON.parse(entries.get('manifest.json')?.toString('utf8') ?? 'null') as { sessionId: string; + kimiCodeVersion: string; + desktopVersion?: string; webLogPath?: string; }; expect(entries.get('logs/kimi-web.jsonl')?.toString('utf8')).toBe(webLog); expect(manifest).toMatchObject({ sessionId: id, + kimiCodeVersion: TEST_HOST_IDENTITY.version, webLogPath: 'logs/kimi-web.jsonl', }); + // The engine version never enters the manifest, and a non-desktop export + // carries no `desktopVersion`. + expect(manifest.desktopVersion).toBeUndefined(); await expect.poll(() => listExportTempDirs(id)).toEqual([]); }); @@ -262,12 +270,16 @@ describe('server-v2 /api/v1/sessions', () => { expect(res.status).toBe(200); const entries = readZipEntries(archive); const manifest = JSON.parse(entries.get('manifest.json')?.toString('utf8') ?? 'null') as { + kimiCodeVersion: string; desktopLogPath?: string; + desktopVersion?: string; }; expect(entries.get('logs/kimi-desktop.log')?.toString('utf8')).toBe( '2026-07-27T00:00:00.000Z INFO [renderer] hello\n', ); expect(manifest.desktopLogPath).toBe('logs/kimi-desktop.log'); + expect(manifest.kimiCodeVersion).toBe(TEST_HOST_IDENTITY.version); + expect(manifest.desktopVersion).toBe(TEST_HOST_IDENTITY.version); }); async function createStoppedGoalRig(status: 'paused' | 'blocked') { @@ -1244,6 +1256,7 @@ describe('server-v2 /api/v1/sessions status context window', () => { 'utf-8', ); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index 6a0286297d..7e7d7e2276 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -37,6 +37,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -62,7 +63,7 @@ describe('server-v2 /api/v1 skills', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-skills-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; }); @@ -307,6 +308,7 @@ describe('server-v2 /api/v1 skills', () => { await server!.close(); server = undefined; server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/snapshot.test.ts b/packages/kap-server/test/snapshot.test.ts index ecca46f95c..9ed27f2dac 100644 --- a/packages/kap-server/test/snapshot.test.ts +++ b/packages/kap-server/test/snapshot.test.ts @@ -26,6 +26,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { registerSnapshotRoutes } from '../src/routes/snapshot'; import { SnapshotNotFoundError } from '../src/services/snapshot'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; function fakeAccessor(entries: ReadonlyArray) { @@ -254,7 +255,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-snapshot-test-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; }); @@ -350,7 +351,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { await server!.close(); server = undefined; - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; // Guard: nothing is live in the new process — the session is cold. @@ -395,7 +396,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { await server!.close(); server = undefined; - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; // Guard: still cold — the auto reader must serve from disk, not resume. @@ -439,7 +440,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { }), ); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; // Resume the cold session, then seed messages into the live context so the diff --git a/packages/kap-server/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index a9f0de167c..82fa8e283d 100644 --- a/packages/kap-server/test/tasks.test.ts +++ b/packages/kap-server/test/tasks.test.ts @@ -12,6 +12,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -74,6 +75,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { }, }; server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/terminals.test.ts b/packages/kap-server/test/terminals.test.ts index 916d87f629..fa81751a2f 100644 --- a/packages/kap-server/test/terminals.test.ts +++ b/packages/kap-server/test/terminals.test.ts @@ -15,6 +15,7 @@ import type { Terminal } from '@moonshot-ai/agent-core-v2/os/interface/terminal' import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; // --- Fake PTY service ------------------------------------------------------- @@ -120,6 +121,7 @@ describe('server-v2 /api/v1/sessions/{sid}/terminals', () => { ].join('\n'), ); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/tools.test.ts b/packages/kap-server/test/tools.test.ts index a1d4ebee1e..26526eae18 100644 --- a/packages/kap-server/test/tools.test.ts +++ b/packages/kap-server/test/tools.test.ts @@ -32,6 +32,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -82,6 +83,7 @@ describe('server-v2 /api/v1 tools + mcp', () => { }, }; server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts index 49dbcf8318..cbeecca312 100644 --- a/packages/kap-server/test/transcript.test.ts +++ b/packages/kap-server/test/transcript.test.ts @@ -28,6 +28,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -156,6 +157,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { async function boot(): Promise { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home as string, diff --git a/packages/kap-server/test/workspaceFs.test.ts b/packages/kap-server/test/workspaceFs.test.ts index a581ede98c..50cd2e3be1 100644 --- a/packages/kap-server/test/workspaceFs.test.ts +++ b/packages/kap-server/test/workspaceFs.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -44,6 +45,7 @@ describe('server-v2 /api/v1 fs folder picker', () => { // picker only sees the test fixtures. instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-instances-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -191,6 +193,7 @@ describe('server-v2 /api/v1 fs:mkdir', () => { dir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fsmkdir-')); instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fsmkdir-instances-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: dir, @@ -292,6 +295,7 @@ describe('server-v2 /api/v1 fs:content', () => { dir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fscontent-')); instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fscontent-instances-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: dir, diff --git a/packages/kap-server/test/workspaces.test.ts b/packages/kap-server/test/workspaces.test.ts index bbaf4eca68..2148053201 100644 --- a/packages/kap-server/test/workspaces.test.ts +++ b/packages/kap-server/test/workspaces.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -38,6 +39,7 @@ describe('server-v2 /api/v1/workspaces', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-workspaces-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/wsBearerProtocol.test.ts b/packages/kap-server/test/wsBearerProtocol.test.ts index 53bbe96e91..bfc810d39a 100644 --- a/packages/kap-server/test/wsBearerProtocol.test.ts +++ b/packages/kap-server/test/wsBearerProtocol.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { WS_BEARER_PROTOCOL_PREFIX } from '../src/transport/ws/bearerProtocol'; function openWs(url: string, protocols: string | string[]): Promise { @@ -24,7 +25,7 @@ describe('server-v2 WS bearer subprotocol', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ws-bearer-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; }); diff --git a/packages/kap-server/test/wsHostOrigin.test.ts b/packages/kap-server/test/wsHostOrigin.test.ts index c4154a7e5c..14edee32c9 100644 --- a/packages/kap-server/test/wsHostOrigin.test.ts +++ b/packages/kap-server/test/wsHostOrigin.test.ts @@ -16,6 +16,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { fixedTokenAuth } from './helpers/fixedAuth'; const TOKEN = 'test-token'; @@ -65,6 +66,7 @@ describe('WS upgrade Host/Origin checks', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ws-host-origin-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, @@ -113,6 +115,7 @@ describe('WS upgrade Host/Origin checks', () => { it('allows an explicitly allowed Origin', async () => { await server?.close(); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/wsUpgradeAuth.test.ts b/packages/kap-server/test/wsUpgradeAuth.test.ts index f85c16294b..44cec5a9f8 100644 --- a/packages/kap-server/test/wsUpgradeAuth.test.ts +++ b/packages/kap-server/test/wsUpgradeAuth.test.ts @@ -17,6 +17,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket, type RawData } from 'ws'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { fixedTokenAuth } from './helpers/fixedAuth'; const TOKEN = 'test-token'; @@ -82,6 +83,7 @@ describe('WS upgrade auth', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ws-upgrade-auth-')); server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, diff --git a/packages/kap-server/test/wsV1Resync.test.ts b/packages/kap-server/test/wsV1Resync.test.ts index ff01d33851..f2116d42eb 100644 --- a/packages/kap-server/test/wsV1Resync.test.ts +++ b/packages/kap-server/test/wsV1Resync.test.ts @@ -18,6 +18,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authHeaders } from './helpers/auth'; interface Frame { @@ -116,7 +117,7 @@ describe('server-v2 /api/v1/ws resync', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-wsv1-test-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; }); From 023fec54f481a76ae152a610d1860059e95699bc Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 01:57:49 +0800 Subject: [PATCH 05/15] feat(cli): wire the CLI host identity into the kimi web server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kimi web now passes createKimiCodeHostIdentity(version) as the server's hostIdentity, so web-UI OAuth flows and the engine's outbound requests carry the explicit CLI identity (productName + version + platform). The explicit hostRequestHeadersSeed is dropped — kap-server derives the same headers from hostIdentity — and buildKimiDefaultHeaders goes away with its only consumer. --- apps/kimi-code/src/cli/sub/web/run.ts | 18 +++++------------- apps/kimi-code/src/cli/version.ts | 10 +--------- apps/kimi-code/test/cli/version.test.ts | 7 ------- 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index feada90bf4..b71673d60e 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -11,7 +11,6 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2'; import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import chalk from 'chalk'; @@ -25,7 +24,7 @@ import { getDataDir } from '#/utils/paths'; import { initializeServerTelemetry } from '../../telemetry'; import { - buildKimiDefaultHeaders, + createKimiCodeHostIdentity, getHostPackageRoot, getVersion, } from '../../version'; @@ -279,13 +278,10 @@ async function runServerInProcess( // Report the CLI's product version as `server_version` (/meta, web UI) // rather than kap-server's private package version. serverVersion: version, - // Temporary CLI identity wiring: the full host-identity hookup lands in - // step 5 of the host-identity unification. - hostIdentity: { - productName: 'kimi-code-cli', - version, - platform: 'kimi_code_cli', - }, + // The CLI's host identity: feeds the engine's bootstrap client identity + // and the derived outbound headers (User-Agent + X-Msh-*), so web-UI + // OAuth flows and model / WebSearch requests carry the CLI identity. + hostIdentity: createKimiCodeHostIdentity(version), logLevel: options.logLevel, logger, debugEndpoints: options.debugEndpoints, @@ -298,10 +294,6 @@ async function runServerInProcess( // `telemetry` toggle). Complements the v1 client registered above, which // only covers host-level events. telemetry: true, - // Seed the CLI's Kimi identity headers so the engine's outbound - // requests (model, WebSearch, FetchURL) carry the same User-Agent + - // X-Msh-* identity as direct CLI runs. - seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)), webAssetsDir, }); logger.info('serving the REST/WS API and the bundled web UI'); diff --git a/apps/kimi-code/src/cli/version.ts b/apps/kimi-code/src/cli/version.ts index 5a283d269d..21e1213937 100644 --- a/apps/kimi-code/src/cli/version.ts +++ b/apps/kimi-code/src/cli/version.ts @@ -7,11 +7,10 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { createKimiDefaultHeaders, createKimiUserAgent, KIMI_CODE_PLATFORM, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; +import { createKimiUserAgent, KIMI_CODE_PLATFORM, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; import { CLI_USER_AGENT_PRODUCT } from '#/constant/app'; -import { getDataDir } from '../utils/paths'; import { KIMI_BUILD_INFO } from './build-info'; const MODULE_DIR = import.meta.dirname; @@ -63,10 +62,3 @@ export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIden export function createKimiCodeUserAgent(version = getVersion()): string { return createKimiUserAgent(createKimiCodeHostIdentity(version)); } - -export function buildKimiDefaultHeaders(version: string): Record { - return createKimiDefaultHeaders({ - homeDir: getDataDir(), - ...createKimiCodeHostIdentity(version), - }); -} diff --git a/apps/kimi-code/test/cli/version.test.ts b/apps/kimi-code/test/cli/version.test.ts index 073d5c727b..5451262205 100644 --- a/apps/kimi-code/test/cli/version.test.ts +++ b/apps/kimi-code/test/cli/version.test.ts @@ -4,7 +4,6 @@ import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { - buildKimiDefaultHeaders, createKimiCodeUserAgent, getHostPackageJsonPath, getHostPackageRoot, @@ -21,12 +20,6 @@ describe('cli version helpers', () => { expect(getVersion()).toBe(pkg.version); }); - it('builds default headers with the kimi-code-cli user-agent', () => { - const headers = buildKimiDefaultHeaders('1.2.3'); - - expect(headers['User-Agent']).toBe('kimi-code-cli/1.2.3'); - }); - it('builds the product user-agent for ad-hoc fetches', () => { expect(createKimiCodeUserAgent('1.2.3')).toBe('kimi-code-cli/1.2.3'); }); From 6ec294a8e17acd0f2d2d1e9f1eccf2cbeb0f5179 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 01:59:27 +0800 Subject: [PATCH 06/15] test(klient): drop clientVersion from the bootstrap contract parity list --- packages/klient/test/contract-parity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 461e0a3c7c..70a6ba3bc2 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -385,7 +385,8 @@ const _removePluginInput: AssertWire = true; // env.ts has no named schemas; `platform` narrows to `NodeJS.Platform` in the -// engine — assert the bootstrap properties are all strings instead. +// engine — assert the bootstrap properties are all strings instead. The +// object-typed `clientIdentity` is intentionally not in this list. type _bootstrapStringProps = AssertStringProps< Pick< IBootstrapService, @@ -395,7 +396,6 @@ type _bootstrapStringProps = AssertStringProps< | 'osHomeDir' | 'homeDir' | 'configPath' - | 'clientVersion' | 'sessionsDir' | 'blobsDir' | 'storeDir' From 5ee5c01b584b6db5a9b57e258a9a5505844ca204 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 02:02:23 +0800 Subject: [PATCH 07/15] chore: add changesets for the host identity unification --- .changeset/agent-core-auth-identity.md | 5 +++++ .changeset/cli-web-login-identity.md | 5 +++++ .changeset/kap-server-host-identity.md | 5 +++++ .changeset/oauth-host-identity-platform.md | 5 +++++ .changeset/sdk-host-identity-rename.md | 5 +++++ .changeset/v2-bootstrap-client-identity.md | 5 +++++ 6 files changed, 30 insertions(+) create mode 100644 .changeset/agent-core-auth-identity.md create mode 100644 .changeset/cli-web-login-identity.md create mode 100644 .changeset/kap-server-host-identity.md create mode 100644 .changeset/oauth-host-identity-platform.md create mode 100644 .changeset/sdk-host-identity-rename.md create mode 100644 .changeset/v2-bootstrap-client-identity.md diff --git a/.changeset/agent-core-auth-identity.md b/.changeset/agent-core-auth-identity.md new file mode 100644 index 0000000000..b162df6922 --- /dev/null +++ b/.changeset/agent-core-auth-identity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core": patch +--- + +Thread the host identity through the managed auth facades so OAuth token refreshes from inside the core carry the `X-Msh-*` device headers. diff --git a/.changeset/cli-web-login-identity.md b/.changeset/cli-web-login-identity.md new file mode 100644 index 0000000000..8faec1cd16 --- /dev/null +++ b/.changeset/cli-web-login-identity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Send the CLI's device identity headers with OAuth login and token refresh requests from kimi web, and record the host product version in session export manifests. diff --git a/.changeset/kap-server-host-identity.md b/.changeset/kap-server-host-identity.md new file mode 100644 index 0000000000..42460233a2 --- /dev/null +++ b/.changeset/kap-server-host-identity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kap-server": minor +--- + +Require a `hostIdentity` when starting the server and derive the default outbound identity headers (User-Agent + `X-Msh-*`) from it, replacing the hardcoded CLI fallback. The `version` option is renamed to `serverVersion`, and session export manifests now record the host product version — plus an optional desktop version for desktop exports — instead of the engine version. diff --git a/.changeset/oauth-host-identity-platform.md b/.changeset/oauth-host-identity-platform.md new file mode 100644 index 0000000000..20908a83b4 --- /dev/null +++ b/.changeset/oauth-host-identity-platform.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-oauth": minor +--- + +Rework the host identity type: rename `userAgentProduct` to `productName` and add a required `platform` field, so every host explicitly declares the `X-Msh-Platform` value it reports instead of silently inheriting the CLI's. diff --git a/.changeset/sdk-host-identity-rename.md b/.changeset/sdk-host-identity-rename.md new file mode 100644 index 0000000000..113ad3638c --- /dev/null +++ b/.changeset/sdk-host-identity-rename.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Rename `userAgentProduct` to `productName` and require `platform` in the host identity options exposed by the SDK. diff --git a/.changeset/v2-bootstrap-client-identity.md b/.changeset/v2-bootstrap-client-identity.md new file mode 100644 index 0000000000..eccf5c4e92 --- /dev/null +++ b/.changeset/v2-bootstrap-client-identity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": minor +--- + +Replace the bootstrap `clientVersion` with a required `clientIdentity` host identity object: the OAuth device-flow endpoints now send the full `X-Msh-*` device headers on every host, telemetry reads the client version from the same source, and the session export manifest gains an optional desktop version field. From f2a293c026042a91e2ef6c747714637a7e8e018e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 02:26:00 +0800 Subject: [PATCH 08/15] feat(cli): tag kimi web requests with a (web) User-Agent suffix kimi web shares the CLI product token and platform, so its outbound requests were indistinguishable from direct CLI runs upstream. Its host identity now carries userAgentSuffix 'web', putting web-UI traffic at kimi-code-cli/ (web) while X-Msh-Platform stays kimi_code_cli. --- .changeset/cli-web-login-identity.md | 2 +- apps/kimi-code/src/cli/sub/web/run.ts | 11 ++++++++--- apps/kimi-code/src/constant/app.ts | 4 ++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.changeset/cli-web-login-identity.md b/.changeset/cli-web-login-identity.md index 8faec1cd16..d76bc70ba6 100644 --- a/.changeset/cli-web-login-identity.md +++ b/.changeset/cli-web-login-identity.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Send the CLI's device identity headers with OAuth login and token refresh requests from kimi web, and record the host product version in session export manifests. +Send the CLI's device identity headers with OAuth login and token refresh requests from kimi web, tag web-UI requests with a `(web)` User-Agent suffix so upstream can tell them apart from direct CLI traffic, and record the host product version in session export manifests. diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index b71673d60e..26088c05fb 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -16,7 +16,7 @@ import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import chalk from 'chalk'; import { type Command } from 'commander'; -import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; +import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app'; import { getNativeWebAssetsDir } from '#/native/web-assets'; import { darkColors } from '#/tui/theme/colors'; import { openUrl as defaultOpenUrl } from '#/utils/open-url'; @@ -280,8 +280,13 @@ async function runServerInProcess( serverVersion: version, // The CLI's host identity: feeds the engine's bootstrap client identity // and the derived outbound headers (User-Agent + X-Msh-*), so web-UI - // OAuth flows and model / WebSearch requests carry the CLI identity. - hostIdentity: createKimiCodeHostIdentity(version), + // OAuth flows and model / WebSearch requests carry the CLI identity. The + // `web` User-Agent suffix distinguishes web-UI traffic from direct CLI + // runs upstream (same product token, same platform). + hostIdentity: { + ...createKimiCodeHostIdentity(version), + userAgentSuffix: WEB_USER_AGENT_SUFFIX, + }, logLevel: options.logLevel, logger, debugEndpoints: options.debugEndpoints, diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 59aa187974..b7cd749395 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -10,6 +10,10 @@ export const CLI_UI_MODE = 'shell'; // Telemetry ui_mode for the `kimi web` host. Same product // as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. export const WEB_UI_MODE = 'web'; +// User-Agent suffix for the `kimi web` host: its requests go out as +// `kimi-code-cli/ (web)` so upstream can tell web-UI traffic +// apart from direct CLI runs without changing the product token or platform. +export const WEB_USER_AGENT_SUFFIX = 'web'; // Give telemetry a short flush window without making CLI exit feel stuck. export const CLI_SHUTDOWN_TIMEOUT_MS = 3000; From 84af1318021a2d3ac66db8d4ab0e1b2731d2461a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 02:52:52 +0800 Subject: [PATCH 09/15] fix(klient): keep the env() clientVersion wire field after the bootstrap identity switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap snapshot replaced the flat clientVersion scalar with clientIdentity, which broke klient's env() fan-out (RPCError: method not found). The wire surface keeps clientVersion — now sourced from clientIdentity.version — and bootstrapService gains a clientIdentity read (registered in envContract with an object schema) for consumers that want the full identity. --- packages/klient/src/contract/global/env.ts | 14 ++++++++++++- packages/klient/src/core/facade/global.ts | 23 ++++++++++++---------- packages/klient/test/facade.test.ts | 11 +++++++++++ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/klient/src/contract/global/env.ts b/packages/klient/src/contract/global/env.ts index f784f391a6..28a62c07e8 100644 --- a/packages/klient/src/contract/global/env.ts +++ b/packages/klient/src/contract/global/env.ts @@ -1,7 +1,8 @@ /** * `bootstrapService` — frozen startup snapshot: host facts and app path * layout. Mirrors `agent-core-v2/app/bootstrap/bootstrap.ts`. The string - * properties are exposed as zero-arg reads. + * properties are exposed as zero-arg reads; `clientIdentity` returns the + * host identity object (which replaced the flat `clientVersion` scalar). */ import { z } from 'zod'; @@ -10,6 +11,16 @@ import type { ServiceContract } from '../types.js'; const stringRead = { input: z.tuple([]), output: z.string() }; +const clientIdentityRead = { + input: z.tuple([]), + output: z.object({ + productName: z.string(), + version: z.string(), + platform: z.string(), + userAgentSuffix: z.string().optional(), + }), +}; + export const envContract = { platform: stringRead, arch: stringRead, @@ -18,6 +29,7 @@ export const envContract = { homeDir: stringRead, configPath: stringRead, clientVersion: stringRead, + clientIdentity: clientIdentityRead, sessionsDir: stringRead, blobsDir: stringRead, storeDir: stringRead, diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index 72be8c00a5..bef77c30d4 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -228,14 +228,13 @@ export interface GlobalFacade { // tie every contract schema to its engine type. // --------------------------------------------------------------------------- -const ENV_PROPERTIES = [ +const ENV_SCALAR_PROPERTIES = [ 'platform', 'arch', 'cwd', 'osHomeDir', 'homeDir', 'configPath', - 'clientVersion', 'sessionsDir', 'blobsDir', 'storeDir', @@ -251,14 +250,18 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr // env() result can never change — resolve it once and reuse the promise. let envPromise: Promise | undefined; const env = (): Promise => { - envPromise ??= Promise.all( - ENV_PROPERTIES.map((prop) => call('bootstrapService', prop, []) as Promise), - ).then( - (values) => - Object.fromEntries( - ENV_PROPERTIES.map((prop, index) => [prop, values[index]]), - ) as unknown as KlientEnvInfo, - ); + envPromise ??= Promise.all([ + ...ENV_SCALAR_PROPERTIES.map((prop) => call('bootstrapService', prop, []) as Promise), + // The wire surface keeps `clientVersion` (a string); it is sourced from + // the bootstrap clientIdentity, which replaced the flat scalar. + call('bootstrapService', 'clientIdentity', []) as Promise<{ version: string }>, + ]).then((values) => { + const scalars = Object.fromEntries( + ENV_SCALAR_PROPERTIES.map((prop, index) => [prop, values[index]]), + ); + const identity = values[values.length - 1] as { version: string }; + return { ...scalars, clientVersion: identity.version } as unknown as KlientEnvInfo; + }); return envPromise; }; diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 3eedd5d614..c7cb911a8a 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -96,9 +96,15 @@ describe('facade routing', () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); channel.result = 'v'; + channel.results.set('bootstrapService.clientIdentity', { + productName: 'v', + version: 'v', + platform: 'v', + }); const env = await klient.global.env(); expect(env.platform).toBe('v'); expect(env.logsDir).toBe('v'); + expect(env.clientVersion).toBe('v'); expect(channel.calls).toHaveLength(12); expect(channel.calls.every((call) => call.service === 'bootstrapService')).toBe(true); }); @@ -107,6 +113,11 @@ describe('facade routing', () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); channel.result = 'v'; + channel.results.set('bootstrapService.clientIdentity', { + productName: 'v', + version: 'v', + platform: 'v', + }); await klient.global.env(); expect(channel.calls).toHaveLength(12); From bad0bab0be6b1e7aaa6c6ba538fc8d35d5fccb0f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 03:04:50 +0800 Subject: [PATCH 10/15] feat(oauth): send the product User-Agent on OAuth requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth endpoints used to receive only the X-Msh-* device headers (undici's default UA otherwise), which left the OAuth host unable to distinguish runtime surfaces — notably kimi web, whose platform matches the CLI and whose only distinguishing mark is the (web) UA suffix. The toolkit now feeds the full identity headers (User-Agent + X-Msh-*) into every device authorization, token polling, and refresh request; the request-header type widens from DeviceHeaders to OAuthRequestHeaders. --- .changeset/oauth-host-identity-platform.md | 2 +- packages/oauth/src/oauth-manager.ts | 8 ++++---- packages/oauth/src/oauth.ts | 12 ++++++------ packages/oauth/src/toolkit.ts | 9 +++++---- packages/oauth/src/types.ts | 6 ++++++ 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/.changeset/oauth-host-identity-platform.md b/.changeset/oauth-host-identity-platform.md index 20908a83b4..69ac014321 100644 --- a/.changeset/oauth-host-identity-platform.md +++ b/.changeset/oauth-host-identity-platform.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code-oauth": minor --- -Rework the host identity type: rename `userAgentProduct` to `productName` and add a required `platform` field, so every host explicitly declares the `X-Msh-Platform` value it reports instead of silently inheriting the CLI's. +Rework the host identity type: rename `userAgentProduct` to `productName` and add a required `platform` field, so every host explicitly declares the `X-Msh-Platform` value it reports instead of silently inheriting the CLI's. OAuth requests now also send the product User-Agent (with the optional runtime suffix), so the OAuth host can tell client families and surfaces apart. diff --git a/packages/oauth/src/oauth-manager.ts b/packages/oauth/src/oauth-manager.ts index 1f16b8a057..b9a389ff83 100644 --- a/packages/oauth/src/oauth-manager.ts +++ b/packages/oauth/src/oauth-manager.ts @@ -22,7 +22,7 @@ import { pollDeviceToken, refreshAccessToken, requestDeviceAuthorization } from import type { DevicePollResult, RefreshOptions } from './oauth'; import type { TokenStorage } from './storage'; import { classifyToken, revokedTombstone, type TokenState } from './token-state'; -import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, TokenInfo } from './types'; +import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, OAuthRequestHeaders, TokenInfo } from './types'; const MIN_REFRESH_THRESHOLD_SECONDS = 300; const REFRESH_THRESHOLD_RATIO = 0.5; @@ -68,7 +68,7 @@ export interface OAuthManagerOptions { readonly pollDeviceImpl?: | ((config: OAuthFlowConfig, deviceCode: string) => Promise) | undefined; - readonly deviceHeaders?: (() => DeviceHeaders | undefined) | undefined; + readonly deviceHeaders?: (() => OAuthRequestHeaders | undefined) | undefined; /** * Root directory for per-provider lock files; resolves to * `{configDir}/oauth/{providerName}.lock`. @@ -104,7 +104,7 @@ export class OAuthManager { private readonly refreshImpl: NonNullable; private readonly requestImpl: NonNullable; private readonly pollImpl: NonNullable; - private readonly deviceHeaders: (() => DeviceHeaders | undefined) | undefined; + private readonly deviceHeaders: (() => OAuthRequestHeaders | undefined) | undefined; private readonly configDir: string | undefined; private readonly onRefresh: ((outcome: OAuthRefreshOutcome) => void) | undefined; @@ -155,7 +155,7 @@ export class OAuthManager { this.configDir = options.configDir ?? envConfigDir; } - private resolveDeviceHeaders(): DeviceHeaders | undefined { + private resolveDeviceHeaders(): OAuthRequestHeaders | undefined { return this.deviceHeaders?.(); } diff --git a/packages/oauth/src/oauth.ts b/packages/oauth/src/oauth.ts index f55d22c2e3..df14d3c820 100644 --- a/packages/oauth/src/oauth.ts +++ b/packages/oauth/src/oauth.ts @@ -17,7 +17,7 @@ import { OAuthUnauthorizedError, RetryableRefreshError, } from './errors'; -import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, TokenInfo } from './types'; +import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, OAuthRequestHeaders, TokenInfo } from './types'; import { isRecord } from './utils'; const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]); @@ -59,7 +59,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 30_000; async function postForm( url: string, params: Record, - deviceHeaders?: DeviceHeaders | undefined, + deviceHeaders?: OAuthRequestHeaders | undefined, options?: { timeoutMs?: number; signal?: AbortSignal }, ): Promise<{ status: number; data: Record }> { const timeoutMs = options?.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS; @@ -118,7 +118,7 @@ function describeFetchFailure(error: unknown): string { export async function requestDeviceAuthorization( config: OAuthFlowConfig, - options: { readonly deviceHeaders?: DeviceHeaders | undefined }, + options: { readonly deviceHeaders?: OAuthRequestHeaders | undefined }, ): Promise { const url = `${config.oauthHost.replace(/\/$/, '')}/api/oauth/device_authorization`; const { status, data } = await postForm( @@ -168,7 +168,7 @@ export type DevicePollResult = export async function pollDeviceToken( config: OAuthFlowConfig, deviceCode: string, - options: { readonly deviceHeaders?: DeviceHeaders | undefined }, + options: { readonly deviceHeaders?: OAuthRequestHeaders | undefined }, ): Promise { const url = `${config.oauthHost.replace(/\/$/, '')}/api/oauth/token`; const { status, data } = await postForm( @@ -213,7 +213,7 @@ export async function pollDeviceToken( // ── refreshAccessToken ──────────────────────────────────────────────── export interface RefreshOptions { - readonly deviceHeaders?: DeviceHeaders | undefined; + readonly deviceHeaders?: OAuthRequestHeaders | undefined; readonly maxRetries?: number | undefined; /** * Backoff between retries in ms. Defaults to `2 ** attempt * 1000` (1s, 2s). @@ -289,4 +289,4 @@ export async function refreshAccessToken( throw lastError ?? new OAuthError('Token refresh failed after retries.'); } -export type { DeviceHeaders }; +export type { DeviceHeaders, OAuthRequestHeaders }; diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 766c66796f..e9809f4b49 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -6,7 +6,6 @@ import { OAuthUnauthorizedError } from './errors'; import { assertKimiHostIdentity, createKimiDefaultHeaders, - createKimiDeviceHeaders, type KimiHostIdentity, } from './identity'; import { @@ -393,10 +392,12 @@ export class KimiOAuthToolkit { identity === undefined ? undefined : () => - createKimiDeviceHeaders({ + // Full identity headers (User-Agent + X-Msh-*): the OAuth host + // reads the platform for the client family and the UA (suffix) + // for the runtime surface, e.g. kimi web's `(web)`. + createKimiDefaultHeaders({ homeDir: this.homeDir, - version: identity.version, - platform: identity.platform, + ...identity, }), ...this.managerOptions, }); diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index 9073f86142..66f597467a 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -54,6 +54,12 @@ export interface DeviceHeaders { readonly 'X-Msh-Device-Id': string; } +/** Headers sent with OAuth HTTP requests: the `X-Msh-*` device set, plus a + product User-Agent when the caller carries a host identity — the OAuth + host needs both to tell client families (platform) and runtime surfaces + (UA suffix) apart. */ +export type OAuthRequestHeaders = Record; + /** JSON wire format for token persistence (snake_case, Python-compatible). */ export interface TokenInfoWire { readonly access_token: string; From 0f5050520ef0c5f6826c104b3bcadb51c6a87aae Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 11:54:50 +0800 Subject: [PATCH 11/15] feat(vscode): report kimi_code_vscode as the extension's platform The VS Code extension inherited the CLI's hardcoded X-Msh-Platform value; with platform now an explicit identity field it declares its own, so the managed endpoints and OAuth host can tell extension traffic apart from CLI runs. --- apps/vscode/src/runtime/kimi-runtime.ts | 2 +- apps/vscode/test/replay-resume.integration.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/vscode/src/runtime/kimi-runtime.ts b/apps/vscode/src/runtime/kimi-runtime.ts index a2ddd68fe3..d07af86f82 100644 --- a/apps/vscode/src/runtime/kimi-runtime.ts +++ b/apps/vscode/src/runtime/kimi-runtime.ts @@ -62,7 +62,7 @@ export class KimiRuntime { identity: { productName: "kimi-code-vscode", version: options.version, - platform: "kimi_code_cli", + platform: "kimi_code_vscode", }, uiMode: "vscode", }); diff --git a/apps/vscode/test/replay-resume.integration.test.ts b/apps/vscode/test/replay-resume.integration.test.ts index 769511cc1b..ab0d146c54 100644 --- a/apps/vscode/test/replay-resume.integration.test.ts +++ b/apps/vscode/test/replay-resume.integration.test.ts @@ -46,7 +46,7 @@ async function createReplayRig(): Promise { const provider = await createFakeProviderHarness(); const harness = createKimiHarness({ homeDir, - identity: { productName: "kimi-code-vscode", version: "test", platform: "kimi_code_cli" }, + identity: { productName: "kimi-code-vscode", version: "test", platform: "kimi_code_vscode" }, }); await harness.setConfig({ providers: { From 4a727b46fff2a6d35dee552cd82f2b23ae5a0120 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 12:12:35 +0800 Subject: [PATCH 12/15] refactor(agent-core-v2)!: require the client identity at the composition root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap fallback identity fabricated a kimi-code-cli/unknown host for any caller that forgot to pass one — the same silent-misreport pattern this series set out to remove, and it made "required" a lie. BootstrapInput.clientIdentity is now required, so a missing identity fails at compile time instead of being papered over. Test and example callers pass a shared fixture (klient examples and test engines get one each); the node-sdk v2 client asserts its host identity with the oauth helper. Also folds DeviceHeaders from an interface into a type alias so it stays assignable to the widened OAuthRequestHeaders record. --- .../src/app/bootstrap/bootstrap.ts | 23 ++++++----------- .../app/bootstrap/bootstrapService.test.ts | 25 +++++++++++-------- packages/klient/examples/basic.ts | 5 +++- packages/klient/examples/context-usage.ts | 5 +++- packages/klient/examples/identity.ts | 6 +++++ packages/klient/examples/kimi-select-tools.ts | 7 ++++-- .../klient/examples/kosong-config-stress.ts | 7 ++++-- .../examples/model-requester-boundary.ts | 5 +++- packages/klient/examples/smoke.ts | 5 +++- .../test/e2e/invalid-input-matrix.test.ts | 4 ++- packages/klient/test/helpers/engine.ts | 9 ++++++- packages/node-sdk/src/sdk-rpc-client-v2.ts | 2 +- packages/oauth/src/oauth-manager.ts | 2 +- packages/oauth/src/types.ts | 4 +-- 14 files changed, 68 insertions(+), 41 deletions(-) create mode 100644 packages/klient/examples/identity.ts diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index a9a7558fd8..509b9566db 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -89,21 +89,12 @@ export interface BootstrapInput { readonly platform?: NodeJS.Platform; readonly arch?: string; readonly cwd?: string; - readonly clientIdentity?: KimiHostIdentity; + /** Required: every process names its host. There is deliberately no default + — a fabricated identity would silently misreport the host upstream. */ + readonly clientIdentity: KimiHostIdentity; } -/** - * Built-in identity used only when the caller omits `clientIdentity`. This is - * a fallback so bare `bootstrap()` calls in unit tests keep working — NOT the - * contract: composition roots must pass the host's own `KimiHostIdentity`. - */ -const FALLBACK_CLIENT_IDENTITY: KimiHostIdentity = { - productName: 'kimi-code-cli', - version: 'unknown', - platform: 'kimi_code_cli', -}; - -export function resolveBootstrapOptions(input: BootstrapInput = {}): IBootstrapOptions { +export function resolveBootstrapOptions(input: BootstrapInput): IBootstrapOptions { const env = input.env ?? process.env; const osHomeDir = input.osHomeDir ?? homedir(); const homeDir = resolveKimiHome(input.homeDir, env, osHomeDir); @@ -116,11 +107,11 @@ export function resolveBootstrapOptions(input: BootstrapInput = {}): IBootstrapO arch: input.arch ?? process.arch, cwd: input.cwd ?? process.cwd(), env, - clientIdentity: input.clientIdentity ?? FALLBACK_CLIENT_IDENTITY, + clientIdentity: input.clientIdentity, }; } -export function bootstrapSeed(input: BootstrapInput = {}): ScopeSeed { +export function bootstrapSeed(input: BootstrapInput): ScopeSeed { return [[IBootstrapOptions as ServiceIdentifier, resolveBootstrapOptions(input)]]; } @@ -128,7 +119,7 @@ export interface BootstrapResult { readonly app: Scope; } -export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = []): BootstrapResult { +export function bootstrap(input: BootstrapInput, extraSeeds: ScopeSeed = []): BootstrapResult { const options = resolveBootstrapOptions(input); const app = createAppScope({ extra: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds], diff --git a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts index dc08aee9f7..0866164e83 100644 --- a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts +++ b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts @@ -60,9 +60,20 @@ describe('BootstrapService (scoped)', () => { describe('resolveBootstrapOptions', () => { it('prefers explicit homeDir over KIMI_CODE_HOME over osHomeDir', () => { - expect(resolveBootstrapOptions({ homeDir: '/a', osHomeDir: '/b', env: {} }).homeDir).toBe('/a'); - expect(resolveBootstrapOptions({ osHomeDir: '/b', env: { KIMI_CODE_HOME: '/c' } }).homeDir).toBe('/c'); - expect(resolveBootstrapOptions({ osHomeDir: '/b', env: {} }).homeDir).toBe('/b/.kimi-code'); + expect( + resolveBootstrapOptions({ homeDir: '/a', osHomeDir: '/b', env: {}, clientIdentity: stubClientIdentity }) + .homeDir, + ).toBe('/a'); + expect( + resolveBootstrapOptions({ + osHomeDir: '/b', + env: { KIMI_CODE_HOME: '/c' }, + clientIdentity: stubClientIdentity, + }).homeDir, + ).toBe('/c'); + expect( + resolveBootstrapOptions({ osHomeDir: '/b', env: {}, clientIdentity: stubClientIdentity }).homeDir, + ).toBe('/b/.kimi-code'); }); it('passes through an explicit clientIdentity', () => { @@ -70,14 +81,6 @@ describe('resolveBootstrapOptions', () => { resolveBootstrapOptions({ env: {}, clientIdentity: stubClientIdentity }).clientIdentity, ).toEqual(stubClientIdentity); }); - - it('falls back to a built-in CLI identity when clientIdentity is omitted', () => { - expect(resolveBootstrapOptions({ env: {} }).clientIdentity).toEqual({ - productName: 'kimi-code-cli', - version: 'unknown', - platform: 'kimi_code_cli', - }); - }); }); describe('bootstrap() storage seeding', () => { diff --git a/packages/klient/examples/basic.ts b/packages/klient/examples/basic.ts index 422d0c4879..c668b531ec 100644 --- a/packages/klient/examples/basic.ts +++ b/packages/klient/examples/basic.ts @@ -11,12 +11,15 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + + import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; import { createKlient } from '@moonshot-ai/klient/memory'; async function main(): Promise { const homeDir = await mkdtemp(join(tmpdir(), 'klient-basic-')); - const { app } = bootstrap({ homeDir }, [ + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); try { diff --git a/packages/klient/examples/context-usage.ts b/packages/klient/examples/context-usage.ts index cc83ce2b10..ebe0fa2c79 100644 --- a/packages/klient/examples/context-usage.ts +++ b/packages/klient/examples/context-usage.ts @@ -33,6 +33,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + + import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; import { createKlient } from '@moonshot-ai/klient/memory'; @@ -63,7 +66,7 @@ async function main(): Promise { } const homeDir = await mkdtemp(join(tmpdir(), 'klient-context-usage-')); - const { app } = bootstrap({ homeDir }, [ + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); try { diff --git a/packages/klient/examples/identity.ts b/packages/klient/examples/identity.ts new file mode 100644 index 0000000000..4a94c02401 --- /dev/null +++ b/packages/klient/examples/identity.ts @@ -0,0 +1,6 @@ +/** Shared host identity for klient examples (bootstrap requires one). */ +export const EXAMPLE_CLIENT_IDENTITY = { + productName: 'kimi-code-example', + version: '0.0.0-example', + platform: 'example', +} as const; diff --git a/packages/klient/examples/kimi-select-tools.ts b/packages/klient/examples/kimi-select-tools.ts index adedf176a6..bcbcd8cfd3 100644 --- a/packages/klient/examples/kimi-select-tools.ts +++ b/packages/klient/examples/kimi-select-tools.ts @@ -52,6 +52,9 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { homedir } from 'node:os'; import { join } from 'node:path'; + +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + import type { AddressInfo } from 'node:net'; import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; @@ -515,7 +518,7 @@ async function step2UseLoadedTool( async function probeLiveKimiProviders(): Promise { const homeDir = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); console.log(`\n=== part B: live select_tools flow on real kimi providers (${homeDir}) ===`); - const { app } = bootstrap({ homeDir }, [ + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); try { @@ -623,7 +626,7 @@ function describeWireBody(raw: Buffer): string[] { async function probeTappedContext(): Promise { const homeDir = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); console.log(`\n=== part C: tapped wire context (${homeDir}) ===`); - const { app } = bootstrap({ homeDir }, [ + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); try { diff --git a/packages/klient/examples/kosong-config-stress.ts b/packages/klient/examples/kosong-config-stress.ts index 74599a1c87..85ecf70817 100644 --- a/packages/klient/examples/kosong-config-stress.ts +++ b/packages/klient/examples/kosong-config-stress.ts @@ -31,6 +31,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + + import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; import { IKosongConfigService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/kosongConfig'; @@ -84,7 +87,7 @@ async function phase(label: string, ops: number, run: () => Promise): Prom async function main(): Promise { const homeDir = await mkdtemp(join(tmpdir(), 'klient-kosong-stress-')); - const { app } = bootstrap({ homeDir }, [ + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); // Filled in right before the restart phase. @@ -221,7 +224,7 @@ async function main(): Promise { // 7) Restart durability: a fresh engine on the SAME home must rehydrate the // exact pre-restart state — the ultimate proof the writes hit the disk. await phase('restart durability', 1, async () => { - const { app: app2 } = bootstrap({ homeDir }, [ + const { app: app2 } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); try { diff --git a/packages/klient/examples/model-requester-boundary.ts b/packages/klient/examples/model-requester-boundary.ts index b520f2ca62..97523a7cf5 100644 --- a/packages/klient/examples/model-requester-boundary.ts +++ b/packages/klient/examples/model-requester-boundary.ts @@ -40,6 +40,9 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { homedir } from 'node:os'; import { join } from 'node:path'; + +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + import type { AddressInfo } from 'node:net'; import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; @@ -80,7 +83,7 @@ const tick = (ms: number): Promise => async function probeRealConfig(): Promise { const homeDir = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); console.log(`\n=== part 1: real config (${homeDir}/config.toml) ===`); - const { app } = bootstrap({ homeDir }, [ + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); try { diff --git a/packages/klient/examples/smoke.ts b/packages/klient/examples/smoke.ts index 57a6b6c8d9..b5a9fe6929 100644 --- a/packages/klient/examples/smoke.ts +++ b/packages/klient/examples/smoke.ts @@ -12,6 +12,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + + import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; import { createKlient } from '@moonshot-ai/klient/memory'; @@ -26,7 +29,7 @@ const tick = (ms: number): Promise => async function main(): Promise { const homeDir = await mkdtemp(join(tmpdir(), 'klient-smoke-')); - const { app } = bootstrap({ homeDir }, [ + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); try { diff --git a/packages/klient/test/e2e/invalid-input-matrix.test.ts b/packages/klient/test/e2e/invalid-input-matrix.test.ts index 6f4d66a9af..10de4eeec2 100644 --- a/packages/klient/test/e2e/invalid-input-matrix.test.ts +++ b/packages/klient/test/e2e/invalid-input-matrix.test.ts @@ -34,6 +34,8 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; + +import { TEST_CLIENT_IDENTITY } from '../helpers/engine.js'; import type { ContentPart } from '@moonshot-ai/agent-core-v2/kosong/contract/message'; import { IModelService } from '@moonshot-ai/agent-core-v2/kosong/model/model'; @@ -295,7 +297,7 @@ const sockets = new Set(); beforeAll(async () => { homeDir = await mkdtemp(join(tmpdir(), 'klient-matrix-home-')); workRoot = await mkdtemp(join(tmpdir(), 'klient-matrix-work-')); - ({ app } = bootstrap({ homeDir }, [ + ({ app } = bootstrap({ homeDir, clientIdentity: TEST_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ])); klient = createMemoryKlient({ scope: app }); diff --git a/packages/klient/test/helpers/engine.ts b/packages/klient/test/helpers/engine.ts index bdb2904a4c..b5be60c2f2 100644 --- a/packages/klient/test/helpers/engine.ts +++ b/packages/klient/test/helpers/engine.ts @@ -12,6 +12,13 @@ import { join } from 'node:path'; import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +/** Shared host identity for klient test engines (bootstrap requires one). */ +export const TEST_CLIENT_IDENTITY = { + productName: 'klient-test', + version: '0.0.0-test', + platform: 'test', +} as const; + export interface TestEngine { readonly homeDir: string; readonly app: ReturnType['app']; @@ -19,7 +26,7 @@ export interface TestEngine { export async function makeEngine(prefix = 'klient-test-engine-'): Promise { const homeDir = await mkdtemp(join(tmpdir(), prefix)); - const { app } = bootstrap({ homeDir }, [ + const { app } = bootstrap({ homeDir, clientIdentity: TEST_CLIENT_IDENTITY }, [ ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), ]); return { homeDir, app }; diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 96f4f2e224..15a6d53779 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -416,7 +416,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { { homeDir: this.homeDir, configPath: this.configPath, - clientIdentity: this.identity, + clientIdentity: assertKimiHostIdentity(this.identity), }, [ ...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env })), diff --git a/packages/oauth/src/oauth-manager.ts b/packages/oauth/src/oauth-manager.ts index b9a389ff83..f5ef0ea621 100644 --- a/packages/oauth/src/oauth-manager.ts +++ b/packages/oauth/src/oauth-manager.ts @@ -22,7 +22,7 @@ import { pollDeviceToken, refreshAccessToken, requestDeviceAuthorization } from import type { DevicePollResult, RefreshOptions } from './oauth'; import type { TokenStorage } from './storage'; import { classifyToken, revokedTombstone, type TokenState } from './token-state'; -import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, OAuthRequestHeaders, TokenInfo } from './types'; +import type { DeviceAuthorization, OAuthFlowConfig, OAuthRequestHeaders, TokenInfo } from './types'; const MIN_REFRESH_THRESHOLD_SECONDS = 300; const REFRESH_THRESHOLD_RATIO = 0.5; diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index 66f597467a..9197259aae 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -45,14 +45,14 @@ export interface OAuthFlowConfig { } /** Device identification for `X-Msh-*` headers. */ -export interface DeviceHeaders { +export type DeviceHeaders = { readonly 'X-Msh-Platform': string; readonly 'X-Msh-Version': string; readonly 'X-Msh-Device-Name': string; readonly 'X-Msh-Device-Model': string; readonly 'X-Msh-Os-Version': string; readonly 'X-Msh-Device-Id': string; -} +}; /** Headers sent with OAuth HTTP requests: the `X-Msh-*` device set, plus a product User-Agent when the caller carries a host identity — the OAuth From 1ba14496d7509567601a9eb9d9e7adc0b61956d3 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 12:29:21 +0800 Subject: [PATCH 13/15] feat(oauth)!: require and validate the platform in device headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the quiet CLI fallback in createKimiDeviceHeaders (the same silent-misreport pattern removed from the bootstrap identity): platform is now a required option, validated with the same required-ASCII rule as the version — empty or all-non-ASCII values throw instead of emitting a blank X-Msh-Platform, and header-unsafe characters are stripped rather than sent raw. --- packages/oauth/src/identity.ts | 8 ++++---- packages/oauth/test/identity.test.ts | 27 +++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/oauth/src/identity.ts b/packages/oauth/src/identity.ts index 8812722f59..0f1defa2a9 100644 --- a/packages/oauth/src/identity.ts +++ b/packages/oauth/src/identity.ts @@ -77,12 +77,12 @@ export function createKimiDeviceId( export function createKimiDeviceHeaders(options: { readonly homeDir: string; readonly version: string; - /** Falls back to `KIMI_CODE_PLATFORM` only for direct callers without a host - identity; hosts carrying a `KimiHostIdentity` always pass theirs. */ - readonly platform?: string | undefined; + /** Required and validated like the version: non-empty ASCII, no fallback — + a blank or fabricated platform would silently misreport the host. */ + readonly platform: string; }): DeviceHeaders { return { - 'X-Msh-Platform': options.platform ?? KIMI_CODE_PLATFORM, + 'X-Msh-Platform': requiredAsciiHeader(options.platform, 'Kimi identity platform'), 'X-Msh-Version': requiredAsciiHeader(options.version, 'Kimi identity version'), 'X-Msh-Device-Name': asciiHeader(hostname()), 'X-Msh-Device-Model': asciiHeader(deviceModel()), diff --git a/packages/oauth/test/identity.test.ts b/packages/oauth/test/identity.test.ts index 6dfb34c87a..ca00fef2fd 100644 --- a/packages/oauth/test/identity.test.ts +++ b/packages/oauth/test/identity.test.ts @@ -61,10 +61,11 @@ describe('Kimi identity factories', () => { expect(readKimiDeviceId(homeDir)).toBeNull(); }); - it('creates complete X-Msh device headers from host version', () => { + it('creates complete X-Msh device headers from host version and platform', () => { const headers = createKimiDeviceHeaders({ homeDir: tempHome(), version: '1.2.3-test', + platform: KIMI_CODE_PLATFORM, }); expect(headers['X-Msh-Platform']).toBe(KIMI_CODE_PLATFORM); @@ -101,6 +102,24 @@ describe('Kimi identity factories', () => { expect(headers['X-Msh-Platform']).toBe('kimi_code_desktop'); }); + it('rejects an empty, whitespace, or all-non-ASCII platform instead of emitting a bad header', () => { + for (const platform of ['', ' ', '桌面']) { + expect( + () => createKimiDeviceHeaders({ homeDir: tempHome(), version: '1.2.3', platform }), + JSON.stringify(platform), + ).toThrow('Kimi identity platform'); + } + }); + + it('sanitizes header-unsafe characters out of the platform value', () => { + const headers = createKimiDeviceHeaders({ + homeDir: tempHome(), + version: '1.2.3', + platform: 'kimi_code_桌面\n', + }); + expect(headers['X-Msh-Platform']).toBe('kimi_code_'); + }); + it('merges User-Agent and device headers into default headers', () => { const headers = createKimiDefaultHeaders({ homeDir: tempHome(), @@ -156,7 +175,7 @@ describe('ascii header value sanitization', () => { try { const { createKimiDeviceHeaders: createHeaders } = await import('../src/identity'); - const headers = createHeaders({ homeDir: tempHome(), version: '1.0.0' }); + const headers = createHeaders({ homeDir: tempHome(), version: '1.0.0', platform: 'test' }); expect(headers['X-Msh-Device-Name']).toBe('unknown'); } finally { vi.doUnmock('node:os'); @@ -179,7 +198,7 @@ describe('ascii header value sanitization', () => { try { const { createKimiDeviceHeaders: createHeaders } = await import('../src/identity'); - const headers = createHeaders({ homeDir: tempHome(), version: '1.0.0' }); + const headers = createHeaders({ homeDir: tempHome(), version: '1.0.0', platform: 'test' }); for (const [key, value] of Object.entries(headers)) { expect(value, `header ${key} has untrimmed whitespace: ${JSON.stringify(value)}`).toBe( value.trim(), @@ -211,7 +230,7 @@ describe('ascii header value sanitization', () => { try { const { createKimiDeviceHeaders } = await import('../src/identity'); - const headers = createKimiDeviceHeaders({ homeDir: tempHome(), version: '1.0.0' }); + const headers = createKimiDeviceHeaders({ homeDir: tempHome(), version: '1.0.0', platform: 'test' }); expect(headers['X-Msh-Device-Model']).toBe('macOS 25.5.0 arm64'); } finally { vi.doUnmock('node:os'); From e7f03365d3a26bb91c5fd13b246553702531794f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 13:01:49 +0800 Subject: [PATCH 14/15] fix(node-sdk): seed the host request headers on the v2 client path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive v2 engine path (experimental flag) bootstrapped without a hostRequestHeaders seed, so managed vendor calls went out with the SDK's default User-Agent (OpenAI/JS) and no X-Msh-* at all — v1 passes the full identity headers on the same requests. The v2 client now seeds the headers from its asserted host identity, and a test pins the seed. --- packages/node-sdk/src/sdk-rpc-client-v2.ts | 12 ++++++++++-- .../node-sdk/test/sdk-rpc-client-v2.test.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 15a6d53779..31f5c64d10 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -197,6 +197,7 @@ import { ISkillDiscovery, ITelemetryService, IWorkspaceAliases, + hostRequestHeadersSeed, logSeed, MAIN_AGENT_ID, prepareSystemPromptContext, @@ -223,7 +224,7 @@ import { } from '@moonshot-ai/agent-core-v2'; import type { AgentHandle, Klient } from '@moonshot-ai/klient'; import { createKlient } from '@moonshot-ai/klient/memory'; -import { assertKimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; +import { assertKimiHostIdentity, createKimiDefaultHeaders } from '@moonshot-ai/kimi-code-oauth'; import { KimiAuthFacade } from '#/auth'; import { KimiHarness } from '#/kimi-harness'; @@ -412,14 +413,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { onRefresh: options.onOAuthRefresh, }); + const identity = assertKimiHostIdentity(this.identity); const { app } = bootstrap( { homeDir: this.homeDir, configPath: this.configPath, - clientIdentity: assertKimiHostIdentity(this.identity), + clientIdentity: identity, }, [ ...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env })), + // Host identity headers for the engine's outbound requests (model, + // WebSearch, registry refresh). Without this seed the managed vendors + // go out with the SDK's default User-Agent and no X-Msh-* at all. + ...hostRequestHeadersSeed( + createKimiDefaultHeaders({ homeDir: this.homeDir, ...identity }), + ), // `--skills-dir` (v1 parity): explicit skill dirs replace default // user / project discovery for every session this client hosts. ...skillCatalogRuntimeOptionsSeed(options.skillDirs), diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index ca50857931..88a23c50c6 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createKimiHarnessV2, ErrorCodes, KimiError, KimiHarness, SDKRpcClientV2 } from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; +import { IHostRequestHeaders } from '@moonshot-ai/agent-core-v2'; import { TEST_IDENTITY } from './test-identity'; import { recordingTelemetry, type TelemetryRecord } from './telemetry'; @@ -34,6 +35,23 @@ async function makeHarness(): Promise<{ harness: KimiHarness; homeDir: string }> } describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { + it('seeds the host request headers (User-Agent + X-Msh-*) into the engine', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + // Without this seed the managed vendors go out with the SDK's default + // User-Agent and no X-Msh-* — the interactive-v2 path's identity bug. + const headers = client.engineAccessor.get(IHostRequestHeaders).headers; + expect(headers['User-Agent']).toBe(`kimi-code-cli/${TEST_IDENTITY.version}`); + expect(headers['X-Msh-Platform']).toBe('kimi_code_cli'); + expect(headers['X-Msh-Version']).toBe(TEST_IDENTITY.version); + expect(headers['X-Msh-Device-Id']).toBeTruthy(); + } finally { + await client.close(); + } + }); + it('serves getExperimentalFeatures from the v2 engine', async () => { const { harness } = await makeHarness(); try { From 1439ebd29a0ff13cecfb376759f1a4e6dba51e52 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 13:42:33 +0800 Subject: [PATCH 15/15] chore: simplify the CLI changeset wording --- .changeset/cli-web-login-identity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cli-web-login-identity.md b/.changeset/cli-web-login-identity.md index d76bc70ba6..669e2dff39 100644 --- a/.changeset/cli-web-login-identity.md +++ b/.changeset/cli-web-login-identity.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Send the CLI's device identity headers with OAuth login and token refresh requests from kimi web, tag web-UI requests with a `(web)` User-Agent suffix so upstream can tell them apart from direct CLI traffic, and record the host product version in session export manifests. +Fix request headers not being passed correctly on some requests.