diff --git a/.changeset/oauth-region-split.md b/.changeset/oauth-region-split.md new file mode 100644 index 00000000000..6c894c7123d --- /dev/null +++ b/.changeset/oauth-region-split.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Support two OAuth login methods — kimi.ai and kimi.com. diff --git a/.changeset/sdk-auth-login-region.md b/.changeset/sdk-auth-login-region.md new file mode 100644 index 00000000000..9c4c433cacb --- /dev/null +++ b/.changeset/sdk-auth-login-region.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Add an optional region parameter to the auth login API for selecting the OAuth login endpoint (.com or .ai deployment). diff --git a/apps/kimi-code/src/cli/sub/acp-native.ts b/apps/kimi-code/src/cli/sub/acp-native.ts index 2b9886769f6..83d444e343f 100644 --- a/apps/kimi-code/src/cli/sub/acp-native.ts +++ b/apps/kimi-code/src/cli/sub/acp-native.ts @@ -25,7 +25,7 @@ import { getVersion } from '#/cli/version'; import { KIMI_CODE_HOME_ENV } from '#/constant/app'; import { getDataDir } from '#/utils/paths'; -import { runLoginFlow } from './login-flow'; +import { parseRegionFlag, runLoginFlow } from './login-flow'; export function registerNativeAcpCommand(parent: Command): void { parent @@ -36,9 +36,12 @@ export function registerNativeAcpCommand(parent: Command): void { 'Run the device-code login flow then exit (entry point for ACP terminal-auth).', false, ) - .action(async (opts: { login?: boolean }) => { + .option('--region ', 'Login region used together with --login: "mainland-cn" (kimi.com) or "global" (kimi.ai).') + .action(async (opts: { login?: boolean; region?: string }) => { if (opts.login === true) { - await runLoginFlow(); + await runLoginFlow({ + region: opts.region === undefined ? undefined : parseRegionFlag(opts.region), + }); return; } // Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the diff --git a/apps/kimi-code/src/cli/sub/acp.ts b/apps/kimi-code/src/cli/sub/acp.ts index 4da7e892ea8..d0803a852fd 100644 --- a/apps/kimi-code/src/cli/sub/acp.ts +++ b/apps/kimi-code/src/cli/sub/acp.ts @@ -35,7 +35,7 @@ import { buildSkillSlashCommands } from '#/tui/commands/skills'; import { isLegacyEnabled } from '../experimental-v2'; import { registerNativeAcpCommand } from './acp-native'; -import { runLoginFlow } from './login-flow'; +import { parseRegionFlag, runLoginFlow } from './login-flow'; export function registerAcpCommand(parent: Command): void { if (!isLegacyEnabled()) { @@ -51,9 +51,12 @@ export function registerAcpCommand(parent: Command): void { 'Run the device-code login flow then exit (entry point for ACP terminal-auth).', false, ) - .action(async (opts: { login?: boolean }) => { + .option('--region ', 'Login region used together with --login: "mainland-cn" (kimi.com) or "global" (kimi.ai).') + .action(async (opts: { login?: boolean; region?: string }) => { if (opts.login === true) { - await runLoginFlow(); + await runLoginFlow({ + region: opts.region === undefined ? undefined : parseRegionFlag(opts.region), + }); return; } const identity = createKimiCodeHostIdentity(); diff --git a/apps/kimi-code/src/cli/sub/login-flow.ts b/apps/kimi-code/src/cli/sub/login-flow.ts index 005dc17295b..34014660bef 100644 --- a/apps/kimi-code/src/cli/sub/login-flow.ts +++ b/apps/kimi-code/src/cli/sub/login-flow.ts @@ -6,11 +6,27 @@ */ import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiRegion } from '@moonshot-ai/kimi-code-oauth'; import { createKimiCodeHostIdentity } from '#/cli/version'; import { openUrl } from '#/utils/open-url'; +import { persistedKimiOAuthRef, regionForBareLogin } from '#/utils/region'; -export async function runLoginFlow(): Promise { +/** Parse a `--region` CLI flag; exits with an actionable message on bad input. */ +export function parseRegionFlag(value: string): KimiRegion { + if (value !== 'mainland-cn' && value !== 'global') { + process.stderr.write(`Invalid --region "${value}" (expected "mainland-cn" or "global").\n`); + process.exit(1); + } + return value; +} + +export async function runLoginFlow(options: { region?: KimiRegion } = {}): Promise { + // No flag: a fresh install follows the resolved region (env/marker/ + // default); an existing login keeps its own environment (see + // regionForBareLogin — the default slot re-pins mainland-cn, a scoped slot + // keeps its configured hosts). + const region = options.region ?? regionForBareLogin(persistedKimiOAuthRef()); const identity = createKimiCodeHostIdentity(); const harness = createKimiHarness({ identity, @@ -23,6 +39,7 @@ export async function runLoginFlow(): Promise { try { const result = await harness.auth.login(undefined, { signal: controller.signal, + region, onDeviceCode: (data) => { const url = data.verificationUriComplete || data.verificationUri; // Print the manual fallback before attempting to open the user's diff --git a/apps/kimi-code/src/cli/sub/login.ts b/apps/kimi-code/src/cli/sub/login.ts index 2c17b4c3aa9..78510c9953e 100644 --- a/apps/kimi-code/src/cli/sub/login.ts +++ b/apps/kimi-code/src/cli/sub/login.ts @@ -8,13 +8,19 @@ import type { Command } from 'commander'; -import { runLoginFlow } from './login-flow'; +import { parseRegionFlag, runLoginFlow } from './login-flow'; export function registerLoginCommand(parent: Command): void { parent .command('login') .description('Authenticate with Kimi Code CLI via the device-code flow.') - .action(async () => { - await runLoginFlow(); + .option( + '--region ', + 'Login region: "mainland-cn" (kimi.com) or "global" (kimi.ai).', + ) + .action(async (opts: { region?: string }) => { + await runLoginFlow({ + region: opts.region === undefined ? undefined : parseRegionFlag(opts.region), + }); }); } diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts index fefec09e339..3c3b63d8a14 100644 --- a/apps/kimi-code/src/cli/telemetry.ts +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -17,6 +17,7 @@ import { } from '@moonshot-ai/kimi-telemetry'; import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app'; +import { currentKimiProfile } from '#/utils/region'; import { createKimiCodeHostIdentity } from './version'; @@ -57,6 +58,7 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): uiMode: options.uiMode, model: options.model ?? options.config.defaultModel, sessionId: options.sessionId, + endpoint: () => currentKimiProfile().telemetryEndpoint, getAccessToken: async () => (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); @@ -105,6 +107,7 @@ export function initializeServerTelemetry( version: options.version, uiMode: WEB_UI_MODE, model: config.defaultModel, + endpoint: () => currentKimiProfile().telemetryEndpoint, getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts index 4990568c9c4..6e423cdb0c0 100644 --- a/apps/kimi-code/src/cli/update/cdn.ts +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -1,7 +1,7 @@ import { valid } from 'semver'; import { z } from 'zod'; -import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { kimiCodeCdnLatestJsonUrl, kimiCodeCdnLatestUrl } from '#/constant/app'; import type { UpdateManifest } from './types'; @@ -58,7 +58,7 @@ async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise export async function fetchLatestVersionFromCdn( fetchImpl: typeof fetch = fetch, ): Promise { - const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_URL); + const response = await fetchWithTimeout(fetchImpl, kimiCodeCdnLatestUrl()); if (!response.ok) { throw new Error(`CDN /latest returned HTTP ${response.status}`); } @@ -70,7 +70,7 @@ export async function fetchLatestVersionFromCdn( } async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { - const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_JSON_URL); + const response = await fetchWithTimeout(fetchImpl, kimiCodeCdnLatestJsonUrl()); if (!response.ok) { throw new Error(`CDN /latest.json returned HTTP ${response.status}`); } diff --git a/apps/kimi-code/src/cli/update/native-manifest.ts b/apps/kimi-code/src/cli/update/native-manifest.ts index 06c7c5bfc5a..0b47fb393aa 100644 --- a/apps/kimi-code/src/cli/update/native-manifest.ts +++ b/apps/kimi-code/src/cli/update/native-manifest.ts @@ -10,7 +10,7 @@ import { valid } from 'semver'; import { z } from 'zod'; -import { KIMI_CODE_CDN_BINARIES_BASE } from '#/constant/app'; +import { kimiCodeCdnBinariesBase } from '#/constant/app'; const MANIFEST_FETCH_TIMEOUT_MS = 10_000; @@ -33,11 +33,11 @@ export type NativeReleaseManifest = z.infer; export type NativePlatformEntry = z.infer; export function nativeManifestUrl(version: string): string { - return `${KIMI_CODE_CDN_BINARIES_BASE}/${version}/manifest.json`; + return `${kimiCodeCdnBinariesBase()}/${version}/manifest.json`; } export function nativeBinaryUrl(version: string, filename: string): string { - return `${KIMI_CODE_CDN_BINARIES_BASE}/${version}/${filename}`; + return `${kimiCodeCdnBinariesBase()}/${version}/${filename}`; } /** diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index f54ff9b22a8..9d6d1f17249 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -4,9 +4,9 @@ import { log, type Logger } from '@moonshot-ai/kimi-code-sdk'; import type { TelemetryProperties } from '@moonshot-ai/kimi-telemetry'; import { - KIMI_CODE_OFFICIAL_INSTALL_URL, - NATIVE_INSTALL_COMMAND_UNIX, - NATIVE_INSTALL_COMMAND_WIN, + kimiCodeOfficialInstallUrl, + nativeInstallCommandUnix, + nativeInstallCommandWin, } from '#/constant/app'; import { loadTuiConfig } from '#/tui/config'; import { resolveCommandPath } from '#/utils/process/resolve-command'; @@ -82,7 +82,7 @@ export function installCommandFor( case 'homebrew': return 'brew upgrade kimi-code'; case 'native': - return platform === 'win32' ? NATIVE_INSTALL_COMMAND_WIN : NATIVE_INSTALL_COMMAND_UNIX; + return platform === 'win32' ? nativeInstallCommandWin() : nativeInstallCommandUnix(); case 'unsupported': return `npm install -g ${NPM_PACKAGE_NAME}@${version}`; } @@ -184,9 +184,13 @@ function resolveInstallSpawn( return { resolvedCmd, args, shell: platform === 'win32' }; } -const THIRD_PARTY_SOURCE_NOTE = - '\nNote: Third-party sources may lag behind the official release.\n' + - `For the latest updates, use the official installer: ${KIMI_CODE_OFFICIAL_INSTALL_URL}\n`; +// Built per call: the official-installer URL follows the current region. +function thirdPartySourceNote(): string { + return ( + '\nNote: Third-party sources may lag behind the official release.\n' + + `For the latest updates, use the official installer: ${kimiCodeOfficialInstallUrl()}\n` + ); +} export function renderManualUpdateMessage( currentVersion: string, @@ -217,7 +221,7 @@ export function renderManualUpdateMessage( `(${currentVersion} -> ${target.version}).\n` + `Detected install source: ${sourceDesc}\n` + `To update manually, run: ${installCommand}\n` + - (source === 'homebrew' ? THIRD_PARTY_SOURCE_NOTE : '') + (source === 'homebrew' ? thirdPartySourceNote() : '') ); } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index d514d029bdf..d981636a487 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -1,4 +1,7 @@ import { ErrorCodes } from '@moonshot-ai/kimi-code-sdk'; +import { kimiCdnContentUrl } from '@moonshot-ai/kimi-code-oauth'; + +import { currentKimiProfile } from '#/utils/region'; export const PRODUCT_NAME = 'Kimi Code'; export const CLI_COMMAND_NAME = 'kimi'; @@ -74,7 +77,9 @@ export const OAUTH_LOGIN_REQUIRED_CODE = ErrorCodes.AUTH_LOGIN_REQUIRED; export const FEEDBACK_ISSUE_URL = 'https://github.com/MoonshotAI/kimi-code/issues'; // Sign-up / sign-in page offered to signed-out users so they can create an // account and submit feedback through the authenticated channel next time. -export const KIMI_CODE_SIGNUP_URL = 'https://www.kimi.com/code'; +export function kimiCodeSignupUrl(): string { + return `${currentKimiProfile().siteBase}/code`; +} // Sent in the feedback `version` field so the backend can distinguish this // TypeScript client from clients that send a bare version. @@ -84,34 +89,59 @@ export const FEEDBACK_VERSION_PREFIX = 'kimi-code-'; export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. -export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; -export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; +// The off-session endpoints derive from the current region profile so a +// global login points at the .ai deployment; they are resolved per call so +// a region switch (login/logout + refreshKimiRegion) takes effect immediately. +export function kimiCodeCdnBase(): string { + return currentKimiProfile().cdnBase; +} +export function kimiCodeCdnLatestUrl(): string { + return `${kimiCodeCdnBase()}/latest`; +} // Rollout manifest consumed by update checks; the plain-text `/latest` above // stays unchanged forever — already-shipped clients hard-fail on non-semver // bodies, and the CDN install scripts read it for fresh installs. -export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; +export function kimiCodeCdnLatestJsonUrl(): string { + return `${kimiCodeCdnBase()}/latest.json`; +} // Per-release native artifacts: `/binaries//manifest.json` + // `/binaries//kimi-code-[.exe]` — the bare platform binary // (same layout install.ps1 consumes). -export const KIMI_CODE_CDN_BINARIES_BASE = `${KIMI_CODE_CDN_BASE}/binaries`; -export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; -// The marketplace catalog location constants live in the shared -// agent-core-v2 plugin domain (kap-server consumes them from there). -// Deep-path import: this module is evaluated on every CLI invocation, so it -// must not pull in the engine root. -export { - KIMI_CODE_PLUGIN_MARKETPLACE_URL, - KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, -} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; +export function kimiCodeCdnBinariesBase(): string { + return `${kimiCodeCdnBase()}/binaries`; +} +// The tips banner rides the content CDN, which both regions currently share. +export function kimiCodeTipsBannerUrl(): string { + return kimiCdnContentUrl('kimi-code-tips/tips.json'); +} +// The marketplace env override name lives in the shared agent-core-v2 plugin +// domain (kap-server consumes it from there). Deep-path import: this module is +// evaluated on every CLI invocation, so it must not pull in the engine root. +export { KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; +// The CLI-side default catalog derives from the current region profile; the +// env override above takes priority at the call site. +export function kimiCodePluginMarketplaceUrl(): string { + return `${kimiCodeCdnBase()}/plugins/marketplace.json`; +} // Official plugins whose usage bills against the user's plan quota. Installing // one of these shows a quota note after the install result. export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource']; -export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`; -export const KIMI_CODE_INSTALL_PS1_URL = `${KIMI_CODE_CDN_BASE}/install.ps1`; +export function kimiCodeInstallShUrl(): string { + return `${kimiCodeCdnBase()}/install.sh`; +} +export function kimiCodeInstallPs1Url(): string { + return `${kimiCodeCdnBase()}/install.ps1`; +} // Official download page, referenced by prompt copy that steers users away // from third-party install sources. -export const KIMI_CODE_OFFICIAL_INSTALL_URL = 'https://www.kimi.com/code'; +export function kimiCodeOfficialInstallUrl(): string { + return `${currentKimiProfile().siteBase}/code`; +} // Native install commands, split by platform. Use these for prompt copy and spawn calls only; do not assemble the strings elsewhere. -export const NATIVE_INSTALL_COMMAND_UNIX = `curl -fsSL ${KIMI_CODE_INSTALL_SH_URL} | bash`; -export const NATIVE_INSTALL_COMMAND_WIN = `irm ${KIMI_CODE_INSTALL_PS1_URL} | iex`; +export function nativeInstallCommandUnix(): string { + return `curl -fsSL ${kimiCodeInstallShUrl()} | bash`; +} +export function nativeInstallCommandWin(): string { + return `irm ${kimiCodeInstallPs1Url()} | iex`; +} diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts index daf54f952e9..4ea4a75ec72 100644 --- a/apps/kimi-code/src/tui/banner/banner-provider.ts +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { eq, gte, lt, valid } from 'semver'; -import { KIMI_CODE_TIPS_BANNER_URL } from '#/constant/app'; +import { kimiCodeTipsBannerUrl } from '#/constant/app'; import type { BannerDisplay, BannerState } from '#/tui/types'; import type { BannerDisplayState } from './state'; @@ -315,7 +315,7 @@ export function selectDisplayableBanner({ export class BannerProvider { constructor( private readonly clientVersion: string, - private readonly url: string = KIMI_CODE_TIPS_BANNER_URL, + private readonly url: string = kimiCodeTipsBannerUrl(), ) {} async load( diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index a44b4fab5df..0c573a4accf 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -4,6 +4,7 @@ import { filterModelsByPrefix, getOpenPlatformById, OpenPlatformApiError, + type KimiRegion, type ManagedKimiCodeModelInfo, type ManagedKimiConfigShape, type OpenPlatformDefinition, @@ -13,6 +14,10 @@ import { log } from '@moonshot-ai/kimi-code-sdk'; import type { ChoiceOption } from '../components/dialogs/choice-picker'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { + KIMI_CODE_GLOBAL_PLATFORM_VALUE, + refreshKimiRegion, +} from '#/utils/region'; import type { LoginProgressSpinnerHandle } from '../types'; import { promptApiKey, @@ -30,8 +35,9 @@ export async function handleLoginCommand(host: SlashCommandHost): Promise const platformId = await promptPlatformSelection(host); if (platformId === undefined) return; - if (platformId === 'kimi-code') { - await handleKimiCodeOAuthLogin(host); + if (platformId === 'kimi-code' || platformId === KIMI_CODE_GLOBAL_PLATFORM_VALUE) { + const region: KimiRegion = platformId === KIMI_CODE_GLOBAL_PLATFORM_VALUE ? 'global' : 'mainland-cn'; + await handleKimiCodeOAuthLogin(host, region); return; } @@ -40,7 +46,10 @@ export async function handleLoginCommand(host: SlashCommandHost): Promise await handleOpenPlatformLogin(host, platform); } -async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { +async function handleKimiCodeOAuthLogin( + host: SlashCommandHost, + region: KimiRegion, +): Promise { const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); const alreadyLoggedIn = status.providers.some( (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, @@ -53,12 +62,17 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { }; host.cancelInFlight = cancelLogin; try { + // The facade maps region → profile hosts (env overrides keep priority); + // 'mainland-cn' is passed explicitly too so switching back overrides a + // persisted global login. await host.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, { signal: controller.signal, + region, onDeviceCode: (data) => { spinner = host.showLoginAuthorizationPrompt(data); }, }); + refreshKimiRegion(); spinner?.stop({ ok: true, label: 'Logged in.' }); spinner = undefined; try { @@ -235,6 +249,7 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise availableProviders: updated.providers ?? {}, }); } + refreshKimiRegion(); host.track('logout', { provider: target }); const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index feccd19ce08..ef6717650b4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -17,7 +17,7 @@ import { FEEDBACK_TELEMETRY_EVENT, feedbackIdLine, feedbackSessionLine, - KIMI_CODE_SIGNUP_URL, + kimiCodeSignupUrl, withFeedbackVersionPrefix, } from '../constant/feedback'; import { DEFAULT_OAUTH_PROVIDER_NAME, isManagedUsageProvider } from '../constant/kimi-tui'; @@ -55,7 +55,7 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise { if (providerId === DEFAULT_OAUTH_PROVIDER_NAME) { await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); + // Drop the process-wide region cache with the credential: derived + // endpoints (updates, marketplace, site links, telemetry) must fall back + // to the marker/default profile, not the logged-out region. + refreshKimiRegion(); await host.authFlow.refreshConfigAfterLogout(); await host.authFlow.clearActiveSessionAfterLogout(); return; diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts index a332f70af67..89a51d6c666 100644 --- a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -1,11 +1,25 @@ import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth'; +import { KIMI_CODE_GLOBAL_PLATFORM_VALUE } from '#/utils/region'; + import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; -const PLATFORM_OPTIONS: readonly ChoiceOption[] = [ - { value: 'kimi-code', label: 'Kimi Code (OAuth)' }, - ...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })), -]; +const KIMI_CODE_MAINLAND_CN_OPTION: ChoiceOption = { + value: 'kimi-code', + label: 'Kimi Code (kimi.com/code)', +}; +const KIMI_CODE_GLOBAL_OPTION: ChoiceOption = { + value: KIMI_CODE_GLOBAL_PLATFORM_VALUE, + label: 'Kimi Code (kimi.ai/code)', +}; + +function platformOptions(): readonly ChoiceOption[] { + return [ + KIMI_CODE_MAINLAND_CN_OPTION, + KIMI_CODE_GLOBAL_OPTION, + ...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })), + ]; +} export interface PlatformSelectorOptions { readonly onSelect: (platformId: string) => void; @@ -16,7 +30,7 @@ export class PlatformSelectorComponent extends ChoicePickerComponent { constructor(opts: PlatformSelectorOptions) { super({ title: 'Select a platform', - options: [...PLATFORM_OPTIONS], + options: [...platformOptions()], onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/kimi-code/src/tui/constant/feedback.ts b/apps/kimi-code/src/tui/constant/feedback.ts index 8f2ad7a0f53..f33fc112c75 100644 --- a/apps/kimi-code/src/tui/constant/feedback.ts +++ b/apps/kimi-code/src/tui/constant/feedback.ts @@ -13,7 +13,7 @@ export { FEEDBACK_ISSUE_URL, FEEDBACK_TELEMETRY_EVENT, FEEDBACK_VERSION_PREFIX, - KIMI_CODE_SIGNUP_URL, + kimiCodeSignupUrl, } from '#/constant/app'; export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; diff --git a/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts b/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts index ab6d7280791..3f8e21585a1 100644 --- a/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts +++ b/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts @@ -1,6 +1,6 @@ import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; -import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { kimiCodePluginMarketplaceUrl } from '#/constant/app'; import { computeUpdateStatus, loadPluginMarketplace, @@ -166,7 +166,7 @@ export class PluginUpdateNotifier { // Only the default official catalog can back an "Official Marketplace" // notice — a custom catalog (KIMI_CODE_PLUGIN_MARKETPLACE_URL) may // advertise anything under any id. - if (marketplace.source !== KIMI_CODE_PLUGIN_MARKETPLACE_URL) return; + if (marketplace.source !== kimiCodePluginMarketplaceUrl()) return; const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId); if (entry === undefined) return; const installed = (await session.listPlugins()).find((plugin) => plugin.id === pluginId); diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index 370ab0a9481..e5d8ee41ae6 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -6,6 +6,13 @@ export const THIRD_PARTY_BADGE = 'third-party'; export type PluginTrustLabel = 'official' | 'curated' | 'third-party'; +// Trusted plugin hosts come in .com / .ai region pairs: code.kimi.* is the +// per-region marketplace CDN (cdnBase), cdn.kimi.* the content CDN. Both +// families are trusted regardless of the current region — a zip served by +// either deployment is still an official build. +const CODE_CDN_HOSTS = new Set(['code.kimi.com', 'code.kimi.ai']); +const CONTENT_CDN_HOSTS = new Set(['cdn.kimi.com', 'cdn.kimi.ai']); + /** * Human-readable provenance label for a plugin, suitable for inline display * in `/plugins` overviews and lists. @@ -40,7 +47,7 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { } if ( url.protocol === 'https:' && - url.hostname === 'code.kimi.com' && + CODE_CDN_HOSTS.has(url.hostname) && url.pathname.startsWith('/kimi-code/plugins/curated/') ) { return 'curated'; @@ -84,9 +91,9 @@ export function isOfficialPluginInstall(plugin: PluginSummary): boolean { function isOfficialPluginUrl(url: URL): boolean { if (url.protocol !== 'https:') return false; return ( - (url.hostname === 'code.kimi.com' && + (CODE_CDN_HOSTS.has(url.hostname) && url.pathname.startsWith('/kimi-code/plugins/official/')) || - (url.hostname === 'cdn.kimi.com' && + (CONTENT_CDN_HOSTS.has(url.hostname) && (url.pathname.startsWith('/kimi-computer-use/') || url.pathname.startsWith('/kimi-computer-use-windows/'))) ); diff --git a/apps/kimi-code/src/utils/client-configs.ts b/apps/kimi-code/src/utils/client-configs.ts index 02156aabf84..b1955d8551a 100644 --- a/apps/kimi-code/src/utils/client-configs.ts +++ b/apps/kimi-code/src/utils/client-configs.ts @@ -1,14 +1,14 @@ import { join } from 'node:path'; -import { kimiCodeBaseUrl } from '@moonshot-ai/kimi-code-oauth'; import { z } from 'zod'; import { getCacheDir } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; +import { currentKimiProfile, currentKimiRegion } from '#/utils/region'; /** * Generic client for the public client-configs endpoint: - * `POST {kimiCodeBaseUrl}/client_configs {"name": ""}` returns + * `POST {baseUrl}/client_configs {"name": ""}` returns * `{ name, config: }`, where the payload shape is config-specific * and validated by the caller-supplied schema. * @@ -25,6 +25,19 @@ const CLIENT_CONFIGS_PATH = '/client_configs'; const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const FETCH_TIMEOUT_MS = 5000; +/** The endpoint's API base: the env override keeps winning (custom/internal + envs); otherwise the active region profile, so a global login's token is + not sent to the mainland-China deployment. */ +function clientConfigsBaseUrl(): string { + return (process.env['KIMI_CODE_BASE_URL'] ?? currentKimiProfile().baseUrl).replace(/\/+$/, ''); +} + +/** Cache entries are partitioned by region so a login switch never serves + the other deployment's cached config. */ +function cacheKeyFor(name: string): string { + return `${currentKimiRegion()}:${name}`; +} + export interface ClientConfigFetchOptions { /** Managed OAuth token; sent as Bearer when present. The endpoint is * public, so anonymous fetches work too. */ @@ -49,7 +62,7 @@ const cacheFileEnvelopeSchema = z.object({ function cacheFileFor(name: string, options: ClientConfigFetchOptions): string | undefined { if (options.cacheFile === null) return undefined; if (options.cacheFile !== undefined) return options.cacheFile; - return join(getCacheDir(), 'client-configs', `${name.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}.json`); + return join(getCacheDir(), 'client-configs', `${cacheKeyFor(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_')}.json`); } /** Fresh disk entry, or undefined when missing/stale/invalid. */ @@ -96,7 +109,8 @@ export async function getClientConfig( options: ClientConfigFetchOptions = {}, ): Promise | undefined> { const now = options.now ?? Date.now(); - const hit = cache.get(name); + const key = cacheKeyFor(name); + const hit = cache.get(key); if (hit !== undefined && now - hit.fetchedAt < CONFIG_CACHE_TTL_MS) { return hit.data as z.infer; } @@ -106,13 +120,13 @@ export async function getClientConfig( if (diskHit !== undefined) { // Warm the in-process layer with the original fetch time, so the entry // still expires a day after it was actually fetched. - cache.set(name, diskHit); + cache.set(key, diskHit); return diskHit.data; } } const data = await fetchClientConfig(name, schema, options); if (data === undefined) return undefined; - cache.set(name, { fetchedAt: now, data }); + cache.set(key, { fetchedAt: now, data }); if (file !== undefined) await writeDiskCache(file, data, now); return data; } @@ -136,7 +150,7 @@ export function peekClientConfig( schema: S, now: number = Date.now(), ): z.infer | undefined { - const hit = cache.get(name); + const hit = cache.get(cacheKeyFor(name)); if (hit === undefined || now - hit.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined; const parsed = schema.safeParse(hit.data); return parsed.success ? (parsed.data as z.infer) : undefined; @@ -156,7 +170,7 @@ export async function fetchClientConfig( headers['authorization'] = `Bearer ${options.accessToken}`; } try { - const response = await fetchFn(`${kimiCodeBaseUrl()}${CLIENT_CONFIGS_PATH}`, { + const response = await fetchFn(`${clientConfigsBaseUrl()}${CLIENT_CONFIGS_PATH}`, { method: 'POST', headers, body: JSON.stringify({ name }), @@ -182,6 +196,6 @@ export function resetClientConfigCache(name?: string): void { if (name === undefined) { cache.clear(); } else { - cache.delete(name); + cache.delete(cacheKeyFor(name)); } } diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index 557698111a7..b8c28b78c1f 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -22,8 +22,8 @@ import { } from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; import { - KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, + kimiCodePluginMarketplaceUrl, } from '#/constant/app'; export { @@ -51,7 +51,7 @@ export async function loadPluginMarketplace( options: LoadPluginMarketplaceOptions, ): Promise { const configuredSource = options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; - const source = configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL; + const source = configuredSource ?? kimiCodePluginMarketplaceUrl(); const fetchImpl = options.fetchImpl ?? fetch; let read: { raw: string; location: MarketplaceLocation }; try { diff --git a/apps/kimi-code/src/utils/process/fd-detect.ts b/apps/kimi-code/src/utils/process/fd-detect.ts index f41c5d0a68c..48d5e147c2a 100644 --- a/apps/kimi-code/src/utils/process/fd-detect.ts +++ b/apps/kimi-code/src/utils/process/fd-detect.ts @@ -15,12 +15,11 @@ import { join } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { KIMI_CODE_CDN_BASE } from '#/constant/app'; +import { kimiCodeCdnBase } from '#/constant/app'; import { getBinDir } from '#/utils/paths'; import { resolveCommandPath } from '#/utils/process/resolve-command'; const CANDIDATES = ['fd', 'fdfind']; -const FD_BASE_URL = `${KIMI_CODE_CDN_BASE}/fd`; const DOWNLOAD_TIMEOUT_MS = 120_000; const FD_ARCHIVE_SHA256: Record = { @@ -121,7 +120,7 @@ async function downloadFd(): Promise { const archivePath = join(extractDir, assetName); try { - const downloadUrl = `${FD_BASE_URL}/${assetName}`; + const downloadUrl = `${kimiCodeCdnBase()}/fd/${assetName}`; await downloadFile(downloadUrl, archivePath); verifyArchive(archivePath, expectedSha256); extractArchive(archivePath, extractDir, assetName); diff --git a/apps/kimi-code/src/utils/region.ts b/apps/kimi-code/src/utils/region.ts new file mode 100644 index 00000000000..2b34050c339 --- /dev/null +++ b/apps/kimi-code/src/utils/region.ts @@ -0,0 +1,81 @@ +/** + * Process-wide region cache for the CLI/TUI. + * + * Region decides which deployment (mainland-China .com / international .ai) + * the client's off-session endpoints point at: CDN (updates, plugins, tips), + * site links, telemetry. The OAuth login flow itself does NOT read this — it + * takes explicit hosts; this cache is for everything derived afterwards. + * + * Resolution lives in `@moonshot-ai/kimi-code-oauth` (see `resolveKimiRegion`); + * this module only adds the one thing that package deliberately does not own: + * reading the persisted login's oauth ref (credential key + `oauthHost`) out + * of config.toml, synchronously, via the SDK's safe config reader. First call + * wins; `refreshKimiRegion` re-resolves after login/logout rewrote the oauth + * ref. + */ + +import { loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/kimi-code-sdk'; +import { + KIMI_CODE_OAUTH_KEY, + KIMI_REGION_PROFILES, + resolveKimiRegion, + type KimiRegion, + type KimiRegionProfile, +} from '@moonshot-ai/kimi-code-oauth'; + +// Same value as DEFAULT_OAUTH_PROVIDER_NAME in '#/constant/app' — inlined here +// to keep the import one-directional (constant/app derives URLs from this +// module, so this module must not import back from it). +const MANAGED_KIMI_CODE_PROVIDER_KEY = 'managed:kimi-code'; + +/** Platform-selector value for the global OAuth login entry. */ +export const KIMI_CODE_GLOBAL_PLATFORM_VALUE = 'kimi-code-global'; + +let cached: KimiRegion | undefined; + +export interface PersistedKimiOAuthRef { + readonly key: string; + readonly oauthHost?: string; +} + +/** The oauth ref persisted by a previous login, if any. */ +export function persistedKimiOAuthRef(): PersistedKimiOAuthRef | undefined { + const result = loadRuntimeConfigSafe(resolveConfigPath({})); + // `providers` is always present on a real config load; the `?.` guards + // hosts/tests that hand us a partial config shape. + const oauth = result.config.providers?.[MANAGED_KIMI_CODE_PROVIDER_KEY]?.oauth; + if (oauth === undefined) return undefined; + return { key: oauth.key, oauthHost: oauth.oauthHost }; +} + +/** Region for a no-flag `kimi login` / `kimi acp --login`: a fresh install + follows the resolved region (env/marker/default); the default slot (only + ever a mainland-cn login) re-pins the profile explicitly; a scoped slot — + a global login, or a custom env persisted with only KIMI_CODE_BASE_URL and + no oauthHost — keeps its configured hosts (`undefined`). */ +export function regionForBareLogin(ref: PersistedKimiOAuthRef | undefined): KimiRegion | undefined { + if (ref === undefined) return currentKimiRegion(); + return ref.key === KIMI_CODE_OAUTH_KEY ? 'mainland-cn' : undefined; +} + +export function currentKimiRegion(): KimiRegion { + if (cached === undefined) { + const persisted = persistedKimiOAuthRef(); + cached = resolveKimiRegion({ + configuredOAuthHost: persisted?.oauthHost, + configuredOAuthKey: persisted?.key, + readMarker: process.env['KIMI_CODE_REGION_MARKER'] !== 'off', + }); + } + return cached; +} + +export function currentKimiProfile(): KimiRegionProfile { + return KIMI_REGION_PROFILES[currentKimiRegion()]; +} + +/** Drop the cache and re-resolve. Call after login/logout rewrote config. */ +export function refreshKimiRegion(): KimiRegion { + cached = undefined; + return currentKimiRegion(); +} diff --git a/apps/kimi-code/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts index 25f72ae1ec8..c4fcc286a29 100644 --- a/apps/kimi-code/test/cli/export.test.ts +++ b/apps/kimi-code/test/cli/export.test.ts @@ -14,6 +14,7 @@ import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleExport, registerExportCommand } from '#/cli/sub/export'; +import { refreshKimiRegion } from '#/utils/region'; import type { ExportDeps } from '#/cli/sub/export'; import type { ExportSessionInput, @@ -105,11 +106,16 @@ beforeEach(() => { // Pin the legacy engine so the default-deps cases keep exercising the legacy // SDK harness this suite asserts on; the routing cases below re-stub it. vi.stubEnv('KIMI_CODE_LEGACY_FLAG', '1'); + // Pin region to cn: the telemetry endpoint assertion must not follow the + // dev machine's own login/marker state. + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.kimi.com'); + refreshKimiRegion(); tmp = mkdtempSync(join(tmpdir(), 'kimi-export-')); }); afterEach(() => { vi.unstubAllEnvs(); + refreshKimiRegion(); rmSync(tmp, { recursive: true, force: true }); vi.clearAllMocks(); mocks.harnessGetConfig.mockResolvedValue({ @@ -426,8 +432,14 @@ describe('kimi export', () => { uiMode: 'shell', model: 'k2', sessionId: undefined, + endpoint: expect.any(Function), getAccessToken: expect.any(Function), }); + // The endpoint resolver defers to the active region profile at flush time. + const telemetryOptions = mocks.initializeTelemetry.mock.calls[0]![0] as { + endpoint: () => string; + }; + expect(telemetryOptions.endpoint()).toBe('https://telemetry-logs.kimi.com/v1/event'); expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( mocks.harnessExportSession.mock.invocationCallOrder[0]!, ); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 73b7a22222d..51909bde9a2 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -4,6 +4,7 @@ import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/ki import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runShell } from '#/cli/run-shell'; +import { refreshKimiRegion } from '#/utils/region'; import { captureProcessWrite, ExitCalled, mockProcessExit } from '../helpers/process'; @@ -167,11 +168,16 @@ vi.mock('../../src/utils/process/resolve-command', () => ({ describe('runShell', () => { beforeEach(() => { vi.stubEnv('KIMI_CODE_LEGACY_FLAG', '1'); + // Pin region to cn: the telemetry endpoint assertion below must not + // follow the dev machine's own login/marker state. + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.kimi.com'); + refreshKimiRegion(); }); afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); + refreshKimiRegion(); mocks.harnessGetConfig.mockResolvedValue({ providers: {}, defaultModel: 'k2', @@ -331,8 +337,14 @@ describe('runShell', () => { uiMode: 'shell', model: 'k2', sessionId: undefined, + endpoint: expect.any(Function), getAccessToken: expect.any(Function), }); + // The endpoint resolver defers to the active region profile at flush time. + const telemetryOptions = mocks.initializeTelemetry.mock.calls[0]![0] as { + endpoint: () => string; + }; + expect(telemetryOptions.endpoint()).toBe('https://telemetry-logs.kimi.com/v1/event'); expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); const [, harness, startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; diff --git a/apps/kimi-code/test/cli/update/cdn.test.ts b/apps/kimi-code/test/cli/update/cdn.test.ts index 7eba81080cd..bbfaf965b11 100644 --- a/apps/kimi-code/test/cli/update/cdn.test.ts +++ b/apps/kimi-code/test/cli/update/cdn.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn'; -import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { kimiCodeCdnLatestJsonUrl, kimiCodeCdnLatestUrl } from '#/constant/app'; function mockFetchOk(body: string): typeof fetch { return vi.fn(async () => ({ @@ -54,7 +54,7 @@ describe('fetchLatestVersionFromCdn', () => { const f = mockFetchOk(' 0.5.0\n'); await expect(fetchLatestVersionFromCdn(f)).resolves.toBe('0.5.0'); expect(f).toHaveBeenCalledWith( - KIMI_CODE_CDN_LATEST_URL, + kimiCodeCdnLatestUrl(), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); @@ -83,7 +83,7 @@ describe('fetchLatestVersionFromCdn', () => { describe('fetchLatestFromCdn', () => { it('parses latest.json and returns the manifest', async () => { - const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY } }); + const f = mockRoutedFetch({ [kimiCodeCdnLatestJsonUrl()]: { body: MANIFEST_BODY } }); await expect(fetchLatestFromCdn(f)).resolves.toEqual({ latest: '2.0.0', manifest: { @@ -97,7 +97,7 @@ describe('fetchLatestFromCdn', () => { }, }); expect(f).toHaveBeenCalledWith( - KIMI_CODE_CDN_LATEST_JSON_URL, + kimiCodeCdnLatestJsonUrl(), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(f).toHaveBeenCalledTimes(1); @@ -111,7 +111,7 @@ describe('fetchLatestFromCdn', () => { rollout: [], futureField: { nested: true }, }); - const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } }); + const f = mockRoutedFetch({ [kimiCodeCdnLatestJsonUrl()]: { body } }); const result = await fetchLatestFromCdn(f); expect(result.manifest).toEqual({ version: '2.0.0', @@ -125,7 +125,7 @@ describe('fetchLatestFromCdn', () => { version: '2.0.0', publishedAt: '2026-06-12T00:00:00.000Z', }); - const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } }); + const f = mockRoutedFetch({ [kimiCodeCdnLatestJsonUrl()]: { body } }); const result = await fetchLatestFromCdn(f); expect(result.manifest?.rollout).toEqual([]); }); @@ -155,8 +155,8 @@ describe('fetchLatestFromCdn', () => { for (const [name, route] of fallbackCases) { it(`falls back to plain /latest when ${name}`, async () => { const f = mockRoutedFetch({ - [KIMI_CODE_CDN_LATEST_JSON_URL]: route, - [KIMI_CODE_CDN_LATEST_URL]: { body: '1.9.0\n' }, + [kimiCodeCdnLatestJsonUrl()]: route, + [kimiCodeCdnLatestUrl()]: { body: '1.9.0\n' }, }); await expect(fetchLatestFromCdn(f)).resolves.toEqual({ latest: '1.9.0', @@ -167,16 +167,16 @@ describe('fetchLatestFromCdn', () => { it('throws when both latest.json and plain /latest fail', async () => { const f = mockRoutedFetch({ - [KIMI_CODE_CDN_LATEST_JSON_URL]: { status: 500 }, - [KIMI_CODE_CDN_LATEST_URL]: { status: 500 }, + [kimiCodeCdnLatestJsonUrl()]: { status: 500 }, + [kimiCodeCdnLatestUrl()]: { status: 500 }, }); await expect(fetchLatestFromCdn(f)).rejects.toThrow(/HTTP 500/); }); it('propagates the plain /latest error when the fallback also breaks', async () => { const f = mockRoutedFetch({ - [KIMI_CODE_CDN_LATEST_JSON_URL]: new Error('json down'), - [KIMI_CODE_CDN_LATEST_URL]: { body: 'not-a-version' }, + [kimiCodeCdnLatestJsonUrl()]: new Error('json down'), + [kimiCodeCdnLatestUrl()]: { body: 'not-a-version' }, }); await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/); }); @@ -185,14 +185,14 @@ describe('fetchLatestFromCdn', () => { vi.useFakeTimers(); try { const f = vi.fn(async (input: string | URL, init?: RequestInit) => { - if (String(input) === KIMI_CODE_CDN_LATEST_JSON_URL) { + if (String(input) === kimiCodeCdnLatestJsonUrl()) { return new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => { reject(new Error('aborted')); }, { once: true }); }); } - if (String(input) === KIMI_CODE_CDN_LATEST_URL) { + if (String(input) === kimiCodeCdnLatestUrl()) { return { ok: true, status: 200, text: async () => '1.9.0\n' }; } return { ok: false, status: 404, text: async () => '' }; diff --git a/apps/kimi-code/test/cli/update/native-manifest.test.ts b/apps/kimi-code/test/cli/update/native-manifest.test.ts index 32a930db1cb..3c4df548fd6 100644 --- a/apps/kimi-code/test/cli/update/native-manifest.test.ts +++ b/apps/kimi-code/test/cli/update/native-manifest.test.ts @@ -6,7 +6,7 @@ import { nativeManifestUrl, selectPlatformEntry, } from '#/cli/update/native-manifest'; -import { KIMI_CODE_CDN_BINARIES_BASE } from '#/constant/app'; +import { kimiCodeCdnBinariesBase } from '#/constant/app'; const VERSION = '0.7.0'; @@ -144,9 +144,9 @@ describe('selectPlatformEntry', () => { describe('url helpers', () => { it('builds the manifest and binary URLs from the binaries base', () => { - expect(nativeManifestUrl(VERSION)).toBe(`${KIMI_CODE_CDN_BINARIES_BASE}/${VERSION}/manifest.json`); + expect(nativeManifestUrl(VERSION)).toBe(`${kimiCodeCdnBinariesBase()}/${VERSION}/manifest.json`); expect(nativeBinaryUrl(VERSION, 'kimi-code-win32-x64.zip')).toBe( - `${KIMI_CODE_CDN_BINARIES_BASE}/${VERSION}/kimi-code-win32-x64.zip`, + `${kimiCodeCdnBinariesBase()}/${VERSION}/kimi-code-win32-x64.zip`, ); }); }); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index a37c889f418..fcc57ad6b4a 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -9,7 +9,7 @@ import { readUpdateInstallState, writeUpdateInstallState, } from '#/cli/update/install-state'; -import { runUpdatePreflight } from '#/cli/update/preflight'; +import { installCommandFor, runUpdatePreflight } from '#/cli/update/preflight'; import { promptForInstallChoice } from '#/cli/update/prompt'; import type * as PromptModule from '#/cli/update/prompt'; import { refreshUpdateCache } from '#/cli/update/refresh'; @@ -23,6 +23,7 @@ import { type UpdateManifest, } from '#/cli/update/types'; import type { TuiConfig } from '#/tui/config'; +import { refreshKimiRegion } from '#/utils/region'; const mocks = vi.hoisted(() => ({ readUpdateCache: vi.fn(), @@ -237,6 +238,10 @@ describe('runUpdatePreflight', () => { // regardless of the host environment (the flag bypasses batch holds). // Tests that exercise the bypass opt back in with `vi.stubEnv(..., '1')`. vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', ''); + // Pin the region to cn so address assertions don't follow the dev + // machine's own login/marker state; global tests override below. + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.kimi.com'); + refreshKimiRegion(); mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.writeUpdateInstallState.mockResolvedValue(undefined); mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); @@ -249,7 +254,7 @@ describe('runUpdatePreflight', () => { mocks.resolveCommandPath.mockImplementation((cmd: string) => cmd); }); - afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); }); + afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); refreshKimiRegion(); }); it('skips all update work when KIMI_CODE_NO_AUTO_UPDATE is set', async () => { vi.stubEnv('KIMI_CODE_NO_AUTO_UPDATE', '1'); @@ -546,6 +551,31 @@ describe('runUpdatePreflight', () => { } }); + it('global region: derives install commands and site links from the .ai profile', async () => { + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.kimi.ai'); + refreshKimiRegion(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + // Native updates self-spawn the staged downloader silently, so the + // region surface there is the manual install command text. + expect(installCommandFor('native', '0.5.0', 'win32')).toBe( + 'irm https://code.kimi.ai/kimi-code/install.ps1 | iex', + ); + + mocks.detectInstallSource.mockResolvedValue('homebrew'); + const brew = captureOutput(); + await expect(runUpdatePreflight('0.4.0', brew.options)).resolves.toBe('continue'); + expect(brew.stdout.join('')).toContain('https://www.kimi.ai/code'); + expect(mocks.spawn).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + refreshKimiRegion(); + } + }); + it('unsupported: prints fallback npm command', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index abfde3668e4..f7aef8e7685 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -195,6 +195,33 @@ describe('plugins selector dialogs', () => { })).toBe('third-party'); }); + it('trusts the .ai Kimi plugin hosts with the same path rules', () => { + const labelFor = (originalSource: string) => + pluginTrustLabel({ + id: 'demo', + displayName: 'Demo', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'zip-url', + originalSource, + }); + // code.kimi.ai mirrors the cdnBase rules; cdn.kimi.ai the content-CDN ones. + expect(labelFor('https://code.kimi.ai/kimi-code/plugins/official/kimi-datasource.zip')).toBe('official'); + expect(labelFor('https://code.kimi.ai/kimi-code/plugins/curated/superpowers.zip')).toBe('curated'); + expect(labelFor('https://cdn.kimi.ai/kimi-computer-use/latest/kimi-cu-plugin.zip')).toBe('official'); + expect(labelFor('https://cdn.kimi.ai/kimi-computer-use-windows/latest/kimi-cu-win-plugin.zip')).toBe('official'); + // Non-plugin paths on the .ai hosts, and lookalike hosts, stay third-party. + expect(labelFor('https://code.kimi.ai/demo.zip')).toBe('third-party'); + expect(labelFor('https://cdn.kimi.ai/unrelated/plugin.zip')).toBe('third-party'); + expect(labelFor('https://code.kimi.ai.example.test/kimi-code/plugins/official/x.zip')).toBe('third-party'); + }); + it('recognizes installed plugins by official provenance', () => { const base = { id: 'kimi-datasource', @@ -214,6 +241,11 @@ describe('plugins selector dialogs', () => { source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', })).toBe(true); + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'https://code.kimi.ai/kimi-code/plugins/official/kimi-datasource.zip', + })).toBe(true); expect(isOfficialPluginInstall({ ...base, id: 'kimi-cu', @@ -272,6 +304,16 @@ describe('plugins selector dialogs', () => { 'https://cdn.kimi.com/kimi-computer-use-windows/latest/kimi-cu-win-plugin.zip', ), ).toBe(true); + // The .ai region family follows the same path rules. + expect(isOfficialPluginSource('https://code.kimi.ai/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true); + expect(isOfficialPluginSource('https://cdn.kimi.ai/kimi-computer-use/latest/kimi-cu-plugin.zip')).toBe(true); + expect( + isOfficialPluginSource( + 'https://cdn.kimi.ai/kimi-computer-use-windows/latest/kimi-cu-win-plugin.zip', + ), + ).toBe(true); + expect(isOfficialPluginSource('https://code.kimi.ai/kimi-code/plugins/curated/superpowers.zip')).toBe(false); + expect(isOfficialPluginSource('https://cdn.kimi.ai/unrelated/plugin.zip')).toBe(false); // Curated and other Kimi CDN paths are not "official" for the install gate. expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false); expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false); diff --git a/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts b/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts index ca66af33f23..c7bc0c5f665 100644 --- a/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts +++ b/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; -import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { kimiCodePluginMarketplaceUrl } from '#/constant/app'; import { PluginUpdateNotifier, type PluginUpdateNotifierSession, @@ -49,7 +49,7 @@ function makeMarketplaceEntry( function makeMarketplace(version = '3.4.0'): PluginMarketplace { return { - source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + source: kimiCodePluginMarketplaceUrl(), plugins: [makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', version)], }; } @@ -268,7 +268,7 @@ describe('PluginUpdateNotifier', () => { it('keeps every notified plugin when a turn uses two outdated plugins', async () => { const harness = makeHarness({ marketplace: { - source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + source: kimiCodePluginMarketplaceUrl(), plugins: [ makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', '3.4.0'), makeMarketplaceEntry('another-plugin', 'Another Plugin', '2.0.0'), diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 1634c26bb62..51dc104b944 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -20,7 +20,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; -import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { kimiCodePluginMarketplaceUrl } from '#/constant/app'; import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; import { AgentSwarmProgressComponent, @@ -6959,7 +6959,7 @@ command = "vim" 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', ); }); - expect(globalThis.fetch).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL); + expect(globalThis.fetch).toHaveBeenCalledWith(kimiCodePluginMarketplaceUrl()); } finally { vi.stubGlobal('fetch', originalFetch); } diff --git a/apps/kimi-code/test/utils/client-configs.test.ts b/apps/kimi-code/test/utils/client-configs.test.ts index f97effa8fb9..0290c3c7e5b 100644 --- a/apps/kimi-code/test/utils/client-configs.test.ts +++ b/apps/kimi-code/test/utils/client-configs.test.ts @@ -10,6 +10,7 @@ import { peekClientConfig, resetClientConfigCache, } from '#/utils/client-configs'; +import { refreshKimiRegion } from '#/utils/region'; import { z } from 'zod'; const configSchema = z.object({ @@ -354,3 +355,50 @@ describe('getClientConfig disk cache', () => { expect(result).toEqual(CONFIG); }); }); + +describe('region awareness', () => { + beforeEach(() => { + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.kimi.ai'); + refreshKimiRegion(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + refreshKimiRegion(); + }); + + it('fetches from the active region profile and partitions the cache by region', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const data = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(data).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining('https://api.kimi.ai/coding/v1/client_configs'), + expect.anything(), + ); + expect(peekClientConfig('estimated_cache_duration', configSchema)).toEqual(CONFIG); + + // A region switch must not serve the other deployment's cached entry. + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.kimi.com'); + refreshKimiRegion(); + expect(peekClientConfig('estimated_cache_duration', configSchema)).toBeUndefined(); + }); + + it('keeps honoring the KIMI_CODE_BASE_URL override ahead of the profile', async () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://env-api.example.com'); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + await fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining('https://env-api.example.com/client_configs'), + expect.anything(), + ); + }); +}); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 5bc803ec3df..65ac4b0b9db 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -6,8 +6,8 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { - KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, + kimiCodePluginMarketplaceUrl, } from '#/constant/app'; import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace'; @@ -248,18 +248,18 @@ describe('loadPluginMarketplace', () => { const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', - source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + source: kimiCodePluginMarketplaceUrl(), fetchImpl, }); - expect(fetchImpl).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL); + expect(fetchImpl).toHaveBeenCalledWith(kimiCodePluginMarketplaceUrl()); expect(marketplace.plugins[0]).toEqual( expect.objectContaining({ id: 'kimi-datasource', displayName: 'Kimi Datasource', source: new URL( './official/kimi-datasource.zip', - KIMI_CODE_PLUGIN_MARKETPLACE_URL, + kimiCodePluginMarketplaceUrl(), ).toString(), }), ); @@ -275,7 +275,7 @@ describe('loadPluginMarketplace', () => { try { const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', fetchImpl }); - expect(fetchImpl).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL); + expect(fetchImpl).toHaveBeenCalledWith(kimiCodePluginMarketplaceUrl()); expect(marketplace.source).toBe(join(REPO_ROOT, 'plugins/marketplace.json')); expect(marketplace.plugins).toContainEqual( expect.objectContaining({ @@ -299,7 +299,7 @@ describe('loadPluginMarketplace', () => { await expect(loadPluginMarketplace({ workDir: '/tmp/work', - source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + source: kimiCodePluginMarketplaceUrl(), fetchImpl, })).rejects.toThrow(/fetch failed/); }); diff --git a/apps/kimi-code/test/utils/region.test.ts b/apps/kimi-code/test/utils/region.test.ts new file mode 100644 index 00000000000..dfb8c25a5f4 --- /dev/null +++ b/apps/kimi-code/test/utils/region.test.ts @@ -0,0 +1,85 @@ +import { 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 { currentKimiRegion, refreshKimiRegion, regionForBareLogin } from '#/utils/region'; + +const originalEnv = { ...process.env }; + +let home: string; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'kimi-region-test-')); + process.env['KIMI_CODE_HOME'] = home; + delete process.env['KIMI_CODE_OAUTH_HOST']; + delete process.env['KIMI_OAUTH_HOST']; + delete process.env['KIMI_CODE_REGION_MARKER']; + refreshKimiRegion(); +}); + +afterEach(() => { + process.env = { ...originalEnv }; + refreshKimiRegion(); + rmSync(home, { recursive: true, force: true }); +}); + +describe('currentKimiRegion', () => { + it('follows the install-channel marker before the first login', () => { + writeFileSync(join(home, 'region'), 'global\n'); + expect(refreshKimiRegion()).toBe('global'); + expect(currentKimiRegion()).toBe('global'); + }); + + it('ignores the marker when KIMI_CODE_REGION_MARKER=off (embedded server)', () => { + writeFileSync(join(home, 'region'), 'global\n'); + process.env['KIMI_CODE_REGION_MARKER'] = 'off'; + expect(refreshKimiRegion()).toBe('mainland-cn'); + }); + + it('still honors a persisted global login when the marker is opted out', () => { + writeFileSync(join(home, 'region'), 'global\n'); + writeFileSync( + join(home, 'config.toml'), + [ + '[providers."managed:kimi-code"]', + 'type = "kimi"', + '', + '[providers."managed:kimi-code".oauth]', + 'storage = "file"', + 'key = "oauth/kimi-code-env-0123456789abcdef"', + 'oauthHost = "https://auth.kimi.ai"', + '', + ].join('\n'), + ); + process.env['KIMI_CODE_REGION_MARKER'] = 'off'; + expect(refreshKimiRegion()).toBe('global'); + }); +}); + +describe('regionForBareLogin', () => { + it('follows the resolved region for a fresh install (no persisted ref)', () => { + expect(regionForBareLogin(undefined)).toBe('mainland-cn'); + writeFileSync(join(home, 'region'), 'global\n'); + refreshKimiRegion(); + expect(regionForBareLogin(undefined)).toBe('global'); + }); + + it('re-pins mainland-cn for the default slot', () => { + expect(regionForBareLogin({ key: 'oauth/kimi-code' })).toBe('mainland-cn'); + }); + + it('keeps the configured environment for a scoped slot without a persisted host', () => { + expect(regionForBareLogin({ key: 'oauth/kimi-code-env-0123456789abcdef' })).toBeUndefined(); + }); + + it('keeps the persisted environment for a global login', () => { + expect( + regionForBareLogin({ + key: 'oauth/kimi-code-env-0123456789abcdef', + oauthHost: 'https://auth.kimi.ai', + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/vscode/webview-ui/src/components/LoginScreen.tsx b/apps/vscode/webview-ui/src/components/LoginScreen.tsx index 07fff46b450..8826e362de2 100644 --- a/apps/vscode/webview-ui/src/components/LoginScreen.tsx +++ b/apps/vscode/webview-ui/src/components/LoginScreen.tsx @@ -70,6 +70,11 @@ export function LoginScreen({ onLoginSuccess, onSkip }: LoginScreenProps) { }; const handleSubscribe = () => { + // TODO(region-split): derive this from the region profile's siteBase + // (`https://www.kimi.ai/code` for overseas logins). The webview cannot + // resolve the region itself — @moonshot-ai/kimi-code-oauth is not a + // webview dependency and its region resolver is Node-only — so the + // extension host needs to hand the site URL over the bridge first. window.open("https://www.kimi.com/code", "_blank"); setShowSubscribeDialog(false); }; diff --git a/packages/agent-core-v2/src/app/auth/auth.ts b/packages/agent-core-v2/src/app/auth/auth.ts index 457d0e75eab..19e6dedb2ae 100644 --- a/packages/agent-core-v2/src/app/auth/auth.ts +++ b/packages/agent-core-v2/src/app/auth/auth.ts @@ -6,6 +6,7 @@ import type { KimiOAuthLoginResult, KimiOAuthLogoutResult, KimiOAuthTokenRef, + KimiRegion, } from '@moonshot-ai/kimi-code-oauth'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { Error2 } from '#/_base/errors/errors'; @@ -26,10 +27,14 @@ export interface AuthStatus { readonly provider?: string; } +export interface OAuthLoginOptions { + readonly region?: KimiRegion; +} + export interface IOAuthService { readonly _serviceBrand: undefined; - startLogin(provider?: string): Promise; + startLogin(provider?: string, options?: OAuthLoginOptions): Promise; getFlow(provider?: string): OAuthFlowSnapshot | undefined; cancelLogin(provider?: string): Promise; logout(provider?: string): Promise; @@ -39,6 +44,7 @@ export interface IOAuthService { getManagedUserInfo(provider?: string): Promise; resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined; getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise; + getRegion(): KimiRegion; } export const IOAuthService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 1de0585d999..7960271daa2 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -6,6 +6,7 @@ import { KIMI_CODE_PROVIDER_NAME, KimiOAuthToolkit, kimiCodeBaseUrl, + kimiRegionLoginHosts, OAuthError, applyManagedKimiCodeConfig, clearManagedKimiCodeConfig, @@ -13,10 +14,12 @@ import { resolveKimiCodeLoginAuth, resolveKimiCodeOAuthRef, resolveKimiCodeRuntimeAuth, + resolveKimiRegion, type AuthManagedUserInfoResult, type AuthManagedUsageResult, type BearerTokenProvider, type DeviceAuthorization, + type KimiRegion, type ManagedKimiConfigShape, } from '@moonshot-ai/kimi-code-oauth'; import type { @@ -68,6 +71,7 @@ import { IAuthSummaryService, IOAuthService, IOAuthToolkit, + type OAuthLoginOptions, } from './auth'; const TERMINAL_RETENTION_MS = 5 * 60 * 1000; @@ -101,6 +105,7 @@ export class OAuthService extends Disposable implements IOAuthService { @ITelemetryService private readonly telemetry: ITelemetryService, @ILogService private readonly log: ILogService, @IEventService private readonly events: IEventService, + @IBootstrapService private readonly bootstrap: IBootstrapService, ) { super(); this._register(providerService.onDidChangeProviders((event) => { @@ -108,9 +113,12 @@ export class OAuthService extends Disposable implements IOAuthService { })); } - async startLogin(provider = KIMI_CODE_PROVIDER_NAME): Promise { + async startLogin( + provider = KIMI_CODE_PROVIDER_NAME, + options: OAuthLoginOptions = {}, + ): Promise { this.log.info('oauth startLogin: enter', { provider }); - const loginAuth = this.resolveLoginAuth(provider); + const loginAuth = this.resolveLoginAuth(provider, options.region); this.log.info('oauth startLogin: resolved login auth', { provider, hasOAuthRef: loginAuth.oauthRef !== undefined, @@ -390,7 +398,22 @@ export class OAuthService extends Disposable implements IOAuthService { }; } - private resolveLoginAuth(provider: string): { + getRegion(): KimiRegion { + const oauth = this.providerService.get(KIMI_CODE_PROVIDER_NAME)?.oauth; + return resolveKimiRegion({ + configuredOAuthHost: oauth?.oauthHost, + configuredOAuthKey: oauth?.key, + readMarker: + (this.bootstrap.getEnv('KIMI_CODE_REGION_MARKER') ?? + process.env['KIMI_CODE_REGION_MARKER']) !== 'off', + homeDir: this.bootstrap.homeDir, + }); + } + + private resolveLoginAuth( + provider: string, + region?: KimiRegion, + ): { readonly oauthRef: OAuthRef | undefined; readonly baseUrl: string | undefined; readonly oauthHost: string | undefined; @@ -399,9 +422,12 @@ export class OAuthService extends Disposable implements IOAuthService { if (provider !== KIMI_CODE_PROVIDER_NAME) { return { oauthRef: config?.oauth, baseUrl: undefined, oauthHost: undefined }; } + const hosts = region === undefined ? undefined : kimiRegionLoginHosts(region); const loginAuth = resolveKimiCodeLoginAuth({ configuredBaseUrl: config?.baseUrl, configuredOAuthRef: config?.oauth, + requestedBaseUrl: hosts?.baseUrl, + requestedOAuthHost: hosts?.oauthHost, }); const oauthRef = loginAuth.oauthRef ?? diff --git a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts index 484b073dabc..cb31534da0b 100644 --- a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts +++ b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { isoDateTimeSchema } from '#/_base/utils/isoDateTime'; +import { kimiRegionSchema } from '@moonshot-ai/kimi-code-oauth'; export const oauthFlowStatusEnum = z.enum([ 'pending', @@ -64,6 +65,11 @@ export const oauthLogoutResponseSchema = z.object({ }); export type OAuthLogoutResponse = z.infer; +export const oauthRegionResultSchema = z.object({ + region: kimiRegionSchema, +}); +export type OAuthRegionResult = z.infer; + const providerRefreshChangeSchema = z.object({ provider_id: z.string().min(1), provider_name: z.string().min(1), diff --git a/packages/agent-core-v2/src/app/capability/capabilityService.ts b/packages/agent-core-v2/src/app/capability/capabilityService.ts index 6b38026a1f4..c89afba6084 100644 --- a/packages/agent-core-v2/src/app/capability/capabilityService.ts +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -1,5 +1,7 @@ import { homedir } from 'node:os'; +import { KIMI_CODE_PROVIDER_NAME, resolveKimiRegion } from '@moonshot-ai/kimi-code-oauth'; + import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Disposable } from '#/_base/di/lifecycle'; @@ -8,6 +10,7 @@ import { ILogService } from '#/_base/log/log'; import { Error2 } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IPluginService } from '#/app/plugin/plugin'; +import { IProviderService } from '#/kosong/provider/provider'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { ICapabilityService } from './capability'; @@ -49,6 +52,7 @@ export class CapabilityService extends Disposable implements ICapabilityService @IPluginService plugins: IPluginService, @IHostProcessService hostProcess: IHostProcessService, @ILogService private readonly log: ILogService, + @IProviderService providers: IProviderService, entriesOverride?: readonly CapabilityEntry[], ) { super(); @@ -62,6 +66,17 @@ export class CapabilityService extends Disposable implements ICapabilityService userHomeDir: homedir(), plugins, hostProcess, + resolveRegion: () => { + const oauth = providers.get(KIMI_CODE_PROVIDER_NAME)?.oauth; + return resolveKimiRegion({ + configuredOAuthHost: oauth?.oauthHost, + configuredOAuthKey: oauth?.key, + readMarker: + (bootstrap.getEnv('KIMI_CODE_REGION_MARKER') ?? + process.env['KIMI_CODE_REGION_MARKER']) !== 'off', + homeDir: bootstrap.homeDir, + }); + }, }; this.entries = new Map([ ['kimi-cu', createKimiCuEntry(ctx)], diff --git a/packages/agent-core-v2/src/app/capability/entries/context.ts b/packages/agent-core-v2/src/app/capability/entries/context.ts index 01a189ca5db..cee1863994a 100644 --- a/packages/agent-core-v2/src/app/capability/entries/context.ts +++ b/packages/agent-core-v2/src/app/capability/entries/context.ts @@ -1,3 +1,5 @@ +import type { KimiRegion } from '@moonshot-ai/kimi-code-oauth'; + import type { IPluginService } from '#/app/plugin/plugin'; import type { IHostProcessService } from '#/os/interface/hostProcess'; @@ -13,4 +15,5 @@ export interface CapabilityEntryContext { readonly webbridgeBaseUrl?: string; readonly detectProbeTimeoutMs?: number; readonly commandTimeoutMs?: number; + readonly resolveRegion?: () => KimiRegion | Promise; } diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts index e0153cf7b1f..7d44d8a0154 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts @@ -3,6 +3,8 @@ import { access, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/ import { tmpdir } from 'node:os'; import path from 'node:path'; +import { kimiCdnContentUrl } from '@moonshot-ai/kimi-code-oauth'; + import { downloadToFile, runCommand } from '../host'; import type { CapabilityDetectResult, @@ -12,18 +14,8 @@ import type { } from '../types'; import type { CapabilityEntryContext } from './context'; -const MAC_PLUGIN = { - id: 'kimi-cu', - zipUrl: 'https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip', -} as const; -const WINDOWS_PLUGIN = { - id: 'kimi-cu-win', - zipUrl: - 'https://cdn.kimi.com/kimi-computer-use-windows/latest/kimi-cu-win-plugin.zip', -} as const; -const APP_ZIP_URL = 'https://cdn.kimi.com/kimi-computer-use/latest/KimiCU.app.zip'; -const WINDOWS_SETUP_URL = - 'https://cdn.kimi.com/kimi-computer-use-windows/latest/setup_windows.ps1'; +const MAC_PLUGIN_ID = 'kimi-cu'; +const WINDOWS_PLUGIN_ID = 'kimi-cu-win'; const APP_BUNDLE = 'KimiCU.app'; const LAUNCHD_LABEL = 'ai.kimi.cu.service'; const COMMAND_TIMEOUT_MS = 30_000; @@ -54,6 +46,20 @@ interface PluginLayerConfig { readonly zipUrl: string; } +function macPlugin(): PluginLayerConfig { + return { + id: MAC_PLUGIN_ID, + zipUrl: kimiCdnContentUrl('kimi-computer-use/latest/kimi-cu-plugin.zip'), + }; +} + +function windowsPlugin(): PluginLayerConfig { + return { + id: WINDOWS_PLUGIN_ID, + zipUrl: kimiCdnContentUrl('kimi-computer-use-windows/latest/kimi-cu-win-plugin.zip'), + }; +} + interface PermissionStatus { readonly accessibility: boolean; readonly screenRecording: boolean; @@ -302,7 +308,7 @@ function createMacKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry { async function detect(): Promise { const steps: CapabilityStep[] = []; - const plugin = await detectPluginLayer(ctx, MAC_PLUGIN); + const plugin = await detectPluginLayer(ctx, macPlugin()); steps.push(plugin.step); if ((await legacyMcpFile()) !== undefined) { @@ -417,7 +423,7 @@ function createMacKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry { .every((step) => step.state === 'ok'); report('plugin'); - await installPluginLayer(ctx, MAC_PLUGIN); + await installPluginLayer(ctx, macPlugin()); if (await removeLegacyMcpRegistration(legacyMcpBefore).catch(() => false)) { report('mcp-config'); @@ -430,7 +436,7 @@ function createMacKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry { report('download', 0); const zipPath = path.join(workDir, 'KimiCU.app.zip'); await downloadToFile( - APP_ZIP_URL, + kimiCdnContentUrl('kimi-computer-use/latest/KimiCU.app.zip'), zipPath, (percent) => { report('download', percent); @@ -487,7 +493,7 @@ function createMacKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry { return { id: 'kimi-cu', - pluginId: MAC_PLUGIN.id, + pluginId: MAC_PLUGIN_ID, displayName: 'Kimi Computer Use', description: 'macOS GUI automation in the background — read app UIs and click, type, scroll, and drag without taking over your mouse or foregrounding apps.', @@ -590,7 +596,7 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry async function detect(): Promise { const [plugin, runtime] = await Promise.all([ - detectPluginLayer(ctx, WINDOWS_PLUGIN), + detectPluginLayer(ctx, windowsPlugin()), detectRuntimeStep(), ]); return { @@ -616,7 +622,7 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry if (installPlugin) { report('plugin'); try { - await installPluginLayer(ctx, WINDOWS_PLUGIN); + await installPluginLayer(ctx, windowsPlugin()); } catch (error) { if ( typeof error !== 'object' || @@ -639,7 +645,7 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry const setupPath = path.join(workDir, 'setup_windows.ps1'); report('download', 0); await downloadToFile( - WINDOWS_SETUP_URL, + kimiCdnContentUrl('kimi-computer-use-windows/latest/setup_windows.ps1'), setupPath, (percent) => { report('download', percent); @@ -684,7 +690,7 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry return { id: 'kimi-cu', - pluginId: WINDOWS_PLUGIN.id, + pluginId: WINDOWS_PLUGIN_ID, displayName: 'Kimi Computer Use for Windows', description: 'Windows GUI automation — read app UIs and click, type, scroll, and drag in desktop apps.', diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts index dbe13b597d6..ebf2b47ea20 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts @@ -3,6 +3,12 @@ import { access, chmod, mkdir, mkdtemp, rename, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { + kimiCdnContentUrl, + kimiRegionProfile, + resolveKimiRegion, +} from '@moonshot-ai/kimi-code-oauth'; + import { downloadToFile, runCommand } from '../host'; import type { CapabilityDetectResult, @@ -13,9 +19,8 @@ import type { import type { CapabilityEntryContext } from './context'; const PLUGIN_ID = 'kimi-webbridge'; -const PLUGIN_ZIP_URL = - 'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip'; -const BINARY_CDN_BASE = 'https://cdn.kimi.com/webbridge/latest/releases'; +const PLUGIN_ZIP_PATH = 'plugins/official/kimi-webbridge.zip'; +const BINARY_CDN_PATH = 'webbridge/latest/releases'; const DEFAULT_DAEMON_BASE_URL = 'http://127.0.0.1:10086'; const STATUS_TIMEOUT_MS = 1_500; const START_TIMEOUT_MS = 30_000; @@ -217,7 +222,10 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit } report('skill'); - const summary = await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL }); + const region = (await ctx.resolveRegion?.()) ?? resolveKimiRegion(); + const summary = await ctx.plugins.installPlugin({ + source: `${kimiRegionProfile(region).cdnBase}/${PLUGIN_ZIP_PATH}`, + }); if (!summary.enabled) { await ctx.plugins.setPluginEnabled({ id: PLUGIN_ID, enabled: true }); } @@ -242,7 +250,7 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit asset: string, ): Promise { report('download', 0); - const url = `${BINARY_CDN_BASE}/${asset}`; + const url = kimiCdnContentUrl(`${BINARY_CDN_PATH}/${asset}`); const staging = path.join( tmpdir(), `kimi-webbridge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ctx.platform === 'win32' ? '.exe' : ''}`, diff --git a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts index 4dde4606d0b..4b731373970 100644 --- a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts +++ b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts @@ -84,6 +84,10 @@ export class CloudAppender implements ITelemetryAppender { storage: options.storage, deviceId: options.deviceId, endpoint: options.endpoint, + homeDir: options.bootstrap.homeDir, + readMarker: + (options.bootstrap.getEnv('KIMI_CODE_REGION_MARKER') ?? + process.env['KIMI_CODE_REGION_MARKER']) !== 'off', getAccessToken: options.getAccessToken, fetchImpl: options.fetchImpl, retryBackoffsMs: options.retryBackoffsMs, diff --git a/packages/agent-core-v2/src/app/telemetry/cloudTransport.ts b/packages/agent-core-v2/src/app/telemetry/cloudTransport.ts index 77349824cd5..6fe3fbb8417 100644 --- a/packages/agent-core-v2/src/app/telemetry/cloudTransport.ts +++ b/packages/agent-core-v2/src/app/telemetry/cloudTransport.ts @@ -1,5 +1,11 @@ import { randomBytes } from 'node:crypto'; +import { + KIMI_REGION_PROFILES, + kimiRegionProfile, + resolveKimiRegion, +} from '@moonshot-ai/kimi-code-oauth'; + import { isAbortError } from '#/_base/utils/abort'; import type { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -31,6 +37,12 @@ export interface CloudTransportOptions { readonly storage: IFileSystemStorageService; readonly deviceId: string; readonly endpoint?: string; + /** Bootstrapped home for the default endpoint's region resolution (the + install marker lives there, not necessarily under KIMI_CODE_HOME). */ + readonly homeDir?: string; + /** Pre-resolved marker opt-out from the host's bootstrap env (defaults to + reading KIMI_CODE_REGION_MARKER from the process env). */ + readonly readMarker?: boolean; readonly getAccessToken?: () => string | null | Promise; readonly fetchImpl?: typeof fetch; readonly retryBackoffsMs?: readonly number[]; @@ -39,7 +51,7 @@ export interface CloudTransportOptions { readonly now?: () => number; } -export const TELEMETRY_ENDPOINT = 'https://telemetry-logs.kimi.com/v1/event'; +export const TELEMETRY_ENDPOINT = KIMI_REGION_PROFILES['mainland-cn'].telemetryEndpoint; export const SERVER_EVENT_PREFIX = 'kfc_'; export const USER_ID_PREFIX = 'kfc_device_id_'; export const DISK_EVENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; @@ -53,6 +65,12 @@ const JSONL_SUFFIX = '.jsonl'; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); +function defaultTelemetryEndpoint(homeDir?: string, readMarker = true): string { + return kimiRegionProfile( + resolveKimiRegion({ readMarker, homeDir }), + ).telemetryEndpoint; +} + export class CloudTransport { private readonly storage: IFileSystemStorageService; private readonly deviceId: string; @@ -67,7 +85,12 @@ export class CloudTransport { constructor(options: CloudTransportOptions) { this.storage = options.storage; this.deviceId = options.deviceId; - this.endpoint = options.endpoint ?? TELEMETRY_ENDPOINT; + this.endpoint = + options.endpoint ?? + defaultTelemetryEndpoint( + options.homeDir, + options.readMarker ?? process.env['KIMI_CODE_REGION_MARKER'] !== 'off', + ); this.getAccessToken = options.getAccessToken ?? null; this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); this.retryBackoffsMs = options.retryBackoffsMs ?? RETRY_BACKOFFS_MS; diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts index 880af72c0e1..f70c175e63d 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts @@ -5,6 +5,7 @@ import { homedir, tmpdir } from 'node:os'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; +import { kimiRegionProfile, resolveKimiRegion } from '@moonshot-ai/kimi-code-oauth'; import { extract as extractTar } from 'tar'; import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; import { basename, join } from 'pathe'; @@ -13,7 +14,6 @@ import { abortable } from '#/_base/utils/abort'; import { ErrorCodes, Error2 } from '#/errors'; const RG_VERSION = '15.0.0'; -const RG_BASE_URL = 'https://code.kimi.com/kimi-code/rg'; const DOWNLOAD_TIMEOUT_MS = 600_000; const RG_ARCHIVE_SHA256: Record = { 'ripgrep-15.0.0-aarch64-apple-darwin.tar.gz': @@ -65,6 +65,10 @@ export function getShareBinRgPath(): string { return join(getShareDir(), 'bin', rgBinaryName()); } +function rgBaseUrl(): string { + return `${kimiRegionProfile(resolveKimiRegion()).cdnBase}/rg`; +} + function throwIfAborted(signal: AbortSignal | undefined): void { if (signal?.aborted === true) { throw new DOMException('Aborted', 'AbortError'); @@ -190,7 +194,7 @@ async function downloadAndInstallRg(shareDir: string): Promise { { details: { archiveName } }, ); } - const url = `${RG_BASE_URL}/${archiveName}`; + const url = `${rgBaseUrl()}/${archiveName}`; const binDir = join(shareDir, 'bin'); await mkdir(binDir, { recursive: true }); diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index 892170dc368..2ff59f96ef3 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -1,3 +1,7 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { clearManagedKimiCodeConfig, @@ -67,6 +71,15 @@ const ENV_SCOPED_REF = { oauthHost: 'https://env-auth.example.com', } as const; +const OVERSEAS_SCOPED_REF = { + storage: 'file', + key: resolveKimiCodeOAuthKey({ + oauthHost: 'https://auth.kimi.ai', + baseUrl: 'https://api.kimi.ai/coding/v1', + }), + oauthHost: 'https://auth.kimi.ai', +} as const; + interface FakeToolkit { readonly login: Mock<(...args: any[]) => any>; readonly logout: ReturnType; @@ -353,6 +366,113 @@ describe('OAuthService', () => { ); }); + it('startLogin with region global resolves the global login environment', async () => { + stubManagedModelsFetch(); + toolkit.login.mockImplementation((_provider, options) => { + options.onDeviceCode(deviceAuth); + return Promise.resolve({ providerName: OAUTH_PROVIDER, ok: true }); + }); + const svc = createService(); + await svc.startLogin(OAUTH_PROVIDER, { region: 'global' }); + + expect(toolkit.login).toHaveBeenCalledWith( + OAUTH_PROVIDER, + expect.objectContaining({ + oauthRef: OVERSEAS_SCOPED_REF, + baseUrl: 'https://api.kimi.ai/coding/v1', + oauthHost: 'https://auth.kimi.ai', + }), + ); + await flush(); + expect(providerSet).toHaveBeenCalledWith( + OAUTH_PROVIDER, + expect.objectContaining({ + type: 'kimi', + baseUrl: 'https://api.kimi.ai/coding/v1', + oauth: OVERSEAS_SCOPED_REF, + }), + ); + }); + + it('startLogin with a region still honors env endpoint overrides', async () => { + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://env-auth.example.com'); + stubManagedModelsFetch(); + toolkit.login.mockImplementation((_provider, options) => { + options.onDeviceCode(deviceAuth); + return Promise.resolve({ providerName: OAUTH_PROVIDER, ok: true }); + }); + const svc = createService(); + await svc.startLogin(OAUTH_PROVIDER, { region: 'global' }); + + expect(toolkit.login).toHaveBeenCalledWith( + OAUTH_PROVIDER, + expect.objectContaining({ + oauthHost: 'https://env-auth.example.com', + baseUrl: 'https://api.example.com', + }), + ); + }); + + it('getRegion resolves cn by default and global from the persisted login host', () => { + vi.stubEnv('KIMI_CODE_REGION_MARKER', 'off'); + const svc = createService(); + expect(svc.getRegion()).toBe('mainland-cn'); + + providers[OAUTH_PROVIDER] = { + type: 'kimi', + oauth: { storage: 'file', key: OVERSEAS_SCOPED_REF.key, oauthHost: 'https://auth.kimi.ai' }, + }; + expect(svc.getRegion()).toBe('global'); + }); + + it('getRegion reads the install marker from the bootstrapped home unless KIMI_CODE_REGION_MARKER=off', async () => { + const home = ix.get(IBootstrapService).homeDir; + try { + await mkdir(home, { recursive: true }); + await writeFile(join(home, 'region'), 'global\n', 'utf-8'); + vi.stubEnv('KIMI_CODE_OAUTH_HOST', ''); + providers[OAUTH_PROVIDER] = { type: 'kimi' }; + expect(createService().getRegion()).toBe('global'); + + vi.stubEnv('KIMI_CODE_REGION_MARKER', 'off'); + expect(createService().getRegion()).toBe('mainland-cn'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('getRegion reads the marker from the bootstrapped home, not KIMI_CODE_HOME', async () => { + const bootstrapHome = ix.get(IBootstrapService).homeDir; + const envHome = await mkdtemp(join(tmpdir(), 'kimi-v2-auth-envhome-')); + try { + await mkdir(bootstrapHome, { recursive: true }); + await writeFile(join(bootstrapHome, 'region'), 'global\n', 'utf-8'); + vi.stubEnv('KIMI_CODE_HOME', envHome); + vi.stubEnv('KIMI_CODE_OAUTH_HOST', ''); + providers[OAUTH_PROVIDER] = { type: 'kimi' }; + expect(createService().getRegion()).toBe('global'); + } finally { + await rm(bootstrapHome, { recursive: true, force: true }); + await rm(envHome, { recursive: true, force: true }); + } + }); + + it('getRegion resolves cn from the default-slot oauth ref despite an global marker', async () => { + const home = ix.get(IBootstrapService).homeDir; + try { + await mkdir(home, { recursive: true }); + await writeFile(join(home, 'region'), 'global\n', 'utf-8'); + vi.stubEnv('KIMI_CODE_OAUTH_HOST', ''); + providers[OAUTH_PROVIDER] = { + type: 'kimi', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }; + expect(createService().getRegion()).toBe('mainland-cn'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('resolves the runtime credential slot to the env environment after an env-scoped login', async () => { vi.stubEnv('KIMI_CODE_BASE_URL', 'https://env-api.example.com/coding/v1'); vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://env-auth.example.com'); diff --git a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts index a5030a87cfb..07f8bce35c6 100644 --- a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts +++ b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts @@ -42,6 +42,7 @@ function fakeService( undefined as never, undefined as never, log, + undefined as never, entries, ); } diff --git a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts index 22190b55ae8..6745a7a0c01 100644 --- a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts +++ b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts @@ -275,6 +275,28 @@ describe('kimi-webbridge entry', () => { expect(reports.some(([step]) => step === 'skill')).toBe(true); }); + it('installs the plugin zip from the global CDN when the region is global', async () => { + const plugins = fakePlugins([]); + const host = fakeHostProcess(); + const { fetchImpl } = fakeFetch({ + statusSequence: [{ running: true, version: 'v1.11.3', extension_connected: true }], + }); + const entry = createKimiWebbridgeEntry( + makeCtx({ + plugins: plugins.service, + hostProcess: host.service, + fetchImpl, + resolveRegion: () => 'global', + }), + ); + + await entry.install(() => {}); + + expect(plugins.installs).toEqual([ + 'https://code.kimi.ai/kimi-code/plugins/official/kimi-webbridge.zip', + ]); + }); + it('never starts the daemon when one is already running (coexistence)', async () => { const plugins = fakePlugins([]); const host = fakeHostProcess(); 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 7d70c3d4f17..89c267e3203 100644 --- a/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -45,12 +45,15 @@ function statusResponse(status: number): Response { } function baseOptions( - overrides: Partial & { homeDir?: string } = {}, + overrides: Partial & { homeDir?: string; bootstrapEnv?: NodeJS.ProcessEnv } = {}, ): CloudAppenderOptions { - const { homeDir: dir = '', storage, ...rest } = overrides; + const { homeDir: dir = '', storage, bootstrapEnv, ...rest } = overrides; return { storage: storage ?? new FileStorageService(dir), - bootstrap: { ...stubBootstrap(), clientIdentity: { ...stubClientIdentity, version: '1.0.0' } }, + bootstrap: { + ...stubBootstrap(dir === '' ? undefined : dir, bootstrapEnv), + clientIdentity: { ...stubClientIdentity, version: '1.0.0' }, + }, deviceId: 'dev', appName: 'test-app', sleep: async () => {}, @@ -60,13 +63,28 @@ function baseOptions( describe('CloudAppender', () => { let homeDir: string; + let savedOauthHost: string | undefined; + let savedLegacyOauthHost: string | undefined; + let savedKimiHome: string | undefined; beforeEach(() => { homeDir = mkdtempSync(join(tmpdir(), 'cloud-appender-')); + savedOauthHost = process.env['KIMI_CODE_OAUTH_HOST']; + savedLegacyOauthHost = process.env['KIMI_OAUTH_HOST']; + savedKimiHome = process.env['KIMI_CODE_HOME']; + delete process.env['KIMI_CODE_OAUTH_HOST']; + delete process.env['KIMI_OAUTH_HOST']; + process.env['KIMI_CODE_HOME'] = homeDir; }); afterEach(() => { rmSync(homeDir, { recursive: true, force: true }); + if (savedOauthHost === undefined) delete process.env['KIMI_CODE_OAUTH_HOST']; + else process.env['KIMI_CODE_OAUTH_HOST'] = savedOauthHost; + if (savedLegacyOauthHost === undefined) delete process.env['KIMI_OAUTH_HOST']; + else process.env['KIMI_OAUTH_HOST'] = savedLegacyOauthHost; + if (savedKimiHome === undefined) delete process.env['KIMI_CODE_HOME']; + else process.env['KIMI_CODE_HOME'] = savedKimiHome; }); it('sends a flattened, prefixed payload with user_id and context', async () => { @@ -103,6 +121,94 @@ describe('CloudAppender', () => { expect(typeof event?.['timestamp']).toBe('number'); }); + it('derives the global endpoint when the env pins the global region', async () => { + process.env['KIMI_CODE_OAUTH_HOST'] = 'https://auth.kimi.ai'; + const requests: CapturedRequest[] = []; + const appender = new CloudAppender( + baseOptions({ + homeDir, + fetchImpl: makeFetch((req) => { + requests.push(req); + return okResponse(); + }), + }), + ); + + appender.track('tool.call', { name: 'bash' }); + await appender.flush(); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe('https://telemetry-logs.kimi.ai/v1/event'); + }); + + it('reads the install marker from the bootstrapped home for the default endpoint', async () => { + writeFileSync(join(homeDir, 'region'), 'global\n'); + const requests: CapturedRequest[] = []; + const appender = new CloudAppender( + baseOptions({ + homeDir, + fetchImpl: makeFetch((req) => { + requests.push(req); + return okResponse(); + }), + }), + ); + + appender.track('tool.call', { name: 'bash' }); + await appender.flush(); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe('https://telemetry-logs.kimi.ai/v1/event'); + }); + + it('honors the marker opt-out from the bootstrap env bag (no process.env needed)', async () => { + writeFileSync(join(homeDir, 'region'), 'global\n'); + const requests: CapturedRequest[] = []; + const appender = new CloudAppender( + baseOptions({ + homeDir, + bootstrapEnv: { KIMI_CODE_REGION_MARKER: 'off' }, + fetchImpl: makeFetch((req) => { + requests.push(req); + return okResponse(); + }), + }), + ); + + appender.track('tool.call', { name: 'bash' }); + await appender.flush(); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe('https://telemetry-logs.kimi.com/v1/event'); + }); + + it('honors KIMI_CODE_REGION_MARKER=off so embedded servers ignore the install marker', async () => { + writeFileSync(join(homeDir, 'region'), 'global\n'); + const savedMarkerFlag = process.env['KIMI_CODE_REGION_MARKER']; + process.env['KIMI_CODE_REGION_MARKER'] = 'off'; + try { + const requests: CapturedRequest[] = []; + const appender = new CloudAppender( + baseOptions({ + homeDir, + fetchImpl: makeFetch((req) => { + requests.push(req); + return okResponse(); + }), + }), + ); + + appender.track('tool.call', { name: 'bash' }); + await appender.flush(); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe('https://telemetry-logs.kimi.com/v1/event'); + } finally { + if (savedMarkerFlag === undefined) delete process.env['KIMI_CODE_REGION_MARKER']; + else process.env['KIMI_CODE_REGION_MARKER'] = savedMarkerFlag; + } + }); + it('applies setContext sessionId and model updates to subsequent events', async () => { const requests: CapturedRequest[] = []; const appender = new CloudAppender( diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/rgLocator.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/rgLocator.test.ts index b38ba8a41ce..fb3068b7f27 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/rgLocator.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/rgLocator.test.ts @@ -316,6 +316,64 @@ describe('ensureRgPath download branch', () => { expect(new URL(url).protocol).toBe('https:'); }); + it('downloads from the global CDN when the env pins the global region', async () => { + const savedHost = process.env['KIMI_CODE_OAUTH_HOST']; + process.env['KIMI_CODE_OAUTH_HOST'] = 'https://auth.kimi.ai'; + try { + const body = bodyFromBuffer(Buffer.from('not a real archive', 'utf8')); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body, + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toMatch(/^https:\/\/code\.kimi\.ai\/kimi-code\/rg\/ripgrep-/); + } finally { + if (savedHost === undefined) delete process.env['KIMI_CODE_OAUTH_HOST']; + else process.env['KIMI_CODE_OAUTH_HOST'] = savedHost; + } + }); + + it('downloads from the cn CDN by default (no env override, no install marker)', async () => { + const savedHost = process.env['KIMI_CODE_OAUTH_HOST']; + const savedLegacyHost = process.env['KIMI_OAUTH_HOST']; + const savedHome = process.env['KIMI_CODE_HOME']; + delete process.env['KIMI_CODE_OAUTH_HOST']; + delete process.env['KIMI_OAUTH_HOST']; + process.env['KIMI_CODE_HOME'] = fakeShare; + try { + const body = bodyFromBuffer(Buffer.from('not a real archive', 'utf8')); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body, + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect( + ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toMatch(/^https:\/\/code\.kimi\.com\/kimi-code\/rg\/ripgrep-/); + } finally { + if (savedHost === undefined) delete process.env['KIMI_CODE_OAUTH_HOST']; + else process.env['KIMI_CODE_OAUTH_HOST'] = savedHost; + if (savedLegacyHost === undefined) delete process.env['KIMI_OAUTH_HOST']; + else process.env['KIMI_OAUTH_HOST'] = savedLegacyHost; + if (savedHome === undefined) delete process.env['KIMI_CODE_HOME']; + else process.env['KIMI_CODE_HOME'] = savedHome; + } + }); + it('rejects archives that do not match the pinned SHA-256 before extraction', async () => { const tarMock = vi.mocked(extractTar); tarMock.mockClear(); diff --git a/packages/agent-core/src/tools/support/rg-locator.ts b/packages/agent-core/src/tools/support/rg-locator.ts index 88ae3610f67..165fe409207 100644 --- a/packages/agent-core/src/tools/support/rg-locator.ts +++ b/packages/agent-core/src/tools/support/rg-locator.ts @@ -20,13 +20,13 @@ import { basename, join } from 'pathe'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; +import { kimiRegionProfile, resolveKimiRegion } from '@moonshot-ai/kimi-code-oauth'; import { extract as extractTar } from 'tar'; import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; import { abortable } from '../../utils/abort'; const RG_VERSION = '15.0.0'; -const RG_BASE_URL = 'https://code.kimi.com/kimi-code/rg'; const DOWNLOAD_TIMEOUT_MS = 600_000; const RG_ARCHIVE_SHA256: Record = { 'ripgrep-15.0.0-aarch64-apple-darwin.tar.gz': @@ -124,6 +124,13 @@ function rgBinaryName(): string { return process.platform === 'win32' ? 'rg.exe' : 'rg'; } +// Resolved per download so the region follows env changes; this tool-layer +// module has no access to the persisted config, so resolution is +// env override > install marker > cn default. +function rgBaseUrl(): string { + return `${kimiRegionProfile(resolveKimiRegion()).cdnBase}/rg`; +} + function getShareDir(): string { const override = process.env['KIMI_CODE_HOME']; if (override !== undefined && override !== '') return override; @@ -191,7 +198,7 @@ async function downloadAndInstallRg(shareDir: string): Promise { if (expectedSha256 === undefined) { throw new Error(`No pinned SHA-256 is configured for ripgrep archive ${archiveName}`); } - const url = `${RG_BASE_URL}/${archiveName}`; + const url = `${rgBaseUrl()}/${archiveName}`; const binDir = join(shareDir, 'bin'); await mkdir(binDir, { recursive: true }); diff --git a/packages/agent-core/test/tools/rg-locator.test.ts b/packages/agent-core/test/tools/rg-locator.test.ts index badd679439b..e7824dc14d5 100644 --- a/packages/agent-core/test/tools/rg-locator.test.ts +++ b/packages/agent-core/test/tools/rg-locator.test.ts @@ -319,6 +319,61 @@ describe('ensureRgPath download branch', () => { expect(new URL(url).protocol).toBe('https:'); }); + it('downloads from the overseas CDN when the env pins the overseas region', async () => { + const savedHost = process.env['KIMI_CODE_OAUTH_HOST']; + process.env['KIMI_CODE_OAUTH_HOST'] = 'https://auth.kimi.ai'; + try { + const body = bodyFromBuffer(Buffer.from('not a real archive', 'utf8')); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body, + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(ensureRgPath({ shareDir: fakeShare })).rejects.toThrow(); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toMatch(/^https:\/\/code\.kimi\.ai\/kimi-code\/rg\/ripgrep-/); + } finally { + if (savedHost === undefined) delete process.env['KIMI_CODE_OAUTH_HOST']; + else process.env['KIMI_CODE_OAUTH_HOST'] = savedHost; + } + }); + + it('downloads from the cn CDN by default (no env override, no install marker)', async () => { + const savedHost = process.env['KIMI_CODE_OAUTH_HOST']; + const savedLegacyHost = process.env['KIMI_OAUTH_HOST']; + const savedHome = process.env['KIMI_CODE_HOME']; + delete process.env['KIMI_CODE_OAUTH_HOST']; + delete process.env['KIMI_OAUTH_HOST']; + // A home dir without a `region` marker file keeps the default resolution. + process.env['KIMI_CODE_HOME'] = fakeShare; + try { + const body = bodyFromBuffer(Buffer.from('not a real archive', 'utf8')); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body, + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(ensureRgPath({ shareDir: fakeShare })).rejects.toThrow(); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toMatch(/^https:\/\/code\.kimi\.com\/kimi-code\/rg\/ripgrep-/); + } finally { + if (savedHost === undefined) delete process.env['KIMI_CODE_OAUTH_HOST']; + else process.env['KIMI_CODE_OAUTH_HOST'] = savedHost; + if (savedLegacyHost === undefined) delete process.env['KIMI_OAUTH_HOST']; + else process.env['KIMI_OAUTH_HOST'] = savedLegacyHost; + if (savedHome === undefined) delete process.env['KIMI_CODE_HOME']; + else process.env['KIMI_CODE_HOME'] = savedHome; + } + }); + it('rejects archives that do not match the pinned SHA-256 before extraction', async () => { const tarMock = vi.mocked(extractTar); tarMock.mockClear(); diff --git a/packages/kap-server/src/protocol/rest-oauth.ts b/packages/kap-server/src/protocol/rest-oauth.ts index 4d803dafc08..70620ff84cf 100644 --- a/packages/kap-server/src/protocol/rest-oauth.ts +++ b/packages/kap-server/src/protocol/rest-oauth.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; export const oauthLoginStartRequestSchema = z.object({ provider: z.string().min(1).optional(), + region: z.enum(['mainland-cn', 'global']).optional(), }); export type OAuthLoginStartRequest = z.infer; diff --git a/packages/kap-server/src/routes/oauth.ts b/packages/kap-server/src/routes/oauth.ts index d892b33a009..1cf48669dfc 100644 --- a/packages/kap-server/src/routes/oauth.ts +++ b/packages/kap-server/src/routes/oauth.ts @@ -6,6 +6,7 @@ import { oauthFlowStartSchema, oauthLoginCancelResponseSchema, oauthLogoutResponseSchema, + oauthRegionResultSchema, type ManagedUsageResult, type UsageRow, } from '@moonshot-ai/agent-core-v2/app/auth/oauthProtocol'; @@ -63,7 +64,9 @@ export function registerOAuthRoutes(app: RouteHost, core: Scope): void { tags: ['auth'], }, async (req, reply) => { - const result = await core.accessor.get(IOAuthService).startLogin(req.body.provider); + const result = await core.accessor + .get(IOAuthService) + .startLogin(req.body.provider, { region: req.body.region }); requestLog(req)?.info({ provider: req.body.provider, action: 'login' }, 'oauth login started'); reply.send(okEnvelope(result, req.id)); }, @@ -178,6 +181,25 @@ export function registerOAuthRoutes(app: RouteHost, core: Scope): void { userInfoRoute.options, userInfoRoute.handler as Parameters[2], ); + + const regionRoute = defineRoute( + { + method: 'GET', + path: '/oauth/region', + success: { data: oauthRegionResultSchema }, + description: 'Resolve the client region (mainland-cn/global)', + tags: ['auth'], + }, + async (req, reply) => { + const region = core.accessor.get(IOAuthService).getRegion(); + reply.send(okEnvelope({ region }, req.id)); + }, + ); + app.get( + regionRoute.path, + regionRoute.options, + regionRoute.handler as Parameters[2], + ); } function toWireUsage(result: ManagedUsageDomainResult): ManagedUsageResult { diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 8b21887f90b..4d1b5696944 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -81,8 +81,10 @@ async function getSourceCheckoutLocation(): Promise string; /** * True when the catalog location is the built-in default (neither the * server option nor the env var set) — only then does a failed remote read @@ -113,7 +115,7 @@ export function registerPluginsRoutes( let read: { raw: string; location: MarketplaceLocation }; try { read = await readPluginMarketplace({ - source: opts.marketplaceUrl, + source: opts.marketplaceUrl(), workDir: process.cwd(), fetchImpl, sourceCheckoutLocation: diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 34d30e5d023..29582caa985 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -69,8 +69,10 @@ export interface RegisterApiV1RoutesOptions { readonly connectionRegistry: IConnectionRegistry; readonly broadcaster: SessionEventBroadcaster; readonly transcriptService: TranscriptService; - /** Catalog URL for the `/plugins/marketplace` route (resolved by start.ts). */ - readonly pluginMarketplaceUrl: string; + /** Catalog URL resolver for the `/plugins/marketplace` route (start.ts + applies the option/env override; the default follows the active login + region per request). */ + readonly pluginMarketplaceUrl: () => string; /** True when the catalog URL is the built-in default (no option/env set). */ readonly pluginMarketplaceIsDefault: boolean; /** diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 2e6c533e11a..79f95633177 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -7,13 +7,13 @@ import { CapabilityChanged, IConfigService, IEventService, + IOAuthService, IProviderDiscoveryService, ISessionIndex, ISessionIndexMirror, ICapabilityService, IPluginService, IWorkspaceService, - KIMI_CODE_PLUGIN_MARKETPLACE_URL, PluginChanged, logSeed, resolveConfigPath, @@ -25,6 +25,7 @@ import { } from '@moonshot-ai/agent-core-v2'; import { createKimiDefaultHeaders, + kimiRegionProfile, type KimiHostIdentity, } from '@moonshot-ai/kimi-code-oauth'; import { createAsyncApiDocument } from './protocol/asyncapi'; @@ -97,6 +98,14 @@ export interface ServerStartOptions { readonly host?: string; readonly port?: number; readonly homeDir?: string; + /** + * Environment bag handed to the engine bootstrap (`IBootstrapService.getEnv`). + * Defaults to `process.env`; hosts that need to override engine-level env + * reads (e.g. an embedded server pinning `KIMI_CODE_REGION_MARKER=off`) + * pass a merged bag here instead of mutating the host process's env, which + * would leak the override into every child process the host spawns. + */ + readonly env?: NodeJS.ProcessEnv; /** * Plugin marketplace catalog URL for `GET /api/v1/plugins/marketplace`. * Defaults to the `KIMI_CODE_PLUGIN_MARKETPLACE_URL` env var, then the @@ -237,6 +246,7 @@ export async function startServer(opts: ServerStartOptions): Promise { + const configured = opts.pluginMarketplaceUrl ?? process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL']; + if (configured !== undefined) return () => configured; + return () => + `${kimiRegionProfile(core.accessor.get(IOAuthService).getRegion()).cdnBase}/plugins/marketplace.json`; + })(), pluginMarketplaceIsDefault: opts.pluginMarketplaceUrl === undefined && (process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] === undefined || diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index e6126f65158..fcc32638e34 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -140,6 +140,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/oauth/login", ], + [ + "GET", + "/api/v1/oauth/region", + ], [ "GET", "/api/v1/oauth/usage", diff --git a/packages/kap-server/test/modelCatalog.test.ts b/packages/kap-server/test/modelCatalog.test.ts index c2bf67ac6bb..a5358f7e1c5 100644 --- a/packages/kap-server/test/modelCatalog.test.ts +++ b/packages/kap-server/test/modelCatalog.test.ts @@ -312,6 +312,7 @@ describe('server-v2 /api/v1 model/provider catalog', () => { getManagedUserInfo: async () => ({ kind: 'error' as const, message: 'unused' }), resolveTokenProvider: () => undefined, getCachedAccessToken: async () => undefined, + getRegion: () => 'mainland-cn', }; } diff --git a/packages/kap-server/test/oauthUsage.test.ts b/packages/kap-server/test/oauthUsage.test.ts index fba3c1a50ad..c2ca0fd4f5b 100644 --- a/packages/kap-server/test/oauthUsage.test.ts +++ b/packages/kap-server/test/oauthUsage.test.ts @@ -65,6 +65,7 @@ describe('server-v2 GET /api/v1/oauth/usage', () => { getManagedUserInfo: async () => ({ kind: 'error' as const, message: 'unused' }), resolveTokenProvider: () => undefined, getCachedAccessToken: async () => undefined, + getRegion: () => 'mainland-cn', }; } @@ -195,6 +196,7 @@ describe('server-v2 GET /api/v1/oauth/userinfo', () => { getManagedUserInfo, resolveTokenProvider: () => undefined, getCachedAccessToken: async () => undefined, + getRegion: () => 'mainland-cn', }; } diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 496a6794c9a..5ee22b6c916 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -564,6 +564,7 @@ describe('server-v2 /api/v1/sessions', () => { getManagedUserInfo: async () => ({ kind: 'error', message: 'unused' }), resolveTokenProvider: () => ({ getAccessToken: async () => 'test-token' }), getCachedAccessToken: async () => 'test-token', + getRegion: () => 'mainland-cn', }; server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, diff --git a/packages/klient/src/contract/global/auth.ts b/packages/klient/src/contract/global/auth.ts index 618cede734e..acafd2f9c91 100644 --- a/packages/klient/src/contract/global/auth.ts +++ b/packages/klient/src/contract/global/auth.ts @@ -80,8 +80,15 @@ export const refreshOAuthProviderModelsResponseSchema = z.object({ failed: z.array(z.object({ provider: z.string(), reason: z.string() })), }); +export const oAuthLoginOptionsSchema = z.object({ + region: z.enum(['mainland-cn', 'global']).optional(), +}); + export const authContract = { - startLogin: { input: z.tuple([z.string().optional()]), output: oAuthFlowStartSchema }, + startLogin: { + input: z.tuple([z.string().optional(), oAuthLoginOptionsSchema.optional()]), + output: oAuthFlowStartSchema, + }, getFlow: { input: z.tuple([z.string().optional()]), output: maybe(oAuthFlowSnapshotSchema), diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index c185812f248..c4ae72fc75e 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -25,6 +25,7 @@ import type { ProviderConfig } from '@moonshot-ai/agent-core-v2/kosong/provider/ import type { AuthStatus, IOAuthService, + OAuthLoginOptions, } from '@moonshot-ai/agent-core-v2/app/auth/auth'; import type { ExperimentalFeatureState } from '@moonshot-ai/agent-core-v2/app/flag/flag'; import type { @@ -179,7 +180,7 @@ export interface GlobalAuthFacade { * model usage does not depend on the OAuth-only {@link summarize} view. */ ensureReady(modelOverride?: string): Promise; - startLogin(provider?: string): Promise; + startLogin(provider?: string, options?: OAuthLoginOptions): Promise; flow(provider?: string): Promise; cancelLogin(provider?: string): Promise; logout(provider?: string): Promise; @@ -441,8 +442,8 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr summarize: () => call('authSummaryService', 'summarize', []) as Promise, ensureReady: (modelOverride) => call('authSummaryService', 'ensureReady', [modelOverride]) as Promise, - startLogin: (provider) => - call('oauthService', 'startLogin', [provider]) as Promise, + startLogin: (provider, options) => + call('oauthService', 'startLogin', [provider, options]) as Promise, flow: (provider) => call('oauthService', 'getFlow', [provider]) as Promise, cancelLogin: (provider) => diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 26d8ab82f64..ae34660509c 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -95,6 +95,29 @@ describe('facade routing', () => { }); }); + it('forwards the login region option through the wire contract', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + + channel.results.set('oauthService.startLogin', { + flow_id: 'f1', + provider: 'managed:kimi-code', + status: 'pending', + verification_uri: 'https://example.com/device', + verification_uri_complete: 'https://example.com/device?user_code=ABCD', + user_code: 'ABCD', + expires_in: 1800, + expires_at: '2026-08-19T15:00:00.000Z', + interval: 5, + }); + await klient.global.auth.startLogin('managed:kimi-code', { region: 'global' }); + expect(channel.calls[0]).toMatchObject({ + service: 'oauthService', + method: 'startLogin', + args: ['managed:kimi-code', { region: 'global' }], + }); + }); + it('routes capability calls through the registered app service contract', async () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 4f02747f888..b00bd36e02a 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -11,6 +11,7 @@ import { applyManagedKimiCodeLogoutConfig, KIMI_CODE_PROVIDER_NAME, KimiOAuthToolkit, + kimiRegionLoginHosts, resolveKimiCodeLoginAuth, resolveKimiCodeRuntimeAuth, type AuthManagedUsageResult, @@ -21,6 +22,7 @@ import { type FetchSubmitFeedbackResult, type KimiHostIdentity, type KimiOAuthLoginOptions, + type KimiRegion, type ManagedKimiConfigShape, type OAuthRefreshOutcome, } from '@moonshot-ai/kimi-code-oauth'; @@ -71,7 +73,16 @@ export type KimiAuthCreateFeedbackUploadUrlResult = | KimiAuthCreateFeedbackUploadUrlOk | FetchFeedbackUploadError; -export type KimiAuthLoginOptions = Omit; +export type KimiAuthLoginOptions = Omit & { + /** + * Explicit region choice from the login UI ('mainland-cn' / 'global'). Maps + * to the region profile's OAuth/API hosts — including for 'mainland-cn', so + * switching back overrides a persisted global login. Yields to + * `KIMI_CODE_OAUTH_HOST` / `KIMI_CODE_BASE_URL` env overrides and to + * explicit `oauthHost` / `baseUrl` options. + */ + readonly region?: KimiRegion; +}; export interface KimiAuthLoginResult { readonly providerName: string; @@ -126,18 +137,20 @@ export class KimiAuthFacade { providerName: string | undefined = KIMI_CODE_PROVIDER_NAME, options: KimiAuthLoginOptions = {}, ): Promise { + const { region, ...loginOptions } = options; + const regionHosts = region === undefined ? undefined : kimiRegionLoginHosts(region); const auth = this.resolveManagedAuth(providerName); const loginAuth = resolveKimiCodeLoginAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, - requestedBaseUrl: options.baseUrl, - requestedOAuthHost: options.oauthHost, + requestedBaseUrl: loginOptions.baseUrl ?? regionHosts?.baseUrl, + requestedOAuthHost: loginOptions.oauthHost ?? regionHosts?.oauthHost, }); const result = await this.toolkit.login(providerName, { - ...options, + ...loginOptions, baseUrl: loginAuth.baseUrl, oauthHost: loginAuth.oauthHost, - oauthRef: options.oauthRef ?? loginAuth.oauthRef, + oauthRef: loginOptions.oauthRef ?? loginAuth.oauthRef, provisionConfig: true, }); if (result.provision === undefined) { diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index a885d16b9eb..4fd6919dc5a 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { FileTokenStorage, + KIMI_CODE_OAUTH_KEY, KIMI_CODE_PROVIDER_NAME, KimiOAuthToolkit, OAuthConnectionError, @@ -345,6 +346,140 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } ]); }); + it('logs in against the global region hosts when region is global', async () => { + const baseUrl = 'https://api.kimi.ai/coding/v1'; + const oauthHost = 'https://auth.kimi.ai'; + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl }); + const storageName = resolveKimiTokenStorageName({ oauthKey }); + const storage = new FileTokenStorage(join(homeDir, 'credentials')); + await storage.save(storageName, { + ...freshToken(), + accessToken: 'expired-global-access-token', + refreshToken: 'global-refresh-token', + expiresAt: 1, + }); + const fetchMock = vi.fn(async (input, init) => { + const url = fetchInputUrl(input); + if (url === `${oauthHost}/api/oauth/token`) { + if (typeof init?.body !== 'string') throw new TypeError('expected form body'); + const body = new URLSearchParams(init.body); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('global-refresh-token'); + return new Response( + JSON.stringify({ + access_token: 'rotated-global-access-token', + refresh_token: 'rotated-global-refresh-token', + expires_in: 3600, + scope: '', + token_type: 'Bearer', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + if (url === `${baseUrl}/models`) { + expect(new Headers(init?.headers).get('authorization')).toBe( + 'Bearer rotated-global-access-token', + ); + return new Response( + JSON.stringify({ + data: [{ id: 'kimi-for-coding', context_length: 262144, supports_reasoning: true }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + await expect(harness.auth.login(undefined, { region: 'global' })).resolves.toMatchObject({ + providerName: KIMI_CODE_PROVIDER_NAME, + ok: true, + defaultModel: 'kimi-code/kimi-for-coding', + }); + const config = await harness.getConfig({ reload: true }); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ + baseUrl, + oauth: { storage: 'file', key: oauthKey, oauthHost }, + }); + // The default (cn) credential slot must stay untouched. + expect( + await new FileTokenStorage(join(homeDir, 'credentials')).load( + resolveKimiTokenStorageName({ oauthKey: KIMI_CODE_OAUTH_KEY }), + ), + ).toBeUndefined(); + }); + + it('logs back into the mainland-cn region over a persisted global login', async () => { + const globalBaseUrl = 'https://api.kimi.ai/coding/v1'; + const globalOauthHost = 'https://auth.kimi.ai'; + const globalKey = resolveKimiCodeOAuthKey({ + oauthHost: globalOauthHost, + baseUrl: globalBaseUrl, + }); + await writeFile( + join(homeDir, 'config.toml'), + ` +[providers."managed:kimi-code"] +type = "kimi" +base_url = "${globalBaseUrl}" +api_key = "" +oauth = { storage = "file", key = "${globalKey}", oauth_host = "${globalOauthHost}" } +`, + ); + const storage = new FileTokenStorage(join(homeDir, 'credentials')); + const defaultStorageName = resolveKimiTokenStorageName({ oauthKey: KIMI_CODE_OAUTH_KEY }); + await storage.save(defaultStorageName, { + ...freshToken(), + accessToken: 'expired-cn-access-token', + refreshToken: 'cn-refresh-token', + expiresAt: 1, + }); + const fetchMock = vi.fn(async (input, init) => { + const url = fetchInputUrl(input); + if (url === 'https://auth.kimi.com/api/oauth/token') { + if (typeof init?.body !== 'string') throw new TypeError('expected form body'); + const body = new URLSearchParams(init.body); + expect(body.get('refresh_token')).toBe('cn-refresh-token'); + return new Response( + JSON.stringify({ + access_token: 'rotated-cn-access-token', + refresh_token: 'rotated-cn-refresh-token', + expires_in: 3600, + scope: '', + token_type: 'Bearer', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + if (url === 'https://api.kimi.com/coding/v1/models') { + return new Response( + JSON.stringify({ + data: [{ id: 'kimi-for-coding', context_length: 262144, supports_reasoning: true }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + await expect(harness.auth.login(undefined, { region: 'mainland-cn' })).resolves.toMatchObject({ + providerName: KIMI_CODE_PROVIDER_NAME, + ok: true, + }); + const config = await harness.getConfig({ reload: true }); + const provider = config.providers[KIMI_CODE_PROVIDER_NAME]; + expect(provider?.oauth?.key).toBe(KIMI_CODE_OAUTH_KEY); + // Back on the default hosts, the persisted oauth ref carries no host trace. + expect(provider?.oauth?.oauthHost).toBeUndefined(); + expect(fetchMock.mock.calls.map((call) => fetchInputUrl(call[0]))).toEqual([ + 'https://auth.kimi.com/api/oauth/token', + 'https://api.kimi.com/coding/v1/models', + ]); + }); + it('recomputes legacy managed OAuth refs during login for non-default base URLs', async () => { const baseUrl = 'https://api.example.test/coding/v1'; const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 417876b66c9..92e925e1bf5 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -42,6 +42,17 @@ export type { KimiHostIdentity, KimiIdentityOptions } from './identity'; export { KIMI_CODE_FLOW_CONFIG } from './constants'; +export { + KIMI_REGION_MARKER_FILENAME, + KIMI_REGION_PROFILES, + kimiCdnContentUrl, + kimiRegionLoginHosts, + kimiRegionProfile, + kimiRegionSchema, + resolveKimiRegion, +} from './region'; +export type { KimiRegion, KimiRegionProfile, ResolveKimiRegionOptions } from './region'; + export { applyManagedApiKeyProviderModels, applyManagedKimiCodeLogoutConfig, diff --git a/packages/oauth/src/region.ts b/packages/oauth/src/region.ts new file mode 100644 index 00000000000..de7aa8aa6e3 --- /dev/null +++ b/packages/oauth/src/region.ts @@ -0,0 +1,187 @@ +/** + * Region profiles for the mainland-China (.com) and global (.ai) + * Kimi Code deployments, plus the resolver that decides which region a + * client belongs to. + * + * A region is a bundle of endpoints (OAuth host, managed API base URL, CDN, + * site, telemetry). The OAuth client_id is shared across regions and stays + * in `./constants`. + * + * Resolution order (first match wins): + * 1. env override (`KIMI_CODE_OAUTH_HOST` / `KIMI_OAUTH_HOST`) + * 2. persisted login (the `oauthHost` stored in config.toml's oauth ref) + * 3. persisted default-slot login (the oauth ref's key equals + * `KIMI_CODE_OAUTH_KEY` — a mainland-China login persists no + * `oauthHost`, so the default slot's presence is an explicit-mainland-cn + * signal that outranks the marker) + * 4. install-channel marker file (`/region`, written by install + * scripts; consultable only before the first login) + * 5. default 'mainland-cn' + */ + +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { z } from 'zod'; + +import { DEFAULT_KIMI_CODE_OAUTH_HOST } from './constants'; +import { DEFAULT_KIMI_CODE_BASE_URL } from './managed-usage'; +import { kimiCodeEnvBaseUrl, kimiCodeEnvOAuthHost, KIMI_CODE_OAUTH_KEY } from './managed-kimi-code'; + +export type KimiRegion = 'mainland-cn' | 'global'; + +/** Zod schema for the wire/domain contract; parses to {@link KimiRegion}. */ +export const kimiRegionSchema = z.enum(['mainland-cn', 'global']); + +export interface KimiRegionProfile { + /** OAuth host the device flow talks to (authorize/token derive from it). */ + readonly oauthHost: string; + /** Managed API base (`/coding/v1`): usages, userinfo, models, feedback... */ + readonly baseUrl: string; + /** Update/install/plugin-marketplace root. */ + readonly cdnBase: string; + /** Official site root (docs, console, signup, upgrade pages). */ + readonly siteBase: string; + readonly telemetryEndpoint: string; +} + +export const KIMI_REGION_PROFILES: Record = { + 'mainland-cn': { + oauthHost: DEFAULT_KIMI_CODE_OAUTH_HOST, + baseUrl: DEFAULT_KIMI_CODE_BASE_URL, + cdnBase: 'https://code.kimi.com/kimi-code', + siteBase: 'https://www.kimi.com', + telemetryEndpoint: 'https://telemetry-logs.kimi.com/v1/event', + }, + global: { + oauthHost: 'https://auth.kimi.ai', + baseUrl: 'https://api.kimi.ai/coding/v1', + cdnBase: 'https://code.kimi.ai/kimi-code', + siteBase: 'https://www.kimi.ai', + telemetryEndpoint: 'https://telemetry-logs.kimi.ai/v1/event', + }, +}; + +export function kimiRegionProfile(region: KimiRegion): KimiRegionProfile { + return KIMI_REGION_PROFILES[region]; +} + +/** + * Content-CDN URL builder (tips banner, WebBridge / Computer-Use binaries). + * International mirror coverage of cdn.kimi.ai for these payloads is still + * being confirmed, so both regions currently share the .com host — funnel + * every content URL through here so flipping later touches one function. + */ +export function kimiCdnContentUrl(path: string): string { + return `https://cdn.kimi.com/${path.replace(/^\/+/, '')}`; +} + +/** + * Login hosts for an explicit region choice, or `undefined` when an env + * override (`KIMI_CODE_OAUTH_HOST` / `KIMI_OAUTH_HOST` / `KIMI_CODE_BASE_URL`) + * is in play — env keeps full control of endpoints, so a region pick must not + * smuggle profile hosts past it (requested hosts outrank env in + * `resolveKimiCodeLoginAuth`). + * + * When returned, both hosts are always set — including for 'mainland-cn', + * whose values equal the defaults. Passing them explicitly is what lets + * "switch back to mainland China" override a previously persisted global + * login in config.toml. + */ +export function kimiRegionLoginHosts( + region: KimiRegion, + env: NodeJS.ProcessEnv = process.env, +): { readonly oauthHost: string; readonly baseUrl: string } | undefined { + if (kimiCodeEnvOAuthHost(env) !== undefined || kimiCodeEnvBaseUrl(env) !== undefined) { + return undefined; + } + const profile = kimiRegionProfile(region); + return { oauthHost: profile.oauthHost, baseUrl: profile.baseUrl }; +} + +/** + * Marker file name under the Kimi home dir. Install scripts write a single + * line (`mainland-cn` or `global`) here so a fresh client can default to the + * region matching the channel it was installed from. It is only consulted + * while the user has never logged in; a persisted login (config.toml) always + * wins. + */ +export const KIMI_REGION_MARKER_FILENAME = 'region'; + +export interface ResolveKimiRegionOptions { + /** Defaults to `process.env`. */ + readonly env?: NodeJS.ProcessEnv; + /** The `oauthHost` persisted in config.toml's oauth ref, if any. */ + readonly configuredOAuthHost?: string; + /** + * The credential key persisted in config.toml's oauth ref, if any. The + * default slot ({@link KIMI_CODE_OAUTH_KEY}) only ever holds a + * mainland-China login — mainland-cn persists no `oauthHost` — so its + * presence is an explicit-mainland-cn signal that outranks the + * install-channel marker. + */ + readonly configuredOAuthKey?: string; + /** Kimi home dir; defaults to `KIMI_CODE_HOME` or `~/.kimi-code`. */ + readonly homeDir?: string; + /** + * Set false to skip the install-channel marker (e.g. the desktop app's + * embedded server, which is not installed through a channel script and + * leaves the region choice entirely to the login UI). + */ + readonly readMarker?: boolean; +} + +function normalizeHost(value: string): string { + return value.trim().replace(/\/+$/, ''); +} + +function regionForOAuthHost(oauthHost: string): KimiRegion | undefined { + const normalized = normalizeHost(oauthHost); + for (const region of Object.keys(KIMI_REGION_PROFILES) as KimiRegion[]) { + if (normalizeHost(KIMI_REGION_PROFILES[region].oauthHost) === normalized) return region; + } + return undefined; +} + +function readRegionMarker(homeDir: string): KimiRegion | undefined { + let raw: string; + try { + raw = readFileSync(join(homeDir, KIMI_REGION_MARKER_FILENAME), 'utf-8'); + } catch { + return undefined; + } + const value = raw.trim(); + return value === 'mainland-cn' || value === 'global' ? value : undefined; +} + +// Mirrors `defaultKimiHome` in ./toolkit; keep the two in sync so the marker +// always lands next to the credentials dir it describes. +function defaultHomeDir(env: NodeJS.ProcessEnv): string { + const override = env['KIMI_CODE_HOME']; + if (override !== undefined && override.length > 0) return override; + return join(homedir(), '.kimi-code'); +} + +export function resolveKimiRegion(options: ResolveKimiRegionOptions = {}): KimiRegion { + const env = options.env ?? process.env; + // An env host that matches a profile pins the region. An unknown env host + // means a custom/internal environment: the per-endpoint env overrides keep + // doing their job regardless of region, so skip straight to the default + // instead of letting a stale config/marker point CDN links somewhere odd. + const envHost = env['KIMI_CODE_OAUTH_HOST'] ?? env['KIMI_OAUTH_HOST']; + if (envHost !== undefined && envHost.length > 0) { + return regionForOAuthHost(envHost) ?? 'mainland-cn'; + } + const configured = options.configuredOAuthHost; + if (configured !== undefined && configured.length > 0) { + const configuredRegion = regionForOAuthHost(configured); + if (configuredRegion !== undefined) return configuredRegion; + } + if (options.configuredOAuthKey === KIMI_CODE_OAUTH_KEY) return 'mainland-cn'; + if (options.readMarker !== false) { + const markerRegion = readRegionMarker(options.homeDir ?? defaultHomeDir(env)); + if (markerRegion !== undefined) return markerRegion; + } + return 'mainland-cn'; +} diff --git a/packages/oauth/test/region.test.ts b/packages/oauth/test/region.test.ts new file mode 100644 index 00000000000..09a625785c7 --- /dev/null +++ b/packages/oauth/test/region.test.ts @@ -0,0 +1,201 @@ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { DEFAULT_KIMI_CODE_OAUTH_HOST } from '#/constants'; +import { KIMI_CODE_OAUTH_KEY } from '#/managed-kimi-code'; +import { DEFAULT_KIMI_CODE_BASE_URL } from '#/managed-usage'; +import { + KIMI_REGION_MARKER_FILENAME, + KIMI_REGION_PROFILES, + kimiRegionLoginHosts, + kimiRegionProfile, + kimiRegionSchema, + resolveKimiRegion, +} from '#/region'; + +import { createTempWorkDir, type TempDirHandle } from './helpers'; + +describe('KIMI_REGION_PROFILES', () => { + it('keeps the mainland-cn profile aligned with the shared defaults', () => { + expect(KIMI_REGION_PROFILES['mainland-cn'].oauthHost).toBe(DEFAULT_KIMI_CODE_OAUTH_HOST); + expect(KIMI_REGION_PROFILES['mainland-cn'].baseUrl).toBe(DEFAULT_KIMI_CODE_BASE_URL); + }); + + it('kimiRegionProfile returns the requested profile', () => { + expect(kimiRegionProfile('global').oauthHost).toBe('https://auth.kimi.ai'); + expect(kimiRegionProfile('mainland-cn')).toBe(KIMI_REGION_PROFILES['mainland-cn']); + }); +}); + +describe('resolveKimiRegion', () => { + let workDir: TempDirHandle | undefined; + + afterEach(async () => { + await workDir?.cleanup(); + workDir = undefined; + }); + + async function markerDir(contents?: string): Promise { + workDir = await createTempWorkDir(); + if (contents !== undefined) { + await writeFile(join(workDir.path, KIMI_REGION_MARKER_FILENAME), contents, 'utf-8'); + } + return workDir.path; + } + + it('defaults to mainland-cn when nothing points anywhere', async () => { + expect(resolveKimiRegion({ env: {}, homeDir: await markerDir() })).toBe('mainland-cn'); + }); + + it('resolves a known env oauth host, KIMI_CODE_OAUTH_HOST first', () => { + expect(resolveKimiRegion({ env: { KIMI_CODE_OAUTH_HOST: 'https://auth.kimi.ai' } })).toBe( + 'global', + ); + expect(resolveKimiRegion({ env: { KIMI_OAUTH_HOST: 'https://auth.kimi.ai' } })).toBe( + 'global', + ); + expect( + resolveKimiRegion({ + env: { + KIMI_CODE_OAUTH_HOST: 'https://auth.kimi.com', + KIMI_OAUTH_HOST: 'https://auth.kimi.ai', + }, + }), + ).toBe('mainland-cn'); + }); + + it('treats an unknown env host as a custom environment and falls back to cn', async () => { + // ...even when the persisted login or marker says otherwise: the custom + // env overrides every endpoint anyway. + expect( + resolveKimiRegion({ + env: { KIMI_CODE_OAUTH_HOST: 'https://auth.internal.example.com' }, + configuredOAuthHost: 'https://auth.kimi.ai', + homeDir: await markerDir('global\n'), + }), + ).toBe('mainland-cn'); + }); + + it('resolves the persisted login host, tolerating trailing slashes', () => { + expect(resolveKimiRegion({ env: {}, configuredOAuthHost: 'https://auth.kimi.ai/' })).toBe( + 'global', + ); + expect(resolveKimiRegion({ env: {}, configuredOAuthHost: 'https://auth.kimi.com' })).toBe('mainland-cn'); + }); + + it('ignores an unrecognized persisted host and continues down the chain', async () => { + expect( + resolveKimiRegion({ + env: {}, + configuredOAuthHost: 'https://auth.legacy.example.com', + homeDir: await markerDir('global'), + }), + ).toBe('global'); + }); + + it('reads the install-channel marker when nothing else decides', async () => { + expect(resolveKimiRegion({ env: {}, homeDir: await markerDir('global\n') })).toBe('global'); + expect(resolveKimiRegion({ env: {}, homeDir: await markerDir(' mainland-cn ') })).toBe('mainland-cn'); + }); + + it('ignores a malformed or missing marker', async () => { + expect(resolveKimiRegion({ env: {}, homeDir: await markerDir('apac') })).toBe('mainland-cn'); + expect(resolveKimiRegion({ env: {}, homeDir: await markerDir('') })).toBe('mainland-cn'); + }); + + it('skips the marker entirely when readMarker is false', async () => { + expect( + resolveKimiRegion({ env: {}, homeDir: await markerDir('global'), readMarker: false }), + ).toBe('mainland-cn'); + }); + + it('honors KIMI_CODE_HOME when homeDir is not passed explicitly', async () => { + const dir = await markerDir('global'); + expect(resolveKimiRegion({ env: { KIMI_CODE_HOME: dir } })).toBe('global'); + }); + + it('env beats persisted login beats marker', async () => { + const dir = await markerDir('global'); + expect( + resolveKimiRegion({ + env: { KIMI_CODE_OAUTH_HOST: 'https://auth.kimi.com' }, + configuredOAuthHost: 'https://auth.kimi.ai', + homeDir: dir, + }), + ).toBe('mainland-cn'); + expect( + resolveKimiRegion({ + env: {}, + configuredOAuthHost: 'https://auth.kimi.ai', + homeDir: dir, + }), + ).toBe('global'); + }); + + it('treats the persisted default-slot key as explicit mainland-cn, beating the marker', async () => { + const dir = await markerDir('global'); + expect( + resolveKimiRegion({ env: {}, configuredOAuthKey: KIMI_CODE_OAUTH_KEY, homeDir: dir }), + ).toBe('mainland-cn'); + }); + + it('still follows the marker when no key or host is persisted', async () => { + expect(resolveKimiRegion({ env: {}, homeDir: await markerDir('global') })).toBe('global'); + }); + + it('lets an unknown scoped key fall through to the marker', async () => { + const dir = await markerDir('global'); + expect( + resolveKimiRegion({ + env: {}, + configuredOAuthKey: 'oauth/kimi-code-env-0123456789abcdef', + homeDir: dir, + }), + ).toBe('global'); + }); + + it('resolves a recognized persisted host before consulting the key', () => { + expect( + resolveKimiRegion({ + env: {}, + configuredOAuthHost: 'https://auth.kimi.ai', + configuredOAuthKey: KIMI_CODE_OAUTH_KEY, + }), + ).toBe('global'); + }); +}); + +describe('kimiRegionLoginHosts', () => { + it('returns both profile hosts, mainland-cn included (explicit beats stale config)', () => { + expect(kimiRegionLoginHosts('mainland-cn', {})).toEqual({ + oauthHost: 'https://auth.kimi.com', + baseUrl: 'https://api.kimi.com/coding/v1', + }); + expect(kimiRegionLoginHosts('global', {})).toEqual({ + oauthHost: 'https://auth.kimi.ai', + baseUrl: 'https://api.kimi.ai/coding/v1', + }); + }); + + it('yields to env overrides', () => { + expect(kimiRegionLoginHosts('global', { KIMI_CODE_OAUTH_HOST: 'https://auth.x.com' })).toBe( + undefined, + ); + expect(kimiRegionLoginHosts('global', { KIMI_OAUTH_HOST: 'https://auth.x.com' })).toBe( + undefined, + ); + expect( + kimiRegionLoginHosts('global', { KIMI_CODE_BASE_URL: 'https://api.x.com/coding/v1' }), + ).toBe(undefined); + }); +}); + +describe('kimiRegionSchema', () => { + it('parses valid regions and rejects others', () => { + expect(kimiRegionSchema.parse('mainland-cn')).toBe('mainland-cn'); + expect(kimiRegionSchema.parse('global')).toBe('global'); + expect(kimiRegionSchema.safeParse('apac').success).toBe(false); + }); +}); diff --git a/packages/telemetry/src/bootstrap.ts b/packages/telemetry/src/bootstrap.ts index f5d79f06356..a9fb7598f45 100644 --- a/packages/telemetry/src/bootstrap.ts +++ b/packages/telemetry/src/bootstrap.ts @@ -20,6 +20,13 @@ export interface TelemetryBootstrapOptions { readonly terminal?: string; readonly locale?: string; readonly getAccessToken?: () => string | null | Promise; + /** + * Region-aware endpoint derived by the composition root (this package stays + * dependency-free and keeps the cn default in `TELEMETRY_ENDPOINT`). A + * resolver is invoked per flush so an in-process region switch takes effect + * without re-initialization. + */ + readonly endpoint?: string | (() => string); } export function isTelemetryDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { @@ -49,6 +56,7 @@ export function initializeTelemetry(options: TelemetryBootstrapOptions): void { const transport = new AsyncTransport({ homeDir: options.homeDir, deviceId: options.deviceId, + endpoint: options.endpoint, getAccessToken: options.getAccessToken, }); const sink = new EventSink({ diff --git a/packages/telemetry/src/transport.ts b/packages/telemetry/src/transport.ts index 73d6f1f8b98..7f1544a78c3 100644 --- a/packages/telemetry/src/transport.ts +++ b/packages/telemetry/src/transport.ts @@ -13,6 +13,11 @@ import { join } from 'node:path'; import type { EnrichedTelemetryEvent, TelemetryPrimitive } from './types'; import { isTelemetryPrimitive } from './types'; +// Mainland-China telemetry endpoint, mirroring +// `KIMI_REGION_PROFILES['mainland-cn'].telemetryEndpoint` in +// `@moonshot-ai/kimi-code-oauth` (the region source of truth). This package +// deliberately has no dependency on it — region-aware callers pass `endpoint` +// explicitly (e.g. through `initializeTelemetry`). export const TELEMETRY_ENDPOINT = 'https://telemetry-logs.kimi.com/v1/event'; export const SERVER_EVENT_PREFIX = 'kfc_'; export const USER_ID_PREFIX = 'kfc_device_id_'; @@ -22,7 +27,9 @@ export const RETRY_BACKOFFS_MS = [1_000, 4_000, 16_000] as const; export interface AsyncTransportOptions { readonly homeDir: string; readonly deviceId: string; - readonly endpoint?: string; + /** Static endpoint, or a resolver invoked per flush so an in-process region + switch (login/logout) takes effect without rebuilding the transport. */ + readonly endpoint?: string | (() => string); readonly getAccessToken?: () => string | null | Promise; readonly fetchImpl?: typeof fetch; readonly retryBackoffsMs?: readonly number[]; @@ -41,7 +48,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; export class AsyncTransport { private readonly homeDir: string; private readonly deviceId: string; - private readonly endpoint: string; + private readonly endpoint: string | (() => string); private readonly getAccessToken: (() => string | null | Promise) | null; private readonly fetchImpl: typeof fetch; private readonly retryBackoffsMs: readonly number[]; @@ -193,9 +200,10 @@ export class AsyncTransport { signal?: AbortSignal, ): Promise { try { + const endpoint = typeof this.endpoint === 'function' ? this.endpoint() : this.endpoint; return await fetchWithTimeout( this.fetchImpl, - this.endpoint, + endpoint, { method: 'POST', headers: { ...headers }, diff --git a/packages/telemetry/test/telemetry.test.ts b/packages/telemetry/test/telemetry.test.ts index 63d09b1b4b0..cd548037bc1 100644 --- a/packages/telemetry/test/telemetry.test.ts +++ b/packages/telemetry/test/telemetry.test.ts @@ -551,6 +551,27 @@ describe('AsyncTransport', () => { }); }); + it('resolves a function endpoint per send, so an in-process switch needs no rebuild', async () => { + const fetchImpl = vi.fn(async (_url: string | URL, _init?: RequestInit) => + Promise.resolve(new Response('', { status: 200 })), + ); + let endpoint = 'https://cn.test/events'; + const transport = new AsyncTransport({ + homeDir: await tempHome(), + deviceId: 'dev', + endpoint: () => endpoint, + fetchImpl: fetchImpl as unknown as typeof fetch, + retryBackoffsMs: [], + }); + + await transport.send([sampleEvent()]); + expect(fetchImpl.mock.calls[0]?.[0]).toBe('https://cn.test/events'); + + endpoint = 'https://global.test/events'; + await transport.send([sampleEvent()]); + expect(fetchImpl.mock.calls[1]?.[0]).toBe('https://global.test/events'); + }); + it('retries anonymously on 401 with a token', async () => { const fetchImpl = vi .fn() @@ -851,6 +872,24 @@ describe('telemetry bootstrap', () => { }); }); + it('forwards a caller-provided endpoint to the transport', async () => { + const fetchImpl = vi.fn(async (_input: unknown) => new Response('', { status: 200 })); + vi.stubGlobal('fetch', fetchImpl); + + initializeTelemetry({ + homeDir: await tempHome(), + deviceId: 'dev', + appName: 'kimi-code-cli', + version: '1.2.3', + endpoint: 'https://mock.test/events', + }); + track('custom_endpoint'); + await shutdownTelemetry(); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]?.[0]).toBe('https://mock.test/events'); + }); + it('flushes the singleton synchronously to disk fallback', async () => { const homeDir = await tempHome(); initializeTelemetry({