Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
552e119
feat(kimi-code): support automatic updates for native installations v…
liruifengv Aug 17, 2026
965e94b
fix(kimi-code): fully clean staged artifacts on swap discard paths
liruifengv Aug 17, 2026
6a21aed
fix(kimi-code): restore staged metadata on swap failure and sweep upd…
liruifengv Aug 17, 2026
4d3fbaf
Merge remote-tracking branch 'origin/main' into feat/native-staged-au…
liruifengv Aug 17, 2026
e8284b6
fix(kimi-code): address codex review on lock contention and swap cras…
liruifengv Aug 17, 2026
5ee963b
Merge remote-tracking branch 'origin/feat/native-staged-auto-update' …
liruifengv Aug 17, 2026
993dafa
test(kimi-code): align swap test expectation with smoke-before-rename…
liruifengv Aug 17, 2026
bec250b
fix(kimi-code): stage the bare CDN binary instead of unzipping
liruifengv Aug 17, 2026
f04b4c0
fix(kimi-code): address second codex review round
liruifengv Aug 17, 2026
844f558
feat(kimi-code): show download progress for native updates
liruifengv Aug 17, 2026
3cb0cc0
fix(kimi-code): bound native update downloads with an idle timeout
liruifengv Aug 17, 2026
ef97ea3
fix(kimi-code): retry native updates blocked by an orphaned active re…
liruifengv Aug 17, 2026
11a913b
fix: skip staged swap while another instance holds a fresh claim
liruifengv Aug 17, 2026
bc747e0
fix: keep the install lock while its holder process is alive
liruifengv Aug 17, 2026
e630b0b
fix: keep recovery artifacts on rollback failure and wait out same-ve…
liruifengv Aug 17, 2026
07b52bd
fix: stamp the swap claim with a fresh mtime when claiming
liruifengv Aug 17, 2026
4019f20
fix: stamp the claim before the rename so it is born fresh
liruifengv Aug 17, 2026
40fbbcc
fix: chmod the staged download before publishing it at its final name
liruifengv Aug 17, 2026
d4dcccd
fix: publish the install lock atomically via hard link
liruifengv Aug 17, 2026
21d32be
fix: serialize stale-lock takeover through a secondary lock
liruifengv Aug 17, 2026
32f0e33
fix: verify lock ownership after publish and preserve freshly staged …
liruifengv Aug 17, 2026
41b3c0a
fix: reject mismatched manifests, take over from dead holders, unique…
liruifengv Aug 18, 2026
c8dbe15
fix: restrict staging cleanup to updater-owned names and retry short …
liruifengv Aug 18, 2026
e5aa04b
fix: scope failure cleanup, recognize all semvers, reverify staged ch…
liruifengv Aug 18, 2026
37bbf47
fix: validate versions before path derivation and honor the update op…
liruifengv Aug 18, 2026
8d8f3b9
fix: restrict backup cleanup to updater-owned .bak names
liruifengv Aug 18, 2026
d199e98
fix: claim staged metadata before validating it and let manual upgrad…
liruifengv Aug 18, 2026
849145e
fix: promote adopted stages to manual and preserve claim-referenced p…
liruifengv Aug 18, 2026
53dfffb
fix: drop exe deletion from stale-claim cleanup
liruifengv Aug 18, 2026
bdc9567
fix: never delete the staged exe when discarding a claim
liruifengv Aug 18, 2026
f1af868
fix: only reap staging orphans old enough to be abandoned
liruifengv Aug 18, 2026
763ebcf
fix: honor the persisted auto-update preference in the swap and drop …
liruifengv Aug 18, 2026
b934466
fix: gate on claimed metadata, stop shared-path deletes on failure, e…
liruifengv Aug 18, 2026
8168d5e
fix: confirm the manual marker before reporting stage adoption
liruifengv Aug 18, 2026
7d2d52f
fix(cli): verify the staged payload digest before adopting it as alre…
liruifengv Aug 18, 2026
bd90100
fix(cli): keep staged metadata until its replacement is ready
liruifengv Aug 18, 2026
0de8d01
fix(cli): preserve the exe referenced by the current staged record du…
liruifengv Aug 18, 2026
e539748
chore(kimi-code): reword the native auto-update changeset
liruifengv Aug 18, 2026
c01433d
chore(kimi-code): trim the native auto-update changeset
liruifengv Aug 18, 2026
eb71053
fix(cli): support update locking on filesystems without hard links
liruifengv Aug 18, 2026
9c4cf49
fix(cli): publish staged exes under unique names and recover orphaned…
liruifengv Aug 18, 2026
cf5e0c8
fix(cli): verify the staged payload digest in the lock-wait adoption …
liruifengv Aug 18, 2026
fbc61f6
fix(cli): serialize swap critical sections and preserve in-flight pub…
liruifengv Aug 18, 2026
cfc6717
fix(cli): keep backup cleanup inside the swap mutex
liruifengv Aug 18, 2026
f576043
Merge branch 'main' into feat/native-staged-auto-update
liruifengv Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/native-staged-auto-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Support automatic updates for native (single-binary) installations, including Windows: new versions download in the background, verify against the release checksum, and swap in on the next launch. Run `kimi upgrade` to update now, or let the background updater handle it.
11 changes: 11 additions & 0 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ export type MainCommandHandler = (opts: CLIOptions) => void;
export type MigrateCommandHandler = () => void;
export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void;
export type UpgradeCommandHandler = () => void | Promise<void>;
export type UpdateDownloadHandler = (version: string) => void;

