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..669e2dff39 --- /dev/null +++ b/.changeset/cli-web-login-identity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix request headers not being passed correctly on some requests. 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..69ac014321 --- /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. 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/.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. diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index 9318b85395..26088c05fb 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -11,13 +11,12 @@ 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'; 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'; @@ -25,7 +24,7 @@ import { getDataDir } from '#/utils/paths'; import { initializeServerTelemetry } from '../../telemetry'; import { - buildKimiDefaultHeaders, + createKimiCodeHostIdentity, getHostPackageRoot, getVersion, } from '../../version'; @@ -278,7 +277,16 @@ 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, + // 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. 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, @@ -291,10 +299,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/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/src/cli/version.ts b/apps/kimi-code/src/cli/version.ts index d5a4f36cdb..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, 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; @@ -50,8 +49,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, }; } @@ -62,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/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; 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/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/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'); }); 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..d07af86f82 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_vscode", }, 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..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: { userAgentProduct: "kimi-code-vscode", version: "test" }, + identity: { productName: "kimi-code-vscode", version: "test", platform: "kimi_code_vscode" }, }); await harness.setConfig({ providers: { 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..509b9566db 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,10 +89,12 @@ export interface BootstrapInput { readonly platform?: NodeJS.Platform; readonly arch?: string; readonly cwd?: string; - readonly clientVersion?: string; + /** Required: every process names its host. There is deliberately no default + — a fabricated identity would silently misreport the host upstream. */ + readonly clientIdentity: KimiHostIdentity; } -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); @@ -103,11 +107,11 @@ export function resolveBootstrapOptions(input: BootstrapInput = {}): IBootstrapO arch: input.arch ?? process.arch, cwd: input.cwd ?? process.cwd(), env, - clientVersion: input.clientVersion ?? 'unknown', + clientIdentity: input.clientIdentity, }; } -export function bootstrapSeed(input: BootstrapInput = {}): ScopeSeed { +export function bootstrapSeed(input: BootstrapInput): ScopeSeed { return [[IBootstrapOptions as ServiceIdentifier, resolveBootstrapOptions(input)]]; } @@ -115,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/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/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/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..0866164e83 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(); @@ -45,15 +60,32 @@ 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', () => { + expect( + resolveBootstrapOptions({ env: {}, clientIdentity: stubClientIdentity }).clientIdentity, + ).toEqual(stubClientIdentity); }); }); 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/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 0c9241e4c8..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,10 +274,50 @@ 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, - { 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 +338,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/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 79610a3d95..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, { @@ -428,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 e68800fe12..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,33 +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(server.core.accessor.get(IBootstrapService).clientVersion).toBe('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: 'test-host', + version: '9.9.9-host', + 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, @@ -154,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, @@ -168,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, @@ -182,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, @@ -211,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, @@ -382,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/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/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`; }); 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/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/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' 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/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); 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/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/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 81848610c6..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, - clientVersion: this.identity?.version, + 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 { 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..0f1defa2a9 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; + /** 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': 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()), @@ -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 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 0229db7b03..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,9 +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, + ...identity, }), ...this.managerOptions, }); diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index 9073f86142..9197259aae 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -45,14 +45,20 @@ 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 + 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 { diff --git a/packages/oauth/test/identity.test.ts b/packages/oauth/test/identity.test.ts index e94e04a3cf..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); @@ -78,42 +79,84 @@ 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('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(), - 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'); }); @@ -132,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'); @@ -155,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(), @@ -187,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'); 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(() => {