From d8bba2c5608b72a36cbe0296908936d16cf26780 Mon Sep 17 00:00:00 2001 From: tt-a1i <53142663+tt-a1i@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:38:56 +0800 Subject: [PATCH 1/2] fix(tui): update pi-tui for kitty backspace --- .changeset/fix-kitty-backspace.md | 5 +++ apps/kimi-code/package.json | 2 +- apps/kimi-code/scripts/native/assets.mjs | 4 +- .../kimi-code/scripts/native/check-bundle.mjs | 2 - .../scripts/native/entitlements.plist | 2 +- apps/kimi-code/scripts/native/native-deps.mjs | 24 ----------- apps/kimi-code/src/main.ts | 2 - apps/kimi-code/src/native/module-hook.ts | 40 ------------------- apps/kimi-code/src/native/smoke.ts | 2 +- .../test/scripts/native/native-deps.test.ts | 18 ++------- pnpm-lock.yaml | 36 ++++++----------- 11 files changed, 26 insertions(+), 111 deletions(-) create mode 100644 .changeset/fix-kitty-backspace.md delete mode 100644 apps/kimi-code/src/native/module-hook.ts diff --git a/.changeset/fix-kitty-backspace.md b/.changeset/fix-kitty-backspace.md new file mode 100644 index 000000000..3e14cb3eb --- /dev/null +++ b/.changeset/fix-kitty-backspace.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Backspace handling in Kitty terminals. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 2c7289645..4a484b5fd 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -67,7 +67,7 @@ "postinstall": "node scripts/postinstall.mjs" }, "dependencies": { - "@earendil-works/pi-tui": "^0.74.0", + "@earendil-works/pi-tui": "^0.79.3", "@mariozechner/clipboard": "^0.3.2", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", diff --git a/apps/kimi-code/scripts/native/assets.mjs b/apps/kimi-code/scripts/native/assets.mjs index 7b0560b69..75fee7b43 100644 --- a/apps/kimi-code/scripts/native/assets.mjs +++ b/apps/kimi-code/scripts/native/assets.mjs @@ -17,9 +17,7 @@ export const NATIVE_TARGETS = Object.freeze( SUPPORTED_TARGETS.map((t) => { const deps = resolveTargetDeps(t); const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName; - const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0]; - const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null; - return [t, { clipboardPackage: clipboardTarget, koffiTriplet }]; + return [t, { clipboardPackage: clipboardTarget }]; }), ), ); diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index a0479f209..2c69ac9e1 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -21,12 +21,10 @@ const optionalRuntimeRequires = new Set([ 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(['koffi']); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; if (optionalRuntimeRequires.has(specifier)) return true; - if (handledNativeRuntimeRequires.has(specifier)) return true; return false; } diff --git a/apps/kimi-code/scripts/native/entitlements.plist b/apps/kimi-code/scripts/native/entitlements.plist index d7e0e9d6b..508d365d6 100644 --- a/apps/kimi-code/scripts/native/entitlements.plist +++ b/apps/kimi-code/scripts/native/entitlements.plist @@ -11,7 +11,7 @@ com.apple.security.cs.allow-unsigned-executable-memory - com.apple.security.cs.disable-library-validation diff --git a/apps/kimi-code/scripts/native/native-deps.mjs b/apps/kimi-code/scripts/native/native-deps.mjs index f195a3cb1..78acbff17 100644 --- a/apps/kimi-code/scripts/native/native-deps.mjs +++ b/apps/kimi-code/scripts/native/native-deps.mjs @@ -27,15 +27,6 @@ const clipboardSubpackageByTarget = Object.freeze({ 'win32-x64': '@mariozechner/clipboard-win32-x64-msvc', }); -const koffiTripletByTarget = Object.freeze({ - 'darwin-arm64': 'darwin_arm64', - 'darwin-x64': 'darwin_x64', - 'linux-arm64': 'linux_arm64', - 'linux-x64': 'linux_x64', - 'win32-arm64': 'win32_arm64', - 'win32-x64': 'win32_x64', -}); - export function isSupportedTarget(target) { return SUPPORTED_TARGETS.includes(target); } @@ -68,21 +59,6 @@ export const nativeDeps = Object.freeze([ collect: 'native-files', parent: 'clipboard-host', }, - { - id: 'pi-tui', - name: () => '@earendil-works/pi-tui', - // pi-tui is bundled into main.cjs at build time — we don't collect it as - // a native dep, only register it so koffi can declare it as parent. - collect: 'virtual', - parent: null, - }, - { - id: 'koffi', - name: () => 'koffi', - collect: 'js-and-native-file', - parent: 'pi-tui', - nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`], - }, ]); /** diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index e94472590..c638c98c5 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -35,7 +35,6 @@ import { runUpdatePreflight } from './cli/update/preflight'; import { createKimiCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; -import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; export async function handleMainCommand(opts: CLIOptions, version: string): Promise { @@ -122,7 +121,6 @@ export function main(): void { // before any client is constructed. No-op when no proxy variable is set; an // invalid proxy URL is reported and ignored rather than aborting startup. installGlobalProxyDispatcher(); - installNativeModuleHook(); if (runNativeAssetSmokeIfRequested()) return; // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. diff --git a/apps/kimi-code/src/native/module-hook.ts b/apps/kimi-code/src/native/module-hook.ts deleted file mode 100644 index bbef5d4cb..000000000 --- a/apps/kimi-code/src/native/module-hook.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createRequire } from 'node:module'; - -import { loadNativePackage } from './native-require'; - -type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown; - -interface ModuleWithLoad { - _load?: ModuleLoad; -} - -const nodeRequire = createRequire(import.meta.url); -let installed = false; -let loadingNativePackage = false; - -export function installNativeModuleHook(): void { - if (installed) return; - installed = true; - - const moduleBuiltin = nodeRequire('node:module') as ModuleWithLoad; - const originalLoad = moduleBuiltin._load; - if (originalLoad === undefined) return; - - moduleBuiltin._load = function loadWithNativeAssets( - this: unknown, - request: string, - parent: unknown, - isMain: boolean, - ): unknown { - if (request === 'koffi' && !loadingNativePackage) { - loadingNativePackage = true; - try { - const pkg = loadNativePackage('koffi'); - if (pkg !== null) return pkg; - } finally { - loadingNativePackage = false; - } - } - return originalLoad.call(this, request, parent, isMain); - }; -} diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index 56d39253f..564a4cf64 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -1,6 +1,6 @@ import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; -const smokePackages = ['@mariozechner/clipboard', 'koffi']; +const smokePackages = ['@mariozechner/clipboard']; export function runNativeAssetSmokeIfRequested(): boolean { if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; diff --git a/apps/kimi-code/test/scripts/native/native-deps.test.ts b/apps/kimi-code/test/scripts/native/native-deps.test.ts index c96b642e6..d879a6ff8 100644 --- a/apps/kimi-code/test/scripts/native/native-deps.test.ts +++ b/apps/kimi-code/test/scripts/native/native-deps.test.ts @@ -41,7 +41,6 @@ describe('resolveTargetDeps', () => { const names = deps.map((d) => d.resolvedName); expect(names).toContain('@mariozechner/clipboard'); expect(names).toContain('@mariozechner/clipboard-darwin-arm64'); - expect(names).toContain('koffi'); }); it('picks the right clipboard subpackage per target', () => { @@ -56,15 +55,6 @@ describe('resolveTargetDeps', () => { ).toContain('@mariozechner/clipboard-win32-arm64-msvc'); }); - it('encodes koffi native file path with target triplet', () => { - const linuxKoffi = resolveTargetDeps('linux-arm64').find((d) => d.resolvedName === 'koffi'); - expect(linuxKoffi?.nativeFileRelatives).toEqual(['build/koffi/linux_arm64/koffi.node']); - const macKoffi = resolveTargetDeps('darwin-x64').find((d) => d.resolvedName === 'koffi'); - expect(macKoffi?.nativeFileRelatives).toEqual(['build/koffi/darwin_x64/koffi.node']); - const winArmKoffi = resolveTargetDeps('win32-arm64').find((d) => d.resolvedName === 'koffi'); - expect(winArmKoffi?.nativeFileRelatives).toEqual(['build/koffi/win32_arm64/koffi.node']); - }); - it('throws on unsupported target', () => { expect(() => resolveTargetDeps('linux-x64-musl')).toThrow(/unsupported/i); }); @@ -82,9 +72,9 @@ describe('nativeDeps registry shape', () => { expect(target?.parent).toBe('clipboard-host'); }); - it('has koffi (collect=js-and-native-file, parent=pi-tui)', () => { - const koffi = nativeDeps.find((d) => d.id === 'koffi'); - expect(koffi?.collect).toBe('js-and-native-file'); - expect(koffi?.parent).toBe('pi-tui'); + it('only collects installed native packages', () => { + expect(nativeDeps.map((d) => d.id).toSorted()).toEqual( + ['clipboard-host', 'clipboard-target'].toSorted(), + ); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13fa0c2a0..c202c3f7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,8 +67,8 @@ importers: apps/kimi-code: dependencies: '@earendil-works/pi-tui': - specifier: ^0.74.0 - version: 0.74.0 + specifier: ^0.79.3 + version: 0.79.3 '@mariozechner/clipboard': specifier: ^0.3.2 version: 0.3.2 @@ -774,9 +774,9 @@ packages: search-insights: optional: true - '@earendil-works/pi-tui@0.74.0': - resolution: {integrity: sha512-1aIfXZp7D/z+1VlZX8BZcs6pgO8rjmil7kwyhctNDsWvce3Yfl8GVgu4eq+I0Mjhr8Cj+ipBiv9CLIzdoyCOIQ==} - engines: {node: '>=20.0.0'} + '@earendil-works/pi-tui@0.79.3': + resolution: {integrity: sha512-cpmkEM1aEuGUx6YZM36VlzpulwLzqD5T2cUEkGHndDTNGEbnn5sj/9SYm+QBfKjvZsWoHfZuFBnu4+hh96/FbA==} + engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -2379,9 +2379,6 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/mime-types@2.1.4': - resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -3497,6 +3494,10 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3902,9 +3903,6 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - koffi@2.16.0: - resolution: {integrity: sha512-h/2NJueOKWd0YYycEOWDspomizgNfuOKf/V7ZE2fytvuRtHoY9Tb+y4x6GJ6pFqaVndWn9dLK+sCI14eWtu5rA==} - layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -6055,15 +6053,10 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-tui@0.74.0': + '@earendil-works/pi-tui@0.79.3': dependencies: - '@types/mime-types': 2.1.4 - chalk: 5.6.2 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 marked: 15.0.12 - mime-types: 3.0.2 - optionalDependencies: - koffi: 2.16.0 '@emnapi/core@1.10.0': dependencies: @@ -7231,8 +7224,6 @@ snapshots: '@types/mdurl@2.0.0': {} - '@types/mime-types@2.1.4': {} - '@types/ms@2.1.0': {} '@types/node@12.20.55': {} @@ -8504,6 +8495,8 @@ snapshots: get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8912,9 +8905,6 @@ snapshots: kind-of@6.0.3: {} - koffi@2.16.0: - optional: true - layout-base@1.0.2: {} layout-base@2.0.1: {} From d45d7b06c09451e1dd4faa07bb935244b863eb2f Mon Sep 17 00:00:00 2001 From: tt-a1i <53142663+tt-a1i@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:11:23 +0800 Subject: [PATCH 2/2] fix(native): package pi-tui helpers --- .github/actions/macos-notarize/action.yml | 31 +++++++++--- apps/kimi-code/scripts/native/03-inject.mjs | 16 ++++++- apps/kimi-code/scripts/native/04-sign.mjs | 30 +++++++++++- apps/kimi-code/scripts/native/05-verify.mjs | 13 ++++- apps/kimi-code/scripts/native/assets.mjs | 38 ++++++++++++++- .../scripts/native/entitlements.plist | 4 +- apps/kimi-code/scripts/native/native-deps.mjs | 26 ++++++++++ apps/kimi-code/scripts/native/package.mjs | 47 +++++++++++++++++-- apps/kimi-code/src/native/smoke.ts | 13 +++++ .../test/scripts/native/native-deps.test.ts | 31 ++++++++++-- .../scripts/native/release-artifacts.test.ts | 20 +++++++- .../test/scripts/native/sign-args.test.ts | 37 ++++++++++++++- flake.nix | 4 ++ 13 files changed, 287 insertions(+), 23 deletions(-) diff --git a/.github/actions/macos-notarize/action.yml b/.github/actions/macos-notarize/action.yml index 9a919d810..1a8dc04f7 100644 --- a/.github/actions/macos-notarize/action.yml +++ b/.github/actions/macos-notarize/action.yml @@ -1,8 +1,8 @@ name: macOS notarize description: | - Submit a signed binary to Apple notary service via notarytool, wait for - result, and run Gatekeeper online check (spctl). Fails the job with the - notarytool log on rejection. + Submit a signed binary and sibling native helpers to Apple notary service via + notarytool, wait for result, and run Gatekeeper online check (spctl). Fails + the job with the notarytool log on rejection. inputs: binary-path: @@ -47,10 +47,18 @@ runs: # 2. Pack with ditto (--norsrc avoids ._AppleDouble files) binary_name=$(basename "$BINARY_PATH") + binary_dir=$(dirname "$BINARY_PATH") + package_root="${RUNNER_TEMP}/${binary_name}-notarize-root" zip_path="${RUNNER_TEMP}/${binary_name}.notarize.zip" - ditto -c -k --norsrc --keepParent "$BINARY_PATH" "$zip_path" + rm -rf "$package_root" + mkdir -p "$package_root" + cp "$BINARY_PATH" "$package_root/$binary_name" + if [[ -d "$binary_dir/native" ]]; then + cp -R "$binary_dir/native" "$package_root/native" + fi + ditto -c -k --norsrc "$package_root" "$zip_path" - echo "==> Submitting $binary_name for notarization..." + echo "==> Submitting $binary_name and sibling native helpers for notarization..." # 3. Submit and capture log log_path="${RUNNER_TEMP}/notarize-${binary_name}.log" @@ -74,14 +82,14 @@ runs: --key-id "$APPLE_NOTARIZATION_KEY_ID" \ --issuer "$APPLE_NOTARIZATION_ISSUER_ID" || true fi - rm -f "$key_path" "$zip_path" + rm -rf "$key_path" "$zip_path" "$package_root" exit 1 fi echo "==> Notarization accepted" # 5. Cleanup - rm -f "$key_path" "$zip_path" + rm -rf "$key_path" "$zip_path" "$package_root" - name: Verify with Gatekeeper online check shell: bash @@ -91,5 +99,14 @@ runs: set -euo pipefail echo "==> codesign -dv $BINARY_PATH" codesign -dv --verbose=2 "$BINARY_PATH" + binary_dir=$(dirname "$BINARY_PATH") + if [[ -d "$binary_dir/native" ]]; then + while IFS= read -r -d '' helper; do + echo "==> codesign --verify $helper" + codesign --verify --strict --verbose=2 "$helper" + echo "==> codesign -dv $helper" + codesign -dv --verbose=2 "$helper" + done < <(find "$binary_dir/native" -type f -name '*.node' -print0) + fi echo "==> spctl -a -vvv -t install $BINARY_PATH" spctl -a -vvv -t install "$BINARY_PATH" diff --git a/apps/kimi-code/scripts/native/03-inject.mjs b/apps/kimi-code/scripts/native/03-inject.mjs index 24cd0ad32..9a5441047 100644 --- a/apps/kimi-code/scripts/native/03-inject.mjs +++ b/apps/kimi-code/scripts/native/03-inject.mjs @@ -1,6 +1,7 @@ import { copyFile, mkdir, stat } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { resolveExecutableNativeFiles } from './assets.mjs'; import { fail, run, tryRun } from './exec.mjs'; import { appRoot, @@ -52,12 +53,25 @@ async function injectSeaBlob(target) { await run(postjectPath(), args); } +async function copyExecutableNativeFiles(target) { + const files = resolveExecutableNativeFiles({ appRoot, target }); + for (const file of files) { + const destination = resolve(nativeBinDir(target), file.relativePath); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(file.sourcePath, destination); + } + if (files.length > 0) { + console.log(`Copied ${files.length} native helper file(s) for ${target}`); + } +} + export async function runInjectStep() { const target = targetTriple(); await ensureBlobExists(); await copyNodeExecutable(target); await removeSignatureIfNeeded(target); await injectSeaBlob(target); + await copyExecutableNativeFiles(target); } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/apps/kimi-code/scripts/native/04-sign.mjs b/apps/kimi-code/scripts/native/04-sign.mjs index 2930b5e63..f3ee41c5d 100644 --- a/apps/kimi-code/scripts/native/04-sign.mjs +++ b/apps/kimi-code/scripts/native/04-sign.mjs @@ -1,10 +1,11 @@ import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; +import { stat, writeFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; import { run } from './exec.mjs'; -import { nativeBinPath, targetTriple } from './paths.mjs'; +import { resolveExecutableFileRelatives } from './native-deps.mjs'; +import { nativeBinDir, nativeBinPath, targetTriple } from './paths.mjs'; const ENTITLEMENTS_PATH = resolve(import.meta.dirname, 'entitlements.plist'); @@ -28,6 +29,18 @@ export function buildCodesignArgs({ identity, executable, entitlementsPath, keyc return args; } +export function buildCodesignNativeHelperArgs({ identity, file, keychainPath }) { + if (identity === '-') { + return ['--sign', '-', file]; + } + const args = ['--sign', identity, '--options', 'runtime', '--timestamp']; + if (keychainPath) { + args.push('--keychain', keychainPath); + } + args.push('--force', file); + return args; +} + async function sha256(path) { return await new Promise((resolveHash, reject) => { const hash = createHash('sha256'); @@ -48,6 +61,19 @@ export async function runSignStep({ identity = '-', keychainPath = null } = {}) const executable = nativeBinPath(target); if (process.platform === 'darwin') { + for (const relativePath of resolveExecutableFileRelatives(target)) { + const file = resolve(nativeBinDir(target), relativePath); + await stat(file); + await run( + 'codesign', + buildCodesignNativeHelperArgs({ + identity, + file, + keychainPath, + }), + ); + } + const args = buildCodesignArgs({ identity, executable, diff --git a/apps/kimi-code/scripts/native/05-verify.mjs b/apps/kimi-code/scripts/native/05-verify.mjs index 5c47da6bc..77df74812 100644 --- a/apps/kimi-code/scripts/native/05-verify.mjs +++ b/apps/kimi-code/scripts/native/05-verify.mjs @@ -1,5 +1,8 @@ +import { resolve } from 'node:path'; + import { run } from './exec.mjs'; -import { nativeBinPath, targetTriple } from './paths.mjs'; +import { resolveExecutableFileRelatives } from './native-deps.mjs'; +import { nativeBinDir, nativeBinPath, targetTriple } from './paths.mjs'; export async function runVerifyStep({ requireGatekeeper = false } = {}) { if (process.platform !== 'darwin') { @@ -13,6 +16,14 @@ export async function runVerifyStep({ requireGatekeeper = false } = {}) { console.log(`==> codesign -dv ${executable}`); await run('codesign', ['-dv', '--verbose=2', executable]); + for (const relativePath of resolveExecutableFileRelatives(target)) { + const file = resolve(nativeBinDir(target), relativePath); + console.log(`==> codesign --verify ${file}`); + await run('codesign', ['--verify', '--strict', '--verbose=2', file]); + console.log(`==> codesign -dv ${file}`); + await run('codesign', ['-dv', '--verbose=2', file]); + } + if (requireGatekeeper) { // spctl in 'install' mode simulates the Gatekeeper online check — only a // fully notarized binary passes. Ad-hoc signed binaries fail, so this is diff --git a/apps/kimi-code/scripts/native/assets.mjs b/apps/kimi-code/scripts/native/assets.mjs index 75fee7b43..7454a0939 100644 --- a/apps/kimi-code/scripts/native/assets.mjs +++ b/apps/kimi-code/scripts/native/assets.mjs @@ -6,7 +6,7 @@ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path import { pathToFileURL } from 'node:url'; import { NATIVE_ASSET_MANIFEST_VERSION, buildManifestKey } from './manifest.mjs'; -import { resolveTargetDeps, SUPPORTED_TARGETS } from './native-deps.mjs'; +import { nativeDeps, resolveTargetDeps, SUPPORTED_TARGETS } from './native-deps.mjs'; export { NATIVE_ASSET_MANIFEST_VERSION }; @@ -272,3 +272,39 @@ export async function collectNativeAssets({ appRoot, target }) { assets, }; } + +export function resolveExecutableNativeFiles({ appRoot, target }) { + const requireFromApp = createRequire(pathToFileURL(resolve(appRoot, 'package.json'))); + const files = []; + + for (const dep of nativeDeps) { + const executableFileRelatives = dep.executableFileRelatives?.(target) ?? []; + if (executableFileRelatives.length === 0) continue; + + const packageName = dep.name(target); + const parentName = dep.parent + ? nativeDeps.find((p) => p.id === dep.parent)?.name(target) ?? null + : null; + const packageRoot = resolvePackageRootGeneric( + requireFromApp, + packageName, + parentName, + appRoot, + target, + ); + + for (const relativePath of executableFileRelatives) { + const sourcePath = resolve(packageRoot, relativePath); + if (!existsSync(sourcePath)) { + fail(`Native package ${packageName} does not contain ${relativePath} at ${packageRoot}`); + } + files.push({ + packageName, + relativePath: toPosixPath(relativePath), + sourcePath, + }); + } + } + + return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} diff --git a/apps/kimi-code/scripts/native/entitlements.plist b/apps/kimi-code/scripts/native/entitlements.plist index 508d365d6..c6a4a68fe 100644 --- a/apps/kimi-code/scripts/native/entitlements.plist +++ b/apps/kimi-code/scripts/native/entitlements.plist @@ -11,8 +11,8 @@ com.apple.security.cs.allow-unsigned-executable-memory - com.apple.security.cs.disable-library-validation diff --git a/apps/kimi-code/scripts/native/native-deps.mjs b/apps/kimi-code/scripts/native/native-deps.mjs index 78acbff17..dbe627f4c 100644 --- a/apps/kimi-code/scripts/native/native-deps.mjs +++ b/apps/kimi-code/scripts/native/native-deps.mjs @@ -27,6 +27,13 @@ const clipboardSubpackageByTarget = Object.freeze({ 'win32-x64': '@mariozechner/clipboard-win32-x64-msvc', }); +const piTuiExecutableFilesByTarget = Object.freeze({ + 'darwin-arm64': ['native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node'], + 'darwin-x64': ['native/darwin/prebuilds/darwin-x64/darwin-modifiers.node'], + 'win32-arm64': ['native/win32/prebuilds/win32-arm64/win32-console-mode.node'], + 'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'], +}); + export function isSupportedTarget(target) { return SUPPORTED_TARGETS.includes(target); } @@ -43,6 +50,9 @@ export function isSupportedTarget(target) { * @property {(target: string) => string[]} [nativeFileRelatives] * — explicit list of .node files relative to package root * (used by 'js-and-native-file'; native-files mode auto-scans *.node) + * @property {(target: string) => string[]} [executableFileRelatives] + * — files copied next to the native executable, preserving the + * package-relative path */ /** @type {readonly NativeDepDescriptor[]} */ @@ -59,8 +69,24 @@ export const nativeDeps = Object.freeze([ collect: 'native-files', parent: 'clipboard-host', }, + { + id: 'pi-tui', + name: () => '@earendil-works/pi-tui', + // pi-tui is bundled into main.cjs. Its native helpers are loaded from + // native/... beside the executable instead of from the native asset cache. + collect: 'virtual', + parent: null, + executableFileRelatives: (target) => piTuiExecutableFilesByTarget[target] ?? [], + }, ]); +export function resolveExecutableFileRelatives(target) { + if (!isSupportedTarget(target)) { + throw new Error(`Unsupported native executable target: ${target}`); + } + return nativeDeps.flatMap((d) => d.executableFileRelatives?.(target) ?? []); +} + /** * Resolve which deps need collecting for a given build target, with concrete names. */ diff --git a/apps/kimi-code/scripts/native/package.mjs b/apps/kimi-code/scripts/native/package.mjs index e146af0ab..6cc10f946 100644 --- a/apps/kimi-code/scripts/native/package.mjs +++ b/apps/kimi-code/scripts/native/package.mjs @@ -1,12 +1,18 @@ import { createHash } from 'node:crypto'; import { createReadStream, createWriteStream } from 'node:fs'; -import { mkdir, stat, writeFile } from 'node:fs/promises'; -import { basename, resolve } from 'node:path'; +import { mkdir, readdir, stat, writeFile } from 'node:fs/promises'; +import { basename, relative, resolve } from 'node:path'; import { pipeline } from 'node:stream/promises'; import { ZipFile } from 'yazl'; -import { executableName, nativeArtifactsDir, nativeBinPath, targetTriple } from './paths.mjs'; +import { + executableName, + nativeArtifactsDir, + nativeBinDir, + nativeBinPath, + targetTriple, +} from './paths.mjs'; const target = targetTriple(); const execName = executableName(); @@ -33,6 +39,38 @@ async function sha256(path) { }); } +function toPosixPath(path) { + return path.split('\\').join('/'); +} + +async function listFiles(root) { + const files = []; + + async function walk(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw error; + } + + for (const entry of entries) { + const path = resolve(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (entry.isFile()) { + files.push(path); + } + } + } + + await walk(root); + return files.sort((a, b) => a.localeCompare(b)); +} + try { await stat(sourceBinary); } catch { @@ -43,6 +81,9 @@ await mkdir(artifactsDir, { recursive: true }); const zip = new ZipFile(); zip.addFile(sourceBinary, execName, { mode: 0o100755 }); +for (const file of await listFiles(resolve(nativeBinDir(target), 'native'))) { + zip.addFile(file, toPosixPath(relative(nativeBinDir(target), file))); +} zip.end(); await pipeline(zip.outputStream, createWriteStream(artifactPath)); diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index 564a4cf64..74fba04eb 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -1,3 +1,7 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { resolveExecutableFileRelatives } from '../../scripts/native/native-deps.mjs'; import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; const smokePackages = ['@mariozechner/clipboard']; @@ -16,6 +20,15 @@ export function runNativeAssetSmokeIfRequested(): boolean { throw new Error(`Native package is not available: ${packageName}`); } } + const executableNativeFiles = resolveExecutableFileRelatives( + manifest.target, + ) as readonly string[]; + for (const file of executableNativeFiles) { + const path = join(dirname(process.execPath), file); + if (!existsSync(path)) { + throw new Error(`Native executable helper is not available: ${file}`); + } + } process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`); process.exit(0); } catch (error) { diff --git a/apps/kimi-code/test/scripts/native/native-deps.test.ts b/apps/kimi-code/test/scripts/native/native-deps.test.ts index d879a6ff8..7622f44c4 100644 --- a/apps/kimi-code/test/scripts/native/native-deps.test.ts +++ b/apps/kimi-code/test/scripts/native/native-deps.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'; import { nativeDeps, + resolveExecutableFileRelatives, resolveTargetDeps, isSupportedTarget, SUPPORTED_TARGETS, } from '../../../scripts/native/native-deps.mjs'; +import { resolveExecutableNativeFiles } from '../../../scripts/native/assets.mjs'; +import { appRoot } from '../../../scripts/native/paths.mjs'; describe('SUPPORTED_TARGETS', () => { it('contains the six published targets', () => { @@ -41,6 +44,7 @@ describe('resolveTargetDeps', () => { const names = deps.map((d) => d.resolvedName); expect(names).toContain('@mariozechner/clipboard'); expect(names).toContain('@mariozechner/clipboard-darwin-arm64'); + expect(names).not.toContain('@earendil-works/pi-tui'); }); it('picks the right clipboard subpackage per target', () => { @@ -72,9 +76,28 @@ describe('nativeDeps registry shape', () => { expect(target?.parent).toBe('clipboard-host'); }); - it('only collects installed native packages', () => { - expect(nativeDeps.map((d) => d.id).toSorted()).toEqual( - ['clipboard-host', 'clipboard-target'].toSorted(), - ); + it('keeps pi-tui as an executable helper dependency', () => { + const piTui = nativeDeps.find((d) => d.id === 'pi-tui'); + expect(piTui?.collect).toBe('virtual'); + expect(piTui?.executableFileRelatives?.('darwin-arm64')).toEqual([ + 'native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node', + ]); + expect(piTui?.executableFileRelatives?.('win32-x64')).toEqual([ + 'native/win32/prebuilds/win32-x64/win32-console-mode.node', + ]); + expect(piTui?.executableFileRelatives?.('linux-x64')).toEqual([]); + expect(resolveExecutableFileRelatives('darwin-x64')).toEqual([ + 'native/darwin/prebuilds/darwin-x64/darwin-modifiers.node', + ]); + }); + + it('resolves pi-tui helper files copied next to the executable', () => { + expect(resolveExecutableNativeFiles({ appRoot, target: 'darwin-arm64' })).toEqual([ + expect.objectContaining({ + packageName: '@earendil-works/pi-tui', + relativePath: 'native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node', + }), + ]); + expect(resolveExecutableNativeFiles({ appRoot, target: 'linux-x64' })).toEqual([]); }); }); diff --git a/apps/kimi-code/test/scripts/native/release-artifacts.test.ts b/apps/kimi-code/test/scripts/native/release-artifacts.test.ts index 791fd14e0..920c81b31 100644 --- a/apps/kimi-code/test/scripts/native/release-artifacts.test.ts +++ b/apps/kimi-code/test/scripts/native/release-artifacts.test.ts @@ -18,6 +18,12 @@ const artifactsDir = resolve(appRoot, 'dist-native/artifacts'); const target = 'test-zip-artifact'; const executableName = process.platform === 'win32' ? 'kimi.exe' : 'kimi'; const fakeBinary = resolve(appRoot, 'dist-native/bin', target, executableName); +const fakeNativeFile = resolve( + appRoot, + 'dist-native/bin', + target, + 'native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node', +); function sha256(bytes: Buffer | string): string { return createHash('sha256').update(bytes).digest('hex'); @@ -98,8 +104,11 @@ describe('native release artifacts', () => { it('packages the native binary as a zip archive and checksums the archive', async () => { const binaryContent = 'native binary payload\n'; + const nativeContent = 'native helper payload\n'; mkdirSync(resolve(appRoot, 'dist-native/bin', target), { recursive: true }); writeFileSync(fakeBinary, binaryContent, { mode: 0o755 }); + mkdirSync(resolve(fakeNativeFile, '..'), { recursive: true }); + writeFileSync(fakeNativeFile, nativeContent); await execFileAsync(process.execPath, [packageScript], { cwd: appRoot, @@ -110,8 +119,17 @@ describe('native release artifacts', () => { const checksumPath = `${archivePath}.sha256`; expect(existsSync(archivePath)).toBe(true); expect(existsSync(checksumPath)).toBe(true); - expect(zipEntryNames(archivePath)).toEqual([executableName]); + expect(zipEntryNames(archivePath)).toEqual([ + executableName, + 'native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node', + ]); expect(readZipEntry(archivePath, executableName).toString('utf-8')).toBe(binaryContent); + expect( + readZipEntry( + archivePath, + 'native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node', + ).toString('utf-8'), + ).toBe(nativeContent); expect(readFileSync(checksumPath, 'utf-8')).toBe( `${sha256(readFileSync(archivePath))} kimi-code-${target}.zip\n`, ); diff --git a/apps/kimi-code/test/scripts/native/sign-args.test.ts b/apps/kimi-code/test/scripts/native/sign-args.test.ts index 803f1820a..a7036b0a7 100644 --- a/apps/kimi-code/test/scripts/native/sign-args.test.ts +++ b/apps/kimi-code/test/scripts/native/sign-args.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { buildCodesignArgs } from '../../../scripts/native/04-sign.mjs'; +import { + buildCodesignArgs, + buildCodesignNativeHelperArgs, +} from '../../../scripts/native/04-sign.mjs'; describe('buildCodesignArgs', () => { it('returns ad-hoc args for identity "-"', () => { @@ -46,3 +49,35 @@ describe('buildCodesignArgs', () => { expect(args).not.toContain('--keychain'); }); }); + +describe('buildCodesignNativeHelperArgs', () => { + it('returns ad-hoc args for identity "-"', () => { + expect( + buildCodesignNativeHelperArgs({ + identity: '-', + file: '/path/native/helper.node', + keychainPath: null, + }), + ).toEqual(['--sign', '-', '/path/native/helper.node']); + }); + + it('returns hardened-runtime args without app entitlements for Developer ID identity', () => { + expect( + buildCodesignNativeHelperArgs({ + identity: 'Developer ID Application: Moonshot AI (ABCD1234)', + file: '/path/native/helper.node', + keychainPath: '/tmp/sign.keychain-db', + }), + ).toEqual([ + '--sign', + 'Developer ID Application: Moonshot AI (ABCD1234)', + '--options', + 'runtime', + '--timestamp', + '--keychain', + '/tmp/sign.keychain-db', + '--force', + '/path/native/helper.node', + ]); + }); +}); diff --git a/flake.nix b/flake.nix index 45ae4dcff..ce3c5f1a0 100644 --- a/flake.nix +++ b/flake.nix @@ -190,6 +190,10 @@ "apps/kimi-code/dist-native/bin/${nativeTarget}/kimi" \ "$out/bin/kimi" + if [ -d "apps/kimi-code/dist-native/bin/${nativeTarget}/native" ]; then + cp -R "apps/kimi-code/dist-native/bin/${nativeTarget}/native" "$out/bin/" + fi + runHook postInstall '';