export function createProgram(
version: string,
onMain: MainCommandHandler,
onMigrate: MigrateCommandHandler,
onPluginNodeRunner: PluginNodeRunnerHandler = () => {},
onUpgrade: UpgradeCommandHandler = () => {},
onUpdateDownload: UpdateDownloadHandler = () => {},
): Command {
const program = new Command(CLI_COMMAND_NAME)
.description('The Starting Point for Next-Gen Agents')
Expand Down Expand Up @@ -138,6 +140,15 @@ export function createProgram(
onPluginNodeRunner(entry, args);
});

// Self-spawned worker for native staged updates (detached background
// download, or foreground from `kimi upgrade`). Hidden: not user-facing.
program
.command('__update_download', { hidden: true })
.argument('<version>')
.action((targetVersion: string) => {
onUpdateDownload(targetVersion);
});

program.argument('[args...]').action((args: string[]) => {
if (args.length > 0) {
program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`);
Expand Down
35 changes: 35 additions & 0 deletions apps/kimi-code/src/cli/sub/update-download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Hidden `kimi __update_download <version>` sub-command: the self-spawned
* worker behind native staged updates. Preflight spawns it detached in the
* background (and the `upgrade` command in the foreground); it downloads,
* verifies and stages the binary next to the running exe. The swap into
* place happens on the next startup (see `cli/update/native-swap.ts`).
*/

import { log } from '@moonshot-ai/kimi-code-sdk';

import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock';
import { stageNativeUpdate } from '#/cli/update/native-stage';
import { detectNativeInstall } from '#/cli/update/source';

export async function runUpdateDownloadCommand(version: string): Promise<number> {
if (!detectNativeInstall()) {
process.stderr.write('error: update download is only available in the native build\n');
return 1;
}
// Another instance is already staging this version (30-min stale window
// covers crashed downloaders): the outcome is equivalent, exit quietly.
const lock = await tryAcquireUpdateInstallLock({ version });
if (lock === null) return 0;
Comment thread
liruifengv marked this conversation as resolved.
Outdated
try {
await stageNativeUpdate({ version, exePath: process.execPath, stdout: process.stdout });
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`error: failed to download update ${version}: ${message}\n`);
log.warn('native update download failed', { version, error: message });
return 1;
} finally {
await lock.release().catch(() => {});
}
}
91 changes: 91 additions & 0 deletions apps/kimi-code/src/cli/update/native-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Per-release native artifact manifest (`/binaries/<version>/manifest.json`).
*
* Produced by `scripts/native/produce-manifest.mjs` and consumed by the
* install scripts; the staged updater reuses the same file so checksums and
* file names have a single source of truth.
*/

import { valid } from 'semver';
import { z } from 'zod';

import { KIMI_CODE_CDN_BINARIES_BASE } from '#/constant/app';

const MANIFEST_FETCH_TIMEOUT_MS = 10_000;

const PlatformEntrySchema = z.object({
filename: z.string().min(1),
checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }),
});

/**
* Deliberately NOT `.strict()` — unknown fields are ignored so future
* manifest additions never break shipped clients (same contract philosophy
* as the rollout manifest in `cdn.ts`).
*/
export const NativeReleaseManifestSchema = z.object({
version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
platforms: z.record(z.string(), PlatformEntrySchema),
});

export type NativeReleaseManifest = z.infer<typeof NativeReleaseManifestSchema>;
export type NativePlatformEntry = z.infer<typeof PlatformEntrySchema>;

export function nativeManifestUrl(version: string): string {
return `${KIMI_CODE_CDN_BINARIES_BASE}/${version}/manifest.json`;
}

export function nativeBinaryUrl(version: string, filename: string): string {
return `${KIMI_CODE_CDN_BINARIES_BASE}/${version}/${filename}`;
}

/**
* Fetch and parse the per-release manifest. **Throws** on any failure
* (network, non-2xx, malformed body, unknown version) — callers treat a
* throw as "staging failed" and record an install failure.
*
* `version` goes into the URL, so it must be a valid semver (it always is:
* upstream sources are the CDN `latest.json` / the `upgrade` command).
* `fetchImpl` is injectable for tests.
*/
export async function fetchNativeReleaseManifest(
version: string,
fetchImpl: typeof fetch = fetch,
): Promise<NativeReleaseManifest> {
if (valid(version) === null) {
throw new Error(`invalid semver for native manifest lookup: ${JSON.stringify(version)}`);
}
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, MANIFEST_FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetchImpl(nativeManifestUrl(version), { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
Comment thread
liruifengv marked this conversation as resolved.
Outdated
throw new Error(`native manifest for ${version} returned HTTP ${response.status}`);
}
return NativeReleaseManifestSchema.parse(JSON.parse(await response.text()));
}

/**
* Pick the entry for the running platform. The release pipeline keys
* platforms by `<node platform>-<node arch>` (win32-x64, darwin-arm64, …).
* **Throws** when the platform is missing — a silent skip would strand the
* update in a retry loop.
*/
export function selectPlatformEntry(
manifest: NativeReleaseManifest,
platform: NodeJS.Platform,
arch: string,
): NativePlatformEntry {
const target = `${platform}-${arch}`;
const entry = manifest.platforms[target];
if (entry === undefined) {
throw new Error(`platform ${target} not found in native manifest for ${manifest.version}`);
}
return entry;
}
203 changes: 203 additions & 0 deletions apps/kimi-code/src/cli/update/native-stage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* Native staged update: download + verify + unpack into `<exe dir>/.staging/`,
* without touching the running executable. The actual swap happens on the
* next startup (see `native-swap.ts`).
*
* Trust chain: the zip's sha256 comes from the per-release manifest (served
* over HTTPS), and the unpacked exe is re-checked against the zip entry's
* crc32, so a staged binary is byte-exact what the release pipeline produced.
*/

import { createHash } from 'node:crypto';
import { mkdir, open, readFile, rm, rmdir, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import { z } from 'zod';

import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths';
import { writeJsonFile } from '#/utils/persistence';

import {
fetchNativeReleaseManifest,
nativeBinaryUrl,
selectPlatformEntry,
} from './native-manifest';
import { unzipFirstFile } from './unzip';

const StagedNativeUpdateSchema = z
.object({
version: z.string().min(1),
target: z.string().min(1),
/** Base name of the staged executable inside `.staging/`. */
exeFileName: z.string().min(1),
/** sha256 of the zip the exe was unpacked from. */
sha256: z.string().regex(/^[a-f0-9]{64}$/),
exeSize: z.number().int().min(1),
stagedAt: z.string().min(1),
})
.strict();

export type StagedNativeUpdate = z.infer<typeof StagedNativeUpdateSchema>;

export function stagedExeFileName(version: string, platform: NodeJS.Platform): string {
return platform === 'win32' ? `kimi-${version}.exe` : `kimi-${version}`;
}

export function stagedExePath(exePath: string, staged: StagedNativeUpdate): string {
return join(getNativeStagingDir(exePath), staged.exeFileName);
}

/**
* Read the staged-update metadata, returning null when anything is off:
* missing/corrupt `staged.json`, or the staged exe went away / changed size.
* A null result makes callers behave as if no update was ever staged.
*/
export async function readStagedNativeUpdate(
exePath: string,
filePath: string = getNativeStagedStateFile(exePath),
): Promise<StagedNativeUpdate | null> {
let raw: string;
try {
raw = await readFile(filePath, 'utf-8');
} catch {
return null;
}
let json: unknown;
try {
json = JSON.parse(raw);
} catch {
return null;
}
const parsed = StagedNativeUpdateSchema.safeParse(json);
if (!parsed.success) return null;
const staged = parsed.data;
const info = await stat(stagedExePath(exePath, staged)).catch(() => null);
if (info === null || info.size !== staged.exeSize) return null;
return staged;
Comment thread
liruifengv marked this conversation as resolved.
}

/** Remove staged.json + the staged exe; used on downgrade-guard discards and swap failures. */
export async function removeStagedNativeUpdate(exePath: string): Promise<void> {
const stagingDir = getNativeStagingDir(exePath);
const staged = await readStagedNativeUpdate(exePath).catch(() => null);
if (staged !== null) {
await rm(stagedExePath(exePath, staged), { force: true }).catch(() => {});
}
await rm(getNativeStagedStateFile(exePath), { force: true }).catch(() => {});
// Best effort: drop the staging dir itself when empty (leftover `.part`
// files keep it around; the downloader truncates those on the next run).
await rmdir(stagingDir).catch(() => {});
}

export interface StageNativeUpdateOptions {
readonly version: string;
/** Path of the installed executable the staged binary will later replace. */
readonly exePath: string;
readonly platform?: NodeJS.Platform;
readonly arch?: string;
readonly fetchImpl?: typeof fetch;
readonly stdout?: { write(chunk: string): boolean };
}

export type StageNativeUpdateStatus = 'already-staged' | 'staged';

export interface StageNativeUpdateResult {
readonly status: StageNativeUpdateStatus;
readonly staged: StagedNativeUpdate;
}

async function downloadAndHash(
url: string,
partPath: string,
expectedSha256: string,
fetchImpl: typeof fetch,
): Promise<void> {
const response = await fetchImpl(url);
if (!response.ok || response.body === null) {
throw new Error(`native binary download returned HTTP ${response.status}`);
}
const hash = createHash('sha256');
const file = await open(partPath, 'w');
try {
for await (const chunk of response.body as AsyncIterable<Uint8Array>) {
hash.update(chunk);
await file.write(chunk);
Comment thread
liruifengv marked this conversation as resolved.
Outdated
}
} finally {
await file.close();
}
const digest = hash.digest('hex');
if (digest !== expectedSha256) {
throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`);
}
}

