File size: 7,610 Bytes
f0634fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
/**
 * Kimi host and device identity header factories.
 *
 * The caller owns the host identity (product name + host app version)
 * and the `homeDir` where the stable device id is stored. This module
 * intentionally keeps no global CLI version or environment-derived
 * production state.
 */

import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { arch, hostname, release, type } from 'node:os';
import { join } from 'node:path';

import type { DeviceHeaders } from './types';

export const KIMI_CODE_PLATFORM = 'kimi_code_cli';

export interface KimiHostIdentity {
  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;
}

export interface KimiIdentityOptions extends KimiHostIdentity {
  readonly homeDir: string;
}

export interface CreateKimiDeviceIdOptions {
  /** Invoked synchronously the first time a device id is minted on this machine. */
  readonly onFirstLaunch?: ((id: string) => void) | undefined;
}

export function readKimiDeviceId(homeDir: string): string | null {
  const deviceIdPath = join(homeDir, 'device_id');
  if (!existsSync(deviceIdPath)) return null;
  try {
    const text = readFileSync(deviceIdPath, 'utf-8').trim();
    return text.length > 0 ? text : null;
  } catch {
    return null;
  }
}

export function createKimiDeviceId(
  homeDir: string,
  options: CreateKimiDeviceIdOptions = {},
): string {
  const existing = readKimiDeviceId(homeDir);
  if (existing !== null) return existing;

  const id = randomUUID();
  try {
    mkdirSync(homeDir, { recursive: true, mode: 0o700 });
    writeFileSync(join(homeDir, 'device_id'), id, { encoding: 'utf-8', mode: 0o600 });
  } catch {
    // Best-effort: requests can still use the in-memory id.
  }
  if (options.onFirstLaunch !== undefined) {
    try {
      options.onFirstLaunch(id);
    } catch {
      // Telemetry callback must not affect device id creation.
    }
  }
  return id;
}

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': 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()),
    'X-Msh-Os-Version': asciiHeader(release()),
    'X-Msh-Device-Id': createKimiDeviceId(options.homeDir),
  };
}

export function createKimiUserAgent(options: {
  readonly productName: string;
  readonly version: string;
  readonly userAgentSuffix?: string | undefined;
}): string {
  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, '');
  return suffix === undefined || suffix.length === 0
    ? `${product}/${version}`
    : `${product}/${version} (${suffix})`;
}

/**
 * Swap the product token of a User-Agent produced by
 * {@link createKimiUserAgent}, keeping the version and optional suffix intact
 * (`kimi-code-cli/1.2.3 (web)` → `acme/1.2.3 (web)`).
 *
 * Lives next to the builder on purpose: the format knowledge — product token,
 * `/`, version, parenthesized suffix — must exist in exactly one place, so a
 * change to the builder cannot silently desynchronize the rewriter. Callers
 * pass an already-normalized ASCII token; a blank or non-ASCII product still
 * throws rather than emitting an invalid header.
 *
 * A value that does not carry a `/` is treated as a bare product token and
 * replaced wholesale.
 */
export function replaceUserAgentProduct(userAgent: string, product: string): string {
  const cleaned = requiredAsciiHeader(product, 'Kimi identity product');
  const separator = userAgent.indexOf('/');
  return separator < 0 ? cleaned : `${cleaned}${userAgent.slice(separator)}`;
}

export function createKimiDefaultHeaders(options: KimiIdentityOptions): Record<string, string> {
  return {
    'User-Agent': createKimiUserAgent(options),
    ...createKimiDeviceHeaders({
      homeDir: options.homeDir,
      version: options.version,
      platform: options.platform,
    }),
  };
}

/**
 * Env var carrying extra headers applied to every outbound provider request
 * (LLM chat and `/models` listing). Mirrors `ANTHROPIC_CUSTOM_HEADERS`:
 * newline-separated `Name: Value` lines; lines without a colon are skipped;
 * names and values are trimmed.
 *
 * These headers form the lowest-precedence layer — the Kimi identity headers
 * (User-Agent, X-Msh-*), per-provider `customHeaders`, and request auth
 * (Authorization) all override them.
 *
 * Unlike the device identity headers above, this is intentionally
 * environment-derived and stateless (re-read on every call) so callers can
 * apply it uniformly without plumbing the value through every host layer.
 */
export const KIMI_CODE_CUSTOM_HEADERS_ENV = 'KIMI_CODE_CUSTOM_HEADERS';

export function parseKimiCodeCustomHeaders(
  env: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
  const raw = env[KIMI_CODE_CUSTOM_HEADERS_ENV]?.trim();
  if (raw === undefined || raw.length === 0) return {};
  const headers: Record<string, string> = {};
  for (const line of raw.split('\n')) {
    const colon = line.indexOf(':');
    if (colon < 0) continue;
    const name = line.slice(0, colon).trim();
    if (name.length === 0) continue;
    headers[name] = line.slice(colon + 1).trim();
  }
  return headers;
}

export function assertKimiHostIdentity(identity: KimiHostIdentity | undefined): KimiHostIdentity {
  if (identity === undefined) {
    throw new Error('Kimi host identity is required. Pass the host product name and version.');
  }
  requiredAsciiHeader(identity.productName, 'Kimi identity product');
  requiredAsciiHeader(identity.version, 'Kimi identity version');
  return identity;
}

function deviceModel(): string {
  const os = type();
  const version = release();
  const osArch = arch();
  if (os === 'Darwin') return `macOS ${macOsProductVersion() ?? version} ${osArch}`;
  if (os === 'Windows_NT') return `Windows ${version} ${osArch}`;
  return `${os} ${version} ${osArch}`.trim();
}

function macOsProductVersion(): string | undefined {
  try {
    const version = execFileSync('/usr/bin/sw_vers', ['-productVersion'], {
      encoding: 'utf-8',
      timeout: 1000,
    }).trim();
    return version.length > 0 ? version : undefined;
  } catch {
    return undefined;
  }
}

function asciiHeader(value: string, fallback = 'unknown'): string {
  const cleaned = value.replaceAll(/[^\u0020-\u007E]/g, '').trim();
  return cleaned.length > 0 ? cleaned : fallback;
}

function requiredAsciiHeader(value: string, fieldName: string): string {
  const cleaned = asciiHeader(value, '');
  if (cleaned.length === 0) {
    throw new Error(`${fieldName} must be a non-empty ASCII string.`);
  }
  return cleaned;
}