-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(kimi-code): support automatic updates for native installations via staged swap #2994
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 965e94b
fix(kimi-code): fully clean staged artifacts on swap discard paths
liruifengv 6a21aed
fix(kimi-code): restore staged metadata on swap failure and sweep upd…
liruifengv 4d3fbaf
Merge remote-tracking branch 'origin/main' into feat/native-staged-au…
liruifengv e8284b6
fix(kimi-code): address codex review on lock contention and swap cras…
liruifengv 5ee963b
Merge remote-tracking branch 'origin/feat/native-staged-auto-update' …
liruifengv 993dafa
test(kimi-code): align swap test expectation with smoke-before-rename…
liruifengv bec250b
fix(kimi-code): stage the bare CDN binary instead of unzipping
liruifengv f04b4c0
fix(kimi-code): address second codex review round
liruifengv 844f558
feat(kimi-code): show download progress for native updates
liruifengv 3cb0cc0
fix(kimi-code): bound native update downloads with an idle timeout
liruifengv ef97ea3
fix(kimi-code): retry native updates blocked by an orphaned active re…
liruifengv 11a913b
fix: skip staged swap while another instance holds a fresh claim
liruifengv bc747e0
fix: keep the install lock while its holder process is alive
liruifengv e630b0b
fix: keep recovery artifacts on rollback failure and wait out same-ve…
liruifengv 07b52bd
fix: stamp the swap claim with a fresh mtime when claiming
liruifengv 4019f20
fix: stamp the claim before the rename so it is born fresh
liruifengv 40fbbcc
fix: chmod the staged download before publishing it at its final name
liruifengv d4dcccd
fix: publish the install lock atomically via hard link
liruifengv 21d32be
fix: serialize stale-lock takeover through a secondary lock
liruifengv 32f0e33
fix: verify lock ownership after publish and preserve freshly staged …
liruifengv 41b3c0a
fix: reject mismatched manifests, take over from dead holders, unique…
liruifengv c8dbe15
fix: restrict staging cleanup to updater-owned names and retry short …
liruifengv e5aa04b
fix: scope failure cleanup, recognize all semvers, reverify staged ch…
liruifengv 37bbf47
fix: validate versions before path derivation and honor the update op…
liruifengv 8d8f3b9
fix: restrict backup cleanup to updater-owned .bak names
liruifengv d199e98
fix: claim staged metadata before validating it and let manual upgrad…
liruifengv 849145e
fix: promote adopted stages to manual and preserve claim-referenced p…
liruifengv 53dfffb
fix: drop exe deletion from stale-claim cleanup
liruifengv bdc9567
fix: never delete the staged exe when discarding a claim
liruifengv f1af868
fix: only reap staging orphans old enough to be abandoned
liruifengv 763ebcf
fix: honor the persisted auto-update preference in the swap and drop …
liruifengv b934466
fix: gate on claimed metadata, stop shared-path deletes on failure, e…
liruifengv 8168d5e
fix: confirm the manual marker before reporting stage adoption
liruifengv 7d2d52f
fix(cli): verify the staged payload digest before adopting it as alre…
liruifengv bd90100
fix(cli): keep staged metadata until its replacement is ready
liruifengv 0de8d01
fix(cli): preserve the exe referenced by the current staged record du…
liruifengv e539748
chore(kimi-code): reword the native auto-update changeset
liruifengv c01433d
chore(kimi-code): trim the native auto-update changeset
liruifengv eb71053
fix(cli): support update locking on filesystems without hard links
liruifengv 9c4cf49
fix(cli): publish staged exes under unique names and recover orphaned…
liruifengv cf5e0c8
fix(cli): verify the staged payload digest in the lock-wait adoption …
liruifengv fbc61f6
fix(cli): serialize swap critical sections and preserve in-flight pub…
liruifengv cfc6717
fix(cli): keep backup cleanup inside the swap mutex
liruifengv f576043
Merge branch 'main' into feat/native-staged-auto-update
liruifengv File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| 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(() => {}); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
|
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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
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); | ||
|
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); | ||
|
liruifengv marked this conversation as resolved.
Outdated
|
||
|
|
||
| const existing = await readStagedNativeUpdate(options.exePath); | ||
| if (existing !== null && existing.version === options.version) { | ||
|
liruifengv marked this conversation as resolved.
|
||
| return { status: 'already-staged', staged: existing }; | ||
| } | ||
|
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); | ||
|
liruifengv marked this conversation as resolved.
Outdated
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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.