/**
* Download + verify + unpack `version` next to the running executable.
*
* Short-circuits with `already-staged` when the same version is ready on
* disk (repeat `kimi upgrade`, or foreground/background overlap). **Throws**
* on any failure after cleaning up this version's leftovers — the caller
* records an install failure.
*/
export async function stageNativeUpdate(
options: StageNativeUpdateOptions,
): Promise<StageNativeUpdateResult> {
const platform = options.platform ?? process.platform;
const arch = options.arch ?? process.arch;
const fetchImpl = options.fetchImpl ?? fetch;
const target = `${platform}-${arch}`;
const exeFileName = stagedExeFileName(options.version, platform);
Comment thread
liruifengv marked this conversation as resolved.
Outdated

const existing = await readStagedNativeUpdate(options.exePath);
if (existing !== null && existing.version === options.version) {
Comment thread
liruifengv marked this conversation as resolved.
return { status: 'already-staged', staged: existing };
}
Comment thread
liruifengv marked this conversation as resolved.

// A different version was staged earlier and never swapped (skipped
// rollout, user stayed offline, …): supersede it before writing ours.
if (existing !== null) {
await removeStagedNativeUpdate(options.exePath);
Comment thread
liruifengv marked this conversation as resolved.
Outdated
Comment thread
liruifengv marked this conversation as resolved.
Outdated
}
const stagingDir = getNativeStagingDir(options.exePath);
await mkdir(stagingDir, { recursive: true });

const staged: StagedNativeUpdate = {
version: options.version,
target,
exeFileName,
sha256: '',
exeSize: 0,
stagedAt: new Date().toISOString(),
};

try {
const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl);
const entry = selectPlatformEntry(manifest, platform, arch);
const partPath = join(stagingDir, `${entry.filename}.part`);
options.stdout?.write(`Downloading Kimi Code ${options.version} (${target})…\n`);
await downloadAndHash(
nativeBinaryUrl(options.version, entry.filename),
partPath,
entry.checksum,
fetchImpl,
);
options.stdout?.write('Verifying and unpacking…\n');
const { data } = unzipFirstFile(await readFile(partPath));
await writeFile(stagedExePath(options.exePath, staged), data, { mode: 0o755 });
await rm(partPath, { force: true });

staged.sha256 = entry.checksum;
staged.exeSize = data.length;
// Atomic write: staged.json only ever appears complete and consistent.
await writeJsonFile(
getNativeStagedStateFile(options.exePath),
StagedNativeUpdateSchema,
staged,
);
return { status: 'staged', staged };
} catch (error) {
await removeStagedNativeUpdate(options.exePath);
throw error;
}
}
Loading
Loading